mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-15 17:12:59 -04:00
modularize code, add dummy version of roverd
This commit is contained in:
@@ -9,3 +9,4 @@ node_modules/
|
|||||||
.vscode/
|
.vscode/
|
||||||
config.h
|
config.h
|
||||||
robots.json
|
robots.json
|
||||||
|
roverd-dummy
|
||||||
@@ -34,6 +34,17 @@ npm install
|
|||||||
npm run start
|
npm run start
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Need a fake rover or multi-rover testing without hardware? Use the dummy build:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd pi/roverd
|
||||||
|
mkdir -p ../../dist
|
||||||
|
make dummy
|
||||||
|
./../../dist/roverd-dummy -config ./roverd.sample.yaml
|
||||||
|
```
|
||||||
|
|
||||||
|
The dummy binary connects to the Node server, emits simulated sensor frames, and logs every command it receives, so you can spin up as many virtual rovers as you’d like on your dev machine.
|
||||||
|
|
||||||
Deploy a rover by copying the repo + `dist/roverd` to the Pi and running the helper (it will also fetch mediaMTX when `--mediamtx` is set):
|
Deploy a rover by copying the repo + `dist/roverd` to the Pi and running the helper (it will also fetch mediaMTX when `--mediamtx` is set):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
Vendored
+22
@@ -0,0 +1,22 @@
|
|||||||
|
#configuration for roverd
|
||||||
|
name: dummy1
|
||||||
|
serverUrl: ws://192.168.0.84:8080/rover
|
||||||
|
serial:
|
||||||
|
device: /dev/ttyS0
|
||||||
|
baud: 115200
|
||||||
|
brc:
|
||||||
|
gpioPin: 25
|
||||||
|
pulseEvery: 1m
|
||||||
|
pulseWidth: 1s
|
||||||
|
battery:
|
||||||
|
full: 2068
|
||||||
|
warn: 1700
|
||||||
|
urgent: 1650
|
||||||
|
maxWheelSpeed: 350
|
||||||
|
media:
|
||||||
|
whepUrl: https://mediaserver.local/whep/roomba-alpha
|
||||||
|
streamKey: roomba-alpha
|
||||||
|
manage: false
|
||||||
|
service: mediamtx.service
|
||||||
|
healthUrl: http://127.0.0.1:9997/v3/paths/list/rovercam
|
||||||
|
healthInterval: 30s
|
||||||
Vendored
+22
@@ -0,0 +1,22 @@
|
|||||||
|
#configuration for roverd
|
||||||
|
name: dummy2
|
||||||
|
serverUrl: ws://192.168.0.84:8080/rover
|
||||||
|
serial:
|
||||||
|
device: /dev/ttyS0
|
||||||
|
baud: 115200
|
||||||
|
brc:
|
||||||
|
gpioPin: 25
|
||||||
|
pulseEvery: 1m
|
||||||
|
pulseWidth: 1s
|
||||||
|
battery:
|
||||||
|
full: 2068
|
||||||
|
warn: 1700
|
||||||
|
urgent: 1650
|
||||||
|
maxWheelSpeed: 350
|
||||||
|
media:
|
||||||
|
whepUrl: https://mediaserver.local/whep/roomba-alpha
|
||||||
|
streamKey: roomba-alpha
|
||||||
|
manage: false
|
||||||
|
service: mediamtx.service
|
||||||
|
healthUrl: http://127.0.0.1:9997/v3/paths/list/rovercam
|
||||||
|
healthInterval: 30s
|
||||||
Vendored
BIN
Binary file not shown.
@@ -18,6 +18,18 @@ make pi-build
|
|||||||
|
|
||||||
The binary is placed in `dist/roverd` (relative to the repo root).
|
The binary is placed in `dist/roverd` (relative to the repo root).
|
||||||
|
|
||||||
|
### Dummy rover build (for local testing)
|
||||||
|
|
||||||
|
If you need extra “virtual” rovers on your laptop/CI box, build the dummy binary:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd pi/roverd
|
||||||
|
mkdir -p ../../dist
|
||||||
|
make dummy
|
||||||
|
```
|
||||||
|
|
||||||
|
Run the resulting `dist/roverd-dummy` on any machine; it will connect to the server, stream fake Group 100 sensor data, and log drive commands so you can test multi-rover features without additional hardware.
|
||||||
|
|
||||||
## Automated installation (recommended)
|
## Automated installation (recommended)
|
||||||
|
|
||||||
Once the binary (and repo) are on the Pi, run the helper script from the repo root:
|
Once the binary (and repo) are on the Pi, run the helper script from the repo root:
|
||||||
|
|||||||
+4
-1
@@ -3,7 +3,7 @@ GOOS ?= linux
|
|||||||
GOARCH ?= arm
|
GOARCH ?= arm
|
||||||
GOARM ?= 6
|
GOARM ?= 6
|
||||||
|
|
||||||
.PHONY: build pi-build clean
|
.PHONY: build pi-build dummy clean
|
||||||
|
|
||||||
build:
|
build:
|
||||||
go build -o $(BIN_DIR)/roverd ./cmd/roverd
|
go build -o $(BIN_DIR)/roverd ./cmd/roverd
|
||||||
@@ -11,5 +11,8 @@ build:
|
|||||||
pi-build:
|
pi-build:
|
||||||
GOOS=$(GOOS) GOARCH=$(GOARCH) GOARM=$(GOARM) go build -trimpath -ldflags="-s -w" -o $(BIN_DIR)/roverd ./cmd/roverd
|
GOOS=$(GOOS) GOARCH=$(GOARCH) GOARM=$(GOARM) go build -trimpath -ldflags="-s -w" -o $(BIN_DIR)/roverd ./cmd/roverd
|
||||||
|
|
||||||
|
dummy:
|
||||||
|
GOOS=linux GOARCH=amd64 go build -tags dummy -o $(BIN_DIR)/roverd-dummy ./cmd/roverd
|
||||||
|
|
||||||
clean:
|
clean:
|
||||||
rm -f $(BIN_DIR)/roverd
|
rm -f $(BIN_DIR)/roverd
|
||||||
|
|||||||
@@ -99,10 +99,10 @@ func (a *AutoChargeController) emitEvent(event string, data map[string]any) {
|
|||||||
}
|
}
|
||||||
select {
|
select {
|
||||||
case a.events <- RoverEvent{
|
case a.events <- RoverEvent{
|
||||||
Type: "event",
|
Type: "event",
|
||||||
Event: event,
|
Event: event,
|
||||||
Ts: time.Now().UnixMilli(),
|
Ts: time.Now().UnixMilli(),
|
||||||
Data: data,
|
Data: data,
|
||||||
}:
|
}:
|
||||||
default:
|
default:
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
//go:build !dummy
|
||||||
|
|
||||||
package roverd
|
package roverd
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
//go:build dummy
|
||||||
|
|
||||||
|
package roverd
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"log"
|
||||||
|
)
|
||||||
|
|
||||||
|
type BRCPulser struct{}
|
||||||
|
|
||||||
|
func NewBRCPulser(cfg BRCConfig, logger *log.Logger) (*BRCPulser, error) {
|
||||||
|
logger.Printf("[dummy] BRC configured on pin %d", cfg.GPIOPin)
|
||||||
|
return &BRCPulser{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *BRCPulser) Close() {}
|
||||||
|
|
||||||
|
func (b *BRCPulser) Start(ctx context.Context) {}
|
||||||
+4
-4
@@ -1,8 +1,8 @@
|
|||||||
package roverd
|
package roverd
|
||||||
|
|
||||||
type RoverEvent struct {
|
type RoverEvent struct {
|
||||||
Type string `json:"type"`
|
Type string `json:"type"`
|
||||||
Event string `json:"event"`
|
Event string `json:"event"`
|
||||||
Ts int64 `json:"ts"`
|
Ts int64 `json:"ts"`
|
||||||
Data map[string]any `json:"data,omitempty"`
|
Data map[string]any `json:"data,omitempty"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
//go:build !dummy
|
||||||
|
|
||||||
package roverd
|
package roverd
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -15,10 +17,10 @@ const (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type SensorStreamer struct {
|
type SensorStreamer struct {
|
||||||
r io.Reader
|
r io.Reader
|
||||||
rawOut chan<- []byte
|
rawOut chan<- []byte
|
||||||
parsed chan<- SensorSample
|
parsed chan<- SensorSample
|
||||||
logger *log.Logger
|
logger *log.Logger
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewSensorStreamer(r io.Reader, rawOut chan<- []byte, parsed chan<- SensorSample, logger *log.Logger) *SensorStreamer {
|
func NewSensorStreamer(r io.Reader, rawOut chan<- []byte, parsed chan<- SensorSample, logger *log.Logger) *SensorStreamer {
|
||||||
|
|||||||
@@ -0,0 +1,73 @@
|
|||||||
|
//go:build dummy
|
||||||
|
|
||||||
|
package roverd
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"log"
|
||||||
|
"math/rand"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const sensorHeader = 19
|
||||||
|
|
||||||
|
type SensorStreamer struct {
|
||||||
|
rawOut chan<- []byte
|
||||||
|
parsed chan<- SensorSample
|
||||||
|
logger *log.Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewSensorStreamer(_ interface{}, rawOut chan<- []byte, parsed chan<- SensorSample, logger *log.Logger) *SensorStreamer {
|
||||||
|
return &SensorStreamer{rawOut: rawOut, parsed: parsed, logger: logger}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SensorStreamer) Run(ctx context.Context) {
|
||||||
|
ticker := time.NewTicker(200 * time.Millisecond)
|
||||||
|
defer ticker.Stop()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case <-ticker.C:
|
||||||
|
frame := buildDummyFrame()
|
||||||
|
select {
|
||||||
|
case s.rawOut <- frame:
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
sample := SensorSample{
|
||||||
|
Timestamp: time.Now().UnixMilli(),
|
||||||
|
ChargingState: 3, // trickle charging
|
||||||
|
ChargeSources: 0b10, // home base present
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case s.parsed <- sample:
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildDummyFrame() []byte {
|
||||||
|
payload := make([]byte, 0, expectedPayloadLength)
|
||||||
|
payload = append(payload, 100)
|
||||||
|
group := make([]byte, packetSizes[100])
|
||||||
|
group[0] = byte(rand.Intn(16)) // bumps
|
||||||
|
payload = append(payload, group...)
|
||||||
|
payload = append(payload, 21, 3)
|
||||||
|
payload = append(payload, 34, 0b10)
|
||||||
|
|
||||||
|
buf := make([]byte, 0, len(payload)+3)
|
||||||
|
buf = append(buf, sensorHeader, byte(len(payload)))
|
||||||
|
buf = append(buf, payload...)
|
||||||
|
checksum := calcChecksum(buf)
|
||||||
|
buf = append(buf, checksum)
|
||||||
|
return buf
|
||||||
|
}
|
||||||
|
|
||||||
|
func calcChecksum(buf []byte) byte {
|
||||||
|
sum := 0
|
||||||
|
for _, b := range buf {
|
||||||
|
sum += int(b)
|
||||||
|
}
|
||||||
|
return byte((-sum) & 0xFF)
|
||||||
|
}
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
//go:build !dummy
|
||||||
|
|
||||||
package roverd
|
package roverd
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
//go:build dummy
|
||||||
|
|
||||||
|
package roverd
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"log"
|
||||||
|
)
|
||||||
|
|
||||||
|
type dummyPort struct{}
|
||||||
|
|
||||||
|
func (dummyPort) Read(p []byte) (int, error) { return 0, io.EOF }
|
||||||
|
func (dummyPort) Write(p []byte) (int, error) { return len(p), nil }
|
||||||
|
func (dummyPort) Close() error { return nil }
|
||||||
|
|
||||||
|
func OpenSerial(cfg SerialConfig) (io.ReadWriteCloser, error) {
|
||||||
|
return dummyPort{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type SerialAdapter struct {
|
||||||
|
log *log.Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewSerialAdapter(_ io.ReadWriteCloser, logger *log.Logger) *SerialAdapter {
|
||||||
|
return &SerialAdapter{log: logger}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SerialAdapter) DriveDirect(left, right int) error {
|
||||||
|
s.log.Printf("[dummy] drive L=%d R=%d", left, right)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SerialAdapter) MotorPWM(main, side, vacuum int) error {
|
||||||
|
s.log.Printf("[dummy] motor main=%d side=%d vacuum=%d", main, side, vacuum)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SerialAdapter) StartSensorStream(packets []byte) error {
|
||||||
|
if len(packets) == 0 {
|
||||||
|
return errors.New("sensor stream requires packets")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SerialAdapter) PauseSensorStream(pause bool) error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SerialAdapter) SendRaw(raw []byte) error {
|
||||||
|
s.log.Printf("[dummy] raw %v", raw)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SerialAdapter) SeekDock() error {
|
||||||
|
s.log.Printf("[dummy] seek dock")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
require('./src/globals/logger');
|
||||||
|
require('./src/globals/config');
|
||||||
|
require('./src/globals/http');
|
||||||
|
require('./src/globals/io');
|
||||||
|
require('./src/globals/ws');
|
||||||
|
|
||||||
|
require('./src/helpers/sensorDecoder');
|
||||||
|
|
||||||
|
require('./src/services/alertService');
|
||||||
|
require('./src/services/authService');
|
||||||
|
require('./src/services/modeManager');
|
||||||
|
require('./src/services/roverManager');
|
||||||
|
require('./src/services/commandService');
|
||||||
|
require('./src/services/roverConnectionService');
|
||||||
|
require('./src/services/httpServer');
|
||||||
+2
-2
@@ -3,8 +3,8 @@
|
|||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "node src/index.js",
|
"start": "node index.js",
|
||||||
"dev": "nodemon src/index.js"
|
"dev": "nodemon index.js"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"express": "^4.19.2",
|
"express": "^4.19.2",
|
||||||
|
|||||||
@@ -1,193 +0,0 @@
|
|||||||
const socket = io();
|
|
||||||
const statusEl = document.getElementById('status');
|
|
||||||
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;
|
|
||||||
let lastDrive = { left: 0, right: 0 };
|
|
||||||
|
|
||||||
const OI_COMMANDS = {
|
|
||||||
start: [128],
|
|
||||||
safe: [131],
|
|
||||||
full: [132],
|
|
||||||
passive: [128],
|
|
||||||
dock: [143],
|
|
||||||
};
|
|
||||||
|
|
||||||
socket.on('connect', () => {
|
|
||||||
statusEl.textContent = 'Connected';
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('disconnect', () => {
|
|
||||||
statusEl.textContent = 'Disconnected';
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('rovers', (list) => {
|
|
||||||
roverSelect.innerHTML = '';
|
|
||||||
list.forEach((rover) => {
|
|
||||||
const option = document.createElement('option');
|
|
||||||
option.value = rover.id;
|
|
||||||
option.textContent = `${rover.name} (${rover.id})`;
|
|
||||||
roverSelect.appendChild(option);
|
|
||||||
});
|
|
||||||
if (list.length && !selectedRover) {
|
|
||||||
roverSelect.selectedIndex = 0;
|
|
||||||
selectedRover = list[0].id;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('sensorFrame', ({ roverId, frame, sensors }) => {
|
|
||||||
if (roverId !== selectedRover) return;
|
|
||||||
sensorOutput.textContent = renderSensors(sensors, frame?.data);
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('commandAck', ({ roverId, status, error }) => {
|
|
||||||
if (roverId !== selectedRover) return;
|
|
||||||
if (status === 'ok') {
|
|
||||||
statusEl.textContent = 'Command applied';
|
|
||||||
} else {
|
|
||||||
statusEl.textContent = `Command failed: ${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;
|
|
||||||
});
|
|
||||||
|
|
||||||
sensorToggleBtn.addEventListener('click', () => {
|
|
||||||
if (!selectedRover) return;
|
|
||||||
sensorEnabled = !sensorEnabled;
|
|
||||||
sensorToggleBtn.textContent = sensorEnabled ? 'Disable Sensor Stream' : 'Enable Sensor Stream';
|
|
||||||
sendCommand('sensorStream', { enable: sensorEnabled });
|
|
||||||
});
|
|
||||||
|
|
||||||
motorsStopBtn.addEventListener('click', () => {
|
|
||||||
sendCommand('motors', { main: 0, side: 0, vacuum: 0 });
|
|
||||||
});
|
|
||||||
|
|
||||||
mediaRestartBtn.addEventListener('click', () => {
|
|
||||||
sendCommand('media', { action: 'restart' });
|
|
||||||
});
|
|
||||||
|
|
||||||
document.querySelectorAll('[data-mode]').forEach((btn) => {
|
|
||||||
btn.addEventListener('click', () => {
|
|
||||||
const mode = btn.dataset.mode;
|
|
||||||
if (!OI_COMMANDS[mode]) return;
|
|
||||||
sendCommand('raw', { bytes: OI_COMMANDS[mode] });
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
const keys = new Set();
|
|
||||||
const driveInterval = 50;
|
|
||||||
|
|
||||||
window.addEventListener('keydown', (event) => {
|
|
||||||
keys.add(event.key.toLowerCase());
|
|
||||||
});
|
|
||||||
|
|
||||||
window.addEventListener('keyup', (event) => {
|
|
||||||
keys.delete(event.key.toLowerCase());
|
|
||||||
});
|
|
||||||
|
|
||||||
setInterval(() => {
|
|
||||||
if (!selectedRover) return;
|
|
||||||
const speeds = computeDrive();
|
|
||||||
if (speeds.left === lastDrive.left && speeds.right === lastDrive.right) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
lastDrive = speeds;
|
|
||||||
sendCommand('drive', speeds);
|
|
||||||
}, driveInterval);
|
|
||||||
|
|
||||||
function computeDrive() {
|
|
||||||
const forward = keys.has('w');
|
|
||||||
const backward = keys.has('s');
|
|
||||||
const left = keys.has('a');
|
|
||||||
const right = keys.has('d');
|
|
||||||
const fast = keys.has('shift');
|
|
||||||
const base = fast ? 300 : 150;
|
|
||||||
let leftSpeed = 0;
|
|
||||||
let rightSpeed = 0;
|
|
||||||
|
|
||||||
if (forward && !backward) {
|
|
||||||
leftSpeed += base;
|
|
||||||
rightSpeed += base;
|
|
||||||
} else if (backward && !forward) {
|
|
||||||
leftSpeed -= base;
|
|
||||||
rightSpeed -= base;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (left && !right) {
|
|
||||||
leftSpeed -= base;
|
|
||||||
rightSpeed += base;
|
|
||||||
} else if (right && !left) {
|
|
||||||
leftSpeed += base;
|
|
||||||
rightSpeed -= base;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!forward && !backward && (left || right)) {
|
|
||||||
leftSpeed = left ? -base : base;
|
|
||||||
rightSpeed = left ? base : -base;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
left: clamp(leftSpeed, -500, 500),
|
|
||||||
right: clamp(rightSpeed, -500, 500),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function sendCommand(type, data = {}) {
|
|
||||||
if (!selectedRover) return;
|
|
||||||
socket.emit('command', { roverId: selectedRover, type, data });
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatSensorFrame(base64) {
|
|
||||||
const buffer = Uint8Array.from(atob(base64), (c) => c.charCodeAt(0));
|
|
||||||
return Array.from(buffer)
|
|
||||||
.map((b) => b.toString(16).padStart(2, '0'))
|
|
||||||
.join(' ');
|
|
||||||
}
|
|
||||||
|
|
||||||
function clamp(value, min, max) {
|
|
||||||
return Math.max(min, Math.min(max, value));
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderSensors(sensors = {}, rawBase64) {
|
|
||||||
const lines = [];
|
|
||||||
if (sensors && Object.keys(sensors).length) {
|
|
||||||
for (const [key, value] of Object.entries(sensors)) {
|
|
||||||
if (value && typeof value === 'object' && !Array.isArray(value)) {
|
|
||||||
lines.push(`${key}: ${JSON.stringify(value)}`);
|
|
||||||
} else {
|
|
||||||
lines.push(`${key}: ${value}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
lines.push('No decoded sensor data yet.');
|
|
||||||
}
|
|
||||||
if (rawBase64) {
|
|
||||||
lines.push('', 'raw:', formatSensorFrame(rawBase64));
|
|
||||||
}
|
|
||||||
return lines.join('\n');
|
|
||||||
}
|
|
||||||
@@ -22,18 +22,21 @@
|
|||||||
<label for="roverSelect">Rovers</label>
|
<label for="roverSelect">Rovers</label>
|
||||||
<select id="roverSelect" size="10" style="width: 100%"></select>
|
<select id="roverSelect" size="10" style="width: 100%"></select>
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<button data-mode="start">Start OI</button>
|
<button id="requestControl">Request Control</button>
|
||||||
<button data-mode="safe">Safe</button>
|
<button id="lockToggle">Lock/Unlock</button>
|
||||||
<button data-mode="full">Full</button>
|
</div>
|
||||||
<button data-mode="passive">Passive</button>
|
<div class="row">
|
||||||
<button data-mode="dock">Dock</button>
|
<button data-oi="start">Start OI</button>
|
||||||
|
<button data-oi="safe">Safe</button>
|
||||||
|
<button data-oi="full">Full</button>
|
||||||
|
<button data-oi="passive">Passive</button>
|
||||||
|
<button data-oi="dock">Dock</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<button id="sensorToggle">Enable Sensor Stream</button>
|
<button id="sensorToggle">Enable Sensor Stream</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<button id="motorsStop">Stop Motors</button>
|
<button id="motorsStop">Stop Motors</button>
|
||||||
<button id="mediaRestart">Restart Camera</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div style="flex: 1">
|
<div style="flex: 1">
|
||||||
@@ -44,6 +47,13 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<script src="/socket.io/socket.io.js"></script>
|
<script src="/socket.io/socket.io.js"></script>
|
||||||
<script src="app.js" type="module"></script>
|
<script src="loader.js"></script>
|
||||||
|
<script src="src/globals/socket.js"></script>
|
||||||
|
<script src="src/helpers/formatters.js"></script>
|
||||||
|
<script src="src/services/state.js"></script>
|
||||||
|
<script src="src/services/alertFeed.js"></script>
|
||||||
|
<script src="src/services/roverUI.js"></script>
|
||||||
|
<script src="src/services/driveControls.js"></script>
|
||||||
|
<script src="src/main.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
(function () {
|
||||||
|
const modules = {};
|
||||||
|
const cache = {};
|
||||||
|
|
||||||
|
window.registerModule = function (name, factory) {
|
||||||
|
modules[name] = factory;
|
||||||
|
};
|
||||||
|
|
||||||
|
window.requireModule = function (name) {
|
||||||
|
if (cache[name]) {
|
||||||
|
return cache[name];
|
||||||
|
}
|
||||||
|
const factory = modules[name];
|
||||||
|
if (!factory) {
|
||||||
|
throw new Error(`Module ${name} not found`);
|
||||||
|
}
|
||||||
|
const exports = {};
|
||||||
|
cache[name] = exports;
|
||||||
|
factory(requireModule, exports);
|
||||||
|
return exports;
|
||||||
|
};
|
||||||
|
})();
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
registerModule('globals/socket', (require, exports) => {
|
||||||
|
const socket = io();
|
||||||
|
exports.socket = socket;
|
||||||
|
});
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
registerModule('helpers/formatters', (require, exports) => {
|
||||||
|
function formatHex(base64) {
|
||||||
|
if (!base64) return '';
|
||||||
|
const buffer = Uint8Array.from(atob(base64), (c) => c.charCodeAt(0));
|
||||||
|
return Array.from(buffer)
|
||||||
|
.map((b) => b.toString(16).padStart(2, '0'))
|
||||||
|
.join(' ');
|
||||||
|
}
|
||||||
|
|
||||||
|
exports.formatHex = formatHex;
|
||||||
|
});
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
requireModule('globals/socket');
|
||||||
|
requireModule('helpers/formatters');
|
||||||
|
requireModule('services/state');
|
||||||
|
requireModule('services/alertFeed');
|
||||||
|
requireModule('services/roverUI');
|
||||||
|
requireModule('services/driveControls');
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
registerModule('services/alertFeed', (require, exports) => {
|
||||||
|
const { socket } = require('globals/socket');
|
||||||
|
|
||||||
|
const container = document.getElementById('alerts');
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
|
function pushAlert({ color = '#2196f3', title, message, ts }) {
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.className = 'alert';
|
||||||
|
div.style.borderColor = color;
|
||||||
|
div.style.background = color + '22';
|
||||||
|
div.innerHTML = `<strong>${title || 'Notice'}</strong> - ${message || ''}`;
|
||||||
|
container.prepend(div);
|
||||||
|
while (container.children.length > 5) {
|
||||||
|
container.removeChild(container.lastChild);
|
||||||
|
}
|
||||||
|
setTimeout(() => {
|
||||||
|
if (div.parentElement === container) {
|
||||||
|
container.removeChild(div);
|
||||||
|
}
|
||||||
|
}, 15000);
|
||||||
|
}
|
||||||
|
|
||||||
|
socket.on('alert', pushAlert);
|
||||||
|
});
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
registerModule('services/driveControls', (require, exports) => {
|
||||||
|
const { socket } = require('globals/socket');
|
||||||
|
const state = require('services/state');
|
||||||
|
|
||||||
|
const motorsStopBtn = document.getElementById('motorsStop');
|
||||||
|
const sensorToggleBtn = document.getElementById('sensorToggle');
|
||||||
|
const modeButtons = document.querySelectorAll('[data-oi]');
|
||||||
|
|
||||||
|
let sensorEnabled = false;
|
||||||
|
let lastDrive = { left: 0, right: 0 };
|
||||||
|
const keys = new Set();
|
||||||
|
const driveInterval = 120;
|
||||||
|
|
||||||
|
window.addEventListener('keydown', (event) => {
|
||||||
|
keys.add(event.key.toLowerCase());
|
||||||
|
});
|
||||||
|
|
||||||
|
window.addEventListener('keyup', (event) => {
|
||||||
|
keys.delete(event.key.toLowerCase());
|
||||||
|
});
|
||||||
|
|
||||||
|
setInterval(() => {
|
||||||
|
const roverId = state.getSelected();
|
||||||
|
if (!roverId) return;
|
||||||
|
const speeds = computeDrive();
|
||||||
|
if (speeds.left === lastDrive.left && speeds.right === lastDrive.right) return;
|
||||||
|
lastDrive = speeds;
|
||||||
|
socket.emit('command', {
|
||||||
|
roverId,
|
||||||
|
type: 'drive',
|
||||||
|
data: { driveDirect: speeds },
|
||||||
|
});
|
||||||
|
}, driveInterval);
|
||||||
|
|
||||||
|
function computeDrive() {
|
||||||
|
const forward = keys.has('w');
|
||||||
|
const backward = keys.has('s');
|
||||||
|
const left = keys.has('a');
|
||||||
|
const right = keys.has('d');
|
||||||
|
const fast = keys.has('shift');
|
||||||
|
const base = fast ? 300 : 150;
|
||||||
|
let leftSpeed = 0;
|
||||||
|
let rightSpeed = 0;
|
||||||
|
|
||||||
|
if (forward && !backward) {
|
||||||
|
leftSpeed += base;
|
||||||
|
rightSpeed += base;
|
||||||
|
} else if (backward && !forward) {
|
||||||
|
leftSpeed -= base;
|
||||||
|
rightSpeed -= base;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (left && !right) {
|
||||||
|
leftSpeed -= base;
|
||||||
|
rightSpeed += base;
|
||||||
|
} else if (right && !left) {
|
||||||
|
leftSpeed += base;
|
||||||
|
rightSpeed -= base;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!forward && !backward && (left || right)) {
|
||||||
|
leftSpeed = left ? -base : base;
|
||||||
|
rightSpeed = left ? base : -base;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
left: clamp(leftSpeed, -500, 500),
|
||||||
|
right: clamp(rightSpeed, -500, 500),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function clamp(value, min, max) {
|
||||||
|
return Math.max(min, Math.min(max, value));
|
||||||
|
}
|
||||||
|
|
||||||
|
motorsStopBtn?.addEventListener('click', () => {
|
||||||
|
const roverId = state.getSelected();
|
||||||
|
if (!roverId) return;
|
||||||
|
socket.emit('command', {
|
||||||
|
roverId,
|
||||||
|
type: 'motors',
|
||||||
|
data: { motorPwm: { main: 0, side: 0, vacuum: 0 } },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
sensorToggleBtn?.addEventListener('click', () => {
|
||||||
|
sensorEnabled = !sensorEnabled;
|
||||||
|
sensorToggleBtn.textContent = sensorEnabled ? 'Disable Sensor Stream' : 'Enable Sensor Stream';
|
||||||
|
const roverId = state.getSelected();
|
||||||
|
socket.emit('command', {
|
||||||
|
roverId,
|
||||||
|
type: 'sensorStream',
|
||||||
|
data: { sensorStream: { enable: sensorEnabled } },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const OI_COMMANDS = {
|
||||||
|
start: [128],
|
||||||
|
safe: [131],
|
||||||
|
full: [132],
|
||||||
|
passive: [128],
|
||||||
|
dock: [143],
|
||||||
|
};
|
||||||
|
|
||||||
|
modeButtons.forEach((btn) => {
|
||||||
|
btn.addEventListener('click', () => {
|
||||||
|
const cmd = btn.dataset.oi;
|
||||||
|
const bytes = OI_COMMANDS[cmd];
|
||||||
|
if (!bytes) return;
|
||||||
|
const roverId = state.getSelected();
|
||||||
|
socket.emit('command', {
|
||||||
|
roverId,
|
||||||
|
type: 'raw',
|
||||||
|
data: { raw: bytesToBase64(bytes) },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function bytesToBase64(bytes) {
|
||||||
|
return btoa(String.fromCharCode(...bytes));
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
registerModule('services/roverUI', (require, exports) => {
|
||||||
|
const { socket } = require('globals/socket');
|
||||||
|
const { formatHex } = require('helpers/formatters');
|
||||||
|
const state = require('services/state');
|
||||||
|
|
||||||
|
const statusEl = document.getElementById('status');
|
||||||
|
const roverSelect = document.getElementById('roverSelect');
|
||||||
|
const sensorOutput = document.getElementById('sensorOutput');
|
||||||
|
const requestBtn = document.getElementById('requestControl');
|
||||||
|
const lockBtn = document.getElementById('lockToggle');
|
||||||
|
|
||||||
|
let roster = [];
|
||||||
|
|
||||||
|
socket.on('rovers', (list) => {
|
||||||
|
roster = list;
|
||||||
|
roverSelect.innerHTML = '';
|
||||||
|
list.forEach((rover) => {
|
||||||
|
const option = document.createElement('option');
|
||||||
|
option.value = rover.id;
|
||||||
|
option.textContent = `${rover.name}${rover.locked ? ' (locked)' : ''}`;
|
||||||
|
roverSelect.appendChild(option);
|
||||||
|
});
|
||||||
|
if (!state.getSelected() && list.length) {
|
||||||
|
state.setSelected(list[0].id);
|
||||||
|
roverSelect.value = list[0].id;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
roverSelect.addEventListener('change', () => {
|
||||||
|
state.setSelected(roverSelect.value);
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('controlGranted', ({ roverId }) => {
|
||||||
|
state.setSelected(roverId);
|
||||||
|
roverSelect.value = roverId;
|
||||||
|
statusEl.textContent = `Driving ${roverId}`;
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('sensorFrame', ({ roverId, frame, sensors }) => {
|
||||||
|
if (roverId !== state.getSelected()) return;
|
||||||
|
const lines = [];
|
||||||
|
if (sensors) {
|
||||||
|
Object.entries(sensors).forEach(([key, value]) => {
|
||||||
|
lines.push(`${key}: ${typeof value === 'object' ? JSON.stringify(value) : value}`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
lines.push('', 'raw:', formatHex(frame?.data));
|
||||||
|
sensorOutput.textContent = lines.join('\n');
|
||||||
|
});
|
||||||
|
|
||||||
|
requestBtn?.addEventListener('click', () => {
|
||||||
|
socket.emit('requestControl', { roverId: state.getSelected() });
|
||||||
|
});
|
||||||
|
|
||||||
|
lockBtn?.addEventListener('click', () => {
|
||||||
|
const roverId = state.getSelected();
|
||||||
|
const rover = roster.find((r) => r.id === roverId);
|
||||||
|
if (!rover) return;
|
||||||
|
socket.emit('lockRover', { roverId, locked: !rover.locked });
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('connect', () => {
|
||||||
|
statusEl.textContent = 'Connected';
|
||||||
|
});
|
||||||
|
socket.on('disconnect', () => {
|
||||||
|
statusEl.textContent = 'Disconnected';
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
registerModule('services/state', (require, exports) => {
|
||||||
|
let selectedRover = null;
|
||||||
|
|
||||||
|
function setSelected(roverId) {
|
||||||
|
selectedRover = roverId;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSelected() {
|
||||||
|
return selectedRover;
|
||||||
|
}
|
||||||
|
|
||||||
|
exports.setSelected = setSelected;
|
||||||
|
exports.getSelected = getSelected;
|
||||||
|
});
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
port: process.env.PORT || 8080,
|
||||||
|
staticDir: path.join(__dirname, '..', '..', 'public'),
|
||||||
|
};
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
const path = require('path');
|
||||||
|
const http = require('http');
|
||||||
|
const express = require('express');
|
||||||
|
const morgan = require('morgan');
|
||||||
|
const config = require('./config');
|
||||||
|
|
||||||
|
const app = express();
|
||||||
|
app.use(morgan('dev'));
|
||||||
|
app.use(express.json());
|
||||||
|
app.use(express.static(config.staticDir));
|
||||||
|
|
||||||
|
const httpServer = http.createServer(app);
|
||||||
|
|
||||||
|
module.exports = { app, httpServer };
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
const { Server: SocketIOServer } = require('socket.io');
|
||||||
|
const { httpServer } = require('./http');
|
||||||
|
|
||||||
|
const io = new SocketIOServer(httpServer, {
|
||||||
|
cors: { origin: '*' },
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = io;
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
function stamp(level, args) {
|
||||||
|
return [new Date().toISOString(), `[${level}]`, ...args];
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
info: (...args) => console.log(...stamp('INFO', args)),
|
||||||
|
warn: (...args) => console.warn(...stamp('WARN', args)),
|
||||||
|
error: (...args) => console.error(...stamp('ERROR', args)),
|
||||||
|
};
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
const { WebSocketServer } = require('ws');
|
||||||
|
const { httpServer } = require('./http');
|
||||||
|
const logger = require('./logger');
|
||||||
|
|
||||||
|
const roverWSS = new WebSocketServer({ noServer: true });
|
||||||
|
|
||||||
|
httpServer.on('upgrade', (req, socket, head) => {
|
||||||
|
if (req.url.startsWith('/rover')) {
|
||||||
|
roverWSS.handleUpgrade(req, socket, head, (ws) => {
|
||||||
|
roverWSS.emit('connection', ws, req);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
socket.destroy();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
roverWSS.on('connection', () => {
|
||||||
|
logger.info('Rover websocket connected');
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = roverWSS;
|
||||||
@@ -1,185 +0,0 @@
|
|||||||
const http = require('http');
|
|
||||||
const path = require('path');
|
|
||||||
const express = require('express');
|
|
||||||
const morgan = require('morgan');
|
|
||||||
const { Server: SocketIOServer } = require('socket.io');
|
|
||||||
const { WebSocketServer } = require('ws');
|
|
||||||
const { v4: uuidv4 } = require('uuid');
|
|
||||||
const { parseSensorFrame } = require('./sensorDecoder');
|
|
||||||
|
|
||||||
const PORT = process.env.PORT || 8080;
|
|
||||||
|
|
||||||
const app = express();
|
|
||||||
app.use(morgan('dev'));
|
|
||||||
app.use(express.json());
|
|
||||||
app.use(express.static(path.join(__dirname, '..', 'public')));
|
|
||||||
|
|
||||||
const httpServer = http.createServer(app);
|
|
||||||
const io = new SocketIOServer(httpServer, {
|
|
||||||
cors: { origin: '*' },
|
|
||||||
});
|
|
||||||
|
|
||||||
const roverWSS = new WebSocketServer({ noServer: true });
|
|
||||||
const rovers = new Map();
|
|
||||||
const pendingCommands = new Map();
|
|
||||||
|
|
||||||
httpServer.on('upgrade', (req, socket, head) => {
|
|
||||||
if (req.url.startsWith('/rover')) {
|
|
||||||
roverWSS.handleUpgrade(req, socket, head, (ws) => {
|
|
||||||
roverWSS.emit('connection', ws, req);
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
socket.destroy();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
roverWSS.on('connection', (ws) => {
|
|
||||||
handleRoverConnection(ws);
|
|
||||||
});
|
|
||||||
|
|
||||||
function handleRoverConnection(ws) {
|
|
||||||
let roverId = null;
|
|
||||||
ws.on('message', (raw) => {
|
|
||||||
let msg;
|
|
||||||
try {
|
|
||||||
msg = JSON.parse(raw.toString());
|
|
||||||
} catch (err) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
switch (msg.type) {
|
|
||||||
case 'hello':
|
|
||||||
roverId = msg.name;
|
|
||||||
rovers.set(roverId, {
|
|
||||||
id: roverId,
|
|
||||||
ws,
|
|
||||||
meta: msg,
|
|
||||||
lastSensor: null,
|
|
||||||
lastSeen: Date.now(),
|
|
||||||
});
|
|
||||||
broadcastRoster();
|
|
||||||
break;
|
|
||||||
case 'sensor':
|
|
||||||
if (!roverId || !rovers.has(roverId)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const rover = rovers.get(roverId);
|
|
||||||
const decoded = parseSensorFrame(msg.data);
|
|
||||||
rover.lastSensor = { raw: msg, decoded };
|
|
||||||
rover.lastSeen = Date.now();
|
|
||||||
io.emit('sensorFrame', { roverId, frame: msg, sensors: decoded });
|
|
||||||
break;
|
|
||||||
case 'ack':
|
|
||||||
if (msg.id && pendingCommands.has(msg.id)) {
|
|
||||||
const pending = pendingCommands.get(msg.id);
|
|
||||||
pendingCommands.delete(msg.id);
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
ws.on('close', () => {
|
|
||||||
if (roverId) {
|
|
||||||
rovers.delete(roverId);
|
|
||||||
broadcastRoster();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
io.on('connection', (socket) => {
|
|
||||||
socket.emit('rovers', getRoster());
|
|
||||||
|
|
||||||
socket.on('command', (payload = {}, cb = () => {}) => {
|
|
||||||
try {
|
|
||||||
const commandId = routeCommand(payload);
|
|
||||||
cb({ id: commandId });
|
|
||||||
} catch (err) {
|
|
||||||
cb({ error: err.message });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
function getRoster() {
|
|
||||||
return Array.from(rovers.values()).map(({ id, meta, lastSeen }) => ({
|
|
||||||
id,
|
|
||||||
name: meta?.name ?? id,
|
|
||||||
battery: meta?.battery ?? null,
|
|
||||||
maxWheelSpeed: meta?.maxWheelSpeed ?? null,
|
|
||||||
media: meta?.media ?? null,
|
|
||||||
lastSeen,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
function broadcastRoster() {
|
|
||||||
io.emit('rovers', getRoster());
|
|
||||||
}
|
|
||||||
|
|
||||||
function routeCommand(payload) {
|
|
||||||
const { roverId, type, data } = payload;
|
|
||||||
if (!roverId) {
|
|
||||||
throw new Error('roverId missing');
|
|
||||||
}
|
|
||||||
if (!rovers.has(roverId)) {
|
|
||||||
throw new Error(`rover ${roverId} not connected`);
|
|
||||||
}
|
|
||||||
const rover = rovers.get(roverId);
|
|
||||||
const message = buildCommand(type, data);
|
|
||||||
const id = uuidv4();
|
|
||||||
message.id = id;
|
|
||||||
rover.ws.send(JSON.stringify(message));
|
|
||||||
pendingCommands.set(id, { roverId, issuedAt: Date.now(), type });
|
|
||||||
return id;
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildCommand(type, data = {}) {
|
|
||||||
switch (type) {
|
|
||||||
case 'drive':
|
|
||||||
return { type: 'drive', driveDirect: { left: data.left || 0, right: data.right || 0 } };
|
|
||||||
case 'motors':
|
|
||||||
return {
|
|
||||||
type: 'motors',
|
|
||||||
motorPwm: {
|
|
||||||
main: data.main ?? 0,
|
|
||||||
side: data.side ?? 0,
|
|
||||||
vacuum: data.vacuum ?? 0,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
case 'raw':
|
|
||||||
return { type: 'raw', raw: Buffer.from(data.bytes || []).toString('base64') };
|
|
||||||
case 'sensorStream':
|
|
||||||
return { type: 'sensorStream', sensorStream: { enable: Boolean(data.enable) } };
|
|
||||||
case 'media':
|
|
||||||
if (!data || !data.action) {
|
|
||||||
throw new Error('media action required');
|
|
||||||
}
|
|
||||||
return { type: 'media', media: { action: data.action } };
|
|
||||||
default:
|
|
||||||
throw new Error(`unknown command type: ${type}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (require.main === module) {
|
|
||||||
httpServer.listen(PORT, () => {
|
|
||||||
console.log(`Server listening on :${PORT}`);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = {
|
|
||||||
httpServer,
|
|
||||||
io,
|
|
||||||
routeCommand,
|
|
||||||
};
|
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
const io = require('../globals/io');
|
||||||
|
|
||||||
|
const COLORS = {
|
||||||
|
info: '#2196f3',
|
||||||
|
success: '#4caf50',
|
||||||
|
warn: '#f0b651',
|
||||||
|
error: '#e53935',
|
||||||
|
};
|
||||||
|
|
||||||
|
function sendAlert({ color, title, message, ts = Date.now() }) {
|
||||||
|
const payload = {
|
||||||
|
color: color || COLORS.info,
|
||||||
|
title,
|
||||||
|
message,
|
||||||
|
ts,
|
||||||
|
};
|
||||||
|
io.emit('alert', payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
COLORS,
|
||||||
|
sendAlert,
|
||||||
|
};
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
function isAdmin(/* socket */) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isLockdownAdmin(/* socket */) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
isAdmin,
|
||||||
|
isLockdownAdmin,
|
||||||
|
};
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
const { v4: uuidv4 } = require('uuid');
|
||||||
|
const io = require('../globals/io');
|
||||||
|
const roverManager = require('./roverManager');
|
||||||
|
const { isAdmin } = require('./authService');
|
||||||
|
|
||||||
|
const pendingCommands = new Map(); // id -> { roverId }
|
||||||
|
|
||||||
|
function issueCommand(roverId, payload) {
|
||||||
|
const record = roverManager.rovers.get(roverId);
|
||||||
|
if (!record || !record.ws) {
|
||||||
|
throw new Error('Rover offline');
|
||||||
|
}
|
||||||
|
const id = uuidv4();
|
||||||
|
const message = { ...payload, id };
|
||||||
|
record.ws.send(JSON.stringify(message));
|
||||||
|
pendingCommands.set(id, { roverId, ts: Date.now(), type: payload.type });
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleAck(msg) {
|
||||||
|
const pending = pendingCommands.get(msg.id);
|
||||||
|
if (!pending) return;
|
||||||
|
pendingCommands.delete(msg.id);
|
||||||
|
io.emit('commandAck', {
|
||||||
|
roverId: pending.roverId,
|
||||||
|
id: msg.id,
|
||||||
|
status: msg.status || 'ok',
|
||||||
|
error: msg.error,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
issueCommand,
|
||||||
|
handleAck,
|
||||||
|
};
|
||||||
|
|
||||||
|
io.on('connection', (socket) => {
|
||||||
|
socket.on('command', ({ roverId, type, data } = {}, cb = () => {}) => {
|
||||||
|
try {
|
||||||
|
if (!roverId) {
|
||||||
|
throw new Error('roverId required');
|
||||||
|
}
|
||||||
|
if (!roverManager.isDriver(roverId, socket) && !isAdmin(socket)) {
|
||||||
|
throw new Error('Not controlling this rover');
|
||||||
|
}
|
||||||
|
const payload = data ? { ...data } : {};
|
||||||
|
const id = issueCommand(roverId, { type, ...payload });
|
||||||
|
cb({ id });
|
||||||
|
} catch (err) {
|
||||||
|
cb({ error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
const { httpServer } = require('../globals/http');
|
||||||
|
const config = require('../globals/config');
|
||||||
|
const logger = require('../globals/logger');
|
||||||
|
|
||||||
|
httpServer.listen(config.port, () => {
|
||||||
|
logger.info(`Server listening on :${config.port}`);
|
||||||
|
});
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
const io = require('../globals/io');
|
||||||
|
const { sendAlert, COLORS } = require('./alertService');
|
||||||
|
const { isAdmin, isLockdownAdmin } = require('./authService');
|
||||||
|
|
||||||
|
const MODES = {
|
||||||
|
OPEN: 'open',
|
||||||
|
TURNS: 'turns',
|
||||||
|
ADMIN: 'admin',
|
||||||
|
LOCKDOWN: 'lockdown',
|
||||||
|
};
|
||||||
|
|
||||||
|
let currentMode = MODES.OPEN;
|
||||||
|
|
||||||
|
function canChangeMode(socket, nextMode) {
|
||||||
|
if (nextMode === MODES.LOCKDOWN) {
|
||||||
|
return isLockdownAdmin(socket);
|
||||||
|
}
|
||||||
|
return isAdmin(socket);
|
||||||
|
}
|
||||||
|
|
||||||
|
function setMode(nextMode, socket) {
|
||||||
|
if (!Object.values(MODES).includes(nextMode)) {
|
||||||
|
throw new Error(`Unknown mode ${nextMode}`);
|
||||||
|
}
|
||||||
|
if (!canChangeMode(socket, nextMode)) {
|
||||||
|
throw new Error('Not authorized to change mode');
|
||||||
|
}
|
||||||
|
if (currentMode === nextMode) {
|
||||||
|
return currentMode;
|
||||||
|
}
|
||||||
|
currentMode = nextMode;
|
||||||
|
sendAlert({
|
||||||
|
color: COLORS.info,
|
||||||
|
title: 'Mode Changed',
|
||||||
|
message: `Server mode set to ${nextMode}`,
|
||||||
|
});
|
||||||
|
return currentMode;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getMode() {
|
||||||
|
return currentMode;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
MODES,
|
||||||
|
getMode,
|
||||||
|
setMode,
|
||||||
|
};
|
||||||
|
|
||||||
|
io.on('connection', (socket) => {
|
||||||
|
socket.on('setMode', ({ mode }) => {
|
||||||
|
try {
|
||||||
|
setMode(mode, socket);
|
||||||
|
} catch (err) {
|
||||||
|
sendAlert({ color: COLORS.error, title: 'Mode change failed', message: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
const roverWSS = require('../globals/ws');
|
||||||
|
const logger = require('../globals/logger');
|
||||||
|
const roverManager = require('./roverManager');
|
||||||
|
const { sendAlert, COLORS } = require('./alertService');
|
||||||
|
const { handleAck } = require('./commandService');
|
||||||
|
|
||||||
|
function handleMessage(roverId, msg) {
|
||||||
|
switch (msg.type) {
|
||||||
|
case 'hello':
|
||||||
|
roverManager.upsertRover(msg, this);
|
||||||
|
sendAlert({ color: COLORS.info, title: 'Rover Connected', message: roverId });
|
||||||
|
break;
|
||||||
|
case 'sensor':
|
||||||
|
roverManager.handleSensorFrame(roverId, msg);
|
||||||
|
break;
|
||||||
|
case 'event':
|
||||||
|
sendAlert({ color: COLORS.info, title: `${roverId} event`, message: msg.event });
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
roverWSS.on('connection', (ws) => {
|
||||||
|
let roverId = null;
|
||||||
|
ws.on('message', (raw) => {
|
||||||
|
let msg;
|
||||||
|
try {
|
||||||
|
msg = JSON.parse(raw.toString());
|
||||||
|
} catch (err) {
|
||||||
|
logger.warn('Invalid rover message', err.message);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (msg.type === 'hello') {
|
||||||
|
roverId = msg.name;
|
||||||
|
roverManager.upsertRover(msg, ws);
|
||||||
|
roverManager.broadcastRoster();
|
||||||
|
sendAlert({ color: COLORS.success, title: 'Rover Online', message: roverId });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!roverId) return;
|
||||||
|
if (msg.type === 'sensor') {
|
||||||
|
roverManager.handleSensorFrame(roverId, msg);
|
||||||
|
} else if (msg.type === 'ack') {
|
||||||
|
handleAck(msg);
|
||||||
|
} else if (msg.type === 'event') {
|
||||||
|
sendAlert({ color: COLORS.info, title: `${roverId}`, message: msg.event });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
ws.on('close', () => {
|
||||||
|
if (roverId) {
|
||||||
|
roverManager.removeRover(roverId);
|
||||||
|
sendAlert({ color: COLORS.warn, title: 'Rover Offline', message: roverId });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,203 @@
|
|||||||
|
const io = require('../globals/io');
|
||||||
|
const logger = require('../globals/logger');
|
||||||
|
const { sendAlert, COLORS } = require('./alertService');
|
||||||
|
const { parseSensorFrame } = require('../helpers/sensorDecoder');
|
||||||
|
const { MODES, getMode } = require('./modeManager');
|
||||||
|
const { isAdmin } = require('./authService');
|
||||||
|
|
||||||
|
const rovers = new Map(); // roverId -> record
|
||||||
|
const socketToRovers = new Map(); // socketId -> Set(roverId)
|
||||||
|
|
||||||
|
function ensureRecord(id) {
|
||||||
|
if (!rovers.has(id)) {
|
||||||
|
rovers.set(id, {
|
||||||
|
id,
|
||||||
|
meta: null,
|
||||||
|
ws: null,
|
||||||
|
lastSensor: null,
|
||||||
|
drivers: new Set(),
|
||||||
|
locked: false,
|
||||||
|
room: `rover:${id}`,
|
||||||
|
lastSeen: Date.now(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return rovers.get(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
function upsertRover(meta, ws) {
|
||||||
|
const id = meta.name || meta.id;
|
||||||
|
const record = ensureRecord(id);
|
||||||
|
record.meta = meta;
|
||||||
|
record.ws = ws;
|
||||||
|
record.lastSeen = Date.now();
|
||||||
|
rovers.set(id, record);
|
||||||
|
broadcastRoster();
|
||||||
|
return record;
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeRover(id) {
|
||||||
|
const record = rovers.get(id);
|
||||||
|
if (!record) return;
|
||||||
|
rovers.delete(id);
|
||||||
|
broadcastRoster();
|
||||||
|
}
|
||||||
|
|
||||||
|
function lockRover(id, locked, actorSocket) {
|
||||||
|
const record = rovers.get(id);
|
||||||
|
if (!record) {
|
||||||
|
throw new Error('Unknown rover');
|
||||||
|
}
|
||||||
|
if (locked) {
|
||||||
|
record.locked = true;
|
||||||
|
sendAlert({ color: COLORS.warn, title: 'Rover Locked', message: `${id} locked by admin.` });
|
||||||
|
} else {
|
||||||
|
record.locked = false;
|
||||||
|
sendAlert({ color: COLORS.success, title: 'Rover Unlocked', message: `${id} unlocked.` });
|
||||||
|
}
|
||||||
|
broadcastRoster();
|
||||||
|
return record.locked;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRoster() {
|
||||||
|
return Array.from(rovers.values()).map((record) => ({
|
||||||
|
id: record.id,
|
||||||
|
name: record.meta?.name || record.id,
|
||||||
|
battery: record.meta?.battery,
|
||||||
|
maxWheelSpeed: record.meta?.maxWheelSpeed,
|
||||||
|
media: record.meta?.media,
|
||||||
|
locked: record.locked,
|
||||||
|
lastSeen: record.lastSeen,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function broadcastRoster() {
|
||||||
|
io.emit('rovers', getRoster());
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSensorFrame(roverId, frame) {
|
||||||
|
const record = rovers.get(roverId);
|
||||||
|
if (!record) return;
|
||||||
|
record.lastSeen = Date.now();
|
||||||
|
const decoded = parseSensorFrame(frame.data);
|
||||||
|
record.lastSensor = { raw: frame, decoded };
|
||||||
|
io.to(record.room).emit('sensorFrame', {
|
||||||
|
roverId,
|
||||||
|
frame,
|
||||||
|
sensors: decoded,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeSocket(socket) {
|
||||||
|
const joined = socketToRovers.get(socket.id);
|
||||||
|
if (!joined) return;
|
||||||
|
for (const roverId of joined) {
|
||||||
|
const record = rovers.get(roverId);
|
||||||
|
if (record) {
|
||||||
|
record.drivers.delete(socket.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
socketToRovers.delete(socket.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
function requestControl(roverId, socket) {
|
||||||
|
const record = rovers.get(roverId);
|
||||||
|
if (!record) {
|
||||||
|
throw new Error('Unknown rover');
|
||||||
|
}
|
||||||
|
if (record.locked && !isAdmin(socket)) {
|
||||||
|
throw new Error('Rover locked');
|
||||||
|
}
|
||||||
|
const mode = getMode();
|
||||||
|
if (mode === MODES.ADMIN && !isAdmin(socket)) {
|
||||||
|
throw new Error('Admins only');
|
||||||
|
}
|
||||||
|
if (mode === MODES.LOCKDOWN && !isAdmin(socket)) {
|
||||||
|
throw new Error('Server in lockdown');
|
||||||
|
}
|
||||||
|
// TODO: future turns logic
|
||||||
|
record.drivers.add(socket.id);
|
||||||
|
if (!socketToRovers.has(socket.id)) {
|
||||||
|
socketToRovers.set(socket.id, new Set());
|
||||||
|
}
|
||||||
|
socketToRovers.get(socket.id).add(roverId);
|
||||||
|
socket.join(record.room);
|
||||||
|
sendAlert({
|
||||||
|
color: COLORS.success,
|
||||||
|
title: 'Control Granted',
|
||||||
|
message: `${socket.id} now driving ${roverId}`,
|
||||||
|
});
|
||||||
|
return { roverId, room: record.room };
|
||||||
|
}
|
||||||
|
|
||||||
|
function releaseControl(roverId, socket) {
|
||||||
|
const record = rovers.get(roverId);
|
||||||
|
if (!record) return;
|
||||||
|
record.drivers.delete(socket.id);
|
||||||
|
const joined = socketToRovers.get(socket.id);
|
||||||
|
if (joined) {
|
||||||
|
joined.delete(roverId);
|
||||||
|
if (joined.size === 0) {
|
||||||
|
socketToRovers.delete(socket.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
socket.leave(record.room);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isDriver(roverId, socket) {
|
||||||
|
const record = rovers.get(roverId);
|
||||||
|
if (!record) return false;
|
||||||
|
return record.drivers.has(socket.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
upsertRover,
|
||||||
|
removeRover,
|
||||||
|
lockRover,
|
||||||
|
getRoster,
|
||||||
|
broadcastRoster,
|
||||||
|
handleSensorFrame,
|
||||||
|
requestControl,
|
||||||
|
releaseControl,
|
||||||
|
removeSocket,
|
||||||
|
isDriver,
|
||||||
|
rovers,
|
||||||
|
};
|
||||||
|
|
||||||
|
io.on('connection', (socket) => {
|
||||||
|
socket.on('requestControl', ({ roverId } = {}) => {
|
||||||
|
try {
|
||||||
|
const targetId = roverId || Array.from(rovers.keys())[0];
|
||||||
|
if (!targetId) {
|
||||||
|
throw new Error('No rovers available');
|
||||||
|
}
|
||||||
|
requestControl(targetId, socket);
|
||||||
|
socket.emit('controlGranted', { roverId: targetId });
|
||||||
|
} catch (err) {
|
||||||
|
sendAlert({ color: COLORS.warn, title: 'Control denied', message: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('releaseControl', ({ roverId }) => {
|
||||||
|
if (!roverId) return;
|
||||||
|
releaseControl(roverId, socket);
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('lockRover', ({ roverId, locked }) => {
|
||||||
|
if (!isAdmin(socket)) return;
|
||||||
|
try {
|
||||||
|
lockRover(roverId, locked);
|
||||||
|
} catch (err) {
|
||||||
|
sendAlert({ color: COLORS.error, title: 'Lock failed', message: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('subscribeAll', () => {
|
||||||
|
for (const record of rovers.values()) {
|
||||||
|
socket.join(record.room);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('disconnect', () => {
|
||||||
|
removeSocket(socket);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user