Servo Control Feature (Rotating Frame)#598
Conversation
There was a problem hiding this comment.
Pull request overview
Adds first-party servo motor support to InkyPi (for rotating frames / orientation control), along with a new servo_control plugin, install-time opt-in dependencies, and a few core-system robustness updates to support control-only plugins.
Changes:
- Introduces a new
servo_controlplugin andServoDriverutility (kernel PWM sysfs / libgpiod backends). - Adds
servo_enabledconfig flag and installer-Soption to opt-in servo dependencies and PWM overlay. - Improves rendering pipeline robustness (allow plugins to return
None; saferinverted_imagecoercion) and broadens generic UI element styling.
Reviewed changes
Copilot reviewed 16 out of 19 changed files in this pull request and generated 13 comments.
Show a summary per file
| File | Description |
|---|---|
| src/utils/servo_utils.py | New servo hardware driver (sysfs PWM / libgpiod) and movement logic. |
| src/static/styles/main.css | Adds more generic element styling and consolidates text color behavior. |
| src/static/images/current_image.png | Adds a committed “current image” asset (likely runtime output path). |
| src/refresh_task.py | Allows plugins to return None to skip display updates. |
| src/plugins/servo_control/settings.html | New settings UI for servo control + device-config side effects + API example. |
| src/plugins/servo_control/servo_control.py | New plugin implementing servo movement, optional test image, and device config updates. |
| src/plugins/servo_control/plugin-info.json | Registers the new plugin. |
| src/plugins/servo_control/icon.png | Adds plugin icon asset. |
| src/plugins/servo_control/README.md | Documents the plugin and a rotating-frame build example. |
| src/inkypi.py | Changes Waitress binding configuration. |
| src/display/display_manager.py | Skips rendering on None and makes inverted_image parsing more robust. |
| src/config/device_dev.json | Adds servo-related fields and updates dev config values/state. |
| src/config.py | Filters servo_control based on servo_enabled. |
| install/servo-requirements.txt | Adds optional Python requirements for servo support. |
| install/requirements.txt | Minor formatting/line adjustment. |
| install/install.sh | Adds -S flag, PWM overlay enablement, and servo dependency installation. |
| install/config_base/device.json | Adds servo_enabled: false default to base config. |
| docs/community.md | Adds a community link for a rotatable stand model. |
| README.md | Documents installer -S flag and provides examples. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| 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} | ||
|
|
There was a problem hiding this comment.
The servo_enabled filtering only applies to get_plugins() (UI ordering and load_plugins()), but get_plugin() still returns servo_control even when disabled. This can lead to runtime errors if a playlist or API call references servo_control while servo_enabled is false (plugin config exists, but it was never registered/loaded). Consider enforcing the same flag in get_plugin() and/or preventing refresh actions from selecting disabled plugins (e.g., filtering playlist plugin instances on load).
| # 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}") |
There was a problem hiding this comment.
inverted_image is always overwritten in device config even when the checkbox is unchecked and therefore absent from the submitted form data (it becomes None and gets coerced to False). This makes it impossible to leave the existing device setting unchanged from this plugin. Only update device_config.inverted_image when the setting is explicitly present, or add a "keep current" option / hidden field pattern so false is explicit.
| # 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}") | |
| # Update image inversion only if explicitly specified in settings | |
| if 'inverted_image' in settings: | |
| 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}") |
| @@ -0,0 +1,212 @@ | |||
| import logging | |||
| from display.mock_display import MockDisplay | |||
There was a problem hiding this comment.
MockDisplay is imported but never used in this module. Remove the unused import to avoid lint/runtime overhead and keep dependencies clear.
| from display.mock_display import MockDisplay |
src/config/device_dev.json
Outdated
| "refresh_time": "2026-02-14T12:22:33.752604+00:00", | ||
| "image_hash": "bd2481643abcf6ae451ee72e1f33f704367d4d21b7f60b1273dd14e57061a6c4", | ||
| "refresh_type": "Manual Update", | ||
| "plugin_id": "servo_control" | ||
| }, | ||
| "inverted_image": true, | ||
| "current_servo_angle": 45 |
There was a problem hiding this comment.
device_dev.json now contains runtime state (refresh timestamps/hash, inverted_image: true, current_servo_angle). This makes dev mode start from a non-default state and creates noisy diffs over time. Consider keeping refresh_info fields null and omitting/zeroing transient fields so the file remains a stable baseline config.
| "refresh_time": "2026-02-14T12:22:33.752604+00:00", | |
| "image_hash": "bd2481643abcf6ae451ee72e1f33f704367d4d21b7f60b1273dd14e57061a6c4", | |
| "refresh_type": "Manual Update", | |
| "plugin_id": "servo_control" | |
| }, | |
| "inverted_image": true, | |
| "current_servo_angle": 45 | |
| "refresh_time": null, | |
| "image_hash": null, | |
| "refresh_type": null, | |
| "plugin_id": null | |
| }, | |
| "inverted_image": false, | |
| "current_servo_angle": 0 |
src/plugins/servo_control/README.md
Outdated
| - 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) |
There was a problem hiding this comment.
Spelling: "Assambly" → "Assembly".
| - Rotating Frame Assambly 3D Print e.g. from Thingiverse (https://www.thingiverse.com/thing:7290592) | |
| - Rotating Frame Assembly 3D Print e.g. from Thingiverse (https://www.thingiverse.com/thing:7290592) |
| // Update speed display | ||
| document.getElementById('servo-speed').addEventListener('input', function() { | ||
| document.getElementById('speed-display').textContent = this.value + 'ms'; | ||
| updateApiExample(); | ||
| }); |
There was a problem hiding this comment.
The script updates #speed-display on servo speed changes, but no element with id="speed-display" exists in the template. This will throw a JS error and can break the settings page. Either add the missing element (e.g., a span next to the input) or remove/update the JS references.
src/utils/servo_utils.py
Outdated
| self.current_gpio_pin = None | ||
| self._cleanup_gpiod() | ||
| self._cleanup_pwm_sysfs() |
There was a problem hiding this comment.
configure() sets self.current_gpio_pin = None before calling _cleanup_pwm_sysfs(). _cleanup_pwm_sysfs() uses current_gpio_pin to determine which PWM channel to unexport, so this can leave the previously-exported PWM channel active when switching GPIO pins. Capture the previous pin (or call cleanup before clearing current_gpio_pin) so cleanup can unexport correctly.
| self.current_gpio_pin = None | |
| self._cleanup_gpiod() | |
| self._cleanup_pwm_sysfs() | |
| self._cleanup_gpiod() | |
| self._cleanup_pwm_sysfs() | |
| self.current_gpio_pin = None |
| 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) |
There was a problem hiding this comment.
Per-degree movement logs are emitted at INFO level inside the movement loop. For large angle changes this can spam logs and slow down movement on slower storage/SD cards. Consider demoting these to DEBUG or logging only the start/end (and maybe every N steps).
src/plugins/servo_control/README.md
Outdated
| @@ -0,0 +1,22 @@ | |||
| # Servo Control Plugin | |||
|
|
|||
| This Plugin provides controll of a Servo motor connected to your Raspberry Pi via a configurable GPIO pin. | |||
There was a problem hiding this comment.
Spelling: "controll" → "control".
| This Plugin provides controll of a Servo motor connected to your Raspberry Pi via a configurable GPIO pin. | |
| This Plugin provides control of a Servo motor connected to your Raspberry Pi via a configurable GPIO pin. |
src/plugins/servo_control/README.md
Outdated
| # 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 and Image Invertion saved in device_config. |
There was a problem hiding this comment.
Spelling: "Invertion" → "Inversion".
| You can set the Target Angle and optional the orientation and Image Invertion saved in device_config. | |
| You can set the Target Angle and optional the orientation and Image Inversion saved in device_config. |
Description
The Servo Control plugin & utils adds physical servo motor control to InkyPi, enabling dynamic rotation of e.g. a display frame to match image orientation (e.g. https://www.thingiverse.com/thing:7290592). This is particularly useful for creating rotating picture frames that automatically adjust between landscape and portrait modes. (Could also be used to control other features)
💬: First I wanted to make a separate third Party Plugin but since it needs Dependencies and integration at the Install Steps I choose to do it directly in the Repo
Core System Enhancements:
Possible Future Enhancments
Auto rotate in other Plugins.
We could store the Default Values for Portrait/Landscape, then if you e.g. upload an Image, you could detect which AspectRatio the Image has and auto rotate the Frame.