50 KiB
Boot-discovered rover peripherals
This document defines the system for attaching self-describing ESP32 peripherals to a rover over USB. A peripheral advertises a small ordered set of controls, the web UI renders those controls automatically, and the current driver can use them without adding device-specific configuration to the rover or server.
The design deliberately stays small:
- Firmata is the only serial protocol.
- Standard Firmata commands operate ordinary digital, PWM, and servo outputs.
- One Firmata user feature advertises controls and invokes custom ESP32 callbacks.
- A peripheral can expose sliders, buttons, number inputs, and text inputs.
- Controls appear in one vertical column in the order registered by the ESP32 program.
- Anyone who can currently drive the rover can use its peripheral controls.
- Peripherals are discovered once when
roverdstarts; changing one requires restarting the rover. - ESP32 firmware is built and uploaded with PlatformIO.
- The same firmware supports CH340/CP210x USB-to-UART boards and native USB CDC boards.
- There is no peripheral configuration in the rover configuration file.
- There is no separate rover-peripheral protocol version.
This document is both the design contract and implementation guide. The PlatformIO firmware library, reference sketch, focused Go Firmata client, hardware probe, boot-time daemon discovery, fixed inventory, generic output dispatch, built-in hardware backend selection, rover WebSocket message shapes, server roster forwarding, and shared HUD renderer now exist.
System boundary
The complete path is:
ESP32 peripheral
│
│ USB serial carrying Firmata
▼
roverd peripheral manager
│
│ existing rover WebSocket
▼
server rover state and command authorization
│
│ existing Socket.IO session and command paths
▼
driver web UI
Each layer has one responsibility:
- The ESP32 declares controls and implements custom hardware behavior.
roverddiscovers USB devices, speaks Firmata, and translates between Firmata and rover WebSocket messages.- The server stores the live peripheral list and permits control only for a socket that can drive the rover.
- The browser renders the declared controls and sends user changes to the server.
The server and browser do not know how a peripheral is wired. A control can operate a servo, light, motor, display, addressable LED strip, or an arbitrary sequence because the hardware mapping stays either in the Firmata output declaration or in the ESP32 callback.
Firmata model
Firmata already supplies serial framing, firmware discovery, capability queries, pin-mode commands, digital output, PWM, servo output, I2C, and its SysEx extension mechanism. The peripheral system uses those capabilities instead of defining another serial transport.
Two kinds of controls coexist on the same Firmata connection.
Standard Firmata controls
A standard control identifies a Firmata output mode and pin. roverd translates UI values directly into normal Firmata commands.
Examples:
- A servo slider sets a pin to
SERVOmode and writes the selected angle. - A brightness slider sets a pin to
PWMmode and writes the selected duty value. - A toggle button sets a pin to digital output and writes
HIGHorLOW. - A momentary digital button writes
HIGHon press andLOWon release.
The ESP32 application does not need a callback for these controls because its normal Firmata implementation performs the output operation.
Custom function controls
A custom control is registered with an ESP32 callback. The control description identifies it by its string control ID. When roverd receives a value from the browser, it sends that ID and value through the rover-peripheral Firmata feature. The ESP32 library finds the registered control and invokes its callback.
Custom functions can do anything the ESP32 program can do, including:
- Run a coordinated servo or motor sequence.
- Start or stop a light-strip animation.
- Send text to a display.
- Operate hardware through an ESP32-specific library.
- Change several outputs as one operation.
- Update state used by non-blocking work in
updateRoverPeripheral().
The visible control name is also its string wire identifier, so authors provide one meaningful name instead of maintaining a second hidden ID. They do not assign numeric action IDs. Firmata necessarily uses a numeric SysEx feature byte internally, but that is an implementation detail hidden by the package.
Firmata user feature
Firmata reserves SysEx feature IDs 0x01 through 0x0F for user-defined features. MultiRoombaRover uses 0x01 for its rover-peripheral feature.
Every feature message has normal Firmata SysEx framing:
0xF0 0x01 <operation> <payload> 0xF7
Where:
0xF0isSTART_SYSEX.0x01is the project-local rover-peripheral feature.operationselects one of the messages below.payloadcontains only 7-bit Firmata data bytes.0xF7isEND_SYSEX.
The feature operations are internal library constants:
| Operation | Direction | Purpose |
|---|---|---|
DESCRIBE |
roverd to ESP32 |
Request the current peripheral description. |
DESCRIPTION |
ESP32 to roverd |
Return the peripheral name and ordered controls. |
CONTROL |
roverd to ESP32 |
Deliver a value to a custom control callback. |
The first implementation does not need additional operations. Standard controls continue using standard Firmata messages and therefore do not use CONTROL.
Text encoding
The DESCRIPTION and CONTROL payloads contain compact JSON because the data is naturally structured and JSON is straightforward to inspect while developing firmware and roverd.
Firmata requires every byte between START_SYSEX and END_SYSEX to have its most significant bit clear. The library therefore encodes each UTF-8 JSON byte as two 7-bit bytes:
encoded byte 1 = source byte & 0x7f
encoded byte 2 = (source byte >> 7) & 0x01
The receiver combines each pair:
source byte = encoded byte 1 | (encoded byte 2 << 7)
Peripheral authors never perform this encoding themselves. It belongs inside the ESP32 RoverPeripheral package and the Go Firmata client used by roverd.
ConfigurableFirmata's ESP32 parser accepts 252 bytes inside one incoming SysEx frame, including the feature and operation bytes. CONTROL values are not chunked in this deliberately simple design. The Go client checks the fully encoded message before writing it and returns an error if a particular control value cannot fit, rather than sending a frame the ESP32 would discard. Normal numeric, boolean, and short text controls fit comfortably; a text control's configured length should reflect this transport constraint.
Peripheral description
The ESP32 library builds this description from the controls registered in configureRoverPeripheral(). The order of the controls array is the registration order and is also the UI order.
An example description is:
{
"name": "Example peripheral",
"controls": [
{
"id": "Servo position",
"type": "slider",
"name": "Servo position",
"min": 0,
"max": 180,
"output": {
"type": "servo",
"pin": 14
}
},
{
"id": "Light brightness",
"type": "slider",
"name": "Light brightness",
"min": 0,
"max": 255,
"output": {
"type": "pwm",
"pin": 17
}
},
{
"id": "Special action",
"type": "button",
"name": "Special action",
"mode": "momentary",
"output": {
"type": "custom"
}
}
]
}
Peripheral fields
| Field | Meaning |
|---|---|
name |
Human-readable heading shown above the peripheral's controls. |
controls |
Ordered array of controls exposed by the peripheral. |
The startup USB connection is the identity of a peripheral for the lifetime of the roverd process. roverd assigns each discovered connection a process-local peripheral ID and includes that ID in the rover hello. The ESP32 does not need registration, a serial number, or an entry in rover configuration. Restarting roverd rebuilds the complete inventory and may assign different process-local IDs.
Fields shared by every control
| Field | Meaning |
|---|---|
id |
String used to identify the control within this peripheral. It must be unique within the description. |
type |
One of slider, button, number, or text. |
name |
Human-readable label displayed by the web UI. |
output |
Describes whether roverd uses standard Firmata or invokes the registered custom callback. |
Slider
A slider contains numeric min and max values:
{
"id": "armPosition",
"type": "slider",
"name": "Arm position",
"min": 0,
"max": 180,
"output": {
"type": "servo",
"pin": 14
}
}
The browser displays a range input. Values are numbers and are constrained to the declared range before being sent. The initial design uses whole-number values; a separate step field is unnecessary.
Button
A button contains a mode of toggle or momentary:
{
"id": "lights",
"type": "button",
"name": "Lights",
"mode": "toggle",
"output": {
"type": "digital",
"pin": 18
}
}
Toggle behavior:
- The first activation sends
true. - The next activation sends
false. - The browser displays the current local on/off value.
Momentary behavior:
- Press sends
true. - Release sends
false. - Pointer cancellation, loss of capture, or component unmount also sends
falsewhen a press is active, so a momentary control is not left logically held. - A custom callback may react to both values or ignore release when it implements a one-shot action.
Number input
A number input contains numeric min and max values:
{
"id": "motorSpeed",
"type": "number",
"name": "Motor speed",
"min": 0,
"max": 100,
"output": {
"type": "custom"
}
}
The browser displays a numeric input and sends the committed whole-number value. The ESP32 callback receives that number.
Text input
A text input contains maxLength:
{
"id": "displayText",
"type": "text",
"name": "Display text",
"maxLength": 64,
"output": {
"type": "custom"
}
}
The browser displays a single-line text input and sends the value when the user commits it with Enter or leaves the input. Text controls use a custom callback because standard Firmata has no generic application-text output.
Output mappings
The initial system supports four output types.
| Output type | Firmata behavior | Appropriate controls |
|---|---|---|
digital |
Configure the pin as digital output and write LOW or HIGH. |
Toggle or momentary button. |
pwm |
Configure the pin for PWM and write the numeric value. | Slider or number input. |
servo |
Configure the pin for servo output and write the numeric angle. | Slider or number input. |
custom |
Send the control ID and value using the rover-peripheral CONTROL operation. |
Any control type. |
Standard output mappings include a numeric pin. Custom outputs do not need a handler name in the description because the control's own id is the callback lookup key.
This keeps the declarations small:
{"type":"servo","pin":14}
{"type":"custom"}
Replacing built-in rover GPIO controls
The camera-tilt servo, headlight, and laser already have established server commands, state handling, keybindings, gamepad mappings, and desktop/mobile UI. An ESP32 must be able to provide the physical outputs for those features without recreating them as generic peripheral controls.
The peripheral description therefore has two separate sections:
roverControlsdeclares standardized implementations of existing rover hardware roles.controlsdeclares new generic controls that appear in the peripheral's vertical UI column.
A control declared under roverControls does not also appear in controls. It powers the existing first-class rover control instead of creating a duplicate UI element.
Standardized rover roles
The initial standardized roles are:
| Role | Existing behavior retained | Firmata output |
|---|---|---|
cameraServo |
Camera tilt slider, nudging, logical angle range, home angle, inversion, pulse limits, raw-pulse policy, and movement rate limiting. | Servo configuration and servo writes. |
headlight |
Existing toggle commands, displayed state, keybinding, gamepad mapping, and headlight.state events. |
Digital writes. |
laser |
Existing toggle commands, displayed state, keybinding, gamepad mapping, room-light policy, and laser.state events. |
Digital writes. |
An ESP32 that supplies all three roles describes:
{
"name": "Rover GPIO",
"roverControls": {
"cameraServo": {
"pin": 14,
"minimumAngleDegrees": -15,
"maximumAngleDegrees": 30,
"homeAngleDegrees": 0,
"nudgeDegrees": 2,
"minimumPulseMicroseconds": 900,
"maximumPulseMicroseconds": 2100,
"allowRawPulse": false,
"inverted": false
},
"headlight": {
"pin": 18,
"activeLow": false,
"initiallyOn": false
},
"laser": {
"pin": 16,
"activeLow": false,
"initiallyOn": false
}
},
"controls": []
}
The ESP32 description owns the calibration for hardware attached to that ESP32. Rover YAML does not repeat the ESP32 pin numbers or servo calibration.
Optional backend-selection rule
roverd resolves each built-in role once during startup:
- If that native Pi GPIO feature is enabled in rover YAML, use the native Pi implementation.
- Otherwise, if exactly one discovered ESP32 declares the role, use its Firmata implementation.
- Otherwise, leave the built-in feature disabled.
Native configuration deliberately wins. A Pi rover can attach an ESP32 for unrelated generic controls without unexpectedly moving its existing camera servo, headlight, or laser to the ESP32. To deliberately use the ESP32 for one of those features, disable only that native feature in rover YAML.
Any rover configuration that should use the ESP32 for these roles keeps the corresponding native GPIO features disabled:
cameraServo:
enabled: false
headlight:
enabled: false
laser:
enabled: false
An attached ESP32 can then fill any or all of those roles automatically at the next roverd start. This works identically on Raspberry Pi and laptop rover hosts; no USB path or backend name is added to YAML.
Conflict behavior is fixed and simple:
- Native feature enabled and ESP32 declares the same role: use native and log that the ESP32 role was ignored.
- Native feature disabled and one ESP32 declares the role: use that ESP32.
- Native feature disabled and no ESP32 declares the role: disable the feature.
- Native feature disabled and multiple ESP32s declare the same role: fail startup with a duplicate-role error rather than choosing by USB enumeration order.
Shared controller interfaces
The current WSClient directly owns concrete *CameraServo and *GPIOToggle values. Supporting either physical backend cleanly requires it to depend on the behavior it uses instead of a platform-specific concrete type.
The intended interfaces are:
type CameraServoController interface {
SetAngle(angle float64) error
Nudge(delta float64) error
SetPulseWidth(micros int) error
CurrentAngle() float64
Configuration() CameraServoConfig
Close()
}
type ToggleController interface {
HandleAction(action string) error
On() bool
Configuration() GPIOToggleConfig
Close()
}
Implementations are:
CameraServoController
├── Pi camera-servo backend
└── Firmata camera-servo backend
ToggleController
├── Pi GPIO-toggle backend
└── Firmata digital-toggle backend
WSClient continues dispatching the existing servo, headlight, and laser commands without branching on the selected backend. This keeps all server and browser contracts independent of the physical hardware.
Effective hello metadata
The current hello is built from c.cfg.CameraServo, c.cfg.Headlight, and c.cfg.Laser. That cannot remain the source of truth because an ESP32 role may enable a feature whose native YAML entry is disabled.
After startup resolution, the selected controller supplies the effective public configuration. sendHello() advertises that effective configuration:
{
"cameraServo": {
"enabled": true,
"minAngle": -15,
"maxAngle": 30,
"homeAngle": 0,
"nudgeDegrees": 2,
"allowRawPulse": false,
"invert": false
},
"headlight": {
"enabled": true,
"initialOn": false,
"activeLow": false
},
"laser": {
"enabled": true,
"initialOn": false,
"activeLow": false
}
}
The physical pin is not needed by the server or browser. Existing UI availability continues to depend on the established cameraServo.enabled, headlight.enabled, and laser.enabled fields.
Camera-servo behavior
The present Pi camera-servo implementation combines logical behavior with the Pi PWM write. It should be separated so both backends retain identical control feel:
Shared camera-servo controller
├── angle limits
├── home angle
├── nudge behavior
├── inversion
├── pulse calibration
├── raw-pulse policy
├── movement rate limiting
└── physical output
├── Pi PWM writer
└── Firmata servo writer
For Firmata, roverd configures the servo pin with the ESP32-declared pulse limits and maps the logical rover angle into the servo output range. A raw pulse command, when enabled, is clamped to the declared pulse range and converted to the corresponding Firmata servo position. The existing server command shape does not change.
Headlight and laser behavior
Logical toggle state remains in roverd, just as it does now. The shared controller resolves toggle, on, and off, then asks its backend to write the resulting boolean.
The Firmata toggle backend converts the logical value using activeLow before sending the digital write. After a successful write, the existing code emits headlight.state or laser.state. Server-owned laser restrictions therefore remain in the existing command path and cannot be bypassed by selecting the Firmata backend.
ESP32 authoring API
Peripheral programs include RoverPeripheral.h and define
configureRoverPeripheral(). The library provides serial setup, Firmata setup,
setup(), and loop().
Configurations use structs. Programs assign one named field per line and then register the completed configuration. This avoids positional lists for settings such as angles, pulse widths, polarity, and ranges.
Complete firmware
This program defines the standard camera tilt, headlight, and laser roles. It also defines slider, button, number, and text accessory controls.
#include <RoverPeripheral.h>
namespace {
constexpr uint8_t specialActionPin = 21;
int repeatCount = 1;
String displayMessage;
void runSpecialAction(bool pressed) {
digitalWrite(specialActionPin, pressed ? HIGH : LOW);
}
void setRepeatCount(int value) {
repeatCount = value;
}
void setDisplayMessage(const String& value) {
displayMessage = value;
}
} // namespace
void configureRoverPeripheral(RoverPeripheral& peripheral) {
peripheral.name("Example rover peripheral");
RoverCameraServoConfig cameraServo;
cameraServo.pin = 14;
cameraServo.minimumAngleDegrees = -15;
cameraServo.maximumAngleDegrees = 30;
cameraServo.homeAngleDegrees = 0;
cameraServo.nudgeDegrees = 2;
cameraServo.minimumPulseMicroseconds = 900;
cameraServo.maximumPulseMicroseconds = 2100;
cameraServo.allowRawPulse = false;
cameraServo.inverted = false;
peripheral.addCameraServo(cameraServo);
RoverDigitalOutputConfig headlight;
headlight.pin = 18;
headlight.polarity = OutputPolarity::ActiveHigh;
headlight.initiallyOn = false;
peripheral.addHeadlight(headlight);
RoverDigitalOutputConfig laser;
laser.pin = 16;
laser.polarity = OutputPolarity::ActiveHigh;
laser.initiallyOn = false;
peripheral.addLaser(laser);
pinMode(specialActionPin, OUTPUT);
digitalWrite(specialActionPin, LOW);
SliderControlConfig brightness;
brightness.name = "Light brightness";
brightness.minimum = 0;
brightness.maximum = 255;
PwmOutput brightnessOutput;
brightnessOutput.pin = 17;
peripheral.addSlider(brightness, brightnessOutput);
ButtonControlConfig action;
action.name = "Special action";
action.mode = ButtonMode::Momentary;
peripheral.addButton(action, runSpecialAction);
NumberControlConfig repeats;
repeats.name = "Repeat count";
repeats.minimum = 1;
repeats.maximum = 20;
peripheral.addNumber(repeats, setRepeatCount);
TextControlConfig message;
message.name = "Display message";
message.maximumLength = 64;
peripheral.addText(message, setDisplayMessage);
}
Controls appear in registration order. Each accessory control name must be non-empty and unique within its peripheral. The name is also its wire identifier.
Built-in rover roles
The standard roles use these configuration types:
RoverCameraServoConfig
RoverDigitalOutputConfig
They are registered with:
peripheral.addCameraServo(cameraServo);
peripheral.addHeadlight(headlight);
peripheral.addLaser(laser);
Camera servo programs set the pin, logical angle range, home angle, nudge size,
pulse range, raw-pulse policy, and inversion in
RoverCameraServoConfig. Headlight and laser programs set the pin, polarity,
and initial state in RoverDigitalOutputConfig.
These roles keep the existing camera tilt, headlight, and laser HUD controls. They do not add entries to the accessory list.
Standard accessory outputs
A servo slider combines SliderControlConfig with ServoOutput:
SliderControlConfig position;
position.name = "Arm position";
position.minimum = 0;
position.maximum = 180;
ServoOutput servo;
servo.pin = 13;
peripheral.addSlider(position, servo);
A PWM slider combines SliderControlConfig with PwmOutput:
SliderControlConfig brightness;
brightness.name = "Light brightness";
brightness.minimum = 0;
brightness.maximum = 255;
PwmOutput light;
light.pin = 17;
peripheral.addSlider(brightness, light);
A digital button combines ButtonControlConfig with DigitalOutput:
ButtonControlConfig workLight;
workLight.name = "Work light";
workLight.mode = ButtonMode::Toggle;
DigitalOutput light;
light.pin = 21;
light.polarity = OutputPolarity::ActiveHigh;
peripheral.addButton(workLight, light);
Custom accessory functions
A custom slider passes its value to a callback:
SliderControlConfig speed;
speed.name = "Motor speed";
speed.minimum = 0;
speed.maximum = 100;
peripheral.addSlider(speed, setMotorSpeed);
A custom button passes its logical state to a bool callback:
ButtonControlConfig motor;
motor.name = "Motor";
motor.mode = ButtonMode::Momentary;
peripheral.addButton(motor, setMotorRunning);
Momentary bool callbacks receive true on press and false on release. A
zero-argument callback may be registered for a momentary action that runs only
on press.
Number and text inputs use their corresponding configuration structs:
NumberControlConfig repeats;
repeats.name = "Repeat count";
repeats.minimum = 1;
repeats.maximum = 20;
peripheral.addNumber(repeats, setRepeatCount);
TextControlConfig message;
message.name = "Display message";
message.maximumLength = 64;
peripheral.addText(message, setDisplayMessage);
A program may define updateRoverPeripheral() for recurring work:
void updateRoverPeripheral() {
// Update application state.
}
Callbacks and recurring work must not block Firmata processing. Programs must
not write debug output to Serial because Firmata uses that stream.
Library runtime
The library runtime performs these steps:
- opens
Serialat 115200 baud; - calls
configureRoverPeripheral(); - sets the serial timeout to zero;
- initializes Firmata and the rover-peripheral feature;
- applies initial output states; and
- processes Firmata messages and optional recurring work.
The zero timeout prevents ConfigurableFirmata from waiting for its receive buffer to fill before processing a short command.
PlatformIO package
The package source and reference project are:
esp32/
├── libraries/
│ └── RoverPeripheralFirmata/
│ ├── library.json
│ ├── LICENSE
│ ├── README.md
│ ├── examples/
│ └── src/
└── rover-gpio-peripheral/
├── platformio.ini
└── src/main.cpp
The published package name is legop3/RoverPeripheral. Version 2.0.0
contains the struct-based public API.
A classic ESP32 PlatformIO project declares:
[env:esp32dev]
platform = espressif32
board = esp32dev
framework = arduino
lib_deps =
legop3/RoverPeripheral @ ^2.0.0
A native-USB ESP32-S3 uses board = esp32-s3-devkitc-1 and:
build_flags =
-D ARDUINO_USB_MODE=1
-D ARDUINO_USB_CDC_ON_BOOT=1
The package manifest installs ConfigurableFirmata, ArduinoJson, and ESP32Servo.
Release validation and publication use:
pio pkg pack esp32/libraries/RoverPeripheralFirmata
pio pkg publish esp32/libraries/RoverPeripheralFirmata --owner legop3
Published versions are immutable. Each release uses a new version in
library.json.
Building and probing
Build and upload the repository reference firmware with:
cd esp32/rover-gpio-peripheral
pio run -e esp32dev
pio run -e esp32dev -t upload --upload-port /dev/ttyUSB0
Use esp32-s3-devkitc-1 and the matching /dev/ttyACM* device for a
native-USB ESP32-S3.
cd pi/roverd
go run ./cmd/peripheral-probe -port /dev/ttyUSB0
Connection lifecycle
Startup discovery
Peripheral discovery happens exactly once per roverd process. The currently implemented startup path runs before roverd constructs its existing built-in hardware controllers or connects to the server:
- Enumerates the serial devices present on Linux.
- Opens each candidate device found by the startup scan.
- Waits for a possible board reset and drains stale serial bytes to a quiet read boundary.
- Starts one Firmata client per opened connection.
- Performs the normal Firmata firmware and capability queries.
- Sends the rover-peripheral
DESCRIBEoperation. - Decodes and validates each
DESCRIPTIONresponse. - Validates every advertised standard-output pin against Firmata capabilities.
- Configures generic digital, PWM, and servo pin modes once.
- Assigns process-local IDs such as
firmata-0andfirmata-1in discovery order. - Resolves
cameraServo,headlight, andlaseragainst the native configuration. - Rejects duplicate ESP32 providers only when the corresponding native role is disabled and Firmata selection would otherwise be ambiguous.
- Constructs the selected native or Firmata controllers and fixed generic inventory.
- Constructs
WSClientwith those controllers and that inventory. - Connects to the server and includes the effective built-in configuration and renderable inventory in the normal rover hello.
The Linux scan checks stable /dev/serial/by-id/* names first, then /dev/ttyUSB* and /dev/ttyACM*. It canonicalizes symlinks so one device is not opened twice under its stable name and kernel name, and it excludes the configured Roomba Open Interface serial device. Each opened candidate receives the same reset wait used by the probe, followed by a read-until-quiet drain so an old partial SysEx cannot contaminate the new handshake.
Firmware and capability queries remain standard Firmata. RoverPeripheralFirmata::begin() registers the standard Firmata firmware name RoverPeripheralFirmata, so individual sketches do not repeat that discovery detail. A Firmata device with another firmware name is closed and ignored. Once a device identifies itself as rover-peripheral firmware, a malformed capability or description response is a startup error rather than a silently missing configured accessory.
Linux paths such as /dev/ttyACM0 remain private roverd connection details. The browser and server use only the process-local peripheral ID from the hello.
If a serial device responds to Firmata but does not implement the rover-peripheral feature, roverd does not publish it or use it as a built-in provider.
No runtime discovery
roverd does not watch for serial-device additions or removals after startup. Changing the physical ESP32 arrangement requires restarting roverd or rebooting the rover.
- Connecting an ESP32 after startup has no effect.
- Replacing one ESP32 with another has no effect until restart.
- Reconnecting an unexpectedly disconnected ESP32 does not restore it until restart.
- A restart discards all prior process-local peripheral IDs and rebuilds the entire inventory.
This fixed lifecycle is intentional. It keeps peripheral selection equivalent to the existing boot-time Pi GPIO setup and removes live inventory reconciliation from every layer.
Unexpected disconnection
If an ESP32 is unplugged or its serial connection fails while roverd is running:
- Its Firmata client marks the connection unavailable.
- Commands routed to that peripheral or one of its built-in roles return an error.
- The failed command is logged by the existing rover WebSocket command path.
- The advertised inventory does not change during that process lifetime.
The first terminal read or write error also produces one concise tty1
broadcast through the existing ConsoleNotifier:
Rover peripheral "Rover GPIO" (firmata-0) disconnected: <error>. Reconnect it and restart roverd.
Normal startup uses the same local-console mechanism to announce every fixed peripheral, the selected native or ESP32 backend for camera servo, headlight, and laser, any ignored ESP32 roles, or that no ESP32 was found. Detailed Linux paths and Firmata handshake diagnostics remain in the systemd journal.
The disconnected device is never replaced automatically by another serial device. This ensures that a command cannot be redirected merely because Linux reused a /dev/ttyACM* path.
Server WebSocket reconnect
The discovered peripherals and resolved controllers belong to the roverd process, not an individual server WebSocket. If only the server connection drops, roverd reconnects and sends a new hello containing the same startup inventory. It does not rescan USB hardware.
Rover shutdown
When roverd stops, all of its USB connections close. The server already removes the rover when its WebSocket closes, so the rover and all of its peripherals disappear together.
Rover-to-server messages
The existing rover hello includes the fixed startup list and the effective built-in controller configurations:
{
"type": "hello",
"name": "rover-name",
"cameraServo": {
"enabled": true,
"minAngle": -15,
"maxAngle": 30,
"homeAngle": 0,
"nudgeDegrees": 2
},
"headlight": {
"enabled": true,
"initialOn": false
},
"laser": {
"enabled": true,
"initialOn": false
},
"peripherals": [
{
"id": "firmata-0",
"name": "Example peripheral",
"controls": []
}
]
}
No live peripheral or hardware-capability message is required. The server stores the hello metadata in the existing rover record and includes it in the normal roster/session synchronization path.
The browser-facing roster entry therefore contains:
{
"id": "rover-name",
"name": "rover-name",
"peripherals": [
{
"id": "firmata-0",
"name": "Example peripheral",
"controls": []
}
]
}
No global session.features flag is necessary. Peripherals are inherently optional: the controls are absent when the rover started without a discovered generic peripheral.
Browser-to-server control path
The browser sends every peripheral interaction through the existing Socket.IO
command event. Peripheral actuation is a rover command, so it does not need a
parallel event or authorization path.
command
Payload:
{
"roverId": "rover-name",
"type": "peripheral",
"data": {
"peripheral": {
"id": "firmata-0",
"control": "Servo position",
"value": 90
}
}
}
The server performs the same ownership check used by other driver controls:
roverManager.canDrive(roverId, socket)
If the socket cannot drive that rover, the event acknowledgement returns an error. If it can, the server passes the generic command through commandService:
{
"type": "peripheral",
"peripheral": {
"id": "firmata-0",
"control": "Servo position",
"value": 90
}
}
roverd resolves the process-local peripheral ID and control description from its fixed startup inventory.
- For
digital,pwm, orservo, it sends the corresponding standard Firmata command. - For
custom, it sends the rover-peripheralCONTROLoperation containing the control ID and value. - If the peripheral connection is unavailable, command dispatch returns an error through the existing rover acknowledgement path and remains unavailable until
roverdrestarts.
The browser does not select a pin, Firmata operation, or custom-function name. It sends only the identifiers from the current session description and the new UI value.
Standard Firmata command examples
These examples show the logical Firmata operations. The Go Firmata client should build the exact byte messages.
Servo slider
For servo pin 14 and value 90:
SET_PIN_MODE pin=14 mode=SERVO
EXTENDED_ANALOG pin=14 value=90
Firmata's extended analog message is:
0xF0 0x6F <pin> <value bits 0-6> <value bits 7-13> ... 0xF7
roverd only needs to set the mode when the connection/control is initialized or when the current pin mode differs. Slider changes then send the value write.
PWM slider
For PWM pin 18 and value 200:
SET_PIN_MODE pin=18 mode=PWM
EXTENDED_ANALOG pin=18 value=200
EXTENDED_ANALOG supports pins beyond the four-bit channel range of Firmata's shorter analog message, so it provides one consistent PWM and servo write path.
Digital button
For digital pin 19:
SET_PIN_MODE pin=19 mode=OUTPUT
SET_DIGITAL_PIN_VALUE pin=19 value=1
SET_DIGITAL_PIN_VALUE pin=19 value=0
A toggle sends one of the last two writes per activation. A momentary button sends 1 on press and 0 on release.
Custom callback communication
Suppose Special action is pressed. roverd creates the JSON payload:
{"control":"Special action","value":true}
After 8-to-7-bit encoding, it is placed in:
START_SYSEX
ROVER_PERIPHERAL_FEATURE
CONTROL
encoded payload
END_SYSEX
The ESP32 library:
- Receives the SysEx feature message through Firmata.
- Decodes the JSON bytes.
- Reads
controlandvalue. - Finds the control registered as
Special action. - Converts the JSON boolean to the registered button callback's
boolargument. - Calls the callback with
true.
On release the same path carries false.
For the reference sketch, the callback drives its output high on press and low again on release:
void runSpecialAction(bool pressed) {
digitalWrite(specialActionPin, pressed ? HIGH : LOW);
}
A hold-style custom function can use both transitions:
[](bool pressed) {
if (pressed) {
startMotor();
} else {
stopMotor();
}
}
UI behavior
The driver UI finds the assigned rover in session.roster and reads its peripherals array. It renders:
Peripheral name
control 1
control 2
control 3
Next peripheral name
control 1
control 2
Each peripheral is one vertical column. Within that column, the browser uses the array order exactly as received. It does not alphabetize or regroup controls.
The generic renderer maps:
sliderto a labeled range input.buttonwithtogglemode to a labeled on/off button.buttonwithmomentarymode to a press-and-hold button.numberto a labeled numeric input.textto a labeled single-line text input.
Generic peripheral controls are rover controls, so they follow the new driver's HUD language. They do not belong in either sidebar: the sidebars contain chat, queues, room controls, settings, and other controls that are not direct rover actuation.
The standardized replacements do not create any new UI. cameraServo, headlight, and laser continue to use their current camera-tilt, headlight, and laser HUD controls. Only entries in the generic controls arrays appear in a new surface named Accessories.
On desktop, Accessories is a vertical button centered on the left wall of the video. It uses the existing translucent black HUD treatment and opens a height-limited, vertically scrollable panel toward the right. The panel uses the same compact control renderer as mobile and is independent of the bottom-left horn, headlight, and laser pod.
On mobile, a vertical Accessories button sits directly to the right of the vacuum-forward and vacuum-backward buttons. Activating it replaces the complete AuxColumn contents with the ordered, vertically scrollable accessory list. A small Aux button shares the first compact device heading and returns to the normal vacuum, camera, light, laser, and horn controls without creating a separate rail or overlay border.
Desktop and mobile reuse one placement-independent RoverAccessoryControls renderer inside their different containers. Device-specific React components are not created for individual peripherals. The renderer sends actions through ControlSystemProvider, ControlContext, and the existing command pipeline so assignment gating, input cancellation, and command behavior remain consistent with other rover HUD controls. Both parents and the renderer disappear completely when the assigned rover has no generic controls; no launcher, empty shell, or reserved space remains.
Control values are local UI values in the first implementation. Slider and toggle changes update the displayed value immediately and are then sent to the server. Generic sliders use the same custom pointer-capture approach as mobile camera tilt rather than a browser-native range control, which keeps touch behavior and appearance consistent while driving. Restarting roverd recreates controls from the new hello rather than persisting peripheral values in roverSettings.
Permissions
Peripheral permissions have one rule: if a socket can currently drive the rover, it can operate that rover's peripherals.
The server enforces this with the existing roverManager.canDrive(roverId, socket) decision. The browser hiding or disabling controls is only presentation; it is not the permission boundary.
No peripheral-specific roles, administrator-only controls, access lists, or permissions in ESP32 configuration are part of this design.
When the driver loses the rover assignment, the UI stops presenting enabled controls and subsequent peripheral commands fail the same server-side drive check.
Expected repository changes
Implementation should remain concentrated in a few clear areas.
ESP32 library
The Arduino-compatible RoverPeripheral package now contains:
- Ordered control registration.
- Standardized
cameraServo,headlight, andlaserrole registration. - Generation of the peripheral description.
- Registration of Firmata feature
0x01. DESCRIBEresponse handling.CONTROLdecoding and callback dispatch.- 8-to-7-bit payload encoding and decoding.
- The public configuration structs and registration methods listed above.
- Arduino
setup()andloop()ownership, including the zero-timeout Firmata parser configuration required for immediate short-command handling.
Example ESP32 sketches import only RoverPeripheral.h rather than exposing or
hand-writing any Firmata setup or SysEx parsing.
The package source lives in esp32/libraries/RoverPeripheralFirmata, with the
complete esp32/rover-gpio-peripheral PlatformIO project serving as the
repository reference firmware. The package manifest, README, and examples are
self-contained so the same directory can be published directly to the
PlatformIO Registry as legop3/RoverPeripheral.
pi/roverd
PeripheralManager is responsible for:
- One-time Linux USB serial discovery during startup on either rover host type.
- One Firmata client per connected peripheral.
- Firmata handshake and capability queries.
- Rover-peripheral description queries.
- The fixed process-local peripheral list.
- Startup resolution of native Pi and Firmata built-in-control backends.
- Standard Firmata output dispatch.
- Custom
CONTROLdispatch. - Rejecting commands for disconnected peripheral IDs.
The manager validates every advertised standard-output pin against the device's Firmata capability response and configures each generic pin mode once during startup. Runtime servo and PWM changes therefore send only value writes; they do not repeatedly detach and reconfigure the hardware output. Generic digital controls start logically off, including the corresponding high electrical level for active-low declarations.
Only renderable control metadata leaves roverd. Firmata pin numbers, output mappings, Linux paths, live clients, capabilities, and built-in-role declarations stay in the manager's private fixed inventory. The rover hello contains process-local peripheral IDs, names, and ordered generic controls.
The manager should remain independent of the existing Roomba Open Interface serial adapter. A peripheral serial connection is not the Roomba base serial connection and must not be routed through SerialAdapter.
The transport foundation is a focused Firmata parser/client in pi/roverd/firmata.go. It operates on io.ReadWriteCloser, which keeps byte-stream behavior testable without hardware and lets discovery pass either /dev/ttyUSB* or /dev/ttyACM* ports into the same client. pi/roverd/cmd/peripheral-probe remains the direct hardware diagnostic entry point, while PeripheralManager now connects the same client to automatic daemon startup discovery.
WSClient now depends on shared camera-servo and toggle controller interfaces rather than platform-selected concrete types. The startup resolver uses the same native-first rule in the ARM Pi and amd64 laptop binaries. Firmata camera movement retains the established limits, home position, nudging, inversion, pulse calibration, raw-pulse policy, and movement-rate behavior; Firmata toggles retain logical state and polarity conversion.
Server
Extend the existing rover connection and roster path to:
- Accept
peripheralsin rover hello metadata. - Include peripherals in
roverManager.getRoster(). - Continue exposing effective
cameraServo,headlight, andlasermetadata through their existing roster fields regardless of physical backend. - Route generic controls through the existing Socket.IO
commandhandler. - Reuse
roverManager.canDrive()for authorization. - Forward the command through
commandServiceso rover acknowledgements remain consistent with other controls.
Web UI
Add one generic peripheral control renderer that:
- Selects the assigned rover and its peripherals from session state.
- Preserves peripheral and control array order.
- Renders only the four agreed control types.
- Sends every interaction through the existing
commandevent with typeperipheral. - Supports momentary press and release for pointer, touch, and keyboard activation.
- Mounts in the desktop left-wall expansion and as a replacement view inside mobile
AuxColumn. - Uses the shared control context and command pipeline rather than emitting directly from layout code.
- Disappears completely when the assigned rover has no peripherals.
Implementation sequence
The implemented vertical path is:
- The public ESP32 package declares built-in roles and ordered accessory controls.
- Its private Firmata implementation advertises the generated description.
roverddiscovers all startup peripherals and resolves hardware backends.- Rover hello metadata carries the fixed renderable inventory to the server.
- The server preserves that inventory in the roster and applies normal driver authorization.
- The shared web renderer presents the four control types on desktop and mobile.
- Commands return through the existing pipeline to standard Firmata outputs or custom callbacks.
- The package README and examples give external authors the same concise API used by the repository firmware.
The protocol and session shapes are arrays from the beginning, so supporting multiple devices does not require changing the external contracts after the first-device vertical slice.
Verification scenarios
The completed system should be verified with a real ESP32 and rover Linux computer rather than only mocked serial data.
Discovery
- Start
roverdwithout an ESP32 and confirm no peripheral or ESP32-provided built-in controls appear. - Stop
roverd, connect the ESP32, and start it again. - Confirm the Firmata handshake completes.
- Confirm the peripheral description reaches the assigned driver's session.
- Confirm controls appear in registration order.
- Connect an ESP32 after startup and confirm it is intentionally ignored until restart.
Standard controls
- Move the reference brightness slider and confirm pin 17 receives PWM values across the declared range.
- Register a
SliderControlConfigwith aServoOutputand confirm its selected pin receives servo values across the declared range. - Confirm neither standard control invokes the custom callback path.
Custom controls
- Press the momentary button and confirm the ESP32 callback receives
trueonce. - Release it and confirm the callback receives
falseonce. - Submit number and text values and confirm their typed callbacks receive the advertised values.
- Cancel a held pointer or leave the control layout and confirm a release is sent.
- Confirm arbitrary non-blocking ESP32 behavior can continue from
updateRoverPeripheral()after the callback changes its state.
Permissions
- Confirm the current driver can use all connected peripheral controls.
- Confirm a spectator or a user assigned to another rover cannot operate them.
- Change drivers and confirm permission follows the rover assignment immediately.
Built-in GPIO replacement
- Start either rover host type with native camera servo, headlight, and laser disabled and an ESP32 declaring all three roles.
- Confirm the normal camera tilt, headlight, and laser UI appears without generic duplicates.
- Confirm camera angle limits, home position, nudge amount, inversion, pulse calibration, and rate limiting match the declared ESP32 configuration.
- Confirm headlight and laser toggle state events remain identical to the native Pi path.
- Confirm existing laser restrictions still apply with the Firmata backend.
- Enable a native role and declare the same ESP32 role; confirm native wins and the ignored role is logged.
- Disable native and declare the same role from two ESP32s; confirm startup fails with a clear duplicate-role error.
- Confirm a Pi rover can use native built-in controls and generic ESP32 controls simultaneously.
- Repeat ESP32 role selection on both the ARM Pi binary and amd64 laptop binary and confirm their commands and advertised configurations match.
Fixed-device lifecycle
- Unplug an ESP32 after startup and confirm its commands fail cleanly while its advertised controls remain fixed.
- Reconnect it without restarting and confirm it is not silently rebound.
- Restart
roverdand confirm the reconnected peripheral becomes available again. - Swap the ESP32 while
roverdis stopped and confirm the new startup description replaces the old one completely. - Restart only the server connection and confirm
roverdresends the same startup inventory without rescanning USB.
Multiple peripherals
- Connect two peripherals and confirm both appear separately.
- Confirm each peripheral preserves its own control order.
- Operate controls with identical control IDs on both devices and confirm process-local peripheral IDs route them to the correct startup connection.
- Unplug one and confirm commands to it fail without affecting commands to the other startup connection.
Final design summary
The system uses Firmata exactly where Firmata is useful:
- Device discovery and capability communication occur on a Firmata connection.
- Digital, PWM, and servo controls use standard Firmata commands.
- One Firmata user feature advertises the UI description and invokes arbitrary ESP32 callbacks.
- The ESP32 program registers controls in UI order through a small helper library.
- An ESP32 can optionally provide the existing camera-servo, headlight, and laser roles when their native Pi GPIO configurations are disabled.
roverddiscovers peripherals and resolves native or Firmata hardware backends once during startup.- The server applies the existing driver permission rule.
- The web UI renders four generic control types in a vertical column.
There is no device-specific rover configuration, live hot-plug behavior, separate serial transport, numbered application-action registry, or requirement for the server or browser to understand the attached hardware. Changing ESP32 hardware requires restarting roverd or rebooting the rover.
Firmata references
- Firmata core protocol defines SysEx framing, firmware and capability queries, pin modes, digital writes, extended analog writes, and the requirement that SysEx payload bytes are 7-bit values.
- Firmata feature registry reserves feature IDs
0x01through0x0Ffor user-defined features. - Firmata servo feature documents servo configuration and use with Firmata analog output messages.
- ConfigurableFirmata is the reference modular Firmata firmware whose feature structure should be evaluated when implementing the ESP32 helper library.