first push of

This commit is contained in:
legop3
2025-11-09 21:15:13 -05:00
parent b6bc4674f4
commit 3a91e0cff0
24 changed files with 2952 additions and 1 deletions
+1560
View File
File diff suppressed because it is too large Load Diff
+19
View File
@@ -0,0 +1,19 @@
{
"name": "multiroombarover-server",
"version": "0.1.0",
"private": true,
"scripts": {
"start": "node src/index.js",
"dev": "nodemon src/index.js"
},
"dependencies": {
"express": "^4.19.2",
"morgan": "^1.10.0",
"socket.io": "^4.7.5",
"uuid": "^9.0.1",
"ws": "^8.18.0"
},
"devDependencies": {
"nodemon": "^3.1.0"
}
}
+155
View File
@@ -0,0 +1,155 @@
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 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 }) => {
if (roverId !== selectedRover) return;
sensorOutput.textContent = formatSensorFrame(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}`;
}
});
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 = 120;
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));
}
+46
View File
@@ -0,0 +1,46 @@
<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<title>Multi Roomba Rover</title>
<style>
body { font-family: sans-serif; margin: 16px; }
#layout { display: flex; gap: 16px; }
#roverList { width: 240px; }
#sensorOutput { font-family: monospace; height: 320px; overflow: auto; border: 1px solid #999; padding: 8px; }
button { margin: 4px; }
.row { margin-bottom: 8px; }
</style>
</head>
<body>
<h1>Multi Roomba Rover</h1>
<div id="status">Connecting…</div>
<div id="layout">
<div id="roverList">
<label for="roverSelect">Rovers</label>
<select id="roverSelect" size="10" style="width: 100%"></select>
<div class="row">
<button data-mode="start">Start OI</button>
<button data-mode="safe">Safe</button>
<button data-mode="full">Full</button>
<button data-mode="passive">Passive</button>
<button data-mode="dock">Dock</button>
</div>
<div class="row">
<button id="sensorToggle">Enable Sensor Stream</button>
</div>
<div class="row">
<button id="motorsStop">Stop Motors</button>
<button id="mediaRestart">Restart Camera</button>
</div>
</div>
<div style="flex: 1">
<h3>Sensor Frames</h3>
<pre id="sensorOutput"></pre>
<p>Use WASD keys to drive the selected rover. Shift increases speed.</p>
</div>
</div>
<script src="/socket.io/socket.io.js"></script>
<script src="app.js" type="module"></script>
</body>
</html>
+171
View File
@@ -0,0 +1,171 @@
const http = require('http');
const path = require('path');
const express = require('express');
const morgan = require('morgan');
const { Server: SocketIOServer } = require('socket.io');
const { WebSocketServer } = require('ws');
const { v4: uuidv4 } = require('uuid');
const PORT = process.env.PORT || 8080;
const app = express();
app.use(morgan('dev'));
app.use(express.json());
app.use(express.static(path.join(__dirname, '..', 'public')));
const httpServer = http.createServer(app);
const io = new SocketIOServer(httpServer, {
cors: { origin: '*' },
});
const roverWSS = new WebSocketServer({ noServer: true });
const rovers = new Map();
const pendingCommands = new Map();
httpServer.on('upgrade', (req, socket, head) => {
if (req.url.startsWith('/rover')) {
roverWSS.handleUpgrade(req, socket, head, (ws) => {
roverWSS.emit('connection', ws, req);
});
} else {
socket.destroy();
}
});
roverWSS.on('connection', (ws) => {
handleRoverConnection(ws);
});
function handleRoverConnection(ws) {
let roverId = null;
ws.on('message', (raw) => {
let msg;
try {
msg = JSON.parse(raw.toString());
} catch (err) {
return;
}
switch (msg.type) {
case 'hello':
roverId = msg.name;
rovers.set(roverId, {
id: roverId,
ws,
meta: msg,
lastSensor: null,
lastSeen: Date.now(),
});
broadcastRoster();
break;
case 'sensor':
if (!roverId || !rovers.has(roverId)) {
return;
}
rovers.get(roverId).lastSensor = msg;
rovers.get(roverId).lastSeen = Date.now();
io.emit('sensorFrame', { roverId, frame: msg });
break;
case 'ack':
if (msg.id && pendingCommands.has(msg.id)) {
const pending = pendingCommands.get(msg.id);
pendingCommands.delete(msg.id);
io.emit('commandAck', { roverId: pending.roverId, id: msg.id, status: msg.status, error: msg.error });
}
break;
default:
break;
}
});
ws.on('close', () => {
if (roverId) {
rovers.delete(roverId);
broadcastRoster();
}
});
}
io.on('connection', (socket) => {
socket.emit('rovers', getRoster());
socket.on('command', (payload = {}, cb = () => {}) => {
try {
const commandId = routeCommand(payload);
cb({ id: commandId });
} catch (err) {
cb({ error: err.message });
}
});
});
function getRoster() {
return Array.from(rovers.values()).map(({ id, meta, lastSeen }) => ({
id,
name: meta?.name ?? id,
battery: meta?.battery ?? null,
maxWheelSpeed: meta?.maxWheelSpeed ?? null,
media: meta?.media ?? null,
lastSeen,
}));
}
function broadcastRoster() {
io.emit('rovers', getRoster());
}
function routeCommand(payload) {
const { roverId, type, data } = payload;
if (!roverId) {
throw new Error('roverId missing');
}
if (!rovers.has(roverId)) {
throw new Error(`rover ${roverId} not connected`);
}
const rover = rovers.get(roverId);
const message = buildCommand(type, data);
const id = uuidv4();
message.id = id;
rover.ws.send(JSON.stringify(message));
pendingCommands.set(id, { roverId, issuedAt: Date.now(), type });
return id;
}
function buildCommand(type, data = {}) {
switch (type) {
case 'drive':
return { type: 'drive', driveDirect: { left: data.left || 0, right: data.right || 0 } };
case 'motors':
return {
type: 'motors',
motorPwm: {
main: data.main ?? 0,
side: data.side ?? 0,
vacuum: data.vacuum ?? 0,
},
};
case 'raw':
return { type: 'raw', raw: Buffer.from(data.bytes || []).toString('base64') };
case 'sensorStream':
return { type: 'sensorStream', sensorStream: { enable: Boolean(data.enable) } };
case 'media':
if (!data || !data.action) {
throw new Error('media action required');
}
return { type: 'media', media: { action: data.action } };
default:
throw new Error(`unknown command type: ${type}`);
}
}
if (require.main === module) {
httpServer.listen(PORT, () => {
console.log(`Server listening on :${PORT}`);
});
}
module.exports = {
httpServer,
io,
routeCommand,
};