diff --git a/README.md b/README.md index a2b8c53dc..063f3714c 100644 --- a/README.md +++ b/README.md @@ -61,22 +61,35 @@ To install InkyPi, follow these steps: ``` 3. Run the installation script with sudo: ```bash - sudo bash install/install.sh [-W ] + sudo bash install/install.sh [-W ] [-S] ``` - Option: + Options: * -W \ - specify this parameter **ONLY** if installing for a Waveshare display. After the -W option specify the Waveshare device model e.g. epd7in3f. + * -S (optional) - include this flag to enable servo control support. If not specified, servo support is disabled. - e.g. for Inky displays use: + Examples: + + For Inky displays without servo: ```bash sudo bash install/install.sh ``` - and for [Waveshare displays](#waveshare-display-support) use: + For Inky displays with servo support: + ```bash + sudo bash install/install.sh -S + ``` + + For [Waveshare displays](#waveshare-display-support) without servo: ```bash sudo bash install/install.sh -W epd7in3f ``` + For [Waveshare displays](#waveshare-display-support) with servo support: + ```bash + sudo bash install/install.sh -W epd7in3f -S + ``` + After the installation is complete, the script will prompt you to reboot your Raspberry Pi. Once rebooted, the display will update to show the InkyPi splash screen. diff --git a/docs/community.md b/docs/community.md index 27b134f4c..ca982f3da 100644 --- a/docs/community.md +++ b/docs/community.md @@ -9,5 +9,6 @@ A collection of 3D models, custom builds, and frames contributed by the communit | 7.3" Inky Impression | 3D Printable bezel for IKEA Rodalm Frame | [3D model](https://makerworld.com/en/models/1242875-inky-impression-bezel-for-ikea-rodalm#profileId-1263722) | [scotthsieh0503](https://github.com/scotthsieh0503) | | 7.3" Inky Impression (2025 Edition) | 3D Printable frame with stand | [3D model](https://makerworld.com/en/models/1482862-inky-impression-2025-edition-7-3-frame#profileId-1548643) | [JeremyCottontail](https://github.com/JeremyCottontail) | | 7.3" Inky Impression (2025 Edition) | 3D Printable bezel for IKEA Rodalm Frame | [3D model](https://makerworld.com/en/models/1457388-inky-impression-2025-pim773-ikea-rodlam-mount) | [Maxweeje](https://makerworld.com/en/@Maxweeje) +| 7.3" Inky Impression | 3D Printable Rotatable Stand | [3D model](https://www.thingiverse.com/thing:7290592) | [Andreas Wolf](https://github.com/and-who) | | 5.7" Inky Impression | Lego Stand | [Instructions](https://github.com/pikesley/impression-clock?tab=readme-ov-file#making-the-stand) | [pikesley](https://github.com/pikesley) | | 13.3" Inky Impression | 3D Printable Frame and Stand | [3D model](https://makerworld.com/en/models/1618040-inky-impression-pimoroni-13-3-frame) | [Phil Schaffer](https://makerworld.com/en/@pjschaffer) | diff --git a/install/config_base/device.json b/install/config_base/device.json index cb5343a00..a0559b04f 100644 --- a/install/config_base/device.json +++ b/install/config_base/device.json @@ -3,5 +3,6 @@ "orientation": "horizontal", "inverted_image": false, "scheduler_sleep_time": 60, - "startup": true + "startup": true, + "servo_enabled": false } \ No newline at end of file diff --git a/install/install.sh b/install/install.sh index d2c1074fc..5a34ed855 100644 --- a/install/install.sh +++ b/install/install.sh @@ -5,12 +5,14 @@ # Description: This script automates the installation of InkyPI and creation of # the InkyPI service. # -# Usage: ./install.sh [-W ] +# Usage: ./install.sh [-W ] [-S] # -W (optional) Install for a Waveshare device, # specifying the device model type, e.g. epd7in3e. # # If not specified then the Pimoroni Inky display # is assumed. +# -S (optional) Install servo control support and enable PWM overlay. +# If not specified, servo support is disabled. # ============================================================================= # Formatting stuff @@ -48,13 +50,23 @@ PIP_REQUIREMENTS_FILE="$SCRIPT_DIR/requirements.txt" WS_TYPE="" WS_REQUIREMENTS_FILE="$SCRIPT_DIR/ws-requirements.txt" -# Parse the arguments, looking for the -W option. +# +# Additional requirements for Servo support. +# +# empty means no servo support required, otherwise install servo support +SERVO_ENABLED="" +SERVO_REQUIREMENTS_FILE="$SCRIPT_DIR/servo-requirements.txt" + +# Parse the arguments, looking for the -W and -S options. parse_arguments() { - while getopts ":W:" opt; do + while getopts ":W:S" opt; do case $opt in W) WS_TYPE=$OPTARG echo "Optional parameter WS is set for Waveshare support. Screen type is: $WS_TYPE" ;; + S) SERVO_ENABLED="true" + echo "Optional parameter S is set for Servo control support." + ;; \?) echo "Invalid option: -$OPTARG." >&2 exit 1 ;; @@ -140,6 +152,28 @@ enable_interfaces(){ fi } +enable_pwm_overlay(){ + # Only enable PWM overlay if servo support is requested + if [[ -z "$SERVO_ENABLED" ]]; then + return + fi + + echo "Enabling PWM overlay for servo control" + local CONFIG_PRIMARY="/boot/firmware/config.txt" + local CONFIG_FALLBACK="/boot/config.txt" + + for cfg in "$CONFIG_PRIMARY" "$CONFIG_FALLBACK"; do + if [ -f "$cfg" ]; then + if ! grep -E -q '^[[:space:]]*dtoverlay=pwm-2chan' "$cfg"; then + sed -i '/^dtparam=spi=on/a dtoverlay=pwm-2chan' "$cfg" + echo_success "\tPWM overlay enabled (pwm-2chan) in $cfg" + else + echo_success "\tPWM overlay already enabled in $cfg" + fi + fi + done +} + show_loader() { local pid=$! local delay=0.1 @@ -192,6 +226,11 @@ install_debian_dependencies() { fi } +install_servo_dependencies() { + echo "Installing servo dependencies (libgpiod and tools)." + sudo apt-get install -y gpiod python3-libgpiod libgpiod-dev > /dev/null +} + setup_zramswap_service() { echo "Enabling and starting zramswap service." sudo apt-get install -y zram-tools > /dev/null @@ -219,6 +258,13 @@ create_venv(){ show_loader "\tInstalling additional Waveshare python dependencies. " fi + # do additional dependencies for Servo support. + if [[ -n "$SERVO_ENABLED" ]]; then + echo "Adding additional dependencies for servo control to the python virtual environment. " + $VENV_PATH/bin/python -m pip install -r $SERVO_REQUIREMENTS_FILE > servo_pip_install.log & + show_loader "\tInstalling additional Servo python dependencies. " + fi + } install_app_service() { @@ -254,12 +300,12 @@ install_config() { } # -# Update the device.json file with the supplied Waveshare parameter (if set). +# Update the device.json file with the supplied Waveshare and Servo parameters (if set). # update_config() { + local DEVICE_JSON="$CONFIG_DIR/device.json" + if [[ -n "$WS_TYPE" ]]; then - local DEVICE_JSON="$CONFIG_DIR/device.json" - if grep -q '"display_type":' "$DEVICE_JSON"; then # Update existing display_type value sed -i "s/\"display_type\": \".*\"/\"display_type\": \"$WS_TYPE\"/" "$DEVICE_JSON" @@ -273,8 +319,22 @@ update_config() { echo "}" >> "$DEVICE_JSON" # Add trailing } echo "Added display_type: $WS_TYPE" fi - else - echo "Config not updated as WS_TYPE flag is not set" + fi + + if [[ -n "$SERVO_ENABLED" ]]; then + if grep -q '"servo_enabled":' "$DEVICE_JSON"; then + # Update existing servo_enabled value + sed -i 's/"servo_enabled": false/"servo_enabled": true/' "$DEVICE_JSON" + echo "Updated servo_enabled to: true" + else + # Append servo_enabled safely, ensuring proper comma placement + if grep -q '}$' "$DEVICE_JSON"; then + sed -i '$s/}/,/' "$DEVICE_JSON" # Replace last } with a comma + fi + echo " \"servo_enabled\": true" >> "$DEVICE_JSON" + echo "}" >> "$DEVICE_JSON" # Add trailing } + echo "Added servo_enabled: true" + fi fi } @@ -364,7 +424,12 @@ if [[ -n "$WS_TYPE" ]]; then fetch_waveshare_driver fi enable_interfaces +enable_pwm_overlay install_debian_dependencies +# Only install servo dependencies if servo support is requested +if [[ -n "$SERVO_ENABLED" ]]; then + install_servo_dependencies +fi # check OS version for Bookworm to setup zramswap if [[ $(get_os_version) = "12" ]] ; then echo "OS version is Bookworm - setting up zramswap" @@ -378,8 +443,8 @@ install_cli create_venv install_executable install_config -# update the config file with additional WS if defined. -if [[ -n "$WS_TYPE" ]]; then +# update the config file with additional WS or Servo if defined. +if [[ -n "$WS_TYPE" ]] || [[ -n "$SERVO_ENABLED" ]]; then update_config fi install_app_service diff --git a/install/requirements.txt b/install/requirements.txt index c7ab09a23..613850f4c 100644 --- a/install/requirements.txt +++ b/install/requirements.txt @@ -15,4 +15,4 @@ psutil==7.0.0 cysystemd==2.0.1 waitress==3.0.2 feedparser==6.0.11 -astral>=3.1 +astral>=3.1 \ No newline at end of file diff --git a/install/servo-requirements.txt b/install/servo-requirements.txt new file mode 100644 index 000000000..a03728609 --- /dev/null +++ b/install/servo-requirements.txt @@ -0,0 +1,4 @@ +gpiozero==2.0.1 +gpiod>=2.0.0 +lgpio==0.2.2.0 +RPi.GPIO==0.7.1 \ No newline at end of file diff --git a/src/config.py b/src/config.py index 4761f4592..5fb7f28a6 100644 --- a/src/config.py +++ b/src/config.py @@ -67,14 +67,19 @@ def get_config(self, key=None, default={}): return self.config def get_plugins(self): - """Returns the list of plugin configurations, sorted by custom order if set.""" + """Returns the list of plugin configurations, sorted by custom order if set. + Disables servo_control plugin if servo_enabled is false in config.""" plugin_order = self.config.get('plugin_order', []) + servo_enabled = self.config.get('servo_enabled', False) + + # Filter out servo_control plugin if servo is not enabled + filtered_plugins = [p for p in self.plugins_list if not (p['id'] == 'servo_control' and not servo_enabled)] if not plugin_order: - return self.plugins_list + return filtered_plugins # Create a dict for quick lookup - plugins_dict = {p['id']: p for p in self.plugins_list} + plugins_dict = {p['id']: p for p in filtered_plugins} # Build ordered list ordered = [] diff --git a/src/config/device_dev.json b/src/config/device_dev.json index ad38b8b8b..3e052cc00 100644 --- a/src/config/device_dev.json +++ b/src/config/device_dev.json @@ -6,6 +6,7 @@ 480 ], "orientation": "horizontal", + "servo_enabled": true, "plugin_order": [ "calendar", "ai_image", @@ -45,5 +46,7 @@ "image_hash": null, "refresh_type": null, "plugin_id": null - } + }, + "inverted_image": false, + "current_servo_angle": 45 } \ No newline at end of file diff --git a/src/display/display_manager.py b/src/display/display_manager.py index 71d9459f3..5468bf19c 100644 --- a/src/display/display_manager.py +++ b/src/display/display_manager.py @@ -69,6 +69,11 @@ def display_image(self, image, image_settings=[]): if not hasattr(self, "display"): raise ValueError("No valid display instance initialized.") + + # If no Image provided, skip rendering + if image is None: + logger.info("No image provided, skipping rendering") + return # Save the image logger.info(f"Saving image to {self.device_config.current_image_file}") @@ -77,7 +82,8 @@ def display_image(self, image, image_settings=[]): # Resize and adjust orientation image = change_orientation(image, self.device_config.get_config("orientation")) image = resize_image(image, self.device_config.get_resolution(), image_settings) - if self.device_config.get_config("inverted_image"): image = image.rotate(180) + invert_setting = self.device_config.get_config("inverted_image") + if str(invert_setting).lower() in ("1", "true", "yes", "on"): image = image.rotate(180) image = apply_image_enhancement(image, self.device_config.get_config("image_settings")) # Pass to the concrete instance to render to the device. diff --git a/src/inkypi.py b/src/inkypi.py index 5dc4de57b..d30e3c4e1 100755 --- a/src/inkypi.py +++ b/src/inkypi.py @@ -112,6 +112,6 @@ except: pass # Ignore if we can't get the IP - serve(app, host="0.0.0.0", port=PORT, threads=1) + serve(app, listen="0.0.0.0:80 [::]:80", threads=1) finally: refresh_task.stop() diff --git a/src/plugins/servo_control/README.md b/src/plugins/servo_control/README.md new file mode 100644 index 000000000..4ba083239 --- /dev/null +++ b/src/plugins/servo_control/README.md @@ -0,0 +1,22 @@ +# Servo Control Plugin + +This Plugin provides control of a Servo motor connected to your Raspberry Pi via a configurable GPIO pin. +You can set the Target Angle and optional the orientation and Image Inversion saved in device_config. +Mainly this Plugin is intended to be used together with the Rotating Image Frame to create a physical frame rotation when displaying images. +But it could also be used for controlling other Mechanics (e.g. Windscreen Wipers, Blinds, etc.). + +## Rotating Image Frame Example + +https://www.thingiverse.com/thing:7290592 + +### Parts Needed +- Raspberry Pi Zero 2 W +- Screen (tested with Pimoron Inky Impression - 7.3” Spectra 6 Edition) +- Powercable for Raspberry Pi +- Servo Motor (e.g. SG90) +- 18 Jumper Cables +- Rotating Frame Assembly 3D Print e.g. from Thingiverse (https://www.thingiverse.com/thing:7290592) +- Bearing (e.g. 8 mm x 22 mm x 7 mm) +- Frame (Ikea Roedalm - 200 mm x 150 mm) + + diff --git a/src/plugins/servo_control/icon.png b/src/plugins/servo_control/icon.png new file mode 100644 index 000000000..0db265951 Binary files /dev/null and b/src/plugins/servo_control/icon.png differ diff --git a/src/plugins/servo_control/plugin-info.json b/src/plugins/servo_control/plugin-info.json new file mode 100644 index 000000000..0fcef09dc --- /dev/null +++ b/src/plugins/servo_control/plugin-info.json @@ -0,0 +1,6 @@ +{ + "display_name": "Servo Control", + "id": "servo_control", + "class": "ServoControl", + "repository": "" +} diff --git a/src/plugins/servo_control/servo_control.py b/src/plugins/servo_control/servo_control.py new file mode 100644 index 000000000..27e3fd8ed --- /dev/null +++ b/src/plugins/servo_control/servo_control.py @@ -0,0 +1,211 @@ +import logging +from plugins.base_plugin.base_plugin import BasePlugin +from PIL import Image, ImageDraw, ImageFont +from utils.servo_utils import ServoDriver, DEFAULT_GPIO_PIN, DEFAULT_ANGLE, DEFAULT_SPEED + +logger = logging.getLogger(__name__) + +DEFAULT_PWM_CHIP = "pwmchip0" + +class ServoControl(BasePlugin): + """ + Plugin to control a servo motor connected to a Raspberry Pi GPIO pin. + Supports manual angle control and orientation updates. + """ + + def __init__(self, config, **dependencies): + super().__init__(config, **dependencies) + self.pwm_chip = DEFAULT_PWM_CHIP + self.pwm_channel = None + self.servo_driver = ServoDriver() + + def generate_settings_template(self): + """Provide settings template.""" + template_params = super().generate_settings_template() + return template_params + + def generate_image(self, settings, device_config): + """ + Generate a status image showing current servo state and move servo to target angle. + + Args: + settings: Plugin settings containing gpio_pin, target_angle, servo_speed, orientation + device_config: Device configuration instance + + Returns: + PIL.Image: Status display image + """ + # Get current settings (convert strings to integers) + gpio_pin = int(settings.get('gpio_pin', DEFAULT_GPIO_PIN)) + target_angle = int(settings.get('target_angle', DEFAULT_ANGLE)) + servo_speed = int(settings.get('servo_speed', DEFAULT_SPEED)) + orientation = settings.get('orientation', 'current') + invert_setting = settings.get('inverted_image', None) + self.pwm_chip = str(settings.get('pwm_chip', DEFAULT_PWM_CHIP)) + pwm_channel = settings.get('pwm_channel', None) + self.pwm_channel = int(pwm_channel) if pwm_channel not in (None, "") else None + display_type = device_config.get_config("display_type") + + # Get current angle from device config (persistent across reboots) + current_angle = device_config.get_config('current_servo_angle', DEFAULT_ANGLE) + + # Update orientation if specified + if orientation == 'landscape': + device_config.update_value("orientation", "horizontal", write=True) + logger.info("Updated device orientation to horizontal (landscape)") + elif orientation == 'portrait': + device_config.update_value("orientation", "vertical", write=True) + logger.info("Updated device orientation to vertical (portrait)") + # if 'current', do not change orientation + + # Update image inversion if specified + invert_value = str(invert_setting).lower() in ("1", "true", "yes", "on") + device_config.update_value("inverted_image", invert_value, write=True) + logger.info(f"Updated inverted_image to {invert_value}") + + + # Move servo to the target angle + logger.info("Call Servo Move") + if display_type == "mock": + logger.info(f"Mock Servo move from {current_angle}° to {target_angle}° at speed {servo_speed} (GPIO pin {gpio_pin})") + else: + self.servo_driver.configure(gpio_pin=gpio_pin, pwm_chip=self.pwm_chip, pwm_channel=self.pwm_channel) + self.servo_driver.move(current_angle, target_angle, servo_speed) + logger.info("Finished Servo Move Call") + + # Store new angle in device config for next boot + device_config.update_value('current_servo_angle', target_angle, write=True) + + # Get dimensions + dimensions = device_config.get_resolution() + if device_config.get_config("orientation") == "vertical": + dimensions = dimensions[::-1] + + # Check if test image should be shown + show_test_image = str(settings.get('show_test_image', 'false')).lower() in ("1", "true", "yes", "on") + + if show_test_image: + # Create status image with virtual horizon + return self._create_status_image(dimensions, gpio_pin, target_angle, orientation) + else: + # No image to display - servo has been moved, return None + return None + + def _create_status_image(self, dimensions, gpio_pin, target_angle, orientation): + """ + Create a virtual horizon test image. + + The horizon is drawn at an angle that compensates for the physical rotation + of the display. When the screen is rotated to match the servo angle (target_angle), + the horizon should appear level/straight. + + For example: + - If target_angle = 90° (level), the horizon is drawn horizontally + - If target_angle = 0° (rotated 90° CCW), the horizon is drawn at 90° CW + so it appears horizontal when the screen is physically rotated + + Args: + dimensions: Image dimensions (width, height) + gpio_pin: GPIO pin number + target_angle: Target servo angle in degrees + orientation: Display orientation setting + + Returns: + PIL.Image: Virtual horizon test image + """ + import math + + width, height = dimensions + image = Image.new('RGB', (width, height), color='black') + draw = ImageDraw.Draw(image) + + # High-contrast color palette + sky_color = '#00CFEB' # Cyan for sky + ground_color = '#EACE00' # Yellow for ground + horizon_color = '#EB0078' # Magenta for horizon line + text_color = '#00EB00' # Light green for text + + # Calculate the rotation angle for the virtual horizon + # The horizon should be tilted opposite to the physical rotation + # so it appears level when the display is rotated + # Assuming 90° is level, and angles rotate from there + rotation_angle = 90 - target_angle # Degrees to rotate the horizon + rotation_rad = math.radians(rotation_angle) + + # Center point of the image + cx, cy = width / 2, height / 2 + + # Length of the horizon line (diagonal across the image) + line_len = math.sqrt(width**2 + height**2) * 1.2 + + # Calculate horizon line endpoints using rotation + cos_r = math.cos(rotation_rad) + sin_r = math.sin(rotation_rad) + x1 = cx - (line_len / 2) * cos_r + y1 = cy - (line_len / 2) * sin_r + x2 = cx + (line_len / 2) * cos_r + y2 = cy + (line_len / 2) * sin_r + + # Determine which corners are above/below the horizon line + # Create polygons for sky (above) and ground (below) + corners = [(0, 0), (width, 0), (width, height), (0, height)] + + # Sky polygon: top corners + horizon line points + if rotation_angle >= -45 and rotation_angle <= 45: + # Horizon is mostly horizontal + sky_points = [(0, 0), (width, 0), (x2, y2), (x1, y1)] + ground_points = [(0, height), (width, height), (x2, y2), (x1, y1)] + else: + # For steep angles, use all corners and line points + sky_points = [(0, 0), (width, 0), (x2, y2), (x1, y1)] + ground_points = [(0, height), (width, height), (x2, y2), (x1, y1)] + + # Fill sky and ground + draw.polygon(sky_points, fill=sky_color) + draw.polygon(ground_points, fill=ground_color) + + # Draw the horizon line (thick) + line_width = max(3, int(min(width, height) * 0.015)) + draw.line([(x1, y1), (x2, y2)], fill=horizon_color, width=line_width) + + # Draw center marker (crosshair) + marker_size = max(8, int(min(width, height) * 0.03)) + # Horizontal line + draw.line( + [(cx - marker_size, cy), (cx + marker_size, cy)], + fill=horizon_color, + width=max(2, line_width // 2) + ) + # Vertical line + draw.line( + [(cx, cy - marker_size), (cx, cy + marker_size)], + fill=horizon_color, + width=max(2, line_width // 2) + ) + + # Draw angle information + font = ImageFont.load_default() + angle_text = f"Servo: {target_angle}°" + rotation_text = f"Horizon: {rotation_angle:.1f}°" + + # Position text in corner with padding + padding = int(min(width, height) * 0.03) + draw.text((padding, padding), angle_text, font=font, fill=text_color) + draw.text((padding, padding + 15), rotation_text, font=font, fill=text_color) + + # Add instructions at bottom + instruction_text = "Rotate display to match servo angle" + bbox = draw.textbbox((0, 0), instruction_text, font=font) + text_width = bbox[2] - bbox[0] + draw.text( + ((width - text_width) // 2, height - padding - 15), + instruction_text, + font=font, + fill=text_color + ) + + return image + + def cleanup(self, settings): + """Clean up servo resources when plugin instance is deleted.""" + self.servo_driver.cleanup() diff --git a/src/plugins/servo_control/settings.html b/src/plugins/servo_control/settings.html new file mode 100644 index 000000000..63f96c785 --- /dev/null +++ b/src/plugins/servo_control/settings.html @@ -0,0 +1,178 @@ + + + +
+ Servo Control + +
+ + + GPIO pin where the servo signal wire is connected (default: 18, other Pins not tested, perhaps need more configuration) +
+ +
+ + + 45° + + Drag the slider to set target servo angle (-45° - 135°) + +
+ +
+ + + Delay between angle steps in milliseconds (lower = faster) +
+
+ + +
+ Device Config + +
+ + + + Update device orientation when servo moves. + "Keep Current" will not change the display orientation. + +
+ +
+ + + Invert the display output. +
+
+ + +
+ Display Options + +
+ + + Display a virtual horizon test image that appears level when the screen is physically rotated to match the servo angle. +
+
+ + +
+ API Example +
+

Example POST request to update servo control settings:

+
curl -X POST http://inkypi.local/update_now \
+  -d "plugin_id=servo_control&gpio_pin=18&target_angle=45&servo_speed=35&orientation=current&inverted_image=false&show_test_image=false"
+
+
+ + diff --git a/src/refresh_task.py b/src/refresh_task.py index f554e2adb..74fd9eefa 100644 --- a/src/refresh_task.py +++ b/src/refresh_task.py @@ -112,6 +112,9 @@ def _run(self): continue plugin = get_plugin_instance(plugin_config) image = refresh_action.execute(plugin, self.device_config, current_dt) + if image is None: + logger.info(f"Plugin '{refresh_action.get_plugin_id()}' did not return an image.") + continue image_hash = compute_image_hash(image) refresh_info = refresh_action.get_refresh_info() @@ -183,7 +186,7 @@ def _determine_next_plugin(self, playlist_manager, latest_refresh_info, current_ return None, None plugin = playlist.get_next_plugin() - logger.info(f"Determined next plugin. | active_playlist: {playlist.name} | plugin_instance: {plugin.name}") + logger.info(f"Determined next plugin. | active_playlist: {playlist.name}") return playlist, plugin diff --git a/src/static/images/current_image.png b/src/static/images/current_image.png new file mode 100644 index 000000000..967fdf8e0 Binary files /dev/null and b/src/static/images/current_image.png differ diff --git a/src/static/styles/main.css b/src/static/styles/main.css index bb9cf3faa..3fba364fe 100644 --- a/src/static/styles/main.css +++ b/src/static/styles/main.css @@ -77,6 +77,7 @@ html[data-theme="dark"] { body { font-family: 'Poppins', sans-serif; + color: var(--text-primary); background-color: var(--bg-primary); display: flex; justify-content: center; @@ -85,6 +86,58 @@ body { transition: background-color 0.3s ease, color 0.3s ease; } +/** HTML Elements **/ + +h1, h2, h3, h4, h5, h6, button, label, span, p, div, input, select, option, textarea, small { + color: var(--text-primary); +} + +input, textarea, select, button { + background: var(--input-bg); + padding: 0.5rem; + font-size: 1rem; + border: 1px solid var(--border-subtle); + border-radius: 6px; + box-sizing: border-box; + transition: all 0.3s ease; +} + +input:focus, textarea:focus, select:focus, button:focus { + outline: none; + border-color: var(--accent-primary); + box-shadow: 0 0 3px var(--input-focus); +} + +textarea { + font-family: 'Poppins', sans-serif; + transition: all 0.3s ease; +} + +textarea::placeholder { + color: var(--text-secondary); +} + +/* Small text elements for form hints */ +small { + transition: color 0.3s ease; +} + +fieldset { + padding: 0.5rem; + border: 2px solid var(--border-color); +} + +legend { + padding: 0.5rem; + border: 2px solid var(--border-color); +} + +details { + margin: 5px 0 5px; +} + + + /* Subtle frame container */ .frame { background-color: var(--bg-secondary); @@ -110,7 +163,6 @@ body { .header h1 { font-size: 2rem; font-weight: 700; - color: var(--text-primary); text-transform: uppercase; letter-spacing: 2px; margin-bottom: 0; @@ -242,7 +294,6 @@ body { .app-title { font-size: 1.5rem; font-weight: bold; - color: var(--text-primary); transition: color 0.3s ease; } @@ -283,76 +334,21 @@ body { .form-group label { white-space: nowrap; font-weight: 600; - color: var(--text-primary); transition: color 0.3s ease; } .form-group span { - color: var(--text-primary); transition: color 0.3s ease; } .form-group input[type="text"] { flex-grow: 1; - padding: 0.5rem; - font-size: 1rem; - border: 1px solid var(--border-subtle); - border-radius: 6px; - box-sizing: border-box; min-width: 50px; - background-color: var(--input-bg); - color: var(--text-primary); - transition: all 0.3s ease; + } .form-group input[type="number"] { min-width: 50px; - background-color: var(--input-bg); - color: var(--text-primary); - border: 1px solid var(--border-subtle); - border-radius: 6px; - padding: 0.5rem; - transition: all 0.3s ease; -} - -.form-group input[type="text"]:focus { - outline: none; - border-color: var(--accent-primary); - box-shadow: 0 0 3px var(--input-focus); -} - -.form-group input[type="number"]:focus { - outline: none; - border-color: var(--accent-primary); - box-shadow: 0 0 3px var(--input-focus); -} - -/* Textarea styling */ -textarea { - background-color: var(--input-bg); - color: var(--text-primary); - border: 1px solid var(--border-subtle); - border-radius: 6px; - padding: 0.5rem; - font-size: 1rem; - font-family: 'Poppins', sans-serif; - transition: all 0.3s ease; -} - -textarea:focus { - outline: none; - border-color: var(--accent-primary); - box-shadow: 0 0 3px var(--input-focus); -} - -textarea::placeholder { - color: var(--text-secondary); -} - -/* Small text elements for form hints */ -small { - color: var(--text-primary); - transition: color 0.3s ease; } /* Buttons container */ @@ -365,7 +361,6 @@ small { /* Back button */ .back-button { background-color: var(--button-bg); - color: var(--text-primary); border: 1px solid var(--border-subtle); border-radius: 5px; padding: 10px 20px; @@ -428,7 +423,6 @@ small { .form-label { font-size: 1rem; font-weight: 600; - color: var(--text-primary); white-space: nowrap; transition: color 0.3s ease; } @@ -436,20 +430,6 @@ small { /* Input field styling */ .form-input { flex: auto; - padding: 0.4rem; - font-size: 1rem; - border: 1px solid var(--border-subtle); - border-radius: 5px; - box-sizing: border-box; - background-color: var(--input-bg); - color: var(--text-primary); - transition: all 0.3s ease; -} - -.form-input:focus { - outline: none; - border-color: var(--accent-primary); - box-shadow: 0 0 0 2px var(--input-focus); } /* Toggle Container */ @@ -506,7 +486,6 @@ small { .file-upload-label { display: inline-block; background-color: var(--button-bg); - color: var(--text-primary); padding: 10px 20px; border-radius: 6px; cursor: pointer; @@ -532,7 +511,6 @@ small { /* File name display styling */ .file-name { font-size: 1rem; - color: var(--text-primary); word-wrap: break-word; transition: color 0.3s ease; } @@ -660,7 +638,6 @@ html[data-theme="dark"] .settings-button.dark-mode-toggle::before { .modal-content h2 { font-size: 1.2rem; margin-bottom: 15px; - color: var(--text-primary); text-align: center; transition: color 0.3s ease; } @@ -847,7 +824,6 @@ html[data-theme="dark"] .settings-button.dark-mode-toggle::before { } .plugin-instance { - color: var(--text-primary); transition: color 0.3s ease; } @@ -901,7 +877,6 @@ html[data-theme="dark"] .settings-button.dark-mode-toggle::before { .playlist-title { font-size: 1.2rem; font-weight: 600; - color: var(--text-primary); transition: color 0.3s ease; } @@ -963,7 +938,6 @@ html[data-theme="dark"] .settings-button.dark-mode-toggle::before { box-sizing: border-box; text-align: center; background-color: var(--input-bg); - color: var(--text-primary); transition: all 0.3s ease; } @@ -1007,7 +981,6 @@ html[data-theme="dark"] .settings-button.dark-mode-toggle::before { text-align: left; font-size: 1rem; margin: 10px 0px 5px 0px; - color: var(--text-primary); transition: all 0.3s ease; } @@ -1045,7 +1018,6 @@ html[data-theme="dark"] .settings-button.dark-mode-toggle::before { margin-left: -1px; position: relative; z-index: 1; - color: var(--text-primary); transition: all 0.3s ease; } @@ -1093,7 +1065,6 @@ html[data-theme="dark"] .settings-button.dark-mode-toggle::before { padding: 6px 10px; cursor: pointer; font-size: 14px; - color: var(--text-primary); transition: all 0.2s; } @@ -1122,7 +1093,6 @@ html[data-theme="dark"] .settings-button.dark-mode-toggle::before { align-items: center; justify-content: flex-start; text-decoration: none; - color: var(--text-primary); padding: 12px 8px 10px; border-radius: 8px; border: 1px solid var(--border-color); diff --git a/src/utils/servo_utils.py b/src/utils/servo_utils.py new file mode 100644 index 000000000..e290bd13a --- /dev/null +++ b/src/utils/servo_utils.py @@ -0,0 +1,324 @@ +import logging +import os +import time +import threading + +logger = logging.getLogger(__name__) + +# Try to import libgpiod for hardware control +try: + import gpiod + from gpiod.line import Direction, Value + HAS_GPIOD = True +except ImportError as e: + HAS_GPIOD = False + logger.warning(f"libgpiod not available, will try kernel PWM only. Error: {e}") + +HAS_PWM_SYSFS = os.path.isdir("/sys/class/pwm") +HARDWARE_AVAILABLE = HAS_PWM_SYSFS or HAS_GPIOD + +DEFAULT_PWM_CHIP = "pwmchip0" +DEFAULT_GPIO_PIN = 18 +DEFAULT_ANGLE = 45 +DEFAULT_SPEED = 30 # milliseconds delay between steps + +SERVO_MIN_PULSE_US = 500 +SERVO_0_DEGREE_PULSE_US = 1000 +SERVO_90_DEGREE_PULSE_US = 2000 +SERVO_MAX_PULSE_US = 2500 +SERVO_US_PER_DEGREE = (SERVO_90_DEGREE_PULSE_US - SERVO_0_DEGREE_PULSE_US) / 90.0 +MIN_ANGLE = (SERVO_MIN_PULSE_US - SERVO_0_DEGREE_PULSE_US) / SERVO_US_PER_DEGREE +MAX_ANGLE = (SERVO_MAX_PULSE_US - SERVO_0_DEGREE_PULSE_US) / SERVO_US_PER_DEGREE +SERVO_PERIOD_US = 20000 # 50Hz + + +class ServoDriver: + """Hardware driver for servo control via kernel PWM or libgpiod.""" + + def __init__(self, gpio_pin=DEFAULT_GPIO_PIN, pwm_chip=DEFAULT_PWM_CHIP, pwm_channel=None): + self.current_gpio_pin = None + self.gpio_pin = gpio_pin + self.pwm_chip = pwm_chip + self.pwm_channel = pwm_channel + + self.gpiod_chip = None + self.gpiod_line = None + self.gpiod_request = None + self.gpiod_api = None + + self.backend = None + self.pwm_chip_path = None + self.pwm_path = None + self.pwm_enabled = False + self._move_lock = threading.Lock() + self._move_thread = None + + def configure(self, gpio_pin=None, pwm_chip=None, pwm_channel=None): + """Update servo configuration and reset backends if needed.""" + if pwm_chip is not None: + self.pwm_chip = pwm_chip + if pwm_channel is not None: + self.pwm_channel = pwm_channel + + if gpio_pin is not None and gpio_pin != self.gpio_pin: + self.gpio_pin = gpio_pin + self._cleanup_gpiod() + self._cleanup_pwm_sysfs() + self.current_gpio_pin = None + + def move(self, current_angle, target_angle, speed_ms): + """Move servo from current angle to target angle asynchronously.""" + if self._move_thread and self._move_thread.is_alive(): + logger.warning("Servo move already in progress; new request ignored.") + return + + self._move_thread = threading.Thread( + target=self._move_blocking, + args=(current_angle, target_angle, speed_ms), + daemon=True, + ) + self._move_thread.start() + + def _move_blocking(self, current_angle, target_angle, speed_ms): + if current_angle == target_angle: + logger.info(f"Servo already at target angle {target_angle}°; no movement needed.") + return + with self._move_lock: + if not HARDWARE_AVAILABLE: + logger.info( + f"Mock: Would move servo on GPIO {self.gpio_pin} from {current_angle}° to {target_angle}° at {speed_ms}ms speed" + ) + return + + current_angle = max(MIN_ANGLE, min(MAX_ANGLE, current_angle)) + target_angle = max(MIN_ANGLE, min(MAX_ANGLE, target_angle)) + + self.backend = self._select_backend(self.gpio_pin) + + if self.backend == "pwm_sysfs": + self._initialize_pwm_if_needed(self.gpio_pin) + step = 1 if target_angle > current_angle else -1 + for angle in range(int(current_angle), int(target_angle), step): + pulse_us = self._angle_to_pulse_us(angle) + self._pwm_sysfs_set_pulse_us(pulse_us) + time.sleep(speed_ms / 1000) + + final_pulse_us = self._angle_to_pulse_us(target_angle) + self._pwm_sysfs_set_pulse_us(final_pulse_us) + time.sleep(0.5) + self._pwm_sysfs_disable() + logger.info(f"Moved servo from {current_angle}° to {target_angle}° (kernel PWM)") + return + + if self.backend == "gpiod": + self._initialize_gpiod_if_needed(self.gpio_pin) + step = 1 if target_angle > current_angle else -1 + for angle in range(int(current_angle), int(target_angle), step): + pulse_us = self._angle_to_pulse_us(angle) + step_duration_ms = max(speed_ms, 20) + self._gpiod_pwm_for_duration(pulse_us, step_duration_ms) + + final_pulse_us = self._angle_to_pulse_us(target_angle) + self._gpiod_pwm_for_duration(final_pulse_us, max(200, speed_ms)) + self._gpiod_set_value(False) + logger.info(f"Moved servo from {current_angle}° to {target_angle}° (libgpiod)") + return + + logger.warning("No supported backend available; servo move skipped.") + + def cleanup(self): + """Clean up hardware resources.""" + self._cleanup_gpiod() + self._cleanup_pwm_sysfs() + + def _select_backend(self, gpio_pin): + if self._pwm_sysfs_available(gpio_pin): + return "pwm_sysfs" + if HAS_GPIOD: + return "gpiod" + return "mock" + + def _pwm_sysfs_available(self, gpio_pin): + chip_path = f"/sys/class/pwm/{self.pwm_chip}" + if not os.path.isdir(chip_path): + return False + if self.pwm_channel is not None: + return True + return gpio_pin in (12, 13, 18, 19) + + def _gpio_pin_to_pwm_channel(self, gpio_pin): + if self.pwm_channel is not None: + return self.pwm_channel + mapping = { + 12: 0, + 18: 0, + 13: 1, + 19: 1, + } + return mapping.get(gpio_pin) + + def _initialize_pwm_if_needed(self, gpio_pin): + if self.current_gpio_pin != gpio_pin: + self._cleanup_pwm_sysfs() + if not self.pwm_path: + self._initialize_pwm_sysfs(gpio_pin) + self.current_gpio_pin = gpio_pin + logger.info(f"Initialized servo on GPIO pin {gpio_pin} with kernel PWM") + + def _initialize_pwm_sysfs(self, gpio_pin): + channel = self._gpio_pin_to_pwm_channel(gpio_pin) + if channel is None: + raise RuntimeError("GPIO pin does not map to a PWM channel. Set pwm_channel in settings.") + + self.pwm_chip_path = f"/sys/class/pwm/{self.pwm_chip}" + self.pwm_path = f"{self.pwm_chip_path}/pwm{channel}" + + export_path = f"{self.pwm_chip_path}/export" + enable_path = f"{self.pwm_path}/enable" + period_path = f"{self.pwm_path}/period" + duty_path = f"{self.pwm_path}/duty_cycle" + + if not os.path.isdir(self.pwm_chip_path): + raise RuntimeError(f"PWM chip path not found: {self.pwm_chip_path}") + + if not os.path.isdir(self.pwm_path): + with open(export_path, "w", encoding="utf-8") as f: + f.write(str(channel)) + + if os.path.exists(enable_path): + with open(enable_path, "w", encoding="utf-8") as f: + f.write("0") + + period_ns = SERVO_PERIOD_US * 1000 + with open(period_path, "w", encoding="utf-8") as f: + f.write(str(period_ns)) + with open(duty_path, "w", encoding="utf-8") as f: + f.write(str(SERVO_MIN_PULSE_US * 1000)) + + with open(enable_path, "w", encoding="utf-8") as f: + f.write("1") + self.pwm_enabled = True + + def _cleanup_pwm_sysfs(self): + if not self.pwm_path or not self.pwm_chip_path: + return + + enable_path = f"{self.pwm_path}/enable" + unexport_path = f"{self.pwm_chip_path}/unexport" + channel = self._gpio_pin_to_pwm_channel(self.current_gpio_pin) if self.current_gpio_pin is not None else None + + try: + if os.path.exists(enable_path): + with open(enable_path, "w", encoding="utf-8") as f: + f.write("0") + except Exception: + pass + + try: + if channel is not None and os.path.exists(unexport_path): + with open(unexport_path, "w", encoding="utf-8") as f: + f.write(str(channel)) + except Exception: + pass + + self.pwm_path = None + self.pwm_chip_path = None + self.pwm_enabled = False + + def _initialize_gpiod_if_needed(self, gpio_pin): + if self.current_gpio_pin != gpio_pin: + self._cleanup_gpiod() + if not self.gpiod_line and not self.gpiod_request: + self._initialize_gpiod_line(gpio_pin) + self.current_gpio_pin = gpio_pin + logger.info(f"Initialized servo on GPIO pin {gpio_pin} with libgpiod") + + def _initialize_gpiod_line(self, gpio_pin): + if hasattr(gpiod, "LineSettings") and hasattr(gpiod, "request_lines"): + self.gpiod_api = "v2" + config = { + gpio_pin: gpiod.LineSettings( + direction=Direction.OUTPUT, + output_value=Value.INACTIVE, + ) + } + self.gpiod_request = gpiod.request_lines( + "/dev/gpiochip0", + consumer="inkypi-servo", + config=config, + ) + else: + self.gpiod_api = "v1" + self.gpiod_chip = gpiod.Chip("gpiochip0") + self.gpiod_line = self.gpiod_chip.get_line(gpio_pin) + self.gpiod_line.request( + consumer="inkypi-servo", + type=gpiod.LINE_REQ_DIR_OUT, + default_vals=[0], + ) + + def _cleanup_gpiod(self): + if self.gpiod_request: + try: + self.gpiod_request.close() + except Exception: + pass + self.gpiod_request = None + + if self.gpiod_line: + try: + self.gpiod_line.release() + except Exception: + pass + self.gpiod_line = None + + if self.gpiod_chip: + try: + self.gpiod_chip.close() + except Exception: + pass + self.gpiod_chip = None + self.gpiod_api = None + + def _angle_to_pulse_us(self, angle): + angle = max(MIN_ANGLE, min(MAX_ANGLE, angle)) + pulse = SERVO_0_DEGREE_PULSE_US + (angle * SERVO_US_PER_DEGREE) + return int(max(SERVO_MIN_PULSE_US, min(SERVO_MAX_PULSE_US, pulse))) + + def _pwm_sysfs_set_pulse_us(self, pulse_us): + if not self.pwm_path: + return + enable_path = f"{self.pwm_path}/enable" + duty_path = f"{self.pwm_path}/duty_cycle" + duty_ns = int(pulse_us * 1000) + if os.path.exists(enable_path): + with open(enable_path, "w", encoding="utf-8") as f: + f.write("1") + with open(duty_path, "w", encoding="utf-8") as f: + f.write(str(duty_ns)) + + def _pwm_sysfs_disable(self): + if not self.pwm_path: + return + enable_path = f"{self.pwm_path}/enable" + if os.path.exists(enable_path): + with open(enable_path, "w", encoding="utf-8") as f: + f.write("0") + + def _gpiod_set_value(self, active): + if self.gpiod_api == "v2" and self.gpiod_request: + value = Value.ACTIVE if active else Value.INACTIVE + self.gpiod_request.set_value(self.current_gpio_pin, value) + elif self.gpiod_line: + self.gpiod_line.set_value(1 if active else 0) + + def _gpiod_pwm_for_duration(self, pulse_us, duration_ms): + period_us = SERVO_PERIOD_US + high_s = pulse_us / 1_000_000 + low_s = max(0.0, (period_us - pulse_us) / 1_000_000) + end_time = time.monotonic() + (duration_ms / 1000.0) + while time.monotonic() < end_time: + self._gpiod_set_value(True) + time.sleep(high_s) + self._gpiod_set_value(False) + time.sleep(low_s)