diff --git a/dist/hornverifier b/dist/hornverifier index c484e09a..051175f3 100755 Binary files a/dist/hornverifier and b/dist/hornverifier differ diff --git a/dist/roverd b/dist/roverd index 40d82da5..01f4378c 100755 Binary files a/dist/roverd and b/dist/roverd differ diff --git a/dist/roverd-debian-laptop b/dist/roverd-debian-laptop index 9ed636d6..0976fb70 100755 Binary files a/dist/roverd-debian-laptop and b/dist/roverd-debian-laptop differ diff --git a/dist/servoverifier b/dist/servoverifier index f6272fdb..7305b930 100755 Binary files a/dist/servoverifier and b/dist/servoverifier differ diff --git a/docs/rover-peripherals.md b/docs/rover-peripherals.md index f49c36f6..4f970be4 100644 --- a/docs/rover-peripherals.md +++ b/docs/rover-peripherals.md @@ -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: . 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 diff --git a/pi/roverd/camera_servo.go b/pi/roverd/camera_servo.go index b6e7b560..50a1216b 100644 --- a/pi/roverd/camera_servo.go +++ b/pi/roverd/camera_servo.go @@ -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)) diff --git a/pi/roverd/camera_servo_debian_laptop.go b/pi/roverd/camera_servo_debian_laptop.go index c04a9373..8f9ec186 100644 --- a/pi/roverd/camera_servo_debian_laptop.go +++ b/pi/roverd/camera_servo_debian_laptop.go @@ -39,3 +39,7 @@ func (c *CameraServo) CurrentAngle() float64 { func (c *CameraServo) Configuration() CameraServoConfig { return CameraServoConfig{} } + +func (c *CameraServo) BackendDescription() string { + return "native GPIO" +} diff --git a/pi/roverd/camera_servo_dummy.go b/pi/roverd/camera_servo_dummy.go index 370673ba..04ff5384 100644 --- a/pi/roverd/camera_servo_dummy.go +++ b/pi/roverd/camera_servo_dummy.go @@ -34,3 +34,7 @@ func (c *CameraServo) CurrentAngle() float64 { func (c *CameraServo) Configuration() CameraServoConfig { return CameraServoConfig{} } + +func (c *CameraServo) BackendDescription() string { + return "native GPIO" +} diff --git a/pi/roverd/cmd/roverd/main.go b/pi/roverd/cmd/roverd/main.go index ae3a8c86..89fd97b7 100644 --- a/pi/roverd/cmd/roverd/main.go +++ b/pi/roverd/cmd/roverd/main.go @@ -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) diff --git a/pi/roverd/firmata.go b/pi/roverd/firmata.go index 34a91b29..027dafaa 100644 --- a/pi/roverd/firmata.go +++ b/pi/roverd/firmata.go @@ -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 } diff --git a/pi/roverd/firmata_hardware_controllers.go b/pi/roverd/firmata_hardware_controllers.go index 67eab220..d9cbe028 100644 --- a/pi/roverd/firmata_hardware_controllers.go +++ b/pi/roverd/firmata_hardware_controllers.go @@ -16,6 +16,7 @@ type FirmataCameraServo struct { cfg CameraServoConfig client *FirmataClient pin byte + peripheralID string mu sync.Mutex currentAngle float64 desiredAngle float64 @@ -41,10 +42,11 @@ func newFirmataCameraServo(peripheral *managedPeripheral, declaration Peripheral Invert: declaration.Inverted, } servo := &FirmataCameraServo{ - cfg: cfg, - client: peripheral.client, - pin: byte(declaration.Pin), - stopCh: make(chan struct{}), + 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 @@ -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() @@ -209,18 +215,26 @@ func (servo *FirmataCameraServo) startMoveLoopLocked() { // 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 - mu sync.Mutex - on bool - closed bool + 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), 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() diff --git a/pi/roverd/firmata_hardware_controllers_test.go b/pi/roverd/firmata_hardware_controllers_test.go index 7a456502..ce798ef9 100644 --- a/pi/roverd/firmata_hardware_controllers_test.go +++ b/pi/roverd/firmata_hardware_controllers_test.go @@ -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() {} diff --git a/pi/roverd/firmata_test.go b/pi/roverd/firmata_test.go index 03cddbc2..f1551208 100644 --- a/pi/roverd/firmata_test.go +++ b/pi/roverd/firmata_test.go @@ -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.recordingConnection.Close() - close(connection.reads) + connection.closeOnce.Do(func() { + _ = connection.recordingConnection.Close() + close(connection.reads) + }) return nil } diff --git a/pi/roverd/gpio_toggle.go b/pi/roverd/gpio_toggle.go index a1809de5..cf30c4b4 100644 --- a/pi/roverd/gpio_toggle.go +++ b/pi/roverd/gpio_toggle.go @@ -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 diff --git a/pi/roverd/gpio_toggle_debian_laptop.go b/pi/roverd/gpio_toggle_debian_laptop.go index a656a631..3b796020 100644 --- a/pi/roverd/gpio_toggle_debian_laptop.go +++ b/pi/roverd/gpio_toggle_debian_laptop.go @@ -34,3 +34,7 @@ func (g *GPIOToggle) On() bool { func (g *GPIOToggle) Configuration() GPIOToggleConfig { return GPIOToggleConfig{} } + +func (g *GPIOToggle) BackendDescription() string { + return "native GPIO" +} diff --git a/pi/roverd/gpio_toggle_dummy.go b/pi/roverd/gpio_toggle_dummy.go index 0b61745f..5e918686 100644 --- a/pi/roverd/gpio_toggle_dummy.go +++ b/pi/roverd/gpio_toggle_dummy.go @@ -28,3 +28,7 @@ func (g *GPIOToggle) On() bool { func (g *GPIOToggle) Configuration() GPIOToggleConfig { return GPIOToggleConfig{} } + +func (g *GPIOToggle) BackendDescription() string { + return "native GPIO" +} diff --git a/pi/roverd/hardware_controllers.go b/pi/roverd/hardware_controllers.go index 31ffe3fc..84b41566 100644 --- a/pi/roverd/hardware_controllers.go +++ b/pi/roverd/hardware_controllers.go @@ -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() } @@ -40,9 +43,37 @@ type ToggleController interface { // 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 + 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 { diff --git a/pi/roverd/peripheral_manager.go b/pi/roverd/peripheral_manager.go index 67188b04..81456435 100644 --- a/pi/roverd/peripheral_manager.go +++ b/pi/roverd/peripheral_manager.go @@ -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 { @@ -76,9 +86,10 @@ type peripheralDiscoveryDependencies struct { 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, + 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/*", diff --git a/pi/roverd/peripheral_manager_test.go b/pi/roverd/peripheral_manager_test.go index c7f70e2d..c262b647 100644 --- a/pi/roverd/peripheral_manager_test.go +++ b/pi/roverd/peripheral_manager_test.go @@ -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) {