Compare commits

..
6 Commits
Author SHA1 Message Date
legop3 a3c13f3dd3 fix title dark 2026-09-12 14:35:10 -04:00
legop3 b0389b5ddc ui tweak fling 2026-09-12 14:31:17 -04:00
legop3 9ca039229a slopping up a platformio library for people to make rover peripherals 2026-09-09 18:43:39 -04:00
legop3 8895ed6bd8 fixfix 2026-09-08 23:56:45 -04:00
legop3 6ca7cc0cf0 adjusting stylings 2026-09-08 23:52:25 -04:00
legop3 d3fd3946e6 uiuiui 2026-09-08 23:11:03 -04:00
34 changed files with 1917 additions and 678 deletions
+298 -319
View File
@@ -16,7 +16,7 @@ The design deliberately stays small:
- There is no peripheral configuration in the rover configuration file. - There is no peripheral configuration in the rover configuration file.
- There is no separate rover-peripheral protocol version. - 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, and rover WebSocket message shapes now exist. Server forwarding and HUD rendering remain later implementation stages. 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 ## System boundary
@@ -77,9 +77,12 @@ Custom functions can do anything the ESP32 program can do, including:
- Send text to a display. - Send text to a display.
- Operate hardware through an ESP32-specific library. - Operate hardware through an ESP32-specific library.
- Change several outputs as one operation. - Change several outputs as one operation.
- Update state used by non-blocking work in `loop()`. - Update state used by non-blocking work in `updateRoverPeripheral()`.
Application authors use string IDs such as `specialAction` or `animationSpeed`. 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 peripheral library. 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 user feature
@@ -126,13 +129,13 @@ The receiver combines each pair:
source byte = encoded byte 1 | (encoded byte 2 << 7) source byte = encoded byte 1 | (encoded byte 2 << 7)
``` ```
Peripheral authors never perform this encoding themselves. It belongs in the ESP32 `RoverPeripheralFirmata` library and the Go Firmata client used by `roverd`. 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. 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 ## Peripheral description
The ESP32 library builds this description from the controls registered during `setup()`. The order of the `controls` array is the registration order and is also the UI order. 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: An example description is:
@@ -141,7 +144,7 @@ An example description is:
"name": "Example peripheral", "name": "Example peripheral",
"controls": [ "controls": [
{ {
"id": "servoPosition", "id": "Servo position",
"type": "slider", "type": "slider",
"name": "Servo position", "name": "Servo position",
"min": 0, "min": 0,
@@ -152,20 +155,20 @@ An example description is:
} }
}, },
{ {
"id": "lightBrightness", "id": "Light brightness",
"type": "slider", "type": "slider",
"name": "Light brightness", "name": "Light brightness",
"min": 0, "min": 0,
"max": 255, "max": 255,
"output": { "output": {
"type": "pwm", "type": "pwm",
"pin": 18 "pin": 17
} }
}, },
{ {
"id": "specialAction", "id": "Special action",
"type": "button", "type": "button",
"name": "Run special action", "name": "Special action",
"mode": "momentary", "mode": "momentary",
"output": { "output": {
"type": "custom" "type": "custom"
@@ -488,227 +491,44 @@ The Firmata toggle backend converts the logical value using `activeLow` before s
## ESP32 authoring API ## ESP32 authoring API
Peripheral authors should not write JSON, construct SysEx messages, or manually dispatch control IDs. The proposed `RoverPeripheralFirmata` Arduino library owns those tasks. Peripheral programs include `RoverPeripheral.h` and define
`configureRoverPeripheral()`. The library provides serial setup, Firmata setup,
`setup()`, and `loop()`.
A long positional call such as `addRoverCameraServo(14, -15, 30, 0, 2, 900, 2100, false, false)` is deliberately not part of the API. Several adjacent numbers and booleans are too difficult to understand or review without repeatedly consulting the function signature. 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.
The public API uses named configuration structs. Field names include units where a bare number would otherwise be ambiguous, and enums replace booleans whose meaning would be unclear at the call site. ### Complete firmware
### Proposed configuration types This program defines the standard camera tilt, headlight, and laser roles. It
also defines slider, button, number, and text accessory controls.
The core public types are:
```cpp ```cpp
enum class OutputPolarity { #include <RoverPeripheral.h>
ActiveHigh,
ActiveLow
};
enum class ButtonMode { namespace {
Toggle, constexpr uint8_t specialActionPin = 21;
Momentary
};
struct FirmataServoOutput { int repeatCount = 1;
uint8_t pin; String displayMessage;
};
struct FirmataPwmOutput { void runSpecialAction(bool pressed) {
uint8_t pin; digitalWrite(specialActionPin, pressed ? HIGH : LOW);
};
struct FirmataDigitalOutput {
uint8_t pin;
OutputPolarity polarity = OutputPolarity::ActiveHigh;
};
struct RoverCameraServoConfig {
uint8_t pin;
float minimumAngleDegrees;
float maximumAngleDegrees;
float homeAngleDegrees = 0;
float nudgeDegrees = 2;
uint16_t minimumPulseMicroseconds = 900;
uint16_t maximumPulseMicroseconds = 2100;
bool allowRawPulse = false;
bool inverted = false;
};
struct RoverDigitalOutputConfig {
uint8_t pin;
OutputPolarity polarity = OutputPolarity::ActiveHigh;
bool initiallyOn = false;
};
struct SliderControlConfig {
String id;
String name;
int minimum;
int maximum;
};
struct ButtonControlConfig {
String id;
String name;
ButtonMode mode;
};
struct NumberControlConfig {
String id;
String name;
int minimum;
int maximum;
};
struct TextControlConfig {
String id;
String name;
size_t maximumLength;
};
```
Defaults cover values that are commonly shared, but required hardware and display values remain explicit. The implementation must validate the completed struct when it is registered rather than assuming that every default-constructed object is usable.
The API uses ordinary field assignments instead of C++ designated initializers. This keeps example sketches compatible with ESP32 Arduino toolchains that are not configured for C++20.
### Generic-control registration
A complete sketch for one servo slider, one light-brightness slider, and one custom momentary button is:
```cpp
#include <Arduino.h>
#include <ConfigurableFirmata.h>
#include <FirmataExt.h>
#include <RoverPeripheralFirmata.h>
/*
* Controls are advertised in the order they are added to this object. The
* browser preserves that order when it renders the peripheral's column.
*/
RoverPeripheralFirmata peripheral("Example peripheral");
FirmataExt firmataExtension;
/*
* This is ordinary application code rather than Firmata plumbing. A real
* peripheral can replace it with any device-specific sequence or library call.
*/
void runSpecialAction() {
// Start or schedule the peripheral's custom behavior here.
} }
void setup() { void setRepeatCount(int value) {
Serial.begin(115200); repeatCount = value;
Firmata.begin(Serial);
/*
* roverd handles this control with standard Firmata SERVO commands. The
* ESP32 application does not need a callback for each slider update.
*/
SliderControlConfig servoPosition;
servoPosition.id = "servoPosition";
servoPosition.name = "Servo position";
servoPosition.minimum = 0;
servoPosition.maximum = 180;
FirmataServoOutput servoOutput;
servoOutput.pin = 14;
peripheral.addServoSlider(servoPosition, servoOutput);
/*
* roverd handles this control with standard Firmata PWM commands. The range
* is included in the generated description and displayed by the web UI.
*/
SliderControlConfig lightBrightness;
lightBrightness.id = "lightBrightness";
lightBrightness.name = "Light brightness";
lightBrightness.minimum = 0;
lightBrightness.maximum = 255;
FirmataPwmOutput lightOutput;
lightOutput.pin = 18;
peripheral.addPwmSlider(lightBrightness, lightOutput);
/*
* Custom controls are delivered through the rover-peripheral Firmata feature.
* The library finds this registration by control ID and invokes the callback
* with true on press and false on release.
*/
ButtonControlConfig specialAction;
specialAction.id = "specialAction";
specialAction.name = "Run special action";
specialAction.mode = ButtonMode::Momentary;
peripheral.addButton(
specialAction,
[](bool pressed) {
if (pressed) {
runSpecialAction();
}
}
);
// Register the extension with Firmata and finalize the control description.
peripheral.begin(firmataExtension);
} }
void loop() { void setDisplayMessage(const String& value) {
// Standard Firmata messages and rover-peripheral SysEx messages share this parser. displayMessage = value;
while (Firmata.available()) {
Firmata.processInput();
}
// Let the peripheral library perform any deferred send or callback work.
peripheral.update();
} }
``` } // namespace
The intended generic registration methods are: void configureRoverPeripheral(RoverPeripheral& peripheral) {
peripheral.name("Example rover peripheral");
```cpp
addServoSlider(const SliderControlConfig&, const FirmataServoOutput&)
addPwmSlider(const SliderControlConfig&, const FirmataPwmOutput&)
addDigitalButton(const ButtonControlConfig&, const FirmataDigitalOutput&)
addSlider(const SliderControlConfig&, SliderCallback)
addButton(const ButtonControlConfig&, ButtonCallback)
addNumber(const NumberControlConfig&, NumberCallback)
addText(const TextControlConfig&, TextCallback)
```
These helpers still produce only the four agreed UI types. The overload or method name distinguishes a standard Firmata output from a custom callback; it does not create an additional UI type.
The standardized built-in replacements use separate methods because they do not create generic UI controls:
```cpp
addRoverCameraServo(const RoverCameraServoConfig&)
addRoverHeadlight(const RoverDigitalOutputConfig&)
addRoverLaser(const RoverDigitalOutputConfig&)
```
A rover GPIO peripheral can combine built-in replacements and additional controls:
```cpp
#include <Arduino.h>
#include <ConfigurableFirmata.h>
#include <FirmataExt.h>
#include <RoverPeripheralFirmata.h>
RoverPeripheralFirmata peripheral("Rover GPIO");
FirmataExt firmataExtension;
void setup() {
Serial.begin(115200);
Firmata.begin(Serial);
/*
* These declarations satisfy existing rover roles. They retain the normal
* camera, headlight, and laser UI instead of entering the generic column.
*/
RoverCameraServoConfig cameraServo; RoverCameraServoConfig cameraServo;
cameraServo.pin = 14; cameraServo.pin = 14;
cameraServo.minimumAngleDegrees = -15; cameraServo.minimumAngleDegrees = -15;
@@ -719,116 +539,264 @@ void setup() {
cameraServo.maximumPulseMicroseconds = 2100; cameraServo.maximumPulseMicroseconds = 2100;
cameraServo.allowRawPulse = false; cameraServo.allowRawPulse = false;
cameraServo.inverted = false; cameraServo.inverted = false;
peripheral.addCameraServo(cameraServo);
peripheral.addRoverCameraServo(cameraServo);
RoverDigitalOutputConfig headlight; RoverDigitalOutputConfig headlight;
headlight.pin = 18; headlight.pin = 18;
headlight.polarity = OutputPolarity::ActiveHigh; headlight.polarity = OutputPolarity::ActiveHigh;
headlight.initiallyOn = false; headlight.initiallyOn = false;
peripheral.addHeadlight(headlight);
peripheral.addRoverHeadlight(headlight);
RoverDigitalOutputConfig laser; RoverDigitalOutputConfig laser;
// GPIO 19 and 20 are reserved for USB on native-USB ESP32-S3 boards.
laser.pin = 16; laser.pin = 16;
laser.polarity = OutputPolarity::ActiveHigh; laser.polarity = OutputPolarity::ActiveHigh;
laser.initiallyOn = false; laser.initiallyOn = false;
peripheral.addLaser(laser);
peripheral.addRoverLaser(laser); pinMode(specialActionPin, OUTPUT);
digitalWrite(specialActionPin, LOW);
/* SliderControlConfig brightness;
* This is an additional feature, so it appears below the peripheral heading brightness.name = "Light brightness";
* in the ordered generic-control column. brightness.minimum = 0;
*/ brightness.maximum = 255;
SliderControlConfig underglowBrightness;
underglowBrightness.id = "underglowBrightness";
underglowBrightness.name = "Underglow brightness";
underglowBrightness.minimum = 0;
underglowBrightness.maximum = 255;
peripheral.addSlider( PwmOutput brightnessOutput;
underglowBrightness, brightnessOutput.pin = 17;
[](int brightness) {
setUnderglowBrightness(brightness);
}
);
peripheral.begin(firmataExtension); peripheral.addSlider(brightness, brightnessOutput);
}
void loop() { ButtonControlConfig action;
while (Firmata.available()) { action.name = "Special action";
Firmata.processInput(); action.mode = ButtonMode::Momentary;
}
peripheral.update(); 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);
} }
``` ```
## PlatformIO firmware layout 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.
PlatformIO is the only supported firmware workflow. The repository contains one shared library and one complete rover GPIO peripheral project: ### Built-in rover roles
The standard roles use these configuration types:
```cpp
RoverCameraServoConfig
RoverDigitalOutputConfig
```
They are registered with:
```cpp
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`:
```cpp
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`:
```cpp
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`:
```cpp
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:
```cpp
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:
```cpp
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:
```cpp
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:
```cpp
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:
1. opens `Serial` at 115200 baud;
2. calls `configureRoverPeripheral()`;
3. sets the serial timeout to zero;
4. initializes Firmata and the rover-peripheral feature;
5. applies initial output states; and
6. 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:
```text ```text
esp32/ esp32/
├── libraries/ ├── libraries/
│ └── RoverPeripheralFirmata/ │ └── RoverPeripheralFirmata/
│ ├── library.json │ ├── library.json
│ ├── LICENSE
│ ├── README.md
│ ├── examples/
│ └── src/ │ └── src/
└── rover-gpio-peripheral/ └── rover-gpio-peripheral/
├── platformio.ini ├── platformio.ini
└── src/main.cpp └── src/main.cpp
``` ```
The local library owns control registration, description generation, rover-peripheral SysEx handling, callback dispatch, and the standard digital, PWM, and servo output subset. The sketch only declares hardware and application behavior. The published package name is `legop3/RoverPeripheral`. Version `2.0.0`
contains the struct-based public API.
`platformio.ini` contains two environments: A classic ESP32 PlatformIO project declares:
| Environment | Intended hardware | Normal Linux device | ```ini
| --- | --- | --- | [env:esp32dev]
| `esp32dev` | ESP32-WROOM-32/DevKitC boards using CH340 or CP210x USB-to-UART | `/dev/ttyUSB*` | platform = espressif32
| `esp32-s3-devkitc-1` | ESP32-S3 boards using native USB CDC | `/dev/ttyACM*` | board = esp32dev
framework = arduino
Both environments compile the same `main.cpp`. The S3 environment only adds the Arduino USB CDC build flags needed to make its native USB serial stream active at boot. The sketch passes Arduino's `Serial` object to `Firmata.begin(Stream&)`, so neither the helper library nor the Firmata messages depend on which USB transport produced the byte stream. lib_deps =
legop3/RoverPeripheral @ ^2.0.0
```
Typical commands are: A native-USB ESP32-S3 uses `board = esp32-s3-devkitc-1` and:
```ini
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:
```bash
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:
```bash ```bash
cd esp32/rover-gpio-peripheral cd esp32/rover-gpio-peripheral
# The TG34/CH340 DevKitC-style ESP32 used for initial testing.
pio run -e esp32dev pio run -e esp32dev
pio run -e esp32dev -t upload --upload-port /dev/ttyUSB0 pio run -e esp32dev -t upload --upload-port /dev/ttyUSB0
pio device monitor --port /dev/ttyUSB0 --baud 115200
# A native-USB ESP32-S3 DevKitC.
pio run -e esp32-s3-devkitc-1
pio run -e esp32-s3-devkitc-1 -t upload --upload-port /dev/ttyACM0
pio device monitor --port /dev/ttyACM0 --baud 115200
``` ```
Do not keep PlatformIO's serial monitor open while `roverd` or the probe is using the peripheral. A serial device can have only one process actively consuming the Firmata stream. Use `esp32-s3-devkitc-1` and the matching `/dev/ttyACM*` device for a
native-USB ESP32-S3.
After uploading, use the Go probe to perform the real handshake and print the self-description:
```bash ```bash
cd pi/roverd cd pi/roverd
go run ./cmd/peripheral-probe -port /dev/ttyUSB0 go run ./cmd/peripheral-probe -port /dev/ttyUSB0
# Exercise the standard servo slider.
go run ./cmd/peripheral-probe -port /dev/ttyUSB0 -control servoPosition -value 90
# Exercise the custom momentary callback. Run once for press and once for release.
go run ./cmd/peripheral-probe -port /dev/ttyUSB0 -control specialAction -value true
go run ./cmd/peripheral-probe -port /dev/ttyUSB0 -control specialAction -value false
``` ```
Use `/dev/ttyACM0` instead for a native-USB board. The probe waits two seconds after opening because either style of development board may reset when its serial connection opens. It then performs the standard Firmata firmware and capability queries before sending `DESCRIBE`. `-control` is intentionally a diagnostic option only; production control will enter through the server and `roverd` command path.
ConfigurableFirmata's stock example disables its servo feature on ESP32. `RoverPeripheralFirmata` therefore uses ConfigurableFirmata for standard framing, parsing, capability dispatch, and firmware queries, but supplies the ESP32 servo implementation with `ESP32Servo`. Servo writes still use the standard Firmata `SERVO_CONFIG`, `SET_PIN_MODE`, and `EXTENDED_ANALOG` messages; this is an implementation substitution inside the firmware, not a custom servo protocol.
The project pins ConfigurableFirmata `3.2.0` because PlatformIO's stable Espressif32 platform currently ships Arduino-ESP32 2.x. ConfigurableFirmata `3.4.0` compiles its bundled PWM source with Arduino-ESP32 3.x LEDC function names even when the sketch does not instantiate that feature. The pinned release uses the matching 2.x LEDC API and compiles for both configured boards. This pin is a build compatibility choice and does not change the Firmata messages used by the rover.
## Connection lifecycle ## Connection lifecycle
### Startup discovery ### Startup discovery
@@ -956,10 +924,12 @@ No global `session.features` flag is necessary. Peripherals are inherently optio
## Browser-to-server control path ## Browser-to-server control path
The browser sends one generic Socket.IO event for every peripheral control: 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.
```text ```text
peripheral:set command
``` ```
Payload: Payload:
@@ -967,9 +937,14 @@ Payload:
```json ```json
{ {
"roverId": "rover-name", "roverId": "rover-name",
"peripheralId": "firmata-0", "type": "peripheral",
"controlId": "servoPosition", "data": {
"peripheral": {
"id": "firmata-0",
"control": "Servo position",
"value": 90 "value": 90
}
}
} }
``` ```
@@ -986,7 +961,7 @@ If the socket cannot drive that rover, the event acknowledgement returns an erro
"type": "peripheral", "type": "peripheral",
"peripheral": { "peripheral": {
"id": "firmata-0", "id": "firmata-0",
"control": "servoPosition", "control": "Servo position",
"value": 90 "value": 90
} }
} }
@@ -1046,10 +1021,10 @@ A toggle sends one of the last two writes per activation. A momentary button sen
## Custom callback communication ## Custom callback communication
Suppose `specialAction` is pressed. `roverd` creates the JSON payload: Suppose `Special action` is pressed. `roverd` creates the JSON payload:
```json ```json
{"control":"specialAction","value":true} {"control":"Special action","value":true}
``` ```
After 8-to-7-bit encoding, it is placed in: After 8-to-7-bit encoding, it is placed in:
@@ -1067,19 +1042,18 @@ The ESP32 library:
1. Receives the SysEx feature message through Firmata. 1. Receives the SysEx feature message through Firmata.
2. Decodes the JSON bytes. 2. Decodes the JSON bytes.
3. Reads `control` and `value`. 3. Reads `control` and `value`.
4. Finds the control registered as `specialAction`. 4. Finds the control registered as `Special action`.
5. Converts the JSON boolean to the registered button callback's `bool` argument. 5. Converts the JSON boolean to the registered button callback's `bool` argument.
6. Calls the callback with `true`. 6. Calls the callback with `true`.
On release the same path carries `false`. On release the same path carries `false`.
For the example sketch, only the press runs the one-shot function: For the reference sketch, the callback drives its output high on press and low
again on release:
```cpp ```cpp
[](bool pressed) { void runSpecialAction(bool pressed) {
if (pressed) { digitalWrite(specialActionPin, pressed ? HIGH : LOW);
runSpecialAction();
}
} }
``` ```
@@ -1124,13 +1098,13 @@ Generic peripheral controls are rover controls, so they follow the new driver's
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`. 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 collapsible HUD drawer connected to the bottom-left rover-control pod. This keeps additional actuation beside the existing horn, headlight, and laser controls without permanently covering the video. The drawer is absent when the assigned rover advertises no generic controls. 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, the HUD launcher opens an unscaled, vertically scrollable sheet over the video stage. Generic controls must not be placed in the fixed `AuxColumn`: an arbitrary device-defined list cannot fit that column's intentionally fixed set of large driving controls. The mobile sheet closes without changing control values and disappears when there are no generic controls. 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 generic renderer inside their different HUD 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. 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. Restarting `roverd` recreates controls from the new hello rather than persisting peripheral values in `roverSettings`. 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 ## Permissions
@@ -1140,7 +1114,7 @@ The server enforces this with the existing `roverManager.canDrive(roverId, socke
No peripheral-specific roles, administrator-only controls, access lists, or permissions in ESP32 configuration are part of this design. 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:set` requests fail the same server-side drive check. 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 ## Expected repository changes
@@ -1148,7 +1122,7 @@ Implementation should remain concentrated in a few clear areas.
### ESP32 library ### ESP32 library
The Arduino-compatible `RoverPeripheralFirmata` library now contains: The Arduino-compatible `RoverPeripheral` package now contains:
- Ordered control registration. - Ordered control registration.
- Standardized `cameraServo`, `headlight`, and `laser` role registration. - Standardized `cameraServo`, `headlight`, and `laser` role registration.
@@ -1157,11 +1131,18 @@ The Arduino-compatible `RoverPeripheralFirmata` library now contains:
- `DESCRIBE` response handling. - `DESCRIBE` response handling.
- `CONTROL` decoding and callback dispatch. - `CONTROL` decoding and callback dispatch.
- 8-to-7-bit payload encoding and decoding. - 8-to-7-bit payload encoding and decoding.
- The standard-output and custom-control helper methods listed above. - The public configuration structs and registration methods listed above.
- Arduino `setup()` and `loop()` ownership, including the zero-timeout Firmata
parser configuration required for immediate short-command handling.
Example ESP32 sketches should use this library rather than hand-writing SysEx parsing. Example ESP32 sketches import only `RoverPeripheral.h` rather than exposing or
hand-writing any Firmata setup or SysEx parsing.
The first implementation lives in `esp32/libraries/RoverPeripheralFirmata`, with the complete `esp32/rover-gpio-peripheral` PlatformIO project serving as both the reference firmware and an example usable by either rover host type. 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` ### `pi/roverd`
@@ -1194,7 +1175,7 @@ Extend the existing rover connection and roster path to:
- Accept `peripherals` in rover hello metadata. - Accept `peripherals` in rover hello metadata.
- Include peripherals in `roverManager.getRoster()`. - Include peripherals in `roverManager.getRoster()`.
- Continue exposing effective `cameraServo`, `headlight`, and `laser` metadata through their existing roster fields regardless of physical backend. - Continue exposing effective `cameraServo`, `headlight`, and `laser` metadata through their existing roster fields regardless of physical backend.
- Add the generic `peripheral:set` Socket.IO handler. - Route generic controls through the existing Socket.IO `command` handler.
- Reuse `roverManager.canDrive()` for authorization. - Reuse `roverManager.canDrive()` for authorization.
- Forward the command through `commandService` so rover acknowledgements remain consistent with other controls. - Forward the command through `commandService` so rover acknowledgements remain consistent with other controls.
@@ -1205,27 +1186,24 @@ Add one generic peripheral control renderer that:
- Selects the assigned rover and its peripherals from session state. - Selects the assigned rover and its peripherals from session state.
- Preserves peripheral and control array order. - Preserves peripheral and control array order.
- Renders only the four agreed control types. - Renders only the four agreed control types.
- Sends every interaction through the same `peripheral:set` event. - Sends every interaction through the existing `command` event with type `peripheral`.
- Supports momentary press and release for pointer, touch, and keyboard activation. - Supports momentary press and release for pointer, touch, and keyboard activation.
- Mounts in the desktop Accessories HUD drawer and mobile Accessories HUD sheet. - 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. - Uses the shared control context and command pipeline rather than emitting directly from layout code.
- Disappears completely when the assigned rover has no peripherals. - Disappears completely when the assigned rover has no peripherals.
## Implementation sequence ## Implementation sequence
The smallest useful vertical implementation is: The implemented vertical path is:
1. Build the ESP32 Firmata feature and the three-control example sketch. 1. The public ESP32 package declares built-in roles and ordered accessory controls.
2. Add boot-time one-device USB discovery and Firmata communication to `roverd`. 2. Its private Firmata implementation advertises the generated description.
3. Include the fixed description in the rover hello and server roster. 3. `roverd` discovers all startup peripherals and resolves hardware backends.
4. Render the ordered generic controls in the driver UI. 4. Rover hello metadata carries the fixed renderable inventory to the server.
5. Route generic servo and PWM controls through standard Firmata. 5. The server preserves that inventory in the roster and applies normal driver authorization.
6. Route the generic momentary button through the custom callback operation. 6. The shared web renderer presents the four control types on desktop and mobile.
7. Add the standardized ESP32 camera-servo, headlight, and laser declarations. 7. Commands return through the existing pipeline to standard Firmata outputs or custom callbacks.
8. Refactor built-in controllers to select native Pi or Firmata backends at startup. 8. The package README and examples give external authors the same concise API used by the repository firmware.
9. Verify that existing tilt, headlight, laser, keybinding, gamepad, state-event, and server-policy behavior is unchanged with both backends.
10. Generalize startup discovery from one connection to multiple simultaneous peripherals.
11. Add the remaining toggle, number, and text registration helpers and UI renderers.
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. 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.
@@ -1244,16 +1222,17 @@ The completed system should be verified with a real ESP32 and rover Linux comput
### Standard controls ### Standard controls
- Move the servo slider and confirm pin 14 receives servo values across the declared range. - Move the reference brightness slider and confirm pin 17 receives PWM values across the declared range.
- Move the brightness slider and confirm pin 18 receives PWM values across the declared range. - Register a `SliderControlConfig` with a `ServoOutput` and confirm its selected pin receives servo values across the declared range.
- Confirm neither standard control invokes the custom callback path. - Confirm neither standard control invokes the custom callback path.
### Custom controls ### Custom controls
- Press the momentary button and confirm the ESP32 callback receives `true` once. - Press the momentary button and confirm the ESP32 callback receives `true` once.
- Release it and confirm the callback receives `false` once. - Release it and confirm the callback receives `false` once.
- 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. - Cancel a held pointer or leave the control layout and confirm a release is sent.
- Confirm arbitrary non-blocking ESP32 behavior can continue from `loop()` after the callback changes its state. - Confirm arbitrary non-blocking ESP32 behavior can continue from `updateRoverPeripheral()` after the callback changes its state.
### Permissions ### Permissions
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Daniel Roberts
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
@@ -0,0 +1,218 @@
# RoverPeripheral
RoverPeripheral is an ESP32 Arduino library for MultiRoombaRover peripherals.
The ESP32 reports its built-in rover roles and accessory controls to `roverd`
over USB serial.
## PlatformIO installation
Classic ESP32 DevKitC-style board:
```ini
[env:esp32dev]
platform = espressif32
board = esp32dev
framework = arduino
lib_deps =
legop3/RoverPeripheral @ ^2.0.0
```
Native-USB ESP32-S3 DevKitC:
```ini
[env:esp32-s3-devkitc-1]
platform = espressif32
board = esp32-s3-devkitc-1
framework = arduino
build_flags =
-D ARDUINO_USB_MODE=1
-D ARDUINO_USB_CDC_ON_BOOT=1
lib_deps =
legop3/RoverPeripheral @ ^2.0.0
```
## Program structure
Include `RoverPeripheral.h` and define `configureRoverPeripheral()`:
```cpp
#include <RoverPeripheral.h>
void configureRoverPeripheral(RoverPeripheral& peripheral) {
peripheral.name("Headlight controller");
RoverDigitalOutputConfig headlight;
headlight.pin = 18;
headlight.polarity = OutputPolarity::ActiveHigh;
headlight.initiallyOn = false;
peripheral.addHeadlight(headlight);
}
```
The library provides `setup()` and `loop()`. Do not define them in the
peripheral program.
## Built-in rover roles
Camera tilt:
```cpp
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);
```
Headlight or laser:
```cpp
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);
```
These registrations use the existing camera, headlight, and laser controls in
the rover UI. They do not create accessory controls.
## Accessory controls
Controls appear in registration order. Each control name must be unique within
the peripheral. The name is also used as the control identifier.
### Servo slider
```cpp
SliderControlConfig position;
position.name = "Arm position";
position.minimum = 0;
position.maximum = 180;
ServoOutput servo;
servo.pin = 13;
peripheral.addSlider(position, servo);
```
### PWM slider
```cpp
SliderControlConfig brightness;
brightness.name = "Light brightness";
brightness.minimum = 0;
brightness.maximum = 255;
PwmOutput light;
light.pin = 17;
peripheral.addSlider(brightness, light);
```
### Digital button
```cpp
ButtonControlConfig workLight;
workLight.name = "Work light";
workLight.mode = ButtonMode::Toggle;
DigitalOutput light;
light.pin = 21;
light.polarity = OutputPolarity::ActiveHigh;
peripheral.addButton(workLight, light);
```
### Custom slider
```cpp
void setMotorSpeed(int value) {
// Apply value to the device.
}
SliderControlConfig speed;
speed.name = "Motor speed";
speed.minimum = 0;
speed.maximum = 100;
peripheral.addSlider(speed, setMotorSpeed);
```
### Custom button
```cpp
void setMotorRunning(bool running) {
// Start or stop the device.
}
ButtonControlConfig motor;
motor.name = "Motor";
motor.mode = ButtonMode::Momentary;
peripheral.addButton(motor, setMotorRunning);
```
A momentary bool callback receives `true` on press and `false` on release. A
zero-argument callback can be used for a one-shot momentary action.
### Number input
```cpp
void setRepeatCount(int value) {
// Store or apply value.
}
NumberControlConfig repeats;
repeats.name = "Repeat count";
repeats.minimum = 1;
repeats.maximum = 20;
peripheral.addNumber(repeats, setRepeatCount);
```
### Text input
```cpp
void setDisplayMessage(const String& value) {
// Store or display value.
}
TextControlConfig message;
message.name = "Display message";
message.maximumLength = 64;
peripheral.addText(message, setDisplayMessage);
```
## Recurring work
Define `updateRoverPeripheral()` when the program needs recurring non-blocking
work:
```cpp
void updateRoverPeripheral() {
// Update a state machine or device.
}
```
Callbacks and `updateRoverPeripheral()` must not block serial processing.
`Serial` is reserved for Firmata and must not be used for debug output.
## License
MIT
@@ -0,0 +1,76 @@
#include <RoverPeripheral.h>
namespace {
constexpr uint8_t kActionPin = 21;
int repeatCount = 1;
String displayMessage;
void setActionActive(bool pressed) {
digitalWrite(kActionPin, pressed ? HIGH : LOW);
}
void setRepeatCount(int value) {
repeatCount = value;
}
void setDisplayMessage(const String& value) {
displayMessage = value;
}
} // namespace
void configureRoverPeripheral(RoverPeripheral& io) {
io.name("Complete 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;
io.addCameraServo(cameraServo);
RoverDigitalOutputConfig headlight;
headlight.pin = 18;
headlight.polarity = OutputPolarity::ActiveHigh;
headlight.initiallyOn = false;
io.addHeadlight(headlight);
RoverDigitalOutputConfig laser;
laser.pin = 16;
laser.polarity = OutputPolarity::ActiveHigh;
laser.initiallyOn = false;
io.addLaser(laser);
pinMode(kActionPin, OUTPUT);
digitalWrite(kActionPin, LOW);
SliderControlConfig brightness;
brightness.name = "Light brightness";
brightness.minimum = 0;
brightness.maximum = 255;
PwmOutput brightnessOutput;
brightnessOutput.pin = 17;
io.addSlider(brightness, brightnessOutput);
ButtonControlConfig action;
action.name = "Special action";
action.mode = ButtonMode::Momentary;
io.addButton(action, setActionActive);
NumberControlConfig repeats;
repeats.name = "Repeat count";
repeats.minimum = 1;
repeats.maximum = 20;
io.addNumber(repeats, setRepeatCount);
TextControlConfig message;
message.name = "Display message";
message.maximumLength = 64;
io.addText(message, setDisplayMessage);
}
@@ -0,0 +1,11 @@
#include <RoverPeripheral.h>
void configureRoverPeripheral(RoverPeripheral& io) {
io.name("Headlight controller");
RoverDigitalOutputConfig headlight;
headlight.pin = 18;
headlight.polarity = OutputPolarity::ActiveHigh;
headlight.initiallyOn = false;
io.addHeadlight(headlight);
}
@@ -1,10 +1,25 @@
{ {
"name": "RoverPeripheralFirmata", "$schema": "https://raw.githubusercontent.com/platformio/platformio-core/develop/platformio/assets/schema/library.json",
"version": "0.1.0", "name": "RoverPeripheral",
"description": "Self-describing Firmata controls for MultiRoombaRover ESP32 peripherals", "version": "2.0.1",
"description": "Create self-describing ESP32 hardware controls for MultiRoombaRover",
"keywords": [
"esp32",
"firmata",
"robotics",
"rover"
],
"repository": {
"type": "git",
"url": "https://github.com/legop3/MultiRoombaRover.git"
},
"homepage": "https://github.com/legop3/MultiRoombaRover/tree/main/esp32/libraries/RoverPeripheralFirmata",
"license": "MIT",
"frameworks": "arduino", "frameworks": "arduino",
"platforms": "espressif32", "platforms": "espressif32",
"headers": "RoverPeripheral.h",
"dependencies": { "dependencies": {
"ConfigurableFirmata": "https://github.com/firmata/ConfigurableFirmata.git#3.2.0",
"bblanchon/ArduinoJson": "^7.4.2", "bblanchon/ArduinoJson": "^7.4.2",
"madhephaestus/ESP32Servo": "^3.0.8" "madhephaestus/ESP32Servo": "^3.0.8"
} }
@@ -0,0 +1,78 @@
#include "RoverPeripheral.h"
#include "internal/RoverPeripheralFirmata.h"
RoverPeripheral::RoverPeripheral()
: implementation_(new RoverPeripheralFirmata("Rover peripheral")) {}
RoverPeripheral::~RoverPeripheral() {
delete implementation_;
}
void RoverPeripheral::name(const String& peripheralName) {
implementation_->setName(peripheralName);
}
void RoverPeripheral::addCameraServo(const RoverCameraServoConfig& config) {
implementation_->addRoverCameraServo(config);
}
void RoverPeripheral::addHeadlight(const RoverDigitalOutputConfig& config) {
implementation_->addRoverHeadlight(config);
}
void RoverPeripheral::addLaser(const RoverDigitalOutputConfig& config) {
implementation_->addRoverLaser(config);
}
void RoverPeripheral::addSlider(const SliderControlConfig& config, const ServoOutput& output) {
implementation_->addServoSlider(config, output);
}
void RoverPeripheral::addSlider(const SliderControlConfig& config, const PwmOutput& output) {
implementation_->addPwmSlider(config, output);
}
void RoverPeripheral::addButton(const ButtonControlConfig& config, const DigitalOutput& output) {
implementation_->addDigitalButton(config, output);
}
void RoverPeripheral::addSlider(const SliderControlConfig& config, SliderCallback callback) {
implementation_->addSlider(config, callback);
}
void RoverPeripheral::addButton(const ButtonControlConfig& config, ButtonCallback callback) {
implementation_->addButton(config, callback);
}
void RoverPeripheral::addButton(const ButtonControlConfig& config, ActionCallback callback) {
// One-shot callbacks apply only to momentary buttons. A toggle requires the
// bool callback overload because application code must receive its new state.
if (config.mode != ButtonMode::Momentary) {
abort();
}
implementation_->addButton(
config,
[callback](bool pressed) {
if (pressed && callback) {
callback();
}
}
);
}
void RoverPeripheral::addNumber(const NumberControlConfig& config, NumberCallback callback) {
implementation_->addNumber(config, callback);
}
void RoverPeripheral::addText(const TextControlConfig& config, TextCallback callback) {
implementation_->addText(config, callback);
}
void RoverPeripheral::begin(FirmataExt& extension) {
implementation_->begin(extension);
}
void RoverPeripheral::update() {
implementation_->update();
}
@@ -0,0 +1,156 @@
#pragma once
#include <Arduino.h>
#include <functional>
/** Describes whether a logical on value drives an output pin high or low. */
enum class OutputPolarity {
ActiveHigh,
ActiveLow,
};
/** Selects whether a button retains its state or is active only while held. */
enum class ButtonMode {
Toggle,
Momentary,
};
/** Configuration for the rover's existing camera-tilt control. */
struct RoverCameraServoConfig {
uint8_t pin = 0;
float minimumAngleDegrees = -15;
float maximumAngleDegrees = 30;
float homeAngleDegrees = 0;
float nudgeDegrees = 2;
uint16_t minimumPulseMicroseconds = 900;
uint16_t maximumPulseMicroseconds = 2100;
bool allowRawPulse = false;
bool inverted = false;
};
/** Configuration for the rover's existing headlight or laser control. */
struct RoverDigitalOutputConfig {
uint8_t pin = 0;
OutputPolarity polarity = OutputPolarity::ActiveHigh;
bool initiallyOn = false;
};
/** Shared display and range settings for a slider control. */
struct SliderControlConfig {
String name;
int minimum = 0;
int maximum = 100;
};
/** Shared display and interaction settings for a button control. */
struct ButtonControlConfig {
String name;
ButtonMode mode = ButtonMode::Momentary;
};
/** Shared display and range settings for a number input. */
struct NumberControlConfig {
String name;
int minimum = 0;
int maximum = 100;
};
/** Shared display and length settings for a text input. */
struct TextControlConfig {
String name;
size_t maximumLength = 32;
};
/** Selects a standard Firmata servo as the destination for a slider. */
struct ServoOutput {
uint8_t pin = 0;
};
/** Selects an ESP32 PWM pin as the destination for a slider. */
struct PwmOutput {
uint8_t pin = 0;
};
/** Selects an ESP32 digital pin as the destination for a button. */
struct DigitalOutput {
uint8_t pin = 0;
OutputPolarity polarity = OutputPolarity::ActiveHigh;
};
using SliderCallback = std::function<void(int)>;
using ButtonCallback = std::function<void(bool)>;
using ActionCallback = std::function<void()>;
using NumberCallback = std::function<void(int)>;
using TextCallback = std::function<void(const String&)>;
class FirmataExt;
class RoverPeripheralFirmata;
/**
* Registration API for a self-describing rover peripheral.
*
* A sketch constructs each configuration one field at a time and registers it
* in configureRoverPeripheral(). Serial and protocol setup stay in the library.
*/
class RoverPeripheral {
public:
RoverPeripheral();
~RoverPeripheral();
RoverPeripheral(const RoverPeripheral&) = delete;
RoverPeripheral& operator=(const RoverPeripheral&) = delete;
/** Sets the peripheral name shown above its accessory controls. */
void name(const String& peripheralName);
/** Registers the rover's existing camera-tilt control. */
void addCameraServo(const RoverCameraServoConfig& config);
/** Registers the rover's existing headlight control. */
void addHeadlight(const RoverDigitalOutputConfig& config);
/** Registers the rover's existing laser control. */
void addLaser(const RoverDigitalOutputConfig& config);
/** Registers a slider backed by a standard Firmata servo output. */
void addSlider(const SliderControlConfig& config, const ServoOutput& output);
/** Registers a slider backed by an ESP32 PWM output. */
void addSlider(const SliderControlConfig& config, const PwmOutput& output);
/** Registers a button backed by an ESP32 digital output. */
void addButton(const ButtonControlConfig& config, const DigitalOutput& output);
/** Registers a slider handled by application code. */
void addSlider(const SliderControlConfig& config, SliderCallback callback);
/** Registers a button whose callback receives its logical state. */
void addButton(const ButtonControlConfig& config, ButtonCallback callback);
/** Registers a momentary button whose callback runs only on press. */
void addButton(const ButtonControlConfig& config, ActionCallback callback);
/** Registers a number input handled by application code. */
void addNumber(const NumberControlConfig& config, NumberCallback callback);
/** Registers a text input handled by application code. */
void addText(const TextControlConfig& config, TextCallback callback);
private:
// The implementation is opaque so importing this header does not expose any
// Firmata types or require firmware authors to understand the wire protocol.
RoverPeripheralFirmata* implementation_;
void begin(FirmataExt& extension);
void update();
friend void setup();
friend void loop();
};
/** Called once by the library after Arduino and Serial initialization. */
void configureRoverPeripheral(RoverPeripheral& peripheral);
/** Optional non-blocking hook for recurring application work. */
void updateRoverPeripheral();
@@ -0,0 +1,46 @@
#include "RoverPeripheral.h"
#include <ConfigurableFirmata.h>
#include <FirmataExt.h>
namespace {
FirmataExt firmataExtension;
RoverPeripheral peripheral;
} // namespace
// A weak no-op preserves the zero-boilerplate case while allowing a sketch to
// define the same function when animations or state machines need regular work.
void __attribute__((weak)) updateRoverPeripheral() {}
void setup() {
// The public configuration hook runs after Arduino initialization, allowing
// peripheral code to safely use pinMode() and initialize third-party devices.
Serial.begin(115200);
configureRoverPeripheral(peripheral);
// ConfigurableFirmata batches reads on ESP32-class boards. Arduino's default
// one-second Stream timeout would delay short commands while waiting for the
// batch buffer to fill, so consume only bytes that have already arrived.
Serial.setTimeout(0);
Firmata.begin(Serial);
peripheral.begin(firmataExtension);
// Applying a normal Firmata reset after registration establishes every
// declared initial output and makes the first host connection deterministic.
Firmata.parse(SYSTEM_RESET);
}
void loop() {
// ConfigurableFirmata retains partial parser state between iterations. Stop
// after each complete message so user update work cannot be starved by a
// sustained burst, while ordinary short commands are still drained at once.
while (Firmata.available()) {
Firmata.processInput();
if (!Firmata.isParsingMessage()) {
break;
}
}
peripheral.update();
updateRoverPeripheral();
}
@@ -28,31 +28,41 @@ RoverPeripheralFirmata* RoverPeripheralFirmata::instance_ = nullptr;
RoverPeripheralFirmata::RoverPeripheralFirmata(const String& name) : name_(name) {} RoverPeripheralFirmata::RoverPeripheralFirmata(const String& name) : name_(name) {}
void RoverPeripheralFirmata::validateControlIdentity(const String& id, const String& name) const { void RoverPeripheralFirmata::setName(const String& name) {
if (id.length() == 0 || name.length() == 0) { if (name.length() == 0) {
// A blank heading makes multiple attached peripherals impossible to
// distinguish. Treat it as a firmware-authoring error at startup rather
// than advertising ambiguous controls to the rover.
abort();
}
name_ = name;
}
void RoverPeripheralFirmata::validateControlName(const String& name) const {
if (name.length() == 0) {
// Registration errors are programmer errors discovered during setup. A // Registration errors are programmer errors discovered during setup. A
// hard stop is preferable to advertising a partially usable device whose // hard stop is preferable to advertising a partially usable device whose
// behavior depends on which malformed control the driver touches first. // behavior depends on which malformed control the driver touches first.
abort(); abort();
} }
for (const ControlRegistration& existing : controls_) { for (const ControlRegistration& existing : controls_) {
if (existing.id == id) { if (existing.id == name) {
abort(); abort();
} }
} }
} }
void RoverPeripheralFirmata::validateRange(const String& id, int minimum, int maximum) const { void RoverPeripheralFirmata::validateRange(const String& name, int minimum, int maximum) const {
if (id.length() == 0 || minimum > maximum) { if (name.length() == 0 || minimum > maximum) {
abort(); abort();
} }
} }
void RoverPeripheralFirmata::addServoSlider(const SliderControlConfig& config, const FirmataServoOutput& output) { void RoverPeripheralFirmata::addServoSlider(const SliderControlConfig& config, const ServoOutput& output) {
validateControlIdentity(config.id, config.name); validateControlName(config.name);
validateRange(config.id, config.minimum, config.maximum); validateRange(config.name, config.minimum, config.maximum);
ControlRegistration control; ControlRegistration control;
control.id = config.id; control.id = config.name;
control.name = config.name; control.name = config.name;
control.type = ControlType::Slider; control.type = ControlType::Slider;
control.output = OutputType::Servo; control.output = OutputType::Servo;
@@ -62,11 +72,11 @@ void RoverPeripheralFirmata::addServoSlider(const SliderControlConfig& config, c
controls_.push_back(control); controls_.push_back(control);
} }
void RoverPeripheralFirmata::addPwmSlider(const SliderControlConfig& config, const FirmataPwmOutput& output) { void RoverPeripheralFirmata::addPwmSlider(const SliderControlConfig& config, const PwmOutput& output) {
validateControlIdentity(config.id, config.name); validateControlName(config.name);
validateRange(config.id, config.minimum, config.maximum); validateRange(config.name, config.minimum, config.maximum);
ControlRegistration control; ControlRegistration control;
control.id = config.id; control.id = config.name;
control.name = config.name; control.name = config.name;
control.type = ControlType::Slider; control.type = ControlType::Slider;
control.output = OutputType::Pwm; control.output = OutputType::Pwm;
@@ -76,10 +86,10 @@ void RoverPeripheralFirmata::addPwmSlider(const SliderControlConfig& config, con
controls_.push_back(control); controls_.push_back(control);
} }
void RoverPeripheralFirmata::addDigitalButton(const ButtonControlConfig& config, const FirmataDigitalOutput& output) { void RoverPeripheralFirmata::addDigitalButton(const ButtonControlConfig& config, const DigitalOutput& output) {
validateControlIdentity(config.id, config.name); validateControlName(config.name);
ControlRegistration control; ControlRegistration control;
control.id = config.id; control.id = config.name;
control.name = config.name; control.name = config.name;
control.type = ControlType::Button; control.type = ControlType::Button;
control.output = OutputType::Digital; control.output = OutputType::Digital;
@@ -90,10 +100,10 @@ void RoverPeripheralFirmata::addDigitalButton(const ButtonControlConfig& config,
} }
void RoverPeripheralFirmata::addSlider(const SliderControlConfig& config, SliderCallback callback) { void RoverPeripheralFirmata::addSlider(const SliderControlConfig& config, SliderCallback callback) {
validateControlIdentity(config.id, config.name); validateControlName(config.name);
validateRange(config.id, config.minimum, config.maximum); validateRange(config.name, config.minimum, config.maximum);
ControlRegistration control; ControlRegistration control;
control.id = config.id; control.id = config.name;
control.name = config.name; control.name = config.name;
control.type = ControlType::Slider; control.type = ControlType::Slider;
control.output = OutputType::Custom; control.output = OutputType::Custom;
@@ -104,9 +114,9 @@ void RoverPeripheralFirmata::addSlider(const SliderControlConfig& config, Slider
} }
void RoverPeripheralFirmata::addButton(const ButtonControlConfig& config, ButtonCallback callback) { void RoverPeripheralFirmata::addButton(const ButtonControlConfig& config, ButtonCallback callback) {
validateControlIdentity(config.id, config.name); validateControlName(config.name);
ControlRegistration control; ControlRegistration control;
control.id = config.id; control.id = config.name;
control.name = config.name; control.name = config.name;
control.type = ControlType::Button; control.type = ControlType::Button;
control.output = OutputType::Custom; control.output = OutputType::Custom;
@@ -116,10 +126,10 @@ void RoverPeripheralFirmata::addButton(const ButtonControlConfig& config, Button
} }
void RoverPeripheralFirmata::addNumber(const NumberControlConfig& config, NumberCallback callback) { void RoverPeripheralFirmata::addNumber(const NumberControlConfig& config, NumberCallback callback) {
validateControlIdentity(config.id, config.name); validateControlName(config.name);
validateRange(config.id, config.minimum, config.maximum); validateRange(config.name, config.minimum, config.maximum);
ControlRegistration control; ControlRegistration control;
control.id = config.id; control.id = config.name;
control.name = config.name; control.name = config.name;
control.type = ControlType::Number; control.type = ControlType::Number;
control.output = OutputType::Custom; control.output = OutputType::Custom;
@@ -130,12 +140,12 @@ void RoverPeripheralFirmata::addNumber(const NumberControlConfig& config, Number
} }
void RoverPeripheralFirmata::addText(const TextControlConfig& config, TextCallback callback) { void RoverPeripheralFirmata::addText(const TextControlConfig& config, TextCallback callback) {
validateControlIdentity(config.id, config.name); validateControlName(config.name);
if (config.maximumLength == 0) { if (config.maximumLength == 0) {
abort(); abort();
} }
ControlRegistration control; ControlRegistration control;
control.id = config.id; control.id = config.name;
control.name = config.name; control.name = config.name;
control.type = ControlType::Text; control.type = ControlType::Text;
control.output = OutputType::Custom; control.output = OutputType::Custom;
@@ -5,95 +5,25 @@
#include <ConfigurableFirmata.h> #include <ConfigurableFirmata.h>
#include <ESP32Servo.h> #include <ESP32Servo.h>
#include <FirmataExt.h> #include <FirmataExt.h>
#include <RoverPeripheral.h>
#include <functional>
#include <vector> #include <vector>
enum class OutputPolarity {
ActiveHigh,
ActiveLow,
};
enum class ButtonMode {
Toggle,
Momentary,
};
struct FirmataServoOutput {
uint8_t pin = 0;
};
struct FirmataPwmOutput {
uint8_t pin = 0;
};
struct FirmataDigitalOutput {
uint8_t pin = 0;
OutputPolarity polarity = OutputPolarity::ActiveHigh;
};
struct RoverCameraServoConfig {
uint8_t pin = 0;
float minimumAngleDegrees = -15;
float maximumAngleDegrees = 30;
float homeAngleDegrees = 0;
float nudgeDegrees = 2;
uint16_t minimumPulseMicroseconds = 900;
uint16_t maximumPulseMicroseconds = 2100;
bool allowRawPulse = false;
bool inverted = false;
};
struct RoverDigitalOutputConfig {
uint8_t pin = 0;
OutputPolarity polarity = OutputPolarity::ActiveHigh;
bool initiallyOn = false;
};
struct SliderControlConfig {
String id;
String name;
int minimum = 0;
int maximum = 100;
};
struct ButtonControlConfig {
String id;
String name;
ButtonMode mode = ButtonMode::Momentary;
};
struct NumberControlConfig {
String id;
String name;
int minimum = 0;
int maximum = 100;
};
struct TextControlConfig {
String id;
String name;
size_t maximumLength = 32;
};
using SliderCallback = std::function<void(int)>;
using ButtonCallback = std::function<void(bool)>;
using NumberCallback = std::function<void(int)>;
using TextCallback = std::function<void(const String&)>;
/* /*
* RoverPeripheralFirmata is both the sketch-facing registration API and one * RoverPeripheralFirmata is the protocol-facing implementation behind the
* ConfigurableFirmata feature. Keeping those responsibilities together gives a * small RoverPeripheral public facade. Keeping this class private prevents
* peripheral author one object to configure while still allowing ordinary * peripheral sketches from depending on Firmata types while ordinary Firmata
* Firmata tooling to use digital, PWM, and servo commands on the same stream. * tooling can still use digital, PWM, and servo commands on the same stream.
*/ */
class RoverPeripheralFirmata : public FirmataFeature { class RoverPeripheralFirmata : public FirmataFeature {
public: public:
explicit RoverPeripheralFirmata(const String& name); explicit RoverPeripheralFirmata(const String& name);
void addServoSlider(const SliderControlConfig& config, const FirmataServoOutput& output); void setName(const String& name);
void addPwmSlider(const SliderControlConfig& config, const FirmataPwmOutput& output);
void addDigitalButton(const ButtonControlConfig& config, const FirmataDigitalOutput& output); void addServoSlider(const SliderControlConfig& config, const ServoOutput& output);
void addPwmSlider(const SliderControlConfig& config, const PwmOutput& output);
void addDigitalButton(const ButtonControlConfig& config, const DigitalOutput& output);
void addSlider(const SliderControlConfig& config, SliderCallback callback); void addSlider(const SliderControlConfig& config, SliderCallback callback);
void addButton(const ButtonControlConfig& config, ButtonCallback callback); void addButton(const ButtonControlConfig& config, ButtonCallback callback);
void addNumber(const NumberControlConfig& config, NumberCallback callback); void addNumber(const NumberControlConfig& config, NumberCallback callback);
@@ -155,8 +85,8 @@ class RoverPeripheralFirmata : public FirmataFeature {
RoverDigitalOutputConfig laser_; RoverDigitalOutputConfig laser_;
Servo* servos_[TOTAL_PINS] = {}; Servo* servos_[TOTAL_PINS] = {};
void validateControlIdentity(const String& id, const String& name) const; void validateControlName(const String& name) const;
void validateRange(const String& id, int minimum, int maximum) const; void validateRange(const String& name, int minimum, int maximum) const;
void buildAndSendDescription(); void buildAndSendDescription();
void dispatchCustomControl(byte argc, byte* argv); void dispatchCustomControl(byte argc, byte* argv);
void writeDigitalPin(byte pin, bool enabled); void writeDigitalPin(byte pin, bool enabled);
@@ -167,4 +97,3 @@ class RoverPeripheralFirmata : public FirmataFeature {
static void digitalPinValueCallback(byte pin, int value); static void digitalPinValueCallback(byte pin, int value);
static void systemResetCallback(); static void systemResetCallback();
}; };
+4 -7
View File
@@ -5,14 +5,11 @@ default_envs = esp32dev
platform = espressif32 platform = espressif32
framework = arduino framework = arduino
monitor_speed = 115200 monitor_speed = 115200
lib_extra_dirs = ../libraries
lib_deps = lib_deps =
; 3.2.0 targets the Arduino 2.x core shipped by PlatformIO's stable ESP32 ; Install the local package through PlatformIO's dependency manager so this
; platform. ConfigurableFirmata 3.4.0 switched its bundled PWM source to the ; reference project exercises the same transitive dependency behavior as an
; Arduino 3.x LEDC API even when that unused source is compiled as a dependency. ; external project using the published Registry package.
https://github.com/firmata/ConfigurableFirmata.git#3.2.0 RoverPeripheral=file://../libraries/RoverPeripheralFirmata
bblanchon/ArduinoJson@^7.4.2
madhephaestus/ESP32Servo@^3.0.8
; This is the generic ESP32-WROOM-32/DevKitC target used by boards carrying a ; This is the generic ESP32-WROOM-32/DevKitC target used by boards carrying a
; CH340 or CP210x USB-to-UART bridge. Linux normally exposes it as ttyUSB*. ; CH340 or CP210x USB-to-UART bridge. Linux normally exposes it as ttyUSB*.
+50 -75
View File
@@ -1,10 +1,4 @@
#include <Arduino.h> #include <RoverPeripheral.h>
#include <ConfigurableFirmata.h>
#include <FirmataExt.h>
#include <RoverPeripheralFirmata.h>
FirmataExt firmataExtension;
RoverPeripheralFirmata peripheral("Rover GPIO");
namespace { namespace {
// Every example pin is present on both the classic ESP32 DevKitC and the // Every example pin is present on both the classic ESP32 DevKitC and the
@@ -12,18 +6,33 @@ namespace {
// S3 boards use them for USB D- and D+. // S3 boards use them for USB D- and D+.
constexpr uint8_t kSpecialActionPin = 21; constexpr uint8_t kSpecialActionPin = 21;
void runSpecialAction() { int repeatCount = 1;
// This intentionally represents arbitrary device behavior rather than a raw String displayMessage;
// pin mapping. Replace it with a motor sequence, LED animation, actuator
// routine, or any other application-specific function the accessory needs. void runSpecialAction(bool pressed) {
digitalWrite(kSpecialActionPin, HIGH); // Receiving both button edges lets application hardware remain active only
delay(80); // while the driver holds the momentary control.
digitalWrite(kSpecialActionPin, LOW); digitalWrite(kSpecialActionPin, pressed ? HIGH : LOW);
} }
void registerBuiltInRoverControls() { void setRepeatCount(int value) {
// These three roles replace physical GPIO backends while preserving the // A real device can use this value when it starts its next animation or
// existing camera, headlight, and laser commands and HUD controls. // actuator sequence. Storing it keeps this reference callback non-blocking.
repeatCount = value;
}
void setDisplayMessage(const String& value) {
// Display hardware can render the stored value from updateRoverPeripheral().
// Avoiding Serial output is important because Serial belongs to Firmata.
displayMessage = value;
}
} // namespace
void configureRoverPeripheral(RoverPeripheral& io) {
io.name("Rover GPIO");
// Standard roles retain the rover's existing HUD controls while moving the
// electrical outputs to this ESP32 on either a Pi or laptop rover host.
RoverCameraServoConfig cameraServo; RoverCameraServoConfig cameraServo;
cameraServo.pin = 14; cameraServo.pin = 14;
cameraServo.minimumAngleDegrees = -15; cameraServo.minimumAngleDegrees = -15;
@@ -34,89 +43,55 @@ void registerBuiltInRoverControls() {
cameraServo.maximumPulseMicroseconds = 2100; cameraServo.maximumPulseMicroseconds = 2100;
cameraServo.allowRawPulse = false; cameraServo.allowRawPulse = false;
cameraServo.inverted = false; cameraServo.inverted = false;
peripheral.addRoverCameraServo(cameraServo); io.addCameraServo(cameraServo);
RoverDigitalOutputConfig headlight; RoverDigitalOutputConfig headlight;
headlight.pin = 18; headlight.pin = 18;
headlight.polarity = OutputPolarity::ActiveHigh; headlight.polarity = OutputPolarity::ActiveHigh;
headlight.initiallyOn = false; headlight.initiallyOn = false;
peripheral.addRoverHeadlight(headlight); io.addHeadlight(headlight);
RoverDigitalOutputConfig laser; RoverDigitalOutputConfig laser;
laser.pin = 16; laser.pin = 16;
laser.polarity = OutputPolarity::ActiveHigh; laser.polarity = OutputPolarity::ActiveHigh;
laser.initiallyOn = false; laser.initiallyOn = false;
peripheral.addRoverLaser(laser); io.addLaser(laser);
}
void registerGenericControls() { pinMode(kSpecialActionPin, OUTPUT);
// Registration order is UI order. This servo slider is handled entirely by digitalWrite(kSpecialActionPin, LOW);
// standard Firmata SET_PIN_MODE and EXTENDED_ANALOG messages from roverd.
// Accessory controls render in precisely this registration order.
SliderControlConfig servoPosition; SliderControlConfig servoPosition;
servoPosition.id = "servoPosition";
servoPosition.name = "Servo position"; servoPosition.name = "Servo position";
servoPosition.minimum = 0; servoPosition.minimum = 0;
servoPosition.maximum = 180; servoPosition.maximum = 180;
FirmataServoOutput servoOutput; ServoOutput servoOutput;
servoOutput.pin = 13; servoOutput.pin = 13;
peripheral.addServoSlider(servoPosition, servoOutput); io.addSlider(servoPosition, servoOutput);
// PWM brightness is another standard Firmata output. No sketch callback is
// involved when the driver moves this slider.
SliderControlConfig lightBrightness; SliderControlConfig lightBrightness;
lightBrightness.id = "lightBrightness";
lightBrightness.name = "Light brightness"; lightBrightness.name = "Light brightness";
lightBrightness.minimum = 0; lightBrightness.minimum = 0;
lightBrightness.maximum = 255; lightBrightness.maximum = 255;
FirmataPwmOutput lightOutput; PwmOutput lightOutput;
lightOutput.pin = 17; lightOutput.pin = 17;
peripheral.addPwmSlider(lightBrightness, lightOutput); io.addSlider(lightBrightness, lightOutput);
// A custom momentary control receives both press and release. This example
// runs a one-shot action only on press, but a motor could use both values to
// start while held and stop on release.
ButtonControlConfig specialAction; ButtonControlConfig specialAction;
specialAction.id = "specialAction"; specialAction.name = "Special action";
specialAction.name = "Run special action";
specialAction.mode = ButtonMode::Momentary; specialAction.mode = ButtonMode::Momentary;
peripheral.addButton(specialAction, [](bool pressed) { io.addButton(specialAction, runSpecialAction);
if (pressed) {
runSpecialAction(); NumberControlConfig repeats;
} repeats.name = "Repeat count";
}); repeats.minimum = 1;
} repeats.maximum = 20;
} // namespace io.addNumber(repeats, setRepeatCount);
void setup() { TextControlConfig message;
pinMode(kSpecialActionPin, OUTPUT); message.name = "Display message";
digitalWrite(kSpecialActionPin, LOW); message.maximumLength = 64;
io.addText(message, setDisplayMessage);
registerBuiltInRoverControls();
registerGenericControls();
// Supplying Serial as a Stream keeps all protocol code identical between a
// CH340/CP210x UART bridge and native ESP32-S3 USB CDC. Only PlatformIO's S3
// build flags differ.
Serial.begin(115200);
Firmata.begin(Serial);
peripheral.begin(firmataExtension);
// A Firmata system reset establishes declared initial output states and also
// proves that all callbacks were installed before normal traffic begins.
Firmata.parse(SYSTEM_RESET);
}
void loop() {
// Processing one complete parser unit at a time prevents a long serial burst
// from starving application work while still draining ordinary USB traffic
// quickly on both supported transports.
while (Firmata.available()) {
Firmata.processInput();
if (!Firmata.isParsingMessage()) {
break;
}
}
peripheral.update();
} }
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -12,8 +12,8 @@
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" /> <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<!-- site-metadata:inject --> <!-- site-metadata:inject -->
<!-- analytics:inject --> <!-- analytics:inject -->
<script type="module" crossorigin src="/assets/index-BZ2ymoHR.js"></script> <script type="module" crossorigin src="/assets/index-CNVNbsvk.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-BFNKIMjg.css"> <link rel="stylesheet" crossorigin href="/assets/index-B5TEaoXl.css">
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>
@@ -239,6 +239,11 @@ function createRosterLifecycle(deps) {
cameraServo: record.meta?.cameraServo, cameraServo: record.meta?.cameraServo,
audio: record.meta?.audio, audio: record.meta?.audio,
horn: record.meta?.horn, horn: record.meta?.horn,
// Generic peripheral metadata is already reduced by roverd to the fields
// the browser needs: stable process-local IDs, display names, and ordered
// controls. Preserve that order here instead of rebuilding the inventory,
// because ESP32 registration order is also the driver's display order.
peripherals: Array.isArray(record.meta?.peripherals) ? record.meta.peripherals : [],
headlight: record.meta?.headlight headlight: record.meta?.headlight
? { ...record.meta.headlight, state: record.headlightState } ? { ...record.meta.headlight, state: record.headlightState }
: record.meta?.headlight, : record.meta?.headlight,
@@ -0,0 +1,65 @@
// Rover Roster Lifecycle Tests
// Purpose: Verifies that boot-discovered accessory metadata reaches the public rover roster unchanged.
// Scope: Covers roster projection only; roverd remains responsible for validating and reducing device descriptions.
const test = require('node:test');
const assert = require('node:assert/strict');
const { createRosterLifecycle } = require('./rosterLifecycle');
function createRosterManager(meta) {
const rovers = new Map([[
meta.name,
{
id: meta.name,
meta,
batteryState: null,
headlightState: null,
laserState: null,
locked: false,
lockReason: null,
lastSeen: 123,
},
]]);
return createRosterLifecycle({
rovers,
isPrivateRecord: () => false,
isPrivateOpen: () => true,
getPrivateSafety: () => ({}),
});
}
test('getRoster preserves peripheral and control registration order', () => {
const peripherals = [
{
id: 'firmata-0',
name: 'Camera arm',
controls: [
{ id: 'position', type: 'slider', name: 'Position', min: 0, max: 180 },
{ id: 'action', type: 'button', name: 'Action', mode: 'momentary' },
],
},
{
id: 'firmata-1',
name: 'Lighting',
controls: [
{ id: 'brightness', type: 'number', name: 'Brightness', min: 0, max: 255 },
],
},
];
const manager = createRosterManager({ name: 'rover-one', peripherals });
const [entry] = manager.getRoster();
// Deep equality verifies both the public field set and array order. The
// server must not alphabetize controls because their firmware order is a UI
// contract rather than incidental transport ordering.
assert.deepEqual(entry.peripherals, peripherals);
});
test('getRoster supplies an empty peripheral inventory when none was advertised', () => {
const manager = createRosterManager({ name: 'rover-one' });
const [entry] = manager.getRoster();
assert.deepEqual(entry.peripherals, []);
});
@@ -0,0 +1,42 @@
// Desktop Accessories Expansion
// Purpose: Places generic rover controls on a collapsible surface centered along the video's left wall.
// Scope: Owns desktop positioning and persisted visibility while reusing the layout-independent control renderer.
import RoverAccessoryControls from '../../../RoverAccessoryControls/index.jsx';
import AccessoriesToggle from '../../../RoverAccessoryControls/AccessoriesToggle.jsx';
import useRoverAccessories from '../../../RoverAccessoryControls/useRoverAccessories.js';
import usePodVisibility from './usePodVisibility.js';
export default function AccessoriesExpansion({ roverId }) {
const { hasAccessories } = useRoverAccessories(roverId);
const [open, setOpen] = usePodVisibility('accessories', false);
// Do not leave an invisible anchor or reserved HUD area on rovers whose
// peripherals provide only standardized controls or no controls at all.
if (!hasAccessories) return null;
return (
<div className="pointer-events-none absolute inset-y-0 left-0 z-20 flex items-center">
{/* The tab remains attached to the video's left wall. When open, this
single shell grows around both the unchanged tab position and the
controls to its right, so the controls are not rendered as a second
disconnected panel. Its height follows content until the shared
renderer reaches the scrolling boundary. */}
<div className="pointer-events-auto flex max-h-[70vh] items-center overflow-hidden rounded-r-xl bg-black/60">
<AccessoriesToggle
label="Accessories"
ariaLabel={open ? 'Hide accessory controls' : 'Show accessory controls'}
onClick={() => setOpen(!open)}
hud
className="!h-28"
/>
{open ? (
<RoverAccessoryControls
roverId={roverId}
plainCenteredHeadings
className="w-64 max-h-[70vh]"
/>
) : null}
</div>
</div>
);
}
@@ -4,6 +4,7 @@ import TopLeftPod from './TopLeftPod.jsx';
import TopRightPod from './TopRightPod.jsx'; import TopRightPod from './TopRightPod.jsx';
import BottomLeftPod from './BottomLeftPod.jsx'; import BottomLeftPod from './BottomLeftPod.jsx';
import BottomRightPod from './BottomRightPod.jsx'; import BottomRightPod from './BottomRightPod.jsx';
import AccessoriesExpansion from './AccessoriesExpansion.jsx';
import { useDriverLayout } from '../../../../layouts/driver/DriverLayoutContext.jsx'; import { useDriverLayout } from '../../../../layouts/driver/DriverLayoutContext.jsx';
export default function CornerPods({ roverId }) { export default function CornerPods({ roverId }) {
@@ -17,6 +18,9 @@ export default function CornerPods({ roverId }) {
{/* The mobile layouts already provide large touch controls around the video. {/* The mobile layouts already provide large touch controls around the video.
Omitting this pod avoids presenting duplicate horn, light, and laser actions. */} Omitting this pod avoids presenting duplicate horn, light, and laser actions. */}
{showPhysicalControlPods ? <BottomLeftPod roverId={roverId} /> : null} {showPhysicalControlPods ? <BottomLeftPod roverId={roverId} /> : null}
{/* Generic accessory controls stay on the same left side at every
breakpoint, but mobile owns their placement inside AuxColumn. */}
{showPhysicalControlPods ? <AccessoriesExpansion roverId={roverId} /> : null}
{/* BottomRightPod also owns the independent chat expansion, so it remains mounted {/* BottomRightPod also owns the independent chat expansion, so it remains mounted
on mobile and determines its own camera-control visibility from layout context. */} on mobile and determines its own camera-control visibility from layout context. */}
<BottomRightPod roverId={roverId} /> <BottomRightPod roverId={roverId} />
@@ -1,7 +1,7 @@
// Aux Column // Aux Column
// Purpose: Assembles the mobile auxiliary controls column, which is the left column by default. // Purpose: Assembles the mobile auxiliary controls column, which is the left column by default.
// Scope: Owns mobile aux/camera/headlight/laser/horn wiring while reusing desktop variation components where intended. // Scope: Owns mobile aux/camera/headlight/laser/horn wiring while reusing desktop variation components where intended.
import { useCallback, useEffect, useRef } from 'react'; import { useCallback, useEffect, useRef, useState } from 'react';
import { FaBullhorn, FaCrosshairs, FaLightbulb } from 'react-icons/fa'; import { FaBullhorn, FaCrosshairs, FaLightbulb } from 'react-icons/fa';
import './mobileControls.css'; import './mobileControls.css';
import { useControlActions, useControlSelector } from '../../controls/index.js'; import { useControlActions, useControlSelector } from '../../controls/index.js';
@@ -15,11 +15,14 @@ import { AUX_ZERO } from './constants.js';
import VacuumControls from './VacuumControls.jsx'; import VacuumControls from './VacuumControls.jsx';
import VerticalCameraTilt from './VerticalCameraTilt.jsx'; import VerticalCameraTilt from './VerticalCameraTilt.jsx';
import { useSessionSelector } from '../../context/SessionContext.jsx'; import { useSessionSelector } from '../../context/SessionContext.jsx';
import RoverAccessoryControls from '../RoverAccessoryControls/index.jsx';
import AccessoriesToggle from '../RoverAccessoryControls/AccessoriesToggle.jsx';
import useRoverAccessories from '../RoverAccessoryControls/useRoverAccessories.js';
const CAMERA_TILT_STEP_DEGREES = 0.5; const CAMERA_TILT_STEP_DEGREES = 0.5;
const CAMERA_TILT_PRECISION_STEP_DEGREES = 0.1; const CAMERA_TILT_PRECISION_STEP_DEGREES = 0.1;
function AuxColumnContent() { function AuxColumnContent({ accessoriesAvailable, onShowAccessories }) {
const roverId = useControlSelector((control) => control.state.roverId); const roverId = useControlSelector((control) => control.state.roverId);
const roomLightsLockedOn = useSessionSelector((state) => Boolean(state.session?.homeAssistant?.lightPolicy?.lockedOn)); const roomLightsLockedOn = useSessionSelector((state) => Boolean(state.session?.homeAssistant?.lightPolicy?.lockedOn));
const camera = useControlSelector((control) => control.state.camera); const camera = useControlSelector((control) => control.state.camera);
@@ -119,11 +122,20 @@ function AuxColumnContent() {
return ( return (
<div className="mobile-touch-control grid h-full min-h-0 w-full grid-rows-[minmax(0,1fr)_minmax(0,1fr)_minmax(0,1fr)] gap-0.5 text-slate-100"> <div className="mobile-touch-control grid h-full min-h-0 w-full grid-rows-[minmax(0,1fr)_minmax(0,1fr)_minmax(0,1fr)] gap-0.5 text-slate-100">
<div className={`mobile-touch-control grid min-h-0 gap-0.5 ${accessoriesAvailable ? 'grid-cols-[minmax(0,1fr)_2rem]' : 'grid-cols-1'}`}>
<VacuumControls <VacuumControls
disabled={vacuumDisabled} disabled={vacuumDisabled}
onPress={handleAuxPress} onPress={handleAuxPress}
onRelease={handleAuxRelease} onRelease={handleAuxRelease}
/> />
{accessoriesAvailable ? (
<AccessoriesToggle
label="Accessories"
ariaLabel="Show accessory controls"
onClick={onShowAccessories}
/>
) : null}
</div>
<div className="mobile-touch-control flex min-h-0 items-stretch gap-0.5"> <div className="mobile-touch-control flex min-h-0 items-stretch gap-0.5">
{cameraEnabled ? ( {cameraEnabled ? (
<VerticalCameraTilt <VerticalCameraTilt
@@ -180,10 +192,49 @@ function AuxColumnContent() {
); );
} }
export default function AuxColumn({ layout, className = '' }) { function RoverAuxColumn({ roverId, layout, className }) {
const [showAccessories, setShowAccessories] = useState(false);
const { hasAccessories } = useRoverAccessories(roverId);
return ( return (
<div className={`mobile-touch-control flex flex-col gap-0.5 ${className}`.trim()} data-mobile-layout={layout}> <div className={`mobile-touch-control flex flex-col gap-0.5 ${className}`.trim()} data-mobile-layout={layout}>
<AuxColumnContent /> {showAccessories && hasAccessories ? (
<div className="mobile-touch-control h-full min-h-0 overflow-hidden">
<RoverAccessoryControls
roverId={roverId}
className="h-full"
headerAction={(
<AccessoriesToggle
label="Back"
ariaLabel="Return to auxiliary controls"
compact
onClick={() => setShowAccessories(false)}
/>
)}
/>
</div>
) : (
<AuxColumnContent
accessoriesAvailable={hasAccessories}
onShowAccessories={() => setShowAccessories(true)}
/>
)}
</div> </div>
); );
} }
export default function AuxColumn({ layout, className = '' }) {
const roverId = useControlSelector((control) => control.state.roverId);
// Keying the stateful view by assignment makes every newly selected rover
// start in the familiar Aux view. It also guarantees that an Accessories
// view cannot remain open after changing to a rover without accessories.
return (
<RoverAuxColumn
key={roverId || 'no-rover'}
roverId={roverId}
layout={layout}
className={className}
/>
);
}
@@ -0,0 +1,45 @@
// Accessories Vertical Toggle
// Purpose: Gives mobile and desktop the same edge-mounted control for changing accessory visibility.
// Scope: Owns only visual treatment and activation; each parent decides its position and destination.
import { FaPuzzlePiece } from 'react-icons/fa';
import { triggerTouchHaptic } from '../../lib/touchHaptics.js';
export default function AccessoriesToggle({
label,
ariaLabel,
onClick,
compact = false,
hud = false,
className = '',
}) {
// The Back action is still shorter than the vertical launcher, but it needs
// a normal touch target and a readable word instead of the previous tiny
// abbreviated control. The full launcher continues to fill the height its
// desktop or mobile parent assigns to it.
const sizeClass = compact ? 'h-8 w-14' : 'h-full w-8';
const toneClass = hud
// On desktop the tab sits inside the shared popout shell. Rounding only
// its exposed right edge preserves its left-wall attachment without
// introducing a separate accessory-specific panel treatment.
? 'rounded-r-xl border-0 bg-black/60 text-white shadow-none'
: 'rounded-xl border-2 border-cyan-300/70 bg-cyan-900 text-cyan-50 shadow-md';
return (
<button
type="button"
aria-label={ariaLabel || label}
onClick={() => {
triggerTouchHaptic('button');
onClick();
}}
className={`mobile-touch-control flex shrink-0 items-center justify-center text-sm font-semibold ${sizeClass} ${toneClass} ${className}`.trim()}
>
{/* Full launchers use vertical writing in the narrow wall space. The
compact Back action stays horizontal so it fits in the heading. */}
<span className={compact ? 'flex items-center' : 'flex items-center gap-1 [writing-mode:vertical-rl] rotate-180'}>
{!compact ? <FaPuzzlePiece className="shrink-0 text-sm" aria-hidden="true" /> : null}
<span>{label}</span>
</span>
</button>
);
}
@@ -0,0 +1,337 @@
// Accessory Control Field
// Purpose: Maps one firmware-advertised generic control to a compact rover-control surface.
// Scope: Owns browser-local input semantics; transport and device-specific behavior stay outside this file.
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { triggerTouchHaptic } from '../../lib/touchHaptics.js';
function integerBound(value, fallback) {
return Number.isInteger(value) ? value : fallback;
}
function clampInteger(value, minimum, maximum) {
const numeric = Number(value);
if (!Number.isFinite(numeric)) return minimum;
return Math.min(maximum, Math.max(minimum, Math.round(numeric)));
}
function trimUnicode(value, maximumLength) {
// Array.from counts Unicode code points instead of UTF-16 code units. That
// mirrors Go's rune-count validation for emoji and other non-BMP characters.
return Array.from(String(value ?? '')).slice(0, maximumLength).join('');
}
const CARD_CLASS = 'mobile-touch-control rounded-xl border-2 px-2 py-1 text-slate-50';
// Sliders already contain a label row and a separate track. Giving that
// two-row control the same vertical padding as a one-row button made it much
// taller than its neighbors without improving its touch target.
const SLIDER_CARD_CLASS = 'mobile-touch-control rounded-xl border-2 px-1.5 py-0.5 text-slate-50';
const DISABLED_CLASS = 'disabled:cursor-not-allowed disabled:opacity-40';
function SliderControl({ peripheralId, control, disabled, send, value: storedValue }) {
const minimum = integerBound(control.min, 0);
const maximum = integerBound(control.max, minimum);
const value = Number.isInteger(storedValue)
? clampInteger(storedValue, minimum, maximum)
: minimum;
const trackRef = useRef(null);
const pointerIdRef = useRef(null);
const lastHapticValueRef = useRef(value);
const valuePercent = useMemo(() => {
if (maximum === minimum) return 50;
return ((value - minimum) / (maximum - minimum)) * 100;
}, [maximum, minimum, value]);
const sendValue = useCallback((nextValue) => {
const next = clampInteger(nextValue, minimum, maximum);
const hapticStep = Math.max(1, Math.round((maximum - minimum) / 20));
if (Math.abs(next - lastHapticValueRef.current) >= hapticStep) {
triggerTouchHaptic('camera');
lastHapticValueRef.current = next;
}
send(peripheralId, control.id, next);
}, [control.id, maximum, minimum, peripheralId, send]);
const valueFromPointer = useCallback((event) => {
const track = trackRef.current;
if (!track) return value;
const bounds = track.getBoundingClientRect();
const rawPercent = (event.clientX - bounds.left) / Math.max(1, bounds.width);
return minimum + Math.max(0, Math.min(1, rawPercent)) * (maximum - minimum);
}, [maximum, minimum, value]);
const updateFromPointer = useCallback((event) => {
sendValue(valueFromPointer(event));
}, [sendValue, valueFromPointer]);
const handlePointerDown = useCallback((event) => {
if (disabled || pointerIdRef.current !== null) return;
// This follows VerticalCameraTilt's custom pointer-capture path so a range
// drag remains reliable while another finger is operating the drive pad.
event.preventDefault();
pointerIdRef.current = event.pointerId;
lastHapticValueRef.current = value;
trackRef.current?.setPointerCapture?.(event.pointerId);
updateFromPointer(event);
}, [disabled, updateFromPointer, value]);
const handlePointerMove = useCallback((event) => {
if (pointerIdRef.current !== event.pointerId) return;
event.preventDefault();
updateFromPointer(event);
}, [updateFromPointer]);
const handlePointerEnd = useCallback((event) => {
if (pointerIdRef.current !== event.pointerId) return;
event.preventDefault();
pointerIdRef.current = null;
trackRef.current?.releasePointerCapture?.(event.pointerId);
}, []);
const handleKeyDown = (event) => {
if (disabled) return;
let next = null;
if (event.key === 'ArrowLeft' || event.key === 'ArrowDown') next = value - 1;
if (event.key === 'ArrowRight' || event.key === 'ArrowUp') next = value + 1;
if (event.key === 'Home') next = minimum;
if (event.key === 'End') next = maximum;
if (next == null) return;
event.preventDefault();
sendValue(next);
};
return (
<div className={`${SLIDER_CARD_CLASS} border-emerald-300/70 bg-emerald-900 ${disabled ? 'cursor-not-allowed opacity-40' : ''}`}>
<div className="flex items-center justify-between gap-1 text-sm font-semibold">
<span className="min-w-0 truncate">{control.name}</span>
<span className="shrink-0 font-mono text-emerald-100">{value}</span>
</div>
<div
ref={trackRef}
role="slider"
aria-label={control.name}
aria-valuemin={minimum}
aria-valuemax={maximum}
aria-valuenow={value}
aria-disabled={disabled}
tabIndex={disabled ? -1 : 0}
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerEnd}
onPointerCancel={handlePointerEnd}
onLostPointerCapture={(event) => {
if (pointerIdRef.current === event.pointerId) pointerIdRef.current = null;
}}
onKeyDown={handleKeyDown}
onContextMenu={(event) => event.preventDefault()}
style={{ touchAction: 'none' }}
className="mobile-touch-control mobile-drag-control relative mt-0.5 h-6 w-full rounded-full border border-emerald-100/80 bg-emerald-950 shadow-inner focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-emerald-200"
>
{/* An inset track gives the thumb room to remain entirely inside the
card at both endpoints without browser-specific range styling. */}
<div className="pointer-events-none absolute inset-1">
<div
className="absolute inset-y-0 left-0 rounded-full bg-emerald-400"
style={{ width: `${valuePercent}%` }}
/>
</div>
<div
className="pointer-events-none absolute top-1/2 h-3.5 w-3.5 -translate-x-1/2 -translate-y-1/2 rounded-full border border-emerald-950 bg-emerald-200 shadow"
style={{ left: `clamp(0.4375rem, ${valuePercent}%, calc(100% - 0.4375rem))` }}
/>
</div>
</div>
);
}
function ToggleControl({ peripheralId, control, disabled, send, value }) {
const enabled = value === true;
const toggle = () => {
if (disabled) return;
send(peripheralId, control.id, !enabled);
triggerTouchHaptic('button');
};
return (
<button
type="button"
aria-pressed={enabled}
disabled={disabled}
onClick={toggle}
className={`${CARD_CLASS} ${DISABLED_CLASS} flex min-h-12 w-full items-center justify-between gap-1 font-semibold ${enabled ? 'border-emerald-300/70 bg-emerald-800 text-emerald-50' : 'border-amber-300/70 bg-amber-900 text-amber-50'}`}
>
<span className="min-w-0 truncate">{control.name}</span>
<span className="shrink-0 text-xs">{enabled ? 'On' : 'Off'}</span>
</button>
);
}
function MomentaryControl({ peripheralId, control, disabled, send, value }) {
const pressed = value === true;
const pressedRef = useRef(false);
const pointerIdRef = useRef(null);
const release = useCallback(() => {
if (!pressedRef.current) return;
pressedRef.current = false;
pointerIdRef.current = null;
// Always pair a successful press with false, including cancellation,
// permission loss, and replacement of the Accessories view.
send(peripheralId, control.id, false);
}, [control.id, peripheralId, send]);
const press = useCallback(() => {
if (disabled || pressedRef.current) return;
pressedRef.current = true;
send(peripheralId, control.id, true);
}, [control.id, disabled, peripheralId, send]);
useEffect(() => release, [release]);
useEffect(() => {
if (disabled) release();
}, [disabled, release]);
return (
<button
type="button"
aria-pressed={pressed}
disabled={disabled}
onPointerDown={(event) => {
if (disabled || pointerIdRef.current != null) return;
event.preventDefault();
pointerIdRef.current = event.pointerId;
event.currentTarget.setPointerCapture?.(event.pointerId);
press();
}}
onPointerUp={(event) => {
if (pointerIdRef.current !== event.pointerId) return;
release();
triggerTouchHaptic('button');
}}
onPointerCancel={release}
onLostPointerCapture={release}
onKeyDown={(event) => {
if ((event.key === ' ' || event.key === 'Enter') && !event.repeat) {
event.preventDefault();
press();
}
}}
onKeyUp={(event) => {
if (event.key === ' ' || event.key === 'Enter') {
event.preventDefault();
release();
triggerTouchHaptic('button');
}
}}
onContextMenu={(event) => event.preventDefault()}
className={`${CARD_CLASS} ${DISABLED_CLASS} flex min-h-12 w-full items-center justify-center text-center font-semibold ${pressed ? 'border-fuchsia-200 bg-fuchsia-600 text-white' : 'border-fuchsia-300/70 bg-fuchsia-900 text-fuchsia-50'}`}
>
{control.name}
</button>
);
}
function NumberControl({ peripheralId, control, disabled, send, value: storedValue }) {
const minimum = integerBound(control.min, 0);
const maximum = integerBound(control.max, minimum);
const initialValue = Number.isInteger(storedValue)
? clampInteger(storedValue, minimum, maximum)
: minimum;
const [value, setValue] = useState(String(initialValue));
const lastSentRef = useRef(initialValue);
const commit = () => {
const next = clampInteger(value, minimum, maximum);
setValue(String(next));
if (disabled || next === lastSentRef.current) return;
lastSentRef.current = next;
send(peripheralId, control.id, next);
triggerTouchHaptic('button');
};
return (
<label className={`${CARD_CLASS} flex min-h-12 items-center gap-1 border-indigo-300/70 bg-indigo-900`}>
<span className="min-w-0 flex-1 truncate text-sm font-semibold">{control.name}</span>
<input
type="number"
inputMode="numeric"
min={minimum}
max={maximum}
step="1"
value={value}
disabled={disabled}
aria-label={`${control.name}, ${minimum} to ${maximum}`}
onChange={(event) => setValue(event.target.value)}
onBlur={commit}
onKeyDown={(event) => {
if (event.key === 'Enter') {
event.preventDefault();
commit();
event.currentTarget.blur();
}
}}
className={`mobile-touch-control h-9 w-[45%] min-w-16 rounded-lg border border-indigo-200/70 bg-indigo-950 px-1.5 text-right text-base text-white outline-none focus-visible:ring-2 focus-visible:ring-indigo-200 ${DISABLED_CLASS}`}
/>
</label>
);
}
function TextControl({ peripheralId, control, disabled, send, value: storedValue }) {
const maximumLength = Math.max(1, integerBound(control.maxLength, 1));
const initialValue = typeof storedValue === 'string'
? trimUnicode(storedValue, maximumLength)
: '';
const [value, setValue] = useState(initialValue);
const lastSentRef = useRef(initialValue);
const commit = () => {
const next = trimUnicode(value, maximumLength);
setValue(next);
if (disabled || next === lastSentRef.current) return;
lastSentRef.current = next;
send(peripheralId, control.id, next);
triggerTouchHaptic('button');
};
return (
<label className={`${CARD_CLASS} flex min-h-12 items-center gap-1 border-sky-300/70 bg-sky-900`}>
<span className="min-w-0 flex-1 truncate text-sm font-semibold">{control.name}</span>
<input
type="text"
value={value}
disabled={disabled}
aria-label={`${control.name}, maximum ${maximumLength} characters`}
onChange={(event) => setValue(trimUnicode(event.target.value, maximumLength))}
onBlur={commit}
onKeyDown={(event) => {
if (event.key === 'Enter') {
event.preventDefault();
commit();
event.currentTarget.blur();
}
}}
className={`mobile-touch-control h-9 w-[55%] min-w-20 rounded-lg border border-sky-200/70 bg-sky-950 px-1.5 text-base text-white outline-none focus-visible:ring-2 focus-visible:ring-sky-200 ${DISABLED_CLASS}`}
/>
</label>
);
}
export default function AccessoryControlField(props) {
switch (props.control?.type) {
case 'slider':
return <SliderControl {...props} />;
case 'button':
return props.control.mode === 'momentary'
? <MomentaryControl {...props} />
: <ToggleControl {...props} />;
case 'number':
return <NumberControl {...props} />;
case 'text':
return <TextControl {...props} />;
default:
// roverd validates the four-type contract before publishing metadata.
// Returning nothing remains a defensive boundary for stale servers.
return null;
}
}
@@ -0,0 +1,73 @@
// Rover Accessory Controls
// Purpose: Renders every generic control advertised by a selected rover as one ordered control surface.
// Scope: Reusable content only; mobile and desktop parents own placement, expansion, and visibility.
import { useCallback } from 'react';
import { useControlActions, useControlSelector } from '../../controls/index.js';
import useCanControlRover from '../../hooks/useCanControlRover.js';
import AccessoryControlField from './AccessoryControlField.jsx';
import useRoverAccessories from './useRoverAccessories.js';
const EMPTY_ACCESSORY_VALUES = Object.freeze({});
export default function RoverAccessoryControls({
roverId,
headerAction = null,
plainCenteredHeadings = false,
className = '',
}) {
const { peripherals } = useRoverAccessories(roverId);
const canControl = useCanControlRover(roverId);
const { setPeripheralControl } = useControlActions();
const values = useControlSelector(
(control) => control.state.peripheralValues?.[String(roverId)] || EMPTY_ACCESSORY_VALUES,
);
const send = useCallback(
(peripheralId, controlId, value) => setPeripheralControl(peripheralId, controlId, value),
[setPeripheralControl],
);
if (peripherals.length === 0) return null;
return (
<div
className={`mobile-touch-control min-h-0 overflow-y-auto overscroll-contain text-slate-100 ${className}`.trim()}
aria-label="Rover accessories"
>
{peripherals.map((peripheral, peripheralIndex) => {
return (
<section key={peripheral.id} className="mb-0.5 flex flex-col gap-0.5 last:mb-0">
{/* The firmware's array order is authoritative. Mapping directly over
it keeps physical authoring order intact across every UI host.
Every peripheral keeps its heading even when it is the only
device, because its firmware-provided name identifies which
physical accessory owns the controls below it. */}
<div
className={`flex min-h-8 items-center gap-1 px-1 text-xs font-semibold text-white ${plainCenteredHeadings ? '' : 'bg-black/60'}`.trim()}
>
{/* Desktop already supplies one continuous HUD background, so
its title needs neither a second tone nor left alignment.
Mobile keeps the ordinary heading because it also carries
the Back action on the opposite side. */}
<h3 className={`min-w-0 flex-1 truncate ${plainCenteredHeadings ? 'text-center' : ''}`.trim()}>
{peripheral.name}
</h3>
{peripheralIndex === 0 ? headerAction : null}
</div>
<div className="flex flex-col gap-0.5">
{peripheral.controls.map((control) => (
<AccessoryControlField
key={control.id}
peripheralId={peripheral.id}
control={control}
value={values[peripheral.id]?.[control.id]}
disabled={!roverId || !canControl}
send={send}
/>
))}
</div>
</section>
);
})}
</div>
);
}
@@ -0,0 +1,29 @@
// Rover Accessories Selector
// Purpose: Provides the ordered generic-control inventory advertised by one rover.
// Scope: Selects public roster metadata only; placement and visibility remain parent-UI decisions.
import { useMemo } from 'react';
import { useSessionSelector } from '../../context/SessionContext.jsx';
export default function useRoverAccessories(roverId) {
const rosterEntry = useSessionSelector((state) => {
if (!roverId) return null;
const roster = Array.isArray(state.session?.roster) ? state.session.roster : [];
return roster.find((entry) => String(entry.id) === String(roverId)) || null;
});
const advertisedPeripherals = rosterEntry?.peripherals;
const peripherals = useMemo(() => {
if (!Array.isArray(advertisedPeripherals)) return [];
// A peripheral with no generic controls may still provide a standardized
// camera, headlight, or laser backend. Those roles use their established
// HUD controls and must not create an empty Accessories surface.
return advertisedPeripherals.filter(
(peripheral) => Array.isArray(peripheral?.controls) && peripheral.controls.length > 0,
);
}, [advertisedPeripherals]);
return {
peripherals,
hasAccessories: peripherals.length > 0,
};
}
+26
View File
@@ -66,6 +66,7 @@ const CONTROL_ACTION_NAMES = [
'sendSong', 'sendSong',
'startHorn', 'startHorn',
'stopHorn', 'stopHorn',
'setPeripheralControl',
'setMicPttActive', 'setMicPttActive',
]; ];
@@ -724,6 +725,29 @@ export function ControlSystemProvider({ children }) {
dispatch({ type: 'control/set-mic-ptt', payload: Boolean(active) }); dispatch({ type: 'control/set-mic-ptt', payload: Boolean(active) });
}, []); }, []);
const setPeripheralControl = useCallback(
(peripheralId, controlId, value) => {
const sent = pipeline.sendPeripheralControl(peripheralId, controlId, value);
if (sent) {
// The generic protocol is currently command-only. Recording the value
// here lets every renderer instance share the browser's latest intent
// without pretending that it is device-reported telemetry.
dispatch({
type: 'control/set-peripheral-value',
payload: {
roverId: pipeline.roverId,
peripheralId,
controlId,
value,
},
});
recordControlIntent();
}
return sent;
},
[pipeline, recordControlIntent],
);
const actionImplementations = useMemo( const actionImplementations = useMemo(
() => ({ () => ({
setMode, setMode,
@@ -751,6 +775,7 @@ export function ControlSystemProvider({ children }) {
sendSong, sendSong,
startHorn, startHorn,
stopHorn, stopHorn,
setPeripheralControl,
setMicPttActive, setMicPttActive,
}), }),
[ [
@@ -779,6 +804,7 @@ export function ControlSystemProvider({ children }) {
sendSong, sendSong,
startHorn, startHorn,
stopHorn, stopHorn,
setPeripheralControl,
setMicPttActive, setMicPttActive,
], ],
); );
+26
View File
@@ -44,6 +44,11 @@ export function useCommandPipeline(options = {}) {
return rosterEntry.horn; return rosterEntry.horn;
}, [rosterEntry]); }, [rosterEntry]);
const peripherals = useMemo(
() => (Array.isArray(rosterEntry?.peripherals) ? rosterEntry.peripherals : []),
[rosterEntry],
);
const headlightState = useMemo(() => rosterEntry?.headlight?.state ?? null, [rosterEntry]); const headlightState = useMemo(() => rosterEntry?.headlight?.state ?? null, [rosterEntry]);
const laserState = useMemo(() => rosterEntry?.laser?.state ?? null, [rosterEntry]); const laserState = useMemo(() => rosterEntry?.laser?.state ?? null, [rosterEntry]);
const emitCommand = useCallback( const emitCommand = useCallback(
@@ -208,6 +213,22 @@ export function useCommandPipeline(options = {}) {
[emitCommand, roverId], [emitCommand, roverId],
); );
const sendPeripheralControl = useCallback(
(peripheralId, controlId, value) => {
if (!roverId || !peripheralId || !controlId) return null;
const peripheral = { id: peripheralId, control: controlId, value };
// Peripheral commands deliberately use the same command envelope as all
// other rover actuation. This keeps turn authorization, acknowledgements,
// and rover WebSocket routing in the server's existing command boundary.
emitCommand({
type: 'peripheral',
data: { peripheral },
});
return peripheral;
},
[emitCommand, roverId],
);
const sendSong = useCallback( const sendSong = useCallback(
(notes = [], options = {}) => { (notes = [], options = {}) => {
if (!roverId) return null; if (!roverId) return null;
@@ -244,6 +265,7 @@ export function useCommandPipeline(options = {}) {
laser, laser,
laserState, laserState,
horn, horn,
peripherals,
emitCommand, emitCommand,
enableSensorStream, enableSensorStream,
sendDriveDirect, sendDriveDirect,
@@ -253,6 +275,7 @@ export function useCommandPipeline(options = {}) {
sendHeadlight, sendHeadlight,
sendLaser, sendLaser,
sendHorn, sendHorn,
sendPeripheralControl,
sendSong, sendSong,
runMacroSteps, runMacroSteps,
}), }),
@@ -265,6 +288,7 @@ export function useCommandPipeline(options = {}) {
laser, laser,
laserState, laserState,
horn, horn,
peripherals,
emitCommand, emitCommand,
enableSensorStream, enableSensorStream,
sendDriveDirect, sendDriveDirect,
@@ -274,6 +298,8 @@ export function useCommandPipeline(options = {}) {
sendHeadlight, sendHeadlight,
sendLaser, sendLaser,
sendHorn, sendHorn,
sendPeripheralControl,
sendSong,
runMacroSteps, runMacroSteps,
], ],
); );
+25
View File
@@ -69,6 +69,10 @@ export const initialControlState = {
macros: DEFAULT_MACROS, macros: DEFAULT_MACROS,
keymap: DEFAULT_KEYMAP, keymap: DEFAULT_KEYMAP,
inputs: {}, inputs: {},
// Generic accessory controls do not currently report state back from roverd.
// Keep the last browser-issued value per rover/device/control so closing a
// drawer or changing responsive layouts does not make the UI lie about it.
peripheralValues: {},
}; };
export function controlReducer(state, action) { export function controlReducer(state, action) {
@@ -226,6 +230,27 @@ export function controlReducer(state, action) {
active: Boolean(action.payload), active: Boolean(action.payload),
}, },
}; };
case 'control/set-peripheral-value': {
const roverId = String(action.payload?.roverId || '');
const peripheralId = String(action.payload?.peripheralId || '');
const controlId = String(action.payload?.controlId || '');
if (!roverId || !peripheralId || !controlId) return state;
const roverValues = state.peripheralValues?.[roverId] || {};
const peripheralValues = roverValues[peripheralId] || {};
return {
...state,
peripheralValues: {
...(state.peripheralValues || {}),
[roverId]: {
...roverValues,
[peripheralId]: {
...peripheralValues,
[controlId]: action.payload.value,
},
},
},
};
}
default: default:
return state; return state;
} }