diff --git a/.gitignore b/.gitignore index 5de944ba..c8b10647 100644 --- a/.gitignore +++ b/.gitignore @@ -9,4 +9,5 @@ node_modules/ .vscode/ config.h robots.json -roverd-dummy \ No newline at end of file +roverd-dummy +server/config.yaml diff --git a/README.md b/README.md index 431a6605..b021411f 100644 --- a/README.md +++ b/README.md @@ -45,6 +45,10 @@ make dummy 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. +## Admin config & authentication + +Before running the Node server, copy `server/config.example.yaml` to `server/config.yaml` and customize the admin records (password hashes, Discord IDs, lockdown permission). Those credentials are used by the driver UI’s login panel—only admins can toggle locks/modes, and lockdown admins retain access when the system enters lockdown mode. The spectator page (future) can set `role:set` to `spectator`, and the server enforces all permissions server-side so client tweaks can’t grant extra control. + 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/roverd b/dist/roverd index 71edbe45..65406101 100755 Binary files a/dist/roverd and b/dist/roverd differ diff --git a/pi/roverd/wsclient.go b/pi/roverd/wsclient.go index a55a9e53..8c4849be 100644 --- a/pi/roverd/wsclient.go +++ b/pi/roverd/wsclient.go @@ -41,6 +41,9 @@ func (c *WSClient) Run(ctx context.Context) error { if err := c.sendHello(ctx, conn); err != nil { return err } + if err := c.ensureSensorStream(); err != nil { + c.log.Printf("sensor stream init failed: %v", err) + } errCh := make(chan error, 1) go func() { @@ -126,7 +129,13 @@ func (c *WSClient) dispatch(ctx context.Context, msg *inboundMessage) error { if err != nil { return fmt.Errorf("raw decode: %w", err) } - return c.adapter.SendRaw(buf) + if err := c.adapter.SendRaw(buf); err != nil { + return err + } + if len(buf) > 0 && isModeOpcode(buf[0]) { + return c.ensureSensorStream() + } + return nil case msg.Media != nil: if c.media == nil { return fmt.Errorf("media supervisor disabled") @@ -193,3 +202,19 @@ func clamp(value, min, max int) int { } return value } + +func (c *WSClient) ensureSensorStream() error { + if err := c.adapter.StartSensorStream(defaultStreamPackets); err != nil { + return err + } + return c.adapter.PauseSensorStream(false) +} + +func isModeOpcode(op byte) bool { + switch op { + case 128, 131, 132: + return true + default: + return false + } +} diff --git a/server/config.example.yaml b/server/config.example.yaml new file mode 100644 index 00000000..1b707ef5 --- /dev/null +++ b/server/config.example.yaml @@ -0,0 +1,9 @@ +admins: + - username: admin + password_hash: "$2b$10$ZW4Jy7ctIt7k9V1AogFky.v4wedLF92t4/ZlT9kWPlIiCmdQNzJ.C" # password: adminpass + discord_id: "1234567890" + lockdown: false + - username: lockdown + password_hash: "$2b$10$n0L0oe1ZQy7IgM.FvVAzb.aXz43uaZWFiT0wr.05uNoVIDLawmrCG" # password: lockdownpass + discord_id: "0987654321" + lockdown: true diff --git a/server/index.js b/server/index.js index ca75ada4..77cf4c28 100644 --- a/server/index.js +++ b/server/index.js @@ -9,6 +9,7 @@ require('./src/helpers/sensorDecoder'); require('./src/services/alertService'); require('./src/services/authService'); require('./src/services/modeManager'); +require('./src/services/lockdownGuard'); require('./src/services/roverManager'); require('./src/services/commandService'); require('./src/services/roverConnectionService'); diff --git a/server/package-lock.json b/server/package-lock.json index 656f3d97..c132c607 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -8,7 +8,9 @@ "name": "multiroombarover-server", "version": "0.1.0", "dependencies": { + "bcrypt": "^6.0.0", "express": "^4.19.2", + "js-yaml": "^4.1.1", "morgan": "^1.10.0", "socket.io": "^4.7.5", "uuid": "^9.0.1", @@ -69,6 +71,12 @@ "node": ">= 8" } }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "license": "Python-2.0" + }, "node_modules/array-flatten": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", @@ -109,6 +117,20 @@ "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", "license": "MIT" }, + "node_modules/bcrypt": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/bcrypt/-/bcrypt-6.0.0.tgz", + "integrity": "sha512-cU8v/EGSrnH+HnxV2z0J7/blxH8gq7Xh2JFT6Aroax7UohdmiJJlxApMxtKfuI7z68NvvVcmR78k2LbT6efhRg==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "node-addon-api": "^8.3.0", + "node-gyp-build": "^4.8.4" + }, + "engines": { + "node": ">= 18" + } + }, "node_modules/binary-extensions": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", @@ -769,6 +791,18 @@ "node": ">=0.12.0" } }, + "node_modules/js-yaml": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -894,6 +928,26 @@ "node": ">= 0.6" } }, + "node_modules/node-addon-api": { + "version": "8.5.0", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.5.0.tgz", + "integrity": "sha512-/bRZty2mXUIFY/xU5HLvveNHlswNJej+RnxBjOMkidWfwZzgTbPG1E3K5TOxRLOR+5hX7bSofy8yf1hZevMS8A==", + "license": "MIT", + "engines": { + "node": "^18 || ^20 || >= 21" + } + }, + "node_modules/node-gyp-build": { + "version": "4.8.4", + "resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz", + "integrity": "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==", + "license": "MIT", + "bin": { + "node-gyp-build": "bin.js", + "node-gyp-build-optional": "optional.js", + "node-gyp-build-test": "build-test.js" + } + }, "node_modules/nodemon": { "version": "3.1.10", "resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.10.tgz", diff --git a/server/package.json b/server/package.json index d185b954..4a3e2017 100644 --- a/server/package.json +++ b/server/package.json @@ -7,7 +7,9 @@ "dev": "nodemon index.js" }, "dependencies": { + "bcrypt": "^6.0.0", "express": "^4.19.2", + "js-yaml": "^4.1.1", "morgan": "^1.10.0", "socket.io": "^4.7.5", "uuid": "^9.0.1", diff --git a/server/public/index.html b/server/public/index.html index fa7197f2..9c4f64c8 100644 --- a/server/public/index.html +++ b/server/public/index.html @@ -12,11 +12,21 @@ .row { margin-bottom: 8px; } #alerts { margin-top: 12px; max-width: 600px; } .alert { background: #fff1d2; border: 1px solid #f0b651; padding: 6px 8px; margin-bottom: 6px; font-size: 0.9rem; } + #authPanel { margin: 12px 0; display: flex; gap: 8px; align-items: center; } + #authPanel input { padding: 4px; } + #activeDriver { margin-bottom: 8px; font-weight: bold; }
Use WASD keys to drive the selected rover. Shift increases speed.
@@ -52,6 +63,7 @@ + diff --git a/server/public/src/main.js b/server/public/src/main.js index 053d7eaf..c188854d 100644 --- a/server/public/src/main.js +++ b/server/public/src/main.js @@ -2,5 +2,6 @@ requireModule('globals/socket'); requireModule('helpers/formatters'); requireModule('services/state'); requireModule('services/alertFeed'); +requireModule('services/authControls'); requireModule('services/roverUI'); requireModule('services/driveControls'); diff --git a/server/public/src/services/authControls.js b/server/public/src/services/authControls.js new file mode 100644 index 00000000..e4d3c680 --- /dev/null +++ b/server/public/src/services/authControls.js @@ -0,0 +1,41 @@ +registerModule('services/authControls', (require, exports) => { + const { socket } = require('globals/socket'); + const state = require('services/state'); + + const usernameInput = document.getElementById('username'); + const passwordInput = document.getElementById('password'); + const loginBtn = document.getElementById('loginBtn'); + const roleStatus = document.getElementById('roleStatus'); + const spectatorBtn = document.getElementById('spectatorBtn'); + + function updateRole(role) { + if (roleStatus) { + roleStatus.textContent = `Role: ${role}`; + } + state.setRole(role); + } + + loginBtn?.addEventListener('click', () => { + const username = usernameInput.value.trim(); + const password = passwordInput.value; + socket.emit('auth:login', { username, password }, (resp = {}) => { + if (!resp.success) { + alert(resp.error || 'Login failed'); + } else { + updateRole(resp.role); + } + passwordInput.value = ''; + }); + }); + + socket.on('auth:role', ({ role }) => { + updateRole(role); + }); + // initialize from default role + updateRole(state.getRole()); + + spectatorBtn?.addEventListener('click', () => { + socket.emit('role:set', { role: 'spectator' }); + updateRole('spectator'); + }); +}); diff --git a/server/public/src/services/roverUI.js b/server/public/src/services/roverUI.js index 6aab2d09..0f3467e0 100644 --- a/server/public/src/services/roverUI.js +++ b/server/public/src/services/roverUI.js @@ -8,9 +8,16 @@ registerModule('services/roverUI', (require, exports) => { const sensorOutput = document.getElementById('sensorOutput'); const requestBtn = document.getElementById('requestControl'); const lockBtn = document.getElementById('lockToggle'); + const activeDriverEl = document.getElementById('activeDriver'); let roster = []; + state.onRoleChange((role) => { + const isAdmin = role === 'admin' || role === 'lockdown'; + if (requestBtn) requestBtn.disabled = !isAdmin; + if (lockBtn) lockBtn.disabled = !isAdmin; + }); + socket.on('rovers', (list) => { roster = list; roverSelect.innerHTML = ''; @@ -48,6 +55,13 @@ registerModule('services/roverUI', (require, exports) => { sensorOutput.textContent = lines.join('\n'); }); + socket.on('activeDriver', ({ roverId, socketId }) => { + if (roverId !== state.getSelected()) return; + if (activeDriverEl) { + activeDriverEl.textContent = `Active driver: ${socketId || 'none'}`; + } + }); + requestBtn?.addEventListener('click', () => { socket.emit('requestControl', { roverId: state.getSelected() }); }); diff --git a/server/public/src/services/state.js b/server/public/src/services/state.js index f679871c..b1c55bef 100644 --- a/server/public/src/services/state.js +++ b/server/public/src/services/state.js @@ -1,5 +1,7 @@ registerModule('services/state', (require, exports) => { let selectedRover = null; + let role = 'user'; + const roleListeners = new Set(); function setSelected(roverId) { selectedRover = roverId; @@ -9,6 +11,24 @@ registerModule('services/state', (require, exports) => { return selectedRover; } + function setRole(nextRole) { + role = nextRole; + roleListeners.forEach((cb) => cb(role)); + } + + function getRole() { + return role; + } + + function onRoleChange(cb) { + roleListeners.add(cb); + cb(role); + return () => roleListeners.delete(cb); + } + exports.setSelected = setSelected; exports.getSelected = getSelected; + exports.setRole = setRole; + exports.getRole = getRole; + exports.onRoleChange = onRoleChange; }); diff --git a/server/scripts/hashPassword.js b/server/scripts/hashPassword.js new file mode 100755 index 00000000..38159875 --- /dev/null +++ b/server/scripts/hashPassword.js @@ -0,0 +1,27 @@ +#!/usr/bin/env node +const bcrypt = require('bcrypt'); +const readline = require('readline'); + +const passwordFromArg = process.argv[2]; + +async function hashPassword(password) { + try { + const hash = await bcrypt.hash(password, 10); + console.log(`Password: ${password}`); + console.log(`Hash: ${hash}`); + process.exit(0); + } catch (err) { + console.error('Error hashing password:', err.message); + process.exit(1); + } +} + +if (passwordFromArg) { + hashPassword(passwordFromArg); +} else { + const rl = readline.createInterface({ input: process.stdin, output: process.stdout }); + rl.question('Password to hash: ', (answer) => { + rl.close(); + hashPassword(answer); + }); +} diff --git a/server/src/helpers/configLoader.js b/server/src/helpers/configLoader.js new file mode 100644 index 00000000..d225be03 --- /dev/null +++ b/server/src/helpers/configLoader.js @@ -0,0 +1,20 @@ +const fs = require('fs'); +const path = require('path'); +const yaml = require('js-yaml'); + +const CONFIG_PATH = process.env.SERVER_CONFIG || path.join(__dirname, '..', '..', 'config.yaml'); + +let cachedConfig; + +function loadConfig() { + if (cachedConfig) { + return cachedConfig; + } + const file = fs.readFileSync(CONFIG_PATH, 'utf8'); + cachedConfig = yaml.load(file); + return cachedConfig; +} + +module.exports = { + loadConfig, +}; diff --git a/server/src/services/authService.js b/server/src/services/authService.js index a0ff7b5f..854b005d 100644 --- a/server/src/services/authService.js +++ b/server/src/services/authService.js @@ -1,12 +1,63 @@ -function isAdmin(/* socket */) { - return false; +const bcrypt = require('bcrypt'); +const io = require('../globals/io'); +const { loadConfig } = require('../helpers/configLoader'); +const { clearLockdownTimer } = require('./lockdownGuard'); +const { setRole, roleEvents } = require('./roleService'); + +const config = loadConfig(); +const admins = config.admins || []; + +function findAdmin(username) { + return admins.find((admin) => admin.username === username); } -function isLockdownAdmin(/* socket */) { - return false; +async function authenticate(username, password) { + const admin = findAdmin(username); + if (!admin) { + throw new Error('Invalid credentials'); + } + const ok = await bcrypt.compare(password, admin.password_hash); + if (!ok) { + throw new Error('Invalid credentials'); + } + return admin; } +function isAdmin(socket) { + return socket?.data?.role === 'admin' || socket?.data?.role === 'lockdown'; +} + +function isLockdownAdmin(socket) { + return socket?.data?.role === 'lockdown'; +} + +io.on('connection', (socket) => { + setRole(socket, 'user'); + socket.emit('auth:role', { role: 'user' }); + socket.on('auth:login', async ({ username, password }, cb = () => {}) => { + try { + const admin = await authenticate(username, password); + const role = admin.lockdown ? 'lockdown' : 'admin'; + socket.data.user = { username: admin.username, discordId: admin.discord_id }; + setRole(socket, role); + socket.emit('auth:role', { role }); + clearLockdownTimer(socket); + cb({ success: true, role: socket.data.role }); + } catch (err) { + cb({ success: false, error: err.message }); + } + }); + + socket.on('role:set', ({ role }) => { + if (role === 'spectator' || role === 'user') { + setRole(socket, role); + socket.emit('auth:role', { role }); + } + }); +}); + module.exports = { isAdmin, isLockdownAdmin, + authenticate, }; diff --git a/server/src/services/commandService.js b/server/src/services/commandService.js index 606d61ac..7988d7c8 100644 --- a/server/src/services/commandService.js +++ b/server/src/services/commandService.js @@ -1,7 +1,6 @@ const { v4: uuidv4 } = require('uuid'); const io = require('../globals/io'); const roverManager = require('./roverManager'); -const { isAdmin } = require('./authService'); const pendingCommands = new Map(); // id -> { roverId } @@ -40,8 +39,8 @@ io.on('connection', (socket) => { if (!roverId) { throw new Error('roverId required'); } - if (!roverManager.isDriver(roverId, socket) && !isAdmin(socket)) { - throw new Error('Not controlling this rover'); + if (!roverManager.canDrive(roverId, socket)) { + throw new Error('Not your turn or no control'); } const payload = data ? { ...data } : {}; const id = issueCommand(roverId, { type, ...payload }); diff --git a/server/src/services/lockdownGuard.js b/server/src/services/lockdownGuard.js new file mode 100644 index 00000000..b3b0f2e3 --- /dev/null +++ b/server/src/services/lockdownGuard.js @@ -0,0 +1,46 @@ +const io = require('../globals/io'); +const { MODES, getMode, modeEvents } = require('./modeManager'); +const { isLockdownAdmin } = require('./roleService'); + +function disconnectForLockdown(socket) { + socket.emit('lockdown', { message: 'Server is in lockdown mode' }); + socket.disconnect(true); +} + +function clearLockdownTimer(socket) { + if (socket?.data?.lockdownTimer) { + clearTimeout(socket.data.lockdownTimer); + socket.data.lockdownTimer = null; + } +} + +function enforceLockdown() { + for (const socket of io.sockets.sockets.values()) { + if (!isLockdownAdmin(socket)) { + disconnectForLockdown(socket); + } + } +} + +io.on('connection', (socket) => { + if (getMode() === MODES.LOCKDOWN && !isLockdownAdmin(socket)) { + socket.data.lockdownTimer = setTimeout(() => { + if (!isLockdownAdmin(socket)) { + disconnectForLockdown(socket); + } + }, 10000); + socket.once('disconnect', () => clearLockdownTimer(socket)); + } +}); + +module.exports = { + enforceLockdown, + disconnectForLockdown, + clearLockdownTimer, +}; + +modeEvents.on('change', (mode) => { + if (mode === MODES.LOCKDOWN) { + enforceLockdown(); + } +}); diff --git a/server/src/services/modeManager.js b/server/src/services/modeManager.js index 8ee15fac..1a8876da 100644 --- a/server/src/services/modeManager.js +++ b/server/src/services/modeManager.js @@ -1,6 +1,7 @@ +const EventEmitter = require('events'); const io = require('../globals/io'); const { sendAlert, COLORS } = require('./alertService'); -const { isAdmin, isLockdownAdmin } = require('./authService'); +const { isAdmin, isLockdownAdmin } = require('./roleService'); const MODES = { OPEN: 'open', @@ -10,6 +11,7 @@ const MODES = { }; let currentMode = MODES.OPEN; +const modeEvents = new EventEmitter(); function canChangeMode(socket, nextMode) { if (nextMode === MODES.LOCKDOWN) { @@ -34,6 +36,7 @@ function setMode(nextMode, socket) { title: 'Mode Changed', message: `Server mode set to ${nextMode}`, }); + modeEvents.emit('change', currentMode); return currentMode; } @@ -45,6 +48,7 @@ module.exports = { MODES, getMode, setMode, + modeEvents, }; io.on('connection', (socket) => { diff --git a/server/src/services/roleService.js b/server/src/services/roleService.js new file mode 100644 index 00000000..097f06bb --- /dev/null +++ b/server/src/services/roleService.js @@ -0,0 +1,33 @@ +const EventEmitter = require('events'); + +const roleEvents = new EventEmitter(); + +function getRole(socket) { + return socket?.data?.role || 'user'; +} + +function setRole(socket, role) { + socket.data = socket.data || {}; + if (socket.data.role === role) { + return; + } + socket.data.role = role; + roleEvents.emit('change', { socket, role }); +} + +function isAdmin(socket) { + const role = getRole(socket); + return role === 'admin' || role === 'lockdown'; +} + +function isLockdownAdmin(socket) { + return getRole(socket) === 'lockdown'; +} + +module.exports = { + getRole, + setRole, + isAdmin, + isLockdownAdmin, + roleEvents, +}; diff --git a/server/src/services/roverManager.js b/server/src/services/roverManager.js index 3d6c383a..756944f8 100644 --- a/server/src/services/roverManager.js +++ b/server/src/services/roverManager.js @@ -3,10 +3,12 @@ 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 { isAdmin, roleEvents } = require('./roleService'); const rovers = new Map(); // roverId -> record const socketToRovers = new Map(); // socketId -> Set(roverId) +const spectatorSockets = new Set(); +const turnService = require('./turnService'); function ensureRecord(id) { if (!rovers.has(id)) { @@ -31,6 +33,10 @@ function upsertRover(meta, ws) { record.ws = ws; record.lastSeen = Date.now(); rovers.set(id, record); + spectatorSockets.forEach((socketId) => { + const sock = io.sockets.sockets.get(socketId); + sock?.join(record.room); + }); broadcastRoster(); return record; } @@ -39,6 +45,11 @@ function removeRover(id) { const record = rovers.get(id); if (!record) return; rovers.delete(id); + turnService.cleanupRover(id); + spectatorSockets.forEach((socketId) => { + const sock = io.sockets.sockets.get(socketId); + sock?.leave(record.room); + }); broadcastRoster(); } @@ -89,14 +100,19 @@ function handleSensorFrame(roverId, frame) { function removeSocket(socket) { const joined = socketToRovers.get(socket.id); - if (!joined) return; + if (!joined) { + disableSpectator(socket); + return; + } for (const roverId of joined) { const record = rovers.get(roverId); if (record) { record.drivers.delete(socket.id); } + turnService.driverRemoved(roverId, socket.id); } socketToRovers.delete(socket.id); + disableSpectator(socket); } function requestControl(roverId, socket) { @@ -104,6 +120,9 @@ function requestControl(roverId, socket) { if (!record) { throw new Error('Unknown rover'); } + if (!isAdmin(socket)) { + throw new Error('Only admins can request control'); + } if (record.locked && !isAdmin(socket)) { throw new Error('Rover locked'); } @@ -114,13 +133,13 @@ function requestControl(roverId, socket) { 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); + turnService.driverAdded(roverId, socket.id); sendAlert({ color: COLORS.success, title: 'Control Granted', @@ -141,6 +160,7 @@ function releaseControl(roverId, socket) { } } socket.leave(record.room); + turnService.driverRemoved(roverId, socket.id); } function isDriver(roverId, socket) { @@ -149,6 +169,10 @@ function isDriver(roverId, socket) { return record.drivers.has(socket.id); } +function canDrive(roverId, socket) { + return turnService.canDrive(roverId, socket) || isAdmin(socket); +} + module.exports = { upsertRover, removeRover, @@ -160,10 +184,26 @@ module.exports = { releaseControl, removeSocket, isDriver, + canDrive, + enableSpectator, + disableSpectator, rovers, }; +roleEvents.on('change', ({ socket, role }) => { + if (role === 'spectator') { + enableSpectator(socket); + } else { + disableSpectator(socket); + } +}); + io.on('connection', (socket) => { + socket.emit('rovers', getRoster()); + if (socket.data?.role === 'spectator') { + enableSpectator(socket); + } + socket.on('requestControl', ({ roverId } = {}) => { try { const targetId = roverId || Array.from(rovers.keys())[0]; @@ -192,6 +232,9 @@ io.on('connection', (socket) => { }); socket.on('subscribeAll', () => { + if (socket.data?.role !== 'spectator') { + return; + } for (const record of rovers.values()) { socket.join(record.room); } @@ -201,3 +244,19 @@ io.on('connection', (socket) => { removeSocket(socket); }); }); + +function enableSpectator(socket) { + if (!socket?.id || spectatorSockets.has(socket.id)) return; + spectatorSockets.add(socket.id); + for (const record of rovers.values()) { + socket.join(record.room); + } +} + +function disableSpectator(socket) { + if (!socket?.id || !spectatorSockets.has(socket.id)) return; + spectatorSockets.delete(socket.id); + for (const record of rovers.values()) { + socket.leave(record.room); + } +} diff --git a/server/src/services/turnService.js b/server/src/services/turnService.js new file mode 100644 index 00000000..d8cb5d32 --- /dev/null +++ b/server/src/services/turnService.js @@ -0,0 +1,129 @@ +const io = require('../globals/io'); +const { sendAlert, COLORS } = require('./alertService'); +const { MODES, getMode, modeEvents } = require('./modeManager'); + +const driverQueues = new Map(); // roverId -> { queue: [], current: socketId, timer: Timeout | null } +const activeDrivers = new Map(); +const TURN_DURATION_MS = 60 * 1000; + +function driverAdded(roverId, socketId) { + const queue = ensureQueue(roverId); + if (!queue.queue.includes(socketId)) { + queue.queue.push(socketId); + } + if (!queue.current) { + queue.current = socketId; + } + syncState(roverId); +} + +function driverRemoved(roverId, socketId) { + const queue = driverQueues.get(roverId); + if (!queue) return; + queue.queue = queue.queue.filter((id) => id !== socketId); + if (queue.current === socketId) { + advanceTurn(roverId); + } +} + +function cleanupRover(roverId) { + const queue = driverQueues.get(roverId); + if (queue) { + clearTimeout(queue.timer); + } + driverQueues.delete(roverId); + activeDrivers.delete(roverId); +} + +function canDrive(roverId, socket) { + if (!socket) return false; + const queue = driverQueues.get(roverId); + if (!queue || getMode() !== MODES.TURNS || queue.queue.length <= 1) { + return true; + } + return activeDrivers.get(roverId) === socket.id; +} + +function ensureQueue(roverId) { + if (!driverQueues.has(roverId)) { + driverQueues.set(roverId, { queue: [], current: null, timer: null }); + } + return driverQueues.get(roverId); +} + +function syncState(roverId) { + const mode = getMode(); + const queue = ensureQueue(roverId); + if (mode !== MODES.TURNS || queue.queue.length <= 1) { + queue.current = queue.queue[0] || null; + setActiveDriver(roverId, queue.current); + clearTimeout(queue.timer); + return; + } + if (!queue.current) { + queue.current = queue.queue[0]; + } + setActiveDriver(roverId, queue.current); + scheduleNextTurn(roverId); +} + +function scheduleNextTurn(roverId) { + const queue = driverQueues.get(roverId); + if (!queue) return; + clearTimeout(queue.timer); + queue.timer = setTimeout(() => advanceTurn(roverId), TURN_DURATION_MS); +} + +function advanceTurn(roverId) { + const queue = driverQueues.get(roverId); + if (!queue) return; + if (queue.queue.length === 0) { + clearTimeout(queue.timer); + setActiveDriver(roverId, null); + return; + } + const mode = getMode(); + if (mode !== MODES.TURNS || queue.queue.length <= 1) { + queue.current = queue.queue[0] || null; + setActiveDriver(roverId, queue.current); + clearTimeout(queue.timer); + return; + } + const idx = queue.queue.findIndex((id) => id === queue.current); + const nextIdx = idx === -1 ? 0 : (idx + 1) % queue.queue.length; + queue.current = queue.queue[nextIdx]; + setActiveDriver(roverId, queue.current); + sendAlert({ color: COLORS.info, title: 'Turn switch', message: `${queue.current} now controls ${roverId}` }); + stopRover(roverId); + scheduleNextTurn(roverId); +} + +function setActiveDriver(roverId, socketId) { + if (!socketId) { + activeDrivers.delete(roverId); + } else { + activeDrivers.set(roverId, socketId); + } + io.emit('activeDriver', { roverId, socketId }); +} + +function stopRover(roverId) { + try { + const { issueCommand } = require('./commandService'); + issueCommand(roverId, { type: 'drive', driveDirect: { left: 0, right: 0 } }); + issueCommand(roverId, { type: 'motors', motorPwm: { main: 0, side: 0, vacuum: 0 } }); + } catch (err) { + // best effort; log elsewhere if needed + } +} + +modeEvents.on('change', (mode) => { + driverQueues.forEach((_, roverId) => syncState(roverId)); +}); + +module.exports = { + driverAdded, + driverRemoved, + cleanupRover, + canDrive, +};