mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-15 17:12:59 -04:00
did stuff
This commit is contained in:
@@ -10,3 +10,4 @@ node_modules/
|
||||
config.h
|
||||
robots.json
|
||||
roverd-dummy
|
||||
server/config.yaml
|
||||
|
||||
@@ -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
|
||||
|
||||
Vendored
BIN
Binary file not shown.
+26
-1
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -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');
|
||||
|
||||
Generated
+54
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Multi Roomba Rover</h1>
|
||||
<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="roverList">
|
||||
<label for="roverSelect">Rovers</label>
|
||||
@@ -41,6 +51,7 @@
|
||||
</div>
|
||||
<div style="flex: 1">
|
||||
<h3>Sensor Frames</h3>
|
||||
<div id="activeDriver">Active driver: --</div>
|
||||
<pre id="sensorOutput"></pre>
|
||||
<p>Use WASD keys to drive the selected rover. Shift increases speed.</p>
|
||||
<div id="alerts"></div>
|
||||
@@ -52,6 +63,7 @@
|
||||
<script src="src/helpers/formatters.js"></script>
|
||||
<script src="src/services/state.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/driveControls.js"></script>
|
||||
<script src="src/main.js"></script>
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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() });
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
|
||||
Executable
+27
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
Reference in New Issue
Block a user