From 73b1b44c35af6d375767f1f5446f6f4180bb809d Mon Sep 17 00:00:00 2001 From: legop3 Date: Wed, 12 Nov 2025 01:13:29 -0500 Subject: [PATCH] modularize code, add dummy version of roverd --- .gitignore | 3 +- README.md | 11 + dist/dummy1.yml | 22 ++ dist/dummy2.yml | 22 ++ dist/roverd | Bin 6815928 -> 6815928 bytes docs/pi-deployment.md | 12 ++ pi/roverd/Makefile | 5 +- pi/roverd/auto_charge.go | 8 +- pi/roverd/brc.go | 2 + pi/roverd/brc_dummy.go | 19 ++ pi/roverd/events.go | 8 +- pi/roverd/sensor_stream.go | 10 +- pi/roverd/sensor_stream_dummy.go | 73 +++++++ pi/roverd/serial.go | 2 + pi/roverd/serial_dummy.go | 58 +++++ server/index.js | 15 ++ server/package.json | 4 +- server/public/app.js | 193 ----------------- server/public/index.html | 24 ++- server/public/loader.js | 22 ++ server/public/src/globals/socket.js | 4 + server/public/src/helpers/formatters.js | 11 + server/public/src/main.js | 6 + server/public/src/services/alertFeed.js | 25 +++ server/public/src/services/driveControls.js | 122 +++++++++++ server/public/src/services/roverUI.js | 68 ++++++ server/public/src/services/state.js | 14 ++ server/src/globals/config.js | 6 + server/src/globals/http.js | 14 ++ server/src/globals/io.js | 8 + server/src/globals/logger.js | 9 + server/src/globals/ws.js | 21 ++ server/src/{ => helpers}/sensorDecoder.js | 0 server/src/index.js | 185 ---------------- server/src/services/alertService.js | 23 ++ server/src/services/authService.js | 12 ++ server/src/services/commandService.js | 53 +++++ server/src/services/httpServer.js | 7 + server/src/services/modeManager.js | 58 +++++ server/src/services/roverConnectionService.js | 57 +++++ server/src/services/roverManager.js | 203 ++++++++++++++++++ 41 files changed, 1018 insertions(+), 401 deletions(-) create mode 100644 dist/dummy1.yml create mode 100644 dist/dummy2.yml create mode 100644 pi/roverd/brc_dummy.go create mode 100644 pi/roverd/sensor_stream_dummy.go create mode 100644 pi/roverd/serial_dummy.go create mode 100644 server/index.js delete mode 100644 server/public/app.js create mode 100644 server/public/loader.js create mode 100644 server/public/src/globals/socket.js create mode 100644 server/public/src/helpers/formatters.js create mode 100644 server/public/src/main.js create mode 100644 server/public/src/services/alertFeed.js create mode 100644 server/public/src/services/driveControls.js create mode 100644 server/public/src/services/roverUI.js create mode 100644 server/public/src/services/state.js create mode 100644 server/src/globals/config.js create mode 100644 server/src/globals/http.js create mode 100644 server/src/globals/io.js create mode 100644 server/src/globals/logger.js create mode 100644 server/src/globals/ws.js rename server/src/{ => helpers}/sensorDecoder.js (100%) delete mode 100644 server/src/index.js create mode 100644 server/src/services/alertService.js create mode 100644 server/src/services/authService.js create mode 100644 server/src/services/commandService.js create mode 100644 server/src/services/httpServer.js create mode 100644 server/src/services/modeManager.js create mode 100644 server/src/services/roverConnectionService.js create mode 100644 server/src/services/roverManager.js diff --git a/.gitignore b/.gitignore index 98cff96d..5de944ba 100644 --- a/.gitignore +++ b/.gitignore @@ -8,4 +8,5 @@ node_modules/ .pio .vscode/ config.h -robots.json \ No newline at end of file +robots.json +roverd-dummy \ No newline at end of file diff --git a/README.md b/README.md index 4e7b20ee..431a6605 100644 --- a/README.md +++ b/README.md @@ -34,6 +34,17 @@ npm install 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): ```bash diff --git a/dist/dummy1.yml b/dist/dummy1.yml new file mode 100644 index 00000000..568c6668 --- /dev/null +++ b/dist/dummy1.yml @@ -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 \ No newline at end of file diff --git a/dist/dummy2.yml b/dist/dummy2.yml new file mode 100644 index 00000000..a3b559f7 --- /dev/null +++ b/dist/dummy2.yml @@ -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 \ No newline at end of file diff --git a/dist/roverd b/dist/roverd index e91cd9d4335fec88be0558304149af33adac62af..b3600b467a74b629ed7bb8d5e57f2d7b83c9abde 100755 GIT binary patch delta 19661 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 delta 19774 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 diff --git a/docs/pi-deployment.md b/docs/pi-deployment.md index 6d424d4d..93f7ab29 100644 --- a/docs/pi-deployment.md +++ b/docs/pi-deployment.md @@ -18,6 +18,18 @@ make pi-build 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) Once the binary (and repo) are on the Pi, run the helper script from the repo root: diff --git a/pi/roverd/Makefile b/pi/roverd/Makefile index 09597911..5aff6e57 100644 --- a/pi/roverd/Makefile +++ b/pi/roverd/Makefile @@ -3,7 +3,7 @@ GOOS ?= linux GOARCH ?= arm GOARM ?= 6 -.PHONY: build pi-build clean +.PHONY: build pi-build dummy clean build: go build -o $(BIN_DIR)/roverd ./cmd/roverd @@ -11,5 +11,8 @@ build: pi-build: 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: rm -f $(BIN_DIR)/roverd diff --git a/pi/roverd/auto_charge.go b/pi/roverd/auto_charge.go index 09cb1235..c9862c28 100644 --- a/pi/roverd/auto_charge.go +++ b/pi/roverd/auto_charge.go @@ -99,10 +99,10 @@ func (a *AutoChargeController) emitEvent(event string, data map[string]any) { } select { case a.events <- RoverEvent{ - Type: "event", - Event: event, - Ts: time.Now().UnixMilli(), - Data: data, + Type: "event", + Event: event, + Ts: time.Now().UnixMilli(), + Data: data, }: default: } diff --git a/pi/roverd/brc.go b/pi/roverd/brc.go index 7d7fb755..005ffdf2 100644 --- a/pi/roverd/brc.go +++ b/pi/roverd/brc.go @@ -1,3 +1,5 @@ +//go:build !dummy + package roverd import ( diff --git a/pi/roverd/brc_dummy.go b/pi/roverd/brc_dummy.go new file mode 100644 index 00000000..40a2d736 --- /dev/null +++ b/pi/roverd/brc_dummy.go @@ -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) {} diff --git a/pi/roverd/events.go b/pi/roverd/events.go index 7640b93f..598db260 100644 --- a/pi/roverd/events.go +++ b/pi/roverd/events.go @@ -1,8 +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"` + Type string `json:"type"` + Event string `json:"event"` + Ts int64 `json:"ts"` + Data map[string]any `json:"data,omitempty"` } diff --git a/pi/roverd/sensor_stream.go b/pi/roverd/sensor_stream.go index 882b6ddf..1365d2fa 100644 --- a/pi/roverd/sensor_stream.go +++ b/pi/roverd/sensor_stream.go @@ -1,3 +1,5 @@ +//go:build !dummy + package roverd import ( @@ -15,10 +17,10 @@ const ( ) type SensorStreamer struct { - r io.Reader - rawOut chan<- []byte - parsed chan<- SensorSample - logger *log.Logger + r io.Reader + rawOut chan<- []byte + parsed chan<- SensorSample + logger *log.Logger } func NewSensorStreamer(r io.Reader, rawOut chan<- []byte, parsed chan<- SensorSample, logger *log.Logger) *SensorStreamer { diff --git a/pi/roverd/sensor_stream_dummy.go b/pi/roverd/sensor_stream_dummy.go new file mode 100644 index 00000000..ed6b6cfd --- /dev/null +++ b/pi/roverd/sensor_stream_dummy.go @@ -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) +} diff --git a/pi/roverd/serial.go b/pi/roverd/serial.go index 416c9b8f..83893d87 100644 --- a/pi/roverd/serial.go +++ b/pi/roverd/serial.go @@ -1,3 +1,5 @@ +//go:build !dummy + package roverd import ( diff --git a/pi/roverd/serial_dummy.go b/pi/roverd/serial_dummy.go new file mode 100644 index 00000000..e359790b --- /dev/null +++ b/pi/roverd/serial_dummy.go @@ -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 +} diff --git a/server/index.js b/server/index.js new file mode 100644 index 00000000..ca75ada4 --- /dev/null +++ b/server/index.js @@ -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'); diff --git a/server/package.json b/server/package.json index 48f5d688..d185b954 100644 --- a/server/package.json +++ b/server/package.json @@ -3,8 +3,8 @@ "version": "0.1.0", "private": true, "scripts": { - "start": "node src/index.js", - "dev": "nodemon src/index.js" + "start": "node index.js", + "dev": "nodemon index.js" }, "dependencies": { "express": "^4.19.2", diff --git a/server/public/app.js b/server/public/app.js deleted file mode 100644 index e86b8a90..00000000 --- a/server/public/app.js +++ /dev/null @@ -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'); -} diff --git a/server/public/index.html b/server/public/index.html index 1804c683..fa7197f2 100644 --- a/server/public/index.html +++ b/server/public/index.html @@ -22,18 +22,21 @@
- - - - - + + +
+
+ + + + +
-
@@ -44,6 +47,13 @@
- + + + + + + + + diff --git a/server/public/loader.js b/server/public/loader.js new file mode 100644 index 00000000..82c6b53f --- /dev/null +++ b/server/public/loader.js @@ -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; + }; +})(); diff --git a/server/public/src/globals/socket.js b/server/public/src/globals/socket.js new file mode 100644 index 00000000..bbe01878 --- /dev/null +++ b/server/public/src/globals/socket.js @@ -0,0 +1,4 @@ +registerModule('globals/socket', (require, exports) => { + const socket = io(); + exports.socket = socket; +}); diff --git a/server/public/src/helpers/formatters.js b/server/public/src/helpers/formatters.js new file mode 100644 index 00000000..d4bf4e0f --- /dev/null +++ b/server/public/src/helpers/formatters.js @@ -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; +}); diff --git a/server/public/src/main.js b/server/public/src/main.js new file mode 100644 index 00000000..053d7eaf --- /dev/null +++ b/server/public/src/main.js @@ -0,0 +1,6 @@ +requireModule('globals/socket'); +requireModule('helpers/formatters'); +requireModule('services/state'); +requireModule('services/alertFeed'); +requireModule('services/roverUI'); +requireModule('services/driveControls'); diff --git a/server/public/src/services/alertFeed.js b/server/public/src/services/alertFeed.js new file mode 100644 index 00000000..04305874 --- /dev/null +++ b/server/public/src/services/alertFeed.js @@ -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 = `${title || 'Notice'} - ${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); +}); diff --git a/server/public/src/services/driveControls.js b/server/public/src/services/driveControls.js new file mode 100644 index 00000000..7e13ced0 --- /dev/null +++ b/server/public/src/services/driveControls.js @@ -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)); + } +}); diff --git a/server/public/src/services/roverUI.js b/server/public/src/services/roverUI.js new file mode 100644 index 00000000..6aab2d09 --- /dev/null +++ b/server/public/src/services/roverUI.js @@ -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'; + }); +}); diff --git a/server/public/src/services/state.js b/server/public/src/services/state.js new file mode 100644 index 00000000..f679871c --- /dev/null +++ b/server/public/src/services/state.js @@ -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; +}); diff --git a/server/src/globals/config.js b/server/src/globals/config.js new file mode 100644 index 00000000..7ec96b60 --- /dev/null +++ b/server/src/globals/config.js @@ -0,0 +1,6 @@ +const path = require('path'); + +module.exports = { + port: process.env.PORT || 8080, + staticDir: path.join(__dirname, '..', '..', 'public'), +}; diff --git a/server/src/globals/http.js b/server/src/globals/http.js new file mode 100644 index 00000000..e28eeb09 --- /dev/null +++ b/server/src/globals/http.js @@ -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 }; diff --git a/server/src/globals/io.js b/server/src/globals/io.js new file mode 100644 index 00000000..043d6d96 --- /dev/null +++ b/server/src/globals/io.js @@ -0,0 +1,8 @@ +const { Server: SocketIOServer } = require('socket.io'); +const { httpServer } = require('./http'); + +const io = new SocketIOServer(httpServer, { + cors: { origin: '*' }, +}); + +module.exports = io; diff --git a/server/src/globals/logger.js b/server/src/globals/logger.js new file mode 100644 index 00000000..137960b0 --- /dev/null +++ b/server/src/globals/logger.js @@ -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)), +}; diff --git a/server/src/globals/ws.js b/server/src/globals/ws.js new file mode 100644 index 00000000..edf62e5b --- /dev/null +++ b/server/src/globals/ws.js @@ -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; diff --git a/server/src/sensorDecoder.js b/server/src/helpers/sensorDecoder.js similarity index 100% rename from server/src/sensorDecoder.js rename to server/src/helpers/sensorDecoder.js diff --git a/server/src/index.js b/server/src/index.js deleted file mode 100644 index ac2ae89e..00000000 --- a/server/src/index.js +++ /dev/null @@ -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, -}; diff --git a/server/src/services/alertService.js b/server/src/services/alertService.js new file mode 100644 index 00000000..d3450c77 --- /dev/null +++ b/server/src/services/alertService.js @@ -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, +}; diff --git a/server/src/services/authService.js b/server/src/services/authService.js new file mode 100644 index 00000000..a0ff7b5f --- /dev/null +++ b/server/src/services/authService.js @@ -0,0 +1,12 @@ +function isAdmin(/* socket */) { + return false; +} + +function isLockdownAdmin(/* socket */) { + return false; +} + +module.exports = { + isAdmin, + isLockdownAdmin, +}; diff --git a/server/src/services/commandService.js b/server/src/services/commandService.js new file mode 100644 index 00000000..606d61ac --- /dev/null +++ b/server/src/services/commandService.js @@ -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 }); + } + }); +}); diff --git a/server/src/services/httpServer.js b/server/src/services/httpServer.js new file mode 100644 index 00000000..88d1a1bf --- /dev/null +++ b/server/src/services/httpServer.js @@ -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}`); +}); diff --git a/server/src/services/modeManager.js b/server/src/services/modeManager.js new file mode 100644 index 00000000..8ee15fac --- /dev/null +++ b/server/src/services/modeManager.js @@ -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 }); + } + }); +}); diff --git a/server/src/services/roverConnectionService.js b/server/src/services/roverConnectionService.js new file mode 100644 index 00000000..0562c4e5 --- /dev/null +++ b/server/src/services/roverConnectionService.js @@ -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 }); + } + }); +}); diff --git a/server/src/services/roverManager.js b/server/src/services/roverManager.js new file mode 100644 index 00000000..3d6c383a --- /dev/null +++ b/server/src/services/roverManager.js @@ -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); + }); +});