add some automation to pi side

This commit is contained in:
legop3
2025-11-11 21:04:04 -05:00
parent 3bc2921f25
commit 74a460a980
11 changed files with 278 additions and 17 deletions
Vendored
BIN
View File
Binary file not shown.
+109
View File
@@ -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:
}
}
+8 -2
View File
@@ -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 {
+8
View File
@@ -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"`
}
+23
View File
@@ -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
}
+61 -11
View File
@@ -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
}
+11 -2
View File
@@ -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})
}
+25 -2
View File
@@ -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 {
+19
View File
@@ -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;
});
+3
View File
@@ -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; }
</style>
</head>
<body>
@@ -38,6 +40,7 @@
<h3>Sensor Frames</h3>
<pre id="sensorOutput"></pre>
<p>Use WASD keys to drive the selected rover. Shift increases speed.</p>
<div id="alerts"></div>
</div>
</div>
<script src="/socket.io/socket.io.js"></script>
+11
View File
@@ -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;
}