Technical documentation for the output layer of Pico Commander.
In the Pico Commander architecture, an Output is the final recipient of an action defined in a scenario. While triggers (inputs or menu clicks) initiate a pipeline, the pipeline consists of scenarios, and each scenario is a list of steps.
Each step can specify an "output" key matching a name defined in config["outputs"]. If omitted, it defaults to "hid". A single output module can serve any number of steps across different scenarios.
Currently, there are four built-in types of outputs:
hid— USB HID Keyboard commands (typing, shortcuts).gpio— Digital pin control (relays, optocouplers, LEDs).mouse— USB HID Mouse commands (movement, clicks).serial— Raw or structured events over the second USB CDC (serial data) port, for a listener script on the host.
All output handlers must inherit from OutputHandler defined in output_base.py.
class OutputHandler:
def __init__(self, name, config):
self.name = name
self._config = config
self._enabled = config.get("enabled", True)
@property
def enabled(self):
return self._enabled
def execute(self, action):
raise NotImplementedError()
def cleanup(self):
pass- Initialization: The
__init__method receives the output'snameand its specificconfigdictionary. It must saveself.name,self._config, andself._enabled. If initialization fails (e.g., bad config or hardware missing), it should setself._enabled = Falserather than crashing the system. - Execution: The
execute(action)method receives the entire step dictionary from the scenario (e.g.,{"output": "gpio1", "action": "gpio_pulse", "duration": 250}). The handler is responsible for reading the keys it needs. - Error Handling:
execute()must returnTrueon success andFalseon failure. It MUST NOT throw exceptions that bubble up. Catch errors internally, log them, and returnFalse. - Cleanup: The optional
cleanup()method is called when the system shuts down or reloads. It should reset hardware to a safe state (e.g., release all keys, turn off relays).
The HID output sends keystrokes to the connected host computer.
Supported Actions:
type: Types a string of text (value).key: Presses a combination of keys (combo, e.g.,"ctrl+shift+esc").wait: Pauses execution (ms). Note: It is generally preferred to use the shorthand{"wait": 500}directly in the scenario rather than routing it through the HID output.enter: Presses the Enter keycounttimes (clamped to 1-10).
Key Modifiers and Mapping:
Keys are mapped internally via _build_key_map(). Supported modifiers include ctrl, alt, shift, super/win. Regular keys include a-z, 0-9, f1-f12, enter, escape/esc, space, tab, backspace, delete, and arrow keys (up, down, left, right).
Lazy Initialization:
To prevent boot delays or crashes when a USB host isn't connected, the Keyboard and KeyboardLayoutUS objects are not created in __init__. Instead, they are initialized upon the first call to execute(). If supervisor.runtime.usb_connected is false, the step is safely skipped and returns False.
Safety:
The cleanup() method calls release_all() to ensure no keys remain virtually "stuck" if the device resets during execution.
The GPIO output controls physical digital pins.
Configuration:
pin: The GPIO pin number (required).active_high: Boolean (defaulttrue). Determines the physical electrical level considered "active".label: Human-readable name for logging.enabled: Boolean toggle.
Internally, GpioOutput uses a private method _drive(active: bool). This translates a logical state ("ON" or "OFF") into the correct physical voltage level based on active_high.
- If
active_high=True:_drive(True)sets pin HIGH. - If
active_high=False:_drive(True)sets pin LOW.
gpio_pulse(No parameters)- Calls
_drive(True), waits 250ms, then calls_drive(False). - Simulates a button press. Respects
active_high.
- Calls
gpio_hold(duration_ms)- Calls
_drive(True), waits forduration_ms, then calls_drive(False). - Respects
active_high.
- Calls
gpio_set(value:"high"/"low")- Important Asymmetry: This action intentionally bypasses
_drive()andactive_high. It writes the literal electrical level directly to the pin. It exists as a raw, low-level control tool for driving basic components (like an LED) where the concept of "active state" might just add confusion.
- Important Asymmetry: This action intentionally bypasses
Upon initialization, and during cleanup() or error fallbacks, the pin is set to the INACTIVE state using _drive(False). This ensures that relays or optocouplers do not accidentally trigger on boot or crash.
You might see default_scenario in the config.json for GPIO outputs. This is not used by output_gpio.py itself. It is a UI convention used by Config Studio (editor.html) to auto-generate a convenient one-step scenario (e.g., scenario_opto_pwr_pulse) that can be easily referenced in pipelines or auto_boot.
USB HID Mouse commands — movement and clicks. Used for a trackball input, but can also be driven from any scenario like any other output.
"outputs": {
"mouse": {
"type": "mouse",
"enabled": true
}
}move: relative movement viadx/dypixel offsets.click: a full press+release onbutton(left/right/middle, defaults toleft).press: pressesbuttonand holds it.release: releasesbutton.
Same pattern as HidOutput — the Mouse object is created on first execute() call, not at __init__. If USB is not connected (supervisor.runtime.usb_connected is False), the action is skipped and logged. cleanup() calls release_all() to ensure no button is left held down on stop/reload.
Unlike scenario-driven outputs, TrackballInput (inputs_manager.py) calls trigger_bus.execute_output("mouse", {...}) directly on every update(now) tick for movement, bypassing fire()/fire_active() entirely. This is intentional: a busy mouse-output call would otherwise block on the continuous movement stream from the same input if it went through the normal cooldown/priority queue. See docs/developers/inputs.md for the full explanation of this exception.
Sends events to a listener script on the host over the second USB CDC interface (usb_cdc.data), separate from the console/REPL port. This exists because HID typing depends on host focus, keyboard layout, and an application actually being ready to receive keystrokes — Serial is a plain data channel that works regardless of what's on screen on the host.
"outputs": {
"serial": {
"type": "serial",
"enabled": true,
"label": "Host event channel"
}
}usb_cdc.enable(console=True, data=True) must be called in boot.py, before usb_hid.enable(...). This creates a second CDC port (e.g. /dev/ttyACM1 on Linux) separate from the console port used for logs and REPL. This only takes effect after a full USB replug — a soft-reload of code.py alone will not apply a boot.py change.
send: writes a raw string with no trailing newline.send_line: writes a string followed by\n.event: writes a JSON object followed by\n. Always includes"event"(from the step'snamefield) and"uptime"(time.monotonic(), not wall-clock time — there is no RTC on the Pico). Any fields in the step'sdataobject are merged in.
Example scenario:
"scenario_notify_poweroff": [
{"output": "serial", "action": "event", "name": "poweroff", "data": {"source": "menu"}}
]write_timeoutis set to0— writes never block the main loop, even if the host isn't reading.- If the host isn't listening (
usb_cdc.data.connectedisFalse), the step is skipped and logged — same fail-safe pattern asHidOutput's USB-not-connected check. - There is no acknowledgment or delivery guarantee. This is a fire-and-forget channel, not a request/response protocol.
The OutputsManager class is the registry and dispatcher for all output handlers.
- Registration: During boot (
trigger_bus.init()), the manager readsconfig["outputs"]. It checks thetypefield of each entry and instantiates the corresponding class (HidOutputorGpioOutput). If a type is unknown, it logs a warning and skips it. - Dispatching: When a scenario step is executed, the engine calls
_outputs_manager.execute(output_name, step). The manager looks up the handler by name, verifies it isenabled, and calls itsexecute(action)method. - Public API: The bus exposes
trigger_bus.execute_output(name, action)for direct execution outside of standard pipelines.
The auto_boot feature is a specialized routine in code.py that automatically triggers a server boot sequence if a USB connection is not detected.
How it interacts with Outputs:
It does not use the pipeline engine. Instead, it looks through config["outputs"] for the first GPIO output that has auto_boot.enabled: true. If the conditions are met, it directly calls:
trigger_bus.execute_output(auto_boot_output_name, {"action": "gpio_pulse"})For full configuration details of auto_boot, see the Configuration Guide.
Adding a new output type requires a new handler class and registering it in the manager. Let's create a PwmOutput for controlling LED brightness or fan speed via pwmio.
import pwmio
import board
from output_base import OutputHandler
class PwmOutput(OutputHandler):
def __init__(self, name, config):
super().__init__(name, config)
pin_num = config.get("pin")
if pin_num is None:
print(f"[output:{name}] WARNING: pin not specified")
self._enabled = False
return
try:
pin_name = f"GP{pin_num}"
self._pwm = pwmio.PWMOut(getattr(board, pin_name), frequency=1000, duty_cycle=0)
print(f"[output:{name}] PWM ready on {pin_name}")
except Exception as e:
print(f"[output:{name}] ERROR initializing PWM:", e)
self._enabled = False
def execute(self, action):
if not self.enabled:
return False
act = action.get("action")
try:
if act == "set_duty":
# Expects 0 to 100 percentage
percent = max(0, min(100, action.get("percent", 0)))
# Convert 0-100 to 0-65535
self._pwm.duty_cycle = int((percent / 100.0) * 65535)
print(f"[output:{self.name}] Duty cycle set to {percent}%")
return True
else:
print(f"[output:{self.name}] Unknown action: {act}")
return False
except Exception as e:
print(f"[output:{self.name}] Error:", e)
return False
def cleanup(self):
if self._enabled and hasattr(self, "_pwm"):
self._pwm.duty_cycle = 0Import your new module at the top of trigger_bus.py:
from output_hid import HidOutput
from output_gpio import GpioOutput
from output_pwm import PwmOutput # <--- Add thisUpdate the OutputsManager.__init__ method:
if output_type == "hid":
self._outputs[name] = HidOutput(name, cfg_dict)
elif output_type == "gpio":
self._outputs[name] = GpioOutput(name, cfg_dict)
elif output_type == "pwm": # <--- Add this block
self._outputs[name] = PwmOutput(name, cfg_dict)Add the new output to the outputs dictionary:
"outputs": {
"case_fan": {
"type": "pwm",
"enabled": true,
"pin": 17
}
}Use it in a scenario step:
"scenario_fan_max": [
{"output": "case_fan", "action": "set_duty", "percent": 100}
]If you want users to be able to configure this output via the Web UI (editor.html):
- Add
<option value="pwm">PWM Controller</option>to the "Choose Output Type" modal. - Create a
renderPwmOutput(name, cfg)function mirroringrenderGpioOutput(), exposing thepinsetting.
gpio_setAsymmetry: As mentioned,gpio_setintentionally ignores theactive_highconfiguration and writes absolute electrical levels to the pin. This is a deliberate design choice for raw component control, not a bug. Do not "fix" this by wrapping it in_drive().auto_bootLimitation: Theauto_bootroutine currently scansconfig["outputs"]and binds to the first GPIO output it finds withauto_boot.enabled: true. It does not support executing multiple auto-boot sequences simultaneously across different outputs.