Technical documentation for developers and contributors.
- System Overview
- Architecture Diagram
- Core Components
- Trigger System
- Execution Flow
- Module Reference
- Extension Possibilities
- Known Limitations
- Performance Considerations
- Error Handling
- Debugging
- Contributing Guidelines
- License
Pico Commander is a trigger-pipeline sequencer that translates physical inputs (encoder, sensors, button) into USB HID keyboard commands and GPIO control signals. The system operates as a USB HID device without requiring drivers on the host system.
- Input → Pipeline → Output Model — Hardware inputs trigger pipelines; pipelines route steps to appropriate outputs (HID, GPIO)
- Typed I/O Modules — Extensible input handlers (Hall, power monitor) and output handlers (HID keyboard, GPIO)
- Unified Pipeline Engine — Both menu triggers and sensor triggers use the same execution engine
- Priority System — Emergency triggers bypass cooldown and queues
- State Persistence — Menu position and pipeline states survive reboots
- Automation Skeleton — Not a framework you configure around; a small runtime with swappable I/O modules
- Single Responsibility — Each input/output type is an isolated handler class
- Fail-Safe — Hardware errors don't crash the system
- Deterministic — Same inputs produce same outputs every time
- Extensible — New input/output types integrate via simple contracts (see inputs.md and outputs.md)
┌─────────────────────────────────────────────────────────────────┐
│ HARDWARE LAYER │
├─────────────────────────────────────────────────────────────────┤
│ Rotary Encoder │ Hall Sensors │ Button │ OLED Display │
│ (GP6,7,8) │ (GP15,16) │ (GP24) │ (GP4,5 I2C) │
│ │ INA226 Power │ │ │
│ │ Monitor (I2C) │ │ │
└────────┬─────────┴────────┬────────┴─────┬────┴─────────┬───────┘
│ │ │ │
▼ ▼ ▼ ▼
┌────────────────┐ ┌───────────────────────────┐ ┌──────────┐
│ encoder.py │ │ inputs_manager.py │ │ display │
│ • Rotation │ │ • HallSensorInput │ │ manager │
│ • Click │ │ • PowerMonitorInput │ └────┬─────┘
│ • Long press │ │ • Button dbl-clk │ │
└───────┬────────┘ └──────┬────────────────────┘ │
│ │ │
│ │ ▼
│ │ ┌──────────────────┐
│ │ │ config.py │
│ │ │ • JSON parse │
│ │ │ • State mgmt │
│ │ │ • inputs{} │
│ │ │ • outputs{} │
│ │ └──────┬───────────┘
│ │ │
└──────────────────┼─────────────────────────┘
│
▼
┌────────────────────────┐
│ trigger_bus.py │
│ │
│ Pipeline Engine: │
│ ┌────────────────────┐ │
│ │ execute_pipeline() │ │
│ │ • loop: true/false │ │
│ │ • Priority queue │ │
│ │ • Busy flag │ │
│ │ • Cooldown │ │
│ └────────────────────┘ │
│ │
│ OutputsManager: │
│ ┌────────────────────┐ │
│ │ Route by output="" │ │
│ └───────┬────────────┘ │
└─────────┼──────────────┘
│
┌──────────────┴──────────────┐
▼ ▼
┌────────────────┐ ┌───────────────────┐
│ output_hid.py │ │ output_gpio.py │
│ • type │ │ • gpio_pulse │
│ • key │ │ • gpio_set │
│ • enter │ │ • gpio_hold │
│ • wait │ │ • active_high │
└────────┬───────┘ └────────┬──────────┘
│ │
▼ ▼
┌────────────────┐ ┌──────────────┐
│ adafruit_hid │ │ digitalio │
│ • Keyboard │ │ GPIO pins │
│ • Layout (US) │ └──────┬───────┘
│ • Keycodes │ │
└────────┬───────┘ │
│ │
▼ ▼
┌────────────────┐ ┌──────────────────┐
│ USB HID │ │ GPIO Output │
│ Interface │ │ Relay/Opto/Power │
└────────┬───────┘ └──────────────────┘
│
▼
┌────────────────┐
│ HOST SYSTEM │
│ (Any OS with │
│ USB support) │
└────────────────┘
Purpose: System initialization and event processing loop
Responsibilities:
- Initialize all subsystems in correct order
- Process encoder events via callback
- Update sensors via InputsManager
- Manage display sleep/wake cycles
- Handle screensaver and warning overlays
Boot Sequence:
1. config.load() # Parse config.json and state.json
2. trigger_bus.init() # Initialize HID keyboard (MUST be before fire())
3. display # Initialize OLED display
4. inputs_manager # Setup Hall sensors, INA226, button
5. encoder # Setup rotary encoder callback
6. warning_screen / splash_screen # Setup overlays
7. auto_boot setup # Check for auto_boot outputs
8. Main loop starts # Enter event processing loopMain Loop Logic:
while True:
inputs.update() # Poll sensors and button
encoder.update() # Poll encoder, call callback on events
# Screen timeout check
if (now - last_interaction) > timeout:
if screensaver_enabled:
display.start_screensaver()
screensaver.update()
else:
display.sleep()
# Handle overlays and auto_boot cycles...
sleep(10ms) # 100 Hz polling ratePurpose: Centralized configuration and state management
Files:
config.json— Static configuration (menu structure, scenarios, hardware pins, inputs, outputs)state.json— Runtime state (menu cursor position, sequence/pipeline indices)
API:
config.load() # Load config.json and state.json
config.get_config() # Returns immutable config dict
config.get_state() # Returns mutable state dict
config.save_state() # Persist state.json to diskPurpose: Central execution hub with unified pipeline engine and output routing.
Key Features:
- Single Execution Point — All scenarios go through
execute_pipeline(). - Outputs Manager — Routes scenario steps to the correct output handler (e.g., HID, GPIO) based on
step["output"]. - Priority System — Emergency triggers bypass normal rules.
- Cooldown Protection — Prevents accidental double-execution.
- Busy Flag — Rejects triggers during scenario execution.
Priority Levels:
PRIORITY_LOW = 0 # Reserved for future use
PRIORITY_NORMAL = 1 # Menu actions, button
PRIORITY_HIGH = 2 # Hall sensors (emergency) - bypasses cooldownPurpose: Unified manager for all input sensors.
Inputs Supported:
HallSensorInput: Hall effect sensors (active-low or active-high) for emergency triggers.PowerMonitorInput: INA226 I2C battery voltage/current monitor, triggers on low battery thresholds.- Button: Boot button double-click detection.
Logic:
- Reads from
config["inputs"]and dynamically creates appropriate handlers. - Implements anti-bounce logic for mechanical inputs.
- Emits triggers via
trigger_bus.fire().
Purpose: Extensible system for executing scenario steps across different hardware.
Outputs Supported:
HidOutput(output_hid.py): USB keyboard commands (key,type,wait,enter).GpioOutput(output_gpio.py): Digital pin control (gpio_pulse,gpio_set,gpio_hold).
Logic:
- Managed by
OutputsManagerinsidetrigger_bus.py. - Resolves target via
step.get("output", "hid"). - Dispatches execution to the respective
OutputHandler.execute(action).
Purpose: OLED display driver with animations and screensavers.
Features:
- 128×32 SSD1306 OLED via I2C.
- Swipe animations for menu transitions.
- Screensaver support.
Purpose: Rotary encoder input processing with debouncing.
Hardware: KY-040 rotary encoder (CLK, DT, SW pins).
Purpose: I2C battery voltage and current monitoring with graceful degradation.
Purpose: Overlays for battery status and warnings.
Passive Triggers (asynchronous, hardware-driven):
- Hall sensor activation
- Button double-click
- INA226 low battery threshold
Active Triggers (synchronous, user-driven):
- Encoder click on menu item
- At-Most-Once — Cooldown prevents double execution.
- Non-Reentrant — Busy flag rejects overlapping triggers.
- Priority Override — High-priority triggers bypass cooldown.
- Resilient Steps — A single failed step (e.g., USB not connected for HID) logs a warning but DOES NOT halt the remaining scenario steps.
Both active and passive triggers use the same engine: execute_pipeline(pipeline_config, trigger_id, priority).
fire() (passive) and fire_active() (menu) act as thin facades that convert their configuration into a standardized pipeline_config and pass it to execute_pipeline().
Pipeline Semantics (loop: true vs loop: false):
loop: false: Executes the entire chain of scenarios sequentially in one go, without tracking position.loop: true: Executes exactly one scenario per trigger event, advancing a pointer instate.json["trigger_positions"]so the next trigger runs the next scenario in the cycle.
Backward Compatibility:
fire_active() still supports the old "sequence" format by resolving it into a loop: true pipeline using _resolve_seq_entry().
# Inside _run_scenario(name):
for step in scenario_steps:
output_name = step.get("output", "hid") # Default to 'hid' for legacy support
# OutputsManager.execute() routes to the specific handler
success = _outputs_manager.execute(output_name, step)
if not success:
print(f"[bus] WARNING: step failed for output '{output_name}'")
# Execution CONTINUES for the next step despite failureroot_menu,current_menu_list,menu_cursor,menu_stack- Initializer for all components.
- Main loop polling
inputs.update()andencoder.update().
init()— InitializesOutputsManager.execute_pipeline(pipeline_config, trigger_id, priority)— Core execution logic.fire(trigger_name, priority)— Passive trigger facade.fire_active(item_id)— Active trigger facade._outputs_manager— Instance ofOutputsManagerfor routing steps.
InputsManager: Readsconfig["inputs"]and initializes handlers.HallSensorInput: Handles debounced Hall sensor reads.PowerMonitorInput: Manages INA226 polling and threshold checks.
OutputHandler: Base class definingexecute(action)andcleanup().
HidOutput: Executeskey,type,wait,enteractions via USB HID.
GpioOutput: Executesgpio_pulse,gpio_set,gpio_holdactions on hardware pins.
load(),get_config(),get_state(),save_state()._migrate_state(): Migrates only legacyseq_positionsfor top-level menu items.trigger_positionsdoes not require migration (uses.get(key, 0)with a default).
- SSD1306 display object manager and animation handler.
- Rotary encoder input processing and debouncing.
Adding a new sensor or input mechanism requires implementing a handler class in inputs_manager.py.
See Input System guide for the full walkthrough.
Adding a new output capability (e.g., networking, I2C commands) involves creating a new class inheriting from OutputHandler.
See Output System guide for the full walkthrough.
Potential: Remote trigger via WiFi (requires Pico W).
- Configure WiFi in
settings.toml. - Add network module listening for HTTP/MQTT triggers.
- Fire scenarios via
trigger_bus.
Potential: Status dashboard mode displaying system uptime, trigger counts, or USB connection status.
Potential: Record keystrokes and save as a scenario to config.json.
Potential: Share configurations across multiple Pico Commanders by exporting/importing config.json.
- Auto-Boot GPIO Limit: The
auto_bootfeature supports only one GPIO output simultaneously. It picks the first output found withauto_boot.enabled: trueand ignores the rest. - GPIO Set Asymmetry:
gpio_setinoutput_gpio.pyintentionally works with the literal electrical level ("value": "high"/"low"directly on the pin), bypassing theactive_highconfiguration. This is for raw/low-level pin control. Conversely,gpio_pulseandgpio_holdrespect theactive_highflag. - Config Studio Limitations: The web UI (Config Studio /
editor.html) currently does not allow assembling a pipeline withloop: falseand multiple scenarios via the interface. It must be done through Raw JSON or manual editing ofconfig.json, even though the pipeline engine fully supports it.
| Operation | Target | Actual | Notes |
|---|---|---|---|
| Main loop cycle | 10ms | ~10ms | 100 Hz polling rate |
| Encoder debounce | 50ms | 50ms | Software timing |
| Hall debounce | 300ms | User config | Prevents oscillation |
| Display refresh | 50ms | ~40ms | SSD1306 I2C transfer |
| Scenario step | Varies | <1ms per step | Except wait actions |
| Screensaver frame | 50ms | 50ms | 20 FPS target |
- Use .mpy files — Precompiled libraries save RAM.
- Minimize string allocations — Reuse label objects in display.
- Limit scenario complexity — Long scenarios block input processing.
- Avoid deep menu nesting — Stack depth limited by RAM.
OLED Display:
- If init fails, system continues without display.
Encoder:
- If pins not responding, no events emitted. System remains responsive to other inputs.
Missing Scenarios:
- Trigger fires, but scenario not found → logged, no crash.
Invalid Action/Step:
- Unknown key or failed action → logged (
[bus] WARNING: step failed...), but the rest of the scenario continues to execute.
- Scenario execution via
HidOutputcheckssupervisor.runtime.usb_connected. - If disconnected, the step skips silently without failing the whole scenario.
Connect to Pico's serial port to view debug output:
# Linux
screen /dev/ttyACM0 115200
# macOS
screen /dev/cu.usbmodem* 115200
# Windows
# Use PuTTY or TeraTermLog Messages:
[main] active_menu: 8 items
[outputs] Manager ready: 2 outputs loaded
[bus] Outputs manager OK
[display] init OK SDA=GP 4 SCL=GP 5
[inputs] Hall hall_sensor_1 на GP15, active_low:True — начальное: PRESENT
[inputs] Manager OK, armed: True, inputs: 2
[main] Ready!
[bus] → scenario_nextcloud_stop
[output:hid] key: ctrl+c
[bus] ✓ scenario_nextcloud_stop
[bus] DROP — cooldown 4.3 s
- PEP 8 compliance for Python code
- Docstrings for public functions
- Type hints optional but encouraged
- Comments for non-obvious logic
Before submitting changes:
- Test on actual hardware (not just simulation).
- Verify all menu navigation paths.
- Test passive triggers (Hall sensors, INA226, button).
- Check USB HID output in text editor.
- Verify state persistence across reboots.
This project is licensed under the MIT License. See LICENSE file.