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 @@