From 9a177341a16a5d05a9ff7e1a3eb47eecb5fb51d1 Mon Sep 17 00:00:00 2001 From: Andreas Wolf Date: Sun, 1 Feb 2026 09:58:50 +0100 Subject: [PATCH 01/35] Servo Control Plugin added --- src/plugins/servo_control/README.md | 35 +++ src/plugins/servo_control/icon.png | Bin 0 -> 18063 bytes src/plugins/servo_control/plugin-info.json | 6 + src/plugins/servo_control/servo_control.py | 252 +++++++++++++++++++++ src/plugins/servo_control/settings.html | 147 ++++++++++++ 5 files changed, 440 insertions(+) create mode 100644 src/plugins/servo_control/README.md create mode 100644 src/plugins/servo_control/icon.png create mode 100644 src/plugins/servo_control/plugin-info.json create mode 100644 src/plugins/servo_control/servo_control.py create mode 100644 src/plugins/servo_control/settings.html diff --git a/src/plugins/servo_control/README.md b/src/plugins/servo_control/README.md new file mode 100644 index 000000000..f0d68433c --- /dev/null +++ b/src/plugins/servo_control/README.md @@ -0,0 +1,35 @@ +# Servo Control Plugin + +This Plugin provides controll of a Servo motor connected to your Raspberry Pi via a configurable GPIO pin. +You can set the Target Angle and optional the orientation saved in device_config. + +** This Plugin will not update the Screen ** + +## Settings Page Configuration + +### Controllable Settings + +- **GPIO Pin**: The GPIO pin number where the Servo signal wire is connected. +- **TargetAngle**: A Slider to manually set the Servo angle between 0° and 180°. +- **ServoSpeed**: A Slider to adjust the speed of the Servo movement (delay between angle steps in milliseconds). +- **Orientation**: An optional setting to define the orientation of the Servo. (landscape | portrait | current). This will update and persist the device_config orientation setting when changed. + +## Usage + +Include the Servo Control Plugin in your Playlists. +** Tipp: ** +You can do a POST Request to the InkyPi to set the TargetAngle configured in a Playlist with the following command. +This way you can store predifined Positions in different Playlists and switch between them via API. + +``` +curl -X POST "http:///api/plugin/servo_control" -H "Content-Type: application/json" -d '{"playlist_name": "YOUR_PLAYLIST_NAME", "plugin_instance": "YOUR_PLUGIN_INSTANCE_NAME", "plugin_id": "servo_control"}' +``` + +## Servo Control +This Plugin currently is optimised for a SG90 Micro Servo. +The Servo is controlled via PWM signals on the specified GPIO pin. +The angle is set by adjusting the duty cycle of the PWM signal. +When moving to a new angle, the Plugin will incrementally adjust the angle in small steps to ensure smooth movement. +The speed of the movement can be adjusted via the ServoSpeed setting. +The current Angle is always stored in the device_config under "current_servo_angle" and used after bootup as the current starting Angle. + diff --git a/src/plugins/servo_control/icon.png b/src/plugins/servo_control/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..563a396ef5d4a022f1f33b650448903696ed4596 GIT binary patch literal 18063 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 literal 0 HcmV?d00001 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..e9ce7732a --- /dev/null +++ b/src/plugins/servo_control/servo_control.py @@ -0,0 +1,252 @@ +import logging +import time +from plugins.base_plugin.base_plugin import BasePlugin +from PIL import Image, ImageDraw, ImageFont +from utils.app_utils import get_font + +logger = logging.getLogger(__name__) + +# Try to import gpiozero for hardware control +try: + from gpiozero import Servo + from gpiozero.pins.pigpio import PiGPIOFactory + HARDWARE_AVAILABLE = True +except ImportError as e: + logger.warning(f"gpiozero not available, servo control will be mocked. Error: {e}") + logger.info("To enable hardware control, install gpiozero: pip install gpiozero") + HARDWARE_AVAILABLE = False + +DEFAULT_GPIO_PIN = 13 +DEFAULT_ANGLE = 90 +DEFAULT_SPEED = 10 # milliseconds delay between steps +MIN_ANGLE = 0 +MAX_ANGLE = 180 + +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.servo = None + self.current_gpio_pin = None + + 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') + + # 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 + + # Move servo to the target angle + self._move_servo(gpio_pin, current_angle, target_angle, servo_speed) + + # 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] + + # Create status image + image = self._create_status_image(dimensions, gpio_pin, target_angle, orientation) + + return image + + def _initialize_servo(self, gpio_pin): + """Initialize servo on specified GPIO pin.""" + if not HARDWARE_AVAILABLE: + logger.info(f"Mock: Would initialize servo on GPIO pin {gpio_pin}") + return + + # Clean up existing servo if pin changed + if self.servo and self.current_gpio_pin != gpio_pin: + try: + self.servo.close() + except: + pass + self.servo = None + + # Initialize new servo + if not self.servo or self.current_gpio_pin != gpio_pin: + try: + # Use pigpio for better PWM control if available + factory = PiGPIOFactory() + # SG90 specific pulse widths: 1ms (0°) to 2ms (180°) + self.servo = Servo(gpio_pin, min_pulse_width=1/1000, max_pulse_width=2/1000, pin_factory=factory) + self.current_gpio_pin = gpio_pin + logger.info(f"Initialized servo on GPIO pin {gpio_pin} with pigpio") + except Exception as e: + logger.warning(f"Failed to use pigpio, falling back to default: {e}") + try: + # SG90 specific pulse widths for default factory too + self.servo = Servo(gpio_pin, min_pulse_width=1/1000, max_pulse_width=2/1000) + self.current_gpio_pin = gpio_pin + logger.info(f"Initialized servo on GPIO pin {gpio_pin} (default pins)") + except Exception as e: + logger.error(f"Failed to initialize servo: {e}") + raise RuntimeError(f"Failed to initialize servo on GPIO pin {gpio_pin}: {e}") + + def _angle_to_servo_value(self, angle): + """ + Convert angle (0-180) to servo value (-1 to 1). + For SG90 servo: -1 = 0°, 0 = 90°, 1 = 180° + """ + # Map 0-180 to -1 to 1 + return (angle / 90.0) - 1.0 + + def _move_servo(self, gpio_pin, current_angle, target_angle, speed_ms): + """ + Move servo from current angle to target angle with smooth motion. + + Args: + gpio_pin: GPIO pin number + current_angle: Starting angle (0-180) + target_angle: Target angle (0-180) + speed_ms: Delay in milliseconds between angle steps + """ + # Validate angles + current_angle = max(MIN_ANGLE, min(MAX_ANGLE, current_angle)) + target_angle = max(MIN_ANGLE, min(MAX_ANGLE, target_angle)) + + if not HARDWARE_AVAILABLE: + logger.info(f"Mock: Would move servo on GPIO {gpio_pin} from {current_angle}° to {target_angle}° at {speed_ms}ms speed") + return + + try: + # Initialize servo if needed + self._initialize_servo(gpio_pin) + + # Calculate step direction + step = 1 if target_angle > current_angle else -1 + + # Move incrementally for smooth motion + for angle in range(int(current_angle), int(target_angle), step): + servo_value = self._angle_to_servo_value(angle) + self.servo.value = servo_value + logger.info(f"new Angle: {angle}° new Servo Value: {servo_value}") + time.sleep(speed_ms/1000) + + # Ensure we reach exact target + final_value = self._angle_to_servo_value(target_angle) + self.servo.value = final_value + + # Stop sending PWM signals without causing servo twitch + time.sleep(0.5) # Hold position briefly + self.servo.value = None + logger.info(f"Moved servo from {current_angle}° to {target_angle}°") + + except Exception as e: + logger.error(f"Failed to move servo: {e}") + raise RuntimeError(f"Failed to move servo: {e}") + + def _create_status_image(self, dimensions, gpio_pin, target_angle, orientation): + """ + Create a status image showing servo state. + + Args: + dimensions: Image dimensions (width, height) + gpio_pin: GPIO pin number + current_angle: Current servo angle + positions: Dictionary of saved positions + + Returns: + PIL.Image: Status image + """ + width, height = dimensions + + # Create white background + image = Image.new('RGB', (width, height), color='white') + draw = ImageDraw.Draw(image) + + # Fonts + try: + title_font = get_font("Roboto-Bold", int(height * 0.08)) + large_font = get_font("Roboto-Bold", int(height * 0.15)) + medium_font = get_font("Roboto-Regular", int(height * 0.06)) + small_font = get_font("Roboto-Regular", int(height * 0.045)) + except: + # Fallback to default font + title_font = ImageFont.load_default() + large_font = ImageFont.load_default() + medium_font = ImageFont.load_default() + small_font = ImageFont.load_default() + + # Title + title = "Servo Control" + draw.text((width // 2, height * 0.1), title, font=title_font, fill='black', anchor='mm') + + # Target angle - large display + angle_text = f"{target_angle}°" + draw.text((width // 2, height * 0.3), angle_text, font=large_font, fill='#2c3e50', anchor='mm') + + # GPIO pin info + gpio_text = f"GPIO Pin: {gpio_pin}" + draw.text((width // 2, height * 0.45), gpio_text, font=medium_font, fill='#7f8c8d', anchor='mm') + + # Draw a simple arc to visualize angle + arc_y = height * 0.6 + arc_radius = min(width, height) * 0.15 + arc_bbox = [ + width // 2 - arc_radius, + arc_y - arc_radius, + width // 2 + arc_radius, + arc_y + arc_radius + ] + + # Background arc (0-180) + draw.arc(arc_bbox, start=0, end=180, fill='#ecf0f1', width=int(height * 0.02)) + + # Target position arc + draw.arc(arc_bbox, start=0, end=target_angle, fill='#3498db', width=int(height * 0.02)) + + # Orientation info + if orientation in ['landscape', 'portrait']: + y_offset = height * 0.78 + draw.text((width // 2, y_offset), "Orientation:", font=medium_font, fill='black', anchor='mm') + + y_offset += height * 0.06 + orientation_text = orientation.capitalize() + orientation_color = '#e74c3c' + draw.text((width // 2, y_offset), orientation_text, font=small_font, fill=orientation_color, anchor='mm') + + return image + + def cleanup(self, settings): + """Clean up servo resources when plugin instance is deleted.""" + if self.servo: + try: + self.servo.close() + logger.info("Closed servo connection") + except Exception as e: + logger.error(f"Error closing servo: {e}") diff --git a/src/plugins/servo_control/settings.html b/src/plugins/servo_control/settings.html new file mode 100644 index 000000000..7fb1d8c8d --- /dev/null +++ b/src/plugins/servo_control/settings.html @@ -0,0 +1,147 @@ + +
+ + + GPIO pin where the servo signal wire is connected (default: 13) +
+ +
+ + + Drag the slider to set target servo angle (0° - 180°) +
+ +
+ + + Delay between angle steps in milliseconds (lower = faster) +
+ +
+ + + + Update device orientation when servo moves. + "Keep Current" will not change the display orientation. + +
+ +
+ +
+

