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 e91cd9d4..b3600b46 100755 Binary files a/dist/roverd and b/dist/roverd differ 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); + }); +});