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
+4
View File
@@ -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))
+4
View File
@@ -39,3 +39,7 @@ func (c *CameraServo) CurrentAngle() float64 {
func (c *CameraServo) Configuration() 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 {
return CameraServoConfig{}
}
func (c *CameraServo) BackendDescription() string {
return "native GPIO"
}
+28
View File
@@ -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
View File
@@ -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
}
+30 -12
View File
@@ -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()
@@ -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() {}
+5 -2
View File
@@ -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
}
+4
View File
@@ -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
+4
View File
@@ -34,3 +34,7 @@ func (g *GPIOToggle) On() bool {
func (g *GPIOToggle) Configuration() 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 {
return GPIOToggleConfig{}
}
func (g *GPIOToggle) BackendDescription() string {
return "native GPIO"
}
+45 -3
View File
@@ -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 {
+53 -3
View File
@@ -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/*",
+51
View File
@@ -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) {