did stuff

This commit is contained in:
legop3
2025-11-13 13:54:14 -05:00
parent 4d5a918921
commit d601e9aa82
22 changed files with 565 additions and 13 deletions
+1
View File
@@ -10,3 +10,4 @@ node_modules/
config.h config.h
robots.json robots.json
roverd-dummy roverd-dummy
server/config.yaml
+4
View File
@@ -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 youd like on your dev machine. 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 youd 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 UIs 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 cant 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): Deploy a rover by copying the repo + `dist/roverd` to the Pi and running the helper (it will also fetch mediaMTX when `--mediamtx` is set):
```bash ```bash
Vendored
BIN
View File
Binary file not shown.
+26 -1
View File
@@ -41,6 +41,9 @@ func (c *WSClient) Run(ctx context.Context) error {
if err := c.sendHello(ctx, conn); err != nil { if err := c.sendHello(ctx, conn); err != nil {
return err return err
} }
if err := c.ensureSensorStream(); err != nil {
c.log.Printf("sensor stream init failed: %v", err)
}
errCh := make(chan error, 1) errCh := make(chan error, 1)
go func() { go func() {
@@ -126,7 +129,13 @@ func (c *WSClient) dispatch(ctx context.Context, msg *inboundMessage) error {
if err != nil { if err != nil {
return fmt.Errorf("raw decode: %w", err) 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: case msg.Media != nil:
if c.media == nil { if c.media == nil {
return fmt.Errorf("media supervisor disabled") return fmt.Errorf("media supervisor disabled")
@@ -193,3 +202,19 @@ func clamp(value, min, max int) int {
} }
return value 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
}
}
+9
View File
@@ -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
+1
View File
@@ -9,6 +9,7 @@ require('./src/helpers/sensorDecoder');
require('./src/services/alertService'); require('./src/services/alertService');
require('./src/services/authService'); require('./src/services/authService');
require('./src/services/modeManager'); require('./src/services/modeManager');
require('./src/services/lockdownGuard');
require('./src/services/roverManager'); require('./src/services/roverManager');
require('./src/services/commandService'); require('./src/services/commandService');
require('./src/services/roverConnectionService'); require('./src/services/roverConnectionService');
+54
View File
@@ -8,7 +8,9 @@
"name": "multiroombarover-server", "name": "multiroombarover-server",
"version": "0.1.0", "version": "0.1.0",
"dependencies": { "dependencies": {
"bcrypt": "^6.0.0",
"express": "^4.19.2", "express": "^4.19.2",
"js-yaml": "^4.1.1",
"morgan": "^1.10.0", "morgan": "^1.10.0",
"socket.io": "^4.7.5", "socket.io": "^4.7.5",
"uuid": "^9.0.1", "uuid": "^9.0.1",
@@ -69,6 +71,12 @@
"node": ">= 8" "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": { "node_modules/array-flatten": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
@@ -109,6 +117,20 @@
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
"license": "MIT" "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": { "node_modules/binary-extensions": {
"version": "2.3.0", "version": "2.3.0",
"resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz",
@@ -769,6 +791,18 @@
"node": ">=0.12.0" "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": { "node_modules/math-intrinsics": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
@@ -894,6 +928,26 @@
"node": ">= 0.6" "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": { "node_modules/nodemon": {
"version": "3.1.10", "version": "3.1.10",
"resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.10.tgz", "resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.10.tgz",
+2
View File
@@ -7,7 +7,9 @@
"dev": "nodemon index.js" "dev": "nodemon index.js"
}, },
"dependencies": { "dependencies": {
"bcrypt": "^6.0.0",
"express": "^4.19.2", "express": "^4.19.2",
"js-yaml": "^4.1.1",
"morgan": "^1.10.0", "morgan": "^1.10.0",
"socket.io": "^4.7.5", "socket.io": "^4.7.5",
"uuid": "^9.0.1", "uuid": "^9.0.1",
+12
View File
@@ -12,11 +12,21 @@
.row { margin-bottom: 8px; } .row { margin-bottom: 8px; }
#alerts { margin-top: 12px; max-width: 600px; } #alerts { margin-top: 12px; max-width: 600px; }
.alert { background: #fff1d2; border: 1px solid #f0b651; padding: 6px 8px; margin-bottom: 6px; font-size: 0.9rem; } .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; }
</style> </style>
</head> </head>
<body> <body>
<h1>Multi Roomba Rover</h1> <h1>Multi Roomba Rover</h1>
<div id="status">Connecting…</div> <div id="status">Connecting…</div>
<div id="authPanel">
<input id="username" placeholder="Username" />
<input id="password" type="password" placeholder="Password" />
<button id="loginBtn">Admin Login</button>
<button id="spectatorBtn">Spectate</button>
<span id="roleStatus">Role: user</span>
</div>
<div id="layout"> <div id="layout">
<div id="roverList"> <div id="roverList">
<label for="roverSelect">Rovers</label> <label for="roverSelect">Rovers</label>
@@ -41,6 +51,7 @@
</div> </div>
<div style="flex: 1"> <div style="flex: 1">
<h3>Sensor Frames</h3> <h3>Sensor Frames</h3>
<div id="activeDriver">Active driver: --</div>
<pre id="sensorOutput"></pre> <pre id="sensorOutput"></pre>
<p>Use WASD keys to drive the selected rover. Shift increases speed.</p> <p>Use WASD keys to drive the selected rover. Shift increases speed.</p>
<div id="alerts"></div> <div id="alerts"></div>
@@ -52,6 +63,7 @@
<script src="src/helpers/formatters.js"></script> <script src="src/helpers/formatters.js"></script>
<script src="src/services/state.js"></script> <script src="src/services/state.js"></script>
<script src="src/services/alertFeed.js"></script> <script src="src/services/alertFeed.js"></script>
<script src="src/services/authControls.js"></script>
<script src="src/services/roverUI.js"></script> <script src="src/services/roverUI.js"></script>
<script src="src/services/driveControls.js"></script> <script src="src/services/driveControls.js"></script>
<script src="src/main.js"></script> <script src="src/main.js"></script>
+1
View File
@@ -2,5 +2,6 @@ requireModule('globals/socket');
requireModule('helpers/formatters'); requireModule('helpers/formatters');
requireModule('services/state'); requireModule('services/state');
requireModule('services/alertFeed'); requireModule('services/alertFeed');
requireModule('services/authControls');
requireModule('services/roverUI'); requireModule('services/roverUI');
requireModule('services/driveControls'); requireModule('services/driveControls');
@@ -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');
});
});
+14
View File
@@ -8,9 +8,16 @@ registerModule('services/roverUI', (require, exports) => {
const sensorOutput = document.getElementById('sensorOutput'); const sensorOutput = document.getElementById('sensorOutput');
const requestBtn = document.getElementById('requestControl'); const requestBtn = document.getElementById('requestControl');
const lockBtn = document.getElementById('lockToggle'); const lockBtn = document.getElementById('lockToggle');
const activeDriverEl = document.getElementById('activeDriver');
let roster = []; 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) => { socket.on('rovers', (list) => {
roster = list; roster = list;
roverSelect.innerHTML = ''; roverSelect.innerHTML = '';
@@ -48,6 +55,13 @@ registerModule('services/roverUI', (require, exports) => {
sensorOutput.textContent = lines.join('\n'); 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', () => { requestBtn?.addEventListener('click', () => {
socket.emit('requestControl', { roverId: state.getSelected() }); socket.emit('requestControl', { roverId: state.getSelected() });
}); });
+20
View File
@@ -1,5 +1,7 @@
registerModule('services/state', (require, exports) => { registerModule('services/state', (require, exports) => {
let selectedRover = null; let selectedRover = null;
let role = 'user';
const roleListeners = new Set();
function setSelected(roverId) { function setSelected(roverId) {
selectedRover = roverId; selectedRover = roverId;
@@ -9,6 +11,24 @@ registerModule('services/state', (require, exports) => {
return selectedRover; 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.setSelected = setSelected;
exports.getSelected = getSelected; exports.getSelected = getSelected;
exports.setRole = setRole;
exports.getRole = getRole;
exports.onRoleChange = onRoleChange;
}); });
+27
View File
@@ -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);
});
}
+20
View File
@@ -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,
};
+55 -4
View File
@@ -1,12 +1,63 @@
function isAdmin(/* socket */) { const bcrypt = require('bcrypt');
return false; 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 */) { async function authenticate(username, password) {
return false; 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 = { module.exports = {
isAdmin, isAdmin,
isLockdownAdmin, isLockdownAdmin,
authenticate,
}; };
+2 -3
View File
@@ -1,7 +1,6 @@
const { v4: uuidv4 } = require('uuid'); const { v4: uuidv4 } = require('uuid');
const io = require('../globals/io'); const io = require('../globals/io');
const roverManager = require('./roverManager'); const roverManager = require('./roverManager');
const { isAdmin } = require('./authService');
const pendingCommands = new Map(); // id -> { roverId } const pendingCommands = new Map(); // id -> { roverId }
@@ -40,8 +39,8 @@ io.on('connection', (socket) => {
if (!roverId) { if (!roverId) {
throw new Error('roverId required'); throw new Error('roverId required');
} }
if (!roverManager.isDriver(roverId, socket) && !isAdmin(socket)) { if (!roverManager.canDrive(roverId, socket)) {
throw new Error('Not controlling this rover'); throw new Error('Not your turn or no control');
} }
const payload = data ? { ...data } : {}; const payload = data ? { ...data } : {};
const id = issueCommand(roverId, { type, ...payload }); const id = issueCommand(roverId, { type, ...payload });
+46
View File
@@ -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();
}
});
+5 -1
View File
@@ -1,6 +1,7 @@
const EventEmitter = require('events');
const io = require('../globals/io'); const io = require('../globals/io');
const { sendAlert, COLORS } = require('./alertService'); const { sendAlert, COLORS } = require('./alertService');
const { isAdmin, isLockdownAdmin } = require('./authService'); const { isAdmin, isLockdownAdmin } = require('./roleService');
const MODES = { const MODES = {
OPEN: 'open', OPEN: 'open',
@@ -10,6 +11,7 @@ const MODES = {
}; };
let currentMode = MODES.OPEN; let currentMode = MODES.OPEN;
const modeEvents = new EventEmitter();
function canChangeMode(socket, nextMode) { function canChangeMode(socket, nextMode) {
if (nextMode === MODES.LOCKDOWN) { if (nextMode === MODES.LOCKDOWN) {
@@ -34,6 +36,7 @@ function setMode(nextMode, socket) {
title: 'Mode Changed', title: 'Mode Changed',
message: `Server mode set to ${nextMode}`, message: `Server mode set to ${nextMode}`,
}); });
modeEvents.emit('change', currentMode);
return currentMode; return currentMode;
} }
@@ -45,6 +48,7 @@ module.exports = {
MODES, MODES,
getMode, getMode,
setMode, setMode,
modeEvents,
}; };
io.on('connection', (socket) => { io.on('connection', (socket) => {
+33
View File
@@ -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,
};
+62 -3
View File
@@ -3,10 +3,12 @@ const logger = require('../globals/logger');
const { sendAlert, COLORS } = require('./alertService'); const { sendAlert, COLORS } = require('./alertService');
const { parseSensorFrame } = require('../helpers/sensorDecoder'); const { parseSensorFrame } = require('../helpers/sensorDecoder');
const { MODES, getMode } = require('./modeManager'); const { MODES, getMode } = require('./modeManager');
const { isAdmin } = require('./authService'); const { isAdmin, roleEvents } = require('./roleService');
const rovers = new Map(); // roverId -> record const rovers = new Map(); // roverId -> record
const socketToRovers = new Map(); // socketId -> Set(roverId) const socketToRovers = new Map(); // socketId -> Set(roverId)
const spectatorSockets = new Set();
const turnService = require('./turnService');
function ensureRecord(id) { function ensureRecord(id) {
if (!rovers.has(id)) { if (!rovers.has(id)) {
@@ -31,6 +33,10 @@ function upsertRover(meta, ws) {
record.ws = ws; record.ws = ws;
record.lastSeen = Date.now(); record.lastSeen = Date.now();
rovers.set(id, record); rovers.set(id, record);
spectatorSockets.forEach((socketId) => {
const sock = io.sockets.sockets.get(socketId);
sock?.join(record.room);
});
broadcastRoster(); broadcastRoster();
return record; return record;
} }
@@ -39,6 +45,11 @@ function removeRover(id) {
const record = rovers.get(id); const record = rovers.get(id);
if (!record) return; if (!record) return;
rovers.delete(id); rovers.delete(id);
turnService.cleanupRover(id);
spectatorSockets.forEach((socketId) => {
const sock = io.sockets.sockets.get(socketId);
sock?.leave(record.room);
});
broadcastRoster(); broadcastRoster();
} }
@@ -89,14 +100,19 @@ function handleSensorFrame(roverId, frame) {
function removeSocket(socket) { function removeSocket(socket) {
const joined = socketToRovers.get(socket.id); const joined = socketToRovers.get(socket.id);
if (!joined) return; if (!joined) {
disableSpectator(socket);
return;
}
for (const roverId of joined) { for (const roverId of joined) {
const record = rovers.get(roverId); const record = rovers.get(roverId);
if (record) { if (record) {
record.drivers.delete(socket.id); record.drivers.delete(socket.id);
} }
turnService.driverRemoved(roverId, socket.id);
} }
socketToRovers.delete(socket.id); socketToRovers.delete(socket.id);
disableSpectator(socket);
} }
function requestControl(roverId, socket) { function requestControl(roverId, socket) {
@@ -104,6 +120,9 @@ function requestControl(roverId, socket) {
if (!record) { if (!record) {
throw new Error('Unknown rover'); throw new Error('Unknown rover');
} }
if (!isAdmin(socket)) {
throw new Error('Only admins can request control');
}
if (record.locked && !isAdmin(socket)) { if (record.locked && !isAdmin(socket)) {
throw new Error('Rover locked'); throw new Error('Rover locked');
} }
@@ -114,13 +133,13 @@ function requestControl(roverId, socket) {
if (mode === MODES.LOCKDOWN && !isAdmin(socket)) { if (mode === MODES.LOCKDOWN && !isAdmin(socket)) {
throw new Error('Server in lockdown'); throw new Error('Server in lockdown');
} }
// TODO: future turns logic
record.drivers.add(socket.id); record.drivers.add(socket.id);
if (!socketToRovers.has(socket.id)) { if (!socketToRovers.has(socket.id)) {
socketToRovers.set(socket.id, new Set()); socketToRovers.set(socket.id, new Set());
} }
socketToRovers.get(socket.id).add(roverId); socketToRovers.get(socket.id).add(roverId);
socket.join(record.room); socket.join(record.room);
turnService.driverAdded(roverId, socket.id);
sendAlert({ sendAlert({
color: COLORS.success, color: COLORS.success,
title: 'Control Granted', title: 'Control Granted',
@@ -141,6 +160,7 @@ function releaseControl(roverId, socket) {
} }
} }
socket.leave(record.room); socket.leave(record.room);
turnService.driverRemoved(roverId, socket.id);
} }
function isDriver(roverId, socket) { function isDriver(roverId, socket) {
@@ -149,6 +169,10 @@ function isDriver(roverId, socket) {
return record.drivers.has(socket.id); return record.drivers.has(socket.id);
} }
function canDrive(roverId, socket) {
return turnService.canDrive(roverId, socket) || isAdmin(socket);
}
module.exports = { module.exports = {
upsertRover, upsertRover,
removeRover, removeRover,
@@ -160,10 +184,26 @@ module.exports = {
releaseControl, releaseControl,
removeSocket, removeSocket,
isDriver, isDriver,
canDrive,
enableSpectator,
disableSpectator,
rovers, rovers,
}; };
roleEvents.on('change', ({ socket, role }) => {
if (role === 'spectator') {
enableSpectator(socket);
} else {
disableSpectator(socket);
}
});
io.on('connection', (socket) => { io.on('connection', (socket) => {
socket.emit('rovers', getRoster());
if (socket.data?.role === 'spectator') {
enableSpectator(socket);
}
socket.on('requestControl', ({ roverId } = {}) => { socket.on('requestControl', ({ roverId } = {}) => {
try { try {
const targetId = roverId || Array.from(rovers.keys())[0]; const targetId = roverId || Array.from(rovers.keys())[0];
@@ -192,6 +232,9 @@ io.on('connection', (socket) => {
}); });
socket.on('subscribeAll', () => { socket.on('subscribeAll', () => {
if (socket.data?.role !== 'spectator') {
return;
}
for (const record of rovers.values()) { for (const record of rovers.values()) {
socket.join(record.room); socket.join(record.room);
} }
@@ -201,3 +244,19 @@ io.on('connection', (socket) => {
removeSocket(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);
}
}
+129
View File
@@ -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,
};