diff --git a/dist/roverd b/dist/roverd index 9a8c6690..e91cd9d4 100755 Binary files a/dist/roverd and b/dist/roverd differ diff --git a/pi/roverd/auto_charge.go b/pi/roverd/auto_charge.go new file mode 100644 index 00000000..09cb1235 --- /dev/null +++ b/pi/roverd/auto_charge.go @@ -0,0 +1,109 @@ +package roverd + +import ( + "context" + "log" + "time" +) + +const ( + autoChargeTimeout = 10 * time.Second + autoChargeCooldown = 2 * time.Minute + sourceHomeBase = 1 << 1 +) + +type AutoChargeController struct { + adapter *SerialAdapter + events chan<- RoverEvent + logger *log.Logger + timerStart time.Time + cooldownUntil time.Time + lastState byte + lastSources byte +} + +func NewAutoChargeController(adapter *SerialAdapter, events chan<- RoverEvent, logger *log.Logger) *AutoChargeController { + return &AutoChargeController{ + adapter: adapter, + events: events, + logger: logger, + } +} + +func (a *AutoChargeController) Run(ctx context.Context, samples <-chan SensorSample) { + for { + select { + case <-ctx.Done(): + return + case sample := <-samples: + a.processSample(sample) + } + } +} + +func (a *AutoChargeController) processSample(sample SensorSample) { + now := time.Now() + docked := sample.ChargeSources&sourceHomeBase != 0 + charging := isCharging(sample.ChargingState) + + if !docked || charging { + if !a.timerStart.IsZero() { + a.emitEvent("autoCharge.timerCleared", map[string]any{ + "durationMs": time.Since(a.timerStart).Milliseconds(), + }) + } + a.timerStart = time.Time{} + a.lastState = sample.ChargingState + a.lastSources = sample.ChargeSources + return + } + + // docked but not charging + if a.cooldownUntil.After(now) { + return + } + + if a.timerStart.IsZero() { + a.timerStart = now + a.emitEvent("autoCharge.timerStarted", map[string]any{ + "chargingState": sample.ChargingState, + }) + return + } + + if now.Sub(a.timerStart) >= autoChargeTimeout { + if err := a.adapter.SeekDock(); err != nil { + a.emitEvent("autoCharge.seekDockError", map[string]any{"error": err.Error()}) + } else { + a.emitEvent("autoCharge.seekDockIssued", map[string]any{ + "waitingMs": autoChargeTimeout.Milliseconds(), + }) + } + a.timerStart = time.Time{} + a.cooldownUntil = now.Add(autoChargeCooldown) + } +} + +func isCharging(state byte) bool { + switch state { + case 1, 2, 3, 4: + return true + default: + return false + } +} + +func (a *AutoChargeController) emitEvent(event string, data map[string]any) { + if a.events == nil { + return + } + select { + case a.events <- RoverEvent{ + Type: "event", + Event: event, + Ts: time.Now().UnixMilli(), + Data: data, + }: + default: + } +} diff --git a/pi/roverd/cmd/roverd/main.go b/pi/roverd/cmd/roverd/main.go index 4a35980d..5972aa51 100644 --- a/pi/roverd/cmd/roverd/main.go +++ b/pi/roverd/cmd/roverd/main.go @@ -44,7 +44,10 @@ func main() { } sensorFrames := make(chan []byte, 8) - streamer := roverd.NewSensorStreamer(serialPort, sensorFrames, logger) + sensorSamples := make(chan roverd.SensorSample, 8) + eventStream := make(chan roverd.RoverEvent, 16) + + streamer := roverd.NewSensorStreamer(serialPort, sensorFrames, sensorSamples, logger) go streamer.Run(ctx) adapter := roverd.NewSerialAdapter(serialPort, logger) @@ -54,7 +57,10 @@ func main() { mediaSupervisor.Start(ctx) } - client := roverd.NewWSClient(cfg, adapter, sensorFrames, mediaSupervisor, logger) + autoCharge := roverd.NewAutoChargeController(adapter, eventStream, logger) + go autoCharge.Run(ctx, sensorSamples) + + client := roverd.NewWSClient(cfg, adapter, sensorFrames, eventStream, mediaSupervisor, logger) retryDelay := time.Second for ctx.Err() == nil { diff --git a/pi/roverd/events.go b/pi/roverd/events.go new file mode 100644 index 00000000..7640b93f --- /dev/null +++ b/pi/roverd/events.go @@ -0,0 +1,8 @@ +package roverd + +type RoverEvent struct { + Type string `json:"type"` + Event string `json:"event"` + Ts int64 `json:"ts"` + Data map[string]any `json:"data,omitempty"` +} diff --git a/pi/roverd/sensor_defs.go b/pi/roverd/sensor_defs.go new file mode 100644 index 00000000..857f196d --- /dev/null +++ b/pi/roverd/sensor_defs.go @@ -0,0 +1,23 @@ +package roverd + +var ( + defaultStreamPackets = []byte{100, 21, 34} + packetSizes = map[byte]int{ + 100: 80, + 21: 1, + 34: 1, + } + expectedPayloadLength = func() int { + sum := 0 + for _, id := range defaultStreamPackets { + sum += 1 + packetSizes[id] + } + return sum + }() +) + +type SensorSample struct { + Timestamp int64 + ChargingState byte + ChargeSources byte +} diff --git a/pi/roverd/sensor_stream.go b/pi/roverd/sensor_stream.go index 37c65f04..882b6ddf 100644 --- a/pi/roverd/sensor_stream.go +++ b/pi/roverd/sensor_stream.go @@ -10,19 +10,19 @@ import ( ) const ( - sensorHeader = 19 - sensorReadTimeout = 150 * time.Millisecond - streamGroupDefault = 100 + sensorHeader = 19 + sensorReadTimeout = 150 * time.Millisecond ) type SensorStreamer struct { - r io.Reader - out chan<- []byte - logger *log.Logger + r io.Reader + rawOut chan<- []byte + parsed chan<- SensorSample + logger *log.Logger } -func NewSensorStreamer(r io.Reader, out chan<- []byte, logger *log.Logger) *SensorStreamer { - return &SensorStreamer{r: r, out: out, logger: logger} +func NewSensorStreamer(r io.Reader, rawOut chan<- []byte, parsed chan<- SensorSample, logger *log.Logger) *SensorStreamer { + return &SensorStreamer{r: r, rawOut: rawOut, parsed: parsed, logger: logger} } func (s *SensorStreamer) Run(ctx context.Context) { @@ -34,14 +34,14 @@ func (s *SensorStreamer) Run(ctx context.Context) { default: } - b, err := reader.ReadByte() + header, err := reader.ReadByte() if err != nil { if ctx.Err() != nil { return } continue } - if b != sensorHeader { + if header != sensorHeader { continue } nBytes, err := reader.ReadByte() @@ -61,9 +61,18 @@ func (s *SensorStreamer) Run(ctx context.Context) { } select { - case s.out <- frame: + case s.rawOut <- frame: default: } + + if s.parsed != nil { + if sample, ok := decodeSensorSample(frame); ok { + select { + case s.parsed <- sample: + default: + } + } + } } } @@ -74,3 +83,44 @@ func validateChecksum(buf []byte) bool { } return byte(sum&0xFF) == 0 } + +func decodeSensorSample(frame []byte) (SensorSample, bool) { + if len(frame) < 3 { + return SensorSample{}, false + } + nBytes := int(frame[1]) + if nBytes+3 != len(frame) { + return SensorSample{}, false + } + payload := frame[2 : 2+nBytes] + if len(payload) != expectedPayloadLength { + return SensorSample{}, false + } + + idx := 0 + var sample SensorSample + var seen byte + for idx < len(payload) { + id := payload[idx] + idx++ + size, ok := packetSizes[id] + if !ok { + return SensorSample{}, false + } + if idx+size > len(payload) { + return SensorSample{}, false + } + segment := payload[idx : idx+size] + switch id { + case 21: + sample.ChargingState = segment[0] + seen |= 1 + case 34: + sample.ChargeSources = segment[0] + seen |= 2 + } + idx += size + } + sample.Timestamp = time.Now().UnixMilli() + return sample, seen&3 == 3 +} diff --git a/pi/roverd/serial.go b/pi/roverd/serial.go index 3f56e768..416c9b8f 100644 --- a/pi/roverd/serial.go +++ b/pi/roverd/serial.go @@ -2,6 +2,7 @@ package roverd import ( "encoding/base64" + "errors" "fmt" "io" "log" @@ -68,8 +69,12 @@ func (s *SerialAdapter) MotorPWM(main, side, vacuum int) error { return s.write(payload) } -func (s *SerialAdapter) StartSensorStream(group byte) error { - payload := []byte{148, 1, group} +func (s *SerialAdapter) StartSensorStream(packets []byte) error { + if len(packets) == 0 { + return errors.New("sensor stream requires packets") + } + payload := []byte{148, byte(len(packets))} + payload = append(payload, packets...) return s.write(payload) } @@ -84,3 +89,7 @@ func (s *SerialAdapter) PauseSensorStream(pause bool) error { func (s *SerialAdapter) SendRaw(raw []byte) error { return s.write(raw) } + +func (s *SerialAdapter) SeekDock() error { + return s.write([]byte{143}) +} diff --git a/pi/roverd/wsclient.go b/pi/roverd/wsclient.go index 6ac1b63a..a55a9e53 100644 --- a/pi/roverd/wsclient.go +++ b/pi/roverd/wsclient.go @@ -15,15 +15,17 @@ type WSClient struct { cfg *Config adapter *SerialAdapter sensorFrames <-chan []byte + events <-chan RoverEvent media *MediaSupervisor log *log.Logger } -func NewWSClient(cfg *Config, adapter *SerialAdapter, frames <-chan []byte, media *MediaSupervisor, logger *log.Logger) *WSClient { +func NewWSClient(cfg *Config, adapter *SerialAdapter, frames <-chan []byte, events <-chan RoverEvent, media *MediaSupervisor, logger *log.Logger) *WSClient { return &WSClient{ cfg: cfg, adapter: adapter, sensorFrames: frames, + events: events, media: media, log: logger, } @@ -45,6 +47,7 @@ func (c *WSClient) Run(ctx context.Context) error { errCh <- c.readLoop(ctx, conn) }() go c.forwardSensors(ctx, conn) + go c.forwardEvents(ctx, conn) select { case <-ctx.Done(): @@ -112,7 +115,7 @@ func (c *WSClient) dispatch(ctx context.Context, msg *inboundMessage) error { return c.adapter.MotorPWM(main, side, vac) case msg.SensorStream != nil: if msg.SensorStream.Enable { - if err := c.adapter.StartSensorStream(streamGroupDefault); err != nil { + if err := c.adapter.StartSensorStream(defaultStreamPackets); err != nil { return err } return c.adapter.PauseSensorStream(false) @@ -153,6 +156,26 @@ func (c *WSClient) forwardSensors(ctx context.Context, conn *websocket.Conn) { } } +func (c *WSClient) forwardEvents(ctx context.Context, conn *websocket.Conn) { + if c.events == nil { + return + } + for { + select { + case <-ctx.Done(): + return + case evt := <-c.events: + if evt.Type == "" { + evt.Type = "event" + } + if err := writeJSON(ctx, conn, evt); err != nil { + c.log.Printf("event send failed: %v", err) + return + } + } + } +} + func writeJSON(ctx context.Context, conn *websocket.Conn, v any) error { data, err := json.Marshal(v) if err != nil { diff --git a/server/public/app.js b/server/public/app.js index 4e7e4c79..df9f5271 100644 --- a/server/public/app.js +++ b/server/public/app.js @@ -4,6 +4,7 @@ const roverSelect = document.getElementById('roverSelect'); const sensorToggleBtn = document.getElementById('sensorToggle'); const motorsStopBtn = document.getElementById('motorsStop'); const sensorOutput = document.getElementById('sensorOutput'); +const alertsEl = document.getElementById('alerts'); const mediaRestartBtn = document.getElementById('mediaRestart'); let selectedRover = null; let sensorEnabled = false; @@ -53,6 +54,24 @@ socket.on('commandAck', ({ roverId, status, error }) => { } }); +socket.on('roverEvent', ({ roverId, event, ts, data }) => { + if (!alertsEl) return; + const div = document.createElement('div'); + div.className = 'alert'; + const when = ts ? new Date(ts).toLocaleTimeString() : new Date().toLocaleTimeString(); + const details = data && data.error ? ` (${data.error})` : ''; + div.textContent = `[${when}] ${roverId}: ${event}${details}`; + alertsEl.prepend(div); + while (alertsEl.children.length > 5) { + alertsEl.removeChild(alertsEl.lastChild); + } + setTimeout(() => { + if (div.parentElement === alertsEl) { + alertsEl.removeChild(div); + } + }, 15000); +}); + roverSelect.addEventListener('change', (e) => { selectedRover = e.target.value; }); diff --git a/server/public/index.html b/server/public/index.html index 0b46f000..1804c683 100644 --- a/server/public/index.html +++ b/server/public/index.html @@ -10,6 +10,8 @@ #sensorOutput { font-family: monospace; height: 320px; overflow: auto; border: 1px solid #999; padding: 8px; } button { margin: 4px; } .row { margin-bottom: 8px; } + #alerts { margin-top: 12px; max-width: 600px; } + .alert { background: #fff1d2; border: 1px solid #f0b651; padding: 6px 8px; margin-bottom: 6px; font-size: 0.9rem; } @@ -38,6 +40,7 @@

Sensor Frames


         

Use WASD keys to drive the selected rover. Shift increases speed.

+
diff --git a/server/src/index.js b/server/src/index.js index 0bb1a37d..b285a2bb 100644 --- a/server/src/index.js +++ b/server/src/index.js @@ -73,6 +73,17 @@ function handleRoverConnection(ws) { io.emit('commandAck', { roverId: pending.roverId, id: msg.id, status: msg.status, error: msg.error }); } break; + case 'event': + if (!roverId) { + return; + } + io.emit('roverEvent', { + roverId, + event: msg.event, + ts: msg.ts || Date.now(), + data: msg.data || {}, + }); + break; default: break; }