mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-16 01:21:20 -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)
|
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)
|
go streamer.Run(ctx)
|
||||||
|
|
||||||
adapter := roverd.NewSerialAdapter(serialPort, logger)
|
adapter := roverd.NewSerialAdapter(serialPort, logger)
|
||||||
@@ -54,7 +57,10 @@ func main() {
|
|||||||
mediaSupervisor.Start(ctx)
|
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
|
retryDelay := time.Second
|
||||||
for ctx.Err() == nil {
|
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
|
||||||
|
}
|
||||||
@@ -12,17 +12,17 @@ import (
|
|||||||
const (
|
const (
|
||||||
sensorHeader = 19
|
sensorHeader = 19
|
||||||
sensorReadTimeout = 150 * time.Millisecond
|
sensorReadTimeout = 150 * time.Millisecond
|
||||||
streamGroupDefault = 100
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type SensorStreamer struct {
|
type SensorStreamer struct {
|
||||||
r io.Reader
|
r io.Reader
|
||||||
out chan<- []byte
|
rawOut chan<- []byte
|
||||||
|
parsed chan<- SensorSample
|
||||||
logger *log.Logger
|
logger *log.Logger
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewSensorStreamer(r io.Reader, out chan<- []byte, logger *log.Logger) *SensorStreamer {
|
func NewSensorStreamer(r io.Reader, rawOut chan<- []byte, parsed chan<- SensorSample, logger *log.Logger) *SensorStreamer {
|
||||||
return &SensorStreamer{r: r, out: out, logger: logger}
|
return &SensorStreamer{r: r, rawOut: rawOut, parsed: parsed, logger: logger}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *SensorStreamer) Run(ctx context.Context) {
|
func (s *SensorStreamer) Run(ctx context.Context) {
|
||||||
@@ -34,14 +34,14 @@ func (s *SensorStreamer) Run(ctx context.Context) {
|
|||||||
default:
|
default:
|
||||||
}
|
}
|
||||||
|
|
||||||
b, err := reader.ReadByte()
|
header, err := reader.ReadByte()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if ctx.Err() != nil {
|
if ctx.Err() != nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if b != sensorHeader {
|
if header != sensorHeader {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
nBytes, err := reader.ReadByte()
|
nBytes, err := reader.ReadByte()
|
||||||
@@ -61,9 +61,18 @@ func (s *SensorStreamer) Run(ctx context.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
select {
|
select {
|
||||||
case s.out <- frame:
|
case s.rawOut <- frame:
|
||||||
default:
|
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
|
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 (
|
import (
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"log"
|
"log"
|
||||||
@@ -68,8 +69,12 @@ func (s *SerialAdapter) MotorPWM(main, side, vacuum int) error {
|
|||||||
return s.write(payload)
|
return s.write(payload)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *SerialAdapter) StartSensorStream(group byte) error {
|
func (s *SerialAdapter) StartSensorStream(packets []byte) error {
|
||||||
payload := []byte{148, 1, group}
|
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)
|
return s.write(payload)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -84,3 +89,7 @@ func (s *SerialAdapter) PauseSensorStream(pause bool) error {
|
|||||||
func (s *SerialAdapter) SendRaw(raw []byte) error {
|
func (s *SerialAdapter) SendRaw(raw []byte) error {
|
||||||
return s.write(raw)
|
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
|
cfg *Config
|
||||||
adapter *SerialAdapter
|
adapter *SerialAdapter
|
||||||
sensorFrames <-chan []byte
|
sensorFrames <-chan []byte
|
||||||
|
events <-chan RoverEvent
|
||||||
media *MediaSupervisor
|
media *MediaSupervisor
|
||||||
log *log.Logger
|
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{
|
return &WSClient{
|
||||||
cfg: cfg,
|
cfg: cfg,
|
||||||
adapter: adapter,
|
adapter: adapter,
|
||||||
sensorFrames: frames,
|
sensorFrames: frames,
|
||||||
|
events: events,
|
||||||
media: media,
|
media: media,
|
||||||
log: logger,
|
log: logger,
|
||||||
}
|
}
|
||||||
@@ -45,6 +47,7 @@ func (c *WSClient) Run(ctx context.Context) error {
|
|||||||
errCh <- c.readLoop(ctx, conn)
|
errCh <- c.readLoop(ctx, conn)
|
||||||
}()
|
}()
|
||||||
go c.forwardSensors(ctx, conn)
|
go c.forwardSensors(ctx, conn)
|
||||||
|
go c.forwardEvents(ctx, conn)
|
||||||
|
|
||||||
select {
|
select {
|
||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
@@ -112,7 +115,7 @@ func (c *WSClient) dispatch(ctx context.Context, msg *inboundMessage) error {
|
|||||||
return c.adapter.MotorPWM(main, side, vac)
|
return c.adapter.MotorPWM(main, side, vac)
|
||||||
case msg.SensorStream != nil:
|
case msg.SensorStream != nil:
|
||||||
if msg.SensorStream.Enable {
|
if msg.SensorStream.Enable {
|
||||||
if err := c.adapter.StartSensorStream(streamGroupDefault); err != nil {
|
if err := c.adapter.StartSensorStream(defaultStreamPackets); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return c.adapter.PauseSensorStream(false)
|
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 {
|
func writeJSON(ctx context.Context, conn *websocket.Conn, v any) error {
|
||||||
data, err := json.Marshal(v)
|
data, err := json.Marshal(v)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ const roverSelect = document.getElementById('roverSelect');
|
|||||||
const sensorToggleBtn = document.getElementById('sensorToggle');
|
const sensorToggleBtn = document.getElementById('sensorToggle');
|
||||||
const motorsStopBtn = document.getElementById('motorsStop');
|
const motorsStopBtn = document.getElementById('motorsStop');
|
||||||
const sensorOutput = document.getElementById('sensorOutput');
|
const sensorOutput = document.getElementById('sensorOutput');
|
||||||
|
const alertsEl = document.getElementById('alerts');
|
||||||
const mediaRestartBtn = document.getElementById('mediaRestart');
|
const mediaRestartBtn = document.getElementById('mediaRestart');
|
||||||
let selectedRover = null;
|
let selectedRover = null;
|
||||||
let sensorEnabled = false;
|
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) => {
|
roverSelect.addEventListener('change', (e) => {
|
||||||
selectedRover = e.target.value;
|
selectedRover = e.target.value;
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -10,6 +10,8 @@
|
|||||||
#sensorOutput { font-family: monospace; height: 320px; overflow: auto; border: 1px solid #999; padding: 8px; }
|
#sensorOutput { font-family: monospace; height: 320px; overflow: auto; border: 1px solid #999; padding: 8px; }
|
||||||
button { margin: 4px; }
|
button { margin: 4px; }
|
||||||
.row { margin-bottom: 8px; }
|
.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>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
@@ -38,6 +40,7 @@
|
|||||||
<h3>Sensor Frames</h3>
|
<h3>Sensor Frames</h3>
|
||||||
<pre id="sensorOutput"></pre>
|
<pre id="sensorOutput"></pre>
|
||||||
<p>Use WASD keys to drive the selected rover. Shift increases speed.</p>
|
<p>Use WASD keys to drive the selected rover. Shift increases speed.</p>
|
||||||
|
<div id="alerts"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<script src="/socket.io/socket.io.js"></script>
|
<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 });
|
io.emit('commandAck', { roverId: pending.roverId, id: msg.id, status: msg.status, error: msg.error });
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
case 'event':
|
||||||
|
if (!roverId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
io.emit('roverEvent', {
|
||||||
|
roverId,
|
||||||
|
event: msg.event,
|
||||||
|
ts: msg.ts || Date.now(),
|
||||||
|
data: msg.data || {},
|
||||||
|
});
|
||||||
|
break;
|
||||||
default:
|
default:
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user