mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-15 17:12:59 -04:00
add some automation to pi side
This commit is contained in:
Vendored
BIN
Binary file not shown.
@@ -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:
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user