add consolenotifier for peripheral system alerts

This commit is contained in:
legop3
2026-09-08 21:46:10 -04:00
parent 3859806fca
commit 038ae0f45a
19 changed files with 299 additions and 25 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.
+12
View File
@@ -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. 3. The failed command is logged by the existing rover WebSocket command path.
4. The advertised inventory does 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.
### Server WebSocket reconnect ### Server WebSocket reconnect
+4
View File
@@ -134,6 +134,10 @@ func (s *CameraServo) Configuration() CameraServoConfig {
return s.cfg 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))
+4
View File
@@ -39,3 +39,7 @@ func (c *CameraServo) CurrentAngle() float64 {
func (c *CameraServo) Configuration() CameraServoConfig { func (c *CameraServo) Configuration() CameraServoConfig {
return CameraServoConfig{} return CameraServoConfig{}
} }
func (c *CameraServo) BackendDescription() string {
return "native GPIO"
}
+4
View File
@@ -34,3 +34,7 @@ func (c *CameraServo) CurrentAngle() float64 {
func (c *CameraServo) Configuration() CameraServoConfig { func (c *CameraServo) Configuration() CameraServoConfig {
return CameraServoConfig{} return CameraServoConfig{}
} }
func (c *CameraServo) BackendDescription() string {
return "native GPIO"
}
+28
View File
@@ -3,6 +3,7 @@ package main
import ( import (
"context" "context"
"flag" "flag"
"fmt"
"log" "log"
"os" "os"
"os/signal" "os/signal"
@@ -42,9 +43,13 @@ func main() {
// rebuilt only when the roverd process itself restarts. // rebuilt only when the roverd process itself restarts.
peripherals, err := roverd.DiscoverPeripheralManager(ctx, cfg.Serial.Device, logger) peripherals, err := roverd.DiscoverPeripheralManager(ctx, cfg.Serial.Device, logger)
if err != nil { if err != nil {
console.Notify(fmt.Sprintf("Rover peripheral startup failed: %v", err))
logger.Fatalf("discover rover peripherals: %v", err) logger.Fatalf("discover rover peripherals: %v", err)
} }
defer peripherals.Close() 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() {
@@ -74,9 +79,32 @@ func main() {
// GPIO wins, otherwise a discovered ESP32 may provide the built-in role. // GPIO wins, otherwise a discovered ESP32 may provide the built-in role.
hardwareControllers, err := roverd.ResolveRoverHardwareControllers(cfg, peripherals, logger) hardwareControllers, err := roverd.ResolveRoverHardwareControllers(cfg, peripherals, logger)
if err != nil { if err != nil {
console.Notify(fmt.Sprintf("Rover peripheral startup failed while selecting hardware: %v", err))
logger.Fatalf("resolve rover hardware controllers: %v", err) logger.Fatalf("resolve rover hardware controllers: %v", err)
} }
defer hardwareControllers.Close() 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) autoCharge := roverd.NewAutoChargeController(adapter, eventStream, logger)
go autoCharge.Run(ctx, sensorSamples) go autoCharge.Run(ctx, sensorSamples)
+41 -5
View File
@@ -358,6 +358,10 @@ type FirmataClient struct {
requestMu sync.Mutex requestMu sync.Mutex
stateMu sync.RWMutex stateMu sync.RWMutex
terminalErr error 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 { 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) { func (client *FirmataClient) publishError(ctx context.Context, err error) {
client.stateMu.Lock() firstTerminalError, handler := client.recordTerminalError(err)
if client.terminalErr == nil { if firstTerminalError && handler != nil && ctx.Err() == nil {
client.terminalErr = err handler(err)
} }
client.stateMu.Unlock()
select { select {
case client.errors <- err: 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 { func (client *FirmataClient) write(message []byte) error {
client.writeMu.Lock() client.writeMu.Lock()
defer client.writeMu.Unlock() defer client.writeMu.Unlock()
@@ -445,10 +472,19 @@ func (client *FirmataClient) write(message []byte) error {
written, err := client.connection.Write(message) written, err := client.connection.Write(message)
if err != nil { if err != nil {
firstTerminalError, handler := client.recordTerminalError(err)
if firstTerminalError && handler != nil {
handler(err)
}
return err return err
} }
if written != len(message) { 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 return nil
} }
+30 -12
View File
@@ -16,6 +16,7 @@ type FirmataCameraServo struct {
cfg CameraServoConfig cfg CameraServoConfig
client *FirmataClient client *FirmataClient
pin byte pin byte
peripheralID string
mu sync.Mutex mu sync.Mutex
currentAngle float64 currentAngle float64
desiredAngle float64 desiredAngle float64
@@ -41,10 +42,11 @@ func newFirmataCameraServo(peripheral *managedPeripheral, declaration Peripheral
Invert: declaration.Inverted, Invert: declaration.Inverted,
} }
servo := &FirmataCameraServo{ servo := &FirmataCameraServo{
cfg: cfg, cfg: cfg,
client: peripheral.client, client: peripheral.client,
pin: byte(declaration.Pin), pin: byte(declaration.Pin),
stopCh: make(chan struct{}), peripheralID: peripheral.metadata.ID,
stopCh: make(chan struct{}),
} }
// SERVO_CONFIG establishes the peripheral-owned pulse calibration before // SERVO_CONFIG establishes the peripheral-owned pulse calibration before
@@ -116,6 +118,10 @@ func (servo *FirmataCameraServo) Configuration() CameraServoConfig {
return servo.cfg return servo.cfg
} }
func (servo *FirmataCameraServo) BackendDescription() string {
return "ESP32 " + servo.peripheralID
}
func (servo *FirmataCameraServo) Close() { func (servo *FirmataCameraServo) Close() {
servo.mu.Lock() servo.mu.Lock()
defer servo.mu.Unlock() defer servo.mu.Unlock()
@@ -209,18 +215,26 @@ func (servo *FirmataCameraServo) startMoveLoopLocked() {
// FirmataToggle owns logical state exactly like GPIOToggle but sends the final // FirmataToggle owns logical state exactly like GPIOToggle but sends the final
// electrical level through Firmata's standard digital-pin command. // electrical level through Firmata's standard digital-pin command.
type FirmataToggle struct { type FirmataToggle struct {
cfg GPIOToggleConfig cfg GPIOToggleConfig
name string name string
client *FirmataClient client *FirmataClient
pin byte pin byte
mu sync.Mutex peripheralID string
on bool mu sync.Mutex
closed bool on bool
closed bool
} }
func newFirmataToggle(name string, peripheral *managedPeripheral, declaration PeripheralDigitalRole, logger *log.Logger) (*FirmataToggle, error) { 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} 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 { if err := toggle.client.SetPinMode(toggle.pin, FirmataPinModeOutput); err != nil {
return nil, fmt.Errorf("select Firmata output mode: %w", err) return nil, fmt.Errorf("select Firmata output mode: %w", err)
} }
@@ -275,6 +289,10 @@ func (toggle *FirmataToggle) Configuration() GPIOToggleConfig {
return toggle.cfg return toggle.cfg
} }
func (toggle *FirmataToggle) BackendDescription() string {
return "ESP32 " + toggle.peripheralID
}
func (toggle *FirmataToggle) Close() { func (toggle *FirmataToggle) Close() {
toggle.mu.Lock() toggle.mu.Lock()
defer toggle.mu.Unlock() 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 { if !controllers.CameraServo.Configuration().Enabled || !controllers.Headlight.Configuration().Enabled || !controllers.Laser.Configuration().Enabled {
t.Fatal("ESP32-backed roles were not advertised as 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, // Initialization uses only standard Firmata: servo calibration and mode,
// followed by the home position and digital initial states. The active-low // 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"] { if controllers.CameraServo != nativeCamera || controllers.Headlight != nativeToggles["headlight"] || controllers.Laser != nativeToggles["laser"] {
t.Fatal("resolver did not retain native controllers") 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 { type testCameraServoController struct {
@@ -140,6 +148,7 @@ func (controller *testCameraServoController) Nudge(float64) error {
func (controller *testCameraServoController) SetPulseWidth(int) error { return nil } func (controller *testCameraServoController) SetPulseWidth(int) error { return nil }
func (controller *testCameraServoController) CurrentAngle() float64 { return 0 } func (controller *testCameraServoController) CurrentAngle() float64 { return 0 }
func (controller *testCameraServoController) Configuration() CameraServoConfig { return controller.cfg } func (controller *testCameraServoController) Configuration() CameraServoConfig { return controller.cfg }
func (controller *testCameraServoController) BackendDescription() string { return "native GPIO" }
func (controller *testCameraServoController) Close() {} func (controller *testCameraServoController) Close() {}
type testToggleController struct { type testToggleController struct {
@@ -150,4 +159,5 @@ type testToggleController struct {
func (controller *testToggleController) HandleAction(string) error { return nil } func (controller *testToggleController) HandleAction(string) error { return nil }
func (controller *testToggleController) On() bool { return controller.on } func (controller *testToggleController) On() bool { return controller.on }
func (controller *testToggleController) Configuration() GPIOToggleConfig { return controller.cfg } func (controller *testToggleController) Configuration() GPIOToggleConfig { return controller.cfg }
func (controller *testToggleController) BackendDescription() string { return "native GPIO" }
func (controller *testToggleController) Close() {} func (controller *testToggleController) Close() {}
+5 -2
View File
@@ -389,6 +389,7 @@ type scriptedConnection struct {
reads chan []byte reads chan []byte
timeoutsBeforeRead int timeoutsBeforeRead int
pendingRead []byte pendingRead []byte
closeOnce sync.Once
} }
func newScriptedConnection(responses ...[]byte) *scriptedConnection { func newScriptedConnection(responses ...[]byte) *scriptedConnection {
@@ -432,7 +433,9 @@ func (connection *scriptedConnection) Write(data []byte) (int, error) {
} }
func (connection *scriptedConnection) Close() error { func (connection *scriptedConnection) Close() error {
_ = connection.recordingConnection.Close() connection.closeOnce.Do(func() {
close(connection.reads) _ = connection.recordingConnection.Close()
close(connection.reads)
})
return nil return nil
} }
+4
View File
@@ -92,6 +92,10 @@ func (g *GPIOToggle) Configuration() GPIOToggleConfig {
return g.cfg 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
+4
View File
@@ -34,3 +34,7 @@ func (g *GPIOToggle) On() bool {
func (g *GPIOToggle) Configuration() GPIOToggleConfig { func (g *GPIOToggle) Configuration() GPIOToggleConfig {
return GPIOToggleConfig{} return GPIOToggleConfig{}
} }
func (g *GPIOToggle) BackendDescription() string {
return "native GPIO"
}
+4
View File
@@ -28,3 +28,7 @@ func (g *GPIOToggle) On() bool {
func (g *GPIOToggle) Configuration() GPIOToggleConfig { func (g *GPIOToggle) Configuration() GPIOToggleConfig {
return GPIOToggleConfig{} return GPIOToggleConfig{}
} }
func (g *GPIOToggle) BackendDescription() string {
return "native GPIO"
}
+45 -3
View File
@@ -3,6 +3,7 @@ package roverd
import ( import (
"fmt" "fmt"
"log" "log"
"strings"
"time" "time"
) )
@@ -24,6 +25,7 @@ type CameraServoController interface {
SetPulseWidth(micros int) error SetPulseWidth(micros int) error
CurrentAngle() float64 CurrentAngle() float64
Configuration() CameraServoConfig Configuration() CameraServoConfig
BackendDescription() string
Close() Close()
} }
@@ -33,6 +35,7 @@ type ToggleController interface {
HandleAction(action string) error HandleAction(action string) error
On() bool On() bool
Configuration() GPIOToggleConfig Configuration() GPIOToggleConfig
BackendDescription() string
Close() Close()
} }
@@ -40,9 +43,37 @@ type ToggleController interface {
// decision. Its effective configurations are derived from whichever backend // decision. Its effective configurations are derived from whichever backend
// won, making the normal rover hello accurate on both Pi and laptop hosts. // won, making the normal rover hello accurate on both Pi and laptop hosts.
type RoverHardwareControllers struct { type RoverHardwareControllers struct {
CameraServo CameraServoController CameraServo CameraServoController
Headlight ToggleController Headlight ToggleController
Laser 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 { 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) { func resolveRoverHardwareControllers(cfg *Config, peripherals *PeripheralManager, logger *log.Logger, factories nativeHardwareControllerFactories) (RoverHardwareControllers, error) {
var controllers RoverHardwareControllers var controllers RoverHardwareControllers
var err error 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) controllers.CameraServo, err = resolveCameraServoController(cfg.CameraServo, peripherals, logger, factories.newCameraServo)
if err != nil { if err != nil {
+53 -3
View File
@@ -63,6 +63,16 @@ type PeripheralManager struct {
cancel context.CancelFunc cancel context.CancelFunc
closeOnce sync.Once closeOnce sync.Once
logger *log.Logger 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 { type peripheralDiscoveryDependencies struct {
@@ -76,9 +86,10 @@ type peripheralDiscoveryDependencies struct {
func discoverPeripheralManager(ctx context.Context, excludedDevice string, logger *log.Logger, dependencies peripheralDiscoveryDependencies) (*PeripheralManager, error) { func discoverPeripheralManager(ctx context.Context, excludedDevice string, logger *log.Logger, dependencies peripheralDiscoveryDependencies) (*PeripheralManager, error) {
managerContext, cancel := context.WithCancel(ctx) managerContext, cancel := context.WithCancel(ctx)
manager := &PeripheralManager{ manager := &PeripheralManager{
byID: make(map[string]*managedPeripheral), byID: make(map[string]*managedPeripheral),
cancel: cancel, cancel: cancel,
logger: logger, logger: logger,
failures: make(chan PeripheralFailure, 16),
} }
candidates, err := dependencies.listCandidates(excludedDevice) candidates, err := dependencies.listCandidates(excludedDevice)
@@ -139,12 +150,51 @@ func discoverPeripheralManager(ctx context.Context, excludedDevice string, logge
} }
manager.peripherals = append(manager.peripherals, peripheral) manager.peripherals = append(manager.peripherals, peripheral)
manager.byID[peripheral.metadata.ID] = 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)) logger.Printf("discovered rover peripheral %s on %s with %d generic controls", description.Name, devicePath, len(description.Controls))
} }
return manager, nil 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) { func listPeripheralCandidates(excludedDevice string) ([]string, error) {
patterns := []string{ patterns := []string{
"/dev/serial/by-id/*", "/dev/serial/by-id/*",
+51
View File
@@ -35,6 +35,10 @@ func TestPeripheralManagerDiscoversInventoryAndDispatchesControls(t *testing.T)
if inventory[0].ID != "firmata-0" || inventory[0].Name != "Bench accessory" { if inventory[0].ID != "firmata-0" || inventory[0].Name != "Bench accessory" {
t.Fatalf("unexpected peripheral metadata: %#v", inventory[0]) 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"} wantOrder := []string{"servoPosition", "lightBrightness", "specialAction"}
for index, controlID := range wantOrder { for index, controlID := range wantOrder {
if inventory[0].Controls[index].ID != controlID { 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) { func TestPeripheralManagerRejectsInvalidValuesBeforeWriting(t *testing.T) {
connection := scriptedPeripheralConnection(t, testPeripheralDescription("Bench accessory", false)) connection := scriptedPeripheralConnection(t, testPeripheralDescription("Bench accessory", false))
manager, err := discoverPeripheralManager( manager, err := discoverPeripheralManager(
@@ -229,6 +272,14 @@ func TestPeripheralManagerReturnsHardwareWriteFailure(t *testing.T) {
if err == nil || !strings.Contains(err.Error(), "USB device removed") { if err == nil || !strings.Contains(err.Error(), "USB device removed") {
t.Fatalf("expected hardware error, got %v", err) 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) { func TestPeripheralManagerPassesRoombaDeviceToCandidateExclusion(t *testing.T) {