Compare commits

...
26 Commits
Author SHA1 Message Date
legop3 b199f45eb2 add bars to thingy flingy 2026-09-13 15:43:41 -04:00
legop3 0f08fb3f0d Merge pull request #25 from legop3/newcontroller
Newcontroller
2026-09-13 02:09:38 -04:00
legop3 0f5a33c1de slop tank steering 2026-09-13 01:41:32 -04:00
legop3 8e96c3cdae glorp! 2026-09-12 20:52:34 -04:00
legop3 ba5c1c5d25 dont stop assignments for help rovers, just allow people to leave them. 2026-09-12 18:58:32 -04:00
legop3 6a914faffb Merge pull request #24 from legop3/HELP
Help
2026-09-12 18:34:35 -04:00
legop3 3b99590b3b lowe note one octave 2026-09-12 18:32:44 -04:00
legop3 5acbf6e0bf fix song hopefully? 2026-09-12 18:14:43 -04:00
legop3 3ec45de4b4 also beep roomba at the same time 2026-09-12 17:44:12 -04:00
legop3 3b7b2ac21e horn beep help thing yay 2026-09-12 17:17:18 -04:00
legop3 02a32e2524 fix help while docked lol 2026-09-12 17:04:21 -04:00
legop3 eb1fab50e4 adjust flashings 2026-09-12 16:43:36 -04:00
legop3 f85e258c29 adjust flashings 2026-09-12 16:41:23 -04:00
legop3 72b8db8a31 dont like shadow 2026-09-12 16:39:40 -04:00
legop3 fb31ff52bd HELP!!!! 2026-09-12 16:16:59 -04:00
legop3 99d2a7689f todoing 2026-09-12 15:23:04 -04:00
legop3 124369dfd7 forgot to pi build?? idk 2026-09-12 14:54:41 -04:00
legop3 4e24b5437d Merge pull request #23 from legop3/esp32io
Esp32io
2026-09-12 14:44:19 -04:00
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
legop3 038ae0f45a add consolenotifier for peripheral system alerts 2026-09-08 21:46:10 -04:00
legop3 3859806fca testings are going goods 2026-09-08 21:10:04 -04:00
114 changed files with 8726 additions and 942 deletions
BIN
View File
Binary file not shown.
Vendored
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
+402 -319
View File
@@ -1,6 +1,6 @@
# Boot-discovered rover peripherals # Boot-discovered rover peripherals
This document defines the planned system for attaching self-describing ESP32 peripherals to a rover over USB. A peripheral advertises a small ordered set of controls, the web UI renders those controls automatically, and the current driver can use them without adding device-specific configuration to the rover or server. This document defines the system for attaching self-describing ESP32 peripherals to a rover over USB. A peripheral advertises a small ordered set of controls, the web UI renders those controls automatically, and the current driver can use them without adding device-specific configuration to the rover or server.
The design deliberately stays small: The design deliberately stays small:
@@ -11,10 +11,12 @@ The design deliberately stays small:
- Controls appear in one vertical column in the order registered by the ESP32 program. - Controls appear in one vertical column in the order registered by the ESP32 program.
- Anyone who can currently drive the rover can use its peripheral controls. - Anyone who can currently drive the rover can use its peripheral controls.
- Peripherals are discovered once when `roverd` starts; changing one requires restarting the rover. - Peripherals are discovered once when `roverd` starts; changing one requires restarting the rover.
- ESP32 firmware is built and uploaded with PlatformIO.
- The same firmware supports CH340/CP210x USB-to-UART boards and native USB CDC boards.
- There is no peripheral configuration in the rover configuration file. - There is no peripheral configuration in the rover configuration file.
- There is no separate rover-peripheral protocol version. - There is no separate rover-peripheral protocol version.
This is a design document. The names of proposed Go, JavaScript, and Arduino APIs describe the intended implementation and do not refer to code that already exists. 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
@@ -75,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
@@ -124,11 +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.
## 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:
@@ -137,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,
@@ -148,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"
@@ -324,44 +331,35 @@ An ESP32 that supplies all three roles describes:
```json ```json
{ {
"name": "Laptop rover GPIO", "name": "Rover GPIO",
"roverControls": { "roverControls": {
"cameraServo": { "cameraServo": {
"output": { "pin": 14,
"type": "servo", "minimumAngleDegrees": -15,
"pin": 14 "maximumAngleDegrees": 30,
}, "homeAngleDegrees": 0,
"minAngle": -15,
"maxAngle": 30,
"homeAngle": 0,
"nudgeDegrees": 2, "nudgeDegrees": 2,
"minPulseUs": 900, "minimumPulseMicroseconds": 900,
"maxPulseUs": 2100, "maximumPulseMicroseconds": 2100,
"allowRawPulse": false, "allowRawPulse": false,
"invert": false "inverted": false
}, },
"headlight": { "headlight": {
"output": { "pin": 18,
"type": "digital", "activeLow": false,
"pin": 18 "initiallyOn": false
},
"initialOn": false,
"activeLow": false
}, },
"laser": { "laser": {
"output": { "pin": 16,
"type": "digital", "activeLow": false,
"pin": 19 "initiallyOn": false
},
"initialOn": false,
"activeLow": false
} }
}, },
"controls": [] "controls": []
} }
``` ```
The ESP32 description owns the calibration for hardware attached to that ESP32. Laptop rover YAML does not repeat the ESP32 pin numbers or servo calibration. The ESP32 description owns the calibration for hardware attached to that ESP32. Rover YAML does not repeat the ESP32 pin numbers or servo calibration.
### Optional backend-selection rule ### Optional backend-selection rule
@@ -373,7 +371,7 @@ The ESP32 description owns the calibration for hardware attached to that ESP32.
Native configuration deliberately wins. A Pi rover can attach an ESP32 for unrelated generic controls without unexpectedly moving its existing camera servo, headlight, or laser to the ESP32. To deliberately use the ESP32 for one of those features, disable only that native feature in rover YAML. Native configuration deliberately wins. A Pi rover can attach an ESP32 for unrelated generic controls without unexpectedly moving its existing camera servo, headlight, or laser to the ESP32. To deliberately use the ESP32 for one of those features, disable only that native feature in rover YAML.
The normal laptop configuration keeps the unavailable native GPIO features disabled: Any rover configuration that should use the ESP32 for these roles keeps the corresponding native GPIO features disabled:
```yaml ```yaml
cameraServo: cameraServo:
@@ -386,7 +384,7 @@ laser:
enabled: false enabled: false
``` ```
An attached ESP32 can then fill any or all of those roles automatically at the next `roverd` start. No USB path or backend name is added to YAML. An attached ESP32 can then fill any or all of those roles automatically at the next `roverd` start. This works identically on Raspberry Pi and laptop rover hosts; no USB path or backend name is added to YAML.
Conflict behavior is fixed and simple: Conflict behavior is fixed and simple:
@@ -493,221 +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 <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");
/*
* 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) {
Firmata.begin(115200); repeatCount = value;
/*
* 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();
} }
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 laptop GPIO peripheral can combine built-in replacements and additional controls:
```cpp
#include <Arduino.h>
#include <ConfigurableFirmata.h>
#include <RoverPeripheralFirmata.h>
RoverPeripheralFirmata peripheral("Laptop rover GPIO");
void setup() {
Firmata.begin(115200);
/*
* 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;
@@ -718,68 +539,289 @@ 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;
laser.pin = 19; 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(); 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);
} }
``` ```
Controls appear in registration order. Each accessory control name must be
non-empty and unique within its peripheral. The name is also its wire
identifier.
### Built-in rover roles
The standard roles use these configuration types:
```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
esp32/
├── libraries/
│ └── RoverPeripheralFirmata/
│ ├── library.json
│ ├── LICENSE
│ ├── README.md
│ ├── examples/
│ └── src/
└── rover-gpio-peripheral/
├── platformio.ini
└── src/main.cpp
```
The published package name is `legop3/RoverPeripheral`. Version `2.0.0`
contains the struct-based public API.
A classic ESP32 PlatformIO project declares:
```ini
[env:esp32dev]
platform = espressif32
board = esp32dev
framework = arduino
lib_deps =
legop3/RoverPeripheral @ ^2.0.0
```
A native-USB ESP32-S3 uses `board = esp32-s3-devkitc-1` and:
```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
cd esp32/rover-gpio-peripheral
pio run -e esp32dev
pio run -e esp32dev -t upload --upload-port /dev/ttyUSB0
```
Use `esp32-s3-devkitc-1` and the matching `/dev/ttyACM*` device for a
native-USB ESP32-S3.
```bash
cd pi/roverd
go run ./cmd/peripheral-probe -port /dev/ttyUSB0
```
## Connection lifecycle ## Connection lifecycle
### Startup discovery ### Startup discovery
Peripheral discovery happens exactly once per `roverd` process. Before constructing the built-in hardware controllers or connecting to the server, `roverd`: Peripheral discovery happens exactly once per `roverd` process. The currently implemented startup path runs before `roverd` constructs its existing built-in hardware controllers or connects to the server:
1. Enumerates the serial devices present on Linux. 1. Enumerates the serial devices present on Linux.
2. Opens each candidate device found by the startup scan. 2. Opens each candidate device found by the startup scan.
3. Starts one Firmata client per opened connection. 3. Waits for a possible board reset and drains stale serial bytes to a quiet read boundary.
4. Performs the normal Firmata firmware and capability queries. 4. Starts one Firmata client per opened connection.
5. Sends the rover-peripheral `DESCRIBE` operation. 5. Performs the normal Firmata firmware and capability queries.
6. Decodes and parses each `DESCRIPTION` response. 6. Sends the rover-peripheral `DESCRIBE` operation.
7. Assigns process-local IDs such as `firmata-0` and `firmata-1`. 7. Decodes and validates each `DESCRIPTION` response.
8. Resolves the optional `cameraServo`, `headlight`, and `laser` roles. 8. Validates every advertised standard-output pin against Firmata capabilities.
9. Builds the fixed generic peripheral list. 9. Configures generic digital, PWM, and servo pin modes once.
10. Constructs `WSClient` with the resolved built-in controllers and peripherals. 10. Assigns process-local IDs such as `firmata-0` and `firmata-1` in discovery order.
11. Connects to the server and sends the normal rover hello. 11. Resolves `cameraServo`, `headlight`, and `laser` against the native configuration.
12. Rejects duplicate ESP32 providers only when the corresponding native role is disabled and Firmata selection would otherwise be ambiguous.
13. Constructs the selected native or Firmata controllers and fixed generic inventory.
14. Constructs `WSClient` with those controllers and that inventory.
15. Connects to the server and includes the effective built-in configuration and renderable inventory in the normal rover hello.
The Linux scan checks stable `/dev/serial/by-id/*` names first, then `/dev/ttyUSB*` and `/dev/ttyACM*`. It canonicalizes symlinks so one device is not opened twice under its stable name and kernel name, and it excludes the configured Roomba Open Interface serial device. Each opened candidate receives the same reset wait used by the probe, followed by a read-until-quiet drain so an old partial SysEx cannot contaminate the new handshake.
Firmware and capability queries remain standard Firmata. `RoverPeripheralFirmata::begin()` registers the standard Firmata firmware name `RoverPeripheralFirmata`, so individual sketches do not repeat that discovery detail. A Firmata device with another firmware name is closed and ignored. Once a device identifies itself as rover-peripheral firmware, a malformed capability or description response is a startup error rather than a silently missing configured accessory.
Linux paths such as `/dev/ttyACM0` remain private `roverd` connection details. The browser and server use only the process-local peripheral ID from the hello. Linux paths such as `/dev/ttyACM0` remain private `roverd` connection details. The browser and server use only the process-local peripheral ID from the hello.
@@ -802,8 +844,20 @@ If an ESP32 is unplugged or its serial connection fails while `roverd` is runnin
1. Its Firmata client marks the connection unavailable. 1. Its Firmata client marks the connection unavailable.
2. Commands routed to that peripheral or one of its built-in roles return an error. 2. Commands routed to that peripheral or one of its built-in roles return an error.
3. `roverd` logs that the peripheral requires reconnection followed by a restart. 3. The failed command is logged by the existing rover WebSocket command path.
4. The advertised roster and visible controls do not change during that process lifetime. 4. The advertised inventory does not change during that process lifetime.
The first terminal read or write error also produces one concise `tty1`
broadcast through the existing `ConsoleNotifier`:
```text
Rover peripheral "Rover GPIO" (firmata-0) disconnected: <error>. Reconnect it and restart roverd.
```
Normal startup uses the same local-console mechanism to announce every fixed
peripheral, the selected native or ESP32 backend for camera servo, headlight,
and laser, any ignored ESP32 roles, or that no ESP32 was found. Detailed Linux
paths and Firmata handshake diagnostics remain in the systemd journal.
The disconnected device is never replaced automatically by another serial device. This ensures that a command cannot be redirected merely because Linux reused a `/dev/ttyACM*` path. The disconnected device is never replaced automatically by another serial device. This ensures that a command cannot be redirected merely because Linux reused a `/dev/ttyACM*` path.
@@ -870,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:
@@ -881,9 +937,14 @@ Payload:
```json ```json
{ {
"roverId": "rover-name", "roverId": "rover-name",
"peripheralId": "firmata-0", "type": "peripheral",
"controlId": "servoPosition", "data": {
"value": 90 "peripheral": {
"id": "firmata-0",
"control": "Servo position",
"value": 90
}
}
} }
``` ```
@@ -900,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
} }
} }
@@ -960,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:
@@ -981,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();
}
} }
``` ```
@@ -1034,9 +1094,17 @@ The generic renderer maps:
- `number` to a labeled numeric input. - `number` to a labeled numeric input.
- `text` to a labeled single-line text input. - `text` to a labeled single-line text input.
The same generic component is reused by desktop and mobile layouts. Layout wrappers decide where the column appears; device-specific components are not created for individual peripherals. Generic peripheral controls are rover controls, so they follow the new driver's HUD language. They do not belong in either sidebar: the sidebars contain chat, queues, room controls, settings, and other controls that are not direct rover actuation.
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`. The standardized replacements do not create any new UI. `cameraServo`, `headlight`, and `laser` continue to use their current camera-tilt, headlight, and laser HUD controls. Only entries in the generic `controls` arrays appear in a new surface named `Accessories`.
On desktop, `Accessories` is a vertical button centered on the left wall of the video. It uses the existing translucent black HUD treatment and opens a height-limited, vertically scrollable panel toward the right. The panel uses the same compact control renderer as mobile and is independent of the bottom-left horn, headlight, and laser pod.
On mobile, a vertical `Accessories` button sits directly to the right of the vacuum-forward and vacuum-backward buttons. Activating it replaces the complete `AuxColumn` contents with the ordered, vertically scrollable accessory list. A small `Aux` button shares the first compact device heading and returns to the normal vacuum, camera, light, laser, and horn controls without creating a separate rail or overlay border.
Desktop and mobile reuse one placement-independent `RoverAccessoryControls` renderer inside their different containers. Device-specific React components are not created for individual peripherals. The renderer sends actions through `ControlSystemProvider`, `ControlContext`, and the existing command pipeline so assignment gating, input cancellation, and command behavior remain consistent with other rover HUD controls. Both parents and the renderer disappear completely when the assigned rover has no generic controls; no launcher, empty shell, or reserved space remains.
Control values are local UI values in the first implementation. Slider and toggle changes update the displayed value immediately and are then sent to the server. Generic sliders use the same custom pointer-capture approach as mobile camera tilt rather than a browser-native range control, which keeps touch behavior and appearance consistent while driving. Restarting `roverd` recreates controls from the new hello rather than persisting peripheral values in `roverSettings`.
## Permissions ## Permissions
@@ -1046,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
@@ -1054,7 +1122,7 @@ Implementation should remain concentrated in a few clear areas.
### ESP32 library ### ESP32 library
Create a small Arduino-compatible `RoverPeripheralFirmata` library containing: 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.
@@ -1063,15 +1131,24 @@ Create a small Arduino-compatible `RoverPeripheralFirmata` library containing:
- `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 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`
Add a peripheral manager responsible for: `PeripheralManager` is responsible for:
- One-time Linux USB serial discovery during startup. - One-time Linux USB serial discovery during startup on either rover host type.
- One Firmata client per connected peripheral. - One Firmata client per connected peripheral.
- Firmata handshake and capability queries. - Firmata handshake and capability queries.
- Rover-peripheral description queries. - Rover-peripheral description queries.
@@ -1081,9 +1158,15 @@ Add a peripheral manager responsible for:
- Custom `CONTROL` dispatch. - Custom `CONTROL` dispatch.
- Rejecting commands for disconnected peripheral IDs. - Rejecting commands for disconnected peripheral IDs.
The manager validates every advertised standard-output pin against the device's Firmata capability response and configures each generic pin mode once during startup. Runtime servo and PWM changes therefore send only value writes; they do not repeatedly detach and reconfigure the hardware output. Generic digital controls start logically off, including the corresponding high electrical level for active-low declarations.
Only renderable control metadata leaves `roverd`. Firmata pin numbers, output mappings, Linux paths, live clients, capabilities, and built-in-role declarations stay in the manager's private fixed inventory. The rover hello contains process-local peripheral IDs, names, and ordered generic controls.
The manager should remain independent of the existing Roomba Open Interface serial adapter. A peripheral serial connection is not the Roomba base serial connection and must not be routed through `SerialAdapter`. The manager should remain independent of the existing Roomba Open Interface serial adapter. A peripheral serial connection is not the Roomba base serial connection and must not be routed through `SerialAdapter`.
Refactor camera servo and GPIO toggles so `WSClient` depends on the shared controller interfaces rather than platform-selected concrete types. Preserve the existing logical servo movement and toggle-state behavior above the Pi and Firmata physical writers. The transport foundation is a focused Firmata parser/client in `pi/roverd/firmata.go`. It operates on `io.ReadWriteCloser`, which keeps byte-stream behavior testable without hardware and lets discovery pass either `/dev/ttyUSB*` or `/dev/ttyACM*` ports into the same client. `pi/roverd/cmd/peripheral-probe` remains the direct hardware diagnostic entry point, while `PeripheralManager` now connects the same client to automatic daemon startup discovery.
`WSClient` now depends on shared camera-servo and toggle controller interfaces rather than platform-selected concrete types. The startup resolver uses the same native-first rule in the ARM Pi and amd64 laptop binaries. Firmata camera movement retains the established limits, home position, nudging, inversion, pulse calibration, raw-pulse policy, and movement-rate behavior; Firmata toggles retain logical state and polarity conversion.
### Server ### Server
@@ -1092,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.
@@ -1103,26 +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.
- Fits into the existing desktop and mobile driver control layouts. - Mounts in the desktop left-wall expansion and as a replacement view inside mobile `AuxColumn`.
- Uses the shared control context and command pipeline rather than emitting directly from layout code.
- Disappears completely when the assigned rover has no peripherals. - 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.
@@ -1141,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
@@ -1160,7 +1242,7 @@ The completed system should be verified with a real ESP32 and rover Linux comput
### Built-in GPIO replacement ### Built-in GPIO replacement
- Start a laptop rover with native camera servo, headlight, and laser disabled and an ESP32 declaring all three roles. - Start either rover host type with native camera servo, headlight, and laser disabled and an ESP32 declaring all three roles.
- Confirm the normal camera tilt, headlight, and laser UI appears without generic duplicates. - Confirm the normal camera tilt, headlight, and laser UI appears without generic duplicates.
- Confirm camera angle limits, home position, nudge amount, inversion, pulse calibration, and rate limiting match the declared ESP32 configuration. - Confirm camera angle limits, home position, nudge amount, inversion, pulse calibration, and rate limiting match the declared ESP32 configuration.
- Confirm headlight and laser toggle state events remain identical to the native Pi path. - Confirm headlight and laser toggle state events remain identical to the native Pi path.
@@ -1168,6 +1250,7 @@ The completed system should be verified with a real ESP32 and rover Linux comput
- Enable a native role and declare the same ESP32 role; confirm native wins and the ignored role is logged. - Enable a native role and declare the same ESP32 role; confirm native wins and the ignored role is logged.
- Disable native and declare the same role from two ESP32s; confirm startup fails with a clear duplicate-role error. - Disable native and declare the same role from two ESP32s; confirm startup fails with a clear duplicate-role error.
- Confirm a Pi rover can use native built-in controls and generic ESP32 controls simultaneously. - Confirm a Pi rover can use native built-in controls and generic ESP32 controls simultaneously.
- Repeat ESP32 role selection on both the ARM Pi binary and amd64 laptop binary and confirm their commands and advertised configurations match.
### Fixed-device lifecycle ### Fixed-device lifecycle
@@ -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);
}
@@ -0,0 +1,26 @@
{
"$schema": "https://raw.githubusercontent.com/platformio/platformio-core/develop/platformio/assets/schema/library.json",
"name": "RoverPeripheral",
"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",
"platforms": "espressif32",
"headers": "RoverPeripheral.h",
"dependencies": {
"ConfigurableFirmata": "https://github.com/firmata/ConfigurableFirmata.git#3.2.0",
"bblanchon/ArduinoJson": "^7.4.2",
"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();
}
@@ -0,0 +1,521 @@
#include "RoverPeripheralFirmata.h"
namespace {
constexpr byte kPeripheralFeature = 0x01;
constexpr byte kDescribeOperation = 0x00;
constexpr byte kDescriptionOperation = 0x01;
constexpr byte kControlOperation = 0x02;
const char* buttonModeName(ButtonMode mode) {
return mode == ButtonMode::Toggle ? "toggle" : "momentary";
}
const char* outputTypeName(uint8_t value) {
switch (value) {
case 0:
return "servo";
case 1:
return "pwm";
case 2:
return "digital";
default:
return "custom";
}
}
} // namespace
RoverPeripheralFirmata* RoverPeripheralFirmata::instance_ = nullptr;
RoverPeripheralFirmata::RoverPeripheralFirmata(const String& name) : name_(name) {}
void RoverPeripheralFirmata::setName(const String& name) {
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
// hard stop is preferable to advertising a partially usable device whose
// behavior depends on which malformed control the driver touches first.
abort();
}
for (const ControlRegistration& existing : controls_) {
if (existing.id == name) {
abort();
}
}
}
void RoverPeripheralFirmata::validateRange(const String& name, int minimum, int maximum) const {
if (name.length() == 0 || minimum > maximum) {
abort();
}
}
void RoverPeripheralFirmata::addServoSlider(const SliderControlConfig& config, const ServoOutput& output) {
validateControlName(config.name);
validateRange(config.name, config.minimum, config.maximum);
ControlRegistration control;
control.id = config.name;
control.name = config.name;
control.type = ControlType::Slider;
control.output = OutputType::Servo;
control.minimum = config.minimum;
control.maximum = config.maximum;
control.pin = output.pin;
controls_.push_back(control);
}
void RoverPeripheralFirmata::addPwmSlider(const SliderControlConfig& config, const PwmOutput& output) {
validateControlName(config.name);
validateRange(config.name, config.minimum, config.maximum);
ControlRegistration control;
control.id = config.name;
control.name = config.name;
control.type = ControlType::Slider;
control.output = OutputType::Pwm;
control.minimum = config.minimum;
control.maximum = config.maximum;
control.pin = output.pin;
controls_.push_back(control);
}
void RoverPeripheralFirmata::addDigitalButton(const ButtonControlConfig& config, const DigitalOutput& output) {
validateControlName(config.name);
ControlRegistration control;
control.id = config.name;
control.name = config.name;
control.type = ControlType::Button;
control.output = OutputType::Digital;
control.buttonMode = config.mode;
control.pin = output.pin;
control.polarity = output.polarity;
controls_.push_back(control);
}
void RoverPeripheralFirmata::addSlider(const SliderControlConfig& config, SliderCallback callback) {
validateControlName(config.name);
validateRange(config.name, config.minimum, config.maximum);
ControlRegistration control;
control.id = config.name;
control.name = config.name;
control.type = ControlType::Slider;
control.output = OutputType::Custom;
control.minimum = config.minimum;
control.maximum = config.maximum;
control.sliderCallback = callback;
controls_.push_back(control);
}
void RoverPeripheralFirmata::addButton(const ButtonControlConfig& config, ButtonCallback callback) {
validateControlName(config.name);
ControlRegistration control;
control.id = config.name;
control.name = config.name;
control.type = ControlType::Button;
control.output = OutputType::Custom;
control.buttonMode = config.mode;
control.buttonCallback = callback;
controls_.push_back(control);
}
void RoverPeripheralFirmata::addNumber(const NumberControlConfig& config, NumberCallback callback) {
validateControlName(config.name);
validateRange(config.name, config.minimum, config.maximum);
ControlRegistration control;
control.id = config.name;
control.name = config.name;
control.type = ControlType::Number;
control.output = OutputType::Custom;
control.minimum = config.minimum;
control.maximum = config.maximum;
control.numberCallback = callback;
controls_.push_back(control);
}
void RoverPeripheralFirmata::addText(const TextControlConfig& config, TextCallback callback) {
validateControlName(config.name);
if (config.maximumLength == 0) {
abort();
}
ControlRegistration control;
control.id = config.name;
control.name = config.name;
control.type = ControlType::Text;
control.output = OutputType::Custom;
control.maximumLength = config.maximumLength;
control.textCallback = callback;
controls_.push_back(control);
}
void RoverPeripheralFirmata::addRoverCameraServo(const RoverCameraServoConfig& config) {
cameraServo_ = config;
hasCameraServo_ = true;
}
void RoverPeripheralFirmata::addRoverHeadlight(const RoverDigitalOutputConfig& config) {
headlight_ = config;
hasHeadlight_ = true;
}
void RoverPeripheralFirmata::addRoverLaser(const RoverDigitalOutputConfig& config) {
laser_ = config;
hasLaser_ = true;
}
void RoverPeripheralFirmata::begin(FirmataExt& extension) {
if (instance_ != nullptr && instance_ != this) {
abort();
}
instance_ = this;
extension.addFeature(*this);
// Discovery uses Firmata's standard REPORT_FIRMWARE query to distinguish a
// rover peripheral from unrelated Firmata devices. The helper owns this
// identity so every sketch gets it without repeating protocol boilerplate.
Firmata.setFirmwareNameAndVersion("RoverPeripheralFirmata", 1, 0);
// SET_DIGITAL_PIN_VALUE is a fixed Firmata command rather than SysEx, so it
// cannot travel through FirmataFeature::handleSysex. Firmata exposes one
// callback for it and this peripheral owns the standard output implementation.
Firmata.attach(SET_DIGITAL_PIN_VALUE, digitalPinValueCallback);
Firmata.attach(SYSTEM_RESET, systemResetCallback);
}
void RoverPeripheralFirmata::update() {
// Custom callbacks execute synchronously from Firmata's parser for now. This
// method intentionally remains available so future non-blocking peripheral
// work can be serviced without changing the sketch's main loop shape.
}
void RoverPeripheralFirmata::handleCapability(byte pin) {
if (!IS_PIN_DIGITAL(pin)) {
return;
}
// The peripheral supports the output modes roverd may select. Capability
// reporting stays standard Firmata, so the Linux probe can also inspect it
// with any other conforming client.
Firmata.write(PIN_MODE_OUTPUT);
Firmata.write(1);
if (IS_PIN_PWM(pin)) {
Firmata.write(PIN_MODE_PWM);
Firmata.write(DEFAULT_PWM_RESOLUTION);
}
Firmata.write(PIN_MODE_SERVO);
Firmata.write(14);
}
boolean RoverPeripheralFirmata::handlePinMode(byte pin, int mode) {
if (pin >= TOTAL_PINS || !IS_PIN_DIGITAL(pin)) {
return false;
}
// A pin can only have one active hardware generator. Detaching a previous
// servo before switching modes prevents it from continuing to pulse after a
// later digital or PWM configuration takes ownership of the pin.
if (mode != PIN_MODE_SERVO) {
detachServo(pin);
}
switch (mode) {
case PIN_MODE_OUTPUT:
pinMode(PIN_TO_DIGITAL(pin), OUTPUT);
digitalWrite(PIN_TO_DIGITAL(pin), LOW);
Firmata.setPinState(pin, 0);
return true;
case PIN_MODE_PWM:
if (!IS_PIN_PWM(pin)) {
return false;
}
pinMode(PIN_TO_PWM(pin), OUTPUT);
analogWrite(PIN_TO_PWM(pin), 0);
Firmata.setPinState(pin, 0);
return true;
case PIN_MODE_SERVO:
attachServo(pin);
Firmata.setPinState(pin, 0);
return true;
default:
return false;
}
}
boolean RoverPeripheralFirmata::handleSysex(byte command, byte argc, byte* argv) {
if (command == kPeripheralFeature) {
if (argc == 0) {
return true;
}
if (argv[0] == kDescribeOperation) {
buildAndSendDescription();
} else if (argv[0] == kControlOperation) {
dispatchCustomControl(argc, argv);
}
return true;
}
if (command == SERVO_CONFIG && argc >= 5) {
const byte pin = argv[0];
const int minimumPulse = argv[1] | (argv[2] << 7);
const int maximumPulse = argv[3] | (argv[4] << 7);
if (pin < TOTAL_PINS && IS_PIN_DIGITAL(pin)) {
Firmata.setPinMode(pin, PIN_MODE_SERVO);
attachServo(pin, minimumPulse, maximumPulse);
}
return true;
}
if (command == EXTENDED_ANALOG && argc >= 2) {
const byte pin = argv[0];
if (pin >= TOTAL_PINS) {
return true;
}
int value = 0;
// Extended analog values contain a variable number of seven-bit chunks.
// Reassembling every received chunk keeps servo angles and PWM values fully
// compatible with normal Firmata clients rather than assuming eight bits.
for (byte index = 1; index < argc && index <= 4; ++index) {
value |= static_cast<int>(argv[index]) << (7 * (index - 1));
}
const byte mode = Firmata.getPinMode(pin);
if (mode == PIN_MODE_PWM && IS_PIN_PWM(pin)) {
analogWrite(PIN_TO_PWM(pin), value);
Firmata.setPinState(pin, value);
} else if (mode == PIN_MODE_SERVO && servos_[pin] != nullptr) {
servos_[pin]->write(value);
Firmata.setPinState(pin, value);
}
return true;
}
return false;
}
void RoverPeripheralFirmata::reset() {
for (byte pin = 0; pin < TOTAL_PINS; ++pin) {
detachServo(pin);
}
// Built-in role defaults are applied on Firmata reset as well as boot. This
// makes reconnecting a client deterministic without creating a second state
// model on the ESP32.
if (hasHeadlight_) {
pinMode(headlight_.pin, OUTPUT);
const bool physicalHigh = headlight_.initiallyOn != (headlight_.polarity == OutputPolarity::ActiveLow);
writeDigitalPin(headlight_.pin, physicalHigh);
}
if (hasLaser_) {
pinMode(laser_.pin, OUTPUT);
const bool physicalHigh = laser_.initiallyOn != (laser_.polarity == OutputPolarity::ActiveLow);
writeDigitalPin(laser_.pin, physicalHigh);
}
}
void RoverPeripheralFirmata::buildAndSendDescription() {
JsonDocument document;
document["name"] = name_;
if (hasCameraServo_ || hasHeadlight_ || hasLaser_) {
JsonObject roverControls = document["roverControls"].to<JsonObject>();
if (hasCameraServo_) {
JsonObject servo = roverControls["cameraServo"].to<JsonObject>();
servo["pin"] = cameraServo_.pin;
servo["minimumAngleDegrees"] = cameraServo_.minimumAngleDegrees;
servo["maximumAngleDegrees"] = cameraServo_.maximumAngleDegrees;
servo["homeAngleDegrees"] = cameraServo_.homeAngleDegrees;
servo["nudgeDegrees"] = cameraServo_.nudgeDegrees;
servo["minimumPulseMicroseconds"] = cameraServo_.minimumPulseMicroseconds;
servo["maximumPulseMicroseconds"] = cameraServo_.maximumPulseMicroseconds;
servo["allowRawPulse"] = cameraServo_.allowRawPulse;
servo["inverted"] = cameraServo_.inverted;
}
auto addDigitalRole = [&roverControls](const char* key, const RoverDigitalOutputConfig& config) {
JsonObject role = roverControls[key].to<JsonObject>();
role["pin"] = config.pin;
role["activeLow"] = config.polarity == OutputPolarity::ActiveLow;
role["initiallyOn"] = config.initiallyOn;
};
if (hasHeadlight_) {
addDigitalRole("headlight", headlight_);
}
if (hasLaser_) {
addDigitalRole("laser", laser_);
}
}
JsonArray controls = document["controls"].to<JsonArray>();
for (const ControlRegistration& registration : controls_) {
JsonObject control = controls.add<JsonObject>();
control["id"] = registration.id;
control["name"] = registration.name;
switch (registration.type) {
case ControlType::Slider:
control["type"] = "slider";
control["min"] = registration.minimum;
control["max"] = registration.maximum;
break;
case ControlType::Button:
control["type"] = "button";
control["mode"] = buttonModeName(registration.buttonMode);
break;
case ControlType::Number:
control["type"] = "number";
control["min"] = registration.minimum;
control["max"] = registration.maximum;
break;
case ControlType::Text:
control["type"] = "text";
control["maxLength"] = registration.maximumLength;
break;
}
JsonObject output = control["output"].to<JsonObject>();
output["type"] = outputTypeName(static_cast<uint8_t>(registration.output));
if (registration.output != OutputType::Custom) {
output["pin"] = registration.pin;
}
if (registration.output == OutputType::Digital && registration.polarity == OutputPolarity::ActiveLow) {
output["activeLow"] = true;
}
}
String payload;
serializeJson(document, payload);
// ConfigurableFirmata's convenience sendSysex takes a byte-sized raw length.
// Descriptions can exceed that, so write the standard framing and each 7-bit
// pair directly. This remains one ordinary Firmata SysEx message on the wire.
Firmata.startSysex();
Firmata.write(kPeripheralFeature);
Firmata.write(kDescriptionOperation);
for (size_t index = 0; index < payload.length(); ++index) {
Firmata.sendValueAsTwo7bitBytes(static_cast<uint8_t>(payload[index]));
}
Firmata.endSysex();
}
void RoverPeripheralFirmata::dispatchCustomControl(byte argc, byte* argv) {
if (argc < 3 || ((argc - 1) % 2) != 0) {
Firmata.sendString(F("Invalid rover control payload"));
return;
}
String decoded;
decoded.reserve((argc - 1) / 2);
for (byte index = 1; index + 1 < argc; index += 2) {
if (argv[index + 1] > 1) {
Firmata.sendString(F("Invalid rover control encoding"));
return;
}
decoded += static_cast<char>(argv[index] | (argv[index + 1] << 7));
}
JsonDocument document;
if (deserializeJson(document, decoded) != DeserializationError::Ok) {
Firmata.sendString(F("Invalid rover control JSON"));
return;
}
const String controlID = document["control"].as<String>();
for (ControlRegistration& registration : controls_) {
if (registration.id != controlID || registration.output != OutputType::Custom) {
continue;
}
// The registration type is the source of truth for value conversion. This
// prevents an unexpected JSON value from silently selecting a different
// callback signature or invoking unrelated application behavior.
switch (registration.type) {
case ControlType::Slider:
if (registration.sliderCallback) {
registration.sliderCallback(document["value"].as<int>());
}
break;
case ControlType::Button:
if (registration.buttonCallback) {
registration.buttonCallback(document["value"].as<bool>());
}
break;
case ControlType::Number:
if (registration.numberCallback) {
registration.numberCallback(document["value"].as<int>());
}
break;
case ControlType::Text:
if (registration.textCallback) {
String value = document["value"].as<String>();
if (value.length() > registration.maximumLength) {
value.remove(registration.maximumLength);
}
registration.textCallback(value);
}
break;
}
return;
}
Firmata.sendString(F("Unknown rover control"));
}
void RoverPeripheralFirmata::writeDigitalPin(byte pin, bool physicalHigh) {
// Standard Firmata digital values represent the electrical pin level. roverd
// applies the advertised activeLow mapping before sending a command, keeping
// this firmware compatible with raw Firmata clients and avoiding inversion in
// two different layers.
digitalWrite(PIN_TO_DIGITAL(pin), physicalHigh ? HIGH : LOW);
Firmata.setPinState(pin, physicalHigh ? 1 : 0);
}
void RoverPeripheralFirmata::attachServo(byte pin, int minimumPulseMicroseconds, int maximumPulseMicroseconds) {
if (pin >= TOTAL_PINS || !IS_PIN_DIGITAL(pin)) {
return;
}
if (servos_[pin] == nullptr) {
servos_[pin] = new Servo();
}
if (servos_[pin]->attached()) {
servos_[pin]->detach();
}
if (minimumPulseMicroseconds > 0 && maximumPulseMicroseconds > minimumPulseMicroseconds) {
servos_[pin]->attach(PIN_TO_SERVO(pin), minimumPulseMicroseconds, maximumPulseMicroseconds);
} else {
servos_[pin]->attach(PIN_TO_SERVO(pin));
}
}
void RoverPeripheralFirmata::detachServo(byte pin) {
if (pin >= TOTAL_PINS || servos_[pin] == nullptr) {
return;
}
if (servos_[pin]->attached()) {
servos_[pin]->detach();
}
delete servos_[pin];
servos_[pin] = nullptr;
}
void RoverPeripheralFirmata::digitalPinValueCallback(byte pin, int value) {
if (instance_ == nullptr || pin >= TOTAL_PINS || Firmata.getPinMode(pin) != PIN_MODE_OUTPUT) {
return;
}
// Polarity is advertised by the peripheral and applied by roverd before this
// standard raw pin-level command reaches the ESP32.
instance_->writeDigitalPin(pin, value != 0);
}
void RoverPeripheralFirmata::systemResetCallback() {
if (instance_ != nullptr) {
instance_->reset();
}
}
@@ -0,0 +1,99 @@
#pragma once
#include <Arduino.h>
#include <ArduinoJson.h>
#include <ConfigurableFirmata.h>
#include <ESP32Servo.h>
#include <FirmataExt.h>
#include <RoverPeripheral.h>
#include <vector>
/*
* RoverPeripheralFirmata is the protocol-facing implementation behind the
* small RoverPeripheral public facade. Keeping this class private prevents
* peripheral sketches from depending on Firmata types while ordinary Firmata
* tooling can still use digital, PWM, and servo commands on the same stream.
*/
class RoverPeripheralFirmata : public FirmataFeature {
public:
explicit RoverPeripheralFirmata(const String& name);
void setName(const String& name);
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 addButton(const ButtonControlConfig& config, ButtonCallback callback);
void addNumber(const NumberControlConfig& config, NumberCallback callback);
void addText(const TextControlConfig& config, TextCallback callback);
void addRoverCameraServo(const RoverCameraServoConfig& config);
void addRoverHeadlight(const RoverDigitalOutputConfig& config);
void addRoverLaser(const RoverDigitalOutputConfig& config);
void begin(FirmataExt& extension);
void update();
// FirmataFeature methods let FirmataExt route standard and custom SysEx
// operations through the same parser that owns the serial connection.
void handleCapability(byte pin) override;
boolean handlePinMode(byte pin, int mode) override;
boolean handleSysex(byte command, byte argc, byte* argv) override;
void reset() override;
private:
enum class ControlType {
Slider,
Button,
Number,
Text,
};
enum class OutputType {
Servo,
Pwm,
Digital,
Custom,
};
struct ControlRegistration {
String id;
String name;
ControlType type;
OutputType output;
int minimum = 0;
int maximum = 0;
size_t maximumLength = 0;
ButtonMode buttonMode = ButtonMode::Momentary;
uint8_t pin = 0;
OutputPolarity polarity = OutputPolarity::ActiveHigh;
SliderCallback sliderCallback;
ButtonCallback buttonCallback;
NumberCallback numberCallback;
TextCallback textCallback;
};
String name_;
std::vector<ControlRegistration> controls_;
bool hasCameraServo_ = false;
bool hasHeadlight_ = false;
bool hasLaser_ = false;
RoverCameraServoConfig cameraServo_;
RoverDigitalOutputConfig headlight_;
RoverDigitalOutputConfig laser_;
Servo* servos_[TOTAL_PINS] = {};
void validateControlName(const String& name) const;
void validateRange(const String& name, int minimum, int maximum) const;
void buildAndSendDescription();
void dispatchCustomControl(byte argc, byte* argv);
void writeDigitalPin(byte pin, bool enabled);
void attachServo(byte pin, int minimumPulseMicroseconds = -1, int maximumPulseMicroseconds = -1);
void detachServo(byte pin);
static RoverPeripheralFirmata* instance_;
static void digitalPinValueCallback(byte pin, int value);
static void systemResetCallback();
};
@@ -0,0 +1,25 @@
[platformio]
default_envs = esp32dev
[env]
platform = espressif32
framework = arduino
monitor_speed = 115200
lib_deps =
; Install the local package through PlatformIO's dependency manager so this
; reference project exercises the same transitive dependency behavior as an
; external project using the published Registry package.
RoverPeripheral=file://../libraries/RoverPeripheralFirmata
; 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*.
[env:esp32dev]
board = esp32dev
; Native USB boards use the same sketch and Firmata stream. These flags make the
; ESP32-S3's USB CDC serial port active at boot, normally appearing as ttyACM*.
[env:esp32-s3-devkitc-1]
board = esp32-s3-devkitc-1
build_flags =
-D ARDUINO_USB_MODE=1
-D ARDUINO_USB_CDC_ON_BOOT=1
+97
View File
@@ -0,0 +1,97 @@
#include <RoverPeripheral.h>
namespace {
// Every example pin is present on both the classic ESP32 DevKitC and the
// ESP32-S3 DevKitC. GPIO 19 and 20 are deliberately avoided because native-USB
// S3 boards use them for USB D- and D+.
constexpr uint8_t kSpecialActionPin = 21;
int repeatCount = 1;
String displayMessage;
void runSpecialAction(bool pressed) {
// Receiving both button edges lets application hardware remain active only
// while the driver holds the momentary control.
digitalWrite(kSpecialActionPin, pressed ? HIGH : LOW);
}
void setRepeatCount(int value) {
// A real device can use this value when it starts its next animation or
// 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;
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(kSpecialActionPin, OUTPUT);
digitalWrite(kSpecialActionPin, LOW);
// Accessory controls render in precisely this registration order.
SliderControlConfig servoPosition;
servoPosition.name = "Servo position";
servoPosition.minimum = 0;
servoPosition.maximum = 180;
ServoOutput servoOutput;
servoOutput.pin = 13;
io.addSlider(servoPosition, servoOutput);
SliderControlConfig lightBrightness;
lightBrightness.name = "Light brightness";
lightBrightness.minimum = 0;
lightBrightness.maximum = 255;
PwmOutput lightOutput;
lightOutput.pin = 17;
io.addSlider(lightBrightness, lightOutput);
ButtonControlConfig specialAction;
specialAction.name = "Special action";
specialAction.mode = ButtonMode::Momentary;
io.addButton(specialAction, runSpecialAction);
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);
}
+10 -4
View File
@@ -25,10 +25,6 @@ type CameraServo struct {
closed bool closed bool
} }
const maxServoDegPerSec = 60.0
const servoStepInterval = 20 * time.Millisecond
const servoAngleEpsilon = 0.01
func NewCameraServo(cfg CameraServoConfig, logger *log.Logger) (*CameraServo, error) { func NewCameraServo(cfg CameraServoConfig, logger *log.Logger) (*CameraServo, error) {
if !cfg.Enabled { if !cfg.Enabled {
return nil, fmt.Errorf("camera servo disabled") return nil, fmt.Errorf("camera servo disabled")
@@ -132,6 +128,16 @@ func (s *CameraServo) CurrentAngle() float64 {
return s.currentAngle return s.currentAngle
} }
// Configuration reports the effective public behavior advertised to the
// server. The native implementation simply returns its validated YAML config.
func (s *CameraServo) Configuration() CameraServoConfig {
return s.cfg
}
func (s *CameraServo) BackendDescription() string {
return "native GPIO"
}
func (s *CameraServo) applyPulseLocked(micros int) { func (s *CameraServo) applyPulseLocked(micros int) {
micros = clampInt(micros, s.cfg.MinPulseUs, s.cfg.MaxPulseUs) micros = clampInt(micros, s.cfg.MinPulseUs, s.cfg.MaxPulseUs)
s.pin.DutyCycle(uint32(micros), uint32(s.cfg.CycleLen)) s.pin.DutyCycle(uint32(micros), uint32(s.cfg.CycleLen))
+11 -4
View File
@@ -11,10 +11,9 @@ type CameraServo struct{}
func NewCameraServo(_ CameraServoConfig, _ *log.Logger) (*CameraServo, error) { func NewCameraServo(_ CameraServoConfig, _ *log.Logger) (*CameraServo, error) {
/* /*
The Debian laptop profile starts with the laptop's built-in webcam and no This constructor represents only native host GPIO. The shared startup
Pi PWM servo. If a laptop rover eventually grows an external servo board, resolver selects the normal Firmata implementation when an ESP32 provides
it should get its own implementation instead of reusing Raspberry Pi GPIO the role, so external hardware is not laptop-specific code.
assumptions.
*/ */
return nil, fmt.Errorf("camera servo not supported in the debian-laptop build") return nil, fmt.Errorf("camera servo not supported in the debian-laptop build")
} }
@@ -36,3 +35,11 @@ func (c *CameraServo) SetPulseWidth(micros int) error {
func (c *CameraServo) CurrentAngle() float64 { func (c *CameraServo) CurrentAngle() float64 {
return 0 return 0
} }
func (c *CameraServo) Configuration() CameraServoConfig {
return CameraServoConfig{}
}
func (c *CameraServo) BackendDescription() string {
return "native GPIO"
}
+8
View File
@@ -30,3 +30,11 @@ func (c *CameraServo) SetPulseWidth(micros int) error {
func (c *CameraServo) CurrentAngle() float64 { func (c *CameraServo) CurrentAngle() float64 {
return 0 return 0
} }
func (c *CameraServo) Configuration() CameraServoConfig {
return CameraServoConfig{}
}
func (c *CameraServo) BackendDescription() string {
return "native GPIO"
}
+151
View File
@@ -0,0 +1,151 @@
package main
import (
"context"
"encoding/json"
"errors"
"flag"
"fmt"
"log"
"os"
"time"
roverd "multiroombarover/pi/roverd"
"github.com/tarm/serial"
)
func main() {
var portName string
var baud int
var timeout time.Duration
var startupWait time.Duration
var controlID string
var rawValue string
flag.StringVar(&portName, "port", "", "serial device, for example /dev/ttyUSB0 or /dev/ttyACM0")
flag.IntVar(&baud, "baud", 115200, "Firmata serial baud rate")
flag.DurationVar(&timeout, "timeout", 5*time.Second, "timeout for each Firmata response")
flag.DurationVar(&startupWait, "startup-wait", 2*time.Second, "time allowed for boards that reset when the port opens")
flag.StringVar(&controlID, "control", "", "optional declared control ID to exercise")
flag.StringVar(&rawValue, "value", "", "JSON value for -control, such as 90, true, or \"hello\"")
flag.Parse()
if portName == "" {
log.Fatal("-port is required")
}
if (controlID == "") != (rawValue == "") {
log.Fatal("-control and -value must be provided together")
}
port, err := serial.OpenPort(&serial.Config{
Name: portName,
Baud: baud,
ReadTimeout: 100 * time.Millisecond,
})
if err != nil {
log.Fatalf("open %s: %v", portName, err)
}
defer port.Close()
// CH340 and native-USB development boards may reset when the host opens the
// port. Waiting here makes the same probe work with both connection styles
// without baking that diagnostic delay into the production Firmata client.
time.Sleep(startupWait)
rootContext, cancelRoot := context.WithCancel(context.Background())
defer cancelRoot()
client := roverd.NewFirmataClient(port)
client.Start(rootContext)
firmware, err := withTimeout(timeout, client.QueryFirmware)
if err != nil {
log.Fatalf("query firmware: %v", err)
}
fmt.Printf("Firmata firmware: %s %d.%d\n", firmware.Name, firmware.Major, firmware.Minor)
capabilities, err := withTimeout(timeout, client.QueryCapabilities)
if err != nil {
log.Fatalf("query capabilities: %v", err)
}
fmt.Printf("Firmata pins described: %d\n", len(capabilities))
description, err := withTimeout(timeout, client.Describe)
if err != nil {
log.Fatalf("describe rover peripheral: %v", err)
}
formatted, err := json.MarshalIndent(description, "", " ")
if err != nil {
log.Fatalf("format description: %v", err)
}
fmt.Printf("Peripheral description:\n%s\n", formatted)
if controlID != "" {
if err := exerciseControl(client, description, controlID, json.RawMessage(rawValue)); err != nil {
log.Fatalf("exercise control %q: %v", controlID, err)
}
fmt.Fprintf(os.Stdout, "Control %q accepted.\n", controlID)
}
}
// withTimeout gives every boot-time exchange its own deadline. A missing board
// therefore reports the exact handshake stage that failed instead of consuming
// one shared timeout and obscuring which response was absent.
func withTimeout[T any](timeout time.Duration, operation func(context.Context) (T, error)) (T, error) {
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
return operation(ctx)
}
func exerciseControl(client *roverd.FirmataClient, description roverd.PeripheralDescription, controlID string, rawValue json.RawMessage) error {
var selected *roverd.PeripheralControl
for index := range description.Controls {
if description.Controls[index].ID == controlID {
selected = &description.Controls[index]
break
}
}
if selected == nil {
return errors.New("control is not present in the device description")
}
var value any
if err := json.Unmarshal(rawValue, &value); err != nil {
return fmt.Errorf("parse -value as JSON: %w", err)
}
// Standard outputs deliberately use standard Firmata commands. Only custom
// callbacks use the rover-peripheral CONTROL operation, which is the central
// distinction the probe is intended to validate on real hardware.
switch selected.Output.Type {
case "custom":
return client.SendPeripheralControl(selected.ID, value)
case "digital":
enabled, ok := value.(bool)
if !ok {
return errors.New("digital control value must be true or false")
}
if selected.Output.ActiveLow {
enabled = !enabled
}
if err := client.SetPinMode(byte(*selected.Output.Pin), roverd.FirmataPinModeOutput); err != nil {
return err
}
return client.SetDigitalPin(byte(*selected.Output.Pin), enabled)
case "pwm", "servo":
number, ok := value.(float64)
if !ok || number != float64(int(number)) {
return errors.New("PWM and servo control values must be whole numbers")
}
mode := roverd.FirmataPinModePWM
if selected.Output.Type == "servo" {
mode = roverd.FirmataPinModeServo
}
if err := client.SetPinMode(byte(*selected.Output.Pin), mode); err != nil {
return err
}
return client.ExtendedAnalog(byte(*selected.Output.Pin), int(number))
default:
return fmt.Errorf("unsupported output %q", selected.Output.Type)
}
}
+42 -24
View File
@@ -3,6 +3,7 @@ package main
import ( import (
"context" "context"
"flag" "flag"
"fmt"
"log" "log"
"os" "os"
"os/signal" "os/signal"
@@ -37,6 +38,19 @@ func main() {
} }
defer serialPort.Close() defer serialPort.Close()
// Peripheral discovery is intentionally a boot-time operation. The manager
// keeps successful USB ports open across server WebSocket reconnects and is
// rebuilt only when the roverd process itself restarts.
peripherals, err := roverd.DiscoverPeripheralManager(ctx, cfg.Serial.Device, logger)
if err != nil {
console.Notify(fmt.Sprintf("Rover peripheral startup failed: %v", err))
logger.Fatalf("discover rover peripherals: %v", err)
}
defer peripherals.Close()
for _, message := range peripherals.StartupBroadcasts() {
console.Notify(message)
}
var pulser *roverd.BRCPulser var pulser *roverd.BRCPulser
if cfg.BRC.Enabled() { if cfg.BRC.Enabled() {
pulser, err = roverd.NewBRCPulser(cfg.BRC, logger) pulser, err = roverd.NewBRCPulser(cfg.BRC, logger)
@@ -61,37 +75,41 @@ func main() {
mediaSupervisor.Start(ctx) mediaSupervisor.Start(ctx)
} }
var cameraServo *roverd.CameraServo // Backend selection is identical on Pi and laptop hosts: enabled native
if cfg.CameraServo.Enabled { // GPIO wins, otherwise a discovered ESP32 may provide the built-in role.
cameraServo, err = roverd.NewCameraServo(cfg.CameraServo, logger) hardwareControllers, err := roverd.ResolveRoverHardwareControllers(cfg, peripherals, logger)
if err != nil { if err != nil {
logger.Fatalf("init camera servo: %v", err) console.Notify(fmt.Sprintf("Rover peripheral startup failed while selecting hardware: %v", err))
} logger.Fatalf("resolve rover hardware controllers: %v", err)
defer cameraServo.Close() }
defer hardwareControllers.Close()
for _, message := range hardwareControllers.StartupBroadcasts() {
console.Notify(message)
} }
var headlight *roverd.GPIOToggle // A peripheral is never hot-reconnected. Report the first terminal serial
if cfg.Headlight.Enabled { // failure for each discovered board and tell the local operator exactly what
headlight, err = roverd.NewGPIOToggle("headlight", cfg.Headlight, logger) // recovery action the fixed boot-time lifecycle requires.
if err != nil { go func() {
logger.Fatalf("init headlight: %v", err) for {
select {
case failure := <-peripherals.Failures():
console.Notify(fmt.Sprintf(
"Rover peripheral %q (%s) disconnected: %v. Reconnect it and restart roverd.",
failure.Name,
failure.ID,
failure.Err,
))
case <-ctx.Done():
return
}
} }
defer headlight.Close() }()
}
var laser *roverd.GPIOToggle
if cfg.Laser.Enabled {
laser, err = roverd.NewGPIOToggle("laser", cfg.Laser, logger)
if err != nil {
logger.Fatalf("init laser: %v", err)
}
defer laser.Close()
}
autoCharge := roverd.NewAutoChargeController(adapter, eventStream, logger) autoCharge := roverd.NewAutoChargeController(adapter, eventStream, logger)
go autoCharge.Run(ctx, sensorSamples) go autoCharge.Run(ctx, sensorSamples)
client := roverd.NewWSClient(cfg, adapter, sensorFrames, eventStream, mediaSupervisor, cameraServo, headlight, laser, logger, console) client := roverd.NewWSClient(cfg, adapter, sensorFrames, eventStream, mediaSupervisor, hardwareControllers.CameraServo, hardwareControllers.Headlight, hardwareControllers.Laser, peripherals, logger, console)
// Startup is announced only after every configured hardware dependency has // Startup is announced only after every configured hardware dependency has
// initialized successfully. A message here therefore means the control loop // initialized successfully. A message here therefore means the control loop
+23 -13
View File
@@ -1,19 +1,22 @@
package roverd package roverd
import "encoding/json"
type helloMessage struct { type helloMessage struct {
Type string `json:"type"` Type string `json:"type"`
Name string `json:"name"` Name string `json:"name"`
Description string `json:"description,omitempty"` Description string `json:"description,omitempty"`
Color string `json:"color,omitempty"` Color string `json:"color,omitempty"`
Battery BatteryConfig `json:"battery"` Battery BatteryConfig `json:"battery"`
MaxWheelSpeed int `json:"maxWheelSpeed"` MaxWheelSpeed int `json:"maxWheelSpeed"`
Media MediaConfig `json:"media"` Media MediaConfig `json:"media"`
CameraServo CameraServoConfig `json:"cameraServo"` CameraServo CameraServoConfig `json:"cameraServo"`
Audio AudioConfig `json:"audio"` Audio AudioConfig `json:"audio"`
Horn HornConfig `json:"horn"` Horn HornConfig `json:"horn"`
Headlight GPIOToggleConfig `json:"headlight"` Headlight GPIOToggleConfig `json:"headlight"`
Laser GPIOToggleConfig `json:"laser"` Laser GPIOToggleConfig `json:"laser"`
Private PrivateConfig `json:"private"` Peripherals []RoverPeripheralMetadata `json:"peripherals,omitempty"`
Private PrivateConfig `json:"private"`
} }
type sensorMessage struct { type sensorMessage struct {
@@ -45,6 +48,7 @@ type inboundMessage struct {
AudioLevels *audioLevelsPayload `json:"audioLevels,omitempty"` AudioLevels *audioLevelsPayload `json:"audioLevels,omitempty"`
Headlight *togglePayload `json:"headlight,omitempty"` Headlight *togglePayload `json:"headlight,omitempty"`
Laser *togglePayload `json:"laser,omitempty"` Laser *togglePayload `json:"laser,omitempty"`
Peripheral *peripheralPayload `json:"peripheral,omitempty"`
Song *songPayload `json:"song,omitempty"` Song *songPayload `json:"song,omitempty"`
Reboot *rebootPayload `json:"reboot,omitempty"` Reboot *rebootPayload `json:"reboot,omitempty"`
// Update is intentionally just a marker payload. The server can request the // Update is intentionally just a marker payload. The server can request the
@@ -103,6 +107,12 @@ type togglePayload struct {
Action string `json:"action"` Action string `json:"action"`
} }
type peripheralPayload struct {
ID string `json:"id"`
Control string `json:"control"`
Value json.RawMessage `json:"value"`
}
type songPayload struct { type songPayload struct {
Slot *int `json:"slot,omitempty"` Slot *int `json:"slot,omitempty"`
Notes []songNote `json:"notes"` Notes []songNote `json:"notes"`
+664
View File
@@ -0,0 +1,664 @@
package roverd
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"sync"
)
// Firmata command and mode constants are kept here instead of scattering raw
// bytes through the peripheral code. The values come directly from the Firmata
// protocol, so captures from a rover can be compared with the specification.
const (
firmataReportVersion byte = 0xF9
firmataSetPinMode byte = 0xF4
firmataSetDigitalPin byte = 0xF5
firmataStartSysex byte = 0xF0
firmataEndSysex byte = 0xF7
firmataReportFirmware byte = 0x79
firmataCapabilityQuery byte = 0x6B
firmataCapabilityReply byte = 0x6C
firmataExtendedAnalog byte = 0x6F
firmataServoConfig byte = 0x70
firmataPeripheralFeature byte = 0x01
firmataPeripheralDescribe byte = 0x00
firmataPeripheralDescription byte = 0x01
firmataPeripheralControl byte = 0x02
firmataMaximumSysexDataBytes = 252
FirmataPinModeOutput byte = 0x01
FirmataPinModePWM byte = 0x03
FirmataPinModeServo byte = 0x04
)
// FirmataMessage is the transport-neutral result of parsing one complete
// Firmata message. For SysEx messages Command is the SysEx feature byte and
// Data is everything between that feature byte and END_SYSEX.
type FirmataMessage struct {
Command byte
Data []byte
Sysex bool
}
// FirmataParser incrementally parses a byte stream. USB serial reads may split
// a message anywhere or combine several messages, so parsing whole Read calls
// as though they were packets would intermittently corrupt valid traffic.
type FirmataParser struct {
inSysex bool
sysex []byte
command byte
data []byte
expected int
}
// Feed accepts any fragment of the serial stream and returns every complete
// message found in it, preserving wire order.
func (p *FirmataParser) Feed(fragment []byte) ([]FirmataMessage, error) {
var messages []FirmataMessage
for _, value := range fragment {
if p.inSysex {
switch {
case value == firmataEndSysex:
if len(p.sysex) == 0 {
p.resetSysex()
return messages, errors.New("Firmata SysEx message is missing a feature byte")
}
messages = append(messages, FirmataMessage{
Command: p.sysex[0],
Data: append([]byte(nil), p.sysex[1:]...),
Sysex: true,
})
p.resetSysex()
case value&0x80 != 0:
// Bytes inside SysEx must be seven-bit clean. Reset immediately so
// a damaged frame cannot consume every later message on the port.
p.resetSysex()
return messages, fmt.Errorf("invalid 8-bit value 0x%02x inside Firmata SysEx", value)
default:
p.sysex = append(p.sysex, value)
}
continue
}
if value == firmataStartSysex {
p.inSysex = true
p.sysex = p.sysex[:0]
p.resetFixed()
continue
}
if value&0x80 != 0 {
p.command = value
p.data = p.data[:0]
p.expected = firmataDataLength(value)
if p.expected == 0 {
messages = append(messages, FirmataMessage{Command: value})
p.resetFixed()
}
continue
}
// Stray data before a status byte is harmless serial noise. Firmata
// has no framing information that could assign it to a command.
if p.expected == 0 {
continue
}
p.data = append(p.data, value)
if len(p.data) == p.expected {
messages = append(messages, FirmataMessage{
Command: p.command,
Data: append([]byte(nil), p.data...),
})
p.resetFixed()
}
}
return messages, nil
}
func (p *FirmataParser) resetSysex() {
p.inSysex = false
p.sysex = p.sysex[:0]
}
func (p *FirmataParser) resetFixed() {
p.command = 0
p.data = p.data[:0]
p.expected = 0
}
// firmataDataLength returns the number of seven-bit data bytes used by the
// fixed-length messages relevant to normal Firmata traffic. Unknown system
// commands are treated as single-byte messages so they cannot stall parsing of
// the rover-peripheral SysEx frames that follow them.
func firmataDataLength(command byte) int {
switch command {
case firmataReportVersion, firmataSetPinMode, firmataSetDigitalPin:
return 2
}
switch command & 0xF0 {
case 0x80, 0x90, 0xA0, 0xE0:
return 2
case 0xC0, 0xD0:
return 1
default:
return 0
}
}
// EncodeFirmata7Bit converts arbitrary bytes into the two-byte representation
// required inside Firmata SysEx. Keeping this transform below the JSON layer
// means firmware authors and UI code never need to think about wire encoding.
func EncodeFirmata7Bit(raw []byte) []byte {
encoded := make([]byte, 0, len(raw)*2)
for _, value := range raw {
encoded = append(encoded, value&0x7F, (value>>7)&0x01)
}
return encoded
}
// DecodeFirmata7Bit reverses EncodeFirmata7Bit and rejects malformed pairs.
func DecodeFirmata7Bit(encoded []byte) ([]byte, error) {
if len(encoded)%2 != 0 {
return nil, fmt.Errorf("Firmata 7-bit payload has odd length %d", len(encoded))
}
decoded := make([]byte, 0, len(encoded)/2)
for index := 0; index < len(encoded); index += 2 {
low, high := encoded[index], encoded[index+1]
if low&0x80 != 0 || high > 1 {
return nil, fmt.Errorf("invalid Firmata 7-bit pair at byte %d", index)
}
decoded = append(decoded, low|(high<<7))
}
return decoded, nil
}
// PeripheralDescription is generated by the ESP32 at boot. Controls is a slice
// intentionally: registration order is part of the UI contract and must never
// be replaced by map iteration or alphabetical sorting.
type PeripheralDescription struct {
Name string `json:"name"`
RoverControls PeripheralRoverControls `json:"roverControls,omitempty"`
Controls []PeripheralControl `json:"controls"`
}
type PeripheralRoverControls struct {
CameraServo *PeripheralCameraServo `json:"cameraServo,omitempty"`
Headlight *PeripheralDigitalRole `json:"headlight,omitempty"`
Laser *PeripheralDigitalRole `json:"laser,omitempty"`
}
type PeripheralCameraServo struct {
Pin int `json:"pin"`
MinimumAngleDegrees float64 `json:"minimumAngleDegrees"`
MaximumAngleDegrees float64 `json:"maximumAngleDegrees"`
HomeAngleDegrees float64 `json:"homeAngleDegrees"`
NudgeDegrees float64 `json:"nudgeDegrees"`
MinimumPulseMicroseconds int `json:"minimumPulseMicroseconds"`
MaximumPulseMicroseconds int `json:"maximumPulseMicroseconds"`
AllowRawPulse bool `json:"allowRawPulse"`
Inverted bool `json:"inverted"`
}
type PeripheralDigitalRole struct {
Pin int `json:"pin"`
ActiveLow bool `json:"activeLow"`
InitiallyOn bool `json:"initiallyOn"`
}
type PeripheralControl struct {
ID string `json:"id"`
Type string `json:"type"`
Name string `json:"name"`
Mode string `json:"mode,omitempty"`
Minimum *int `json:"min,omitempty"`
Maximum *int `json:"max,omitempty"`
MaximumLength *int `json:"maxLength,omitempty"`
Output PeripheralOutput `json:"output"`
}
type PeripheralOutput struct {
Type string `json:"type"`
Pin *int `json:"pin,omitempty"`
ActiveLow bool `json:"activeLow,omitempty"`
}
// Validate catches authoring mistakes at connection time, where the error can
// name the offending peripheral, instead of allowing a malformed declaration
// to turn into a confusing no-op later when a driver uses the control.
func (description PeripheralDescription) Validate() error {
if description.Name == "" {
return errors.New("peripheral description requires a name")
}
if camera := description.RoverControls.CameraServo; camera != nil {
if err := validateFirmataPin("cameraServo", camera.Pin); err != nil {
return err
}
if camera.MinimumAngleDegrees >= camera.MaximumAngleDegrees {
return errors.New("cameraServo angle range must be increasing")
}
if camera.HomeAngleDegrees < camera.MinimumAngleDegrees || camera.HomeAngleDegrees > camera.MaximumAngleDegrees {
return errors.New("cameraServo home angle must be inside its angle range")
}
if camera.NudgeDegrees <= 0 {
return errors.New("cameraServo nudge must be positive")
}
if camera.MinimumPulseMicroseconds <= 0 || camera.MaximumPulseMicroseconds <= camera.MinimumPulseMicroseconds {
return errors.New("cameraServo pulse range must be positive and increasing")
}
}
if role := description.RoverControls.Headlight; role != nil {
if err := validateFirmataPin("headlight", role.Pin); err != nil {
return err
}
}
if role := description.RoverControls.Laser; role != nil {
if err := validateFirmataPin("laser", role.Pin); err != nil {
return err
}
}
seen := make(map[string]struct{}, len(description.Controls))
for index, control := range description.Controls {
if control.ID == "" || control.Name == "" {
return fmt.Errorf("control %d requires both id and name", index)
}
if _, exists := seen[control.ID]; exists {
return fmt.Errorf("control id %q is duplicated", control.ID)
}
seen[control.ID] = struct{}{}
switch control.Type {
case "slider", "number":
if control.Minimum == nil || control.Maximum == nil || *control.Minimum > *control.Maximum {
return fmt.Errorf("control %q requires a valid min and max", control.ID)
}
case "button":
if control.Mode != "toggle" && control.Mode != "momentary" {
return fmt.Errorf("button %q requires toggle or momentary mode", control.ID)
}
case "text":
if control.MaximumLength == nil || *control.MaximumLength <= 0 {
return fmt.Errorf("text control %q requires a positive maxLength", control.ID)
}
default:
return fmt.Errorf("control %q has unsupported type %q", control.ID, control.Type)
}
switch control.Output.Type {
case "digital":
if control.Output.Pin == nil {
return fmt.Errorf("control %q output %q requires a pin", control.ID, control.Output.Type)
}
if err := validateFirmataPin("control "+control.ID, *control.Output.Pin); err != nil {
return err
}
if control.Type != "button" {
return fmt.Errorf("digital output control %q must be a button", control.ID)
}
case "pwm", "servo":
if control.Output.Pin == nil {
return fmt.Errorf("control %q output %q requires a pin", control.ID, control.Output.Type)
}
if err := validateFirmataPin("control "+control.ID, *control.Output.Pin); err != nil {
return err
}
if control.Type != "slider" && control.Type != "number" {
return fmt.Errorf("%s output control %q must be a slider or number", control.Output.Type, control.ID)
}
case "custom":
default:
return fmt.Errorf("control %q has unsupported output %q", control.ID, control.Output.Type)
}
}
return nil
}
func validateFirmataPin(owner string, pin int) error {
// Firmata represents pin numbers with one seven-bit byte. Rejecting values
// outside that wire range avoids silently wrapping a declaration when it is
// converted to a byte for output commands.
if pin < 0 || pin > 127 {
return fmt.Errorf("%s pin must be between 0 and 127", owner)
}
return nil
}
// FirmataFirmware identifies the implementation answering the standard
// REPORT_FIRMWARE query. It is diagnostic metadata, not a protocol gate.
type FirmataFirmware struct {
Major int
Minor int
Name string
}
// FirmataPinCapability is one mode/resolution pair from CAPABILITY_RESPONSE.
type FirmataPinCapability struct {
Mode byte
Resolution byte
}
// FirmataClient owns one already-open serial connection. Its reader goroutine
// separates arbitrary USB read boundaries from request/response handling while
// writeMu prevents two commands from interleaving on the byte stream.
type FirmataClient struct {
connection io.ReadWriteCloser
parser FirmataParser
messages chan FirmataMessage
errors chan error
writeMu sync.Mutex
requestMu sync.Mutex
stateMu sync.RWMutex
terminalErr error
// terminalErrorHandler is invoked only for the first non-timeout read
// failure while the client context remains active. PeripheralManager uses it
// to turn an unexpected USB loss into one operator-facing broadcast.
terminalErrorHandler func(error)
}
func NewFirmataClient(connection io.ReadWriteCloser) *FirmataClient {
return &FirmataClient{
connection: connection,
messages: make(chan FirmataMessage, 16),
errors: make(chan error, 1),
}
}
// Start begins consuming the serial stream. The caller still owns the port and
// closes it during shutdown; this makes the client usable with both real serial
// ports and deterministic in-memory test connections.
func (client *FirmataClient) Start(ctx context.Context) {
go client.readLoop(ctx)
}
func (client *FirmataClient) readLoop(ctx context.Context) {
buffer := make([]byte, 256)
for {
count, err := client.connection.Read(buffer)
if count > 0 {
messages, parseErr := client.parser.Feed(buffer[:count])
if parseErr != nil {
client.publishError(ctx, parseErr)
return
}
for _, message := range messages {
select {
case client.messages <- message:
case <-ctx.Done():
return
}
}
}
if err != nil {
if errors.Is(err, io.EOF) {
// tarm/serial represents an ordinary ReadTimeout with io.EOF. A
// Firmata connection is expected to be quiet between commands, so
// treating that timeout as a closed device kills the reader before
// the next request can receive its reply. A real USB removal is
// reported by the serial driver as a non-EOF error.
select {
case <-ctx.Done():
return
default:
continue
}
}
client.publishError(ctx, err)
return
}
select {
case <-ctx.Done():
return
default:
}
}
}
func (client *FirmataClient) publishError(ctx context.Context, err error) {
firstTerminalError, handler := client.recordTerminalError(err)
if firstTerminalError && handler != nil && ctx.Err() == nil {
handler(err)
}
select {
case client.errors <- err:
case <-ctx.Done():
default:
}
}
func (client *FirmataClient) recordTerminalError(err error) (bool, func(error)) {
client.stateMu.Lock()
defer client.stateMu.Unlock()
firstTerminalError := client.terminalErr == nil
if client.terminalErr == nil {
client.terminalErr = err
}
handler := client.terminalErrorHandler
return firstTerminalError, handler
}
// SetTerminalErrorHandler registers the one-shot observer used after a device
// has completed discovery. If the connection already failed, the observer is
// called immediately so a narrow handshake-to-registration race is not lost.
func (client *FirmataClient) SetTerminalErrorHandler(handler func(error)) {
client.stateMu.Lock()
client.terminalErrorHandler = handler
terminalErr := client.terminalErr
client.stateMu.Unlock()
if terminalErr != nil && handler != nil {
handler(terminalErr)
}
}
func (client *FirmataClient) write(message []byte) error {
client.writeMu.Lock()
defer client.writeMu.Unlock()
client.stateMu.RLock()
terminalErr := client.terminalErr
client.stateMu.RUnlock()
if terminalErr != nil {
return fmt.Errorf("Firmata connection unavailable: %w", terminalErr)
}
written, err := client.connection.Write(message)
if err != nil {
firstTerminalError, handler := client.recordTerminalError(err)
if firstTerminalError && handler != nil {
handler(err)
}
return err
}
if written != len(message) {
err := fmt.Errorf("short Firmata write %d/%d", written, len(message))
firstTerminalError, handler := client.recordTerminalError(err)
if firstTerminalError && handler != nil {
handler(err)
}
return err
}
return nil
}
func (client *FirmataClient) writeSysex(command byte, data []byte) error {
message := make([]byte, 0, len(data)+3)
message = append(message, firmataStartSysex, command)
message = append(message, data...)
message = append(message, firmataEndSysex)
return client.write(message)
}
func (client *FirmataClient) waitFor(ctx context.Context, match func(FirmataMessage) bool) (FirmataMessage, error) {
for {
select {
case message := <-client.messages:
if match(message) {
return message, nil
}
case err := <-client.errors:
return FirmataMessage{}, err
case <-ctx.Done():
return FirmataMessage{}, ctx.Err()
}
}
}
func (client *FirmataClient) QueryFirmware(ctx context.Context) (FirmataFirmware, error) {
client.requestMu.Lock()
defer client.requestMu.Unlock()
if err := client.writeSysex(firmataReportFirmware, nil); err != nil {
return FirmataFirmware{}, err
}
message, err := client.waitFor(ctx, func(message FirmataMessage) bool {
return message.Sysex && message.Command == firmataReportFirmware
})
if err != nil {
return FirmataFirmware{}, err
}
if len(message.Data) < 2 {
return FirmataFirmware{}, errors.New("Firmata firmware response is missing version bytes")
}
name, err := DecodeFirmata7Bit(message.Data[2:])
if err != nil {
return FirmataFirmware{}, fmt.Errorf("decode Firmata firmware name: %w", err)
}
return FirmataFirmware{Major: int(message.Data[0]), Minor: int(message.Data[1]), Name: string(name)}, nil
}
func (client *FirmataClient) QueryCapabilities(ctx context.Context) ([][]FirmataPinCapability, error) {
client.requestMu.Lock()
defer client.requestMu.Unlock()
if err := client.writeSysex(firmataCapabilityQuery, nil); err != nil {
return nil, err
}
message, err := client.waitFor(ctx, func(message FirmataMessage) bool {
return message.Sysex && message.Command == firmataCapabilityReply
})
if err != nil {
return nil, err
}
return parseFirmataCapabilities(message.Data)
}
func parseFirmataCapabilities(data []byte) ([][]FirmataPinCapability, error) {
var pins [][]FirmataPinCapability
var pin []FirmataPinCapability
for index := 0; index < len(data); {
if data[index] == 0x7F {
pins = append(pins, pin)
pin = nil
index++
continue
}
if index+1 >= len(data) {
return nil, errors.New("Firmata capability response ends inside a mode pair")
}
pin = append(pin, FirmataPinCapability{Mode: data[index], Resolution: data[index+1]})
index += 2
}
if pin != nil {
return nil, errors.New("Firmata capability response is missing its final pin separator")
}
return pins, nil
}
func (client *FirmataClient) Describe(ctx context.Context) (PeripheralDescription, error) {
client.requestMu.Lock()
defer client.requestMu.Unlock()
if err := client.writeSysex(firmataPeripheralFeature, []byte{firmataPeripheralDescribe}); err != nil {
return PeripheralDescription{}, err
}
message, err := client.waitFor(ctx, func(message FirmataMessage) bool {
return message.Sysex && message.Command == firmataPeripheralFeature && len(message.Data) > 0 && message.Data[0] == firmataPeripheralDescription
})
if err != nil {
return PeripheralDescription{}, err
}
raw, err := DecodeFirmata7Bit(message.Data[1:])
if err != nil {
return PeripheralDescription{}, fmt.Errorf("decode peripheral description: %w", err)
}
var description PeripheralDescription
if err := json.Unmarshal(raw, &description); err != nil {
return PeripheralDescription{}, fmt.Errorf("parse peripheral description: %w", err)
}
if err := description.Validate(); err != nil {
return PeripheralDescription{}, fmt.Errorf("validate peripheral description: %w", err)
}
return description, nil
}
func (client *FirmataClient) SetPinMode(pin, mode byte) error {
return client.write([]byte{firmataSetPinMode, pin & 0x7F, mode & 0x7F})
}
func (client *FirmataClient) SetDigitalPin(pin byte, enabled bool) error {
value := byte(0)
if enabled {
value = 1
}
return client.write([]byte{firmataSetDigitalPin, pin & 0x7F, value})
}
func (client *FirmataClient) ExtendedAnalog(pin byte, value int) error {
if value < 0 {
return fmt.Errorf("Firmata analog value cannot be negative: %d", value)
}
payload := []byte{pin & 0x7F}
// Firmata encodes integers as many seven-bit chunks as necessary. Zero
// still needs one value byte so the receiver can distinguish it from a
// message that contains only the pin.
for {
payload = append(payload, byte(value&0x7F))
value >>= 7
if value == 0 {
break
}
}
return client.writeSysex(firmataExtendedAnalog, payload)
}
func (client *FirmataClient) ConfigureServo(pin byte, minimumPulseMicroseconds, maximumPulseMicroseconds int) error {
if minimumPulseMicroseconds <= 0 || maximumPulseMicroseconds <= minimumPulseMicroseconds {
return errors.New("servo pulse range must be positive and increasing")
}
payload := []byte{
pin & 0x7F,
byte(minimumPulseMicroseconds & 0x7F), byte((minimumPulseMicroseconds >> 7) & 0x7F),
byte(maximumPulseMicroseconds & 0x7F), byte((maximumPulseMicroseconds >> 7) & 0x7F),
}
return client.writeSysex(firmataServoConfig, payload)
}
func (client *FirmataClient) SendPeripheralControl(controlID string, value any) error {
payload, err := json.Marshal(struct {
Control string `json:"control"`
Value any `json:"value"`
}{Control: controlID, Value: value})
if err != nil {
return fmt.Errorf("encode peripheral control: %w", err)
}
data := append([]byte{firmataPeripheralControl}, EncodeFirmata7Bit(payload)...)
// ConfigurableFirmata on ESP32 stores at most 252 bytes including the SysEx
// feature byte. Refuse a value that the board would otherwise discard as an
// incomplete frame; this is a transport constraint, not an application-level
// text policy.
if len(data)+1 > firmataMaximumSysexDataBytes {
return fmt.Errorf("peripheral control needs %d SysEx data bytes; Firmata accepts at most %d", len(data)+1, firmataMaximumSysexDataBytes)
}
return client.writeSysex(firmataPeripheralFeature, data)
}
+304
View File
@@ -0,0 +1,304 @@
package roverd
import (
"fmt"
"log"
"math"
"strings"
"sync"
"time"
)
// FirmataCameraServo preserves the established logical camera movement model
// while replacing only the final physical write. The ESP32 receives ordinary
// Firmata servo configuration and angle messages, regardless of rover host.
type FirmataCameraServo struct {
cfg CameraServoConfig
client *FirmataClient
pin byte
peripheralID string
mu sync.Mutex
currentAngle float64
desiredAngle float64
lastMove time.Time
moving bool
stopCh chan struct{}
closed bool
}
func newFirmataCameraServo(peripheral *managedPeripheral, declaration PeripheralCameraServo, logger *log.Logger) (*FirmataCameraServo, error) {
cfg := CameraServoConfig{
Enabled: true,
Pin: declaration.Pin,
FreqHz: 50,
CycleLen: 20000,
MinPulseUs: declaration.MinimumPulseMicroseconds,
MaxPulseUs: declaration.MaximumPulseMicroseconds,
MinAngle: declaration.MinimumAngleDegrees,
MaxAngle: declaration.MaximumAngleDegrees,
HomeAngle: declaration.HomeAngleDegrees,
NudgeDegrees: declaration.NudgeDegrees,
AllowRawPulse: declaration.AllowRawPulse,
Invert: declaration.Inverted,
}
servo := &FirmataCameraServo{
cfg: cfg,
client: peripheral.client,
pin: byte(declaration.Pin),
peripheralID: peripheral.metadata.ID,
stopCh: make(chan struct{}),
}
// SERVO_CONFIG establishes the peripheral-owned pulse calibration before
// selecting servo mode. This is standard Firmata, not a rover extension.
if err := servo.client.ConfigureServo(servo.pin, cfg.MinPulseUs, cfg.MaxPulseUs); err != nil {
return nil, fmt.Errorf("configure Firmata servo: %w", err)
}
if err := servo.client.SetPinMode(servo.pin, FirmataPinModeServo); err != nil {
return nil, fmt.Errorf("select Firmata servo mode: %w", err)
}
if err := servo.setAngleLocked(cfg.HomeAngle); err != nil {
return nil, err
}
logger.Printf("camera servo using ESP32 %s pin %d (%.1f..%.1f deg)", peripheral.metadata.ID, declaration.Pin, cfg.MinAngle, cfg.MaxAngle)
return servo, nil
}
func (servo *FirmataCameraServo) SetAngle(angle float64) error {
servo.mu.Lock()
defer servo.mu.Unlock()
return servo.setAngleLocked(angle)
}
func (servo *FirmataCameraServo) setAngleLocked(angle float64) error {
if servo.closed {
return errorsNewControllerClosed("camera servo")
}
servo.desiredAngle = clampFloat(angle, servo.cfg.MinAngle, servo.cfg.MaxAngle)
limited := servo.rateLimitAngleLocked(servo.desiredAngle)
if err := servo.writeAngleLocked(limited); err != nil {
return err
}
servo.currentAngle = limited
if math.Abs(limited-servo.desiredAngle) > servoAngleEpsilon {
servo.startMoveLoopLocked()
}
return nil
}
func (servo *FirmataCameraServo) Nudge(delta float64) error {
servo.mu.Lock()
defer servo.mu.Unlock()
if delta == 0 {
delta = servo.cfg.NudgeDegrees
}
return servo.setAngleLocked(servo.currentAngle + delta)
}
func (servo *FirmataCameraServo) SetPulseWidth(micros int) error {
servo.mu.Lock()
defer servo.mu.Unlock()
if !servo.cfg.AllowRawPulse {
return fmt.Errorf("raw pulse commands disabled")
}
if micros <= 0 {
return fmt.Errorf("pulse width must be > 0")
}
pulse := clampInt(micros, servo.cfg.MinPulseUs, servo.cfg.MaxPulseUs)
return servo.setAngleLocked(servo.pulseToAngle(pulse))
}
func (servo *FirmataCameraServo) CurrentAngle() float64 {
servo.mu.Lock()
defer servo.mu.Unlock()
return servo.currentAngle
}
func (servo *FirmataCameraServo) Configuration() CameraServoConfig {
return servo.cfg
}
func (servo *FirmataCameraServo) BackendDescription() string {
return "ESP32 " + servo.peripheralID
}
func (servo *FirmataCameraServo) Close() {
servo.mu.Lock()
defer servo.mu.Unlock()
if servo.closed {
return
}
// Returning home matches the native Pi implementation. Any write failure is
// ignored during shutdown because the serial connection may already be gone.
_ = servo.writeAngleLocked(servo.cfg.HomeAngle)
close(servo.stopCh)
servo.closed = true
}
func (servo *FirmataCameraServo) writeAngleLocked(angle float64) error {
rangeDegrees := servo.cfg.MaxAngle - servo.cfg.MinAngle
normalized := (angle - servo.cfg.MinAngle) / rangeDegrees
normalized = math.Max(0, math.Min(1, normalized))
if servo.cfg.Invert {
normalized = 1 - normalized
}
// Standard Firmata servo values are positions from 0 through 180. Pulse
// calibration was already supplied through SERVO_CONFIG above.
position := int(math.Round(normalized * 180))
return servo.client.ExtendedAnalog(servo.pin, position)
}
func (servo *FirmataCameraServo) pulseToAngle(pulse int) float64 {
normalized := float64(pulse-servo.cfg.MinPulseUs) / float64(servo.cfg.MaxPulseUs-servo.cfg.MinPulseUs)
if servo.cfg.Invert {
normalized = 1 - normalized
}
return servo.cfg.MinAngle + normalized*(servo.cfg.MaxAngle-servo.cfg.MinAngle)
}
func (servo *FirmataCameraServo) rateLimitAngleLocked(target float64) float64 {
now := time.Now()
if servo.lastMove.IsZero() {
servo.lastMove = now
}
elapsed := now.Sub(servo.lastMove).Seconds()
if elapsed > servoStepInterval.Seconds() {
elapsed = servoStepInterval.Seconds()
}
maximumDelta := maxServoDegPerSec * elapsed
delta := target - servo.currentAngle
if math.Abs(delta) <= maximumDelta {
servo.lastMove = now
return target
}
servo.lastMove = now
if delta > 0 {
return servo.currentAngle + maximumDelta
}
return servo.currentAngle - maximumDelta
}
func (servo *FirmataCameraServo) startMoveLoopLocked() {
if servo.moving || servo.closed {
return
}
servo.moving = true
go func() {
ticker := time.NewTicker(servoStepInterval)
defer ticker.Stop()
for {
select {
case <-ticker.C:
servo.mu.Lock()
if servo.closed || math.Abs(servo.currentAngle-servo.desiredAngle) <= servoAngleEpsilon {
servo.moving = false
servo.mu.Unlock()
return
}
limited := servo.rateLimitAngleLocked(servo.desiredAngle)
if err := servo.writeAngleLocked(limited); err != nil {
// A failed serial write makes further automatic steps pointless.
// The next user command returns the connection error normally.
servo.moving = false
servo.mu.Unlock()
return
}
servo.currentAngle = limited
servo.mu.Unlock()
case <-servo.stopCh:
return
}
}
}()
}
// FirmataToggle owns logical state exactly like GPIOToggle but sends the final
// electrical level through Firmata's standard digital-pin command.
type FirmataToggle struct {
cfg GPIOToggleConfig
name string
client *FirmataClient
pin byte
peripheralID string
mu sync.Mutex
on bool
closed bool
}
func newFirmataToggle(name string, peripheral *managedPeripheral, declaration PeripheralDigitalRole, logger *log.Logger) (*FirmataToggle, error) {
cfg := GPIOToggleConfig{Enabled: true, GPIOPin: declaration.Pin, InitialOn: declaration.InitiallyOn, ActiveLow: declaration.ActiveLow}
toggle := &FirmataToggle{
cfg: cfg,
name: name,
client: peripheral.client,
pin: byte(declaration.Pin),
peripheralID: peripheral.metadata.ID,
on: cfg.InitialOn,
}
if err := toggle.client.SetPinMode(toggle.pin, FirmataPinModeOutput); err != nil {
return nil, fmt.Errorf("select Firmata output mode: %w", err)
}
if err := toggle.writeLocked(toggle.on); err != nil {
return nil, fmt.Errorf("initialize Firmata output: %w", err)
}
logger.Printf("%s using ESP32 %s pin %d (initial=%v activeLow=%v)", name, peripheral.metadata.ID, declaration.Pin, cfg.InitialOn, cfg.ActiveLow)
return toggle, nil
}
func (toggle *FirmataToggle) HandleAction(action string) error {
toggle.mu.Lock()
defer toggle.mu.Unlock()
if toggle.closed {
return errorsNewControllerClosed(toggle.name)
}
switch strings.ToLower(strings.TrimSpace(action)) {
case "", "toggle":
return toggle.setLocked(!toggle.on)
case "on":
return toggle.setLocked(true)
case "off":
return toggle.setLocked(false)
default:
return fmt.Errorf("unknown action %q", action)
}
}
func (toggle *FirmataToggle) setLocked(on bool) error {
if err := toggle.writeLocked(on); err != nil {
return err
}
toggle.on = on
return nil
}
func (toggle *FirmataToggle) writeLocked(on bool) error {
physicalHigh := on
if toggle.cfg.ActiveLow {
physicalHigh = !physicalHigh
}
return toggle.client.SetDigitalPin(toggle.pin, physicalHigh)
}
func (toggle *FirmataToggle) On() bool {
toggle.mu.Lock()
defer toggle.mu.Unlock()
return toggle.on
}
func (toggle *FirmataToggle) Configuration() GPIOToggleConfig {
return toggle.cfg
}
func (toggle *FirmataToggle) BackendDescription() string {
return "ESP32 " + toggle.peripheralID
}
func (toggle *FirmataToggle) Close() {
toggle.mu.Lock()
defer toggle.mu.Unlock()
toggle.closed = true
}
func errorsNewControllerClosed(name string) error {
return fmt.Errorf("%s controller closed", name)
}
@@ -0,0 +1,163 @@
package roverd
import (
"bytes"
"context"
"log"
"testing"
)
func TestDisabledNativeRolesResolveToFirmataOnEveryHostBuild(t *testing.T) {
description := PeripheralDescription{
Name: "Rover GPIO",
RoverControls: PeripheralRoverControls{
CameraServo: &PeripheralCameraServo{
Pin: 14, MinimumAngleDegrees: -15, MaximumAngleDegrees: 30,
HomeAngleDegrees: 0, NudgeDegrees: 2,
MinimumPulseMicroseconds: 900, MaximumPulseMicroseconds: 2100,
},
Headlight: &PeripheralDigitalRole{Pin: 18, ActiveLow: true, InitiallyOn: true},
Laser: &PeripheralDigitalRole{Pin: 16, ActiveLow: false, InitiallyOn: false},
},
Controls: []PeripheralControl{},
}
connection := scriptedPeripheralConnection(t, description)
manager, err := discoverPeripheralManager(
context.Background(),
"/dev/roomba",
discardLogger(),
testPeripheralDiscoveryDependencies([]string{"/dev/rover-gpio"}, map[string]*scriptedConnection{"/dev/rover-gpio": connection}),
)
if err != nil {
t.Fatalf("discover: %v", err)
}
defer manager.Close()
// All native entries are disabled, exactly as they can be on either a Pi or
// laptop rover. The shared resolver must therefore select every ESP32 role.
baseline := len(connection.Bytes())
controllers, err := ResolveRoverHardwareControllers(&Config{}, manager, discardLogger())
if err != nil {
t.Fatalf("resolve: %v", err)
}
defer controllers.Close()
if controllers.CameraServo == nil || controllers.Headlight == nil || controllers.Laser == nil {
t.Fatalf("missing Firmata controller: %#v", controllers)
}
if !controllers.CameraServo.Configuration().Enabled || !controllers.Headlight.Configuration().Enabled || !controllers.Laser.Configuration().Enabled {
t.Fatal("ESP32-backed roles were not advertised as enabled")
}
wantHardwareBroadcast := "Rover hardware ready: camera servo via ESP32 firmata-0, headlight via ESP32 firmata-0, laser via ESP32 firmata-0."
if messages := controllers.StartupBroadcasts(); len(messages) != 1 || messages[0] != wantHardwareBroadcast {
t.Fatalf("hardware broadcasts = %#v, want %q", messages, wantHardwareBroadcast)
}
// Initialization uses only standard Firmata: servo calibration and mode,
// followed by the home position and digital initial states. The active-low
// headlight starts logically on, so its physical output is low.
writes := connection.Bytes()[baseline:]
wantPrefix := []byte{
firmataStartSysex, firmataServoConfig, 14, 4, 7, 52, 16, firmataEndSysex,
firmataSetPinMode, 14, FirmataPinModeServo,
firmataStartSysex, firmataExtendedAnalog, 14, 60, firmataEndSysex,
firmataSetPinMode, 18, FirmataPinModeOutput,
firmataSetDigitalPin, 18, 0,
firmataSetPinMode, 16, FirmataPinModeOutput,
firmataSetDigitalPin, 16, 0,
}
if !bytes.Equal(writes, wantPrefix) {
t.Fatalf("initial controller bytes = %v, want %v", writes, wantPrefix)
}
baseline = len(connection.Bytes())
if err := controllers.Headlight.HandleAction("off"); err != nil {
t.Fatalf("turn headlight off: %v", err)
}
if controllers.Headlight.On() {
t.Fatal("headlight remained logically on")
}
// Active-low means logical off becomes a high electrical output.
if got, want := connection.Bytes()[baseline:], []byte{firmataSetDigitalPin, 18, 1}; !bytes.Equal(got, want) {
t.Fatalf("headlight bytes = %v, want %v", got, want)
}
}
func TestMissingNativeAndFirmataRolesRemainDisabled(t *testing.T) {
manager := &PeripheralManager{byID: make(map[string]*managedPeripheral)}
controllers, err := ResolveRoverHardwareControllers(&Config{}, manager, discardLogger())
if err != nil {
t.Fatalf("resolve: %v", err)
}
if controllers.CameraServo != nil || controllers.Headlight != nil || controllers.Laser != nil {
t.Fatalf("unexpected controllers without providers: %#v", controllers)
}
}
func TestEnabledNativeRolesWinEvenWithSeveralFirmataProviders(t *testing.T) {
roleDescription := PeripheralDescription{RoverControls: PeripheralRoverControls{
CameraServo: &PeripheralCameraServo{},
Headlight: &PeripheralDigitalRole{},
Laser: &PeripheralDigitalRole{},
}}
manager := &PeripheralManager{
byID: make(map[string]*managedPeripheral),
peripherals: []*managedPeripheral{
{metadata: RoverPeripheralMetadata{ID: "firmata-0"}, description: roleDescription},
{metadata: RoverPeripheralMetadata{ID: "firmata-1"}, description: roleDescription},
},
}
cfg := &Config{
CameraServo: CameraServoConfig{Enabled: true},
Headlight: GPIOToggleConfig{Enabled: true},
Laser: GPIOToggleConfig{Enabled: true},
}
nativeCamera := &testCameraServoController{cfg: cfg.CameraServo}
nativeToggles := map[string]*testToggleController{}
factories := nativeHardwareControllerFactories{
newCameraServo: func(_ CameraServoConfig, _ *log.Logger) (CameraServoController, error) {
return nativeCamera, nil
},
newToggle: func(name string, config GPIOToggleConfig, _ *log.Logger) (ToggleController, error) {
controller := &testToggleController{cfg: config}
nativeToggles[name] = controller
return controller, nil
},
}
// Duplicate Firmata declarations are irrelevant when native hardware wins;
// selection must neither fail nor initialize either ESP32 provider.
controllers, err := resolveRoverHardwareControllers(cfg, manager, discardLogger(), factories)
if err != nil {
t.Fatalf("resolve native precedence: %v", err)
}
if controllers.CameraServo != nativeCamera || controllers.Headlight != nativeToggles["headlight"] || controllers.Laser != nativeToggles["laser"] {
t.Fatal("resolver did not retain native controllers")
}
messages := controllers.StartupBroadcasts()
if len(messages) != 2 || messages[0] != "Ignored ESP32 camera servo, headlight, laser because native GPIO is enabled." || messages[1] != "Rover hardware ready: camera servo via native GPIO, headlight via native GPIO, laser via native GPIO." {
t.Fatalf("native precedence broadcasts = %#v", messages)
}
}
type testCameraServoController struct {
cfg CameraServoConfig
}
func (controller *testCameraServoController) SetAngle(float64) error { return nil }
func (controller *testCameraServoController) Nudge(float64) error { return nil }
func (controller *testCameraServoController) SetPulseWidth(int) error { return nil }
func (controller *testCameraServoController) CurrentAngle() float64 { return 0 }
func (controller *testCameraServoController) Configuration() CameraServoConfig { return controller.cfg }
func (controller *testCameraServoController) BackendDescription() string { return "native GPIO" }
func (controller *testCameraServoController) Close() {}
type testToggleController struct {
cfg GPIOToggleConfig
on bool
}
func (controller *testToggleController) HandleAction(string) error { return nil }
func (controller *testToggleController) On() bool { return controller.on }
func (controller *testToggleController) Configuration() GPIOToggleConfig { return controller.cfg }
func (controller *testToggleController) BackendDescription() string { return "native GPIO" }
func (controller *testToggleController) Close() {}
+441
View File
@@ -0,0 +1,441 @@
package roverd
import (
"bytes"
"context"
"encoding/json"
"io"
"reflect"
"sync"
"testing"
"time"
)
func TestFirmataParserHandlesFragmentedSysex(t *testing.T) {
parser := FirmataParser{}
first, err := parser.Feed([]byte{firmataStartSysex, firmataPeripheralFeature, firmataPeripheralDescription, 1})
if err != nil {
t.Fatalf("first fragment: %v", err)
}
if len(first) != 0 {
t.Fatalf("first fragment unexpectedly produced %d messages", len(first))
}
second, err := parser.Feed([]byte{0, 2, 0, firmataEndSysex})
if err != nil {
t.Fatalf("second fragment: %v", err)
}
want := []FirmataMessage{{
Command: firmataPeripheralFeature,
Data: []byte{firmataPeripheralDescription, 1, 0, 2, 0},
Sysex: true,
}}
if !reflect.DeepEqual(second, want) {
t.Fatalf("messages = %#v, want %#v", second, want)
}
}
func TestFirmataParserReturnsSeveralMessagesFromOneRead(t *testing.T) {
parser := FirmataParser{}
messages, err := parser.Feed([]byte{
firmataReportVersion, 2, 5,
firmataStartSysex, firmataCapabilityReply, 0x01, 0x01, 0x7F, firmataEndSysex,
firmataSetDigitalPin, 18, 1,
})
if err != nil {
t.Fatalf("feed: %v", err)
}
if len(messages) != 3 {
t.Fatalf("got %d messages, want 3", len(messages))
}
if messages[0].Command != firmataReportVersion || messages[1].Command != firmataCapabilityReply || messages[2].Command != firmataSetDigitalPin {
t.Fatalf("commands were not preserved in wire order: %#v", messages)
}
}
func TestFirmataParserRejectsEightBitSysexDataAndRecovers(t *testing.T) {
parser := FirmataParser{}
if _, err := parser.Feed([]byte{firmataStartSysex, firmataPeripheralFeature, 0x80}); err == nil {
t.Fatal("expected invalid SysEx data to fail")
}
messages, err := parser.Feed([]byte{firmataReportVersion, 2, 5})
if err != nil {
t.Fatalf("feed after invalid SysEx: %v", err)
}
if len(messages) != 1 || messages[0].Command != firmataReportVersion {
t.Fatalf("parser did not recover: %#v", messages)
}
}
func TestFirmataSevenBitRoundTripIncludesUTF8(t *testing.T) {
raw := []byte(`{"name":"Café lights","value":255}`)
encoded := EncodeFirmata7Bit(raw)
for index, value := range encoded {
if value&0x80 != 0 {
t.Fatalf("encoded byte %d is not seven-bit clean: 0x%02x", index, value)
}
}
decoded, err := DecodeFirmata7Bit(encoded)
if err != nil {
t.Fatalf("decode: %v", err)
}
if !bytes.Equal(decoded, raw) {
t.Fatalf("decoded %q, want %q", decoded, raw)
}
}
func TestDecodeFirmataSevenBitRejectsMalformedPairs(t *testing.T) {
for name, encoded := range map[string][]byte{
"odd length": {1},
"high byte": {1, 2},
"eight bit": {0x80, 0},
} {
t.Run(name, func(t *testing.T) {
if _, err := DecodeFirmata7Bit(encoded); err == nil {
t.Fatal("expected malformed pair to fail")
}
})
}
}
func TestPeripheralDescriptionPreservesControlOrder(t *testing.T) {
raw := []byte(`{
"name":"Test peripheral",
"controls":[
{"id":"servo","type":"slider","name":"Servo","min":0,"max":180,"output":{"type":"servo","pin":14}},
{"id":"lights","type":"slider","name":"Lights","min":0,"max":255,"output":{"type":"pwm","pin":18}},
{"id":"action","type":"button","name":"Action","mode":"momentary","output":{"type":"custom"}}
]
}`)
var description PeripheralDescription
if err := json.Unmarshal(raw, &description); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if err := description.Validate(); err != nil {
t.Fatalf("validate: %v", err)
}
want := []string{"servo", "lights", "action"}
for index, id := range want {
if description.Controls[index].ID != id {
t.Fatalf("control %d = %q, want %q", index, description.Controls[index].ID, id)
}
}
}
func TestPeripheralDescriptionRejectsInvalidDeclarations(t *testing.T) {
minimum, maximum, pin := 10, 1, 200
for name, description := range map[string]PeripheralDescription{
"duplicate id": {
Name: "device",
Controls: []PeripheralControl{
{ID: "same", Name: "First", Type: "button", Mode: "toggle", Output: PeripheralOutput{Type: "custom"}},
{ID: "same", Name: "Second", Type: "button", Mode: "toggle", Output: PeripheralOutput{Type: "custom"}},
},
},
"reversed range": {
Name: "device",
Controls: []PeripheralControl{{
ID: "level", Name: "Level", Type: "slider", Minimum: &minimum, Maximum: &maximum, Output: PeripheralOutput{Type: "custom"},
}},
},
"pin outside Firmata": {
Name: "device",
Controls: []PeripheralControl{{
ID: "switch", Name: "Switch", Type: "button", Mode: "toggle", Output: PeripheralOutput{Type: "digital", Pin: &pin},
}},
},
} {
t.Run(name, func(t *testing.T) {
if err := description.Validate(); err == nil {
t.Fatal("expected invalid description to fail")
}
})
}
}
func TestParseFirmataCapabilities(t *testing.T) {
pins, err := parseFirmataCapabilities([]byte{
FirmataPinModeOutput, 1, FirmataPinModePWM, 8, 0x7F,
FirmataPinModeOutput, 1, FirmataPinModeServo, 14, 0x7F,
})
if err != nil {
t.Fatalf("parse capabilities: %v", err)
}
if len(pins) != 2 || len(pins[0]) != 2 || pins[1][1].Mode != FirmataPinModeServo {
t.Fatalf("unexpected capabilities: %#v", pins)
}
if _, err := parseFirmataCapabilities([]byte{FirmataPinModeOutput}); err == nil {
t.Fatal("expected incomplete capability pair to fail")
}
}
func TestFirmataClientWritesStandardCommands(t *testing.T) {
connection := &recordingConnection{}
client := NewFirmataClient(connection)
if err := client.SetPinMode(14, FirmataPinModeServo); err != nil {
t.Fatalf("set pin mode: %v", err)
}
if err := client.ConfigureServo(14, 900, 2100); err != nil {
t.Fatalf("configure servo: %v", err)
}
if err := client.ExtendedAnalog(14, 180); err != nil {
t.Fatalf("extended analog: %v", err)
}
if err := client.SetDigitalPin(19, true); err != nil {
t.Fatalf("digital write: %v", err)
}
want := []byte{
firmataSetPinMode, 14, FirmataPinModeServo,
firmataStartSysex, firmataServoConfig, 14, 4, 7, 52, 16, firmataEndSysex,
firmataStartSysex, firmataExtendedAnalog, 14, 52, 1, firmataEndSysex,
firmataSetDigitalPin, 19, 1,
}
if got := connection.Bytes(); !bytes.Equal(got, want) {
t.Fatalf("wire bytes = %v, want %v", got, want)
}
}
func TestFirmataClientQueriesAndDecodesDescription(t *testing.T) {
descriptionJSON := []byte(`{"name":"Bench device","controls":[{"id":"go","type":"button","name":"Go","mode":"momentary","output":{"type":"custom"}}]}`)
firmwareName := EncodeFirmata7Bit([]byte("RoverPeripheralFirmata"))
description := append([]byte{firmataStartSysex, firmataPeripheralFeature, firmataPeripheralDescription}, EncodeFirmata7Bit(descriptionJSON)...)
description = append(description, firmataEndSysex)
connection := newScriptedConnection(
append(append([]byte{firmataStartSysex, firmataReportFirmware, 1, 0}, firmwareName...), firmataEndSysex),
description,
)
client := NewFirmataClient(connection)
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
client.Start(ctx)
firmware, err := client.QueryFirmware(ctx)
if err != nil {
t.Fatalf("query firmware: %v", err)
}
if firmware.Name != "RoverPeripheralFirmata" || firmware.Major != 1 || firmware.Minor != 0 {
t.Fatalf("unexpected firmware: %#v", firmware)
}
got, err := client.Describe(ctx)
if err != nil {
t.Fatalf("describe: %v", err)
}
if got.Name != "Bench device" || len(got.Controls) != 1 || got.Controls[0].ID != "go" {
t.Fatalf("unexpected description: %#v", got)
}
writes := connection.Bytes()
wantWrites := []byte{
firmataStartSysex, firmataReportFirmware, firmataEndSysex,
firmataStartSysex, firmataPeripheralFeature, firmataPeripheralDescribe, firmataEndSysex,
}
if !bytes.Equal(writes, wantWrites) {
t.Fatalf("queries = %v, want %v", writes, wantWrites)
}
}
func TestFirmataClientKeepsReadingAfterSerialTimeoutEOF(t *testing.T) {
firmwareName := EncodeFirmata7Bit([]byte("RoverPeripheralFirmata"))
response := append([]byte{firmataStartSysex, firmataReportFirmware, 1, 0}, firmwareName...)
response = append(response, firmataEndSysex)
// tarm/serial returns io.EOF when its ReadTimeout expires without bytes.
// Reproducing that behavior before the response prevents this regression
// from being hidden by an in-memory reader that blocks indefinitely instead.
connection := newScriptedConnection(response)
connection.timeoutsBeforeRead = 1
client := NewFirmataClient(connection)
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
client.Start(ctx)
firmware, err := client.QueryFirmware(ctx)
if err != nil {
t.Fatalf("query firmware after timeout: %v", err)
}
if firmware.Name != "RoverPeripheralFirmata" {
t.Fatalf("firmware name = %q", firmware.Name)
}
}
func TestFirmataClientEncodesCustomControl(t *testing.T) {
for name, testCase := range map[string]struct {
controlID string
value any
wantJSON string
}{
"button": {controlID: "specialAction", value: true, wantJSON: `{"control":"specialAction","value":true}`},
"text": {controlID: "displayText", value: "Café ready", wantJSON: `{"control":"displayText","value":"Café ready"}`},
} {
t.Run(name, func(t *testing.T) {
connection := &recordingConnection{}
client := NewFirmataClient(connection)
if err := client.SendPeripheralControl(testCase.controlID, testCase.value); err != nil {
t.Fatalf("send control: %v", err)
}
wire := connection.Bytes()
if len(wire) < 5 || wire[0] != firmataStartSysex || wire[1] != firmataPeripheralFeature || wire[2] != firmataPeripheralControl || wire[len(wire)-1] != firmataEndSysex {
t.Fatalf("invalid control frame: %v", wire)
}
raw, err := DecodeFirmata7Bit(wire[3 : len(wire)-1])
if err != nil {
t.Fatalf("decode control: %v", err)
}
if string(raw) != testCase.wantJSON {
t.Fatalf("control JSON = %s, want %s", raw, testCase.wantJSON)
}
})
}
}
func TestFirmataClientQueriesCapabilities(t *testing.T) {
response := []byte{
firmataStartSysex, firmataCapabilityReply,
FirmataPinModeOutput, 1, FirmataPinModePWM, 8, 0x7F,
FirmataPinModeOutput, 1, FirmataPinModeServo, 14, 0x7F,
firmataEndSysex,
}
connection := newScriptedConnection(response)
client := NewFirmataClient(connection)
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
client.Start(ctx)
pins, err := client.QueryCapabilities(ctx)
if err != nil {
t.Fatalf("query capabilities: %v", err)
}
if len(pins) != 2 || pins[0][1].Mode != FirmataPinModePWM || pins[1][1].Mode != FirmataPinModeServo {
t.Fatalf("unexpected capabilities: %#v", pins)
}
if want := []byte{firmataStartSysex, firmataCapabilityQuery, firmataEndSysex}; !bytes.Equal(connection.Bytes(), want) {
t.Fatalf("query bytes = %v, want %v", connection.Bytes(), want)
}
}
func TestFirmataClientRejectsControlTooLargeForFirmwareParser(t *testing.T) {
connection := &recordingConnection{}
client := NewFirmataClient(connection)
if err := client.SendPeripheralControl("displayText", string(bytes.Repeat([]byte{'x'}, 200))); err == nil {
t.Fatal("expected oversized control to fail")
}
if len(connection.Bytes()) != 0 {
t.Fatalf("oversized control wrote bytes: %v", connection.Bytes())
}
}
// recordingConnection is deliberately minimal: write-focused tests should not
// need goroutines or a real serial device merely to inspect exact Firmata bytes.
type recordingConnection struct {
mu sync.Mutex
writes bytes.Buffer
closed bool
writeErr error
}
func (connection *recordingConnection) Read(_ []byte) (int, error) { return 0, io.EOF }
func (connection *recordingConnection) Write(data []byte) (int, error) {
connection.mu.Lock()
defer connection.mu.Unlock()
if connection.closed {
return 0, io.ErrClosedPipe
}
if connection.writeErr != nil {
return 0, connection.writeErr
}
return connection.writes.Write(data)
}
func (connection *recordingConnection) Close() error {
connection.mu.Lock()
defer connection.mu.Unlock()
connection.closed = true
return nil
}
func (connection *recordingConnection) Bytes() []byte {
connection.mu.Lock()
defer connection.mu.Unlock()
return append([]byte(nil), connection.writes.Bytes()...)
}
func (connection *recordingConnection) Closed() bool {
connection.mu.Lock()
defer connection.mu.Unlock()
return connection.closed
}
func (connection *recordingConnection) SetWriteError(err error) {
connection.mu.Lock()
defer connection.mu.Unlock()
connection.writeErr = err
}
// scriptedConnection releases one response after each client write. This
// mirrors request/response serial behavior and prevents a fast reader goroutine
// from publishing all scripted answers before the matching query is sent.
type scriptedConnection struct {
recordingConnection
responses chan []byte
reads chan []byte
timeoutsBeforeRead int
pendingRead []byte
closeOnce sync.Once
}
func newScriptedConnection(responses ...[]byte) *scriptedConnection {
connection := &scriptedConnection{
responses: make(chan []byte, len(responses)),
reads: make(chan []byte, len(responses)),
}
for _, response := range responses {
connection.responses <- append([]byte(nil), response...)
}
return connection
}
func (connection *scriptedConnection) Read(target []byte) (int, error) {
if connection.timeoutsBeforeRead > 0 {
connection.timeoutsBeforeRead--
return 0, io.EOF
}
if len(connection.pendingRead) == 0 {
response, ok := <-connection.reads
if !ok {
return 0, io.ErrClosedPipe
}
connection.pendingRead = response
}
written := copy(target, connection.pendingRead)
connection.pendingRead = connection.pendingRead[written:]
return written, nil
}
func (connection *scriptedConnection) Write(data []byte) (int, error) {
written, err := connection.recordingConnection.Write(data)
if err == nil {
select {
case response := <-connection.responses:
connection.reads <- response
default:
}
}
return written, err
}
func (connection *scriptedConnection) Close() error {
connection.closeOnce.Do(func() {
_ = connection.recordingConnection.Close()
close(connection.reads)
})
return nil
}
+9
View File
@@ -87,6 +87,15 @@ func (g *GPIOToggle) On() bool {
return g.on return g.on
} }
// Configuration returns the native toggle behavior used in the rover hello.
func (g *GPIOToggle) Configuration() GPIOToggleConfig {
return g.cfg
}
func (g *GPIOToggle) BackendDescription() string {
return "native GPIO"
}
func (g *GPIOToggle) setLocked(on bool) error { func (g *GPIOToggle) setLocked(on bool) error {
// This is the only place a logical device state becomes an electrical GPIO // This is the only place a logical device state becomes an electrical GPIO
// value. Hardware that turns on when pulled low sets activeLow in roverd // value. Hardware that turns on when pulled low sets activeLow in roverd
+12 -4
View File
@@ -13,10 +13,10 @@ type GPIOToggle struct {
func NewGPIOToggle(name string, _ GPIOToggleConfig, _ *log.Logger) (*GPIOToggle, error) { func NewGPIOToggle(name string, _ GPIOToggleConfig, _ *log.Logger) (*GPIOToggle, error) {
/* /*
A Debian laptop has no Raspberry Pi GPIO character-device contract for A Debian laptop has no native Raspberry Pi GPIO contract. Returning an
headlights or lasers. Returning an error when enabled makes bad laptop error here catches an invalid native configuration; the shared resolver
configs fail during startup instead of advertising controls that cannot selects an ESP32 Firmata toggle before this constructor when native GPIO
change any hardware. is disabled.
*/ */
return nil, fmt.Errorf("%s not supported in the debian-laptop build", name) return nil, fmt.Errorf("%s not supported in the debian-laptop build", name)
} }
@@ -30,3 +30,11 @@ func (g *GPIOToggle) HandleAction(action string) error {
func (g *GPIOToggle) On() bool { func (g *GPIOToggle) On() bool {
return false return false
} }
func (g *GPIOToggle) Configuration() GPIOToggleConfig {
return GPIOToggleConfig{}
}
func (g *GPIOToggle) BackendDescription() string {
return "native GPIO"
}
+8
View File
@@ -24,3 +24,11 @@ func (g *GPIOToggle) HandleAction(action string) error {
func (g *GPIOToggle) On() bool { func (g *GPIOToggle) On() bool {
return false return false
} }
func (g *GPIOToggle) Configuration() GPIOToggleConfig {
return GPIOToggleConfig{}
}
func (g *GPIOToggle) BackendDescription() string {
return "native GPIO"
}
+164
View File
@@ -0,0 +1,164 @@
package roverd
import (
"fmt"
"log"
"strings"
"time"
)
// Both physical servo backends consume these exact motion constants. Keeping
// them in shared code prevents Pi PWM and ESP32 Firmata movement from drifting
// apart as either implementation evolves.
const (
maxServoDegPerSec = 60.0
servoStepInterval = 20 * time.Millisecond
servoAngleEpsilon = 0.01
)
// CameraServoController is the hardware-neutral camera-tilt contract used by
// WSClient. Native Pi PWM and ESP32 Firmata implementations expose identical
// logical behavior, so command handling never branches on the rover host type.
type CameraServoController interface {
SetAngle(angle float64) error
Nudge(delta float64) error
SetPulseWidth(micros int) error
CurrentAngle() float64
Configuration() CameraServoConfig
BackendDescription() string
Close()
}
// ToggleController keeps headlight and laser command/state behavior independent
// of whether the electrical write happens on native Pi GPIO or an ESP32 pin.
type ToggleController interface {
HandleAction(action string) error
On() bool
Configuration() GPIOToggleConfig
BackendDescription() string
Close()
}
// RoverHardwareControllers is the result of the single startup-time backend
// decision. Its effective configurations are derived from whichever backend
// won, making the normal rover hello accurate on both Pi and laptop hosts.
type RoverHardwareControllers struct {
CameraServo CameraServoController
Headlight ToggleController
Laser ToggleController
ignoredESP32Roles []string
}
// StartupBroadcasts returns short operator-facing messages. Detailed pin and
// protocol information remains in the journal; tty1 only explains which
// physical backend won and whether an advertised ESP32 role was ignored.
func (controllers RoverHardwareControllers) StartupBroadcasts() []string {
var messages []string
if len(controllers.ignoredESP32Roles) > 0 {
messages = append(messages, fmt.Sprintf(
"Ignored ESP32 %s because native GPIO is enabled.",
strings.Join(controllers.ignoredESP32Roles, ", "),
))
}
messages = append(messages, fmt.Sprintf(
"Rover hardware ready: camera servo via %s, headlight via %s, laser via %s.",
controllerBackend(controllers.CameraServo),
controllerBackend(controllers.Headlight),
controllerBackend(controllers.Laser),
))
return messages
}
func controllerBackend(controller interface{ BackendDescription() string }) string {
if controller == nil {
return "disabled"
}
return controller.BackendDescription()
}
type nativeHardwareControllerFactories struct {
newCameraServo func(CameraServoConfig, *log.Logger) (CameraServoController, error)
newToggle func(string, GPIOToggleConfig, *log.Logger) (ToggleController, error)
}
// ResolveRoverHardwareControllers applies one rule on every real rover build:
// enabled native GPIO wins, otherwise one discovered ESP32 may fill the role.
// The rule is intentionally not selected by GOARCH or the debian_laptop tag.
func ResolveRoverHardwareControllers(cfg *Config, peripherals *PeripheralManager, logger *log.Logger) (RoverHardwareControllers, error) {
factories := nativeHardwareControllerFactories{
newCameraServo: func(config CameraServoConfig, logger *log.Logger) (CameraServoController, error) {
return NewCameraServo(config, logger)
},
newToggle: func(name string, config GPIOToggleConfig, logger *log.Logger) (ToggleController, error) {
return NewGPIOToggle(name, config, logger)
},
}
return resolveRoverHardwareControllers(cfg, peripherals, logger, factories)
}
func resolveRoverHardwareControllers(cfg *Config, peripherals *PeripheralManager, logger *log.Logger, factories nativeHardwareControllerFactories) (RoverHardwareControllers, error) {
var controllers RoverHardwareControllers
var err error
// Record ignored declarations separately from selecting controllers so the
// same native-first decision can be explained on the local rover console.
if cfg.CameraServo.Enabled && peripherals.HasRoverRole("cameraServo") {
controllers.ignoredESP32Roles = append(controllers.ignoredESP32Roles, "camera servo")
}
if cfg.Headlight.Enabled && peripherals.HasRoverRole("headlight") {
controllers.ignoredESP32Roles = append(controllers.ignoredESP32Roles, "headlight")
}
if cfg.Laser.Enabled && peripherals.HasRoverRole("laser") {
controllers.ignoredESP32Roles = append(controllers.ignoredESP32Roles, "laser")
}
controllers.CameraServo, err = resolveCameraServoController(cfg.CameraServo, peripherals, logger, factories.newCameraServo)
if err != nil {
return RoverHardwareControllers{}, fmt.Errorf("init camera servo: %w", err)
}
controllers.Headlight, err = resolveToggleController("headlight", cfg.Headlight, peripherals, logger, factories.newToggle)
if err != nil {
controllers.Close()
return RoverHardwareControllers{}, fmt.Errorf("init headlight: %w", err)
}
controllers.Laser, err = resolveToggleController("laser", cfg.Laser, peripherals, logger, factories.newToggle)
if err != nil {
controllers.Close()
return RoverHardwareControllers{}, fmt.Errorf("init laser: %w", err)
}
return controllers, nil
}
func resolveCameraServoController(nativeConfig CameraServoConfig, peripherals *PeripheralManager, logger *log.Logger, newNative func(CameraServoConfig, *log.Logger) (CameraServoController, error)) (CameraServoController, error) {
if nativeConfig.Enabled {
if peripherals.HasRoverRole("cameraServo") {
logger.Printf("ignoring ESP32 cameraServo because native camera servo is enabled")
}
return newNative(nativeConfig, logger)
}
return peripherals.NewFirmataCameraServo(logger)
}
func resolveToggleController(name string, nativeConfig GPIOToggleConfig, peripherals *PeripheralManager, logger *log.Logger, newNative func(string, GPIOToggleConfig, *log.Logger) (ToggleController, error)) (ToggleController, error) {
if nativeConfig.Enabled {
if peripherals.HasRoverRole(name) {
logger.Printf("ignoring ESP32 %s because native %s is enabled", name, name)
}
return newNative(name, nativeConfig, logger)
}
return peripherals.NewFirmataToggle(name, logger)
}
// Close releases selected controller resources in reverse dependency order.
// Firmata controllers do not close the shared serial connection; that remains
// owned by PeripheralManager and is released by its separate shutdown defer.
func (controllers *RoverHardwareControllers) Close() {
if controllers.Laser != nil {
controllers.Laser.Close()
}
if controllers.Headlight != nil {
controllers.Headlight.Close()
}
if controllers.CameraServo != nil {
controllers.CameraServo.Close()
}
}
+57
View File
@@ -0,0 +1,57 @@
package roverd
import (
"encoding/json"
"strings"
"testing"
)
func TestHelloPeripheralMetadataContainsOnlyRenderableFields(t *testing.T) {
minimum, maximum := 0, 180
message := helloMessage{
Type: "hello",
Name: "test-rover",
Peripherals: []RoverPeripheralMetadata{{
ID: "firmata-0",
Name: "Camera arm",
Controls: []RoverPeripheralControl{{
ID: "position", Type: "slider", Name: "Position", Minimum: &minimum, Maximum: &maximum,
}},
}},
}
encoded, err := json.Marshal(message)
if err != nil {
t.Fatalf("marshal hello: %v", err)
}
text := string(encoded)
if !strings.Contains(text, `"peripherals":[{"id":"firmata-0","name":"Camera arm","controls":[{"id":"position","type":"slider","name":"Position","min":0,"max":180}]`) {
t.Fatalf("hello is missing ordered peripheral metadata: %s", text)
}
var envelope map[string]json.RawMessage
if err := json.Unmarshal(encoded, &envelope); err != nil {
t.Fatalf("unmarshal hello envelope: %v", err)
}
peripheralJSON := string(envelope["peripherals"])
if strings.Contains(peripheralJSON, `"pin"`) || strings.Contains(peripheralJSON, `"output"`) {
t.Fatalf("hello exposed private Firmata routing: %s", peripheralJSON)
}
}
func TestInboundPeripheralCommandPreservesRawJSONValue(t *testing.T) {
var message inboundMessage
err := json.Unmarshal([]byte(`{
"type":"peripheral",
"id":"command-1",
"peripheral":{"id":"firmata-0","control":"displayText","value":"hello rover"}
}`), &message)
if err != nil {
t.Fatalf("unmarshal command: %v", err)
}
if message.Peripheral == nil || message.Peripheral.ID != "firmata-0" || message.Peripheral.Control != "displayText" {
t.Fatalf("unexpected peripheral command: %#v", message.Peripheral)
}
if string(message.Peripheral.Value) != `"hello rover"` {
t.Fatalf("raw value = %s", message.Peripheral.Value)
}
}
+24
View File
@@ -0,0 +1,24 @@
//go:build dummy
package roverd
import (
"context"
"io"
"log"
"time"
)
// DiscoverPeripheralManager remains inert in a dummy build. The dummy daemon is
// specifically used without rover hardware and must not probe or reset serial
// devices that happen to be attached to a developer's machine.
func DiscoverPeripheralManager(ctx context.Context, excludedDevice string, logger *log.Logger) (*PeripheralManager, error) {
dependencies := peripheralDiscoveryDependencies{
listCandidates: func(string) ([]string, error) { return nil, nil },
open: func(string) (io.ReadWriteCloser, error) { return nil, nil },
sleep: func(time.Duration) {},
startupWait: 0,
handshakeWait: 0,
}
return discoverPeripheralManager(ctx, excludedDevice, logger, dependencies)
}
+38
View File
@@ -0,0 +1,38 @@
//go:build !dummy
package roverd
import (
"context"
"io"
"log"
"time"
"github.com/tarm/serial"
)
const (
peripheralBaud = 115200
peripheralReadTimeout = 100 * time.Millisecond
)
// DiscoverPeripheralManager performs the one and only peripheral scan for this
// roverd process. The Roomba Open Interface serial device is explicitly
// excluded because it belongs to SerialAdapter and must never be probed as an
// ESP32 peripheral.
func DiscoverPeripheralManager(ctx context.Context, excludedDevice string, logger *log.Logger) (*PeripheralManager, error) {
dependencies := peripheralDiscoveryDependencies{
listCandidates: listPeripheralCandidates,
open: func(devicePath string) (io.ReadWriteCloser, error) {
return serial.OpenPort(&serial.Config{
Name: devicePath,
Baud: peripheralBaud,
ReadTimeout: peripheralReadTimeout,
})
},
sleep: time.Sleep,
startupWait: peripheralStartupWait,
handshakeWait: peripheralHandshakeTimeout,
}
return discoverPeripheralManager(ctx, excludedDevice, logger, dependencies)
}
+584
View File
@@ -0,0 +1,584 @@
package roverd
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"log"
"path/filepath"
"sort"
"strings"
"sync"
"time"
"unicode/utf8"
)
const (
peripheralStartupWait = 2 * time.Second
peripheralHandshakeTimeout = 5 * time.Second
peripheralFirmwareName = "RoverPeripheralFirmata"
)
// RoverPeripheralMetadata is the part of a peripheral description that leaves
// roverd. Pin numbers and output mappings intentionally remain private to the
// rover process; the server and browser identify only the declared control.
type RoverPeripheralMetadata struct {
ID string `json:"id"`
Name string `json:"name"`
Controls []RoverPeripheralControl `json:"controls"`
}
// RoverPeripheralControl contains only fields needed to render and operate one
// of the four generic UI controls. Pointer fields preserve legitimate zero
// bounds while still omitting properties that do not apply to a control type.
type RoverPeripheralControl struct {
ID string `json:"id"`
Type string `json:"type"`
Name string `json:"name"`
Mode string `json:"mode,omitempty"`
Minimum *int `json:"min,omitempty"`
Maximum *int `json:"max,omitempty"`
MaximumLength *int `json:"maxLength,omitempty"`
}
type managedPeripheral struct {
metadata RoverPeripheralMetadata
description PeripheralDescription
controls map[string]PeripheralControl
client *FirmataClient
connection io.ReadWriteCloser
devicePath string
capabilities [][]FirmataPinCapability
}
// PeripheralManager owns the immutable boot-time inventory and every serial
// connection behind it. The inventory never changes after discovery, even if a
// USB device later disappears; a process restart is the only rescan mechanism.
type PeripheralManager struct {
mu sync.RWMutex
peripherals []*managedPeripheral
byID map[string]*managedPeripheral
cancel context.CancelFunc
closeOnce sync.Once
logger *log.Logger
failures chan PeripheralFailure
}
// PeripheralFailure is emitted once when a successfully discovered device's
// serial reader terminates unexpectedly. Device identity is retained even
// though reconnection still requires restarting roverd.
type PeripheralFailure struct {
ID string
Name string
Err error
}
type peripheralDiscoveryDependencies struct {
listCandidates func(excludedDevice string) ([]string, error)
open func(devicePath string) (io.ReadWriteCloser, error)
sleep func(time.Duration)
startupWait time.Duration
handshakeWait time.Duration
}
func discoverPeripheralManager(ctx context.Context, excludedDevice string, logger *log.Logger, dependencies peripheralDiscoveryDependencies) (*PeripheralManager, error) {
managerContext, cancel := context.WithCancel(ctx)
manager := &PeripheralManager{
byID: make(map[string]*managedPeripheral),
cancel: cancel,
logger: logger,
failures: make(chan PeripheralFailure, 16),
}
candidates, err := dependencies.listCandidates(excludedDevice)
if err != nil {
manager.Close()
return nil, fmt.Errorf("list peripheral serial devices: %w", err)
}
for _, devicePath := range candidates {
connection, err := dependencies.open(devicePath)
if err != nil {
logger.Printf("skipping peripheral candidate %s: open failed: %v", devicePath, err)
continue
}
// UART bridge and native-USB development boards may reset when opened.
// Waiting and then draining boot fragments gives the handshake a fresh
// parser boundary instead of occasionally starting inside an old SysEx.
dependencies.sleep(dependencies.startupWait)
if err := drainPeripheralSerial(connection); err != nil {
connection.Close()
logger.Printf("skipping peripheral candidate %s: drain failed: %v", devicePath, err)
continue
}
client := NewFirmataClient(connection)
client.Start(managerContext)
firmware, err := queryPeripheralFirmware(managerContext, client, dependencies.handshakeWait)
if err != nil {
connection.Close()
logger.Printf("skipping peripheral candidate %s: Firmata query failed: %v", devicePath, err)
continue
}
if firmware.Name != peripheralFirmwareName {
connection.Close()
logger.Printf("skipping Firmata device %s: firmware %q does not expose rover peripherals", devicePath, firmware.Name)
continue
}
capabilities, err := queryPeripheralCapabilities(managerContext, client, dependencies.handshakeWait)
if err != nil {
connection.Close()
manager.Close()
return nil, fmt.Errorf("query capabilities from rover peripheral %s: %w", devicePath, err)
}
description, err := queryPeripheralDescription(managerContext, client, dependencies.handshakeWait)
if err != nil {
connection.Close()
manager.Close()
return nil, fmt.Errorf("describe rover peripheral %s: %w", devicePath, err)
}
peripheral := newManagedPeripheral(len(manager.peripherals), devicePath, connection, client, description, capabilities)
if err := peripheral.initializeStandardOutputs(); err != nil {
connection.Close()
manager.Close()
return nil, fmt.Errorf("initialize rover peripheral %s: %w", devicePath, err)
}
manager.peripherals = append(manager.peripherals, peripheral)
manager.byID[peripheral.metadata.ID] = peripheral
client.SetTerminalErrorHandler(func(terminalErr error) {
failure := PeripheralFailure{ID: peripheral.metadata.ID, Name: peripheral.metadata.Name, Err: terminalErr}
select {
case manager.failures <- failure:
default:
// The channel is intentionally bounded because broadcasts are
// diagnostic. Never block a Firmata reader during a fleet-wide
// shutdown or an unlikely burst of simultaneous USB failures.
logger.Printf("peripheral failure notification queue full for %s: %v", peripheral.metadata.ID, terminalErr)
}
})
logger.Printf("discovered rover peripheral %s on %s with %d generic controls", description.Name, devicePath, len(description.Controls))
}
return manager, nil
}
// StartupBroadcasts describes the fixed inventory without exposing device
// paths or wiring details on the rover's local console.
func (manager *PeripheralManager) StartupBroadcasts() []string {
inventory := manager.Inventory()
if len(inventory) == 0 {
return []string{"No ESP32 rover peripherals detected during startup."}
}
messages := make([]string, 0, len(inventory))
for _, peripheral := range inventory {
messages = append(messages, fmt.Sprintf(
"Rover peripheral %q connected as %s with %d additional controls.",
peripheral.Name,
peripheral.ID,
len(peripheral.Controls),
))
}
return messages
}
// Failures exposes unexpected runtime disconnects to the daemon entry point,
// which owns the ConsoleNotifier and therefore owns user-facing wording.
func (manager *PeripheralManager) Failures() <-chan PeripheralFailure {
if manager == nil {
return nil
}
return manager.failures
}
func listPeripheralCandidates(excludedDevice string) ([]string, error) {
patterns := []string{
"/dev/serial/by-id/*",
"/dev/ttyUSB*",
"/dev/ttyACM*",
}
var matchesInPreferenceOrder []string
for _, pattern := range patterns {
matches, err := filepath.Glob(pattern)
if err != nil {
return nil, err
}
sort.Strings(matches)
matchesInPreferenceOrder = append(matchesInPreferenceOrder, matches...)
}
return uniquePeripheralCandidates(matchesInPreferenceOrder, excludedDevice), nil
}
func uniquePeripheralCandidates(matches []string, excludedDevice string) []string {
excludedCanonical := canonicalDevicePath(excludedDevice)
seen := make(map[string]struct{})
var candidates []string
for _, match := range matches {
canonical := canonicalDevicePath(match)
if canonical == excludedCanonical {
continue
}
if _, exists := seen[canonical]; exists {
continue
}
seen[canonical] = struct{}{}
// /dev/serial/by-id matches are passed first, so retaining the first
// spelling favors stable names while still removing each tty alias.
candidates = append(candidates, match)
}
return candidates
}
func canonicalDevicePath(devicePath string) string {
if devicePath == "" {
return ""
}
resolved, err := filepath.EvalSymlinks(devicePath)
if err == nil {
return resolved
}
abs, err := filepath.Abs(devicePath)
if err == nil {
return filepath.Clean(abs)
}
return filepath.Clean(devicePath)
}
func drainPeripheralSerial(connection io.Reader) error {
buffer := make([]byte, 256)
for {
_, err := connection.Read(buffer)
if errors.Is(err, io.EOF) {
// tarm/serial uses EOF to mean its short read timeout elapsed. That
// quiet interval is precisely the boundary needed before handshaking.
return nil
}
if err != nil {
return err
}
}
}
func queryPeripheralFirmware(ctx context.Context, client *FirmataClient, timeout time.Duration) (FirmataFirmware, error) {
queryContext, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
return client.QueryFirmware(queryContext)
}
func queryPeripheralCapabilities(ctx context.Context, client *FirmataClient, timeout time.Duration) ([][]FirmataPinCapability, error) {
queryContext, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
return client.QueryCapabilities(queryContext)
}
func queryPeripheralDescription(ctx context.Context, client *FirmataClient, timeout time.Duration) (PeripheralDescription, error) {
queryContext, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
return client.Describe(queryContext)
}
func newManagedPeripheral(index int, devicePath string, connection io.ReadWriteCloser, client *FirmataClient, description PeripheralDescription, capabilities [][]FirmataPinCapability) *managedPeripheral {
controls := make(map[string]PeripheralControl, len(description.Controls))
metadataControls := make([]RoverPeripheralControl, 0, len(description.Controls))
for _, control := range description.Controls {
controls[control.ID] = control
metadataControls = append(metadataControls, RoverPeripheralControl{
ID: control.ID,
Type: control.Type,
Name: control.Name,
Mode: control.Mode,
Minimum: cloneIntPointer(control.Minimum),
Maximum: cloneIntPointer(control.Maximum),
MaximumLength: cloneIntPointer(control.MaximumLength),
})
}
return &managedPeripheral{
metadata: RoverPeripheralMetadata{
ID: fmt.Sprintf("firmata-%d", index),
Name: description.Name,
Controls: metadataControls,
},
description: description,
controls: controls,
client: client,
connection: connection,
devicePath: devicePath,
capabilities: capabilities,
}
}
func cloneIntPointer(value *int) *int {
if value == nil {
return nil
}
cloned := *value
return &cloned
}
func (peripheral *managedPeripheral) initializeStandardOutputs() error {
if camera := peripheral.description.RoverControls.CameraServo; camera != nil {
if err := peripheral.requirePinMode("cameraServo", camera.Pin, FirmataPinModeServo); err != nil {
return err
}
}
if headlight := peripheral.description.RoverControls.Headlight; headlight != nil {
if err := peripheral.requirePinMode("headlight", headlight.Pin, FirmataPinModeOutput); err != nil {
return err
}
}
if laser := peripheral.description.RoverControls.Laser; laser != nil {
if err := peripheral.requirePinMode("laser", laser.Pin, FirmataPinModeOutput); err != nil {
return err
}
}
for _, control := range peripheral.description.Controls {
if control.Output.Type == "custom" {
continue
}
pin := byte(*control.Output.Pin)
requiredMode := FirmataPinModeOutput
if control.Output.Type == "pwm" {
requiredMode = FirmataPinModePWM
} else if control.Output.Type == "servo" {
requiredMode = FirmataPinModeServo
}
if err := peripheral.requirePinMode("control "+control.ID, int(pin), requiredMode); err != nil {
return err
}
switch control.Output.Type {
case "digital":
if err := peripheral.client.SetPinMode(pin, FirmataPinModeOutput); err != nil {
return fmt.Errorf("configure control %q as digital: %w", control.ID, err)
}
// A generic button begins logically off. Active-low hardware needs a
// high electrical level to represent that same initial state.
if err := peripheral.client.SetDigitalPin(pin, control.Output.ActiveLow); err != nil {
return fmt.Errorf("initialize digital control %q: %w", control.ID, err)
}
case "pwm":
if err := peripheral.client.SetPinMode(pin, FirmataPinModePWM); err != nil {
return fmt.Errorf("configure control %q as PWM: %w", control.ID, err)
}
case "servo":
if err := peripheral.client.SetPinMode(pin, FirmataPinModeServo); err != nil {
return fmt.Errorf("configure control %q as servo: %w", control.ID, err)
}
}
}
return nil
}
func (peripheral *managedPeripheral) requirePinMode(owner string, pin int, requiredMode byte) error {
if pin < 0 || pin >= len(peripheral.capabilities) {
return fmt.Errorf("%s advertises pin %d, but Firmata reported only %d pins", owner, pin, len(peripheral.capabilities))
}
for _, capability := range peripheral.capabilities[pin] {
if capability.Mode == requiredMode {
return nil
}
}
return fmt.Errorf("%s advertises pin %d without required Firmata mode 0x%02x", owner, pin, requiredMode)
}
// HasRoverRole reports whether discovery found an ESP32 implementation of one
// established rover control. It is used only for startup selection and logging;
// commands continue to target the selected controller interface directly.
func (manager *PeripheralManager) HasRoverRole(role string) bool {
return len(manager.roverRoleProviders(role)) > 0
}
func (manager *PeripheralManager) roverRoleProviders(role string) []*managedPeripheral {
if manager == nil {
return nil
}
manager.mu.RLock()
defer manager.mu.RUnlock()
var providers []*managedPeripheral
for _, peripheral := range manager.peripherals {
switch role {
case "cameraServo":
if peripheral.description.RoverControls.CameraServo != nil {
providers = append(providers, peripheral)
}
case "headlight":
if peripheral.description.RoverControls.Headlight != nil {
providers = append(providers, peripheral)
}
case "laser":
if peripheral.description.RoverControls.Laser != nil {
providers = append(providers, peripheral)
}
}
}
return providers
}
// NewFirmataCameraServo constructs the shared camera controller only when a
// discovered peripheral declared that standardized role. Absence is a normal
// disabled-feature result rather than an error.
func (manager *PeripheralManager) NewFirmataCameraServo(logger *log.Logger) (CameraServoController, error) {
providers := manager.roverRoleProviders("cameraServo")
if len(providers) == 0 {
return nil, nil
}
if len(providers) > 1 {
return nil, duplicateRoverRoleError("cameraServo", providers)
}
peripheral := providers[0]
return newFirmataCameraServo(peripheral, *peripheral.description.RoverControls.CameraServo, logger)
}
// NewFirmataToggle resolves either standardized digital role without exposing
// the peripheral connection or ESP32 pin to WSClient.
func (manager *PeripheralManager) NewFirmataToggle(role string, logger *log.Logger) (ToggleController, error) {
providers := manager.roverRoleProviders(role)
if len(providers) == 0 {
return nil, nil
}
if len(providers) > 1 {
return nil, duplicateRoverRoleError(role, providers)
}
peripheral := providers[0]
var declaration *PeripheralDigitalRole
switch role {
case "headlight":
declaration = peripheral.description.RoverControls.Headlight
case "laser":
declaration = peripheral.description.RoverControls.Laser
default:
return nil, fmt.Errorf("unknown Firmata toggle role %q", role)
}
return newFirmataToggle(role, peripheral, *declaration, logger)
}
func duplicateRoverRoleError(role string, providers []*managedPeripheral) error {
providerIDs := make([]string, 0, len(providers))
for _, provider := range providers {
providerIDs = append(providerIDs, provider.metadata.ID)
}
return fmt.Errorf("rover peripheral role %s has multiple providers: %s", role, strings.Join(providerIDs, ", "))
}
// Inventory returns a defensive copy in startup order. Server reconnects reuse
// this same list and therefore never cause a USB rescan or ID reassignment.
func (manager *PeripheralManager) Inventory() []RoverPeripheralMetadata {
if manager == nil {
return nil
}
manager.mu.RLock()
defer manager.mu.RUnlock()
inventory := make([]RoverPeripheralMetadata, 0, len(manager.peripherals))
for _, peripheral := range manager.peripherals {
metadata := peripheral.metadata
metadata.Controls = make([]RoverPeripheralControl, 0, len(peripheral.metadata.Controls))
for _, control := range peripheral.metadata.Controls {
control.Minimum = cloneIntPointer(control.Minimum)
control.Maximum = cloneIntPointer(control.Maximum)
control.MaximumLength = cloneIntPointer(control.MaximumLength)
metadata.Controls = append(metadata.Controls, control)
}
inventory = append(inventory, metadata)
}
return inventory
}
// SetControl validates the browser-shaped value against the ESP32 declaration,
// then uses the private output mapping selected during startup. Neither the
// server nor browser can choose a pin or switch a custom control into raw GPIO.
func (manager *PeripheralManager) SetControl(peripheralID, controlID string, rawValue json.RawMessage) error {
if manager == nil {
return errors.New("rover peripherals disabled")
}
manager.mu.RLock()
peripheral := manager.byID[peripheralID]
manager.mu.RUnlock()
if peripheral == nil {
return fmt.Errorf("unknown peripheral %q", peripheralID)
}
control, exists := peripheral.controls[controlID]
if !exists {
return fmt.Errorf("unknown control %q on peripheral %q", controlID, peripheralID)
}
value, err := decodePeripheralControlValue(control, rawValue)
if err != nil {
return fmt.Errorf("control %q: %w", controlID, err)
}
switch control.Output.Type {
case "digital":
enabled := value.(bool)
if control.Output.ActiveLow {
enabled = !enabled
}
return peripheral.client.SetDigitalPin(byte(*control.Output.Pin), enabled)
case "pwm", "servo":
return peripheral.client.ExtendedAnalog(byte(*control.Output.Pin), value.(int))
case "custom":
return peripheral.client.SendPeripheralControl(control.ID, value)
default:
return fmt.Errorf("control has unsupported output %q", control.Output.Type)
}
}
func decodePeripheralControlValue(control PeripheralControl, rawValue json.RawMessage) (any, error) {
if len(rawValue) == 0 {
return nil, errors.New("value is required")
}
switch control.Type {
case "slider", "number":
var value int
if err := json.Unmarshal(rawValue, &value); err != nil {
return nil, errors.New("value must be a whole number")
}
if value < *control.Minimum || value > *control.Maximum {
return nil, fmt.Errorf("value must be between %d and %d", *control.Minimum, *control.Maximum)
}
return value, nil
case "button":
var value bool
if err := json.Unmarshal(rawValue, &value); err != nil {
return nil, errors.New("value must be true or false")
}
return value, nil
case "text":
var value string
if err := json.Unmarshal(rawValue, &value); err != nil {
return nil, errors.New("value must be text")
}
if utf8.RuneCountInString(value) > *control.MaximumLength {
return nil, fmt.Errorf("value must contain at most %d characters", *control.MaximumLength)
}
return value, nil
default:
return nil, fmt.Errorf("unsupported control type %q", control.Type)
}
}
// Close releases every discovered USB connection exactly once. It does not
// alter inventory or attempt to reconnect devices because shutdown/restart is
// the lifecycle boundary chosen for this feature.
func (manager *PeripheralManager) Close() {
if manager == nil {
return
}
manager.closeOnce.Do(func() {
manager.cancel()
manager.mu.Lock()
defer manager.mu.Unlock()
for _, peripheral := range manager.peripherals {
if err := peripheral.connection.Close(); err != nil {
manager.logger.Printf("close rover peripheral %s on %s: %v", peripheral.metadata.ID, peripheral.devicePath, err)
}
}
})
}
+425
View File
@@ -0,0 +1,425 @@
package roverd
import (
"bytes"
"context"
"encoding/json"
"errors"
"io"
"log"
"os"
"path/filepath"
"strings"
"testing"
"time"
)
func TestPeripheralManagerDiscoversInventoryAndDispatchesControls(t *testing.T) {
description := testPeripheralDescription("Bench accessory", false)
connection := scriptedPeripheralConnection(t, description)
dependencies := testPeripheralDiscoveryDependencies(
[]string{"/dev/ttyUSB9"},
map[string]*scriptedConnection{"/dev/ttyUSB9": connection},
)
manager, err := discoverPeripheralManager(context.Background(), "/dev/ttyUSB0", discardLogger(), dependencies)
if err != nil {
t.Fatalf("discover: %v", err)
}
defer manager.Close()
inventory := manager.Inventory()
if len(inventory) != 1 {
t.Fatalf("inventory length = %d, want 1", len(inventory))
}
if inventory[0].ID != "firmata-0" || inventory[0].Name != "Bench accessory" {
t.Fatalf("unexpected peripheral metadata: %#v", inventory[0])
}
wantBroadcast := `Rover peripheral "Bench accessory" connected as firmata-0 with 3 additional controls.`
if broadcasts := manager.StartupBroadcasts(); len(broadcasts) != 1 || broadcasts[0] != wantBroadcast {
t.Fatalf("startup broadcasts = %#v, want %q", broadcasts, wantBroadcast)
}
wantOrder := []string{"servoPosition", "lightBrightness", "specialAction"}
for index, controlID := range wantOrder {
if inventory[0].Controls[index].ID != controlID {
t.Fatalf("control %d = %q, want %q", index, inventory[0].Controls[index].ID, controlID)
}
}
*inventory[0].Controls[0].Minimum = 99
if fresh := manager.Inventory(); *fresh[0].Controls[0].Minimum != 0 {
t.Fatal("caller mutation changed the manager's fixed inventory")
}
// Standard modes are configured once during discovery. Runtime slider
// commands should consequently contain only EXTENDED_ANALOG, not repeated
// mode changes that would detach and reattach a servo while it is moving.
baseline := len(connection.Bytes())
if err := manager.SetControl("firmata-0", "servoPosition", json.RawMessage(`90`)); err != nil {
t.Fatalf("set servo: %v", err)
}
servoWrite := connection.Bytes()[baseline:]
wantServo := []byte{firmataStartSysex, firmataExtendedAnalog, 13, 90, firmataEndSysex}
if !bytes.Equal(servoWrite, wantServo) {
t.Fatalf("servo bytes = %v, want %v", servoWrite, wantServo)
}
baseline = len(connection.Bytes())
if err := manager.SetControl("firmata-0", "lightBrightness", json.RawMessage(`128`)); err != nil {
t.Fatalf("set PWM: %v", err)
}
pwmWrite := connection.Bytes()[baseline:]
wantPWM := []byte{firmataStartSysex, firmataExtendedAnalog, 17, 0, 1, firmataEndSysex}
if !bytes.Equal(pwmWrite, wantPWM) {
t.Fatalf("PWM bytes = %v, want %v", pwmWrite, wantPWM)
}
baseline = len(connection.Bytes())
if err := manager.SetControl("firmata-0", "specialAction", json.RawMessage(`true`)); err != nil {
t.Fatalf("set custom button: %v", err)
}
customWrite := connection.Bytes()[baseline:]
if len(customWrite) < 5 || customWrite[1] != firmataPeripheralFeature || customWrite[2] != firmataPeripheralControl {
t.Fatalf("custom control did not use rover-peripheral SysEx: %v", customWrite)
}
}
func TestPeripheralManagerBroadcastsNoDevices(t *testing.T) {
manager := &PeripheralManager{byID: make(map[string]*managedPeripheral)}
want := "No ESP32 rover peripherals detected during startup."
if messages := manager.StartupBroadcasts(); len(messages) != 1 || messages[0] != want {
t.Fatalf("startup broadcasts = %#v, want %q", messages, want)
}
}
func TestPeripheralManagerReportsUnexpectedDisconnectOnce(t *testing.T) {
connection := scriptedPeripheralConnection(t, testPeripheralDescription("Bench accessory", false))
manager, err := discoverPeripheralManager(
context.Background(),
"/dev/roomba",
discardLogger(),
testPeripheralDiscoveryDependencies([]string{"/dev/accessory"}, map[string]*scriptedConnection{"/dev/accessory": connection}),
)
if err != nil {
t.Fatalf("discover: %v", err)
}
defer manager.Close()
// Closing the fake read stream models an unplugged USB serial adapter. The
// manager should publish one identified failure and never attempt reconnect.
_ = connection.Close()
select {
case failure := <-manager.Failures():
if failure.ID != "firmata-0" || failure.Name != "Bench accessory" || !errors.Is(failure.Err, io.ErrClosedPipe) {
t.Fatalf("unexpected failure: %#v", failure)
}
case <-time.After(time.Second):
t.Fatal("timed out waiting for peripheral disconnect")
}
select {
case duplicate := <-manager.Failures():
t.Fatalf("unexpected duplicate disconnect: %#v", duplicate)
case <-time.After(20 * time.Millisecond):
}
}
func TestPeripheralManagerRejectsInvalidValuesBeforeWriting(t *testing.T) {
connection := scriptedPeripheralConnection(t, testPeripheralDescription("Bench accessory", false))
manager, err := discoverPeripheralManager(
context.Background(),
"/dev/roomba",
discardLogger(),
testPeripheralDiscoveryDependencies([]string{"/dev/accessory"}, map[string]*scriptedConnection{"/dev/accessory": connection}),
)
if err != nil {
t.Fatalf("discover: %v", err)
}
defer manager.Close()
baseline := len(connection.Bytes())
invalid := []struct {
control string
value string
}{
{control: "servoPosition", value: `181`},
{control: "lightBrightness", value: `12.5`},
{control: "specialAction", value: `"yes"`},
}
for _, testCase := range invalid {
if err := manager.SetControl("firmata-0", testCase.control, json.RawMessage(testCase.value)); err == nil {
t.Fatalf("expected %s=%s to fail", testCase.control, testCase.value)
}
}
if got := len(connection.Bytes()); got != baseline {
t.Fatalf("invalid values wrote %d bytes", got-baseline)
}
}
func TestPeripheralManagerSkipsOtherFirmataFirmware(t *testing.T) {
other := newScriptedConnection(testFirmwareFrame("StandardFirmata"))
other.timeoutsBeforeRead = 1
rover := scriptedPeripheralConnection(t, testPeripheralDescription("Rover accessory", false))
dependencies := testPeripheralDiscoveryDependencies(
[]string{"/dev/ttyACM0", "/dev/ttyUSB0"},
map[string]*scriptedConnection{
"/dev/ttyACM0": other,
"/dev/ttyUSB0": rover,
},
)
manager, err := discoverPeripheralManager(context.Background(), "/dev/roomba", discardLogger(), dependencies)
if err != nil {
t.Fatalf("discover: %v", err)
}
defer manager.Close()
if inventory := manager.Inventory(); len(inventory) != 1 || inventory[0].ID != "firmata-0" || inventory[0].Name != "Rover accessory" {
t.Fatalf("unexpected inventory: %#v", inventory)
}
if !other.Closed() {
t.Fatal("non-rover Firmata port was not closed")
}
}
func TestPeripheralManagerFailsMalformedRoverDescription(t *testing.T) {
connection := newScriptedConnection(
testFirmwareFrame(peripheralFirmwareName),
testCapabilityFrame(),
testDescriptionFrame([]byte(`not-json`)),
)
connection.timeoutsBeforeRead = 1
dependencies := testPeripheralDiscoveryDependencies(
[]string{"/dev/ttyUSB0"},
map[string]*scriptedConnection{"/dev/ttyUSB0": connection},
)
manager, err := discoverPeripheralManager(context.Background(), "/dev/roomba", discardLogger(), dependencies)
if err == nil || !strings.Contains(err.Error(), "describe rover peripheral") {
t.Fatalf("expected malformed description error, got manager=%v err=%v", manager, err)
}
if !connection.Closed() {
t.Fatal("malformed rover peripheral connection was not closed")
}
}
func TestPeripheralManagerRejectsAdvertisedUnsupportedPinMode(t *testing.T) {
description := testPeripheralDescription("Bad capability", false)
rawDescription, err := json.Marshal(description)
if err != nil {
t.Fatalf("marshal description: %v", err)
}
connection := newScriptedConnection(
testFirmwareFrame(peripheralFirmwareName),
[]byte{
firmataStartSysex, firmataCapabilityReply,
FirmataPinModeOutput, 1, 0x7F,
firmataEndSysex,
},
testDescriptionFrame(rawDescription),
)
connection.timeoutsBeforeRead = 1
dependencies := testPeripheralDiscoveryDependencies(
[]string{"/dev/ttyUSB0"},
map[string]*scriptedConnection{"/dev/ttyUSB0": connection},
)
manager, err := discoverPeripheralManager(context.Background(), "/dev/roomba", discardLogger(), dependencies)
if err == nil || !strings.Contains(err.Error(), "Firmata reported only 1 pins") {
t.Fatalf("expected unsupported capability error, got manager=%v err=%v", manager, err)
}
if !connection.Closed() {
t.Fatal("unsupported peripheral connection was not closed")
}
}
func TestPeripheralManagerRejectsDuplicateBuiltInProvidersWhenRoleIsSelected(t *testing.T) {
first := scriptedPeripheralConnection(t, testPeripheralDescription("First", true))
second := scriptedPeripheralConnection(t, testPeripheralDescription("Second", true))
dependencies := testPeripheralDiscoveryDependencies(
[]string{"/dev/ttyUSB0", "/dev/ttyUSB1"},
map[string]*scriptedConnection{
"/dev/ttyUSB0": first,
"/dev/ttyUSB1": second,
},
)
manager, err := discoverPeripheralManager(context.Background(), "/dev/roomba", discardLogger(), dependencies)
if err != nil {
t.Fatalf("discovery should retain providers until native precedence is known: %v", err)
}
defer manager.Close()
if _, err := manager.NewFirmataToggle("headlight", discardLogger()); err == nil || !strings.Contains(err.Error(), "role headlight has multiple providers") {
t.Fatalf("expected duplicate provider selection error, got %v", err)
}
if first.Closed() || second.Closed() {
t.Fatal("selection validation unexpectedly closed manager-owned ports")
}
}
func TestPeripheralManagerReturnsHardwareWriteFailure(t *testing.T) {
connection := scriptedPeripheralConnection(t, testPeripheralDescription("Bench accessory", false))
manager, err := discoverPeripheralManager(
context.Background(),
"/dev/roomba",
discardLogger(),
testPeripheralDiscoveryDependencies([]string{"/dev/accessory"}, map[string]*scriptedConnection{"/dev/accessory": connection}),
)
if err != nil {
t.Fatalf("discover: %v", err)
}
defer manager.Close()
connection.SetWriteError(errors.New("USB device removed"))
err = manager.SetControl("firmata-0", "lightBrightness", json.RawMessage(`128`))
if err == nil || !strings.Contains(err.Error(), "USB device removed") {
t.Fatalf("expected hardware error, got %v", err)
}
select {
case failure := <-manager.Failures():
if failure.ID != "firmata-0" || !strings.Contains(failure.Err.Error(), "USB device removed") {
t.Fatalf("unexpected write failure notification: %#v", failure)
}
case <-time.After(time.Second):
t.Fatal("timed out waiting for write failure notification")
}
}
func TestPeripheralManagerPassesRoombaDeviceToCandidateExclusion(t *testing.T) {
const roombaDevice = "/dev/serial/by-id/roomba-base"
listed := false
dependencies := peripheralDiscoveryDependencies{
listCandidates: func(excluded string) ([]string, error) {
listed = true
if excluded != roombaDevice {
t.Fatalf("excluded device = %q, want %q", excluded, roombaDevice)
}
return nil, nil
},
open: func(string) (io.ReadWriteCloser, error) { return nil, errors.New("unexpected open") },
sleep: func(time.Duration) {},
startupWait: 0,
handshakeWait: time.Second,
}
manager, err := discoverPeripheralManager(context.Background(), roombaDevice, discardLogger(), dependencies)
if err != nil {
t.Fatalf("discover: %v", err)
}
manager.Close()
if !listed {
t.Fatal("candidate listing was not called")
}
}
func TestUniquePeripheralCandidatesPrefersStableAliasAndExcludesRoomba(t *testing.T) {
temporaryDirectory := t.TempDir()
peripheralTarget := filepath.Join(temporaryDirectory, "ttyUSB0")
roombaTarget := filepath.Join(temporaryDirectory, "ttyUSB1")
if err := os.WriteFile(peripheralTarget, nil, 0o600); err != nil {
t.Fatalf("create peripheral target: %v", err)
}
if err := os.WriteFile(roombaTarget, nil, 0o600); err != nil {
t.Fatalf("create Roomba target: %v", err)
}
stableAlias := filepath.Join(temporaryDirectory, "usb-rover-peripheral")
if err := os.Symlink(peripheralTarget, stableAlias); err != nil {
t.Fatalf("create stable alias: %v", err)
}
candidates := uniquePeripheralCandidates(
[]string{stableAlias, peripheralTarget, roombaTarget},
roombaTarget,
)
if len(candidates) != 1 || candidates[0] != stableAlias {
t.Fatalf("candidates = %v, want stable peripheral alias only", candidates)
}
}
func testPeripheralDiscoveryDependencies(paths []string, connections map[string]*scriptedConnection) peripheralDiscoveryDependencies {
return peripheralDiscoveryDependencies{
listCandidates: func(string) ([]string, error) {
return append([]string(nil), paths...), nil
},
open: func(devicePath string) (io.ReadWriteCloser, error) {
connection := connections[devicePath]
if connection == nil {
return nil, errors.New("test connection not found")
}
return connection, nil
},
sleep: func(time.Duration) {},
startupWait: 0,
handshakeWait: time.Second,
}
}
func scriptedPeripheralConnection(t *testing.T, description PeripheralDescription) *scriptedConnection {
t.Helper()
rawDescription, err := json.Marshal(description)
if err != nil {
t.Fatalf("marshal description: %v", err)
}
connection := newScriptedConnection(
testFirmwareFrame(peripheralFirmwareName),
testCapabilityFrame(),
testDescriptionFrame(rawDescription),
)
// The first read represents the quiet timeout used to drain boot output
// before the client's parser starts consuming explicit query responses.
connection.timeoutsBeforeRead = 1
return connection
}
func testPeripheralDescription(name string, provideHeadlight bool) PeripheralDescription {
minimumServo, maximumServo := 0, 180
minimumPWM, maximumPWM := 0, 255
servoPin, pwmPin := 13, 17
description := PeripheralDescription{
Name: name,
Controls: []PeripheralControl{
{
ID: "servoPosition", Type: "slider", Name: "Servo position",
Minimum: &minimumServo, Maximum: &maximumServo,
Output: PeripheralOutput{Type: "servo", Pin: &servoPin},
},
{
ID: "lightBrightness", Type: "slider", Name: "Light brightness",
Minimum: &minimumPWM, Maximum: &maximumPWM,
Output: PeripheralOutput{Type: "pwm", Pin: &pwmPin},
},
{
ID: "specialAction", Type: "button", Name: "Run special action", Mode: "momentary",
Output: PeripheralOutput{Type: "custom"},
},
},
}
if provideHeadlight {
description.RoverControls.Headlight = &PeripheralDigitalRole{Pin: 18}
}
return description
}
func testFirmwareFrame(name string) []byte {
frame := []byte{firmataStartSysex, firmataReportFirmware, 1, 0}
frame = append(frame, EncodeFirmata7Bit([]byte(name))...)
return append(frame, firmataEndSysex)
}
func testCapabilityFrame() []byte {
frame := []byte{firmataStartSysex, firmataCapabilityReply}
for pin := 0; pin < 40; pin++ {
// The test ESP32 reports the same three output modes as the reference
// firmware. Repeating real pin entries also exercises capability parsing
// independently of any particular example control pin.
frame = append(frame, FirmataPinModeOutput, 1, FirmataPinModePWM, 8, FirmataPinModeServo, 14, 0x7F)
}
return append(frame, firmataEndSysex)
}
func testDescriptionFrame(rawDescription []byte) []byte {
frame := []byte{firmataStartSysex, firmataPeripheralFeature, firmataPeripheralDescription}
frame = append(frame, EncodeFirmata7Bit(rawDescription)...)
return append(frame, firmataEndSysex)
}
func discardLogger() *log.Logger {
return log.New(io.Discard, "", 0)
}
+27 -8
View File
@@ -19,10 +19,11 @@ type WSClient struct {
sensorFrames <-chan []byte sensorFrames <-chan []byte
events chan RoverEvent events chan RoverEvent
media *MediaSupervisor media *MediaSupervisor
servo *CameraServo servo CameraServoController
horn *HornSynth horn *HornSynth
headlight *GPIOToggle headlight ToggleController
laser *GPIOToggle laser ToggleController
peripherals *PeripheralManager
log *log.Logger log *log.Logger
console *ConsoleNotifier console *ConsoleNotifier
recoverMu sync.Mutex recoverMu sync.Mutex
@@ -45,7 +46,7 @@ type WSClient struct {
audioMu sync.RWMutex audioMu sync.RWMutex
} }
func NewWSClient(cfg *Config, adapter *SerialAdapter, frames <-chan []byte, events chan RoverEvent, media *MediaSupervisor, servo *CameraServo, headlight *GPIOToggle, laser *GPIOToggle, logger *log.Logger, console *ConsoleNotifier) *WSClient { func NewWSClient(cfg *Config, adapter *SerialAdapter, frames <-chan []byte, events chan RoverEvent, media *MediaSupervisor, servo CameraServoController, headlight ToggleController, laser ToggleController, peripherals *PeripheralManager, logger *log.Logger, console *ConsoleNotifier) *WSClient {
var ttsQueue chan *ttsPayload var ttsQueue chan *ttsPayload
if cfg.Audio.TTSEnabled { if cfg.Audio.TTSEnabled {
ttsQueue = make(chan *ttsPayload, 2) ttsQueue = make(chan *ttsPayload, 2)
@@ -68,6 +69,7 @@ func NewWSClient(cfg *Config, adapter *SerialAdapter, frames <-chan []byte, even
horn: horn, horn: horn,
headlight: headlight, headlight: headlight,
laser: laser, laser: laser,
peripherals: peripherals,
log: logger, log: logger,
console: console, console: console,
ttsQueue: ttsQueue, ttsQueue: ttsQueue,
@@ -129,6 +131,20 @@ func (c *WSClient) Run(ctx context.Context) error {
} }
func (c *WSClient) sendHello(ctx context.Context, conn *websocket.Conn) error { func (c *WSClient) sendHello(ctx context.Context, conn *websocket.Conn) error {
// Built-in metadata comes from the selected controller, not necessarily
// YAML. An ESP32 can enable a role whose native GPIO entry is disabled.
cameraServoConfig := CameraServoConfig{}
if c.servo != nil {
cameraServoConfig = c.servo.Configuration()
}
headlightConfig := GPIOToggleConfig{}
if c.headlight != nil {
headlightConfig = c.headlight.Configuration()
}
laserConfig := GPIOToggleConfig{}
if c.laser != nil {
laserConfig = c.laser.Configuration()
}
msg := helloMessage{ msg := helloMessage{
Type: "hello", Type: "hello",
Name: c.cfg.Name, Name: c.cfg.Name,
@@ -137,11 +153,12 @@ func (c *WSClient) sendHello(ctx context.Context, conn *websocket.Conn) error {
Battery: c.cfg.Battery, Battery: c.cfg.Battery,
MaxWheelSpeed: c.cfg.MaxWheelMMs, MaxWheelSpeed: c.cfg.MaxWheelMMs,
Media: c.cfg.Media, Media: c.cfg.Media,
CameraServo: c.cfg.CameraServo, CameraServo: cameraServoConfig,
Audio: c.cfg.Audio, Audio: c.cfg.Audio,
Horn: c.cfg.Horn, Horn: c.cfg.Horn,
Headlight: c.cfg.Headlight, Headlight: headlightConfig,
Laser: c.cfg.Laser, Laser: laserConfig,
Peripherals: c.peripherals.Inventory(),
Private: c.cfg.Private, Private: c.cfg.Private,
} }
c.log.Printf("sending hello (camera servo enabled=%v pin=%d)", msg.CameraServo.Enabled, msg.CameraServo.Pin) c.log.Printf("sending hello (camera servo enabled=%v pin=%d)", msg.CameraServo.Enabled, msg.CameraServo.Pin)
@@ -238,6 +255,8 @@ func (c *WSClient) dispatch(ctx context.Context, msg *inboundMessage) error {
return c.handleToggleCommand("headlight", c.headlight, msg.Headlight) return c.handleToggleCommand("headlight", c.headlight, msg.Headlight)
case msg.Laser != nil: case msg.Laser != nil:
return c.handleToggleCommand("laser", c.laser, msg.Laser) return c.handleToggleCommand("laser", c.laser, msg.Laser)
case msg.Peripheral != nil:
return c.peripherals.SetControl(msg.Peripheral.ID, msg.Peripheral.Control, msg.Peripheral.Value)
case msg.Song != nil: case msg.Song != nil:
slot := 0 slot := 0
if msg.Song.Slot != nil { if msg.Song.Slot != nil {
@@ -253,7 +272,7 @@ func (c *WSClient) dispatch(ctx context.Context, msg *inboundMessage) error {
} }
} }
func (c *WSClient) handleToggleCommand(name string, toggle *GPIOToggle, payload *togglePayload) error { func (c *WSClient) handleToggleCommand(name string, toggle ToggleController, payload *togglePayload) error {
if toggle == nil { if toggle == nil {
return fmt.Errorf("%s disabled", name) return fmt.Errorf("%s disabled", name)
} }
+3
View File
@@ -12,6 +12,9 @@ require('./src/services/eventBus');
require('./src/services/modeManager'); require('./src/services/modeManager');
require('./src/services/lockdownGuard'); require('./src/services/lockdownGuard');
require('./src/services/roverManager'); require('./src/services/roverManager');
// Help monitoring subscribes to roverManager telemetry before assignment and
// session services begin consuming the resulting roster state.
require('./src/services/roverHelpService');
require('./src/services/commandService'); require('./src/services/commandService');
require('./src/services/roverConnectionService'); require('./src/services/roverConnectionService');
require('./src/services/assignmentService'); require('./src/services/assignmentService');
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-_6Wi5B-Z.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-BFNKIMjg.css"> <link rel="stylesheet" crossorigin href="/assets/index-DJhimuQc.css">
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>
+10 -1
View File
@@ -97,6 +97,12 @@ roverManager.managerEvents.on('private', ({ roverId, open }) => {
} }
}); });
roverManager.managerEvents.on('help', ({ needsHelp }) => {
// Entering HELP affects only future automatic placement. When HELP clears,
// retry people who were waiting because every healthy rover was unavailable.
if (!needsHelp) reassignWaiting();
});
roverManager.managerEvents.on('rover', ({ roverId, action }) => { roverManager.managerEvents.on('rover', ({ roverId, action }) => {
if (action === 'removed') { if (action === 'removed') {
/* /*
@@ -238,7 +244,10 @@ function pickRover(socket, options = {}) {
return null; return null;
} }
const allCandidates = Array.from(roverManager.rovers.values()).filter((rover) => { const allCandidates = Array.from(roverManager.rovers.values()).filter((rover) => {
if (!rover || rover.locked) return false; // HELP removes a rover only from automatic placement. Existing drivers are
// not displaced, and explicit requestControl calls retain their normal
// access policy so a person can deliberately take control to rescue it.
if (!rover || rover.locked || rover.needsHelp) return false;
const access = roverManager.canRequestControl(rover.id, socket, { allowUser: true }); const access = roverManager.canRequestControl(rover.id, socket, { allowUser: true });
if (!access.ok) return false; if (!access.ok) return false;
return true; return true;
@@ -6,7 +6,7 @@ const { buildBatteryStatusEmbed, buildBatteryCaption } = require('../batteryEmbe
function createBusEventHandler(deps) { function createBusEventHandler(deps) {
const { logger, discordConfig, roverManager, rovers, schedulePresenceRotation, formatDuration, sendToChannel } = deps; const { logger, discordConfig, roverManager, rovers, schedulePresenceRotation, formatDuration, sendToChannel } = deps;
const ADMIN_ALERT_EVENT_TYPES = new Set(['rover.online', 'rover.offline', 'rover.dockGuard', 'battery.warn', 'battery.urgent', 'battery.docked', 'battery.undocked', 'battery.charging.start', 'battery.charging.stop', 'battery.locked', 'battery.unlocked']); const ADMIN_ALERT_EVENT_TYPES = new Set(['rover.online', 'rover.offline', 'rover.dockGuard', 'rover.helpNeeded', 'rover.helpCleared', 'battery.warn', 'battery.urgent', 'battery.docked', 'battery.undocked', 'battery.charging.start', 'battery.charging.stop', 'battery.locked', 'battery.unlocked']);
let skippedFirstModeAnnouncement = false; let skippedFirstModeAnnouncement = false;
function buildEmbed({ title, description, color, includeSiteUrl = true }) { function buildEmbed({ title, description, color, includeSiteUrl = true }) {
@@ -85,6 +85,23 @@ function createBusEventHandler(deps) {
case 'rover.dockGuard': case 'rover.dockGuard':
announce({ channelId: channels.adminAlerts, color: 0xf0b651, title: 'Dock Guard Triggered', description: `${payload?.roverId} (${payload?.reasonText || 'undocked'}) for ${formatDuration(payload?.idleMs)}.` }); announce({ channelId: channels.adminAlerts, color: 0xf0b651, title: 'Dock Guard Triggered', description: `${payload?.roverId} (${payload?.reasonText || 'undocked'}) for ${formatDuration(payload?.idleMs)}.` });
break; break;
case 'rover.helpNeeded':
announce({
channelId: channels.adminAlerts,
pingRoleId: roles.adminPing || null,
color: 0xef4444,
title: 'Rover Needs Help',
description: `${payload?.roverName || payload?.roverId || 'Unknown rover'}: ${payload?.reason || 'a sustained rover fault was detected'}.`,
});
break;
case 'rover.helpCleared':
announce({
channelId: channels.adminAlerts,
color: 0x4caf50,
title: 'Rover Help Cleared',
description: `${payload?.roverName || payload?.roverId || 'Unknown rover'} no longer needs help.`,
});
break;
case 'battery.warn': case 'battery.warn':
announce({ channelId: channels.adminAlerts, pingRoleId: roles.adminPing || null, color: 0xf0b651, content: buildBatteryCaption(type, rovers.get(payload?.roverId || 'unknown')), embeds: [buildBatteryStatusEmbed({ color: 0xf0b651, records: Array.from(rovers.values()) })] }); announce({ channelId: channels.adminAlerts, pingRoleId: roles.adminPing || null, color: 0xf0b651, content: buildBatteryCaption(type, rovers.get(payload?.roverId || 'unknown')), embeds: [buildBatteryStatusEmbed({ color: 0xf0b651, records: Array.from(rovers.values()) })] });
break; break;
@@ -0,0 +1,140 @@
// Rover Help Horn Notifier
// Purpose: Repeats a short, disruptive locator chirp while a rover needs help.
// Scope: Uses the existing server-to-roverd horn start/stop protocol without changing roverd.
const HELP_HORN_FREQUENCY_HZ = 2000;
const HELP_HORN_DURATION_MS = 250;
const HELP_HORN_INTERVAL_MS = 5 * 1000;
const HELP_ROOMBA_NOTE = 83;
const HELP_ROOMBA_NOTE_DURATION = 16;
function createHelpHornNotifier({
getRover,
issueCommand,
logger,
setIntervalFn = setInterval,
clearIntervalFn = clearInterval,
setTimeoutFn = setTimeout,
clearTimeoutFn = clearTimeout,
} = {}) {
const intervals = new Map();
const stopTimers = new Map();
function stopPendingHorn(roverId) {
const id = String(roverId);
const stopTimer = stopTimers.get(id);
if (stopTimer != null) clearTimeoutFn(stopTimer);
stopTimers.delete(id);
// The Roomba song is self-terminating. With no pending external-horn stop,
// there is no persistent sound owned by this notifier that needs cleanup.
if (stopTimer == null) return;
// Always send a final stop during cleanup. This ensures HELP clearing in
// the middle of a 250 ms chirp silences it immediately instead of waiting
// for a timeout that was just cancelled.
const record = getRover?.(id);
if (!record?.ws) return;
try {
issueCommand(id, { type: 'horn', horn: { action: 'stop' } });
} catch (err) {
logger?.warn?.('Failed to stop rover help horn', { roverId: id, error: err.message });
}
}
function chirp(roverId) {
const id = String(roverId);
const record = getRover?.(id);
if (!record?.ws) return false;
let sounded = false;
let externalHornStarted = false;
if (record.meta?.horn?.enabled) {
try {
issueCommand(id, {
type: 'horn',
horn: {
action: 'start',
waveform: 'saw',
freqs: [HELP_HORN_FREQUENCY_HZ],
},
});
sounded = true;
externalHornStarted = true;
} catch (err) {
logger?.warn?.('Failed to start rover help horn', { roverId: id, error: err.message });
}
}
try {
// Roomba 600-series songs use MIDI notes and 1/64-second durations.
// Note 83 is approximately 987.8 Hz: one octave below the closest MIDI
// pitch to the external 2000 Hz horn. Duration 16 matches its 250 ms pulse.
issueCommand(id, {
type: 'song',
song: {
notes: [{ note: HELP_ROOMBA_NOTE, duration: HELP_ROOMBA_NOTE_DURATION }],
},
});
sounded = true;
} catch (err) {
logger?.warn?.('Failed to play rover help song', { roverId: id, error: err.message });
}
if (externalHornStarted) {
// There can be only one pending automatic stop for a rover. Replacing an
// unexpected stale timer keeps the external pulse duration bounded; the
// independently issued Roomba song always ends itself.
const previousStop = stopTimers.get(id);
if (previousStop != null) clearTimeoutFn(previousStop);
stopTimers.set(
id,
setTimeoutFn(() => {
stopTimers.delete(id);
const current = getRover?.(id);
if (!current?.ws) return;
try {
issueCommand(id, { type: 'horn', horn: { action: 'stop' } });
} catch (err) {
logger?.warn?.('Failed to finish rover help chirp', { roverId: id, error: err.message });
}
}, HELP_HORN_DURATION_MS),
);
}
return sounded;
}
function start(roverId) {
const id = String(roverId);
if (intervals.has(id)) return;
const record = getRover?.(id);
if (!record?.ws) return;
// Sound immediately so a newly detected rover can be located without
// waiting through the first five-second interval.
chirp(id);
intervals.set(id, setIntervalFn(() => chirp(id), HELP_HORN_INTERVAL_MS));
}
function stop(roverId) {
const id = String(roverId);
const interval = intervals.get(id);
if (interval != null) clearIntervalFn(interval);
intervals.delete(id);
stopPendingHorn(id);
}
return {
chirp,
start,
stop,
};
}
module.exports = {
HELP_HORN_DURATION_MS,
HELP_HORN_FREQUENCY_HZ,
HELP_HORN_INTERVAL_MS,
HELP_ROOMBA_NOTE,
HELP_ROOMBA_NOTE_DURATION,
createHelpHornNotifier,
};
@@ -0,0 +1,95 @@
// Rover Help Horn Notifier Tests
// Purpose: Verifies the exact chirp payload, cadence, duration, and cleanup without real timers.
const test = require('node:test');
const assert = require('node:assert/strict');
const {
HELP_HORN_DURATION_MS,
HELP_HORN_FREQUENCY_HZ,
HELP_HORN_INTERVAL_MS,
HELP_ROOMBA_NOTE,
HELP_ROOMBA_NOTE_DURATION,
createHelpHornNotifier,
} = require('./hornNotifier');
function createHarness({ enabled = true } = {}) {
const commands = [];
const intervals = new Map();
const timeouts = new Map();
const clearedIntervals = [];
const clearedTimeouts = [];
let nextTimerId = 1;
const notifier = createHelpHornNotifier({
getRover: () => ({ ws: {}, meta: { horn: { enabled } } }),
issueCommand: (roverId, payload) => commands.push({ roverId, payload }),
setIntervalFn: (callback, ms) => {
const id = nextTimerId++;
intervals.set(id, { callback, ms });
return id;
},
clearIntervalFn: (id) => clearedIntervals.push(id),
setTimeoutFn: (callback, ms) => {
const id = nextTimerId++;
timeouts.set(id, { callback, ms });
return id;
},
clearTimeoutFn: (id) => clearedTimeouts.push(id),
});
return { clearedIntervals, clearedTimeouts, commands, intervals, notifier, timeouts };
}
test('starts matching external and Roomba chirps and schedules the agreed cadence', () => {
const harness = createHarness();
harness.notifier.start('red');
assert.deepEqual(harness.commands, [
{
roverId: 'red',
payload: {
type: 'horn',
horn: { action: 'start', waveform: 'saw', freqs: [HELP_HORN_FREQUENCY_HZ] },
},
},
{
roverId: 'red',
payload: {
type: 'song',
song: {
notes: [{ note: HELP_ROOMBA_NOTE, duration: HELP_ROOMBA_NOTE_DURATION }],
},
},
},
]);
assert.equal(Array.from(harness.intervals.values())[0].ms, HELP_HORN_INTERVAL_MS);
assert.equal(Array.from(harness.timeouts.values())[0].ms, HELP_HORN_DURATION_MS);
Array.from(harness.timeouts.values())[0].callback();
assert.equal(harness.commands.at(-1).payload.horn.action, 'stop');
});
test('stop cancels cadence and pending pulse before issuing a final horn stop', () => {
const harness = createHarness();
harness.notifier.start('blue');
const intervalId = Array.from(harness.intervals.keys())[0];
const timeoutId = Array.from(harness.timeouts.keys())[0];
harness.notifier.stop('blue');
assert.deepEqual(harness.clearedIntervals, [intervalId]);
assert.deepEqual(harness.clearedTimeouts, [timeoutId]);
assert.equal(harness.commands.at(-1).payload.horn.action, 'stop');
});
test('still schedules the Roomba song when the external horn is disabled', () => {
const harness = createHarness({ enabled: false });
harness.notifier.start('green');
assert.deepEqual(harness.commands, [{
roverId: 'green',
payload: {
type: 'song',
song: {
notes: [{ note: HELP_ROOMBA_NOTE, duration: HELP_ROOMBA_NOTE_DURATION }],
},
},
}]);
assert.equal(harness.intervals.size, 1);
assert.equal(harness.timeouts.size, 0);
});
@@ -0,0 +1,71 @@
// Rover Help Service
// Purpose: Publishes sustained 600-series Roomba trouble as roster and alert state.
// Scope: Integrates the pure monitor with roverManager, browser alerts, and the event bus.
const roverManager = require('../roverManager');
const { sendAlert } = require('../alertService');
const { publishEvent } = require('../eventBus');
const { REASON_LABELS, createRoverHelpMonitor } = require('./monitor');
const { createHelpHornNotifier } = require('./hornNotifier');
const HELP_ALERT_COLOR = '#ef4444';
const hornNotifier = createHelpHornNotifier({
getRover: (roverId) => roverManager.rovers.get(String(roverId)),
// commandService imports roverManager, so resolving it only when a chirp is
// actually issued avoids turning server startup order into a circular module
// dependency while retaining the established command transport.
issueCommand: (roverId, payload) => require('../commandService').issueCommand(roverId, payload),
logger: require('../../globals/logger').child('roverHelpService'),
});
const monitor = createRoverHelpMonitor({
onChange({ roverId, needsHelp, addedReason, reasons }) {
const record = roverManager.rovers.get(String(roverId));
if (!record) return;
const wasNeedingHelp = Boolean(record.needsHelp);
roverManager.setNeedsHelp(roverId, needsHelp);
if (!wasNeedingHelp && needsHelp) {
hornNotifier.start(roverId);
const reason = REASON_LABELS[addedReason] || 'a sustained rover fault was detected';
sendAlert({
color: HELP_ALERT_COLOR,
title: 'Rover Needs Help',
message: `${record.meta?.name || roverId}: ${reason}.`,
});
// Discord owns presentation of this event. The UI intentionally receives
// only the roster boolean, keeping every HELP overlay free of reason text.
publishEvent({
source: 'roverHelpService',
type: 'rover.helpNeeded',
payload: { roverId, roverName: record.meta?.name || roverId, reason, reasons },
});
} else if (wasNeedingHelp && !needsHelp) {
hornNotifier.stop(roverId);
publishEvent({
source: 'roverHelpService',
type: 'rover.helpCleared',
payload: { roverId, roverName: record.meta?.name || roverId },
});
}
},
});
roverManager.managerEvents.on('sensor', ({ roverId, sensors }) => {
monitor.handleSensor(roverId, sensors);
});
roverManager.managerEvents.on('dockGuard', (event) => {
monitor.handleDockGuard(event);
});
roverManager.managerEvents.on('rover', ({ roverId, action }) => {
// A reconnect gets a fresh record and fresh persistence timers; stale sensor
// history from a disconnected chassis must never immediately restore HELP.
if (action === 'removed') {
hornNotifier.stop(roverId);
monitor.removeRover(roverId);
}
});
module.exports = { hornNotifier, monitor };
@@ -0,0 +1,177 @@
// Rover Help Monitor
// Purpose: Converts sustained 600-series Roomba sensor conditions into one help state.
// Scope: Owns timing and reason state without performing roster fanout, alerts, or Discord I/O.
const WHEEL_DROP_HELP_MS = 15 * 60 * 1000;
const CLIFF_HELP_MS = 10 * 60 * 1000;
const DOCK_GUARD_HELP_MS = 15 * 60 * 1000;
const REASON_LABELS = Object.freeze({
wheelDrop: 'a wheel-drop sensor remained active for 15 minutes',
cliff: 'the same cliff-sensor pattern remained active for 10 minutes',
docking: 'automatic docking remained active for 15 minutes',
});
function cliffPattern(sensors) {
// A four-bit pattern distinguishes one continuously held physical situation
// from a rover encountering different edges. Zero means no active cliff and
// therefore cannot begin or retain a cliff-help timer.
return [
sensors?.cliffLeft,
sensors?.cliffFrontLeft,
sensors?.cliffFrontRight,
sensors?.cliffRight,
].reduce((pattern, active, index) => pattern | (active ? 1 << index : 0), 0);
}
function createRoverHelpMonitor({ now = () => Date.now(), onChange = () => {} } = {}) {
const states = new Map();
function ensureState(roverId) {
const id = String(roverId);
if (!states.has(id)) {
states.set(id, {
wheelDropSince: null,
cliffPattern: 0,
cliffPatternSince: null,
dockGuardSince: null,
dockGuardSawPassive: false,
reasons: new Set(),
});
}
return states.get(id);
}
function updateReason(roverId, state, reason, active) {
const hadReason = state.reasons.has(reason);
if (active === hadReason) return;
if (active) state.reasons.add(reason);
else state.reasons.delete(reason);
// Notify on every reason-set change so the integration can update the
// aggregate flag correctly when one condition clears but another remains.
onChange({
roverId: String(roverId),
needsHelp: state.reasons.size > 0,
addedReason: active ? reason : null,
removedReason: active ? null : reason,
reasons: Array.from(state.reasons),
});
}
function handleSensor(roverId, sensors) {
if (!roverId || !sensors) return;
const state = ensureState(roverId);
const timestamp = now();
const docked = Boolean(sensors?.chargingSources?.homeBase);
if (docked) {
// A 600-series Roomba can legitimately rest on the dock with wheel-drop
// or cliff bits held by its physical position and the nearby surface.
// Home-base contact is therefore a stronger signal than every monitored
// fault here: reset all persistence history and do not let time spent
// docked contribute toward a later HELP after it leaves the base.
state.wheelDropSince = null;
state.cliffPattern = 0;
state.cliffPatternSince = null;
state.dockGuardSince = null;
state.dockGuardSawPassive = false;
updateReason(roverId, state, 'wheelDrop', false);
updateReason(roverId, state, 'cliff', false);
updateReason(roverId, state, 'docking', false);
return;
}
const wheelDrop = Boolean(
sensors?.bumpsAndWheelDrops?.wheelDropLeft || sensors?.bumpsAndWheelDrops?.wheelDropRight,
);
if (wheelDrop) {
if (state.wheelDropSince == null) state.wheelDropSince = timestamp;
} else {
state.wheelDropSince = null;
}
updateReason(
roverId,
state,
'wheelDrop',
state.wheelDropSince != null && timestamp - state.wheelDropSince >= WHEEL_DROP_HELP_MS,
);
const nextCliffPattern = cliffPattern(sensors);
if (!nextCliffPattern) {
state.cliffPattern = 0;
state.cliffPatternSince = null;
} else if (nextCliffPattern !== state.cliffPattern) {
// Any changed combination is new evidence on a non-mapping Roomba, not
// proof that its chassis translated. Restart only the persistence timer;
// encoder counts are deliberately not consulted anywhere in this monitor.
state.cliffPattern = nextCliffPattern;
state.cliffPatternSince = timestamp;
}
updateReason(
roverId,
state,
'cliff',
state.cliffPatternSince != null && timestamp - state.cliffPatternSince >= CLIFF_HELP_MS,
);
if (state.dockGuardSince != null) {
const oiMode = sensors?.oiMode?.label || null;
if (oiMode === 'passive') state.dockGuardSawPassive = true;
if (state.dockGuardSawPassive && oiMode && oiMode !== 'passive') {
// Dock guard itself stops as soon as the 600-series wheels begin their
// autonomous seek motion. Continue timing that seek after the guard
// interval ends, and clear only on docking or a confirmed exit from the
// passive OI mode used by opcode 143.
state.dockGuardSince = null;
state.dockGuardSawPassive = false;
}
}
updateReason(
roverId,
state,
'docking',
state.dockGuardSince != null && timestamp - state.dockGuardSince >= DOCK_GUARD_HELP_MS,
);
}
function handleDockGuard({ roverId, active, startedAt = null } = {}) {
if (!roverId) return;
const state = ensureState(roverId);
if (active) {
// Prefer roverManager's authoritative start time. The fallback keeps the
// monitor deterministic if an event source omits it in a future caller.
state.dockGuardSince = Number.isFinite(Number(startedAt)) ? Number(startedAt) : now();
state.dockGuardSawPassive = false;
return;
}
// Before passive mode is observed, a stopped guard means docking never
// began. Once passive has been seen, wheel activity stops the guard even
// though the Roomba is still autonomously seeking its dock, so sensor mode
// and charging state become the authoritative completion signals instead.
if (!state.dockGuardSawPassive) {
state.dockGuardSince = null;
updateReason(roverId, state, 'docking', false);
}
}
function removeRover(roverId) {
states.delete(String(roverId));
}
return {
handleSensor,
handleDockGuard,
removeRover,
};
}
module.exports = {
CLIFF_HELP_MS,
DOCK_GUARD_HELP_MS,
REASON_LABELS,
WHEEL_DROP_HELP_MS,
cliffPattern,
createRoverHelpMonitor,
};
@@ -0,0 +1,137 @@
// Rover Help Monitor Tests
// Purpose: Locks down sustained-condition timing without real timers or hardware.
const test = require('node:test');
const assert = require('node:assert/strict');
const {
CLIFF_HELP_MS,
DOCK_GUARD_HELP_MS,
WHEEL_DROP_HELP_MS,
createRoverHelpMonitor,
} = require('./monitor');
function createHarness() {
let timestamp = 1_000;
const changes = [];
const monitor = createRoverHelpMonitor({ now: () => timestamp, onChange: (change) => changes.push(change) });
return {
changes,
monitor,
advance(ms) {
timestamp += ms;
},
now() {
return timestamp;
},
};
}
test('requires a continuous wheel drop and clears help when it releases', () => {
const harness = createHarness();
const dropped = { bumpsAndWheelDrops: { wheelDropLeft: true } };
harness.monitor.handleSensor('red', dropped);
harness.advance(WHEEL_DROP_HELP_MS - 1);
harness.monitor.handleSensor('red', dropped);
assert.equal(harness.changes.length, 0);
harness.advance(1);
harness.monitor.handleSensor('red', dropped);
assert.equal(harness.changes.at(-1).needsHelp, true);
assert.equal(harness.changes.at(-1).addedReason, 'wheelDrop');
harness.monitor.handleSensor('red', { bumpsAndWheelDrops: {} });
assert.equal(harness.changes.at(-1).needsHelp, false);
});
test('a changed cliff combination restarts the ten minute timer', () => {
const harness = createHarness();
harness.monitor.handleSensor('blue', { cliffLeft: true });
harness.advance(CLIFF_HELP_MS - 1);
harness.monitor.handleSensor('blue', { cliffLeft: true, cliffFrontLeft: true });
harness.advance(1);
harness.monitor.handleSensor('blue', { cliffLeft: true, cliffFrontLeft: true });
assert.equal(harness.changes.length, 0);
harness.advance(CLIFF_HELP_MS - 1);
harness.monitor.handleSensor('blue', { cliffLeft: true, cliffFrontLeft: true });
assert.equal(harness.changes.at(-1).addedReason, 'cliff');
});
test('dock guard uses elapsed active time and clears when the guard stops', () => {
const harness = createHarness();
harness.monitor.handleDockGuard({ roverId: 'green', active: true, startedAt: harness.now() });
harness.advance(DOCK_GUARD_HELP_MS);
harness.monitor.handleSensor('green', {});
assert.equal(harness.changes.at(-1).addedReason, 'docking');
harness.monitor.handleDockGuard({ roverId: 'green', active: false });
assert.equal(harness.changes.at(-1).needsHelp, false);
});
test('autonomous passive docking remains timed after wheel motion stops dock guard', () => {
const harness = createHarness();
harness.monitor.handleDockGuard({ roverId: 'yellow', active: true, startedAt: harness.now() });
harness.monitor.handleSensor('yellow', { oiMode: { label: 'passive' }, chargingSources: {} });
harness.monitor.handleDockGuard({ roverId: 'yellow', active: false });
harness.advance(DOCK_GUARD_HELP_MS);
harness.monitor.handleSensor('yellow', { oiMode: { label: 'passive' }, chargingSources: {} });
assert.equal(harness.changes.at(-1).addedReason, 'docking');
harness.monitor.handleSensor('yellow', {
oiMode: { label: 'passive' },
chargingSources: { homeBase: true },
});
assert.equal(harness.changes.at(-1).needsHelp, false);
});
test('clearing one reason retains help while another reason remains', () => {
const harness = createHarness();
const both = { bumpsAndWheelDrops: { wheelDropRight: true }, cliffRight: true };
harness.monitor.handleSensor('orange', both);
// Advance through the longer threshold so both independently sustained
// conditions are active before exercising aggregate clearing behavior.
harness.advance(WHEEL_DROP_HELP_MS);
harness.monitor.handleSensor('orange', both);
assert.deepEqual(harness.changes.at(-1).reasons.sort(), ['cliff', 'wheelDrop']);
harness.monitor.handleSensor('orange', { cliffRight: true });
assert.equal(harness.changes.at(-1).needsHelp, true);
assert.deepEqual(harness.changes.at(-1).reasons, ['cliff']);
});
test('docked sensor conditions never accumulate help time', () => {
const harness = createHarness();
const dockedFaults = {
chargingSources: { homeBase: true },
bumpsAndWheelDrops: { wheelDropLeft: true },
cliffLeft: true,
};
harness.monitor.handleSensor('purple', dockedFaults);
harness.advance(WHEEL_DROP_HELP_MS + CLIFF_HELP_MS);
harness.monitor.handleSensor('purple', dockedFaults);
assert.equal(harness.changes.length, 0);
// Leaving the dock begins fresh timers instead of inheriting the long period
// during which those same physical bits were harmlessly held at home base.
harness.monitor.handleSensor('purple', {
chargingSources: {},
bumpsAndWheelDrops: { wheelDropLeft: true },
cliffLeft: true,
});
assert.equal(harness.changes.length, 0);
});
test('docking clears every active help reason', () => {
const harness = createHarness();
const faults = { bumpsAndWheelDrops: { wheelDropRight: true }, cliffRight: true };
harness.monitor.handleSensor('silver', faults);
harness.advance(WHEEL_DROP_HELP_MS);
harness.monitor.handleSensor('silver', faults);
assert.equal(harness.changes.at(-1).needsHelp, true);
harness.monitor.handleSensor('silver', {
...faults,
chargingSources: { homeBase: true },
});
assert.equal(harness.changes.at(-1).needsHelp, false);
assert.deepEqual(harness.changes.at(-1).reasons, []);
});
@@ -133,6 +133,7 @@ const {
getRoster, getRoster,
getRosterForSocket, getRosterForSocket,
broadcastRoster, broadcastRoster,
setNeedsHelp,
setToggleState, setToggleState,
handleHostStats, handleHostStats,
canSeeRover, canSeeRover,
@@ -281,6 +282,7 @@ module.exports = {
getRoster, getRoster,
getRosterForSocket, getRosterForSocket,
broadcastRoster, broadcastRoster,
setNeedsHelp,
setToggleState, setToggleState,
handleHostStats, handleHostStats,
handleSensorFrame, handleSensorFrame,
@@ -46,6 +46,10 @@ function createRosterLifecycle(deps) {
room: `rover:${id}`, room: `rover:${id}`,
lastSeen: Date.now(), lastSeen: Date.now(),
lastMovementAt: Date.now(), lastMovementAt: Date.now(),
// Help state belongs to the live rover record so every roster consumer
// sees one server-authoritative answer. The monitoring service owns why
// the flag changes; roverManager only owns publishing the roster field.
needsHelp: false,
private: { enabled: false }, private: { enabled: false },
privateOpen: true, privateOpen: true,
privateSafety: { ...DEFAULT_PRIVATE_SAFETY }, privateSafety: { ...DEFAULT_PRIVATE_SAFETY },
@@ -239,6 +243,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,
@@ -247,6 +256,7 @@ function createRosterLifecycle(deps) {
: record.meta?.laser, : record.meta?.laser,
locked: record.locked || (isPrivateRecord(record) && !isPrivateOpen(record)), locked: record.locked || (isPrivateRecord(record) && !isPrivateOpen(record)),
lockReason: record.lockReason || (isPrivateRecord(record) && !isPrivateOpen(record) ? 'private' : null), lockReason: record.lockReason || (isPrivateRecord(record) && !isPrivateOpen(record) ? 'private' : null),
needsHelp: Boolean(record.needsHelp),
lastSeen: record.lastSeen, lastSeen: record.lastSeen,
private: isPrivateRecord(record) private: isPrivateRecord(record)
? { enabled: true, open: isPrivateOpen(record), safety: getPrivateSafety(record) } ? { enabled: true, open: isPrivateOpen(record), safety: getPrivateSafety(record) }
@@ -294,6 +304,21 @@ function createRosterLifecycle(deps) {
managerEvents.emit('rover', { roverId, action: device, record }); managerEvents.emit('rover', { roverId, action: device, record });
} }
function setNeedsHelp(roverId, needsHelp) {
const record = rovers.get(String(roverId));
if (!record) return false;
const next = Boolean(needsHelp);
if (record.needsHelp === next) return false;
// Emit both roster fanout forms used by the application. The lightweight
// `rovers` event updates direct roster listeners immediately, while the
// manager event asks sessionService to rebuild complete session snapshots.
record.needsHelp = next;
broadcastRoster();
managerEvents.emit('help', { roverId: record.id, needsHelp: next });
return true;
}
function handleHostStats(roverId, msg = {}) { function handleHostStats(roverId, msg = {}) {
const record = rovers.get(roverId); const record = rovers.get(roverId);
if (!record) return; if (!record) return;
@@ -340,6 +365,7 @@ function createRosterLifecycle(deps) {
getRosterForSocket, getRosterForSocket,
syncSpectatorRooms, syncSpectatorRooms,
broadcastRoster, broadcastRoster,
setNeedsHelp,
setToggleState, setToggleState,
handleHostStats, handleHostStats,
canSeeRover, canSeeRover,
@@ -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, []);
});
@@ -184,6 +184,11 @@ function createRoverLifecycle(deps) {
const currentRecord = rovers.get(currentId); const currentRecord = rovers.get(currentId);
if (!currentRecord) return { ok: true, currentId }; if (!currentRecord) return { ok: true, currentId };
if (hasOtherDrivers(currentRecord, socket.id)) return { ok: true, currentId }; if (hasOtherDrivers(currentRecord, socket.id)) return { ok: true, currentId };
// HELP means the normal dock-before-leaving requirement has failed to
// resolve the rover's situation and a person may need to take a different
// rover instead. This exception changes departure only; request eligibility
// and automatic assignment ranking remain owned by their existing paths.
if (currentRecord.needsHelp) return { ok: true, currentId };
if (isDockedAndCharging(currentRecord)) return { ok: true, currentId }; if (isDockedAndCharging(currentRecord)) return { ok: true, currentId };
return { ok: false, currentId, message: 'Dock and charge your current rover before switching.' }; return { ok: false, currentId, message: 'Dock and charge your current rover before switching.' };
} }
@@ -57,3 +57,44 @@ test('removing a rover clears driver sets, reverse membership, rooms, and turns'
{ socketId, roverId, action: 'remove' }, { socketId, roverId, action: 'remove' },
]); ]);
}); });
test('the last driver may leave an undocked HELP rover but not an ordinary undocked rover', () => {
const roverId = 'rover-help';
const socketId = 'driver-help';
const socket = { id: socketId };
const record = {
id: roverId,
drivers: new Set([socketId]),
needsHelp: false,
// A present but explicitly non-charging frame exercises the real policy
// boundary instead of accidentally passing through missing rover state.
lastSensor: {
decoded: {
chargingSources: { homeBase: false },
chargingState: { code: 0 },
},
},
};
const lifecycle = createRoverLifecycle({
io: { sockets: { sockets: new Map() } },
rovers: new Map([[roverId, record]]),
socketToRovers: new Map([[socketId, new Set([roverId])]]),
managerEvents: new EventEmitter(),
turnService: {},
isAdmin: () => false,
sendAlert: () => {},
ALERT_COLOR: '#000000',
getMode: () => 'public',
getControlDenialReason: () => null,
});
const ordinaryResult = lifecycle.canLeaveCurrentRover(socket);
assert.equal(ordinaryResult.ok, false);
assert.equal(ordinaryResult.message, 'Dock and charge your current rover before switching.');
// Mutating only the server-owned HELP flag proves that no docking, driver,
// role, or assignment condition is being weakened as part of the exception.
record.needsHelp = true;
const helpResult = lifecycle.canLeaveCurrentRover(socket);
assert.deepEqual(helpResult, { ok: true, currentId: roverId });
});
@@ -329,6 +329,7 @@ function createSensorPipeline(deps) {
function stopDockGuard(roverId) { function stopDockGuard(roverId) {
const state = dockGuardStates.get(roverId); const state = dockGuardStates.get(roverId);
if (!state) return; if (!state) return;
const wasActive = state.active;
if (state.timer) clearInterval(state.timer); if (state.timer) clearInterval(state.timer);
state.active = false; state.active = false;
state.reason = null; state.reason = null;
@@ -336,6 +337,11 @@ function createSensorPipeline(deps) {
state.timer = null; state.timer = null;
state.idleUndockedSince = null; state.idleUndockedSince = null;
state.passiveUndockedSince = null; state.passiveUndockedSince = null;
// The help monitor must clear its docking timer at the same ownership
// boundary that stops dock guard. Inferring this from OI mode would be
// incorrect because a 600-series rover can enter passive mode for reasons
// other than the server's automatic docking workflow.
if (wasActive) managerEvents.emit('dockGuard', { roverId, active: false });
} }
function attemptDockGuard(roverId) { function attemptDockGuard(roverId) {
@@ -360,6 +366,12 @@ function createSensorPipeline(deps) {
state.active = true; state.active = true;
state.reason = reason; state.reason = reason;
state.startedAt = Date.now(); state.startedAt = Date.now();
managerEvents.emit('dockGuard', {
roverId: record.id,
active: true,
reason,
startedAt: state.startedAt,
});
const reasonText = reason === 'passive' ? 'passive mode' : 'idle and undocked'; const reasonText = reason === 'passive' ? 'passive mode' : 'idle and undocked';
sendAlert({ sendAlert({
color: ALERT_COLOR, color: ALERT_COLOR,
@@ -355,6 +355,13 @@ managerEvents.on('privateSafety', ({ roverId }) => {
syncAll(); syncAll();
}); });
managerEvents.on('help', ({ roverId, needsHelp }) => {
// HELP is roster state used by several passive UI routes, so every connected
// socket must receive the transition rather than only the rover's drivers.
logger.info('Rover help state changed', roverId, needsHelp);
syncAll();
});
privateRoverAccessRequestEvents.on('change', (event = {}) => { privateRoverAccessRequestEvents.on('change', (event = {}) => {
logger.info('Private rover access request state changed', event.reason || 'unknown'); logger.info('Private rover access request state changed', event.reason || 'unknown');
syncAll(); syncAll();
+4 -13
View File
@@ -3,19 +3,10 @@
research how midis can be played easier with drag and drop, auto selection based on which playback mode would fit, more useful and clear toggles, sample midi files to analyze how they would play on a single tone system, better live playback to see what notes are actually playing from each source and what simply wont play on the speaker research how midis can be played easier with drag and drop, auto selection based on which playback mode would fit, more useful and clear toggles, sample midi files to analyze how they would play on a single tone system, better live playback to see what notes are actually playing from each source and what simply wont play on the speaker
and make sure each toggle and counter actually has a purpose besides debugging or something more useful to the user, note skipped is just for debugging and make sure each toggle and counter actually has a purpose besides debugging or something more useful to the user, note skipped is just for debugging
``` ```
1. assign rovers based on battery percentage, give people highest one 1. setting to disable replay popups in spectator settings menu
2. setting to disable replay popups in spectator settings menu 2. add admin ui for VIP and private requests instead of only through discord
3. add admin ui for VIP and private requests instead of only through discord 3. make roverd self update checkout to main branch
4. add flag in roverd for video aspect ratio 4. fix this:
1. maybe dont? whats the point anyway? why do we exist at all? is there purpose to life?
1. just removing the black bars, doesnt do anything practical for the driver page
2. would only actually help for keeping spectate page compact
1. maybe just make the spectate videos be fixed width and match the height of the media
2. either 4:3 or 16:9
3. default is 4:3
4. all it does is tell the web UI to make the rover video 16:9 or 4:3 shaped
1. web UI should default to 4:3 if that rover doesnt yet have that config yet
5. fix this:
`Jun 18 15:14:18 roombaserver.local node[216731]: /home/daniel/MultiRoombaRover/server/src/services/roverManager/socketHandlers.js:92 `Jun 18 15:14:18 roombaserver.local node[216731]: /home/daniel/MultiRoombaRover/server/src/services/roverManager/socketHandlers.js:92
Jun 18 15:14:18 roombaserver.local node[216731]: cb({ error: err.message }); Jun 18 15:14:18 roombaserver.local node[216731]: cb({ error: err.message });
Jun 18 15:14:18 roombaserver.local node[216731]: ^ Jun 18 15:14:18 roombaserver.local node[216731]: ^
+290 -11
View File
@@ -8,6 +8,7 @@
"name": "webui", "name": "webui",
"version": "0.0.0", "version": "0.0.0",
"dependencies": { "dependencies": {
"@lizardbyte/gamepad-helper": "^2026.816.4539",
"@thumbmarkjs/thumbmarkjs": "^1.10.0", "@thumbmarkjs/thumbmarkjs": "^1.10.0",
"midi-file": "^1.2.4", "midi-file": "^1.2.4",
"papaparse": "^5.5.4", "papaparse": "^5.5.4",
@@ -34,7 +35,8 @@
"postcss": "^8.5.6", "postcss": "^8.5.6",
"socket.io-client": "4.8.3", "socket.io-client": "4.8.3",
"tailwindcss": "^3.4.14", "tailwindcss": "^3.4.14",
"vite": "^7.2.2" "vite": "^7.2.2",
"vitest": "^5.0.0"
} }
}, },
"node_modules/@alloc/quick-lru": { "node_modules/@alloc/quick-lru": {
@@ -1034,9 +1036,9 @@
} }
}, },
"node_modules/@jridgewell/sourcemap-codec": { "node_modules/@jridgewell/sourcemap-codec": {
"version": "1.5.5", "version": "1.6.0",
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz",
"integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==",
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
@@ -1051,6 +1053,15 @@
"@jridgewell/sourcemap-codec": "^1.4.14" "@jridgewell/sourcemap-codec": "^1.4.14"
} }
}, },
"node_modules/@lizardbyte/gamepad-helper": {
"version": "2026.816.4539",
"resolved": "https://registry.npmjs.org/@lizardbyte/gamepad-helper/-/gamepad-helper-2026.816.4539.tgz",
"integrity": "sha512-Tncd9+MEUOU9JpDnJIeFEDlUure8CGItLHbcr08i14uylUtx20oqWnTR+ZGmVITeGjdqW9+a43FSMJUiK0c4LA==",
"license": "MIT",
"funding": {
"url": "https://app.lizardbyte.dev"
}
},
"node_modules/@nodelib/fs.scandir": { "node_modules/@nodelib/fs.scandir": {
"version": "2.1.5", "version": "2.1.5",
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
@@ -1483,6 +1494,24 @@
"@babel/types": "^7.28.2" "@babel/types": "^7.28.2"
} }
}, },
"node_modules/@types/chai": {
"version": "5.2.3",
"resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz",
"integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/deep-eql": "*",
"assertion-error": "^2.0.1"
}
},
"node_modules/@types/deep-eql": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz",
"integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/estree": { "node_modules/@types/estree": {
"version": "1.0.8", "version": "1.0.8",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
@@ -1550,6 +1579,44 @@
"vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0"
} }
}, },
"node_modules/@vitest/mocker": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-5.0.0.tgz",
"integrity": "sha512-66PGTMIiVJP3t4a5yxU9qPtf7MdTBs8jmToMvy+HVflB3Yy13WJZTtPePdvU+wjRV02SKK5doLbSA6o9pwOmiA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/trace-mapping": "0.3.31",
"@vitest/spy": "5.0.0",
"estree-walker": "^3.0.3",
"magic-string": "^1.2.3"
},
"funding": {
"url": "https://opencollective.com/vitest"
},
"peerDependencies": {
"msw": "^2.4.9",
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
},
"peerDependenciesMeta": {
"msw": {
"optional": true
},
"vite": {
"optional": true
}
}
},
"node_modules/@vitest/spy": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-5.0.0.tgz",
"integrity": "sha512-uy+luWBAPw9XfthoHi5AkfHUnuPYEESjl0p/r+meoBnU8bxg5GDQ3Ey8MjcJ6sqahkL4PFyrvfMJJBw7LbU06g==",
"dev": true,
"license": "MIT",
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/acorn": { "node_modules/acorn": {
"version": "8.15.0", "version": "8.15.0",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
@@ -1667,6 +1734,16 @@
"dev": true, "dev": true,
"license": "Python-2.0" "license": "Python-2.0"
}, },
"node_modules/assertion-error": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
"integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
}
},
"node_modules/autoprefixer": { "node_modules/autoprefixer": {
"version": "10.4.22", "version": "10.4.22",
"resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.22.tgz", "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.22.tgz",
@@ -1834,6 +1911,16 @@
], ],
"license": "CC-BY-4.0" "license": "CC-BY-4.0"
}, },
"node_modules/chai": {
"version": "6.2.2",
"resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz",
"integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"node_modules/chalk": { "node_modules/chalk": {
"version": "4.1.2", "version": "4.1.2",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
@@ -2061,6 +2148,13 @@
"node": ">=10.0.0" "node": ">=10.0.0"
} }
}, },
"node_modules/es-module-lexer": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.2.tgz",
"integrity": "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==",
"dev": true,
"license": "MIT"
},
"node_modules/esbuild": { "node_modules/esbuild": {
"version": "0.25.12", "version": "0.25.12",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz",
@@ -2300,6 +2394,16 @@
"node": ">=4.0" "node": ">=4.0"
} }
}, },
"node_modules/estree-walker": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz",
"integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/estree": "^1.0.0"
}
},
"node_modules/esutils": { "node_modules/esutils": {
"version": "2.0.3", "version": "2.0.3",
"resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz",
@@ -2310,6 +2414,16 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/expect-type": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz",
"integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==",
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": ">=12.0.0"
}
},
"node_modules/fast-deep-equal": { "node_modules/fast-deep-equal": {
"version": "3.1.3", "version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
@@ -2915,6 +3029,16 @@
"yallist": "^3.0.2" "yallist": "^3.0.2"
} }
}, },
"node_modules/magic-string": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.3.1.tgz",
"integrity": "sha512-rm91zr2Ou+XueDTohjQQjdQEcYM6zVi8KVUCG8Ec3vHwUEKrhSdCNyfuIywkA6hcCAteIn0ZOtAHA6eGpiX+Pg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/sourcemap-codec": "^1.6.0"
}
},
"node_modules/merge2": { "node_modules/merge2": {
"version": "1.4.1", "version": "1.4.1",
"resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
@@ -3073,6 +3197,20 @@
"node": ">= 6" "node": ">= 6"
} }
}, },
"node_modules/obug": {
"version": "2.2.1",
"resolved": "https://registry.npmjs.org/obug/-/obug-2.2.1.tgz",
"integrity": "sha512-XrsrhT5sybtKI6wakr2SPOlGZWWYbUXZ7a0jT8/QOeAPau+1X/bSegNe5YR75oJmEZQbKningirmGOEJCIk61Q==",
"dev": true,
"funding": [
"https://github.com/sponsors/sxzz",
"https://opencollective.com/debug"
],
"license": "MIT",
"engines": {
"node": ">=12.20.0"
}
},
"node_modules/optionator": { "node_modules/optionator": {
"version": "0.9.4", "version": "0.9.4",
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
@@ -3208,9 +3346,9 @@
"license": "ISC" "license": "ISC"
}, },
"node_modules/picomatch": { "node_modules/picomatch": {
"version": "4.0.3", "version": "4.0.7",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
@@ -3727,6 +3865,13 @@
"node": ">=8" "node": ">=8"
} }
}, },
"node_modules/siginfo": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz",
"integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==",
"dev": true,
"license": "ISC"
},
"node_modules/signal-exit": { "node_modules/signal-exit": {
"version": "4.1.0", "version": "4.1.0",
"resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz",
@@ -3780,6 +3925,20 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/stackback": {
"version": "0.0.2",
"resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz",
"integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==",
"dev": true,
"license": "MIT"
},
"node_modules/std-env": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/std-env/-/std-env-4.2.0.tgz",
"integrity": "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==",
"dev": true,
"license": "MIT"
},
"node_modules/string-width": { "node_modules/string-width": {
"version": "5.1.2", "version": "5.1.2",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz",
@@ -4013,15 +4172,35 @@
"integrity": "sha512-wtTRjG92pM5eUg/KuUnHsqSAlPM296brTOcLgMRqEeylYTh/CdtvKUvCyyCQTzFuStieWxvZb8mVTMvdPyUpxg==", "integrity": "sha512-wtTRjG92pM5eUg/KuUnHsqSAlPM296brTOcLgMRqEeylYTh/CdtvKUvCyyCQTzFuStieWxvZb8mVTMvdPyUpxg==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/tinybench": {
"version": "6.1.4",
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-6.1.4.tgz",
"integrity": "sha512-9APumHG7r4yOk4X4WlkmE71aZcv1gvin1czO3OQ1U9iJcFA5Ja/ygyb0vPOVHTthFozUYs8CLoLUlM8grb2lTQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/tinyexec": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.0.tgz",
"integrity": "sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"node_modules/tinyglobby": { "node_modules/tinyglobby": {
"version": "0.2.15", "version": "0.2.17",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
"integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"fdir": "^6.5.0", "fdir": "^6.5.0",
"picomatch": "^4.0.3" "picomatch": "^4.0.4"
}, },
"engines": { "engines": {
"node": ">=12.0.0" "node": ">=12.0.0"
@@ -4201,6 +4380,89 @@
} }
} }
}, },
"node_modules/vitest": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/vitest/-/vitest-5.0.0.tgz",
"integrity": "sha512-gpsMNoRhMjMktVxPtstOH4/PJuPyovVaMDr4oDilXaGH1EcqM2OE96SoHT2VIQ6fTGtTjqmHDrEu2X9RQiXf8Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/chai": "^5.2.2",
"@vitest/mocker": "5.0.0",
"chai": "^6.2.2",
"es-module-lexer": "^2.3.2",
"expect-type": "^1.4.0",
"magic-string": "^1.2.3",
"obug": "^2.1.4",
"picomatch": "^4.0.7",
"std-env": "^4.2.0",
"tinybench": "6.1.4",
"tinyexec": "1.3.0",
"tinyglobby": "^0.2.17",
"why-is-node-running": "^2.3.0"
},
"bin": {
"vitest": "vitest.mjs"
},
"engines": {
"node": "^22.12.0 || ^24.0.0 || >=26.0.0"
},
"funding": {
"url": "https://opencollective.com/vitest"
},
"peerDependencies": {
"@edge-runtime/vm": "*",
"@opentelemetry/api": "^1.9.0",
"@types/node": "^22.0.0 || >=24.0.0",
"@vitest/browser-playwright": "5.0.0",
"@vitest/browser-preview": "5.0.0",
"@vitest/browser-webdriverio": "^5.0.0-beta.5 || >=5.0.0",
"@vitest/coverage-istanbul": "5.0.0",
"@vitest/coverage-v8": "5.0.0",
"@vitest/ui": "5.0.0",
"happy-dom": "*",
"jsdom": "*",
"vite": "^6.4.0 || ^7.0.0 || ^8.0.0"
},
"peerDependenciesMeta": {
"@edge-runtime/vm": {
"optional": true
},
"@opentelemetry/api": {
"optional": true
},
"@types/node": {
"optional": true
},
"@vitest/browser-playwright": {
"optional": true
},
"@vitest/browser-preview": {
"optional": true
},
"@vitest/browser-webdriverio": {
"optional": true
},
"@vitest/coverage-istanbul": {
"optional": true
},
"@vitest/coverage-v8": {
"optional": true
},
"@vitest/ui": {
"optional": true
},
"happy-dom": {
"optional": true
},
"jsdom": {
"optional": true
},
"vite": {
"optional": false
}
}
},
"node_modules/web-haptics": { "node_modules/web-haptics": {
"version": "0.0.6", "version": "0.0.6",
"resolved": "https://registry.npmjs.org/web-haptics/-/web-haptics-0.0.6.tgz", "resolved": "https://registry.npmjs.org/web-haptics/-/web-haptics-0.0.6.tgz",
@@ -4243,6 +4505,23 @@
"node": ">= 8" "node": ">= 8"
} }
}, },
"node_modules/why-is-node-running": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
"integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==",
"dev": true,
"license": "MIT",
"dependencies": {
"siginfo": "^2.0.0",
"stackback": "0.0.2"
},
"bin": {
"why-is-node-running": "cli.js"
},
"engines": {
"node": ">=8"
}
},
"node_modules/word-wrap": { "node_modules/word-wrap": {
"version": "1.2.5", "version": "1.2.5",
"resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz",
+4 -1
View File
@@ -7,9 +7,11 @@
"dev": "VITE_ROVERD_URL=https://rover.otter.land vite", "dev": "VITE_ROVERD_URL=https://rover.otter.land vite",
"build": "vite build", "build": "vite build",
"lint": "eslint .", "lint": "eslint .",
"test": "node --test src/controls/inputs/*.test.js src/components/GamepadMappingSettings/*.test.js",
"preview": "vite preview" "preview": "vite preview"
}, },
"dependencies": { "dependencies": {
"@lizardbyte/gamepad-helper": "^2026.816.4539",
"@thumbmarkjs/thumbmarkjs": "^1.10.0", "@thumbmarkjs/thumbmarkjs": "^1.10.0",
"midi-file": "^1.2.4", "midi-file": "^1.2.4",
"papaparse": "^5.5.4", "papaparse": "^5.5.4",
@@ -36,6 +38,7 @@
"postcss": "^8.5.6", "postcss": "^8.5.6",
"socket.io-client": "4.8.3", "socket.io-client": "4.8.3",
"tailwindcss": "^3.4.14", "tailwindcss": "^3.4.14",
"vite": "^7.2.2" "vite": "^7.2.2",
"vitest": "^5.0.0"
} }
} }
@@ -0,0 +1,82 @@
// Auto Fit Text
// Purpose: Sizes one unwrapped label to the largest font that fits its container.
// Scope: Supports the existing width-only labels and full-box overlays from one shared implementation.
import { useLayoutEffect, useRef, useState } from 'react';
export default function AutoFitText({
children,
className = '',
containerClassName = '',
maxSize = 1000,
minSize = 14,
fitHeight = false,
style = undefined,
}) {
const containerRef = useRef(null);
const textRef = useRef(null);
const [fontSize, setFontSize] = useState(maxSize);
useLayoutEffect(() => {
const container = containerRef.current;
const textEl = textRef.current;
if (!container || !textEl) return undefined;
let animationFrame = null;
const fit = () => {
const width = container.clientWidth;
const height = container.clientHeight;
if (!width || (fitHeight && !height)) {
scheduleFit();
return;
}
// Binary search avoids stepping through hundreds of possible font sizes.
// Overlay labels test both axes; ordinary rover labels preserve their
// previous width-only behavior so this shared move changes no layout.
let low = minSize;
let high = maxSize;
let best = minSize;
while (low <= high) {
const middle = Math.floor((low + high) / 2);
textEl.style.fontSize = `${middle}px`;
const fitsWidth = textEl.scrollWidth <= width;
const fitsHeight = !fitHeight || textEl.scrollHeight <= height;
if (fitsWidth && fitsHeight) {
best = middle;
low = middle + 1;
} else {
high = middle - 1;
}
}
setFontSize(best);
};
const scheduleFit = () => {
if (animationFrame) cancelAnimationFrame(animationFrame);
animationFrame = requestAnimationFrame(fit);
};
scheduleFit();
const observer = new ResizeObserver(scheduleFit);
observer.observe(container);
return () => {
if (animationFrame) cancelAnimationFrame(animationFrame);
observer.disconnect();
};
}, [children, fitHeight, maxSize, minSize]);
return (
<div
ref={containerRef}
className={`${fitHeight ? 'flex h-full items-center justify-center' : ''} w-full min-w-0 ${containerClassName}`}
>
<div
ref={textRef}
className={`whitespace-nowrap ${className}`}
style={{ fontSize: `${fontSize}px`, lineHeight: fitHeight ? 1 : 1.1, ...(style || {}) }}
>
{children}
</div>
</div>
);
}
+3 -3
View File
@@ -68,8 +68,8 @@ export default function CardFrame({
? { borderColor: '#008a35' } ? { borderColor: '#008a35' }
: accentRgb : accentRgb
? { ? {
backgroundImage: `linear-gradient(90deg, rgba(23,23,23,0.96) 0%, rgba(38,38,38,0.94) 0%, ${rgba(accentRgb, 0.1)} 100%)`, // backgroundImage: `linear-gradient(90deg, rgba(23,23,23,0.96) 0%, rgba(38,38,38,0.94) 0%, ${rgba(accentRgb, 0.1)} 100%)`,
// backgroundImage: `linear-gradient(90deg, ${rgba(accentRgb, 0.1)} 100%)`, backgroundImage: `linear-gradient(90deg, ${rgba(accentRgb, 0.2)} 100%)`,
// backgroundImage: `background-color: ${rgba(accentRgb, 0.2)}` // backgroundImage: `background-color: ${rgba(accentRgb, 0.2)}`
} }
: undefined; : undefined;
@@ -100,7 +100,7 @@ export default function CardFrame({
> >
<div className="flex min-w-0 items-center gap-0.5"> <div className="flex min-w-0 items-center gap-0.5">
{title ? ( {title ? (
<p className={cx('m-0 text-[0.78rem] font-semibold leading-none', greenMode ? 'text-lime-400' : 'text-neutral-50')}> <p className={cx('m-0 font-semibold leading-none', greenMode ? 'text-lime-400' : 'text-neutral-50')}>
{title} {title}
</p> </p>
) : null} ) : null}
@@ -0,0 +1,9 @@
// Adaptive Control Hint
// Purpose: Renders the binding for a logical action using the user's most recently used input type.
// Scope: Keeps the render component separate from its hook so React fast refresh can safely
// replace this module without treating a non-component export as component state.
import { useControlHintLabel } from './useControlHintLabel.js';
export default function ControlHint({ actionId }) {
return <>{useControlHintLabel(actionId)}</>;
}
@@ -0,0 +1,31 @@
// Adaptive Control Hint Label Hook
// Purpose: Resolves a logical action to the keyboard or controller label appropriate for the
// operator's most recently used input device.
// Scope: Reads control/settings state only; it never captures input or dispatches rover commands.
import { useControlSelector } from '../../controls/index.js';
import { formatKeyLabel } from '../../controls/keymapUtils.js';
import { useControllerRuntime } from '../../controls/inputs/controllerRuntime.js';
import { formatControllerBinding } from '../../controls/inputs/controllerLabels.js';
import { resolveGamepadProfile } from '../../controls/inputs/gamepadBindings.js';
import { useSettingsNamespace } from '../../settings/index.js';
import { GAMEPAD_PROFILE_DEFAULT, GAMEPAD_SETTINGS_DEFAULTS } from '../../settings/namespaces.js';
export function useControlHintLabel(actionId) {
const keyValue = useControlSelector((control) => control.state.keymap?.[actionId]?.[0]);
const runtime = useControllerRuntime();
const { value: gamepadSettings } = useSettingsNamespace('gamepad', GAMEPAD_SETTINGS_DEFAULTS);
if (runtime.inputMethod !== 'controller' || !runtime.controller) {
return formatKeyLabel(keyValue);
}
/* Profiles remain keyed by reusable hardware signature, while runtime controller selection is
instance-specific. This lets two identical connected pads share a mapping without losing the
browser slot used to decide which one currently owns control. */
const storedProfile =
gamepadSettings?.profiles?.[runtime.controller.signature] ??
gamepadSettings?.defaults?.profile ??
GAMEPAD_PROFILE_DEFAULT;
const profile = resolveGamepadProfile(storedProfile, GAMEPAD_PROFILE_DEFAULT);
return formatControllerBinding(profile, actionId, runtime.controller);
}
@@ -6,7 +6,7 @@ import '../MobileControls/mobileControls.css';
import { useControlActions, useControlSelector } from '../../controls/index.js'; import { useControlActions, useControlSelector } from '../../controls/index.js';
import { useTelemetrySelector } from '../../context/TelemetryContext.jsx'; import { useTelemetrySelector } from '../../context/TelemetryContext.jsx';
import { dockTelemetryEqual, selectDockTelemetry } from '../../context/telemetryViews.js'; import { dockTelemetryEqual, selectDockTelemetry } from '../../context/telemetryViews.js';
import { formatKeyLabel } from '../../controls/keymapUtils.js'; import ControlHint from '../ControlHint/index.jsx';
import { useManualDockAssist } from '../../features/manualDockAssist/useManualDockAssist.js'; import { useManualDockAssist } from '../../features/manualDockAssist/useManualDockAssist.js';
import { deriveDriveDockStateFromTelemetry } from './driveDockState.js'; import { deriveDriveDockStateFromTelemetry } from './driveDockState.js';
import { triggerTouchHaptic } from '../../lib/touchHaptics.js'; import { triggerTouchHaptic } from '../../lib/touchHaptics.js';
@@ -90,7 +90,6 @@ export default function DriveDockAction({
}) { }) {
const isMobile = layout === 'mobile'; const isMobile = layout === 'mobile';
const roverId = useControlSelector((control) => control.state.roverId); const roverId = useControlSelector((control) => control.state.roverId);
const keymap = useControlSelector((control) => control.state.keymap);
const actions = useControlActions(); const actions = useControlActions();
const dockAssist = useManualDockAssist(); const dockAssist = useManualDockAssist();
const dockTelemetry = useTelemetrySelector(roverId, selectDockTelemetry, dockTelemetryEqual); const dockTelemetry = useTelemetrySelector(roverId, selectDockTelemetry, dockTelemetryEqual);
@@ -104,8 +103,8 @@ export default function DriveDockAction({
const driveDisabled = !roverId || pending !== null; const driveDisabled = !roverId || pending !== null;
const dockDisabled = !roverId || pending !== null; const dockDisabled = !roverId || pending !== null;
const driveKeyLabel = formatKeyLabel(keymap?.driveMacro?.[0]); const driveKeyLabel = <ControlHint actionId="driveMacro" />;
const dockKeyLabel = formatKeyLabel(keymap?.dockMacro?.[0]); const dockKeyLabel = <ControlHint actionId="dockMacro" />;
const dockInstructions = { const dockInstructions = {
summary: 'Use assist mode to manually line up with the dock.', summary: 'Use assist mode to manually line up with the dock.',
@@ -7,13 +7,15 @@ import { GAMEPAD_PROFILE_DEFAULT, GAMEPAD_SETTINGS_DEFAULTS } from '../../settin
import { import {
computeGamepadOutputs, computeGamepadOutputs,
createProfileForPad, createProfileForPad,
resolveGamepadProfile,
} from '../../controls/inputs/gamepadBindings.js'; } from '../../controls/inputs/gamepadBindings.js';
import { useGamepadHubState } from '../../controls/inputs/gamepadHub.js'; import { useGamepadHubState } from '../../controls/inputs/gamepadHub.js';
import { acquireControllerControlLock } from '../../controls/inputs/controllerRuntime.js';
import { describeController, formatControllerBinding } from '../../controls/inputs/controllerLabels.js';
import CardFrame from '../CardFrame/index.jsx'; import CardFrame from '../CardFrame/index.jsx';
import SliderField from './SliderField.jsx'; import SliderField from './SliderField.jsx';
import { ACTIONS, NUMBER_FORMAT } from './constants.js'; import { ACTIONS, NUMBER_FORMAT } from './constants.js';
import { import {
formatSource,
groupActions, groupActions,
pickActivePad, pickActivePad,
snapshotBaseline, snapshotBaseline,
@@ -26,21 +28,63 @@ function SettingsGroupLabel({ children }) {
return <p className="mx-auto w-full max-w-lg text-sm font-semibold text-white">{children}</p>; return <p className="mx-auto w-full max-w-lg text-sm font-semibold text-white">{children}</p>;
} }
function physicalInputKey(source) {
/* Inversion and activation thresholds describe how an input is interpreted, not which physical
control it is. Ignoring those fields ensures Axis 3 cannot silently own both a tank track and
camera tilt merely because one binding happens to be inverted. */
if (source?.kind === 'axis' || source?.kind === 'axisButton') return `axis:${source.index}`;
if (source?.kind === 'button' || source?.kind === 'buttonAxis') return `button:${source.index}`;
return JSON.stringify(source);
}
function sourcesUseSamePhysicalInput(left, right) {
if (!left || !right) return false;
return physicalInputKey(left) === physicalInputKey(right);
}
function actionDriveMode(actionId) {
return ACTIONS.find((action) => action.id === actionId)?.driveMode ?? null;
}
function CurveField({ label, value, onChange }) {
return (
<label className="mx-auto block w-full max-w-lg rounded bg-neutral-800/80 px-1.5 py-1">
<div className="grid grid-cols-[minmax(0,1fr)_auto] items-center gap-1.5 text-sm text-white">
<span className="font-semibold">{label}</span>
<select
value={value}
onChange={(event) => onChange(event.target.value)}
className="rounded border border-neutral-600 bg-neutral-900 px-1 py-0.5 text-sm text-white"
>
<option value="linear">Linear</option>
<option value="expo">Fine center control</option>
</select>
</div>
</label>
);
}
function MappingRow({ function MappingRow({
action, action,
source, source,
sourceLabel,
liveValue,
isCapturing, isCapturing,
onClear, onClear,
onCapture, onCapture,
onInvert, onInvert,
disabled,
}) { }) {
// Mapping rows are constrained to a readable width so the source text and buttons remain // Mapping rows are constrained to a readable width so the source text and buttons remain
// visually connected. Buttons wrap on very narrow panes instead of forcing tiny text. // visually connected. Buttons wrap on very narrow panes instead of forcing tiny text.
return ( return (
<div className="mx-auto grid w-full max-w-lg grid-cols-[minmax(0,1fr)_auto] items-center gap-1.5 rounded bg-neutral-800/80 px-1.5 py-1 text-sm max-[520px]:grid-cols-1"> <div className="mx-auto grid w-full max-w-lg grid-cols-[minmax(0,1fr)_auto] items-center gap-1.5 rounded bg-neutral-800/80 px-1.5 py-1 text-sm max-[520px]:grid-cols-1">
<div className="min-w-0"> <div className="min-w-0">
<p className="font-semibold leading-snug text-white">{action.label}</p> <div className="flex items-center gap-1">
<p className="mt-0.5 text-xs leading-snug text-white">{formatSource(source)}</p> <p className="font-semibold leading-snug text-white">{action.label}</p>
<span className={`h-1.5 w-1.5 rounded-full ${liveValue ? 'bg-emerald-400' : 'bg-neutral-600'}`} aria-hidden="true" />
</div>
<p className="mt-0.5 text-xs leading-snug text-white">{sourceLabel}</p>
</div> </div>
<div className="flex flex-wrap items-center justify-end gap-1 max-[520px]:justify-start"> <div className="flex flex-wrap items-center justify-end gap-1 max-[520px]:justify-start">
{/* Axis-pair controls expose independent inversion because stick X/Y directions often {/* Axis-pair controls expose independent inversion because stick X/Y directions often
@@ -50,7 +94,7 @@ function MappingRow({
<> <>
<button <button
type="button" type="button"
disabled={!source} disabled={disabled || !source}
onClick={() => onInvert(action, 'invertX')} onClick={() => onInvert(action, 'invertX')}
className="button-dark px-1 py-0.5 text-xs font-medium disabled:opacity-50" className="button-dark px-1 py-0.5 text-xs font-medium disabled:opacity-50"
> >
@@ -58,7 +102,7 @@ function MappingRow({
</button> </button>
<button <button
type="button" type="button"
disabled={!source} disabled={disabled || !source}
onClick={() => onInvert(action, 'invertY')} onClick={() => onInvert(action, 'invertY')}
className="button-dark px-1 py-0.5 text-xs font-medium disabled:opacity-50" className="button-dark px-1 py-0.5 text-xs font-medium disabled:opacity-50"
> >
@@ -71,7 +115,7 @@ function MappingRow({
{action.kind === 'axis' && ( {action.kind === 'axis' && (
<button <button
type="button" type="button"
disabled={!source} disabled={disabled || !source}
onClick={() => onInvert(action)} onClick={() => onInvert(action)}
className="button-dark px-1 py-0.5 text-xs font-medium disabled:opacity-50" className="button-dark px-1 py-0.5 text-xs font-medium disabled:opacity-50"
> >
@@ -80,17 +124,18 @@ function MappingRow({
)} )}
{/* Clear and Capture are always present because they are the primary row actions. They {/* Clear and Capture are always present because they are the primary row actions. They
wrap with the inversion controls on narrow panes instead of shrinking text. */} wrap with the inversion controls on narrow panes instead of shrinking text. */}
<button type="button" onClick={() => onClear(action)} className="button-dark px-1 py-0.5 text-xs"> <button type="button" disabled={disabled} onClick={() => onClear(action)} className="button-dark px-1 py-0.5 text-xs disabled:opacity-50">
Clear Clear
</button> </button>
<button <button
type="button" type="button"
disabled={disabled}
onClick={() => onCapture(action)} onClick={() => onCapture(action)}
className={`${ className={`${
isCapturing isCapturing
? 'rounded-md bg-emerald-500 px-1 py-0.5 text-emerald-950 hover:bg-emerald-400' ? 'rounded-md bg-emerald-500 px-1 py-0.5 text-emerald-950 hover:bg-emerald-400'
: 'button-dark px-1 py-0.5' : 'button-dark px-1 py-0.5'
} text-xs font-medium`} } text-xs font-medium disabled:opacity-50`}
> >
{isCapturing ? 'Waiting...' : 'Capture'} {isCapturing ? 'Waiting...' : 'Capture'}
</button> </button>
@@ -106,25 +151,48 @@ export default function GamepadMappingSettings() {
GAMEPAD_SETTINGS_DEFAULTS, GAMEPAD_SETTINGS_DEFAULTS,
); );
const [captureAction, setCaptureAction] = useState(null); const [captureAction, setCaptureAction] = useState(null);
const [actionFilter, setActionFilter] = useState('');
const baselineRef = useRef(null); const baselineRef = useRef(null);
const grouped = useMemo(() => groupActions(ACTIONS), []); const captureCandidateRef = useRef(null);
useEffect(() => {
/* The settings panel remains a live control surface so operators can tune calibration while
driving and immediately feel the result. Only capture owns the controller lock: without
that narrow guard, pressing the input being assigned could also drive a wheel, start a
motor, or toggle rover hardware before the new binding is saved. */
if (!captureAction) return undefined;
return acquireControllerControlLock('controller-binding-capture');
}, [captureAction]);
const activePad = useMemo( const activePad = useMemo(
() => pickActivePad(hubState.pads, gamepadSettings.activeSignature), () => pickActivePad(hubState.pads, gamepadSettings.activeInstanceKey),
[hubState.pads, gamepadSettings.activeSignature], [hubState.pads, gamepadSettings.activeInstanceKey],
); );
const activeSignature = activePad?.signature ?? null; const activeSignature = activePad?.signature ?? null;
const activeProfile = useMemo(() => { const activeProfile = useMemo(() => {
if (!activeSignature) { const storedProfile = !activeSignature
return gamepadSettings?.defaults?.profile ?? GAMEPAD_PROFILE_DEFAULT; ? gamepadSettings?.defaults?.profile ?? GAMEPAD_PROFILE_DEFAULT
} : (
return (
gamepadSettings?.profiles?.[activeSignature] ?? gamepadSettings?.profiles?.[activeSignature] ??
gamepadSettings?.defaults?.profile ?? gamepadSettings?.defaults?.profile ??
GAMEPAD_PROFILE_DEFAULT GAMEPAD_PROFILE_DEFAULT
); );
return resolveGamepadProfile(storedProfile, GAMEPAD_PROFILE_DEFAULT);
}, [activeSignature, gamepadSettings?.defaults?.profile, gamepadSettings?.profiles]); }, [activeSignature, gamepadSettings?.defaults?.profile, gamepadSettings?.profiles]);
const driveMode = activeProfile.calibration?.driveMode === 'tank' ? 'tank' : 'single';
const grouped = useMemo(() => {
const query = actionFilter.trim().toLowerCase();
/* Only the active steering scheme is shown. Keeping inactive track/stick bindings out of the
mapping list prevents operators from tuning controls that currently have no runtime effect. */
const modeActions = ACTIONS.filter(
(action) => !action.driveMode || action.driveMode === driveMode,
);
const visibleActions = query
? modeActions.filter((action) => `${action.label} ${action.section}`.toLowerCase().includes(query))
: modeActions;
return groupActions(visibleActions);
}, [actionFilter, driveMode]);
useEffect(() => { useEffect(() => {
if (!activePad || !activeSignature) return; if (!activePad || !activeSignature) return;
@@ -132,7 +200,7 @@ export default function GamepadMappingSettings() {
saveGamepadSettings((prev) => { saveGamepadSettings((prev) => {
const current = prev ?? GAMEPAD_SETTINGS_DEFAULTS; const current = prev ?? GAMEPAD_SETTINGS_DEFAULTS;
if (current.profiles?.[activeSignature]) return current; if (current.profiles?.[activeSignature]) return current;
const base = current?.defaults?.profile ?? GAMEPAD_PROFILE_DEFAULT; const base = resolveGamepadProfile(current?.defaults?.profile, GAMEPAD_PROFILE_DEFAULT);
const nextProfile = createProfileForPad(activePad, base); const nextProfile = createProfileForPad(activePad, base);
return { return {
...current, ...current,
@@ -146,6 +214,7 @@ export default function GamepadMappingSettings() {
useEffect(() => { useEffect(() => {
baselineRef.current = null; baselineRef.current = null;
captureCandidateRef.current = null;
}, [captureAction, activeSignature]); }, [captureAction, activeSignature]);
useEffect(() => { useEffect(() => {
@@ -154,16 +223,49 @@ export default function GamepadMappingSettings() {
baselineRef.current = snapshotBaseline(activePad); baselineRef.current = snapshotBaseline(activePad);
return; return;
} }
const descriptor = buildDescriptorFromCapture(activePad, baselineRef.current, captureAction); let descriptor = buildDescriptorFromCapture(activePad, baselineRef.current, captureAction);
if (captureAction.kind === 'button' && descriptor?.kind !== 'chord') {
const now = typeof performance !== 'undefined' ? performance.now() : Date.now();
if (descriptor && !captureCandidateRef.current) {
/* Give the user a short window to add a modifier after the first button. Immediate capture
makes chords physically impossible because browser frames never report both presses at
precisely the same instant. */
captureCandidateRef.current = { descriptor, startedAt: now };
return;
}
if (!captureCandidateRef.current) return;
if (now - captureCandidateRef.current.startedAt < 220) return;
/* A quick tap may already be released when the chord window expires. Preserve the original
candidate so capture completes normally instead of waiting for an unrelated later press. */
descriptor = captureCandidateRef.current.descriptor;
}
if (!descriptor) return; if (!descriptor) return;
saveGamepadSettings((prev) => { saveGamepadSettings((prev) => {
const current = prev ?? GAMEPAD_SETTINGS_DEFAULTS; const current = prev ?? GAMEPAD_SETTINGS_DEFAULTS;
const baseProfile = const baseProfile = resolveGamepadProfile(
current.profiles?.[activeSignature] ?? current?.defaults?.profile ?? GAMEPAD_PROFILE_DEFAULT; current.profiles?.[activeSignature] ?? current?.defaults?.profile,
GAMEPAD_PROFILE_DEFAULT,
);
const bindingsWithoutConflict = Object.fromEntries(
Object.entries(baseProfile.bindings ?? {}).map(([actionId, binding]) => {
if (actionId === captureAction.id) return [actionId, binding];
const otherMode = actionDriveMode(actionId);
const captureMode = actionDriveMode(captureAction.id);
/* Opposing mode-only actions may intentionally reuse a physical input because runtime
never activates them together. Common actions still conflict with both modes. */
if (captureMode && otherMode && captureMode !== otherMode) return [actionId, binding];
const sources = (binding?.sources ?? []).filter(
(source) => !sourcesUseSamePhysicalInput(source, descriptor),
);
return [actionId, { ...binding, sources }];
}),
);
const nextProfile = { const nextProfile = {
...baseProfile, ...baseProfile,
bindings: { bindings: {
...(baseProfile.bindings ?? {}), /* A physical input has one owner by default. Removing an exact duplicate avoids two
toggles firing from one press while still allowing deliberate multi-button chords. */
...bindingsWithoutConflict,
[captureAction.id]: { [captureAction.id]: {
...(baseProfile.bindings?.[captureAction.id] ?? {}), ...(baseProfile.bindings?.[captureAction.id] ?? {}),
kind: captureAction.kind, kind: captureAction.kind,
@@ -179,14 +281,20 @@ export default function GamepadMappingSettings() {
}, },
}; };
}); });
setCaptureAction(null); /* Hub snapshots drive this effect, but capture state is React-owned UI state. Deferring its
reset to a microtask avoids a synchronous state cascade inside the effect while the id
guard prevents an older completion from cancelling a newer capture request. */
const completedActionId = captureAction.id;
queueMicrotask(() => {
setCaptureAction((current) => current?.id === completedActionId ? null : current);
});
}, [activePad, activeSignature, captureAction, saveGamepadSettings]); }, [activePad, activeSignature, captureAction, saveGamepadSettings]);
const setActiveSignature = useCallback( const setActiveInstanceKey = useCallback(
(signature) => { (instanceKey) => {
saveGamepadSettings((prev) => ({ saveGamepadSettings((prev) => ({
...(prev ?? GAMEPAD_SETTINGS_DEFAULTS), ...(prev ?? GAMEPAD_SETTINGS_DEFAULTS),
activeSignature: signature || null, activeInstanceKey: instanceKey || null,
})); }));
}, },
[saveGamepadSettings], [saveGamepadSettings],
@@ -196,10 +304,10 @@ export default function GamepadMappingSettings() {
(patch) => { (patch) => {
saveGamepadSettings((prev) => { saveGamepadSettings((prev) => {
const current = prev ?? GAMEPAD_SETTINGS_DEFAULTS; const current = prev ?? GAMEPAD_SETTINGS_DEFAULTS;
const baseProfile = const storedProfile =
(activeSignature && current.profiles?.[activeSignature]) ?? (activeSignature && current.profiles?.[activeSignature]) ??
current?.defaults?.profile ?? current?.defaults?.profile;
GAMEPAD_PROFILE_DEFAULT; const baseProfile = resolveGamepadProfile(storedProfile, GAMEPAD_PROFILE_DEFAULT);
const nextProfile = { const nextProfile = {
...baseProfile, ...baseProfile,
calibration: { calibration: {
@@ -228,14 +336,48 @@ export default function GamepadMappingSettings() {
[activeSignature, saveGamepadSettings], [activeSignature, saveGamepadSettings],
); );
const updateProfile = useCallback(
(patch) => {
saveGamepadSettings((prev) => {
const current = prev ?? GAMEPAD_SETTINGS_DEFAULTS;
const storedProfile =
(activeSignature && current.profiles?.[activeSignature]) ??
current?.defaults?.profile;
const nextProfile = {
...resolveGamepadProfile(storedProfile, GAMEPAD_PROFILE_DEFAULT),
...patch,
};
if (!activeSignature) {
return {
...current,
defaults: { ...(current.defaults ?? {}), profile: nextProfile },
};
}
return {
...current,
profiles: { ...(current.profiles ?? {}), [activeSignature]: nextProfile },
};
});
},
[activeSignature, saveGamepadSettings],
);
const resetActiveProfile = useCallback(() => {
/* Resetting only the selected hardware avoids erasing carefully tuned profiles for other
controllers. Device metadata is rebuilt so the profile remains recognizable offline. */
const nextProfile = createProfileForPad(activePad, GAMEPAD_PROFILE_DEFAULT);
updateProfile(nextProfile);
setCaptureAction(null);
}, [activePad, updateProfile]);
const updateBinding = useCallback( const updateBinding = useCallback(
(actionId, updater) => { (actionId, updater) => {
saveGamepadSettings((prev) => { saveGamepadSettings((prev) => {
const current = prev ?? GAMEPAD_SETTINGS_DEFAULTS; const current = prev ?? GAMEPAD_SETTINGS_DEFAULTS;
const baseProfile = const storedProfile =
(activeSignature && current.profiles?.[activeSignature]) ?? (activeSignature && current.profiles?.[activeSignature]) ??
current?.defaults?.profile ?? current?.defaults?.profile;
GAMEPAD_PROFILE_DEFAULT; const baseProfile = resolveGamepadProfile(storedProfile, GAMEPAD_PROFILE_DEFAULT);
const nextBinding = updater(baseProfile.bindings?.[actionId] ?? {}); const nextBinding = updater(baseProfile.bindings?.[actionId] ?? {});
const nextProfile = { const nextProfile = {
...baseProfile, ...baseProfile,
@@ -300,39 +442,74 @@ export default function GamepadMappingSettings() {
const outputs = computeGamepadOutputs(activePad, activeProfile); const outputs = computeGamepadOutputs(activePad, activeProfile);
return { outputs }; return { outputs };
}, [activePad, activeProfile]); }, [activePad, activeProfile]);
const controllerDescription = useMemo(() => describeController(activePad), [activePad]);
const liveValueForAction = useCallback((actionId) => {
const outputs = diagnostics?.outputs;
if (!outputs) return false;
if (actionId === 'drive') return Math.hypot(outputs.driveVector.x, outputs.driveVector.y) > 0.01;
if (actionId === 'tankLeft') return Math.abs(outputs.tankTracks?.left ?? 0) > 0.01;
if (actionId === 'tankRight') return Math.abs(outputs.tankTracks?.right ?? 0) > 0.01;
if (actionId === 'tankCameraUp' || actionId === 'tankCameraDown') {
return Boolean(outputs.buttons[actionId]);
}
if (actionId === 'cameraTilt') return Math.abs(outputs.cameraAxis) > 0.01;
if (actionId === 'mainBrush') return Math.abs(outputs.auxAxis.main) > 0.01;
if (actionId === 'sideBrush') return Math.abs(outputs.auxAxis.side) > 0.01;
return Boolean(outputs.buttons[actionId]);
}, [diagnostics]);
return ( return (
<CardFrame <CardFrame
title="Controller" title="Controller"
meta={activePad ? 'Move sticks or press buttons to bind' : 'Connect a controller to configure.'} meta={activePad ? 'Move sticks or press buttons to bind' : 'Connect a controller to configure.'}
actions={
<button type="button" disabled={!activePad} onClick={resetActiveProfile} className="button-dark px-1 py-0.5 text-xs disabled:opacity-50">
Reset profile
</button>
}
bodyClassName="space-y-2 p-1 text-sm" bodyClassName="space-y-2 p-1 text-sm"
> >
{captureAction && ( {captureAction && (
<p className="mx-auto w-full max-w-lg rounded bg-emerald-950/50 px-1.5 py-1 text-sm text-white"> <div className="mx-auto flex w-full max-w-lg items-center justify-between gap-1 rounded bg-emerald-950/50 px-1.5 py-1 text-sm text-white">
Capturing {captureAction.label}... <span>Release controls, then move or press the input for {captureAction.label}.</span>
</p> <button type="button" onClick={() => setCaptureAction(null)} className="button-dark px-1 py-0.5 text-xs">
Cancel
</button>
</div>
)} )}
<div className="space-y-1"> <div className="space-y-1">
<SettingsGroupLabel>Connected controller</SettingsGroupLabel> <SettingsGroupLabel>Connected controller</SettingsGroupLabel>
{hubState.pads.length === 0 ? ( {hubState.error ? (
<p className="mx-auto w-full max-w-lg text-sm text-white">No controller detected.</p> <p className="mx-auto w-full max-w-lg rounded border border-red-500/60 bg-red-950/40 px-1.5 py-1 text-sm text-white">
Controller access failed: {hubState.error}
</p>
) : hubState.pads.length === 0 ? (
<p className="mx-auto w-full max-w-lg text-sm text-white">
{hubState.supported === false
? 'This browser does not support controllers.'
: 'No controller detected. Connect it, focus this page, then press a button.'}
</p>
) : ( ) : (
<div className="mx-auto grid w-full max-w-lg grid-cols-[minmax(0,1fr)_auto] items-center gap-1.5 rounded bg-neutral-800/80 px-1.5 py-1 text-sm max-[420px]:grid-cols-1"> <div className="mx-auto grid w-full max-w-lg grid-cols-[minmax(0,1fr)_auto] items-center gap-1.5 rounded bg-neutral-800/80 px-1.5 py-1 text-sm max-[420px]:grid-cols-1">
<select <select
value={activeSignature ?? ''} value={activePad?.instanceKey ?? ''}
onChange={(event) => setActiveSignature(event.target.value)} onChange={(event) => setActiveInstanceKey(event.target.value)}
className="min-w-0 rounded border border-neutral-600 bg-neutral-900 px-1 py-0.5 text-sm text-white" className="min-w-0 rounded border border-neutral-600 bg-neutral-900 px-1 py-0.5 text-sm text-white"
> >
{hubState.pads.map((pad) => ( {hubState.pads.map((pad) => (
<option key={pad.signature} value={pad.signature}> <option key={pad.instanceKey} value={pad.instanceKey}>
{pad.id || 'Unknown controller'} {pad.id || 'Unknown controller'} (slot {pad.index + 1})
</option> </option>
))} ))}
</select> </select>
<span className="rounded bg-neutral-900 px-1 py-0.5 text-xs text-white"> <span className="rounded bg-neutral-900 px-1 py-0.5 text-xs text-white">
{activePad?.mapping ?? 'unknown'} {activePad?.mapping ?? 'unknown'}
</span> </span>
<p className="col-span-full truncate text-xs text-slate-300" title={activePad?.id}>
{controllerDescription.description ?? activePad?.id}
</p>
</div> </div>
)} )}
</div> </div>
@@ -342,6 +519,42 @@ export default function GamepadMappingSettings() {
{/* Calibration controls stay in one stacked column because range inputs become harder to {/* Calibration controls stay in one stacked column because range inputs become harder to
tune when squeezed into multiple narrow columns. */} tune when squeezed into multiple narrow columns. */}
<div className="grid gap-1"> <div className="grid gap-1">
<label className="mx-auto block w-full max-w-lg rounded bg-neutral-800/80 px-1.5 py-1">
<div className="grid grid-cols-[minmax(0,1fr)_auto] items-center gap-1.5 text-sm text-white">
<span className="font-semibold">Button prompts</span>
<select
value={activeProfile.promptStyle ?? 'auto'}
onChange={(event) => updateProfile({ promptStyle: event.target.value })}
className="rounded border border-neutral-600 bg-neutral-900 px-1 py-0.5 text-sm text-white"
>
<option value="auto">Automatic</option>
<option value="xbox">Xbox</option>
<option value="playstation">PlayStation</option>
<option value="switch">Nintendo</option>
<option value="standard">Generic</option>
</select>
</div>
<p className="mt-0.5 text-xs leading-snug text-white">Override this only when the browser reports the controller incorrectly.</p>
</label>
<label className="mx-auto block w-full max-w-lg rounded bg-neutral-800/80 px-1.5 py-1">
<div className="grid grid-cols-[minmax(0,1fr)_auto] items-start gap-1.5 text-sm text-white">
<span className="min-w-0 font-semibold text-white">Steering mode</span>
<select
value={driveMode}
onChange={(event) => {
updateCalibration({ driveMode: event.target.value });
setCaptureAction(null);
}}
className="rounded border border-neutral-600 bg-neutral-900 px-1 py-0.5 text-sm text-white"
>
<option value="single">Single stick</option>
<option value="tank">Tank sticks</option>
</select>
</div>
<p className="mt-0.5 text-xs leading-snug text-white">
Tank mode controls the left and right wheels with separate stick axes.
</p>
</label>
<SliderField <SliderField
label="Drive deadzone" label="Drive deadzone"
description="Ignore small drive stick drift" description="Ignore small drive stick drift"
@@ -351,15 +564,50 @@ export default function GamepadMappingSettings() {
value={activeProfile.calibration?.driveDeadzone ?? 0.18} value={activeProfile.calibration?.driveDeadzone ?? 0.18}
onChange={(value) => updateCalibration({ driveDeadzone: value })} onChange={(value) => updateCalibration({ driveDeadzone: value })}
/> />
<SliderField <CurveField
label="Camera deadzone" label="Drive response"
description="Ignore small camera tilt drift" value={activeProfile.calibration?.driveCurve ?? 'linear'}
min={0} onChange={(value) => updateCalibration({ driveCurve: value })}
max={0.4}
step={0.01}
value={activeProfile.calibration?.cameraDeadzone ?? 0.08}
onChange={(value) => updateCalibration({ cameraDeadzone: value })}
/> />
<SliderField
label="Full-stick speed"
description="Maximum wheel output at full stick"
min={50}
max={500}
step={10}
value={activeProfile.calibration?.baseSpeed ?? 500}
onChange={(value) => updateCalibration({ baseSpeed: value })}
/>
<SliderField
label="Turbo drive speed"
description="Maximum output while holding the turbo modifier"
min={50}
max={500}
step={10}
value={activeProfile.calibration?.turboSpeed ?? 500}
onChange={(value) => updateCalibration({ turboSpeed: value })}
/>
{driveMode === 'single' && (
<>
{/* Analog-only settings are hidden in tank mode because its two D-pad camera
directions are digital velocity inputs. The values remain saved for when the
operator returns to single-stick steering. */}
<SliderField
label="Velocity camera deadzone"
description="Absolute mode always uses a 0.01 deadzone"
min={0}
max={0.4}
step={0.01}
value={activeProfile.calibration?.cameraDeadzone ?? 0.08}
onChange={(value) => updateCalibration({ cameraDeadzone: value })}
/>
<CurveField
label="Camera response"
value={activeProfile.calibration?.cameraCurve ?? 'linear'}
onChange={(value) => updateCalibration({ cameraCurve: value })}
/>
</>
)}
<SliderField <SliderField
label="Aux deadzone" label="Aux deadzone"
description="Ignore small trigger noise" description="Ignore small trigger noise"
@@ -369,6 +617,11 @@ export default function GamepadMappingSettings() {
value={activeProfile.calibration?.auxDeadzone ?? 0.05} value={activeProfile.calibration?.auxDeadzone ?? 0.05}
onChange={(value) => updateCalibration({ auxDeadzone: value })} onChange={(value) => updateCalibration({ auxDeadzone: value })}
/> />
<CurveField
label="Brush response"
value={activeProfile.calibration?.auxCurve ?? 'linear'}
onChange={(value) => updateCalibration({ auxCurve: value })}
/>
<SliderField <SliderField
label="Side brush scale" label="Side brush scale"
description="Scale side brush output" description="Scale side brush output"
@@ -378,22 +631,33 @@ export default function GamepadMappingSettings() {
value={activeProfile.calibration?.auxSideScale ?? 0.55} value={activeProfile.calibration?.auxSideScale ?? 0.55}
onChange={(value) => updateCalibration({ auxSideScale: value })} onChange={(value) => updateCalibration({ auxSideScale: value })}
/> />
<label className="mx-auto block w-full max-w-lg rounded bg-neutral-800/80 px-1.5 py-1"> <SliderField
{/* Camera mode is styled like the sliders so calibration controls read as one group label="Precision speed"
even though this specific setting is a select instead of a range input. */} description="Maximum drive speed while holding the precision modifier"
<div className="grid grid-cols-[minmax(0,1fr)_auto] items-start gap-1.5 text-sm text-white"> min={20}
<span className="min-w-0 font-semibold text-white">Camera mode</span> max={250}
<select step={5}
value={activeProfile.calibration?.cameraMode ?? 'absolute'} value={activeProfile.calibration?.precisionSpeed ?? 100}
onChange={(event) => updateCalibration({ cameraMode: event.target.value })} onChange={(value) => updateCalibration({ precisionSpeed: value })}
className="rounded border border-neutral-600 bg-neutral-900 px-1 py-0.5 text-sm text-white" />
> {driveMode === 'single' && (
<option value="absolute">Absolute</option> <label className="mx-auto block w-full max-w-lg rounded bg-neutral-800/80 px-1.5 py-1">
<option value="velocity">Velocity</option> {/* Camera mode is styled like the sliders so calibration controls read as one group
</select> even though this specific setting is a select instead of a range input. */}
</div> <div className="grid grid-cols-[minmax(0,1fr)_auto] items-start gap-1.5 text-sm text-white">
<p className="mt-0.5 text-xs leading-snug text-white">Absolute maps stick to angle; velocity moves over time.</p> <span className="min-w-0 font-semibold text-white">Camera mode</span>
</label> <select
value={activeProfile.calibration?.cameraMode ?? 'velocity'}
onChange={(event) => updateCalibration({ cameraMode: event.target.value })}
className="rounded border border-neutral-600 bg-neutral-900 px-1 py-0.5 text-sm text-white"
>
<option value="absolute">Absolute</option>
<option value="velocity">Velocity</option>
</select>
</div>
<p className="mt-0.5 text-xs leading-snug text-white">Absolute maps stick to angle; velocity moves over time.</p>
</label>
)}
<SliderField <SliderField
label="Camera sensitivity" label="Camera sensitivity"
description="Velocity mode degrees per second" description="Velocity mode degrees per second"
@@ -407,6 +671,16 @@ export default function GamepadMappingSettings() {
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<label className="mx-auto block w-full max-w-lg">
<span className="sr-only">Filter controller actions</span>
<input
type="search"
value={actionFilter}
onChange={(event) => setActionFilter(event.target.value)}
placeholder="Find a controller action"
className="field-input w-full px-1.5 py-1 text-sm"
/>
</label>
{Object.entries(grouped).map(([section, actions]) => ( {Object.entries(grouped).map(([section, actions]) => (
<div key={section} className="space-y-1"> <div key={section} className="space-y-1">
<SettingsGroupLabel>{section}</SettingsGroupLabel> <SettingsGroupLabel>{section}</SettingsGroupLabel>
@@ -421,10 +695,13 @@ export default function GamepadMappingSettings() {
key={action.id} key={action.id}
action={action} action={action}
source={source} source={source}
sourceLabel={formatControllerBinding(activeProfile, action.id, activePad)}
liveValue={liveValueForAction(action.id)}
isCapturing={captureAction?.id === action.id} isCapturing={captureAction?.id === action.id}
onClear={handleClear} onClear={handleClear}
onCapture={setCaptureAction} onCapture={setCaptureAction}
onInvert={handleInvert} onInvert={handleInvert}
disabled={!activePad}
/> />
); );
})} })}
@@ -434,11 +711,12 @@ export default function GamepadMappingSettings() {
</div> </div>
<div className="space-y-1"> <div className="space-y-1">
<SettingsGroupLabel>Diagnostics</SettingsGroupLabel>
{!activePad ? ( {!activePad ? (
<p className="mx-auto w-full max-w-lg text-sm text-white">No controller detected.</p> null
) : ( ) : (
<div className="mx-auto w-full max-w-lg space-y-1 rounded bg-neutral-900/70 px-1.5 py-1 text-xs text-white"> <details className="mx-auto w-full max-w-lg rounded bg-neutral-900/70 px-1.5 py-1 text-xs text-white">
<summary className="cursor-pointer text-sm font-semibold text-white">Advanced diagnostics</summary>
<div className="mt-1 space-y-1">
<p className="text-white">Raw axes</p> <p className="text-white">Raw axes</p>
<div className="grid grid-cols-2 gap-1"> <div className="grid grid-cols-2 gap-1">
{activePad.axes.map((value, index) => ( {activePad.axes.map((value, index) => (
@@ -475,7 +753,8 @@ export default function GamepadMappingSettings() {
</div> </div>
</> </>
)} )}
</div> </div>
</details>
)} )}
</div> </div>
</CardFrame> </CardFrame>
@@ -10,6 +10,23 @@ export const ACTIONS = [
kind: 'axisPair', kind: 'axisPair',
section: 'Driving', section: 'Driving',
invertDefaults: { invertX: false, invertY: true }, invertDefaults: { invertX: false, invertY: true },
driveMode: 'single',
},
{
id: 'tankLeft',
label: 'Left track',
kind: 'axis',
section: 'Driving',
invertDefaults: { invert: true },
driveMode: 'tank',
},
{
id: 'tankRight',
label: 'Right track',
kind: 'axis',
section: 'Driving',
invertDefaults: { invert: true },
driveMode: 'tank',
}, },
{ {
id: 'cameraTilt', id: 'cameraTilt',
@@ -17,7 +34,10 @@ export const ACTIONS = [
kind: 'axis', kind: 'axis',
section: 'Camera', section: 'Camera',
invertDefaults: { invert: true }, invertDefaults: { invert: true },
driveMode: 'single',
}, },
{ id: 'tankCameraUp', label: 'Camera up', kind: 'button', section: 'Camera', driveMode: 'tank' },
{ id: 'tankCameraDown', label: 'Camera down', kind: 'button', section: 'Camera', driveMode: 'tank' },
{ {
id: 'mainBrush', id: 'mainBrush',
label: 'Main brush', label: 'Main brush',
@@ -32,14 +52,33 @@ export const ACTIONS = [
section: 'Brushes', section: 'Brushes',
invertDefaults: { invert: false }, invertDefaults: { invert: false },
}, },
{ id: 'vacuum', label: 'Vacuum', kind: 'button', section: 'Aux buttons' }, { id: 'vacuum', label: 'Vacuum only', kind: 'button', section: 'Aux buttons' },
{ id: 'allAux', label: 'All aux', kind: 'button', section: 'Aux buttons' }, { id: 'allAux', label: 'All cleaning motors', kind: 'button', section: 'Aux buttons' },
{ id: 'mainReverse', label: 'Main reverse toggle', kind: 'button', section: 'Brush toggles' }, { id: 'mainReverse', label: 'Main reverse toggle', kind: 'button', section: 'Brush toggles' },
{ id: 'sideReverse', label: 'Side reverse toggle', kind: 'button', section: 'Brush toggles' }, { id: 'sideReverse', label: 'Side reverse toggle', kind: 'button', section: 'Brush toggles' },
{ id: 'driveMacro', label: 'Drive macro', kind: 'button', section: 'Mode macros' }, { id: 'driveMacro', label: 'Drive / undock sequence', kind: 'button', section: 'Mode controls' },
{ id: 'dockMacro', label: 'Dock macro', kind: 'button', section: 'Mode macros' }, { id: 'dockMacro', label: 'Manual docking assist', kind: 'button', section: 'Mode controls' },
{ id: 'headlightToggle', label: 'Headlight toggle', kind: 'button', section: 'Camera' }, { id: 'headlightToggle', label: 'Headlight toggle', kind: 'button', section: 'Camera' },
{ id: 'laserToggle', label: 'Laser toggle', kind: 'button', section: 'Camera' }, { id: 'laserToggle', label: 'Laser toggle', kind: 'button', section: 'Camera' },
{ id: 'boostModifier', label: 'Turbo modifier', kind: 'button', section: 'Driving' },
{ id: 'slowModifier', label: 'Precision modifier', kind: 'button', section: 'Driving' },
{ id: 'hornHonk', label: 'Horn (hold)', kind: 'button', section: 'Audio and chat' },
{ id: 'micPtt', label: 'Microphone push to talk', kind: 'button', section: 'Audio and chat' },
{ id: 'chatFocus', label: 'Focus chat', kind: 'button', section: 'Audio and chat' },
{ id: 'videoFilterCycle', label: 'Cycle video filter', kind: 'button', section: 'Camera' },
{ id: 'songNoteUp', label: 'Play higher note', kind: 'button', section: 'Audio and chat', driveMode: 'single' },
{ id: 'songNoteDown', label: 'Play lower note', kind: 'button', section: 'Audio and chat', driveMode: 'single' },
{ id: 'homeAssistantOn', label: 'Turn next room control on', kind: 'button', section: 'Room controls' },
{ id: 'homeAssistantOff', label: 'Turn next room control off', kind: 'button', section: 'Room controls' },
/* Digital aux actions provide exact parity with the keyboard help surface. They coexist with
analog brush controls so each operator can choose proportional triggers or discrete buttons. */
{ id: 'auxMainForward', label: 'Main brush forward', kind: 'button', section: 'Aux buttons' },
{ id: 'auxMainReverse', label: 'Main brush reverse', kind: 'button', section: 'Aux buttons' },
{ id: 'auxSideForward', label: 'Side brush forward', kind: 'button', section: 'Aux buttons' },
{ id: 'auxSideReverse', label: 'Side brush reverse', kind: 'button', section: 'Aux buttons' },
{ id: 'auxVacuumFast', label: 'Vacuum max', kind: 'button', section: 'Aux buttons' },
{ id: 'auxVacuumSlow', label: 'Vacuum low', kind: 'button', section: 'Aux buttons' },
{ id: 'auxAllForward', label: 'All motors forward', kind: 'button', section: 'Aux buttons' },
]; ];
export const CAPTURE_AXIS_THRESHOLD = 0.45; export const CAPTURE_AXIS_THRESHOLD = 0.45;
@@ -33,10 +33,10 @@ export function groupActions(actions) {
}, {}); }, {});
} }
export function pickActivePad(pads, activeSignature) { export function pickActivePad(pads, activeInstanceKey) {
if (!pads || pads.length === 0) return null; if (!pads || pads.length === 0) return null;
if (activeSignature) { if (activeInstanceKey) {
const match = pads.find((pad) => pad.signature === activeSignature); const match = pads.find((pad) => pad.instanceKey === activeInstanceKey);
if (match) return match; if (match) return match;
} }
return pads[0]; return pads[0];
@@ -62,10 +62,13 @@ function detectAxisCapture(pad, baseline, action) {
if (action.kind === 'axisPair') { if (action.kind === 'axisPair') {
const top = deltas.filter((entry) => entry.delta > CAPTURE_AXIS_THRESHOLD).slice(0, 2); const top = deltas.filter((entry) => entry.delta > CAPTURE_AXIS_THRESHOLD).slice(0, 2);
if (top.length < 2) return null; if (top.length < 2) return null;
const orderedIndices = top.map((entry) => entry.index).sort((a, b) => a - b);
return { return {
kind: 'axisPair', kind: 'axisPair',
x: top[0].index, // Browsers expose two-dimensional controls as adjacent X/Y axes. Sorting the captured pair
y: top[1].index, // prevents whichever direction moved first from randomly swapping steering and throttle.
x: orderedIndices[0],
y: orderedIndices[1],
...(action.invertDefaults ?? {}), ...(action.invertDefaults ?? {}),
}; };
} }
@@ -80,16 +83,25 @@ function detectAxisCapture(pad, baseline, action) {
function detectButtonCapture(pad, baseline, action) { function detectButtonCapture(pad, baseline, action) {
const buttons = pad.buttons ?? []; const buttons = pad.buttons ?? [];
const newlyPressed = [];
for (let i = 0; i < buttons.length; i += 1) { for (let i = 0; i < buttons.length; i += 1) {
const btn = buttons[i]; const btn = buttons[i];
const value = typeof btn?.value === 'number' ? btn.value : btn?.pressed ? 1 : 0; const value = typeof btn?.value === 'number' ? btn.value : btn?.pressed ? 1 : 0;
if (btn?.pressed || value > CAPTURE_BUTTON_THRESHOLD) { const baselineValue = baseline.buttons?.[i]?.value ?? 0;
const baselinePressed = baseline.buttons?.[i]?.pressed ?? false;
if (!baselinePressed && (btn?.pressed || value - baselineValue > CAPTURE_BUTTON_THRESHOLD)) {
if (action.kind === 'axis') { if (action.kind === 'axis') {
return { kind: 'buttonAxis', index: i }; return { kind: 'buttonAxis', index: i };
} }
return { kind: 'button', index: i }; newlyPressed.push({ kind: 'button', index: i });
} }
} }
if (newlyPressed.length > 1) {
// Capturing all buttons observed in the same frame makes intentional modifier chords possible
// without a separate advanced editor, while a normal single press keeps the compact shape.
return { kind: 'chord', inputs: newlyPressed };
}
if (newlyPressed.length === 1) return newlyPressed[0];
const axes = pad.axes ?? []; const axes = pad.axes ?? [];
for (let i = 0; i < axes.length; i += 1) { for (let i = 0; i < axes.length; i += 1) {
const value = axes[i] ?? 0; const value = axes[i] ?? 0;
@@ -0,0 +1,49 @@
// Controller Capture Tests
// Purpose: Verifies that binding capture cannot select held controls or swap stick axes randomly.
// Scope: Covers the pure capture detector used by the Controller settings surface.
import assert from 'node:assert/strict';
import test from 'node:test';
import { buildDescriptorFromCapture, snapshotBaseline } from './helpers.js';
function button(pressed = false, value = pressed ? 1 : 0) {
return { pressed, value };
}
test('a button held before capture is ignored', () => {
const baselinePad = { axes: [0, 0], buttons: [button(true), button(false)] };
const currentPad = { axes: [0, 0], buttons: [button(true), button(false)] };
const descriptor = buildDescriptorFromCapture(
currentPad,
snapshotBaseline(baselinePad),
{ kind: 'button' },
);
assert.equal(descriptor, null);
});
test('axis-pair capture assigns the lower adjacent axis to X regardless of movement order', () => {
const baseline = snapshotBaseline({ axes: [0, 0, 0, 0], buttons: [] });
const descriptor = buildDescriptorFromCapture(
{ axes: [0, 0, -0.7, 0.9], buttons: [] },
baseline,
{ kind: 'axisPair', invertDefaults: { invertY: true } },
);
assert.deepEqual(descriptor, {
kind: 'axisPair',
x: 2,
y: 3,
invertY: true,
});
});
test('simultaneous new buttons are represented as a chord', () => {
const baseline = snapshotBaseline({ axes: [], buttons: [button(), button(), button()] });
const descriptor = buildDescriptorFromCapture(
{ axes: [], buttons: [button(true), button(), button(true)] },
baseline,
{ kind: 'button' },
);
assert.deepEqual(descriptor, {
kind: 'chord',
inputs: [{ kind: 'button', index: 0 }, { kind: 'button', index: 2 }],
});
});
+12 -6
View File
@@ -2,14 +2,14 @@
// Purpose: Defines the Help Content View module and the local helpers/components used in this file. // Purpose: Defines the Help Content View module and the local helpers/components used in this file.
// Scope: Keeps behavior unchanged while isolating this concern into a clear, single-responsibility unit. // Scope: Keeps behavior unchanged while isolating this concern into a clear, single-responsibility unit.
import { useMemo } from 'react'; import { useMemo } from 'react';
import { formatKeyLabel } from '../../controls/keymapUtils.js'; import ControlHint from '../ControlHint/index.jsx';
import { useControllerRuntime } from '../../controls/inputs/controllerRuntime.js';
import { getHelpContent } from '../../help/content.js'; import { getHelpContent } from '../../help/content.js';
function KeyPill({ actionId, keymap }) { function KeyPill({ actionId }) {
const value = keymap?.[actionId]?.[0] ?? '';
return ( return (
<span className="rounded border border-slate-600 bg-slate-900/40 px-1 text-[0.7rem] text-slate-200"> <span className="rounded border border-slate-600 bg-slate-900/40 px-1 text-[0.7rem] text-slate-200">
{formatKeyLabel(value)} <ControlHint actionId={actionId} />
</span> </span>
); );
} }
@@ -130,14 +130,20 @@ function KeyboardGroup({ group, keymap }) {
} }
function KeyboardBlock({ block, keymap }) { function KeyboardBlock({ block, keymap }) {
const runtime = useControllerRuntime();
if (!block) return null; if (!block) return null;
const usingController = runtime.inputMethod === 'controller';
return ( return (
<div className="space-y-0.5"> <div className="space-y-0.5">
{/* Heading and footnote share a row when possible and wrap independently {/* Heading and footnote share a row when possible and wrap independently
when the Help card is mounted in a narrow desktop column. */} when the Help card is mounted in a narrow desktop column. */}
<div className="flex flex-wrap items-center justify-between gap-0.5 text-xs text-slate-200"> <div className="flex flex-wrap items-center justify-between gap-0.5 text-xs text-slate-200">
<span className="font-semibold">{block.title}</span> <span className="font-semibold">{usingController ? 'Controller controls' : block.title}</span>
{block.footnote && <span className="text-[0.7rem] text-slate-400">{block.footnote}</span>} <span className="text-[0.7rem] text-slate-400">
{usingController
? 'Per-controller; adjust bindings in Settings → Controller.'
: block.footnote}
</span>
</div> </div>
{/* Two keyboard groups fit comfortably once the Help surface reaches 32rem. {/* Two keyboard groups fit comfortably once the Help surface reaches 32rem.
Using the real content threshold restores the established old-page layout Using the real content threshold restores the established old-page layout
@@ -3,8 +3,7 @@
// Scope: Keeps behavior unchanged while isolating this concern into a clear, single-responsibility unit. // Scope: Keeps behavior unchanged while isolating this concern into a clear, single-responsibility unit.
import { useMemo } from 'react'; import { useMemo } from 'react';
import { useSessionActions, useSessionSelector } from '../../context/SessionContext.jsx'; import { useSessionActions, useSessionSelector } from '../../context/SessionContext.jsx';
import { useControlSelector } from '../../controls/index.js'; import ControlHint from '../ControlHint/index.jsx';
import { formatKeyLabel } from '../../controls/keymapUtils.js';
import CardFrame from '../CardFrame/index.jsx'; import CardFrame from '../CardFrame/index.jsx';
import { isFeatureEnabled } from '../../lib/features.js'; import { isFeatureEnabled } from '../../lib/features.js';
@@ -184,7 +183,6 @@ export default function HomeAssistantControls() {
} }
function HomeAssistantControlsContent() { function HomeAssistantControlsContent() {
const keymap = useControlSelector((control) => control.state.keymap);
const ha = useSessionSelector((state) => state.session?.homeAssistant || null); const ha = useSessionSelector((state) => state.session?.homeAssistant || null);
const { homeAssistantToggle, homeAssistantSetLightColor, homeAssistantSetLightWhite } = const { homeAssistantToggle, homeAssistantSetLightColor, homeAssistantSetLightWhite } =
useSessionActions(); useSessionActions();
@@ -196,8 +194,8 @@ function HomeAssistantControlsContent() {
const lightPolicyLocked = Boolean(lightPolicy?.locked || lightPolicy?.lockedOn); const lightPolicyLocked = Boolean(lightPolicy?.locked || lightPolicy?.lockedOn);
const controlsLocked = lightPolicyLocked && !adminCanControlLockedLights; const controlsLocked = lightPolicyLocked && !adminCanControlLockedLights;
const lockState = lightPolicy?.lockState || (lightPolicy?.lockedOn ? 'on' : null); const lockState = lightPolicy?.lockState || (lightPolicy?.lockedOn ? 'on' : null);
const onKeyLabel = formatKeyLabel(keymap?.homeAssistantOn?.[0]); const onKeyLabel = <ControlHint actionId="homeAssistantOn" />;
const offKeyLabel = formatKeyLabel(keymap?.homeAssistantOff?.[0]); const offKeyLabel = <ControlHint actionId="homeAssistantOff" />;
if (!ha?.enabled) { if (!ha?.enabled) {
return ( return (
@@ -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>
);
}
@@ -3,7 +3,7 @@
import { useRef } from 'react'; import { useRef } from 'react';
import { FaBullhorn, FaCrosshairs, FaLightbulb } from 'react-icons/fa'; import { FaBullhorn, FaCrosshairs, FaLightbulb } from 'react-icons/fa';
import { useControlActions, useControlSelector } from '../../../../controls/index.js'; import { useControlActions, useControlSelector } from '../../../../controls/index.js';
import { formatKeyLabel } from '../../../../controls/keymapUtils.js'; import ControlHint from '../../../ControlHint/index.jsx';
import { useSessionSelector } from '../../../../context/SessionContext.jsx'; import { useSessionSelector } from '../../../../context/SessionContext.jsx';
import useCanControlRover from '../../../../hooks/useCanControlRover.js'; import useCanControlRover from '../../../../hooks/useCanControlRover.js';
import KeyPill from '../../../vip/VipAudioUploadCard/KeyPill.jsx'; import KeyPill from '../../../vip/VipAudioUploadCard/KeyPill.jsx';
@@ -45,7 +45,6 @@ export default function BottomLeftPod({ roverId }) {
const headlightOn = useControlSelector((control) => Boolean(control.pipeline?.headlightState?.headlightOn)); const headlightOn = useControlSelector((control) => Boolean(control.pipeline?.headlightState?.headlightOn));
const laserOn = useControlSelector((control) => Boolean(control.pipeline?.laserState?.laserOn)); const laserOn = useControlSelector((control) => Boolean(control.pipeline?.laserState?.laserOn));
const hornActive = useControlSelector((control) => Boolean(control.state.horn?.active)); const hornActive = useControlSelector((control) => Boolean(control.state.horn?.active));
const keymap = useControlSelector((control) => control.state.keymap);
const { setHeadlight, setLaser, startHorn, stopHorn } = useControlActions(); const { setHeadlight, setLaser, startHorn, stopHorn } = useControlActions();
const canControl = useCanControlRover(roverId); const canControl = useCanControlRover(roverId);
const hornPointerRef = useRef(null); const hornPointerRef = useRef(null);
@@ -80,13 +79,13 @@ export default function BottomLeftPod({ roverId }) {
{/* Physical rover actions become visibly and behaviorally unavailable {/* Physical rover actions become visibly and behaviorally unavailable
while another queued driver owns the turn. Pod/settings controls while another queued driver owns the turn. Pod/settings controls
remain interactive because they do not mutate rover hardware. */} remain interactive because they do not mutate rover hardware. */}
{hornDevice ? <RoundControl label="Horn" icon={FaBullhorn} keyLabel={formatKeyLabel(keymap?.hornHonk?.[0])} active={hornActive} tone="horn" disabled={!canControl} large onPointerDown={startHornPointer} onPointerUp={stopHornPointer} className="absolute bottom-1 left-1" /> : null} {hornDevice ? <RoundControl label="Horn" icon={FaBullhorn} keyLabel={<ControlHint actionId="hornHonk" />} active={hornActive} tone="horn" disabled={!canControl} large onPointerDown={startHornPointer} onPointerUp={stopHornPointer} className="absolute bottom-1 left-1" /> : null}
{headlight ? <RoundControl label="Headlight" icon={FaLightbulb} keyLabel={formatKeyLabel(keymap?.headlightToggle?.[0])} active={headlightOn} disabled={!canControl} onClick={() => setHeadlight(!headlightOn)} className="absolute left-[1.979rem] top-[0.662rem]" /> : null} {headlight ? <RoundControl label="Headlight" icon={FaLightbulb} keyLabel={<ControlHint actionId="headlightToggle" />} active={headlightOn} disabled={!canControl} onClick={() => setHeadlight(!headlightOn)} className="absolute left-[1.979rem] top-[0.662rem]" /> : null}
{/* The room-light lock deliberately blocks laser activation because {/* The room-light lock deliberately blocks laser activation because
the laser is only intended for use while the room is dark. This the laser is only intended for use while the room is dark. This
mirrors the old desktop control's visible disabled state; turn mirrors the old desktop control's visible disabled state; turn
ownership remains the other independent control restriction. */} ownership remains the other independent control restriction. */}
{laser ? <RoundControl label="Laser" icon={FaCrosshairs} keyLabel={formatKeyLabel(keymap?.laserToggle?.[0])} active={laserOn} disabled={!canControl || roomLightsLockedOn} onClick={() => setLaser(!laserOn)} className="absolute left-[5.338rem] top-[4.021rem]" /> : null} {laser ? <RoundControl label="Laser" icon={FaCrosshairs} keyLabel={<ControlHint actionId="laserToggle" />} active={laserOn} disabled={!canControl || roomLightsLockedOn} onClick={() => setLaser(!laserOn)} className="absolute left-[5.338rem] top-[4.021rem]" /> : null}
<CornerPodToggle corner="bottom-left" expanded label="Hide rover controls" onClick={() => setOpen(false)} /> <CornerPodToggle corner="bottom-left" expanded label="Hide rover controls" onClick={() => setOpen(false)} />
</div> </div>
) : ( ) : (
@@ -3,7 +3,7 @@
import { useCallback } from 'react'; import { useCallback } from 'react';
import { FaVideo } from 'react-icons/fa'; import { FaVideo } from 'react-icons/fa';
import { useControlActions, useControlSelector } from '../../../../controls/index.js'; import { useControlActions, useControlSelector } from '../../../../controls/index.js';
import { formatKeyLabel } from '../../../../controls/keymapUtils.js'; import ControlHint from '../../../ControlHint/index.jsx';
import useCanControlRover from '../../../../hooks/useCanControlRover.js'; import useCanControlRover from '../../../../hooks/useCanControlRover.js';
import { useDriverLayout } from '../../../../layouts/driver/DriverLayoutContext.jsx'; import { useDriverLayout } from '../../../../layouts/driver/DriverLayoutContext.jsx';
import KeyPill from '../../../vip/VipAudioUploadCard/KeyPill.jsx'; import KeyPill from '../../../vip/VipAudioUploadCard/KeyPill.jsx';
@@ -32,7 +32,6 @@ export default function BottomRightPod({ roverId }) {
const [open, setOpen] = usePodVisibility('camera', true); const [open, setOpen] = usePodVisibility('camera', true);
const camera = useControlSelector((control) => control.state.camera); const camera = useControlSelector((control) => control.state.camera);
const dockAssistActive = useControlSelector((control) => Boolean(control.state.manualDockAssist?.active)); const dockAssistActive = useControlSelector((control) => Boolean(control.state.manualDockAssist?.active));
const keymap = useControlSelector((control) => control.state.keymap);
const { setServoAngle } = useControlActions(); const { setServoAngle } = useControlActions();
const canControl = useCanControlRover(roverId); const canControl = useCanControlRover(roverId);
const config = camera?.config; const config = camera?.config;
@@ -86,8 +85,8 @@ export default function BottomRightPod({ roverId }) {
</button> </button>
{/* These positions continue around the same circle just beyond the two slider endpoints. {/* These positions continue around the same circle just beyond the two slider endpoints.
Together they occupy the open third facing the corner without enlarging the pod. */} Together they occupy the open third facing the corner without enlarging the pod. */}
<div className="absolute left-[61%] top-[90%] -translate-x-1/2 -translate-y-1/2"><KeyPill label={formatKeyLabel(keymap?.cameraDown?.[0])} /></div> <div className="absolute left-[61%] top-[90%] -translate-x-1/2 -translate-y-1/2"><KeyPill label={<ControlHint actionId="cameraDown" />} /></div>
<div className="absolute left-[90%] top-[61%] -translate-x-1/2 -translate-y-1/2"><KeyPill label={formatKeyLabel(keymap?.cameraUp?.[0])} /></div> <div className="absolute left-[90%] top-[61%] -translate-x-1/2 -translate-y-1/2"><KeyPill label={<ControlHint actionId="cameraUp" />} /></div>
<CornerPodToggle corner="bottom-right" expanded label="Hide camera tilt" onClick={() => setOpen(false)} /> <CornerPodToggle corner="bottom-right" expanded label="Hide camera tilt" onClick={() => setOpen(false)} />
</div> </div>
) : showCameraControls && enabled ? ( ) : showCameraControls && enabled ? (
@@ -4,14 +4,12 @@ import { useCallback, useState } from 'react';
import { FaComment } from 'react-icons/fa'; import { FaComment } from 'react-icons/fa';
import { useChatActions } from '../../../../context/ChatContext.jsx'; import { useChatActions } from '../../../../context/ChatContext.jsx';
import { useSessionSelector } from '../../../../context/SessionContext.jsx'; import { useSessionSelector } from '../../../../context/SessionContext.jsx';
import { useControlSelector } from '../../../../controls/index.js'; import ControlHint from '../../../ControlHint/index.jsx';
import { formatKeyLabel } from '../../../../controls/keymapUtils.js';
import HudChatInput from '../../HudChatInput/index.jsx'; import HudChatInput from '../../HudChatInput/index.jsx';
import KeyPill from '../../../vip/VipAudioUploadCard/KeyPill.jsx'; import KeyPill from '../../../vip/VipAudioUploadCard/KeyPill.jsx';
export default function ChatExpansion({ podOpen }) { export default function ChatExpansion({ podOpen }) {
const role = useSessionSelector((state) => state.session?.role || null); const role = useSessionSelector((state) => state.session?.role || null);
const chatKeyLabel = useControlSelector((control) => formatKeyLabel(control.state.keymap?.chatFocus?.[0]));
const { blurChat, focusChat } = useChatActions(); const { blurChat, focusChat } = useChatActions();
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
@@ -50,7 +48,7 @@ export default function ChatExpansion({ podOpen }) {
<FaComment aria-hidden="true" /> <FaComment aria-hidden="true" />
{/* The pill reflects the live keymap so remapping chat focus updates this {/* The pill reflects the live keymap so remapping chat focus updates this
compact HUD hint without duplicating or hardcoding the default key. */} compact HUD hint without duplicating or hardcoding the default key. */}
{chatKeyLabel ? <KeyPill label={chatKeyLabel} /> : null} <KeyPill label={<ControlHint actionId="chatFocus" />} />
</button> </button>
<HudChatInput variant="newdrive" open={open} onOpenChange={setChatOpen} /> <HudChatInput variant="newdrive" open={open} onOpenChange={setChatOpen} />
@@ -1,7 +1,7 @@
// Top-right Corner Pod // Top-right Corner Pod
// Purpose: Combines the battery/current gauge with a compact attached advanced-power expansion. // Purpose: Combines the battery/current gauge with a compact attached advanced-power expansion.
import { createElement, useMemo } from 'react'; import { createElement, useMemo } from 'react';
import { FaArrowDown, FaArrowUp, FaBatteryHalf, FaBolt, FaExclamationTriangle, FaMemory, FaThermometerHalf, FaWifi } from 'react-icons/fa'; import { FaArrowDown, FaArrowUp, FaBatteryHalf, FaBolt, FaExclamationTriangle, FaMemory, FaMicrochip, FaThermometerHalf, FaWifi } from 'react-icons/fa';
import { useSessionSelector } from '../../../../context/SessionContext.jsx'; import { useSessionSelector } from '../../../../context/SessionContext.jsx';
import { useVisualTelemetrySelector } from '../../../../context/TelemetryContext.jsx'; import { useVisualTelemetrySelector } from '../../../../context/TelemetryContext.jsx';
import { hostStatsEqual, selectHostStats, selectSpectatorTelemetry, spectatorTelemetryEqual } from '../../../../context/telemetryViews.js'; import { hostStatsEqual, selectHostStats, selectSpectatorTelemetry, spectatorTelemetryEqual } from '../../../../context/telemetryViews.js';
@@ -19,6 +19,20 @@ function clampPercent(value) {
return number == null ? 0 : Math.max(0, Math.min(100, number)); return number == null ? 0 : Math.max(0, Math.min(100, number));
} }
function formatMemoryUsage(totalKb, availableKb, usedPercent) {
const total = finite(totalKb);
const available = finite(availableKb);
const percent = finite(usedPercent);
if (total == null || available == null || percent == null) return percent == null ? '--' : `${Math.round(percent)}%`;
const used = Math.max(0, total - available);
const useGigabytes = total >= 1024 * 1024;
const divisor = useGigabytes ? 1024 * 1024 : 1024;
const decimals = useGigabytes ? 1 : 0;
const unit = useGigabytes ? 'GB' : 'MB';
return `${Math.round(percent)}% · ${(used / divisor).toFixed(decimals)}/${(total / divisor).toFixed(decimals)} ${unit}`;
}
function MetricRow({ icon, label, value, percent, iconClass, fillClass }) { function MetricRow({ icon, label, value, percent, iconClass, fillClass }) {
return ( return (
<div className="min-w-0" title={label}> <div className="min-w-0" title={label}>
@@ -27,9 +41,6 @@ function MetricRow({ icon, label, value, percent, iconClass, fillClass }) {
<span className="min-w-0 flex-1 truncate text-[0.62rem] leading-none text-slate-200">{label}</span> <span className="min-w-0 flex-1 truncate text-[0.62rem] leading-none text-slate-200">{label}</span>
<strong className="shrink-0 text-[0.62rem] leading-none text-white">{value}</strong> <strong className="shrink-0 text-[0.62rem] leading-none text-white">{value}</strong>
</div> </div>
{/* Every meter uses an explicit real-world display range defined by its caller. The bar
therefore adds information instead of merely decorating the latest numeric value.
Keeping it on its own line gives both the title and meter the full panel width. */}
<div className="mt-1 h-1.5 overflow-hidden rounded-full bg-slate-700"> <div className="mt-1 h-1.5 overflow-hidden rounded-full bg-slate-700">
<div className={`h-full rounded-full ${fillClass}`} style={{ width: `${clampPercent(percent)}%` }} /> <div className={`h-full rounded-full ${fillClass}`} style={{ width: `${clampPercent(percent)}%` }} />
</div> </div>
@@ -37,7 +48,7 @@ function MetricRow({ icon, label, value, percent, iconClass, fillClass }) {
); );
} }
function WifiTile({ signal }) { function WifiTile({ signal, ssid }) {
const bars = signal == null ? 0 : signal >= -55 ? 4 : signal >= -65 ? 3 : signal >= -75 ? 2 : 1; const bars = signal == null ? 0 : signal >= -55 ? 4 : signal >= -65 ? 3 : signal >= -75 ? 2 : 1;
const tone = signal == null ? 'bg-slate-600' : signal < -80 ? 'bg-red-400' : signal < -70 ? 'bg-amber-400' : 'bg-emerald-400'; const tone = signal == null ? 'bg-slate-600' : signal < -80 ? 'bg-red-400' : signal < -70 ? 'bg-amber-400' : 'bg-emerald-400';
return ( return (
@@ -47,6 +58,7 @@ function WifiTile({ signal }) {
<span className="min-w-0 flex-1 truncate text-[0.62rem] leading-none text-slate-200">Wi-Fi signal</span> <span className="min-w-0 flex-1 truncate text-[0.62rem] leading-none text-slate-200">Wi-Fi signal</span>
<strong className="shrink-0 text-[0.62rem] leading-none text-white">{signal == null ? '--' : `${Math.round(signal)} dBm`}</strong> <strong className="shrink-0 text-[0.62rem] leading-none text-white">{signal == null ? '--' : `${Math.round(signal)} dBm`}</strong>
</div> </div>
<div className="mt-1 truncate text-[0.68rem] font-semibold leading-none text-white">Wi-Fi: {ssid || '--'}</div>
<div className="mt-1 flex h-2 items-end gap-0.5" aria-hidden="true"> <div className="mt-1 flex h-2 items-end gap-0.5" aria-hidden="true">
{[1, 2, 3, 4].map((bar) => ( {[1, 2, 3, 4].map((bar) => (
<span key={bar} className={`flex-1 rounded-sm ${bar <= bars ? tone : 'bg-slate-700'}`} style={{ height: `${25 * bar}%` }} /> <span key={bar} className={`flex-1 rounded-sm ${bar <= bars ? tone : 'bg-slate-700'}`} style={{ height: `${25 * bar}%` }} />
@@ -98,11 +110,14 @@ export default function TopRightPod({ roverId }) {
const batteryCharge = finite(electrical?.batteryChargeMah); const batteryCharge = finite(electrical?.batteryChargeMah);
const batteryCapacity = finite(electrical?.batteryCapacityMah); const batteryCapacity = finite(electrical?.batteryCapacityMah);
const cpuTemp = finite(host?.cpuTempC); const cpuTemp = finite(host?.cpuTempC);
const cpuUsed = finite(host?.cpuUsedPct);
const memoryUsed = finite(host?.memoryUsedPct); const memoryUsed = finite(host?.memoryUsedPct);
const memoryUsage = formatMemoryUsage(host?.memoryTotalKb, host?.memoryAvailableKb, memoryUsed);
const voltagePercent = voltage == null ? 0 : ((voltage - 12000) / 5000) * 100; const voltagePercent = voltage == null ? 0 : ((voltage - 12000) / 5000) * 100;
const batteryMahPercent = batteryCharge != null && batteryCapacity > 0 ? (batteryCharge / batteryCapacity) * 100 : 0; const batteryMahPercent = batteryCharge != null && batteryCapacity > 0 ? (batteryCharge / batteryCapacity) * 100 : 0;
const cpuTempPercent = cpuTemp == null ? 0 : ((cpuTemp - 30) / 55) * 100; const cpuTempPercent = cpuTemp == null ? 0 : ((cpuTemp - 30) / 55) * 100;
const cpuTempTone = cpuTemp >= 80 ? 'bg-red-400' : cpuTemp >= 70 ? 'bg-amber-400' : 'bg-emerald-400'; const cpuTempTone = cpuTemp >= 80 ? 'bg-red-400' : cpuTemp >= 70 ? 'bg-amber-400' : 'bg-emerald-400';
const cpuTone = cpuUsed >= 90 ? 'bg-red-400' : cpuUsed >= 70 ? 'bg-amber-400' : 'bg-sky-400';
const memoryTone = memoryUsed >= 90 ? 'bg-red-400' : memoryUsed >= 75 ? 'bg-amber-400' : 'bg-violet-400'; const memoryTone = memoryUsed >= 90 ? 'bg-red-400' : memoryUsed >= 75 ? 'bg-amber-400' : 'bg-violet-400';
const download = finite(wifi.downloadMbps); const download = finite(wifi.downloadMbps);
const upload = finite(wifi.uploadMbps); const upload = finite(wifi.uploadMbps);
@@ -154,10 +169,11 @@ export default function TopRightPod({ roverId }) {
<div className="space-y-1.5"> <div className="space-y-1.5">
<MetricRow icon={FaBolt} label="Roomba voltage" value={voltage == null ? '--' : `${(voltage / 1000).toFixed(1)} V`} percent={voltagePercent} iconClass="text-sky-300" fillClass="bg-sky-400" /> <MetricRow icon={FaBolt} label="Roomba voltage" value={voltage == null ? '--' : `${(voltage / 1000).toFixed(1)} V`} percent={voltagePercent} iconClass="text-sky-300" fillClass="bg-sky-400" />
<MetricRow icon={FaBolt} label="Roomba current" value={`${current > 0 ? '+' : ''}${Math.round(current)} mA`} percent={currentPercent * 100} iconClass={current < 0 ? 'text-amber-300' : 'text-emerald-300'} fillClass={current < 0 ? 'bg-amber-400' : 'bg-emerald-400'} /> <MetricRow icon={FaBolt} label="Roomba current" value={`${current > 0 ? '+' : ''}${Math.round(current)} mA`} percent={currentPercent * 100} iconClass={current < 0 ? 'text-amber-300' : 'text-emerald-300'} fillClass={current < 0 ? 'bg-amber-400' : 'bg-emerald-400'} />
<MetricRow icon={FaBatteryHalf} label="Battery charge" value={batteryCharge == null ? '--' : `${Math.round(batteryCharge)} mAh`} percent={batteryMahPercent} iconClass="text-emerald-300" fillClass="bg-emerald-400" /> <MetricRow icon={FaBatteryHalf} label="Battery charge" value={batteryCharge == null || batteryCapacity == null ? '--' : `${Math.round(batteryCharge)} / ${Math.round(batteryCapacity)} mAh`} percent={batteryMahPercent} iconClass="text-emerald-300" fillClass="bg-emerald-400" />
<MetricRow icon={FaThermometerHalf} label="Computer temperature" value={cpuTemp == null ? '--' : `${cpuTemp.toFixed(1)} C`} percent={cpuTempPercent} iconClass={cpuTemp >= 80 ? 'text-red-300' : cpuTemp >= 70 ? 'text-amber-300' : 'text-emerald-300'} fillClass={cpuTempTone} /> <MetricRow icon={FaThermometerHalf} label="Computer temperature" value={cpuTemp == null ? '--' : `${cpuTemp.toFixed(1)} C`} percent={cpuTempPercent} iconClass={cpuTemp >= 80 ? 'text-red-300' : cpuTemp >= 70 ? 'text-amber-300' : 'text-emerald-300'} fillClass={cpuTempTone} />
<MetricRow icon={FaMemory} label="Memory usage" value={memoryUsed == null ? '--' : `${Math.round(memoryUsed)}%`} percent={memoryUsed} iconClass={memoryUsed >= 90 ? 'text-red-300' : memoryUsed >= 75 ? 'text-amber-300' : 'text-violet-300'} fillClass={memoryTone} /> <MetricRow icon={FaMicrochip} label="CPU usage" value={cpuUsed == null ? '--' : `${Math.round(cpuUsed)}%`} percent={cpuUsed} iconClass={cpuUsed >= 90 ? 'text-red-300' : cpuUsed >= 70 ? 'text-amber-300' : 'text-sky-300'} fillClass={cpuTone} />
<WifiTile signal={signal} /> <MetricRow icon={FaMemory} label="Memory usage" value={memoryUsage} percent={memoryUsed} iconClass={memoryUsed >= 90 ? 'text-red-300' : memoryUsed >= 75 ? 'text-amber-300' : 'text-violet-300'} fillClass={memoryTone} />
<WifiTile signal={signal} ssid={wifi.ssidSample} />
<SpeedTile icon={FaArrowDown} label="Download speed" value={download == null ? '--' : `${download.toFixed(1)} Mb/s`} colorClass="text-sky-300" /> <SpeedTile icon={FaArrowDown} label="Download speed" value={download == null ? '--' : `${download.toFixed(1)} Mb/s`} colorClass="text-sky-300" />
<SpeedTile icon={FaArrowUp} label="Upload speed" value={upload == null ? '--' : `${upload.toFixed(1)} Mb/s`} colorClass="text-violet-300" /> <SpeedTile icon={FaArrowUp} label="Upload speed" value={upload == null ? '--' : `${upload.toFixed(1)} Mb/s`} colorClass="text-violet-300" />
</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} />
@@ -4,8 +4,8 @@
// the archived desktop layout retains its previous DriveDockAction behavior. // the archived desktop layout retains its previous DriveDockAction behavior.
import { useCallback, useEffect, useRef, useState } from 'react'; import { useCallback, useEffect, useRef, useState } from 'react';
import { FaChargingStation } from 'react-icons/fa'; import { FaChargingStation } from 'react-icons/fa';
import { useControlActions, useControlSelector } from '../../../../controls/index.js'; import { useControlActions } from '../../../../controls/index.js';
import { formatKeyLabel } from '../../../../controls/keymapUtils.js'; import ControlHint from '../../../ControlHint/index.jsx';
import { useTelemetrySelector } from '../../../../context/TelemetryContext.jsx'; import { useTelemetrySelector } from '../../../../context/TelemetryContext.jsx';
import { dockTelemetryEqual, selectDockTelemetry } from '../../../../context/telemetryViews.js'; import { dockTelemetryEqual, selectDockTelemetry } from '../../../../context/telemetryViews.js';
import { useManualDockAssist } from '../../../../features/manualDockAssist/useManualDockAssist.js'; import { useManualDockAssist } from '../../../../features/manualDockAssist/useManualDockAssist.js';
@@ -252,7 +252,6 @@ function UndockTransitionGhost({ onFinish }) {
export default function DockingHud({ roverId }) { export default function DockingHud({ roverId }) {
const layout = useDriverLayout(); const layout = useDriverLayout();
const actions = useControlActions(); const actions = useControlActions();
const keymap = useControlSelector((control) => control.state.keymap);
const dockTelemetry = useTelemetrySelector(roverId, selectDockTelemetry, dockTelemetryEqual); const dockTelemetry = useTelemetrySelector(roverId, selectDockTelemetry, dockTelemetryEqual);
// This replaces ManualDockAssistOverlay as the current HUD's one lifecycle owner. It preserves the // This replaces ManualDockAssistOverlay as the current HUD's one lifecycle owner. It preserves the
// success sounds, camera positioning, speed cap, and automatic exit after charging begins. // success sounds, camera positioning, speed cap, and automatic exit after charging begins.
@@ -282,12 +281,8 @@ export default function DockingHud({ roverId }) {
/* Mobile already presents its own touch-oriented driving controls. The docked /* Mobile already presents its own touch-oriented driving controls. The docked
action therefore keeps its plain-language instruction without advertising a action therefore keeps its plain-language instruction without advertising a
keyboard shortcut that is irrelevant on that layout. */ keyboard shortcut that is irrelevant on that layout. */
const driveKeyLabel = layout === 'desktop' const driveKeyLabel = layout === 'desktop' ? <ControlHint actionId="driveMacro" /> : '';
? formatKeyLabel(keymap?.driveMacro?.[0]) const dockKeyLabel = layout === 'desktop' ? <ControlHint actionId="dockMacro" /> : '';
: '';
const dockKeyLabel = layout === 'desktop'
? formatKeyLabel(keymap?.dockMacro?.[0])
: '';
const batteryPodOpen = podSettings?.battery !== false; const batteryPodOpen = podSettings?.battery !== false;
// The camera arc is the shared circular-pod reference size. Keep the dock expansion flush // The camera arc is the shared circular-pod reference size. Keep the dock expansion flush
// against the battery shell after enlarging that gauge to the same 8.5-rem footprint. // against the battery shell after enlarging that gauge to the same 8.5-rem footprint.
@@ -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">
<VacuumControls <div className={`mobile-touch-control grid min-h-0 gap-0.5 ${accessoriesAvailable ? 'grid-cols-[minmax(0,1fr)_2rem]' : 'grid-cols-1'}`}>
disabled={vacuumDisabled} <VacuumControls
onPress={handleAuxPress} disabled={vacuumDisabled}
onRelease={handleAuxRelease} onPress={handleAuxPress}
/> 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}
/>
);
}
+3 -8
View File
@@ -16,8 +16,8 @@ import ReplaySourcesPanel from '../ReplaySourcesPanel/index.jsx';
import QueueTargetRow, { QueueUserChips } from '../QueueTargetRow/index.jsx'; import QueueTargetRow, { QueueUserChips } from '../QueueTargetRow/index.jsx';
import TurnsOverlay from '../HudOverlays/TurnsOverlay/index.jsx'; import TurnsOverlay from '../HudOverlays/TurnsOverlay/index.jsx';
import KeyPill from '../vip/VipAudioUploadCard/KeyPill.jsx'; import KeyPill from '../vip/VipAudioUploadCard/KeyPill.jsx';
import { useControlActions, useControlSelector } from '../../controls/index.js'; import { useControlActions } from '../../controls/index.js';
import { formatKeyLabel } from '../../controls/keymapUtils.js'; import ControlHint from '../ControlHint/index.jsx';
import { useSessionActions, useSessionSelector } from '../../context/SessionContext.jsx'; import { useSessionActions, useSessionSelector } from '../../context/SessionContext.jsx';
import { usePtzCameraSnapshots } from '../../hooks/usePtzCameraSnapshot.js'; import { usePtzCameraSnapshots } from '../../hooks/usePtzCameraSnapshot.js';
import { useSharedClock } from '../../hooks/useSharedClock.js'; import { useSharedClock } from '../../hooks/useSharedClock.js';
@@ -283,12 +283,7 @@ function PtzMobileControlsPanel({ ptz, disabled = false }) {
); );
} }
function keyLabelFor(keymap, actionId) {
return formatKeyLabel(keymap?.[actionId]?.[0]);
}
function PtzControlReference() { function PtzControlReference() {
const keymap = useControlSelector((control) => control.state.keymap);
const rows = [ const rows = [
['Tilt up', 'driveForward'], ['Tilt up', 'driveForward'],
['Tilt down', 'driveBackward'], ['Tilt down', 'driveBackward'],
@@ -305,7 +300,7 @@ function PtzControlReference() {
{rows.map(([label, actionId]) => ( {rows.map(([label, actionId]) => (
<div key={label} className="surface flex items-center justify-between gap-1"> <div key={label} className="surface flex items-center justify-between gap-1">
<span className="text-slate-400">{label}</span> <span className="text-slate-400">{label}</span>
<KeyPill label={keyLabelFor(keymap, actionId)} /> <KeyPill label={<ControlHint actionId={actionId} />} />
</div> </div>
))} ))}
</CardFrame> </CardFrame>
@@ -3,6 +3,7 @@
// Scope: Owns row chrome, queue chips, timer labels, and row/button event plumbing; // Scope: Owns row chrome, queue chips, timer labels, and row/button event plumbing;
// callers still own target-specific permission checks and request actions. // callers still own target-specific permission checks and request actions.
import RoverLabel from '../RoverLabel/index.jsx'; import RoverLabel from '../RoverLabel/index.jsx';
import RoverHelpOverlay from '../RoverHelpOverlay/index.jsx';
function classNames(...values) { function classNames(...values) {
return values.filter(Boolean).join(' '); return values.filter(Boolean).join(' ');
@@ -105,7 +106,7 @@ export default function QueueTargetRow({
return ( return (
<li <li
className={classNames( className={classNames(
'surface flex flex-wrap items-start justify-between gap-0.5', 'surface relative flex flex-wrap items-start justify-between gap-0.5 overflow-hidden',
canClick && 'cursor-pointer', canClick && 'cursor-pointer',
locked locked
? 'bg-red-900/40' ? 'bg-red-900/40'
@@ -191,6 +192,9 @@ export default function QueueTargetRow({
{buttonLabel} {buttonLabel}
</button> </button>
) : null} ) : null}
{/* Queue rows use the exact overlay component as the large video and
display surfaces; its measured font automatically adapts to this box. */}
<RoverHelpOverlay active={Boolean(target?.needsHelp)} />
</li> </li>
); );
} }
@@ -1,34 +1,32 @@
import { useMemo } from 'react'; import ControlHint from '../ControlHint/index.jsx';
import { useControlSelector } from '../../controls/index.js';
import { formatKeyLabel } from '../../controls/keymapUtils.js';
import NicknameForm from '../NicknameForm/index.jsx'; import NicknameForm from '../NicknameForm/index.jsx';
import SocialButton from '../SocialButton/index.jsx'; import SocialButton from '../SocialButton/index.jsx';
import KeyPill from '../vip/VipAudioUploadCard/KeyPill.jsx'; import KeyPill from '../vip/VipAudioUploadCard/KeyPill.jsx';
import { useSessionSelector } from '../../context/SessionContext.jsx'; import { useSessionSelector } from '../../context/SessionContext.jsx';
import { getSocialById } from '../../lib/socials.js'; import { getSocialById } from '../../lib/socials.js';
function ControlRow({ label, keyLabel }) { function ControlRow({ label, actionId }) {
return ( return (
<div className="surface-muted flex items-center justify-between gap-0.5 px-0.5 py-0.35 text-[0.8rem] text-slate-200"> <div className="surface-muted flex items-center justify-between gap-0.5 px-0.5 py-0.35 text-[0.8rem] text-slate-200">
<span>{label}</span> <span>{label}</span>
<KeyPill label={keyLabel} /> <KeyPill label={<ControlHint actionId={actionId} />} />
</div> </div>
); );
} }
function DesktopQuickstart({ keymap }) { function DesktopQuickstart() {
return ( return (
<div className="space-y-0.5"> <div className="space-y-0.5">
<p className="text-sm text-slate-200">1. Click "Your rover is docked" to undock.</p> <p className="text-sm text-slate-200">1. Click "Your rover is docked" to undock.</p>
<div className="space-y-0.5"> <div className="space-y-0.5">
<p className="text-sm text-slate-200">2. Drive with these keybindings:</p> <p className="text-sm text-slate-200">2. Drive with these keybindings:</p>
<div className="space-y-0.5"> <div className="space-y-0.5">
<ControlRow label="Forward" keyLabel={formatKeyLabel(keymap?.driveForward?.[0])} /> <ControlRow label="Forward" actionId="driveForward" />
<ControlRow label="Backward" keyLabel={formatKeyLabel(keymap?.driveBackward?.[0])} /> <ControlRow label="Backward" actionId="driveBackward" />
<ControlRow label="Turn Left" keyLabel={formatKeyLabel(keymap?.driveLeft?.[0])} /> <ControlRow label="Turn Left" actionId="driveLeft" />
<ControlRow label="Turn Right" keyLabel={formatKeyLabel(keymap?.driveRight?.[0])} /> <ControlRow label="Turn Right" actionId="driveRight" />
<ControlRow label="Move faster" keyLabel={formatKeyLabel(keymap?.boostModifier?.[0])} /> <ControlRow label="Move faster" actionId="boostModifier" />
<ControlRow label="Move slower" keyLabel={formatKeyLabel(keymap?.slowModifier?.[0])} /> <ControlRow label="Move slower" actionId="slowModifier" />
</div> </div>
</div> </div>
<p className="text-sm text-slate-200">3. Use the video HUD for rover controls and information.</p> <p className="text-sm text-slate-200">3. Use the video HUD for rover controls and information.</p>
@@ -75,9 +73,7 @@ export default function QuickstartOverlay({
onToggleShowOnLoad, onToggleShowOnLoad,
onClose, onClose,
}) { }) {
const rawKeymap = useControlSelector((control) => control.state.keymap);
const isDesktop = layout === 'desktop'; const isDesktop = layout === 'desktop';
const keymap = useMemo(() => rawKeymap || {}, [rawKeymap]);
if (!visible) return null; if (!visible) return null;
@@ -97,7 +93,7 @@ export default function QuickstartOverlay({
</div> </div>
<div className={`grid gap-0.5 p-0.5 ${isDesktop ? 'md:grid-cols-[minmax(0,1.5fr)_minmax(0,1fr)]' : 'grid-cols-1'}`}> <div className={`grid gap-0.5 p-0.5 ${isDesktop ? 'md:grid-cols-[minmax(0,1.5fr)_minmax(0,1fr)]' : 'grid-cols-1'}`}>
<section className="space-y-0.5 border-b border-slate-700"> <section className="space-y-0.5 border-b border-slate-700">
{isDesktop ? <DesktopQuickstart keymap={keymap} /> : <MobileQuickstart />} {isDesktop ? <DesktopQuickstart /> : <MobileQuickstart />}
</section> </section>
{/* {!isDesktop? <div className='w-full h-1 bg-blue-500'></div> : null} */} {/* {!isDesktop? <div className='w-full h-1 bg-blue-500'></div> : null} */}
<section className="space-y-0.5"> <section className="space-y-0.5">
@@ -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,
};
}
@@ -0,0 +1,26 @@
// Rover Help Overlay
// Purpose: Gives every rover surface one unmistakable, responsive HELP treatment.
// Scope: Renders only the shared visual; server roster state decides when it is active.
import AutoFitText from '../AutoFitText/index.jsx';
import './styles.css';
export default function RoverHelpOverlay({ active = false }) {
if (!active) return null;
return (
<div
className="rover-help-overlay pointer-events-none absolute inset-0 z-50 overflow-hidden border-[clamp(0.2rem,1.2cqw,1rem)] border-white bg-red-600 p-[clamp(0.2rem,2cqw,1.5rem)] text-white"
role="status"
aria-label="Rover needs help"
>
<AutoFitText
fitHeight
minSize={8}
maxSize={1400}
className="font-black tracking-tight text-white"
>
HELP
</AutoFitText>
</div>
);
}
@@ -0,0 +1,9 @@
@keyframes rover-help-flash {
0%, 49% { opacity: 0.96; }
50%, 100% { opacity: 0; }
}
.rover-help-overlay {
container-type: size;
animation: rover-help-flash 2s steps(1, end) infinite;
}
+2 -3
View File
@@ -18,7 +18,7 @@ import { useHudMapSetting } from '../../hooks/useHudMapSetting.js';
import { useSettingsNamespace } from '../../settings/index.js'; import { useSettingsNamespace } from '../../settings/index.js';
import { useSocket } from '../../context/SocketContext.jsx'; import { useSocket } from '../../context/SocketContext.jsx';
import { AUDIO_SETTINGS_DEFAULTS, VIDEO_SETTINGS_DEFAULTS } from '../../settings/namespaces.js'; import { AUDIO_SETTINGS_DEFAULTS, VIDEO_SETTINGS_DEFAULTS } from '../../settings/namespaces.js';
import { formatKeyLabel } from '../../controls/keymapUtils.js'; import ControlHint from '../ControlHint/index.jsx';
import { import {
DEFAULT_PAGE_THEME_KEY, DEFAULT_PAGE_THEME_KEY,
PAGE_THEME_OPTIONS, PAGE_THEME_OPTIONS,
@@ -129,7 +129,6 @@ function reconnectSocketWithTransport(socket, transport) {
} }
export default function SettingsPanel() { export default function SettingsPanel() {
const keymap = useControlSelector((control) => control.state.keymap);
const roverId = useControlSelector((control) => control.state.roverId); const roverId = useControlSelector((control) => control.state.roverId);
const { sendOiCommand, setSensorStream } = useControlActions(); const { sendOiCommand, setSensorStream } = useControlActions();
const canControl = Boolean(roverId); const canControl = Boolean(roverId);
@@ -170,7 +169,7 @@ export default function SettingsPanel() {
? Math.max(0, Math.min(1, audioSettings.mainBrushDuckAmount)) ? Math.max(0, Math.min(1, audioSettings.mainBrushDuckAmount))
: AUDIO_SETTINGS_DEFAULTS.mainBrushDuckAmount; : AUDIO_SETTINGS_DEFAULTS.mainBrushDuckAmount;
const videoColorFilter = normalizeVideoFilter(videoSettings?.colorFilter); const videoColorFilter = normalizeVideoFilter(videoSettings?.colorFilter);
const videoFilterCycleKeyLabel = formatKeyLabel(keymap?.videoFilterCycle?.[0]); const videoFilterCycleKeyLabel = <ControlHint actionId="videoFilterCycle" />;
useEffect(() => { useEffect(() => {
// Settings load after the provider mounts and can also be replaced by an incoming inter-instance // Settings load after the provider mounts and can also be replaced by an incoming inter-instance
@@ -4,6 +4,7 @@ import RoverDescriptionOverlay from '../HudOverlays/RoverDescriptionOverlay/inde
import OvercurrentOverlay from '../HudOverlays/OvercurrentOverlay/index.jsx'; import OvercurrentOverlay from '../HudOverlays/OvercurrentOverlay/index.jsx';
import LowBatteryOverlay from '../HudOverlays/LowBatteryOverlay/index.jsx'; import LowBatteryOverlay from '../HudOverlays/LowBatteryOverlay/index.jsx';
import VerticalBatteryOverlay from '../HudOverlays/VerticalBatteryOverlay/index.jsx'; import VerticalBatteryOverlay from '../HudOverlays/VerticalBatteryOverlay/index.jsx';
import RoverHelpOverlay from '../RoverHelpOverlay/index.jsx';
import { useSessionSelector } from '../../context/SessionContext.jsx'; import { useSessionSelector } from '../../context/SessionContext.jsx';
export default function SpectateVideo({ export default function SpectateVideo({
@@ -11,6 +12,7 @@ export default function SpectateVideo({
label, label,
fitParent = false, fitParent = false,
layoutFormat = 'desktop', layoutFormat = 'desktop',
needsHelp = false,
}) { }) {
const isExternalSpectatorSnapshotOnly = useSessionSelector((state) => const isExternalSpectatorSnapshotOnly = useSessionSelector((state) =>
state.session?.role === 'spectator' && state.session?.role === 'spectator' &&
@@ -46,6 +48,7 @@ export default function SpectateVideo({
<OvercurrentOverlay roverId={roverId} compact={false} /> <OvercurrentOverlay roverId={roverId} compact={false} />
<LowBatteryOverlay roverId={roverId} compact={false} /> <LowBatteryOverlay roverId={roverId} compact={false} />
<VerticalBatteryOverlay show roverId={roverId} mobileHud={false} /> <VerticalBatteryOverlay show roverId={roverId} mobileHud={false} />
<RoverHelpOverlay active={needsHelp} />
</div> </div>
</div> </div>
); );
@@ -4,7 +4,7 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { fieldClass } from '../constants.js'; import { fieldClass } from '../constants.js';
import { useControlSelector } from '../../../controls/index.js'; import { useControlSelector } from '../../../controls/index.js';
import { formatKeyLabel } from '../../../controls/keymapUtils.js'; import ControlHint from '../../ControlHint/index.jsx';
import { useSettingsNamespace } from '../../../settings/index.js'; import { useSettingsNamespace } from '../../../settings/index.js';
import { MAX_UPLOAD_BYTES, TARGET_SAMPLE_RATE, RTC_CONFIG } from './constants.js'; import { MAX_UPLOAD_BYTES, TARGET_SAMPLE_RATE, RTC_CONFIG } from './constants.js';
import { bytesToBase64, buildAuthHeader } from './base64.js'; import { bytesToBase64, buildAuthHeader } from './base64.js';
@@ -28,7 +28,6 @@ export default function VipAudioUploadCard({
readyMicWhip, readyMicWhip,
stopMicWhip, stopMicWhip,
}) { }) {
const keymap = useControlSelector((control) => control.state.keymap);
const pttActive = useControlSelector((control) => Boolean(control.state.mic?.pttActive)); const pttActive = useControlSelector((control) => Boolean(control.state.mic?.pttActive));
const { value: vipAudio, save: saveVipAudio } = useSettingsNamespace('vipAudio', { const { value: vipAudio, save: saveVipAudio } = useSettingsNamespace('vipAudio', {
openMicEnabled: false, openMicEnabled: false,
@@ -81,7 +80,7 @@ export default function VipAudioUploadCard({
const whipLinkActive = !clipMode && (micState === 'live' || micState === 'starting'); const whipLinkActive = !clipMode && (micState === 'live' || micState === 'starting');
const clipRecording = clipMode && clipState === 'recording'; const clipRecording = clipMode && clipState === 'recording';
const clipSending = clipMode && clipState === 'sending'; const clipSending = clipMode && clipState === 'sending';
const pttKeyLabel = formatKeyLabel(keymap?.micPtt?.[0]) || 'M'; const pttKeyLabel = <ControlHint actionId="micPtt" />;
const setPttMode = useCallback( const setPttMode = useCallback(
(nextMode) => { (nextMode) => {
@@ -12,8 +12,8 @@ import PtzLiveVideo from '../PtzLiveVideo/index.jsx';
import ReplaySourcesPanel from '../ReplaySourcesPanel/index.jsx'; import ReplaySourcesPanel from '../ReplaySourcesPanel/index.jsx';
import KeyPill from './VipAudioUploadCard/KeyPill.jsx'; import KeyPill from './VipAudioUploadCard/KeyPill.jsx';
import { useSessionActions, useSessionSelector } from '../../context/SessionContext.jsx'; import { useSessionActions, useSessionSelector } from '../../context/SessionContext.jsx';
import { useControlActions, useControlSelector } from '../../controls/index.js'; import { useControlActions } from '../../controls/index.js';
import { formatKeyLabel } from '../../controls/keymapUtils.js'; import ControlHint from '../ControlHint/index.jsx';
import { usePtzCameraSnapshots } from '../../hooks/usePtzCameraSnapshot.js'; import { usePtzCameraSnapshots } from '../../hooks/usePtzCameraSnapshot.js';
import { isFeatureEnabled } from '../../lib/features.js'; import { isFeatureEnabled } from '../../lib/features.js';
import { triggerTouchHaptic } from '../../lib/touchHaptics.js'; import { triggerTouchHaptic } from '../../lib/touchHaptics.js';
@@ -307,12 +307,7 @@ function PtzMobileControlsPanel({ ptz, disabled = false }) {
); );
} }
function keyLabelFor(keymap, actionId) {
return formatKeyLabel(keymap?.[actionId]?.[0]);
}
function PtzControlReference() { function PtzControlReference() {
const keymap = useControlSelector((control) => control.state.keymap);
const rows = [ const rows = [
['Tilt up', 'driveForward'], ['Tilt up', 'driveForward'],
['Tilt down', 'driveBackward'], ['Tilt down', 'driveBackward'],
@@ -331,7 +326,7 @@ function PtzControlReference() {
<span className="text-slate-400">{label}</span> <span className="text-slate-400">{label}</span>
{/* Use the same key display component as the rest of the UI so PTZ {/* Use the same key display component as the rest of the UI so PTZ
controls read as normal mapped controls instead of custom labels. */} controls read as normal mapped controls instead of custom labels. */}
<KeyPill label={keyLabelFor(keymap, actionId)} /> <KeyPill label={<ControlHint actionId={actionId} />} />
</div> </div>
))} ))}
</CardFrame> </CardFrame>
+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;
} }
+328 -69
View File
@@ -3,21 +3,38 @@
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef } from 'react'; import { useCallback, useEffect, useLayoutEffect, useMemo, useRef } from 'react';
import { useControlActions, useControlSelector } from '../ControlContext.jsx'; import { useControlActions, useControlSelector } from '../ControlContext.jsx';
import { useSettingsNamespace } from '../../settings/index.js'; import { useSettingsNamespace } from '../../settings/index.js';
import { GAMEPAD_SETTINGS_DEFAULTS, GAMEPAD_PROFILE_DEFAULT } from '../../settings/namespaces.js';
import { import {
GAMEPAD_SETTINGS_DEFAULTS,
GAMEPAD_PROFILE_DEFAULT,
VIDEO_SETTINGS_DEFAULTS,
} from '../../settings/namespaces.js';
import {
advanceCameraAngle,
computeGamepadOutputs, computeGamepadOutputs,
createProfileForPad, createProfileForPad,
getPadSignature, getPadSignature,
resolveGamepadProfile,
} from './gamepadBindings.js'; } from './gamepadBindings.js';
import { subscribeGamepadHub } from './gamepadHub.js'; import { subscribeGamepadHub } from './gamepadHub.js';
import { isTextEntryActive } from './inputFocusUtils.js'; import { isTextEntryActive } from './inputFocusUtils.js';
import { useManualDockAssist } from '../../features/manualDockAssist/useManualDockAssist.js'; import { useManualDockAssist } from '../../features/manualDockAssist/useManualDockAssist.js';
import {
isControllerControlLocked,
markControllerDisconnected,
markControllerInputActive,
} from './controllerRuntime.js';
import { useChatActions, useChatFocus } from '../../context/ChatContext.jsx';
import { useSessionActions, useSessionSelector } from '../../context/SessionContext.jsx';
import { SONG_DEFAULT_DURATION, SONG_DEFAULT_NOTE, SONG_NOTE_RANGE } from '../constants.js';
const SOURCE = 'gamepad'; const SOURCE = 'gamepad';
const ZERO_VECTOR = { x: 0, y: 0, boost: false }; const ZERO_VECTOR = { x: 0, y: 0, boost: false };
const ZERO_AUX = { main: 0, side: 0, vacuum: 0 }; const ZERO_AUX = { main: 0, side: 0, vacuum: 0 };
const DRIVE_RATE_MS = 100; const DRIVE_RATE_MS = 100;
const AUX_RATE_MS = 100; const AUX_RATE_MS = 100;
const CONTROLLER_ACTIVITY_AXIS_MIN = 0.24;
const CONTROLLER_ACTIVITY_AXIS_DELTA = 0.08;
const VIDEO_FILTER_SEQUENCE = ['none', 'grayscale', 'greenscale'];
function areVectorsEqual(a, b) { function areVectorsEqual(a, b) {
return a && b && a.x === b.x && a.y === b.y && a.boost === b.boost; return a && b && a.x === b.x && a.y === b.y && a.boost === b.boost;
@@ -37,15 +54,63 @@ function isAuxIdle(aux) {
return !aux.main && !aux.side && !aux.vacuum; return !aux.main && !aux.side && !aux.vacuum;
} }
function pickActivePad(pads, activeSignature) { function pickActivePad(pads, activeInstanceKey) {
if (!pads || pads.length === 0) return null; if (!pads || pads.length === 0) return null;
if (activeSignature) { if (activeInstanceKey) {
const match = pads.find((pad) => pad.signature === activeSignature); const match = pads.find((pad) => pad.instanceKey === activeInstanceKey);
if (match) return match; if (match) return match;
} }
return pads[0]; return pads[0];
} }
function hasMeaningfulControllerChange(pad, previous) {
if (!previous) {
return pad.buttons.some((button) => button.pressed) ||
pad.axes.some((axis) => Math.abs(axis) >= CONTROLLER_ACTIVITY_AXIS_MIN);
}
const buttonPressed = pad.buttons.some(
(button, index) => button.pressed && !previous.buttons?.[index]?.pressed,
);
if (buttonPressed) return true;
return pad.axes.some((axis, index) => {
const oldAxis = previous.axes?.[index] ?? 0;
return Math.abs(axis) >= CONTROLLER_ACTIVITY_AXIS_MIN &&
Math.abs(axis - oldAxis) >= CONTROLLER_ACTIVITY_AXIS_DELTA;
});
}
function isControllerNeutral(pad) {
return !pad.buttons.some((button) => button.pressed || button.value > 0.1) &&
!pad.axes.some((axis) => Math.abs(axis) > 0.2);
}
function nextVideoFilter(value) {
const index = VIDEO_FILTER_SEQUENCE.indexOf(value);
return VIDEO_FILTER_SEQUENCE[(index < 0 ? 0 : index + 1) % VIDEO_FILTER_SEQUENCE.length];
}
function cycleHomeAssistant(latest, targetState) {
const homeAssistant = latest.homeAssistant;
if (!homeAssistant?.enabled || !homeAssistant?.connected) return;
if (
(homeAssistant.lightPolicy?.locked || homeAssistant.lightPolicy?.lockedOn) &&
!latest.adminCanControlLockedLights
) {
return;
}
const entities = (homeAssistant.entities ?? []).filter(
(entity) =>
(entity.type === 'light' || entity.type === 'switch') &&
entity.available !== false &&
entity.state !== 'unavailable',
);
const ordered = targetState === 'on' ? entities : [...entities].reverse();
const next = ordered.find((entity) =>
targetState === 'on' ? entity.state !== 'on' : entity.state === 'on',
);
if (next) latest.homeAssistantSetState(next.id, targetState).catch(() => {});
}
export default function GamepadInputManager() { export default function GamepadInputManager() {
const { const {
setMode, setMode,
@@ -57,11 +122,27 @@ export default function GamepadInputManager() {
toggleHeadlight, toggleHeadlight,
toggleLaser, toggleLaser,
registerInputState, registerInputState,
sendSong,
setSongNote,
startHorn,
stopHorn,
setMicPttActive,
} = useControlActions(); } = useControlActions();
const cameraAngle = useControlSelector((control) => control.state.camera?.angle); const cameraAngle = useControlSelector((control) => control.state.camera?.angle);
const cameraConfig = useControlSelector((control) => control.state.camera?.config); const cameraConfig = useControlSelector((control) => control.state.camera?.config);
const roverId = useControlSelector((control) => control.state.roverId); const roverId = useControlSelector((control) => control.state.roverId);
const dockAssist = useManualDockAssist(); const dockAssist = useManualDockAssist();
const { focusChat } = useChatActions();
const { isChatFocused } = useChatFocus();
const { homeAssistantSetState, pushAlert } = useSessionActions();
const homeAssistant = useSessionSelector((state) => state.session?.homeAssistant || null);
const role = useSessionSelector((state) => state.session?.role || null);
const sessionMode = useSessionSelector((state) => state.session?.mode || null);
const songNote = useControlSelector((control) => control.state.song?.note);
const { value: videoSettings, save: saveVideoSettings } = useSettingsNamespace(
'video',
VIDEO_SETTINGS_DEFAULTS,
);
const { value: gamepadSettings, save: saveGamepadSettings } = useSettingsNamespace( const { value: gamepadSettings, save: saveGamepadSettings } = useSettingsNamespace(
'gamepad', 'gamepad',
GAMEPAD_SETTINGS_DEFAULTS, GAMEPAD_SETTINGS_DEFAULTS,
@@ -75,6 +156,12 @@ export default function GamepadInputManager() {
const lastAuxSentAtRef = useRef(0); const lastAuxSentAtRef = useRef(0);
const lastServoAtRef = useRef(0); const lastServoAtRef = useRef(0);
const lastServoAngleRef = useRef(null); const lastServoAngleRef = useRef(null);
const previousPadRef = useRef(null);
const lastConnectedSignatureRef = useRef(null);
const lastConnectedInstanceKeyRef = useRef(null);
const lastRegisteredSignatureRef = useRef(null);
const controllerLockedRef = useRef(false);
const waitingForNeutralRef = useRef(false);
// The hub subscription is intentionally stable, so this ref is the bridge back to the latest // The hub subscription is intentionally stable, so this ref is the bridge back to the latest
// React values. Rewriting it after each commit is cheaper than tearing down browser gamepad // React values. Rewriting it after each commit is cheaper than tearing down browser gamepad
// listeners every time settings, camera state, or control callbacks change. // listeners every time settings, camera state, or control callbacks change.
@@ -91,7 +178,10 @@ export default function GamepadInputManager() {
latest.saveGamepadSettings((prev) => { latest.saveGamepadSettings((prev) => {
const current = prev ?? GAMEPAD_SETTINGS_DEFAULTS; const current = prev ?? GAMEPAD_SETTINGS_DEFAULTS;
if (current.profiles?.[signature]) return current; if (current.profiles?.[signature]) return current;
const base = current?.defaults?.profile ?? GAMEPAD_PROFILE_DEFAULT; const base = resolveGamepadProfile(
current?.defaults?.profile,
GAMEPAD_PROFILE_DEFAULT,
);
const nextProfile = createProfileForPad(padState, base); const nextProfile = createProfileForPad(padState, base);
return { return {
...current, ...current,
@@ -114,12 +204,19 @@ export default function GamepadInputManager() {
const config = latest?.cameraConfig; const config = latest?.cameraConfig;
if (!latest || !config) return; if (!latest || !config) return;
const now = typeof performance !== 'undefined' ? performance.now() : Date.now(); const now = typeof performance !== 'undefined' ? performance.now() : Date.now();
const cameraMode = calibration?.cameraMode ?? 'absolute'; const cameraMode = calibration?.cameraMode ?? 'velocity';
const sensitivity = Math.max(1, Math.min(180, calibration?.cameraSensitivity ?? 60)); const sensitivity = calibration?.cameraSensitivity ?? 60;
const min = typeof config.minAngle === 'number' ? config.minAngle : -45;
const max = typeof config.maxAngle === 'number' ? config.maxAngle : 45;
if (cameraMode === 'velocity') { if (cameraMode === 'velocity') {
if (Math.abs(axisValue) <= 0.001) {
/* Neutral is the safe synchronization point: no controller motion is being integrated, so
an angle changed by another UI can replace our accumulator without causing jitter. */
if (typeof latest.cameraAngle === 'number') lastServoAngleRef.current = latest.cameraAngle;
lastServoAtRef.current = now;
return;
}
const dt = Math.min(50, now - lastServoAtRef.current || 16); const dt = Math.min(50, now - lastServoAtRef.current || 16);
const delta = axisValue * sensitivity * (dt / 1000);
if (Math.abs(delta) < 0.01) return;
const baseline = const baseline =
typeof lastServoAngleRef.current === 'number' typeof lastServoAngleRef.current === 'number'
? lastServoAngleRef.current ? lastServoAngleRef.current
@@ -128,14 +225,12 @@ export default function GamepadInputManager() {
: typeof config.homeAngle === 'number' : typeof config.homeAngle === 'number'
? config.homeAngle ? config.homeAngle
: 0; : 0;
const nextAngle = baseline + delta; const nextAngle = advanceCameraAngle(baseline, axisValue, sensitivity, dt, { min, max });
latest.setServoAngle(nextAngle); latest.setServoAngle(nextAngle);
lastServoAngleRef.current = nextAngle; lastServoAngleRef.current = nextAngle;
lastServoAtRef.current = now; lastServoAtRef.current = now;
return; return;
} }
const min = typeof config.minAngle === 'number' ? config.minAngle : -45;
const max = typeof config.maxAngle === 'number' ? config.maxAngle : 45;
const home = typeof config.homeAngle === 'number' ? config.homeAngle : (min + max) / 2; const home = typeof config.homeAngle === 'number' ? config.homeAngle : (min + max) / 2;
const angle = const angle =
axisValue < 0 axisValue < 0
@@ -153,9 +248,29 @@ export default function GamepadInputManager() {
latest.setServoAngle(angle); latest.setServoAngle(angle);
}, []); }, []);
const activeSignature = useMemo( const neutralizeController = useCallback((latest) => {
() => gamepadSettings?.activeSignature ?? null, /*
[gamepadSettings?.activeSignature], Every path that makes controller commands unsafe converges here. In particular, held horn
and microphone actions need releases just as much as drive and motor axes need zeroes.
*/
latest.setCameraAxisIntent(0);
if (!areVectorsEqual(lastVectorRef.current, ZERO_VECTOR)) {
lastVectorRef.current = ZERO_VECTOR;
latest.setDriveVector(ZERO_VECTOR, { source: SOURCE });
}
if (!areAuxEqual(lastAuxRef.current, ZERO_AUX)) {
lastAuxRef.current = ZERO_AUX;
latest.setAuxMotors(ZERO_AUX);
}
if (buttonStateRef.current.get('hornHonk')) latest.stopHorn();
if (buttonStateRef.current.get('micPtt')) latest.setMicPttActive(false);
buttonStateRef.current = new Map();
reverseStateRef.current = { main: false, side: false };
}, []);
const activeInstanceKey = useMemo(
() => gamepadSettings?.activeInstanceKey ?? null,
[gamepadSettings?.activeInstanceKey],
); );
useLayoutEffect(() => { useLayoutEffect(() => {
@@ -163,83 +278,132 @@ export default function GamepadInputManager() {
// after React commits. Updating this ref before paint keeps the stable hub callback aligned // after React commits. Updating this ref before paint keeps the stable hub callback aligned
// with the newest settings and control actions without resubscribing to the hub. // with the newest settings and control actions without resubscribing to the hub.
latestRef.current = { latestRef.current = {
activeSignature, activeInstanceKey,
adminCanControlLockedLights:
role === 'lockdown' || (role === 'admin' && sessionMode !== 'lockdown'),
cameraAngle, cameraAngle,
cameraConfig, cameraConfig,
dockAssist, dockAssist,
focusChat,
gamepadSettings, gamepadSettings,
homeAssistant,
homeAssistantSetState,
isChatFocused,
pushAlert,
registerInputState, registerInputState,
roverId, roverId,
runMacro, runMacro,
saveGamepadSettings, saveGamepadSettings,
saveVideoSettings,
sendSong,
setAuxMotors, setAuxMotors,
setCameraAxisIntent, setCameraAxisIntent,
setDriveVector, setDriveVector,
setMicPttActive,
setMode, setMode,
setSongNote,
setServoAngle, setServoAngle,
songNote,
startHorn,
stopHorn,
toggleHeadlight, toggleHeadlight,
toggleLaser, toggleLaser,
videoColorFilter: videoSettings?.colorFilter ?? VIDEO_SETTINGS_DEFAULTS.colorFilter,
}; };
}); });
useEffect(() => { useEffect(() => {
return subscribeGamepadHub((hubState) => { const unsubscribe = subscribeGamepadHub((hubState) => {
const latest = latestRef.current; const latest = latestRef.current;
if (!latest) return; if (!latest) return;
const activePad = pickActivePad(hubState.pads, latest.activeSignature); const activePad = pickActivePad(hubState.pads, latest.activeInstanceKey);
if (!activePad) { if (!activePad) {
// A disconnected controller cannot deliver a final neutral axis sample. // A disconnect cannot provide release samples, so synthesize every required release once.
// Publish it here so PTZ zoom never depends on the browser doing so. neutralizeController(latest);
latest.setCameraAxisIntent(0); markControllerDisconnected(lastConnectedSignatureRef.current);
if (!areVectorsEqual(lastVectorRef.current, ZERO_VECTOR)) { previousPadRef.current = null;
lastVectorRef.current = ZERO_VECTOR; lastConnectedSignatureRef.current = null;
latest.setDriveVector(ZERO_VECTOR, { source: SOURCE }); lastConnectedInstanceKeyRef.current = null;
} lastRegisteredSignatureRef.current = null;
if (!areAuxEqual(lastAuxRef.current, ZERO_AUX)) { controllerLockedRef.current = false;
lastAuxRef.current = ZERO_AUX; waitingForNeutralRef.current = false;
latest.setAuxMotors(ZERO_AUX);
}
buttonStateRef.current = new Map();
reverseStateRef.current = { main: false, side: false };
lastDriveSentAtRef.current = 0; lastDriveSentAtRef.current = 0;
lastAuxSentAtRef.current = 0; lastAuxSentAtRef.current = 0;
latest.registerInputState(SOURCE, { connected: false }); latest.registerInputState(SOURCE, { connected: false });
return; return;
} }
if (isTextEntryActive()) { if (
// Entering text blocks gamepad control immediately, including a held lastConnectedInstanceKeyRef.current &&
// camera axis that otherwise would keep its last PTZ zoom direction. lastConnectedInstanceKeyRef.current !== activePad.instanceKey
latest.setCameraAxisIntent(0); ) {
if (!areVectorsEqual(lastVectorRef.current, ZERO_VECTOR)) { /* Browser slots distinguish two identical controllers. Neutralize the old owner before
lastVectorRef.current = ZERO_VECTOR; accepting the replacement and require any controls already held on the new pad to be
latest.setDriveVector(ZERO_VECTOR, { source: SOURCE }); released, preventing a selection change from inheriting drive, horn, or microphone. */
neutralizeController(latest);
previousPadRef.current = null;
lastRegisteredSignatureRef.current = null;
waitingForNeutralRef.current = true;
}
if (hasMeaningfulControllerChange(activePad, previousPadRef.current)) {
markControllerInputActive(activePad);
}
previousPadRef.current = activePad;
lastConnectedSignatureRef.current = activePad.signature;
lastConnectedInstanceKeyRef.current = activePad.instanceKey;
const controlsBlocked = isTextEntryActive() || isControllerControlLocked();
if (controlsBlocked) {
/* Configuration and text entry still receive hub snapshots, but they must never leak
through to physical rover actions. Only publish/reset on the transition into the lock. */
if (!controllerLockedRef.current) {
neutralizeController(latest);
latest.registerInputState(SOURCE, { connected: true, blocked: true });
} }
if (!areAuxEqual(lastAuxRef.current, ZERO_AUX)) { controllerLockedRef.current = true;
lastAuxRef.current = ZERO_AUX; waitingForNeutralRef.current = true;
latest.setAuxMotors(ZERO_AUX);
}
buttonStateRef.current = new Map();
reverseStateRef.current = { main: false, side: false };
latest.registerInputState(SOURCE, { connected: true, blocked: true });
return; return;
} }
if (controllerLockedRef.current) {
controllerLockedRef.current = false;
latest.registerInputState(SOURCE, { connected: true, blocked: false });
}
/* A control held while a dialog closes must not become a fresh command. Require a neutral
sample before rearming the controller, just like releasing an emergency-stop switch. */
if (waitingForNeutralRef.current) {
if (!isControllerNeutral(activePad)) return;
waitingForNeutralRef.current = false;
}
ensureProfile(activePad); ensureProfile(activePad);
const signature = activePad.signature; const signature = activePad.signature;
const profile = const storedProfile =
latest.gamepadSettings?.profiles?.[signature] ?? latest.gamepadSettings?.profiles?.[signature] ??
latest.gamepadSettings?.defaults?.profile ?? latest.gamepadSettings?.defaults?.profile ??
GAMEPAD_PROFILE_DEFAULT; GAMEPAD_PROFILE_DEFAULT;
const profile = resolveGamepadProfile(storedProfile, GAMEPAD_PROFILE_DEFAULT);
const outputs = computeGamepadOutputs(activePad, profile); const outputs = computeGamepadOutputs(activePad, profile);
if (!areVectorsEqual(outputs.driveVector, lastVectorRef.current)) { const driveVector = {
...outputs.driveVector,
boost: Boolean(outputs.buttons.boostModifier),
};
if (!areVectorsEqual(driveVector, lastVectorRef.current)) {
const now = typeof performance !== 'undefined' ? performance.now() : Date.now(); const now = typeof performance !== 'undefined' ? performance.now() : Date.now();
const idle = vectorMagnitude(outputs.driveVector) < 0.02; const idle = vectorMagnitude(driveVector) < 0.02;
if (idle || now - lastDriveSentAtRef.current >= DRIVE_RATE_MS) { if (idle || now - lastDriveSentAtRef.current >= DRIVE_RATE_MS) {
lastVectorRef.current = outputs.driveVector; lastVectorRef.current = driveVector;
lastDriveSentAtRef.current = now; lastDriveSentAtRef.current = now;
latest.setDriveVector(outputs.driveVector, { source: SOURCE }); const precisionSpeed = profile.calibration?.precisionSpeed ?? 100;
const baseSpeed = profile.calibration?.baseSpeed ?? 500;
const turboSpeed = profile.calibration?.turboSpeed ?? 500;
latest.setDriveVector(driveVector, {
source: SOURCE,
speedOptions: outputs.buttons.slowModifier
? { baseSpeed: precisionSpeed, boostSpeed: precisionSpeed }
: { baseSpeed, boostSpeed: turboSpeed },
});
} }
} }
@@ -250,12 +414,30 @@ export default function GamepadInputManager() {
const side = reverseStateRef.current.side const side = reverseStateRef.current.side
? -Math.round(sideMagnitude * auxSideScale) ? -Math.round(sideMagnitude * auxSideScale)
: Math.round(sideMagnitude * auxSideScale); : Math.round(sideMagnitude * auxSideScale);
/* Digital bindings intentionally override proportional axes. This mirrors keyboard aux
precedence exactly while preserving the controller-friendly analog defaults. */
let aux = { let aux = {
main: outputs.auxAxis.main !== 0 ? main : 0, main: outputs.buttons.auxMainForward
side: outputs.auxAxis.side !== 0 ? side : 0, ? 127
vacuum: outputs.buttons.vacuum ? 127 : 0, : outputs.buttons.auxMainReverse
? -127
: outputs.auxAxis.main !== 0
? main
: 0,
side: outputs.buttons.auxSideForward
? 127
: outputs.buttons.auxSideReverse
? -70
: outputs.auxAxis.side !== 0
? side
: 0,
vacuum: (outputs.buttons.vacuum || outputs.buttons.auxVacuumFast)
? 127
: outputs.buttons.auxVacuumSlow
? 50
: 0,
}; };
if (outputs.buttons.allAux) { if (outputs.buttons.allAux || outputs.buttons.auxAllForward) {
aux = { main: 127, side: 127, vacuum: 127 }; aux = { main: 127, side: 127, vacuum: 127 };
} }
if (!areAuxEqual(aux, lastAuxRef.current)) { if (!areAuxEqual(aux, lastAuxRef.current)) {
@@ -305,6 +487,67 @@ export default function GamepadInputManager() {
handleButtonEdge('laserToggle', false); handleButtonEdge('laserToggle', false);
} }
const hornWasPressed = buttonStateRef.current.get('hornHonk') || false;
if (outputs.buttons.hornHonk && handleButtonEdge('hornHonk', true)) {
latest.startHorn();
} else if (!outputs.buttons.hornHonk) {
handleButtonEdge('hornHonk', false);
if (hornWasPressed) latest.stopHorn();
}
const micWasPressed = buttonStateRef.current.get('micPtt') || false;
if (outputs.buttons.micPtt && handleButtonEdge('micPtt', true)) {
latest.setMicPttActive(true);
} else if (!outputs.buttons.micPtt) {
handleButtonEdge('micPtt', false);
if (micWasPressed) latest.setMicPttActive(false);
}
if (outputs.buttons.videoFilterCycle && handleButtonEdge('videoFilterCycle', true)) {
const nextFilter = nextVideoFilter(latest.videoColorFilter);
latest.saveVideoSettings((current) => ({ ...(current ?? {}), colorFilter: nextFilter }));
latest.pushAlert({
id: 'video-filter-active',
title: 'Video filter',
message: `Rover video filter: ${nextFilter}`,
color: '#38bdf8',
lifetimeMs: 1600,
});
} else if (!outputs.buttons.videoFilterCycle) {
handleButtonEdge('videoFilterCycle', false);
}
if (outputs.buttons.chatFocus && handleButtonEdge('chatFocus', true)) {
if (!latest.isChatFocused) latest.focusChat();
} else if (!outputs.buttons.chatFocus) {
handleButtonEdge('chatFocus', false);
}
/* Song directions share identical edge and wrap behavior; the table keeps the two actions
symmetric and prevents one direction from silently diverging during later changes. */
for (const [actionId, direction] of [['songNoteUp', 1], ['songNoteDown', -1]]) {
if (outputs.buttons[actionId] && handleButtonEdge(actionId, true)) {
const [minNote, maxNote] = SONG_NOTE_RANGE;
const currentNote = typeof latest.songNote === 'number' ? latest.songNote : SONG_DEFAULT_NOTE;
const candidate = currentNote + direction;
const nextNote = candidate > maxNote ? minNote : candidate < minNote ? maxNote : candidate;
latest.setSongNote(nextNote);
latest.sendSong([{ note: nextNote, duration: SONG_DEFAULT_DURATION }], { slot: 0 });
} else if (!outputs.buttons[actionId]) {
handleButtonEdge(actionId, false);
}
}
/* Room-control cycling differs only by target state, so both bindings use the same policy
checks and ordered entity selection. */
for (const [actionId, targetState] of [['homeAssistantOn', 'on'], ['homeAssistantOff', 'off']]) {
if (outputs.buttons[actionId] && handleButtonEdge(actionId, true)) {
cycleHomeAssistant(latest, targetState);
} else if (!outputs.buttons[actionId]) {
handleButtonEdge(actionId, false);
}
}
/* /*
PTZ zoom consumes the live signed gamepad axis, including its zero PTZ zoom consumes the live signed gamepad axis, including its zero
position, so releasing the stick is an explicit stop instead of merely position, so releasing the stick is an explicit stop instead of merely
@@ -312,24 +555,40 @@ export default function GamepadInputManager() {
here and continue through their established absolute/velocity mapping. here and continue through their established absolute/velocity mapping.
*/ */
const handledAsPtzZoom = latest.setCameraAxisIntent(outputs.cameraAxis); const handledAsPtzZoom = latest.setCameraAxisIntent(outputs.cameraAxis);
if (!handledAsPtzZoom && Math.abs(outputs.cameraAxis) > 0.001) { /* Tank mode's camera input is a pair of direction buttons rather than a position-bearing
handleCameraAxis(outputs.cameraAxis, profile.calibration); analog axis. Always interpret those buttons as velocity commands; absolute mode would
incorrectly jump directly to a servo endpoint on every D-pad press. The saved analog
camera preference remains untouched and resumes when single-stick steering is selected. */
const cameraCalibration = profile.calibration?.driveMode === 'tank'
? { ...profile.calibration, cameraMode: 'velocity' }
: profile.calibration;
if (
!handledAsPtzZoom &&
(cameraCalibration?.cameraMode === 'velocity' || Math.abs(outputs.cameraAxis) > 0.001)
) {
handleCameraAxis(outputs.cameraAxis, cameraCalibration);
} }
latest.registerInputState(SOURCE, { /* Raw values remain in the dedicated hub used by diagnostics. The shared reducer only
connected: true, needs connection identity, which avoids forcing the entire provider through 60 updates/s. */
signature, if (lastRegisteredSignatureRef.current !== signature) {
id: activePad.id, lastRegisteredSignatureRef.current = signature;
index: activePad.index, latest.registerInputState(SOURCE, {
axes: activePad.axes, connected: true,
buttons: activePad.buttons, blocked: false,
drive: outputs.driveVector, signature,
aux, id: activePad.id,
cameraAxis: outputs.cameraAxis, index: activePad.index,
bindings: outputs.sources, });
}); }
}); });
}, [ensureProfile, handleButtonEdge, handleCameraAxis]); return () => {
unsubscribe();
const latest = latestRef.current;
if (latest) neutralizeController(latest);
markControllerDisconnected(lastConnectedSignatureRef.current);
};
}, [ensureProfile, handleButtonEdge, handleCameraAxis, neutralizeController]);
return null; return null;
} }

Some files were not shown because too many files have changed in this diff Show More