mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-15 17:12:59 -04:00
add consolenotifier for peripheral system alerts
This commit is contained in:
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
@@ -879,6 +879,18 @@ If an ESP32 is unplugged or its serial connection fails while `roverd` is runnin
|
||||
3. The failed command is logged by the existing rover WebSocket command path.
|
||||
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.
|
||||
|
||||
### Server WebSocket reconnect
|
||||
|
||||
@@ -134,6 +134,10 @@ func (s *CameraServo) Configuration() CameraServoConfig {
|
||||
return s.cfg
|
||||
}
|
||||
|
||||
func (s *CameraServo) BackendDescription() string {
|
||||
return "native GPIO"
|
||||
}
|
||||
|
||||
func (s *CameraServo) applyPulseLocked(micros int) {
|
||||
micros = clampInt(micros, s.cfg.MinPulseUs, s.cfg.MaxPulseUs)
|
||||
s.pin.DutyCycle(uint32(micros), uint32(s.cfg.CycleLen))
|
||||
|
||||
@@ -39,3 +39,7 @@ func (c *CameraServo) CurrentAngle() float64 {
|
||||
func (c *CameraServo) Configuration() CameraServoConfig {
|
||||
return CameraServoConfig{}
|
||||
}
|
||||
|
||||
func (c *CameraServo) BackendDescription() string {
|
||||
return "native GPIO"
|
||||
}
|
||||
|
||||
@@ -34,3 +34,7 @@ func (c *CameraServo) CurrentAngle() float64 {
|
||||
func (c *CameraServo) Configuration() CameraServoConfig {
|
||||
return CameraServoConfig{}
|
||||
}
|
||||
|
||||
func (c *CameraServo) BackendDescription() string {
|
||||
return "native GPIO"
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package main
|
||||
import (
|
||||
"context"
|
||||
"flag"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"os/signal"
|
||||
@@ -42,9 +43,13 @@ func main() {
|
||||
// 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
|
||||
if cfg.BRC.Enabled() {
|
||||
@@ -74,9 +79,32 @@ func main() {
|
||||
// GPIO wins, otherwise a discovered ESP32 may provide the built-in role.
|
||||
hardwareControllers, err := roverd.ResolveRoverHardwareControllers(cfg, peripherals, logger)
|
||||
if err != nil {
|
||||
console.Notify(fmt.Sprintf("Rover peripheral startup failed while selecting hardware: %v", err))
|
||||
logger.Fatalf("resolve rover hardware controllers: %v", err)
|
||||
}
|
||||
defer hardwareControllers.Close()
|
||||
for _, message := range hardwareControllers.StartupBroadcasts() {
|
||||
console.Notify(message)
|
||||
}
|
||||
|
||||
// A peripheral is never hot-reconnected. Report the first terminal serial
|
||||
// failure for each discovered board and tell the local operator exactly what
|
||||
// recovery action the fixed boot-time lifecycle requires.
|
||||
go func() {
|
||||
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
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
autoCharge := roverd.NewAutoChargeController(adapter, eventStream, logger)
|
||||
go autoCharge.Run(ctx, sensorSamples)
|
||||
|
||||
+41
-5
@@ -358,6 +358,10 @@ type FirmataClient struct {
|
||||
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 {
|
||||
@@ -420,11 +424,10 @@ func (client *FirmataClient) readLoop(ctx context.Context) {
|
||||
}
|
||||
|
||||
func (client *FirmataClient) publishError(ctx context.Context, err error) {
|
||||
client.stateMu.Lock()
|
||||
if client.terminalErr == nil {
|
||||
client.terminalErr = err
|
||||
firstTerminalError, handler := client.recordTerminalError(err)
|
||||
if firstTerminalError && handler != nil && ctx.Err() == nil {
|
||||
handler(err)
|
||||
}
|
||||
client.stateMu.Unlock()
|
||||
|
||||
select {
|
||||
case client.errors <- err:
|
||||
@@ -433,6 +436,30 @@ func (client *FirmataClient) publishError(ctx context.Context, err error) {
|
||||
}
|
||||
}
|
||||
|
||||
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()
|
||||
@@ -445,10 +472,19 @@ func (client *FirmataClient) write(message []byte) error {
|
||||
|
||||
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) {
|
||||
return fmt.Errorf("short Firmata write %d/%d", 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
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ type FirmataCameraServo struct {
|
||||
cfg CameraServoConfig
|
||||
client *FirmataClient
|
||||
pin byte
|
||||
peripheralID string
|
||||
mu sync.Mutex
|
||||
currentAngle float64
|
||||
desiredAngle float64
|
||||
@@ -44,6 +45,7 @@ func newFirmataCameraServo(peripheral *managedPeripheral, declaration Peripheral
|
||||
cfg: cfg,
|
||||
client: peripheral.client,
|
||||
pin: byte(declaration.Pin),
|
||||
peripheralID: peripheral.metadata.ID,
|
||||
stopCh: make(chan struct{}),
|
||||
}
|
||||
|
||||
@@ -116,6 +118,10 @@ 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()
|
||||
@@ -213,6 +219,7 @@ type FirmataToggle struct {
|
||||
name string
|
||||
client *FirmataClient
|
||||
pin byte
|
||||
peripheralID string
|
||||
mu sync.Mutex
|
||||
on bool
|
||||
closed bool
|
||||
@@ -220,7 +227,14 @@ type FirmataToggle struct {
|
||||
|
||||
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), on: cfg.InitialOn}
|
||||
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)
|
||||
}
|
||||
@@ -275,6 +289,10 @@ 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()
|
||||
|
||||
@@ -47,6 +47,10 @@ func TestDisabledNativeRolesResolveToFirmataOnEveryHostBuild(t *testing.T) {
|
||||
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
|
||||
@@ -129,6 +133,10 @@ func TestEnabledNativeRolesWinEvenWithSeveralFirmataProviders(t *testing.T) {
|
||||
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 {
|
||||
@@ -140,6 +148,7 @@ func (controller *testCameraServoController) Nudge(float64) error {
|
||||
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 {
|
||||
@@ -150,4 +159,5 @@ type testToggleController struct {
|
||||
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() {}
|
||||
|
||||
@@ -389,6 +389,7 @@ type scriptedConnection struct {
|
||||
reads chan []byte
|
||||
timeoutsBeforeRead int
|
||||
pendingRead []byte
|
||||
closeOnce sync.Once
|
||||
}
|
||||
|
||||
func newScriptedConnection(responses ...[]byte) *scriptedConnection {
|
||||
@@ -432,7 +433,9 @@ func (connection *scriptedConnection) Write(data []byte) (int, error) {
|
||||
}
|
||||
|
||||
func (connection *scriptedConnection) Close() error {
|
||||
connection.closeOnce.Do(func() {
|
||||
_ = connection.recordingConnection.Close()
|
||||
close(connection.reads)
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -92,6 +92,10 @@ func (g *GPIOToggle) Configuration() GPIOToggleConfig {
|
||||
return g.cfg
|
||||
}
|
||||
|
||||
func (g *GPIOToggle) BackendDescription() string {
|
||||
return "native GPIO"
|
||||
}
|
||||
|
||||
func (g *GPIOToggle) setLocked(on bool) error {
|
||||
// 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
|
||||
|
||||
@@ -34,3 +34,7 @@ func (g *GPIOToggle) On() bool {
|
||||
func (g *GPIOToggle) Configuration() GPIOToggleConfig {
|
||||
return GPIOToggleConfig{}
|
||||
}
|
||||
|
||||
func (g *GPIOToggle) BackendDescription() string {
|
||||
return "native GPIO"
|
||||
}
|
||||
|
||||
@@ -28,3 +28,7 @@ func (g *GPIOToggle) On() bool {
|
||||
func (g *GPIOToggle) Configuration() GPIOToggleConfig {
|
||||
return GPIOToggleConfig{}
|
||||
}
|
||||
|
||||
func (g *GPIOToggle) BackendDescription() string {
|
||||
return "native GPIO"
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package roverd
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
@@ -24,6 +25,7 @@ type CameraServoController interface {
|
||||
SetPulseWidth(micros int) error
|
||||
CurrentAngle() float64
|
||||
Configuration() CameraServoConfig
|
||||
BackendDescription() string
|
||||
Close()
|
||||
}
|
||||
|
||||
@@ -33,6 +35,7 @@ type ToggleController interface {
|
||||
HandleAction(action string) error
|
||||
On() bool
|
||||
Configuration() GPIOToggleConfig
|
||||
BackendDescription() string
|
||||
Close()
|
||||
}
|
||||
|
||||
@@ -43,6 +46,34 @@ 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 {
|
||||
@@ -68,6 +99,17 @@ func ResolveRoverHardwareControllers(cfg *Config, peripherals *PeripheralManager
|
||||
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 {
|
||||
|
||||
@@ -63,6 +63,16 @@ type PeripheralManager struct {
|
||||
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 {
|
||||
@@ -79,6 +89,7 @@ func discoverPeripheralManager(ctx context.Context, excludedDevice string, logge
|
||||
byID: make(map[string]*managedPeripheral),
|
||||
cancel: cancel,
|
||||
logger: logger,
|
||||
failures: make(chan PeripheralFailure, 16),
|
||||
}
|
||||
|
||||
candidates, err := dependencies.listCandidates(excludedDevice)
|
||||
@@ -139,12 +150,51 @@ func discoverPeripheralManager(ctx context.Context, excludedDevice string, logge
|
||||
}
|
||||
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/*",
|
||||
|
||||
@@ -35,6 +35,10 @@ func TestPeripheralManagerDiscoversInventoryAndDispatchesControls(t *testing.T)
|
||||
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 {
|
||||
@@ -79,6 +83,45 @@ func TestPeripheralManagerDiscoversInventoryAndDispatchesControls(t *testing.T)
|
||||
}
|
||||
}
|
||||
|
||||
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(
|
||||
@@ -229,6 +272,14 @@ func TestPeripheralManagerReturnsHardwareWriteFailure(t *testing.T) {
|
||||
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) {
|
||||
|
||||
Reference in New Issue
Block a user