API Documentation:

+
+
+
+

Trigger servo via playlist (Recommended):

+ + curl -X POST http://your-inkypi:8080/api/plugin/servo_control \
+   -H "Content-Type: application/json" \
+   -d '{
+     "playlist_name": "YOUR_PLAYLIST_NAME",
+     "plugin_instance": "YOUR_PLUGIN_INSTANCE_NAME",
+     "plugin_id": "servo_control"
+   }' +
+ + This will activate the servo with the target angle configured in the specified playlist instance. + +
+ +
+

Direct servo control (Manual):

+ + curl -X POST http://your-inkypi:8080/update_now \
+   -F "plugin_id=servo_control" \
+   -F "gpio_pin=13" \
+   -F "target_angle=90" \
+   -F "servo_speed=10" \
+   -F "orientation=landscape" +
+ + orientation can be: current, landscape, or portrait + +
+ +

+ Note: The servo will move to the target angle and update the display. + The current angle is stored persistently and used as the starting position after reboot. +

+
+
+
+ + From 7cbfa7c48c0686356c1115a6bcb65f20108ecee1 Mon Sep 17 00:00:00 2001 From: Andreas Wolf Date: Sun, 1 Feb 2026 15:04:54 +0100 Subject: [PATCH 02/35] Servo Controll dependency test --- install/install.sh | 17 ++ src/plugins/servo_control/servo_control.py | 294 ++++++++++++++++++++- 2 files changed, 307 insertions(+), 4 deletions(-) diff --git a/install/install.sh b/install/install.sh index d2c1074fc..2d8b11f92 100644 --- a/install/install.sh +++ b/install/install.sh @@ -140,6 +140,16 @@ enable_interfaces(){ fi } +enable_pwm_overlay(){ + echo "Enabling PWM overlay for servo control" + if ! grep -E -q '^[[:space:]]*dtoverlay=pwm-2chan' /boot/firmware/config.txt; then + sed -i '/^dtparam=spi=on/a dtoverlay=pwm-2chan' /boot/firmware/config.txt + echo_success "\tPWM overlay enabled (pwm-2chan)" + else + echo_success "\tPWM overlay already enabled" + fi +} + show_loader() { local pid=$! local delay=0.1 @@ -192,6 +202,11 @@ install_debian_dependencies() { fi } +install_servo_dependencies() { + echo "Installing servo dependencies (libgpiod and tools)." + sudo apt-get install -y gpiod python3-libgpiod > /dev/null +} + setup_zramswap_service() { echo "Enabling and starting zramswap service." sudo apt-get install -y zram-tools > /dev/null @@ -364,7 +379,9 @@ if [[ -n "$WS_TYPE" ]]; then fetch_waveshare_driver fi enable_interfaces +enable_pwm_overlay install_debian_dependencies +install_servo_dependencies # check OS version for Bookworm to setup zramswap if [[ $(get_os_version) = "12" ]] ; then echo "OS version is Bookworm - setting up zramswap" diff --git a/src/plugins/servo_control/servo_control.py b/src/plugins/servo_control/servo_control.py index e9ce7732a..f08c31ae6 100644 --- a/src/plugins/servo_control/servo_control.py +++ b/src/plugins/servo_control/servo_control.py @@ -1,26 +1,44 @@ import logging import time +import os from plugins.base_plugin.base_plugin import BasePlugin from PIL import Image, ImageDraw, ImageFont from utils.app_utils import get_font logger = logging.getLogger(__name__) -# Try to import gpiozero for hardware control +# 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 gpiozero fallback. Error: {e}") + +# Try to import gpiozero as a fallback try: from gpiozero import Servo from gpiozero.pins.pigpio import PiGPIOFactory - HARDWARE_AVAILABLE = True + HAS_GPIOZERO = True except ImportError as e: + HAS_GPIOZERO = False logger.warning(f"gpiozero not available, servo control will be mocked. Error: {e}") - logger.info("To enable hardware control, install gpiozero: pip install gpiozero") - HARDWARE_AVAILABLE = False + logger.info("To enable gpiozero, install: pip install gpiozero") + +HAS_PWM_SYSFS = os.path.isdir("/sys/class/pwm") +SERVO_BACKEND = "auto" +HARDWARE_AVAILABLE = HAS_PWM_SYSFS or HAS_GPIOD or HAS_GPIOZERO DEFAULT_GPIO_PIN = 13 DEFAULT_ANGLE = 90 DEFAULT_SPEED = 10 # milliseconds delay between steps MIN_ANGLE = 0 MAX_ANGLE = 180 +DEFAULT_PWM_CHIP = "pwmchip0" +SERVO_MIN_PULSE_US = 1000 +SERVO_MAX_PULSE_US = 2000 +SERVO_PERIOD_US = 20000 # 50Hz class ServoControl(BasePlugin): """ @@ -32,6 +50,16 @@ def __init__(self, config, **dependencies): super().__init__(config, **dependencies) self.servo = None self.current_gpio_pin = None + self.gpiod_chip = None + self.gpiod_line = None + self.gpiod_request = None + self.gpiod_api = None + self.backend = None + self.pwm_chip = DEFAULT_PWM_CHIP + self.pwm_channel = None + self.pwm_chip_path = None + self.pwm_path = None + self.pwm_enabled = False def generate_settings_template(self): """Provide settings template.""" @@ -54,6 +82,9 @@ def generate_image(self, settings, device_config): target_angle = int(settings.get('target_angle', DEFAULT_ANGLE)) servo_speed = int(settings.get('servo_speed', DEFAULT_SPEED)) orientation = settings.get('orientation', 'current') + 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 # Get current angle from device config (persistent across reboots) current_angle = device_config.get_config('current_servo_angle', DEFAULT_ANGLE) @@ -88,6 +119,36 @@ def _initialize_servo(self, gpio_pin): if not HARDWARE_AVAILABLE: logger.info(f"Mock: Would initialize servo on GPIO pin {gpio_pin}") return + + self.backend = self._select_backend(gpio_pin) + + if self.backend == "pwm_sysfs": + if self.current_gpio_pin != gpio_pin: + self._cleanup_pwm_sysfs() + if not self.pwm_path: + try: + 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") + except Exception as e: + logger.error(f"Failed to initialize kernel PWM on GPIO {gpio_pin}: {e}") + raise RuntimeError(f"Failed to initialize kernel PWM on GPIO pin {gpio_pin}: {e}") + return + + if self.backend == "gpiod": + # Clean up existing line if pin changed + if self.current_gpio_pin != gpio_pin: + self._cleanup_gpiod() + + if not self.gpiod_line and not self.gpiod_request: + try: + self._initialize_gpiod_line(gpio_pin) + self.current_gpio_pin = gpio_pin + logger.info(f"Initialized servo on GPIO pin {gpio_pin} with libgpiod") + except Exception as e: + logger.error(f"Failed to initialize libgpiod on GPIO {gpio_pin}: {e}") + raise RuntimeError(f"Failed to initialize libgpiod on GPIO pin {gpio_pin}: {e}") + return # Clean up existing servo if pin changed if self.servo and self.current_gpio_pin != gpio_pin: @@ -116,6 +177,151 @@ def _initialize_servo(self, gpio_pin): except Exception as e: logger.error(f"Failed to initialize servo: {e}") raise RuntimeError(f"Failed to initialize servo on GPIO pin {gpio_pin}: {e}") + + def _select_backend(self, gpio_pin): + """Select the best available backend for servo control.""" + if self._pwm_sysfs_available(gpio_pin): + return "pwm_sysfs" + if HAS_GPIOD: + return "gpiod" + if HAS_GPIOZERO: + return "gpiozero" + return "mock" + + def _pwm_sysfs_available(self, gpio_pin): + """Check if kernel PWM sysfs is available for the 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): + """Map common Raspberry Pi GPIO pins to PWM channels.""" + 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_sysfs(self, gpio_pin): + """Initialize kernel PWM sysfs for hardware-timed PWM.""" + 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)) + + # Disable before configuring + 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): + """Disable and unexport kernel PWM sysfs.""" + 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_line(self, gpio_pin): + """Initialize libgpiod line (supports v1 and v2 APIs).""" + if hasattr(gpiod, "LineSettings") and hasattr(gpiod, "request_lines"): + # libgpiod v2 API + 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: + # libgpiod v1 API + 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): + """Release libgpiod resources.""" + 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_servo_value(self, angle): """ @@ -124,6 +330,41 @@ def _angle_to_servo_value(self, angle): """ # Map 0-180 to -1 to 1 return (angle / 90.0) - 1.0 + + def _angle_to_pulse_us(self, angle): + """Convert angle (0-180) to PWM pulse width in microseconds.""" + angle = max(MIN_ANGLE, min(MAX_ANGLE, angle)) + span = SERVO_MAX_PULSE_US - SERVO_MIN_PULSE_US + return int(SERVO_MIN_PULSE_US + (angle / 180.0) * span) + + def _pwm_sysfs_set_pulse_us(self, pulse_us): + """Set PWM duty cycle via sysfs in nanoseconds.""" + if not self.pwm_path: + return + duty_path = f"{self.pwm_path}/duty_cycle" + duty_ns = int(pulse_us * 1000) + with open(duty_path, "w", encoding="utf-8") as f: + f.write(str(duty_ns)) + + def _gpiod_set_value(self, active): + """Set GPIO line value using libgpiod.""" + 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): + """Software PWM via libgpiod for a specific duration.""" + 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) def _move_servo(self, gpio_pin, current_angle, target_angle, speed_ms): """ @@ -142,6 +383,49 @@ def _move_servo(self, gpio_pin, current_angle, target_angle, speed_ms): if not HARDWARE_AVAILABLE: logger.info(f"Mock: Would move servo on GPIO {gpio_pin} from {current_angle}° to {target_angle}° at {speed_ms}ms speed") return + + self.backend = self._select_backend(gpio_pin) + + if self.backend == "pwm_sysfs": + try: + self._initialize_servo(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) + logger.info(f"new Angle: {angle}° new Pulse: {pulse_us}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) + logger.info(f"Moved servo from {current_angle}° to {target_angle}° (kernel PWM)") + return + except Exception as e: + logger.error(f"Failed to move servo with kernel PWM: {e}") + raise RuntimeError(f"Failed to move servo with kernel PWM: {e}") + + if self.backend == "gpiod": + try: + # Initialize line if needed + self._initialize_servo(gpio_pin) + + # Calculate step direction + 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) + logger.info(f"new Angle: {angle}° new Pulse: {pulse_us}us") + + # Ensure we reach exact target + final_pulse_us = self._angle_to_pulse_us(target_angle) + self._gpiod_pwm_for_duration(final_pulse_us, max(200, speed_ms)) + logger.info(f"Moved servo from {current_angle}° to {target_angle}° (libgpiod)") + return + except Exception as e: + logger.error(f"Failed to move servo with libgpiod: {e}") + raise RuntimeError(f"Failed to move servo with libgpiod: {e}") try: # Initialize servo if needed @@ -244,6 +528,8 @@ def _create_status_image(self, dimensions, gpio_pin, target_angle, orientation): def cleanup(self, settings): """Clean up servo resources when plugin instance is deleted.""" + self._cleanup_gpiod() + self._cleanup_pwm_sysfs() if self.servo: try: self.servo.close() From a4842684291e4bedb017bff1d660bf64303b8c8b Mon Sep 17 00:00:00 2001 From: Andreas Wolf Date: Sun, 1 Feb 2026 15:42:18 +0100 Subject: [PATCH 03/35] Servo Controll dependencies added --- install/requirements.txt | 3 +++ 1 file changed, 3 insertions(+) diff --git a/install/requirements.txt b/install/requirements.txt index c7ab09a23..991a990d5 100644 --- a/install/requirements.txt +++ b/install/requirements.txt @@ -16,3 +16,6 @@ cysystemd==2.0.1 waitress==3.0.2 feedparser==6.0.11 astral>=3.1 +gpiozero==2.0.1 +lgpio==0.2.2.0 +RPi.GPIO==0.7.1 \ No newline at end of file From 9b06fb6a6aa1745cf1993773dee56ab405e7c743 Mon Sep 17 00:00:00 2001 From: Andreas Wolf Date: Sun, 1 Feb 2026 15:59:28 +0100 Subject: [PATCH 04/35] Servo Controll dependencies test --- install/install.sh | 21 ++++-- install/requirements.txt | 1 + src/plugins/servo_control/servo_control.py | 85 +--------------------- 3 files changed, 18 insertions(+), 89 deletions(-) diff --git a/install/install.sh b/install/install.sh index 2d8b11f92..8e60481eb 100644 --- a/install/install.sh +++ b/install/install.sh @@ -142,12 +142,19 @@ enable_interfaces(){ enable_pwm_overlay(){ echo "Enabling PWM overlay for servo control" - if ! grep -E -q '^[[:space:]]*dtoverlay=pwm-2chan' /boot/firmware/config.txt; then - sed -i '/^dtparam=spi=on/a dtoverlay=pwm-2chan' /boot/firmware/config.txt - echo_success "\tPWM overlay enabled (pwm-2chan)" - else - echo_success "\tPWM overlay already enabled" - fi + 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() { @@ -204,7 +211,7 @@ install_debian_dependencies() { install_servo_dependencies() { echo "Installing servo dependencies (libgpiod and tools)." - sudo apt-get install -y gpiod python3-libgpiod > /dev/null + sudo apt-get install -y gpiod python3-libgpiod libgpiod-dev > /dev/null } setup_zramswap_service() { diff --git a/install/requirements.txt b/install/requirements.txt index 991a990d5..4b1eb4cb2 100644 --- a/install/requirements.txt +++ b/install/requirements.txt @@ -17,5 +17,6 @@ waitress==3.0.2 feedparser==6.0.11 astral>=3.1 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/plugins/servo_control/servo_control.py b/src/plugins/servo_control/servo_control.py index f08c31ae6..6dfd15c09 100644 --- a/src/plugins/servo_control/servo_control.py +++ b/src/plugins/servo_control/servo_control.py @@ -16,19 +16,8 @@ HAS_GPIOD = False logger.warning(f"libgpiod not available, will try gpiozero fallback. Error: {e}") -# Try to import gpiozero as a fallback -try: - from gpiozero import Servo - from gpiozero.pins.pigpio import PiGPIOFactory - HAS_GPIOZERO = True -except ImportError as e: - HAS_GPIOZERO = False - logger.warning(f"gpiozero not available, servo control will be mocked. Error: {e}") - logger.info("To enable gpiozero, install: pip install gpiozero") - HAS_PWM_SYSFS = os.path.isdir("/sys/class/pwm") -SERVO_BACKEND = "auto" -HARDWARE_AVAILABLE = HAS_PWM_SYSFS or HAS_GPIOD or HAS_GPIOZERO +HARDWARE_AVAILABLE = HAS_PWM_SYSFS or HAS_GPIOD DEFAULT_GPIO_PIN = 13 DEFAULT_ANGLE = 90 @@ -48,7 +37,6 @@ class ServoControl(BasePlugin): def __init__(self, config, **dependencies): super().__init__(config, **dependencies) - self.servo = None self.current_gpio_pin = None self.gpiod_chip = None self.gpiod_line = None @@ -150,33 +138,7 @@ def _initialize_servo(self, gpio_pin): raise RuntimeError(f"Failed to initialize libgpiod on GPIO pin {gpio_pin}: {e}") return - # Clean up existing servo if pin changed - if self.servo and self.current_gpio_pin != gpio_pin: - try: - self.servo.close() - except: - pass - self.servo = None - - # Initialize new servo - if not self.servo or self.current_gpio_pin != gpio_pin: - try: - # Use pigpio for better PWM control if available - factory = PiGPIOFactory() - # SG90 specific pulse widths: 1ms (0°) to 2ms (180°) - self.servo = Servo(gpio_pin, min_pulse_width=1/1000, max_pulse_width=2/1000, pin_factory=factory) - self.current_gpio_pin = gpio_pin - logger.info(f"Initialized servo on GPIO pin {gpio_pin} with pigpio") - except Exception as e: - logger.warning(f"Failed to use pigpio, falling back to default: {e}") - try: - # SG90 specific pulse widths for default factory too - self.servo = Servo(gpio_pin, min_pulse_width=1/1000, max_pulse_width=2/1000) - self.current_gpio_pin = gpio_pin - logger.info(f"Initialized servo on GPIO pin {gpio_pin} (default pins)") - except Exception as e: - logger.error(f"Failed to initialize servo: {e}") - raise RuntimeError(f"Failed to initialize servo on GPIO pin {gpio_pin}: {e}") + logger.info("No additional backend initialization required.") def _select_backend(self, gpio_pin): """Select the best available backend for servo control.""" @@ -184,8 +146,6 @@ def _select_backend(self, gpio_pin): return "pwm_sysfs" if HAS_GPIOD: return "gpiod" - if HAS_GPIOZERO: - return "gpiozero" return "mock" def _pwm_sysfs_available(self, gpio_pin): @@ -323,14 +283,6 @@ def _cleanup_gpiod(self): self.gpiod_chip = None self.gpiod_api = None - def _angle_to_servo_value(self, angle): - """ - Convert angle (0-180) to servo value (-1 to 1). - For SG90 servo: -1 = 0°, 0 = 90°, 1 = 180° - """ - # Map 0-180 to -1 to 1 - return (angle / 90.0) - 1.0 - def _angle_to_pulse_us(self, angle): """Convert angle (0-180) to PWM pulse width in microseconds.""" angle = max(MIN_ANGLE, min(MAX_ANGLE, angle)) @@ -427,32 +379,7 @@ def _move_servo(self, gpio_pin, current_angle, target_angle, speed_ms): logger.error(f"Failed to move servo with libgpiod: {e}") raise RuntimeError(f"Failed to move servo with libgpiod: {e}") - try: - # Initialize servo if needed - self._initialize_servo(gpio_pin) - - # Calculate step direction - step = 1 if target_angle > current_angle else -1 - - # Move incrementally for smooth motion - for angle in range(int(current_angle), int(target_angle), step): - servo_value = self._angle_to_servo_value(angle) - self.servo.value = servo_value - logger.info(f"new Angle: {angle}° new Servo Value: {servo_value}") - time.sleep(speed_ms/1000) - - # Ensure we reach exact target - final_value = self._angle_to_servo_value(target_angle) - self.servo.value = final_value - - # Stop sending PWM signals without causing servo twitch - time.sleep(0.5) # Hold position briefly - self.servo.value = None - logger.info(f"Moved servo from {current_angle}° to {target_angle}°") - - except Exception as e: - logger.error(f"Failed to move servo: {e}") - raise RuntimeError(f"Failed to move servo: {e}") + logger.warning("No supported backend available; servo move skipped.") def _create_status_image(self, dimensions, gpio_pin, target_angle, orientation): """ @@ -530,9 +457,3 @@ def cleanup(self, settings): """Clean up servo resources when plugin instance is deleted.""" self._cleanup_gpiod() self._cleanup_pwm_sysfs() - if self.servo: - try: - self.servo.close() - logger.info("Closed servo connection") - except Exception as e: - logger.error(f"Error closing servo: {e}") From ad1efc92130fc5fbbdee9c535de3b3415047d49a Mon Sep 17 00:00:00 2001 From: Andreas Wolf Date: Sun, 1 Feb 2026 16:20:36 +0100 Subject: [PATCH 05/35] Servo Controll dependencies test --- src/plugins/servo_control/servo_control.py | 19 ++++++------------- src/plugins/servo_control/settings.html | 6 +++--- 2 files changed, 9 insertions(+), 16 deletions(-) diff --git a/src/plugins/servo_control/servo_control.py b/src/plugins/servo_control/servo_control.py index 6dfd15c09..338996913 100644 --- a/src/plugins/servo_control/servo_control.py +++ b/src/plugins/servo_control/servo_control.py @@ -19,7 +19,7 @@ HAS_PWM_SYSFS = os.path.isdir("/sys/class/pwm") HARDWARE_AVAILABLE = HAS_PWM_SYSFS or HAS_GPIOD -DEFAULT_GPIO_PIN = 13 +DEFAULT_GPIO_PIN = 18 DEFAULT_ANGLE = 90 DEFAULT_SPEED = 10 # milliseconds delay between steps MIN_ANGLE = 0 @@ -400,18 +400,11 @@ def _create_status_image(self, dimensions, gpio_pin, target_angle, orientation): image = Image.new('RGB', (width, height), color='white') draw = ImageDraw.Draw(image) - # Fonts - try: - title_font = get_font("Roboto-Bold", int(height * 0.08)) - large_font = get_font("Roboto-Bold", int(height * 0.15)) - medium_font = get_font("Roboto-Regular", int(height * 0.06)) - small_font = get_font("Roboto-Regular", int(height * 0.045)) - except: - # Fallback to default font - title_font = ImageFont.load_default() - large_font = ImageFont.load_default() - medium_font = ImageFont.load_default() - small_font = ImageFont.load_default() + title_font = ImageFont.load_default() + large_font = ImageFont.load_default() + medium_font = ImageFont.load_default() + small_font = ImageFont.load_default() + # Title title = "Servo Control" diff --git a/src/plugins/servo_control/settings.html b/src/plugins/servo_control/settings.html index 7fb1d8c8d..32cb8dafa 100644 --- a/src/plugins/servo_control/settings.html +++ b/src/plugins/servo_control/settings.html @@ -8,10 +8,10 @@ class="form-control" min="0" max="27" - value="13" + value="18" required /> - GPIO pin where the servo signal wire is connected (default: 13) + GPIO pin where the servo signal wire is connected (default: 18)
@@ -86,7 +86,7 @@ curl -X POST http://your-inkypi:8080/update_now \
  -F "plugin_id=servo_control" \
-   -F "gpio_pin=13" \
+   -F "gpio_pin=18" \
  -F "target_angle=90" \
  -F "servo_speed=10" \
  -F "orientation=landscape" From 147147af94924d08259257ba13ab95577d3d7b38 Mon Sep 17 00:00:00 2001 From: Andreas Wolf Date: Sun, 1 Feb 2026 16:42:15 +0100 Subject: [PATCH 06/35] Servo Controll extended control --- src/plugins/servo_control/servo_control.py | 30 +++++++++++++++++----- src/plugins/servo_control/settings.html | 9 ++++--- 2 files changed, 29 insertions(+), 10 deletions(-) diff --git a/src/plugins/servo_control/servo_control.py b/src/plugins/servo_control/servo_control.py index 338996913..d883c7175 100644 --- a/src/plugins/servo_control/servo_control.py +++ b/src/plugins/servo_control/servo_control.py @@ -19,14 +19,18 @@ 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 = 90 DEFAULT_SPEED = 10 # milliseconds delay between steps -MIN_ANGLE = 0 -MAX_ANGLE = 180 -DEFAULT_PWM_CHIP = "pwmchip0" -SERVO_MIN_PULSE_US = 1000 -SERVO_MAX_PULSE_US = 2000 + +SERVO_MIN_PULSE_US = 500 +SERVO_0_DEGREE_PULSE_US = 1000 +SERVO_180_DEGREE_PULSE_US = 2000 +SERVO_MAX_PULSE_US = 2500 +SERVO_US_PER_DEGREE = (SERVO_180_DEGREE_PULSE_US - SERVO_0_DEGREE_PULSE_US) / 180.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 ServoControl(BasePlugin): @@ -286,8 +290,8 @@ def _cleanup_gpiod(self): def _angle_to_pulse_us(self, angle): """Convert angle (0-180) to PWM pulse width in microseconds.""" angle = max(MIN_ANGLE, min(MAX_ANGLE, angle)) - span = SERVO_MAX_PULSE_US - SERVO_MIN_PULSE_US - return int(SERVO_MIN_PULSE_US + (angle / 180.0) * span) + 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): """Set PWM duty cycle via sysfs in nanoseconds.""" @@ -298,6 +302,15 @@ def _pwm_sysfs_set_pulse_us(self, pulse_us): with open(duty_path, "w", encoding="utf-8") as f: f.write(str(duty_ns)) + def _pwm_sysfs_disable(self): + """Disable PWM output without unexporting the channel.""" + 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): """Set GPIO line value using libgpiod.""" if self.gpiod_api == "v2" and self.gpiod_request: @@ -350,6 +363,8 @@ def _move_servo(self, gpio_pin, current_angle, target_angle, speed_ms): final_pulse_us = self._angle_to_pulse_us(target_angle) self._pwm_sysfs_set_pulse_us(final_pulse_us) + time.sleep(0.2) + self._pwm_sysfs_disable() logger.info(f"Moved servo from {current_angle}° to {target_angle}° (kernel PWM)") return except Exception as e: @@ -373,6 +388,7 @@ def _move_servo(self, gpio_pin, current_angle, target_angle, speed_ms): # Ensure we reach exact target 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 except Exception as e: diff --git a/src/plugins/servo_control/settings.html b/src/plugins/servo_control/settings.html index 32cb8dafa..8ae1cfe9d 100644 --- a/src/plugins/servo_control/settings.html +++ b/src/plugins/servo_control/settings.html @@ -21,12 +21,14 @@ id="target-angle" name="target_angle" class="form-range" - min="0" - max="180" + min="-90" + max="270" value="90" step="1" /> - Drag the slider to set target servo angle (0° - 180°) + + Drag the slider to set target servo angle (-90° - 270°) +
@@ -105,6 +107,7 @@
diff --git a/src/utils/servo_utils.py b/src/utils/servo_utils.py index 1bb314d16..5b20e228f 100644 --- a/src/utils/servo_utils.py +++ b/src/utils/servo_utils.py @@ -20,7 +20,7 @@ DEFAULT_PWM_CHIP = "pwmchip0" DEFAULT_GPIO_PIN = 18 DEFAULT_ANGLE = 90 -DEFAULT_SPEED = 10 # milliseconds delay between steps +DEFAULT_SPEED = 30 # milliseconds delay between steps SERVO_MIN_PULSE_US = 500 SERVO_0_DEGREE_PULSE_US = 1000 From a500ce93bf555afe128be51d927e7017aab008ba Mon Sep 17 00:00:00 2001 From: Andreas Wolf Date: Sun, 1 Feb 2026 21:18:22 +0100 Subject: [PATCH 12/35] Servo Control move image invertition --- src/plugins/servo_control/servo_control.py | 94 +++++++++++----------- 1 file changed, 48 insertions(+), 46 deletions(-) diff --git a/src/plugins/servo_control/servo_control.py b/src/plugins/servo_control/servo_control.py index c8752dbbb..7242efd99 100644 --- a/src/plugins/servo_control/servo_control.py +++ b/src/plugins/servo_control/servo_control.py @@ -96,55 +96,57 @@ def _create_status_image(self, dimensions, gpio_pin, target_angle, orientation): PIL.Image: Status image """ width, height = dimensions - - # Create white background - image = Image.new('RGB', (width, height), color='white') + image = Image.new('RGB', (width, height), color='black') draw = ImageDraw.Draw(image) - - title_font = ImageFont.load_default() - large_font = ImageFont.load_default() - medium_font = ImageFont.load_default() - small_font = ImageFont.load_default() - - # Title - title = "Servo Control" - draw.text((width // 2, height * 0.1), title, font=title_font, fill='black', anchor='mm') - - # Target angle - large display + # High-contrast palette + sky_color = '#1e90ff' + ground_color = '#f39c12' + horizon_color = '#ffffff' + text_color = '#ffffff' + + # Map target angle (0-180) to horizon tilt (-45 to 45) + normalized = (target_angle - 90) / 90.0 + tilt_deg = max(-45, min(45, normalized * 45)) + tilt_rad = (tilt_deg * 3.141592653589793) / 180.0 + + # Horizon line parameters + cx, cy = width / 2, height / 2 + line_len = max(width, height) * 1.5 + dx = (line_len / 2) * (1.0 if tilt_deg == 0 else (abs(__import__('math').cos(tilt_rad)))) + dy = (line_len / 2) * (1.0 if tilt_deg == 0 else (abs(__import__('math').sin(tilt_rad)))) + + # Compute line endpoints using rotation matrix + cos_t = __import__('math').cos(tilt_rad) + sin_t = __import__('math').sin(tilt_rad) + x1 = cx - (line_len / 2) * cos_t + y1 = cy - (line_len / 2) * sin_t + x2 = cx + (line_len / 2) * cos_t + y2 = cy + (line_len / 2) * sin_t + + # Fill sky/ground polygons + draw.polygon([(0, 0), (width, 0), (x2, y2), (x1, y1)], fill=sky_color) + draw.polygon([(0, height), (width, height), (x2, y2), (x1, y1)], fill=ground_color) + + # Horizon line (thick) + line_width = max(2, int(min(width, height) * 0.04)) + draw.line([(x1, y1), (x2, y2)], fill=horizon_color, width=line_width) + + # Center marker + marker_size = max(4, int(min(width, height) * 0.06)) + draw.rectangle( + [ + (cx - marker_size, cy - line_width), + (cx + marker_size, cy + line_width) + ], + fill=horizon_color + ) + + # Angle text overlay + font = ImageFont.load_default() angle_text = f"{target_angle}°" - draw.text((width // 2, height * 0.3), angle_text, font=large_font, fill='#2c3e50', anchor='mm') - - # GPIO pin info - gpio_text = f"GPIO Pin: {gpio_pin}" - draw.text((width // 2, height * 0.45), gpio_text, font=medium_font, fill='#7f8c8d', anchor='mm') - - # Draw a simple arc to visualize angle - arc_y = height * 0.6 - arc_radius = min(width, height) * 0.15 - arc_bbox = [ - width // 2 - arc_radius, - arc_y - arc_radius, - width // 2 + arc_radius, - arc_y + arc_radius - ] - - # Background arc (0-180) - draw.arc(arc_bbox, start=0, end=180, fill='#ecf0f1', width=int(height * 0.02)) - - # Target position arc - draw.arc(arc_bbox, start=0, end=target_angle, fill='#3498db', width=int(height * 0.02)) - - # Orientation info - if orientation in ['landscape', 'portrait']: - y_offset = height * 0.78 - draw.text((width // 2, y_offset), "Orientation:", font=medium_font, fill='black', anchor='mm') - - y_offset += height * 0.06 - orientation_text = orientation.capitalize() - orientation_color = '#e74c3c' - draw.text((width // 2, y_offset), orientation_text, font=small_font, fill=orientation_color, anchor='mm') - + draw.text((width * 0.06, height * 0.06), angle_text, font=font, fill=text_color) + return image def cleanup(self, settings): From 22613ef0f228ea5f5fef5706a399922e80c6e0f3 Mon Sep 17 00:00:00 2001 From: Andreas Wolf Date: Sun, 1 Feb 2026 21:27:14 +0100 Subject: [PATCH 13/35] Servo Control Test Image --- src/plugins/servo_control/servo_control.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/plugins/servo_control/servo_control.py b/src/plugins/servo_control/servo_control.py index 7242efd99..66df063f4 100644 --- a/src/plugins/servo_control/servo_control.py +++ b/src/plugins/servo_control/servo_control.py @@ -100,14 +100,14 @@ def _create_status_image(self, dimensions, gpio_pin, target_angle, orientation): draw = ImageDraw.Draw(image) # High-contrast palette - sky_color = '#1e90ff' - ground_color = '#f39c12' - horizon_color = '#ffffff' + sky_color = '#00CFEB' + ground_color = '#EACE00' + horizon_color = '#EB0078' text_color = '#ffffff' - # Map target angle (0-180) to horizon tilt (-45 to 45) + # Map target angle (0-180) to horizon tilt (0 to 90 degrees) normalized = (target_angle - 90) / 90.0 - tilt_deg = max(-45, min(45, normalized * 45)) + tilt_deg = max(0, min(90, normalized * 90)) tilt_rad = (tilt_deg * 3.141592653589793) / 180.0 # Horizon line parameters From 33bf9908db3e6ce0488bb79d11eea0b0637764e5 Mon Sep 17 00:00:00 2001 From: Andreas Wolf Date: Tue, 3 Feb 2026 18:51:17 +0100 Subject: [PATCH 14/35] InkyPi Listen also on ipv6 --- src/inkypi.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/inkypi.py b/src/inkypi.py index 5dc4de57b..1dedb7718 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, host="::", port=PORT, threads=1) finally: refresh_task.stop() From 03d99304bc10ef4da805ee9d458a3fada9a0c194 Mon Sep 17 00:00:00 2001 From: Andreas Wolf Date: Fri, 6 Feb 2026 16:07:29 +0100 Subject: [PATCH 15/35] Skip move when Angle reached --- src/utils/servo_utils.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/utils/servo_utils.py b/src/utils/servo_utils.py index 5b20e228f..04ee98412 100644 --- a/src/utils/servo_utils.py +++ b/src/utils/servo_utils.py @@ -80,6 +80,9 @@ def move(self, current_angle, target_angle, speed_ms): 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( From eb51e3ae5bd2ae98c976c00596286892d894de22 Mon Sep 17 00:00:00 2001 From: Andreas Wolf Date: Fri, 6 Feb 2026 16:11:53 +0100 Subject: [PATCH 16/35] invert setting --- src/plugins/servo_control/servo_control.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/plugins/servo_control/servo_control.py b/src/plugins/servo_control/servo_control.py index 66df063f4..d9e456f16 100644 --- a/src/plugins/servo_control/servo_control.py +++ b/src/plugins/servo_control/servo_control.py @@ -58,10 +58,10 @@ def generate_image(self, settings, device_config): # if 'current', do not change orientation # Update image inversion if specified - if invert_setting is not None: - 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}") + 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") From cca3aed65eec62ea73270717f53bb792040733a6 Mon Sep 17 00:00:00 2001 From: Andreas Wolf Date: Fri, 6 Feb 2026 16:16:39 +0100 Subject: [PATCH 17/35] invert setting --- src/display/display_manager.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/display/display_manager.py b/src/display/display_manager.py index 71d9459f3..7ba62b4ac 100644 --- a/src/display/display_manager.py +++ b/src/display/display_manager.py @@ -77,7 +77,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. From f701395a6143d084e1730b460426d09f1f20020a Mon Sep 17 00:00:00 2001 From: Andreas Wolf Date: Fri, 6 Feb 2026 16:19:45 +0100 Subject: [PATCH 18/35] testimage text color --- src/plugins/servo_control/servo_control.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/plugins/servo_control/servo_control.py b/src/plugins/servo_control/servo_control.py index d9e456f16..66ba327bb 100644 --- a/src/plugins/servo_control/servo_control.py +++ b/src/plugins/servo_control/servo_control.py @@ -103,7 +103,7 @@ def _create_status_image(self, dimensions, gpio_pin, target_angle, orientation): sky_color = '#00CFEB' ground_color = '#EACE00' horizon_color = '#EB0078' - text_color = '#ffffff' + text_color = '#EBCFEB' # Map target angle (0-180) to horizon tilt (0 to 90 degrees) normalized = (target_angle - 90) / 90.0 From a69eaec7bae6a0a4af26a87bcc80adfc3a8671e7 Mon Sep 17 00:00:00 2001 From: Andreas Wolf Date: Fri, 6 Feb 2026 16:31:24 +0100 Subject: [PATCH 19/35] test image handling --- src/display/display_manager.py | 5 + src/plugins/servo_control/servo_control.py | 136 ++++++++++++++------- src/plugins/servo_control/settings.html | 11 ++ 3 files changed, 110 insertions(+), 42 deletions(-) diff --git a/src/display/display_manager.py b/src/display/display_manager.py index 7ba62b4ac..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}") diff --git a/src/plugins/servo_control/servo_control.py b/src/plugins/servo_control/servo_control.py index 66ba327bb..0e199de8a 100644 --- a/src/plugins/servo_control/servo_control.py +++ b/src/plugins/servo_control/servo_control.py @@ -77,75 +77,127 @@ def generate_image(self, settings, device_config): if device_config.get_config("orientation") == "vertical": dimensions = dimensions[::-1] - # Create status image - image = self._create_status_image(dimensions, gpio_pin, target_angle, orientation) + # 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 + image = self._create_status_image(dimensions, gpio_pin, target_angle, orientation) return image def _create_status_image(self, dimensions, gpio_pin, target_angle, orientation): """ - Create a status image showing servo state. + 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 - current_angle: Current servo angle - positions: Dictionary of saved positions + target_angle: Target servo angle in degrees + orientation: Display orientation setting Returns: - PIL.Image: Status image + 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 palette - sky_color = '#00CFEB' - ground_color = '#EACE00' - horizon_color = '#EB0078' - text_color = '#EBCFEB' + # 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 = '#EBCFEB' # Light pink for text - # Map target angle (0-180) to horizon tilt (0 to 90 degrees) - normalized = (target_angle - 90) / 90.0 - tilt_deg = max(0, min(90, normalized * 90)) - tilt_rad = (tilt_deg * 3.141592653589793) / 180.0 + # 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) - # Horizon line parameters + # Center point of the image cx, cy = width / 2, height / 2 - line_len = max(width, height) * 1.5 - dx = (line_len / 2) * (1.0 if tilt_deg == 0 else (abs(__import__('math').cos(tilt_rad)))) - dy = (line_len / 2) * (1.0 if tilt_deg == 0 else (abs(__import__('math').sin(tilt_rad)))) + + # Length of the horizon line (diagonal across the image) + line_len = math.sqrt(width**2 + height**2) * 1.2 - # Compute line endpoints using rotation matrix - cos_t = __import__('math').cos(tilt_rad) - sin_t = __import__('math').sin(tilt_rad) - x1 = cx - (line_len / 2) * cos_t - y1 = cy - (line_len / 2) * sin_t - x2 = cx + (line_len / 2) * cos_t - y2 = cy + (line_len / 2) * sin_t + # 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 - # Fill sky/ground polygons - draw.polygon([(0, 0), (width, 0), (x2, y2), (x1, y1)], fill=sky_color) - draw.polygon([(0, height), (width, height), (x2, y2), (x1, y1)], fill=ground_color) + # 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)] - # Horizon line (thick) - line_width = max(2, int(min(width, height) * 0.04)) + # 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) - # Center marker - marker_size = max(4, int(min(width, height) * 0.06)) - draw.rectangle( - [ - (cx - marker_size, cy - line_width), - (cx + marker_size, cy + line_width) - ], - fill=horizon_color + # 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) ) - # Angle text overlay + # Draw angle information font = ImageFont.load_default() - angle_text = f"{target_angle}°" - draw.text((width * 0.06, height * 0.06), angle_text, font=font, fill=text_color) + 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 diff --git a/src/plugins/servo_control/settings.html b/src/plugins/servo_control/settings.html index e878a17be..145ec8d90 100644 --- a/src/plugins/servo_control/settings.html +++ b/src/plugins/servo_control/settings.html @@ -66,6 +66,12 @@ Invert the display output. +
+ + + Display a virtual horizon test image that appears level when the screen is physically rotated to match the servo angle. +
+ From 84c53f64fdd302b589bb4ef98d07eefa60d39721 Mon Sep 17 00:00:00 2001 From: Andreas Wolf Date: Fri, 6 Feb 2026 16:46:46 +0100 Subject: [PATCH 20/35] Angle Config changed --- src/plugins/servo_control/servo_control.py | 2 +- src/plugins/servo_control/settings.html | 10 +++++----- src/utils/servo_utils.py | 6 +++--- 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/src/plugins/servo_control/servo_control.py b/src/plugins/servo_control/servo_control.py index 0e199de8a..29ef49ad5 100644 --- a/src/plugins/servo_control/servo_control.py +++ b/src/plugins/servo_control/servo_control.py @@ -118,7 +118,7 @@ def _create_status_image(self, dimensions, gpio_pin, target_angle, orientation): sky_color = '#00CFEB' # Cyan for sky ground_color = '#EACE00' # Yellow for ground horizon_color = '#EB0078' # Magenta for horizon line - text_color = '#EBCFEB' # Light pink for text + 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 diff --git a/src/plugins/servo_control/settings.html b/src/plugins/servo_control/settings.html index 145ec8d90..565b1f064 100644 --- a/src/plugins/servo_control/settings.html +++ b/src/plugins/servo_control/settings.html @@ -20,14 +20,14 @@ type="number" id="target-angle" name="target_angle" - min="-90" - max="270" - value="90" + min="-45" + max="135" + value="45" step="1" /> - 90° + 45° - Drag the slider to set target servo angle (-90° - 270°) + Drag the slider to set target servo angle (-45° - 135°) diff --git a/src/utils/servo_utils.py b/src/utils/servo_utils.py index 04ee98412..693fdff6a 100644 --- a/src/utils/servo_utils.py +++ b/src/utils/servo_utils.py @@ -19,14 +19,14 @@ DEFAULT_PWM_CHIP = "pwmchip0" DEFAULT_GPIO_PIN = 18 -DEFAULT_ANGLE = 90 +DEFAULT_ANGLE = 45 DEFAULT_SPEED = 30 # milliseconds delay between steps SERVO_MIN_PULSE_US = 500 SERVO_0_DEGREE_PULSE_US = 1000 -SERVO_180_DEGREE_PULSE_US = 2000 +SERVO_90_DEGREE_PULSE_US = 2000 SERVO_MAX_PULSE_US = 2500 -SERVO_US_PER_DEGREE = (SERVO_180_DEGREE_PULSE_US - SERVO_0_DEGREE_PULSE_US) / 180.0 +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 From 2e68b579cb8eb60ffca112016ffdf847046e5d34 Mon Sep 17 00:00:00 2001 From: Andreas Wolf Date: Fri, 6 Feb 2026 17:09:30 +0100 Subject: [PATCH 21/35] Angle Config changed --- src/plugins/servo_control/settings.html | 2 +- src/refresh_task.py | 3 +++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/src/plugins/servo_control/settings.html b/src/plugins/servo_control/settings.html index 565b1f064..370f7a8ed 100644 --- a/src/plugins/servo_control/settings.html +++ b/src/plugins/servo_control/settings.html @@ -38,7 +38,7 @@ id="servo-speed" name="servo_speed" min="0" - value="30" + value="25" /> Delay between angle steps in milliseconds (lower = faster) diff --git a/src/refresh_task.py b/src/refresh_task.py index f554e2adb..6e9376548 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 '{plugin.name}' did not return an image.") + continue image_hash = compute_image_hash(image) refresh_info = refresh_action.get_refresh_info() From 66451d00130a59ebc991ab53c117c178f890d9f7 Mon Sep 17 00:00:00 2001 From: Andreas Wolf Date: Fri, 6 Feb 2026 17:13:02 +0100 Subject: [PATCH 22/35] Angle Config changed --- src/plugins/servo_control/servo_control.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/plugins/servo_control/servo_control.py b/src/plugins/servo_control/servo_control.py index 29ef49ad5..ce2f39698 100644 --- a/src/plugins/servo_control/servo_control.py +++ b/src/plugins/servo_control/servo_control.py @@ -82,9 +82,10 @@ def generate_image(self, settings, device_config): if show_test_image: # Create status image with virtual horizon - image = self._create_status_image(dimensions, gpio_pin, target_angle, orientation) - - return image + 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): """ From 6f9828f4a1f931cb05331c621954d3add75125fd Mon Sep 17 00:00:00 2001 From: Andreas Wolf Date: Fri, 6 Feb 2026 18:52:28 +0100 Subject: [PATCH 23/35] Angle Config changed --- src/plugins/servo_control/settings.html | 2 +- src/refresh_task.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/plugins/servo_control/settings.html b/src/plugins/servo_control/settings.html index 370f7a8ed..780823b2a 100644 --- a/src/plugins/servo_control/settings.html +++ b/src/plugins/servo_control/settings.html @@ -38,7 +38,7 @@ id="servo-speed" name="servo_speed" min="0" - value="25" + value="35" /> Delay between angle steps in milliseconds (lower = faster) diff --git a/src/refresh_task.py b/src/refresh_task.py index 6e9376548..74fd9eefa 100644 --- a/src/refresh_task.py +++ b/src/refresh_task.py @@ -113,7 +113,7 @@ def _run(self): plugin = get_plugin_instance(plugin_config) image = refresh_action.execute(plugin, self.device_config, current_dt) if image is None: - logger.info(f"Plugin '{plugin.name}' did not return an image.") + logger.info(f"Plugin '{refresh_action.get_plugin_id()}' did not return an image.") continue image_hash = compute_image_hash(image) @@ -186,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 From 79d6f0e9decb94c1f937bea1086d7a16fbe720d0 Mon Sep 17 00:00:00 2001 From: Andreas Wolf Date: Sun, 8 Feb 2026 13:12:46 +0100 Subject: [PATCH 24/35] Install Setup --- README.md | 21 +- install/config_base/device.json | 3 +- install/install.sh | 63 +++++- install/servo-requirements.txt | 4 + src/config.py | 11 +- src/plugins/servo_control/README.md | 43 ++-- src/plugins/servo_control/settings.html | 259 ++++++++++++++++++------ 7 files changed, 290 insertions(+), 114 deletions(-) create mode 100644 install/servo-requirements.txt 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/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 8e60481eb..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 ;; @@ -141,6 +153,11 @@ enable_interfaces(){ } 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" @@ -241,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() { @@ -276,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" @@ -295,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 } @@ -388,7 +426,10 @@ fi enable_interfaces enable_pwm_overlay install_debian_dependencies -install_servo_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" @@ -402,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/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/plugins/servo_control/README.md b/src/plugins/servo_control/README.md index f0d68433c..eeeea253d 100644 --- a/src/plugins/servo_control/README.md +++ b/src/plugins/servo_control/README.md @@ -1,35 +1,22 @@ # Servo Control Plugin -This Plugin provides controll of a Servo motor connected to your Raspberry Pi via a configurable GPIO pin. -You can set the Target Angle and optional the orientation saved in device_config. +This Plugin provides controll 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 Invertion 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.). -** This Plugin will not update the Screen ** +## Rotating Image Frame Example -## Settings Page Configuration +https://www.thingiverse.com/thing:7290592 -### Controllable Settings +### 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 Assambly 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) -- **GPIO Pin**: The GPIO pin number where the Servo signal wire is connected. -- **TargetAngle**: A Slider to manually set the Servo angle between 0° and 180°. -- **ServoSpeed**: A Slider to adjust the speed of the Servo movement (delay between angle steps in milliseconds). -- **Orientation**: An optional setting to define the orientation of the Servo. (landscape | portrait | current). This will update and persist the device_config orientation setting when changed. - -## Usage - -Include the Servo Control Plugin in your Playlists. -** Tipp: ** -You can do a POST Request to the InkyPi to set the TargetAngle configured in a Playlist with the following command. -This way you can store predifined Positions in different Playlists and switch between them via API. - -``` -curl -X POST "http:///api/plugin/servo_control" -H "Content-Type: application/json" -d '{"playlist_name": "YOUR_PLAYLIST_NAME", "plugin_instance": "YOUR_PLUGIN_INSTANCE_NAME", "plugin_id": "servo_control"}' -``` - -## Servo Control -This Plugin currently is optimised for a SG90 Micro Servo. -The Servo is controlled via PWM signals on the specified GPIO pin. -The angle is set by adjusting the duty cycle of the PWM signal. -When moving to a new angle, the Plugin will incrementally adjust the angle in small steps to ensure smooth movement. -The speed of the movement can be adjusted via the ServoSpeed setting. -The current Angle is always stored in the device_config under "current_servo_angle" and used after bootup as the current starting Angle. diff --git a/src/plugins/servo_control/settings.html b/src/plugins/servo_control/settings.html index 780823b2a..32019371c 100644 --- a/src/plugins/servo_control/settings.html +++ b/src/plugins/servo_control/settings.html @@ -1,89 +1,211 @@ -
- - - 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°) - -
+ +
+ 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. + +
-
- - - Delay between angle steps in milliseconds (lower = faster) -
+
+ + + Invert the display output. +
+
-
- - - - Update device orientation when servo moves. - "Keep Current" will not change the display orientation. - -
+ +
+ Display Options + +
+ + + Display a virtual horizon test image that appears level when the screen is physically rotated to match the servo angle. +
+
-
- - - Invert the display output. -
+ +
+ API Example +
+

Example POST request to update servo control settings:

+
curl -X POST http://localhost:5000/api/plugin/servo_control/update \
+  -H "Content-Type: application/json" \
+  -d '{
+  "gpio_pin": 18,
+  "target_angle": 45,
+  "servo_speed": 35,
+  "orientation": "current",
+  "inverted_image": false,
+  "show_test_image": false
+}'
+
+
-
- - - Display a virtual horizon test image that appears level when the screen is physically rotated to match the servo angle. -
+ From 5980ca819b7e5626be5dbf2bfd69815fe6b06b1b Mon Sep 17 00:00:00 2001 From: Andreas Wolf Date: Sun, 8 Feb 2026 13:55:38 +0100 Subject: [PATCH 25/35] Update Settings --- src/plugins/servo_control/icon.png | Bin 18063 -> 15089 bytes src/plugins/servo_control/settings.html | 74 +++--------------------- 2 files changed, 7 insertions(+), 67 deletions(-) diff --git a/src/plugins/servo_control/icon.png b/src/plugins/servo_control/icon.png index 563a396ef5d4a022f1f33b650448903696ed4596..0db265951976819eb4520b5437f4845aafac1d45 100644 GIT binary patch literal 15089 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 literal 18063 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 diff --git a/src/plugins/servo_control/settings.html b/src/plugins/servo_control/settings.html index 32019371c..c97106d24 100644 --- a/src/plugins/servo_control/settings.html +++ b/src/plugins/servo_control/settings.html @@ -1,7 +1,7 @@ -
+
Servo Control
@@ -48,7 +48,7 @@
-
+
Device Config
@@ -76,7 +76,7 @@
-
+
Display Options
@@ -91,9 +91,10 @@ API Example

Example POST request to update servo control settings:

-
curl -X POST http://localhost:5000/api/plugin/servo_control/update \
+        
curl -X POST http://inkypi.local/update_now \
   -H "Content-Type: application/json" \
   -d '{
+  "plugin_id": "servo_control",
   "gpio_pin": 18,
   "target_angle": 45,
   "servo_speed": 35,
@@ -104,73 +105,12 @@
     
- -