Merge pull request #1 from legop3/react-ui-remake

React UI remake
This commit is contained in:
legop3
2025-11-17 16:24:42 -05:00
committed by GitHub
73 changed files with 6949 additions and 885 deletions
+2 -2
View File
@@ -13,8 +13,8 @@ source "$ENV_FILE"
: "${PUBLISH_URL:?PUBLISH_URL not set in ${ENV_FILE}}"
VIDEO_WIDTH="${VIDEO_WIDTH:-1280}"
VIDEO_HEIGHT="${VIDEO_HEIGHT:-720}"
VIDEO_WIDTH="${VIDEO_WIDTH:-1920}"
VIDEO_HEIGHT="${VIDEO_HEIGHT:-1080}"
VIDEO_FPS="${VIDEO_FPS:-30}"
VIDEO_BITRATE="${VIDEO_BITRATE:-3000000}"
+13
View File
@@ -0,0 +1,13 @@
# general idea
- servo connected to pin 19 on the pi
- it will allow the camera to look up and down
- use go-rpio
- it has a hardware PWM implementation
- mechanical design will have limits
- set the limits in roverd config
- control the servo from the web browser
- expose both a slider and buttons for "nudging" it up and down
## verification of functionality before full implementation
- make sure that the go program can properly use the GPIO correctly before full implementation
- modify the roverd installer to add the needed stuff to enable PWM access
+17
View File
@@ -0,0 +1,17 @@
## video stream
- video stream needs to always autoplay, even if it cant unmute itself
- currently, it wont play at all unless it can unmute
- video stream currently unloads and reloads based on the tab being active
- just remove this feature. It needs to stay running all the time.
## server sensor decoding
- OI mode is not decoded properly
- says "safe" when in passive, and "unknown" when in full.
## UI and session info
- UI needs a display for the turns mode
## Mobile UI
- need to make it good
- joystick needs to be floating
- aux motor buttons need redone
+2
View File
@@ -17,4 +17,6 @@ require('./src/services/assignmentService');
require('./src/services/videoSessions');
require('./src/services/videoAuthService');
require('./src/services/videoSocketService');
require('./src/services/logStreamService');
require('./src/services/sessionService');
require('./src/services/httpServer');
+2
View File
@@ -33,3 +33,5 @@ paths:
all:
source: publisher
sourceOnDemand: no
# webrtcMaxPlayoutDelay: 0
# alwaysRemuxWhep: no
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+8 -87
View File
@@ -1,93 +1,14 @@
<!doctype html>
<html>
<html lang="en">
<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; }
#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; }
#videoPanel { margin-bottom: 12px; }
#videoContainer { width: 100%; background: #000; min-height: 200px; display: flex; align-items: center; justify-content: center; }
#videoContainer video { width: 100%; max-height: 320px; background: #000; }
#videoStatus { margin-top: 6px; font-size: 0.9rem; color: #444; }
</style>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>webui</title>
<script type="module" crossorigin src="/assets/index-BvgUoEqO.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-DwQ38q3M.css">
</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>
<span id="roleStatus">Role: user</span>
</div>
<div id="layout">
<div id="roverList">
<label for="roverSelect">Rovers</label>
<select id="roverSelect" size="10" style="width: 100%"></select>
<div class="row" id="adminControls">
<label><input type="checkbox" id="forceControl" /> Force control</label>
<button id="lockToggle">Lock/Unlock</button>
</div>
<div class="row" id="modeControls" style="display:none">
<label for="modeSelect">Server mode</label>
<select id="modeSelect">
<option value="open">Open</option>
<option value="turns">Turns</option>
<option value="admin">Admin</option>
<option value="lockdown">Lockdown</option>
</select>
</div>
<div class="row">
<button data-oi="start">Start OI</button>
<button data-oi="safe">Safe</button>
<button data-oi="full">Full</button>
<button data-oi="passive">Passive</button>
<button data-oi="dock">Dock</button>
</div>
<div class="row">
<button id="sensorToggle">Enable Sensor Stream</button>
</div>
<div class="row">
<button id="motorsStop">Stop Motors</button>
</div>
</div>
<div style="flex: 1">
<h3>Sensor Frames</h3>
<div id="currentRover">Active rover: --</div>
<div id="activeDriver">Driver: --</div>
<div id="videoPanel">
<div id="videoContainer">
<video id="driverVideo" muted playsinline></video>
</div>
<div id="videoStatus">No rover selected</div>
<button id="videoReconnect">Reconnect Video</button>
</div>
<pre id="sensorOutput"></pre>
<p>Use WASD keys to drive the selected rover. Shift increases speed.</p>
<div id="alerts"></div>
</div>
</div>
<script src="/socket.io/socket.io.js"></script>
<script src="loader.js"></script>
<script src="src/globals/socket.js"></script>
<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/videoPlayer.js"></script>
<script src="src/services/roverUI.js"></script>
<script src="src/services/driveControls.js"></script>
<script src="src/main.js"></script>
<p><a href="/spectate/">Open spectator view</a></p>
<div id="root"></div>
</body>
</html>
-22
View File
@@ -1,22 +0,0 @@
(function () {
const modules = {};
const cache = {};
window.registerModule = function (name, factory) {
modules[name] = factory;
};
window.requireModule = function (name) {
if (cache[name]) {
return cache[name];
}
const factory = modules[name];
if (!factory) {
throw new Error(`Module ${name} not found`);
}
const exports = {};
cache[name] = exports;
factory(requireModule, exports);
return exports;
};
})();
-33
View File
@@ -1,33 +0,0 @@
<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<title>Rover Spectator</title>
<style>
body { font-family: sans-serif; margin: 16px; }
#status { margin-bottom: 12px; }
#grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 12px; }
.rover-card { border: 1px solid #ccc; padding: 12px; border-radius: 6px; background: #fdfdfd; }
.rover-card h3 { margin: 0 0 8px 0; }
.sensor { font-family: monospace; white-space: pre-wrap; word-break: break-word; }
.meta { font-size: 0.9rem; color: #555; margin-bottom: 8px; }
.video-wrap { background: #000; min-height: 160px; margin-bottom: 6px; display: flex; align-items: center; justify-content: center; }
.video-wrap video { width: 100%; max-height: 200px; background: #000; }
.video-status { font-size: 0.85rem; color: #444; margin-bottom: 4px; }
</style>
</head>
<body>
<h1>Rover Spectator</h1>
<div id="status">Connecting…</div>
<div id="grid"></div>
<script src="/socket.io/socket.io.js"></script>
<script>
window.__desiredRole = 'spectator';
</script>
<script src="../loader.js"></script>
<script src="../src/globals/socket.js"></script>
<script src="../src/services/videoPlayer.js"></script>
<script src="spectator.js"></script>
</body>
</html>
-131
View File
@@ -1,131 +0,0 @@
registerModule('spectator/main', (require, exports) => {
const { socket } = require('globals/socket');
const videoPlayer = require('services/videoPlayer');
const statusEl = document.getElementById('status');
const grid = document.getElementById('grid');
const cards = new Map();
socket.on('connect', () => {
statusEl.textContent = 'Connected';
});
socket.on('disconnect', () => {
statusEl.textContent = 'Disconnected';
stopAllVideos();
});
socket.on('lockdown', ({ message }) => {
statusEl.textContent = message || 'Spectator mode disabled';
stopAllVideos();
});
socket.on('rovers', (list) => {
list.forEach((rover) => {
const card = ensureCard(rover.id);
card.querySelector('.meta').textContent = `Locked: ${rover.locked ? 'yes' : 'no'} | Last seen: ${new Date(rover.lastSeen).toLocaleTimeString()}`;
if (!card.dataset.videoActive) {
connectVideo(rover.id);
}
});
Array.from(cards.keys()).forEach((id) => {
if (!list.find((r) => r.id === id)) {
removeCard(id);
}
});
});
socket.on('sensorFrame', ({ roverId, sensors }) => {
const card = ensureCard(roverId);
card.querySelector('.sensor').textContent = sensors ? JSON.stringify(sensors, null, 2) : 'No data';
});
function ensureCard(roverId) {
if (cards.has(roverId)) {
return cards.get(roverId);
}
const card = document.createElement('div');
card.className = 'rover-card';
card.dataset.videoActive = '';
card.innerHTML = `
<h3>${roverId}</h3>
<div class="meta"></div>
<div class="video-wrap"></div>
<div class="video-status">Video not connected</div>
<button class="video-reconnect">Reconnect video</button>
<pre class="sensor">Waiting for data...</pre>
`;
const reconnectBtn = card.querySelector('.video-reconnect');
reconnectBtn.addEventListener('click', () => connectVideo(roverId, true));
grid.appendChild(card);
cards.set(roverId, card);
connectVideo(roverId, true);
return card;
}
function connectVideo(roverId, force = false) {
const card = ensureCard(roverId);
const status = card.querySelector('.video-status');
const wrap = card.querySelector('.video-wrap');
const playerId = `spectator-${roverId}`;
if (!force && card.dataset.videoActive === 'yes') {
return;
}
status.textContent = 'Connecting video…';
socket.emit('video:request', { roverId }, (resp = {}) => {
if (resp.error) {
status.textContent = `Video error: ${resp.error}`;
videoPlayer.stopStream(playerId);
card.dataset.videoActive = '';
return;
}
videoPlayer
.startStream(playerId, {
url: resp.url,
token: resp.token,
mount: wrap,
onStatus: (state, detail) => {
if (state === 'playing') {
status.textContent = 'Video live';
card.dataset.videoActive = 'yes';
} else if (state === 'connecting') {
status.textContent = 'Connecting video…';
} else if (state === 'error') {
status.textContent = `Video error: ${detail}`;
card.dataset.videoActive = '';
} else if (state === 'stopped') {
status.textContent = 'Video stopped';
card.dataset.videoActive = '';
}
},
})
.catch((err) => {
status.textContent = `Video error: ${err.message}`;
card.dataset.videoActive = '';
});
});
}
function removeCard(roverId) {
const card = cards.get(roverId);
if (card) {
videoPlayer.stopStream(`spectator-${roverId}`);
card.remove();
cards.delete(roverId);
}
}
function stopAllVideos() {
Array.from(cards.keys()).forEach((id) => {
videoPlayer.stopStream(`spectator-${id}`);
const card = cards.get(id);
if (card) {
const status = card.querySelector('.video-status');
status.textContent = 'Video stopped';
card.dataset.videoActive = '';
}
});
}
});
requireModule('spectator/main');
-8
View File
@@ -1,8 +0,0 @@
registerModule('globals/socket', (require, exports) => {
const query = {};
if (window.__desiredRole) {
query.role = window.__desiredRole;
}
const socket = io(query.role ? { query } : undefined);
exports.socket = socket;
});
-11
View File
@@ -1,11 +0,0 @@
registerModule('helpers/formatters', (require, exports) => {
function formatHex(base64) {
if (!base64) return '';
const buffer = Uint8Array.from(atob(base64), (c) => c.charCodeAt(0));
return Array.from(buffer)
.map((b) => b.toString(16).padStart(2, '0'))
.join(' ');
}
exports.formatHex = formatHex;
});
-7
View File
@@ -1,7 +0,0 @@
requireModule('globals/socket');
requireModule('helpers/formatters');
requireModule('services/state');
requireModule('services/alertFeed');
requireModule('services/authControls');
requireModule('services/roverUI');
requireModule('services/driveControls');
-25
View File
@@ -1,25 +0,0 @@
registerModule('services/alertFeed', (require, exports) => {
const { socket } = require('globals/socket');
const container = document.getElementById('alerts');
if (!container) return;
function pushAlert({ color = '#2196f3', title, message, ts }) {
const div = document.createElement('div');
div.className = 'alert';
div.style.borderColor = color;
div.style.background = color + '22';
div.innerHTML = `<strong>${title || 'Notice'}</strong> - ${message || ''}`;
container.prepend(div);
while (container.children.length > 5) {
container.removeChild(container.lastChild);
}
setTimeout(() => {
if (div.parentElement === container) {
container.removeChild(div);
}
}, 15000);
}
socket.on('alert', pushAlert);
});
@@ -1,35 +0,0 @@
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');
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());
});
-127
View File
@@ -1,127 +0,0 @@
registerModule('services/driveControls', (require, exports) => {
const { socket } = require('globals/socket');
const state = require('services/state');
const motorsStopBtn = document.getElementById('motorsStop');
const sensorToggleBtn = document.getElementById('sensorToggle');
const modeButtons = document.querySelectorAll('[data-oi]');
let sensorEnabled = false;
let lastDrive = { left: 0, right: 0 };
const keys = new Set();
window.addEventListener('keydown', (event) => {
const key = event.key.toLowerCase();
if (keys.has(key)) return;
keys.add(key);
maybeSendDriveUpdate();
});
window.addEventListener('keyup', (event) => {
const key = event.key.toLowerCase();
if (!keys.has(key)) return;
keys.delete(key);
maybeSendDriveUpdate();
});
function maybeSendDriveUpdate() {
const roverId = state.getSelected();
if (!roverId) return;
const speeds = computeDrive();
if (speeds.left === lastDrive.left && speeds.right === lastDrive.right) return;
lastDrive = speeds;
socket.emit('command', {
roverId,
type: 'drive',
data: { driveDirect: speeds },
});
}
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 clamp(value, min, max) {
return Math.max(min, Math.min(max, value));
}
motorsStopBtn?.addEventListener('click', () => {
const roverId = state.getSelected();
if (!roverId) return;
socket.emit('command', {
roverId,
type: 'motors',
data: { motorPwm: { main: 0, side: 0, vacuum: 0 } },
});
});
sensorToggleBtn?.addEventListener('click', () => {
sensorEnabled = !sensorEnabled;
sensorToggleBtn.textContent = sensorEnabled ? 'Disable Sensor Stream' : 'Enable Sensor Stream';
const roverId = state.getSelected();
socket.emit('command', {
roverId,
type: 'sensorStream',
data: { sensorStream: { enable: sensorEnabled } },
});
});
const OI_COMMANDS = {
start: [128],
safe: [131],
full: [132],
passive: [128],
dock: [143],
};
modeButtons.forEach((btn) => {
btn.addEventListener('click', () => {
const cmd = btn.dataset.oi;
const bytes = OI_COMMANDS[cmd];
if (!bytes) return;
const roverId = state.getSelected();
socket.emit('command', {
roverId,
type: 'raw',
data: { raw: bytesToBase64(bytes) },
});
});
});
function bytesToBase64(bytes) {
return btoa(String.fromCharCode(...bytes));
}
});
-185
View File
@@ -1,185 +0,0 @@
registerModule('services/roverUI', (require, exports) => {
const { socket } = require('globals/socket');
const { formatHex } = require('helpers/formatters');
const videoPlayer = require('services/videoPlayer');
const state = require('services/state');
const statusEl = document.getElementById('status');
const roverSelect = document.getElementById('roverSelect');
const sensorOutput = document.getElementById('sensorOutput');
const lockBtn = document.getElementById('lockToggle');
const forceCheckbox = document.getElementById('forceControl');
const adminControls = document.getElementById('adminControls');
const modeControls = document.getElementById('modeControls');
const modeSelect = document.getElementById('modeSelect');
const activeDriverEl = document.getElementById('activeDriver');
const currentRoverEl = document.getElementById('currentRover');
const videoContainer = document.getElementById('videoContainer');
const driverVideo = document.getElementById('driverVideo');
const videoStatus = document.getElementById('videoStatus');
const videoReconnect = document.getElementById('videoReconnect');
let roster = [];
let lastSelection = null;
let currentVideoRover = null;
state.onRoleChange((role) => {
const isAdmin = role === 'admin' || role === 'lockdown';
if (lockBtn) lockBtn.disabled = !isAdmin;
if (roverSelect) roverSelect.disabled = !isAdmin;
if (adminControls) {
adminControls.style.display = isAdmin ? 'flex' : 'none';
}
if (modeControls) {
modeControls.style.display = isAdmin ? 'block' : 'none';
if (modeSelect) modeSelect.disabled = !isAdmin;
}
});
socket.on('rovers', (list) => {
roster = list;
roverSelect.innerHTML = '';
list.forEach((rover) => {
const option = document.createElement('option');
option.value = rover.id;
option.textContent = `${rover.name}${rover.locked ? ' (locked)' : ''}`;
roverSelect.appendChild(option);
});
const selected = state.getSelected();
if ((!selected || !list.find((r) => r.id === selected)) && list.length) {
state.setSelected(list[0].id);
} else if (!list.length) {
state.setSelected(null);
}
renderSelection(true);
});
roverSelect.addEventListener('change', () => {
const roverId = roverSelect.value;
const role = state.getRole();
state.setSelected(roverId);
renderSelection(true);
if (role === 'admin' || role === 'lockdown') {
socket.emit('requestControl', { roverId, force: !!forceCheckbox?.checked });
}
});
socket.on('controlGranted', ({ roverId }) => {
state.setSelected(roverId);
renderSelection(true);
statusEl.textContent = `Driving ${roverId}`;
maybeConnectVideo(true);
});
socket.on('sensorFrame', ({ roverId, frame, sensors }) => {
if (roverId !== state.getSelected()) return;
const lines = [];
if (sensors) {
Object.entries(sensors).forEach(([key, value]) => {
lines.push(`${key}: ${typeof value === 'object' ? JSON.stringify(value) : value}`);
});
}
lines.push('', 'raw:', formatHex(frame?.data));
sensorOutput.textContent = lines.join('\n');
});
socket.on('activeDriver', ({ roverId, socketId }) => {
if (roverId !== state.getSelected()) return;
if (activeDriverEl) {
activeDriverEl.textContent = `Active driver: ${socketId || 'none'}`;
}
});
lockBtn?.addEventListener('click', () => {
const roverId = state.getSelected();
const rover = roster.find((r) => r.id === roverId);
if (!rover) return;
socket.emit('lockRover', { roverId, locked: !rover.locked });
});
modeSelect?.addEventListener('change', () => {
socket.emit('setMode', { mode: modeSelect.value });
});
socket.on('mode', ({ mode }) => {
if (modeSelect) {
modeSelect.value = mode;
}
});
videoReconnect?.addEventListener('click', () => maybeConnectVideo(true));
socket.on('connect', () => {
statusEl.textContent = 'Connected';
});
socket.on('disconnect', () => {
statusEl.textContent = 'Disconnected';
});
function renderSelection(clearOutput) {
const roverId = state.getSelected();
if (roverSelect && roverId) {
roverSelect.value = roverId;
} else if (roverSelect && !roverId) {
roverSelect.value = '';
}
if (currentRoverEl) {
currentRoverEl.textContent = `Active rover: ${roverId || '--'}`;
}
if (sensorOutput && (clearOutput || roverId !== lastSelection)) {
sensorOutput.textContent = roverId ? 'Waiting for sensor data...' : '';
}
lastSelection = roverId;
maybeConnectVideo(clearOutput);
}
function maybeConnectVideo(force = false) {
const roverId = state.getSelected();
if (!roverId) {
currentVideoRover = null;
videoPlayer.stopStream('driver');
setVideoStatus('No rover selected');
return;
}
if (!force && roverId === currentVideoRover) {
return;
}
currentVideoRover = roverId;
setVideoStatus('Connecting video…');
socket.emit('video:request', { roverId }, (resp = {}) => {
if (resp.error) {
setVideoStatus(`Video error: ${resp.error}`);
videoPlayer.stopStream('driver');
return;
}
const { url, token } = resp;
videoPlayer
.startStream('driver', {
url,
token,
mount: videoContainer,
videoEl: driverVideo,
onStatus: (state, detail) => {
if (state === 'playing') {
setVideoStatus('Video live');
} else if (state === 'connecting') {
setVideoStatus('Connecting video…');
} else if (state === 'error') {
setVideoStatus(`Video error: ${detail}`);
} else if (state === 'stopped') {
setVideoStatus('Video stopped');
}
},
})
.catch((err) => {
setVideoStatus(`Video error: ${err.message}`);
});
});
}
function setVideoStatus(message) {
if (videoStatus) {
videoStatus.textContent = message;
}
}
});
-34
View File
@@ -1,34 +0,0 @@
registerModule('services/state', (require, exports) => {
let selectedRover = null;
let role = 'user';
const roleListeners = new Set();
function setSelected(roverId) {
selectedRover = roverId;
}
function getSelected() {
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;
});
-152
View File
@@ -1,152 +0,0 @@
registerModule('services/videoPlayer', (require, exports) => {
const players = new Map();
class Player {
constructor(id, options = {}) {
this.id = id;
this.options = options;
this.onStatus = options.onStatus || (() => {});
this.mount = options.mount || null;
this.externalVideo = options.videoEl || null;
this.video = this.externalVideo || this.createVideoElement();
this.video.muted = true;
this.status('idle');
}
createVideoElement() {
const el = document.createElement('video');
el.autoplay = true;
el.playsInline = true;
el.controls = false;
el.muted = true;
el.style.width = '100%';
el.style.maxHeight = '360px';
if (this.mount) {
this.mount.innerHTML = '';
this.mount.appendChild(el);
}
return el;
}
attachAutoPlayLoop() {
this.autoPlayTimer = window.setInterval(() => {
if (!this.video) return;
if (!this.video.paused && !this.video.muted) {
clearInterval(this.autoPlayTimer);
this.autoPlayTimer = null;
return;
}
const attempt = () => this.video.play().catch(() => {});
if (this.video.paused) {
attempt();
}
if (this.video.muted) {
this.video.muted = false;
attempt();
}
}, 2500);
}
status(state, detail) {
this.onStatus(state, detail);
}
async start(url, token) {
this.stopPeer();
this.status('connecting');
this.pc = new RTCPeerConnection();
this.stream = new MediaStream();
this.video.srcObject = this.stream;
this.pc.addTransceiver('video', { direction: 'recvonly' });
this.pc.addTransceiver('audio', { direction: 'recvonly' });
this.pc.ontrack = (event) => {
const [remoteStream] = event.streams;
if (remoteStream) {
this.video.srcObject = remoteStream;
} else {
this.stream.addTrack(event.track);
}
this.video.play().catch(() => {});
};
this.attachAutoPlayLoop();
if (!token) {
throw new Error('Missing video session token');
}
const offer = await this.pc.createOffer();
await this.pc.setLocalDescription(offer);
const response = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/sdp',
Authorization: `Basic ${btoa(`${token}:${token}`)}`,
},
body: offer.sdp,
});
if (!response.ok) {
throw new Error(`WHEP HTTP ${response.status}`);
}
const answer = await response.text();
await this.pc.setRemoteDescription({ type: 'answer', sdp: answer });
this.status('playing');
}
stopPeer() {
if (this.autoPlayTimer) {
clearInterval(this.autoPlayTimer);
this.autoPlayTimer = null;
}
if (this.pc) {
this.pc.getSenders().forEach((sender) => sender.track && sender.track.stop());
this.pc.getReceivers().forEach((receiver) => receiver.track && receiver.track.stop());
this.pc.close();
this.pc = null;
}
if (this.video) {
const tracks = this.video.srcObject?.getTracks() || [];
tracks.forEach((t) => t.stop());
this.video.pause();
this.video.srcObject = null;
}
}
stop() {
this.stopPeer();
if (!this.externalVideo && this.video && this.video.parentElement) {
this.video.parentElement.removeChild(this.video);
}
this.status('stopped');
}
}
async function startStream(id, { url, token, mount, videoEl, onStatus } = {}) {
stopStream(id);
const player = new Player(id, { mount, videoEl, onStatus });
players.set(id, player);
try {
await player.start(url, token);
return player.video;
} catch (err) {
player.status('error', err.message);
player.stop();
players.delete(id);
throw err;
}
}
function stopStream(id) {
const existing = players.get(id);
if (existing) {
existing.stop();
players.delete(id);
}
}
function stopAll() {
Array.from(players.keys()).forEach(stopStream);
}
exports.startStream = startStream;
exports.stopStream = stopStream;
exports.stopAll = stopAll;
});
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

+3
View File
@@ -8,6 +8,9 @@ const app = express();
app.use(morgan('dev'));
app.use(express.json());
app.use(express.static(config.staticDir));
app.get('/spectate', (req, res) => {
res.sendFile(path.join(config.staticDir, 'index.html'));
});
const httpServer = http.createServer(app);
+36
View File
@@ -1,8 +1,37 @@
const sinks = new Set();
function notifySinks(level, label, args) {
if (!sinks.size) return;
const entry = {
level,
label: label || null,
args,
message: args.map((value) => {
if (typeof value === 'string') return value;
try {
return JSON.stringify(value);
} catch (err) {
return String(value);
}
}).join(' '),
timestamp: new Date().toISOString(),
};
sinks.forEach((sink) => {
try {
sink(entry);
} catch (err) {
// avoid recursive logging
console.error(entry.timestamp, '[ERROR]', '[logger]', 'Log sink failed', err);
}
});
}
function stamp(level, label, args) {
const fields = [new Date().toISOString(), `[${level}]`];
if (label) {
fields.push(`[${label}]`);
}
notifySinks(level, label, args);
return [...fields, ...args];
}
@@ -14,7 +43,14 @@ function baseLogger(label) {
};
}
function registerSink(fn) {
if (typeof fn !== 'function') return () => {};
sinks.add(fn);
return () => sinks.delete(fn);
}
module.exports = {
...baseLogger(),
child: (label) => baseLogger(label),
registerSink,
};
+1
View File
@@ -15,6 +15,7 @@ function sendAlert({ color, title, message, ts = Date.now() }) {
ts,
};
io.emit('alert', payload);
io.emit('alert:new', { id: `${ts}-${Math.random().toString(36).slice(2)}`, ...payload });
}
module.exports = {
+29
View File
@@ -1,3 +1,4 @@
const EventEmitter = require('events');
const io = require('../globals/io');
const logger = require('../globals/logger').child('assignment');
const { MODES, getMode, modeEvents } = require('./modeManager');
@@ -7,6 +8,7 @@ const roverManager = require('./roverManager');
const socketRefs = new Map(); // socketId -> socket
const assignments = new Map(); // socketId -> roverId
const waiting = new Set(); // socketIds waiting for placement
const assignmentEvents = new EventEmitter();
io.on('connection', (socket) => {
socketRefs.set(socket.id, socket);
@@ -64,6 +66,7 @@ function assignSocket(socket) {
if (!target) {
waiting.add(socket.id);
logger.info('No rover available, user waiting', socket.id);
assignmentEvents.emit('update', socket.id);
return;
}
try {
@@ -71,9 +74,11 @@ function assignSocket(socket) {
assignments.set(socket.id, target.id);
waiting.delete(socket.id);
logger.info('Assigned user to rover', socket.id, target.id);
assignmentEvents.emit('update', socket.id);
} catch (err) {
logger.warn('Failed to assign user', err.message);
waiting.add(socket.id);
assignmentEvents.emit('update', socket.id);
}
}
@@ -84,6 +89,8 @@ function unassignSocket(socket) {
if (roverId) {
roverManager.releaseControl(roverId, socket);
assignments.delete(socket.id);
logger.info('Unassigned socket from rover', socket.id, roverId);
assignmentEvents.emit('update', socket.id);
}
}
@@ -98,6 +105,7 @@ function reassignFromRover(roverId) {
roverManager.releaseControl(rid, socket);
assignments.delete(socketId);
assignSocket(socket);
assignmentEvents.emit('update', socketId);
}
}
@@ -109,6 +117,7 @@ function reassignWaiting() {
} else {
waiting.delete(socketId);
}
assignmentEvents.emit('update', socketId);
}
}
@@ -116,6 +125,8 @@ function releaseAssignment(socket, roverId) {
roverManager.releaseControl(roverId, socket);
assignments.delete(socket.id);
waiting.add(socket.id);
logger.info('Released assignment back to queue', socket.id, roverId);
assignmentEvents.emit('update', socket.id);
}
function pickRover() {
@@ -133,3 +144,21 @@ function pickRover() {
candidates.sort((a, b) => a.drivers.size - b.drivers.size);
return candidates[0];
}
function describeAssignment(socketId) {
const assignedRoverId = assignments.get(socketId) || null;
const adminRoverId = assignedRoverId ? null : roverManager.getPrimaryRoverForSocket(socketId);
const waitingIndex = waiting.has(socketId) ? Array.from(waiting).indexOf(socketId) : -1;
const roverId = assignedRoverId || adminRoverId || null;
const waitingStatus = waiting.has(socketId);
return {
roverId,
status: assignedRoverId ? 'assigned' : adminRoverId ? 'admin' : waitingStatus ? 'waiting' : null,
queuePosition: waitingIndex >= 0 ? waitingIndex + 1 : null,
};
}
module.exports = {
assignmentEvents,
describeAssignment,
};
+12 -2
View File
@@ -36,6 +36,7 @@ io.on('connection', (socket) => {
const requestedRole = socket.handshake?.query?.role;
const initialRole = requestedRole === 'spectator' ? 'spectator' : 'user';
setRole(socket, initialRole);
logger.info('Socket connected with role', socket.id, initialRole);
socket.emit('auth:role', { role: initialRole });
socket.on('auth:login', async ({ username, password }, cb = () => {}) => {
try {
@@ -45,18 +46,27 @@ io.on('connection', (socket) => {
setRole(socket, role);
socket.emit('auth:role', { role });
clearLockdownTimer(socket);
logger.info('Login success', socket.id, role);
cb({ success: true, role: socket.data.role });
} catch (err) {
logger.warn('Login failed', socket.id, err.message);
cb({ success: false, error: err.message });
}
});
socket.on('role:set', ({ role }) => {
function handleRoleChange({ role } = {}, cb = () => {}) {
if (role === 'spectator' || role === 'user') {
setRole(socket, role);
socket.emit('auth:role', { role });
logger.info('Role changed via client request', socket.id, role);
cb({ success: true, role });
} else {
cb({ error: 'Invalid role' });
}
});
}
socket.on('role:set', handleRoleChange);
socket.on('session:setRole', handleRoleChange);
});
module.exports = {
+13 -4
View File
@@ -1,6 +1,7 @@
const { v4: uuidv4 } = require('uuid');
const io = require('../globals/io');
const roverManager = require('./roverManager');
const logger = require('../globals/logger').child('commandService');
const pendingCommands = new Map(); // id -> { roverId }
@@ -13,6 +14,7 @@ function issueCommand(roverId, payload) {
const message = { ...payload, id };
record.ws.send(JSON.stringify(message));
pendingCommands.set(id, { roverId, ts: Date.now(), type: payload.type });
logger.info('Issued command', roverId, payload.type, id);
return id;
}
@@ -20,6 +22,7 @@ function handleAck(msg) {
const pending = pendingCommands.get(msg.id);
if (!pending) return;
pendingCommands.delete(msg.id);
logger.info('Command acknowledged', pending.roverId, pending.type, msg.status);
io.emit('commandAck', {
roverId: pending.roverId,
id: msg.id,
@@ -34,7 +37,8 @@ module.exports = {
};
io.on('connection', (socket) => {
socket.on('command', ({ roverId, type, data } = {}, cb = () => {}) => {
function handleCommand({ roverId, type, data } = {}, cb) {
const reply = typeof cb === 'function' ? cb : () => {};
try {
if (!roverId) {
throw new Error('roverId required');
@@ -44,9 +48,14 @@ io.on('connection', (socket) => {
}
const payload = data ? { ...data } : {};
const id = issueCommand(roverId, { type, ...payload });
cb({ id });
logger.info('Queued command', socket.id, roverId, type);
reply({ id });
} catch (err) {
cb({ error: err.message });
logger.warn('Command rejected', socket.id, err.message);
reply({ error: err.message });
}
});
}
socket.on('command', handleCommand);
socket.on('command:issue', handleCommand);
});
+40
View File
@@ -0,0 +1,40 @@
const { v4: uuidv4 } = require('uuid');
const io = require('../globals/io');
const loggerRoot = require('../globals/logger');
const logger = loggerRoot.child('logStream');
const MAX_HISTORY = 200;
const history = [];
function pushEntry(entry) {
history.push(entry);
if (history.length > MAX_HISTORY) {
history.shift();
}
}
function broadcast(entry) {
io.emit('log:entry', entry);
}
function hydrateSocket(socket) {
if (!socket) return;
socket.emit('log:init', history);
}
loggerRoot.registerSink(({ level, label, message, timestamp }) => {
const entry = {
id: uuidv4(),
level,
label,
message,
timestamp,
};
pushEntry(entry);
broadcast(entry);
});
io.on('connection', (socket) => {
logger.info('Hydrating log history for', socket.id);
hydrateSocket(socket);
});
+1
View File
@@ -0,0 +1 @@
const io = require('../globals/io');
+71 -19
View File
@@ -115,6 +115,7 @@ function removeSocket(socket) {
record.drivers.delete(socket.id);
}
turnService.driverRemoved(roverId, socket.id);
managerEvents.emit('driver', { socketId: socket.id, roverId, action: 'remove' });
}
socketToRovers.delete(socket.id);
disableSpectator(socket);
@@ -147,6 +148,7 @@ function requestControl(roverId, socket, options = {}) {
socket.join(record.room);
turnService.driverAdded(roverId, socket.id, force && isAdmin(socket));
socket.emit('controlGranted', { roverId });
managerEvents.emit('driver', { socketId: socket.id, roverId, action: 'add' });
sendAlert({
color: COLORS.success,
title: 'Control Granted',
@@ -168,6 +170,7 @@ function releaseControl(roverId, socket) {
}
socket.leave(record.room);
turnService.driverRemoved(roverId, socket.id);
managerEvents.emit('driver', { socketId: socket.id, roverId, action: 'remove' });
}
function isDriver(roverId, socket) {
@@ -180,6 +183,24 @@ function canDrive(roverId, socket) {
return turnService.canDrive(roverId, socket) || isAdmin(socket);
}
function getRoversForSocket(socketId) {
const joined = socketToRovers.get(socketId);
if (!joined || joined.size === 0) {
return [];
}
return Array.from(joined);
}
function getPrimaryRoverForSocket(socketId) {
const joined = socketToRovers.get(socketId);
if (!joined || joined.size === 0) {
return null;
}
const iterator = joined.values();
const first = iterator.next();
return first.done ? null : first.value;
}
module.exports = {
upsertRover,
removeRover,
@@ -196,6 +217,8 @@ module.exports = {
disableSpectator,
rovers,
managerEvents,
getRoversForSocket,
getPrimaryRoverForSocket,
};
roleEvents.on('change', ({ socket, role }) => {
@@ -212,43 +235,72 @@ io.on('connection', (socket) => {
enableSpectator(socket);
}
socket.on('requestControl', ({ roverId, force } = {}) => {
function handleRequestControl({ roverId, force } = {}, cb = () => {}) {
try {
const targetId = roverId || Array.from(rovers.keys())[0];
if (!targetId) {
throw new Error('No rovers available');
}
logger.info('Request control', socket.id, targetId, { force });
requestControl(targetId, socket, { force: Boolean(force) });
socket.emit('controlGranted', { roverId: targetId });
cb({ success: true, roverId: targetId });
} catch (err) {
logger.warn('Request control failed', socket.id, err.message);
sendAlert({ color: COLORS.warn, title: 'Control denied', message: err.message });
cb({ error: err.message });
}
});
}
socket.on('releaseControl', ({ roverId }) => {
if (!roverId) return;
releaseControl(roverId, socket);
});
socket.on('lockRover', ({ roverId, locked }) => {
if (!isAdmin(socket)) return;
try {
lockRover(roverId, locked);
} catch (err) {
sendAlert({ color: COLORS.error, title: 'Lock failed', message: err.message });
}
});
socket.on('subscribeAll', () => {
if (socket.data?.role !== 'spectator') {
function handleReleaseControl({ roverId } = {}, cb = () => {}) {
if (!roverId) {
cb({ error: 'roverId required' });
return;
}
logger.info('Release control', socket.id, roverId);
releaseControl(roverId, socket);
cb({ success: true, roverId });
}
function handleLockToggle({ roverId, locked } = {}, cb = () => {}) {
if (!isAdmin(socket)) {
cb({ error: 'Not authorized' });
return;
}
try {
lockRover(roverId, locked);
logger.info('Lock state changed', roverId, locked);
cb({ success: true });
} catch (err) {
logger.warn('Lock change failed', roverId, err.message);
sendAlert({ color: COLORS.error, title: 'Lock failed', message: err.message });
cb({ error: err.message });
}
}
function handleSubscribeAll(_, cb = () => {}) {
if (socket.data?.role !== 'spectator') {
cb({ error: 'Spectator role required' });
return;
}
logger.info('Spectator subscribing to all rovers', socket.id);
for (const record of rovers.values()) {
socket.join(record.room);
}
});
cb({ success: true });
}
socket.on('requestControl', handleRequestControl);
socket.on('session:requestControl', handleRequestControl);
socket.on('releaseControl', handleReleaseControl);
socket.on('session:releaseControl', handleReleaseControl);
socket.on('lockRover', handleLockToggle);
socket.on('session:lockRover', handleLockToggle);
socket.on('subscribeAll', handleSubscribeAll);
socket.on('session:subscribeAll', handleSubscribeAll);
socket.on('disconnect', () => {
logger.info('Socket disconnected', socket.id);
removeSocket(socket);
});
});
+85
View File
@@ -0,0 +1,85 @@
const io = require('../globals/io');
const logger = require('../globals/logger').child('sessionService');
const { getRole, roleEvents } = require('./roleService');
const { getMode, modeEvents } = require('./modeManager');
const roverManager = require('./roverManager');
const { managerEvents } = roverManager;
const assignmentService = require('./assignmentService');
const { getActiveDrivers, turnEvents } = require('./turnService');
function buildSession(socket) {
return {
socketId: socket?.id || null,
role: getRole(socket),
mode: getMode(),
roster: roverManager.getRoster(),
assignment: assignmentService.describeAssignment(socket?.id || ''),
activeDrivers: getActiveDrivers(),
};
}
function syncSocket(socket) {
if (!socket) return;
const payload = buildSession(socket);
logger.info('Syncing session', socket.id, payload.role, payload.assignment);
socket.emit('session:sync', payload);
}
function syncAll() {
logger.info('Broadcasting session sync to all sockets');
io.sockets.sockets.forEach((socket) => syncSocket(socket));
}
io.on('connection', (socket) => {
logger.info('New socket connected', socket.id);
syncSocket(socket);
});
roleEvents.on('change', ({ socket }) => {
if (!socket) return;
logger.info('Role changed; syncing session', socket.id);
syncSocket(socket);
});
assignmentService.assignmentEvents.on('update', (socketId) => {
const socket = io.sockets.sockets.get(socketId);
if (socket) {
logger.info('Assignment update; syncing session', socketId);
syncSocket(socket);
}
});
modeEvents.on('change', () => {
logger.info('Mode change detected; syncing all clients');
syncAll();
});
managerEvents.on('rover', () => {
logger.info('Rover roster change; syncing all clients');
syncAll();
});
managerEvents.on('lock', ({ roverId, locked }) => {
logger.info('Rover lock change', roverId, locked);
syncAll();
});
managerEvents.on('driver', ({ socketId }) => {
if (!socketId) return;
const socket = io.sockets.sockets.get(socketId);
if (socket) {
logger.info('Driver assignment change; syncing session', socketId);
syncSocket(socket);
}
});
turnEvents.on('activeDriver', () => {
logger.info('Active driver change; syncing all clients');
syncAll();
});
module.exports = {
buildSession,
syncSocket,
syncAll,
};
+13 -1
View File
@@ -1,3 +1,4 @@
const EventEmitter = require('events');
const io = require('../globals/io');
const { sendAlert, COLORS } = require('./alertService');
const { MODES, getMode, modeEvents } = require('./modeManager');
@@ -5,6 +6,7 @@ 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;
const turnEvents = new EventEmitter();
function driverAdded(roverId, socketId, force) {
const queue = ensureQueue(roverId);
@@ -104,7 +106,15 @@ function setActiveDriver(roverId, socketId) {
} else {
activeDrivers.set(roverId, socketId);
}
io.emit('activeDriver', { roverId, socketId });
turnEvents.emit('activeDriver', { roverId, socketId });
}
function getActiveDrivers() {
const map = {};
activeDrivers.forEach((socketId, roverId) => {
map[roverId] = socketId;
});
return map;
}
function stopRover(roverId) {
@@ -126,4 +136,6 @@ module.exports = {
driverRemoved,
cleanupRover,
canDrive,
getActiveDrivers,
turnEvents,
};
+67
View File
@@ -0,0 +1,67 @@
# general idea:
- very compact UI
- utilitarian
- function before pretty
- tiny text, too.
- no large margins or padding. very little wasted space
- up to 1 tailwind unit unless otherwise needed
- black background, gray cards, white or gray text
- tailwind
- no title or bar at the top, vertical screen space is precious on all layouts
- alerts should be moved to a tiny toast popup that will show at the top center of the screen
# three different layouts for the driver page
- desktop
- rover video central, no title or anything above it
- overlayed on rover video (HUD like in a video game)
- build the HUD into the video tile
- because everywhere the video is, the HUD should be too
- shows a dot that blinks on each sensor frame
- shows wheel drops and bumpers
- shows a big "loud" warning overlay during an overcurrent
- simple, easy to understand battery bar UNDERNEATH the video:
- takes in battery full, warn, and urgent values
- treat full as 100% and warn as 0%
- make the bar flash red when its 0% or lower
- on left side of rover video:
- sensor data, visualized.
- battery charge out of capacity (1763/2068)
- charging status
- OI mode
- battery voltage (in volts not mv)
- battery current
- a teeny show of the raw data (because we get it on the client and it looks cool)
- on right side of rover video
- a simple, easy control panel with the following actions:
- a button that runs the start OI, dock, then full commands (in that order)
- also contains a little status of whether or not the rover is in "driving mode" (OI in full mode)
- a button that tells the rover to seek dock
- explains that you should be straight in front of, and about a foot from the dock for a successful attempt
- contains two status indicators:
- docked / not docked (homebase true)
- charging / not charging (when chargingstate isn't "not charging" the rover is charging)
- below the upper control and video row
- center:
- a place for the room camera (not implemented yet)
- left:
- admin login and controls
- right:
- logs
- mobile in portrait mode
- rover video at the top, with HUD
- just below:
- joystick with buttons for aux. motor controls
- have a button to run all aux motors forward at max speed
- also have buttons to run motors forward / backward individually
- probably use some premade react joystick
- scroll down more to see:
- the same simple mode control buttons from the desktop layout
- also needs to have the admin login and controls
- needs to have all the functionality as the desktop page
- mobile in landscape mode
- made for videogame-like control of the rover
- control buttons and aux motors on the left
- rover video in the middle
- joystick on the right
- again, scroll down to see more
- also has to do everything
+24
View File
@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
+16
View File
@@ -0,0 +1,16 @@
# React + Vite
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) (or [oxc](https://oxc.rs) when used in [rolldown-vite](https://vite.dev/guide/rolldown)) for Fast Refresh
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
## React Compiler
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
## Expanding the ESLint configuration
If you are developing a production application, we recommend using TypeScript with type-aware lint rules enabled. Check out the [TS template](https://github.com/vitejs/vite/tree/main/packages/create-vite/template-react-ts) for information on how to integrate TypeScript and [`typescript-eslint`](https://typescript-eslint.io) in your project.
+29
View File
@@ -0,0 +1,29 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import { defineConfig, globalIgnores } from 'eslint/config'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{js,jsx}'],
extends: [
js.configs.recommended,
reactHooks.configs.flat.recommended,
reactRefresh.configs.vite,
],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
parserOptions: {
ecmaVersion: 'latest',
ecmaFeatures: { jsx: true },
sourceType: 'module',
},
},
rules: {
'no-unused-vars': ['error', { varsIgnorePattern: '^[A-Z_]' }],
},
},
])
+13
View File
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>webui</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>
+4242
View File
File diff suppressed because it is too large Load Diff
+32
View File
@@ -0,0 +1,32 @@
{
"name": "webui",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"react": "^19.2.0",
"react-dom": "^19.2.0",
"react-joystick-component": "^6.2.1",
"react-router-dom": "^7.9.6"
},
"devDependencies": {
"@eslint/js": "^9.39.1",
"@types/react": "^19.2.2",
"@types/react-dom": "^19.2.2",
"@vitejs/plugin-react": "^5.1.0",
"autoprefixer": "^10.4.22",
"eslint": "^9.39.1",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.4.24",
"globals": "^16.5.0",
"postcss": "^8.5.6",
"tailwindcss": "^3.4.14",
"vite": "^7.2.2"
}
}
+6
View File
@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

+42
View File
@@ -0,0 +1,42 @@
#root {
max-width: 1280px;
margin: 0 auto;
padding: 2rem;
text-align: center;
}
.logo {
height: 6em;
padding: 1.5em;
will-change: filter;
transition: filter 300ms;
}
.logo:hover {
filter: drop-shadow(0 0 2em #646cffaa);
}
.logo.react:hover {
filter: drop-shadow(0 0 2em #61dafbaa);
}
@keyframes logo-spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
@media (prefers-reduced-motion: no-preference) {
a:nth-of-type(2) .logo {
animation: logo-spin infinite 20s linear;
}
}
.card {
padding: 2em;
}
.read-the-docs {
color: #888;
}
+125
View File
@@ -0,0 +1,125 @@
import { useEffect, useState } from 'react';
import TelemetryPanel from './components/TelemetryPanel.jsx';
import DrivePanel from './components/DrivePanel.jsx';
import AlertFeed from './components/AlertFeed.jsx';
import AdminPanel from './components/AdminPanel.jsx';
import MobileControls, { MobileJoystick, AuxMotorControls } from './components/MobileControls.jsx';
import { DriveControlProvider } from './context/DriveControlContext.jsx';
import RoomCameraPanel from './components/RoomCameraPanel.jsx';
import LogPanel from './components/LogPanel.jsx';
import AuthPanel from './components/AuthPanel.jsx';
import DriverVideoPanel from './components/DriverVideoPanel.jsx';
import RightPaneTabs from './components/RightPaneTabs.jsx';
import ModeGateOverlay from './components/ModeGateOverlay.jsx';
import SessionSnapshot from './components/SessionSnapshot.jsx';
function useLayoutMode() {
const [mode, setMode] = useState(() => {
if (typeof window === 'undefined') return 'desktop';
return window.innerWidth >= 1024
? 'desktop'
: window.innerWidth > window.innerHeight
? 'mobile-landscape'
: 'mobile-portrait';
});
useEffect(() => {
function updateMode() {
if (typeof window === 'undefined') return;
const { innerWidth, innerHeight } = window;
if (innerWidth >= 1024) {
setMode('desktop');
} else if (innerWidth > innerHeight) {
setMode('mobile-landscape');
} else {
setMode('mobile-portrait');
}
}
updateMode();
window.addEventListener('resize', updateMode);
return () => window.removeEventListener('resize', updateMode);
}, []);
return mode;
}
function DesktopLayout({ layout }) {
return (
<div className="flex flex-col gap-1">
<section className="grid grid-cols-[minmax(0,1.8fr)_minmax(0,1fr)] gap-1">
<DriverVideoPanel />
<RightPaneTabs layout={layout} />
</section>
<section className="grid grid-cols-[minmax(0,1fr)_minmax(0,1fr)] gap-1">
{/* <AdminPanel /> */}
<LogPanel />
<SessionSnapshot />
</section>
</div>
);
}
function MobilePortraitLayout() {
return (
<div className="flex flex-col gap-1">
<DriverVideoPanel />
<MobileControls />
<DrivePanel />
<TelemetryPanel />
<AuthPanel />
<AdminPanel />
<RoomCameraPanel />
<LogPanel />
</div>
);
}
function MobileLandscapeLayout() {
return (
<div className="flex flex-col gap-1">
<section className="grid grid-cols-[minmax(0,0.7fr)_minmax(0,2.1fr)_minmax(0,0.7fr)] gap-1">
<div className="flex flex-col gap-1">
<AuxMotorControls />
</div>
<DriverVideoPanel />
<div className="flex flex-col gap-1">
<MobileJoystick />
</div>
</section>
<RoomCameraPanel />
<DrivePanel />
<section className="grid grid-cols-[minmax(0,1fr)_minmax(0,1fr)] gap-1">
<div className="flex flex-col gap-1">
<AuthPanel />
<AdminPanel />
</div>
<div className="flex flex-col gap-1">
<LogPanel />
<TelemetryPanel />
</div>
</section>
</div>
);
}
function App() {
const layout = useLayoutMode();
const renderedLayout =
layout === 'desktop'
? <DesktopLayout layout={layout} />
: layout === 'mobile-landscape'
? <MobileLandscapeLayout />
: <MobilePortraitLayout />;
return (
<div className="min-h-screen bg-black text-slate-50">
<DriveControlProvider>
<main className="flex w-full flex-col gap-1 px-1 py-1 text-base">{renderedLayout}</main>
<AlertFeed />
<ModeGateOverlay />
</DriveControlProvider>
</div>
);
}
export default App;
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

After

Width:  |  Height:  |  Size: 4.0 KiB

+104
View File
@@ -0,0 +1,104 @@
import { useMemo, useState } from 'react';
import { useSession } from '../context/SessionContext.jsx';
const MODES = [
{ key: 'open', label: 'Open' },
{ key: 'turns', label: 'Turns' },
{ key: 'admin', label: 'Admin' },
{ key: 'lockdown', label: 'Lockdown' },
];
export default function AdminPanel() {
const { session, lockRover, setMode, requestControl } = useSession();
const roster = useMemo(() => session?.roster ?? [], [session?.roster]);
const [lockStates, setLockStates] = useState({});
const isAdmin =
session?.role === 'admin' ||
session?.role === 'lockdown' ||
session?.role === 'lockdown-admin';
const currentMode = session?.mode ?? 'open';
const handleLockToggle = async (roverId, locked) => {
try {
await lockRover(roverId, locked);
setLockStates((prev) => ({ ...prev, [roverId]: locked }));
} catch (err) {
alert(err.message);
}
};
const handleModeChange = async (event) => {
const mode = event.target.value;
try {
await setMode(mode);
} catch (err) {
alert(err.message);
}
};
const handleForceControl = async (roverId) => {
try {
await requestControl(roverId, { force: true });
} catch (err) {
alert(err.message);
}
};
const lockMap = useMemo(() => {
const map = {};
roster.forEach((rover) => {
map[rover.id] = lockStates[rover.id] ?? rover.locked;
});
return map;
}, [roster, lockStates]);
if (!isAdmin) return null;
return (
<section className="rounded-sm bg-[#242a32] p-1 text-base text-slate-100">
<div className="flex items-center justify-between gap-1 text-sm">
<span>Admin controls</span>
<select
value={currentMode}
onChange={handleModeChange}
className="rounded-sm bg-black/40 px-1 py-1 text-sm text-slate-100"
>
{MODES.map((mode) => (
<option key={mode.key} value={mode.key}>
{mode.label}
</option>
))}
</select>
</div>
<div className="mt-1 space-y-1 text-sm">
{roster.map((rover) => (
<div key={rover.id} className="flex flex-wrap items-center justify-between gap-1 rounded-sm bg-black/30 px-1 py-1">
<div>
<p className="text-slate-100">{rover.name}</p>
<p className="text-xs text-slate-400">{lockMap[rover.id] ? 'locked' : 'unlocked'}</p>
</div>
<div className="flex flex-wrap gap-1 text-xs">
<button
type="button"
onClick={() => handleLockToggle(rover.id, !lockMap[rover.id])}
className="rounded-sm bg-black/50 px-1 py-1 text-slate-100"
>
{lockMap[rover.id] ? 'Unlock' : 'Lock'}
</button>
<button
type="button"
onClick={() => handleForceControl(rover.id)}
className="rounded-sm bg-black/50 px-1 py-1 text-slate-100"
>
Force
</button>
</div>
</div>
))}
</div>
</section>
);
}
+76
View File
@@ -0,0 +1,76 @@
import { useMemo } from 'react';
import { useDriveControl } from '../context/DriveControlContext.jsx';
import { useSocket } from '../context/SocketContext.jsx';
import AuthPanel from './AuthPanel.jsx';
import AdminPanel from './AdminPanel.jsx';
const manualTabs = [
{ key: 'start', label: 'Start OI' },
{ key: 'safe', label: 'Safe' },
{ key: 'full', label: 'Full' },
{ key: 'passive', label: 'Passive' },
{ key: 'dock', label: 'Dock' },
];
export default function AdvancedSettings() {
const { roverId, sendOiCommand } = useDriveControl();
const socket = useSocket();
const canControl = Boolean(roverId);
const sensorButtons = useMemo(
() => [
{ key: 'start', label: 'Enable stream', enable: true },
{ key: 'stop', label: 'Disable stream', enable: false },
],
[],
);
const handleSensorToggle = (enable) => {
if (!roverId) return;
socket.emit('command', {
roverId,
type: 'sensorStream',
data: { sensorStream: { enable } },
});
};
return (
<div className="space-y-1">
<section className="rounded-sm bg-[#242a32] p-1 text-sm text-slate-100">
<p className="text-xs text-slate-400">Manual OI commands</p>
<div className="mt-1 flex flex-wrap gap-1">
{manualTabs.map((tab) => (
<button
key={tab.key}
type="button"
onClick={() => sendOiCommand(tab.key)}
disabled={!canControl}
className="rounded-sm bg-black/40 px-1 py-0.5 text-xs text-slate-200 disabled:opacity-30"
>
{tab.label}
</button>
))}
</div>
</section>
<section className="rounded-sm bg-[#242a32] p-1 text-sm text-slate-100">
<p className="text-xs text-slate-400">Sensor stream</p>
<div className="mt-1 flex gap-1">
{sensorButtons.map((btn) => (
<button
key={btn.key}
type="button"
onClick={() => handleSensorToggle(btn.enable)}
disabled={!canControl}
className="flex-1 rounded-sm bg-black/40 px-1 py-1 text-xs text-slate-200 disabled:opacity-30"
>
{btn.label}
</button>
))}
</div>
{!canControl && <p className="mt-1 text-xs text-slate-500">Assign a rover to toggle streams.</p>}
</section>
<AuthPanel />
<AdminPanel />
</div>
);
}
+54
View File
@@ -0,0 +1,54 @@
import { useEffect, useMemo, useState } from 'react';
import { useSession } from '../context/SessionContext.jsx';
const LIFETIME_MS = 5000;
function buildKey(alert) {
if (alert.id) return alert.id;
if (alert.timestamp) return `${alert.timestamp}-${alert.message}`;
return `${alert.title || 'alert'}-${alert.message}`;
}
export default function AlertFeed() {
const { alerts } = useSession();
const [now, setNow] = useState(() => Date.now());
const latest = useMemo(() => alerts.slice(-5).map((alert) => ({ alert, key: buildKey(alert) })), [alerts]);
useEffect(() => {
if (!latest.length) return undefined;
const interval = setInterval(() => setNow(Date.now()), 200);
return () => clearInterval(interval);
}, [latest.length]);
const visible = latest
.map((item) => ({
...item,
age: now - (item.alert.receivedAt ?? item.alert.timestamp ?? 0),
}))
.filter((item) => item.age <= LIFETIME_MS);
if (!visible.length) return null;
return (
<div className="pointer-events-none fixed top-1 left-1/2 z-50 flex -translate-x-1/2 flex-col gap-1">
{visible.map((toast) => (
<AlertToast key={toast.key} alert={toast.alert} />
))}
</div>
);
}
function AlertToast({ alert }) {
const colorClasses =
alert.color === 'error'
? 'bg-red-500/30 text-red-100'
: alert.color === 'warn'
? 'bg-amber-500/30 text-amber-100'
: 'bg-emerald-500/30 text-emerald-100';
return (
<div className={`pointer-events-auto rounded-sm px-1 py-1 text-[0.75rem] shadow-lg shadow-black/60 ${colorClasses}`}>
<p className="text-[0.65rem] text-slate-300">{alert.title || 'Alert'}</p>
<p className="text-sm text-white">{alert.message}</p>
</div>
);
}
+67
View File
@@ -0,0 +1,67 @@
import { useState } from 'react';
import { useSession } from '../context/SessionContext.jsx';
export default function AuthPanel() {
const { login, setRole } = useSession();
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [loading, setLoading] = useState(false);
async function handleLogin(event) {
event.preventDefault();
setLoading(true);
try {
await login(username, password);
setUsername('');
setPassword('');
} catch (err) {
alert(err.message);
} finally {
setLoading(false);
}
}
return (
<div className="rounded-sm bg-[#242a32] p-1 text-base text-slate-100">
<p className="text-sm text-slate-400">Admin login</p>
<form className="mt-1 flex flex-col gap-1" onSubmit={handleLogin}>
<input
className="rounded-sm bg-black/50 px-1 py-1 text-slate-100"
placeholder="Username"
value={username}
onChange={(e) => setUsername(e.target.value)}
/>
<input
className="rounded-sm bg-black/50 px-1 py-1 text-slate-100"
placeholder="Password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
<button
type="submit"
disabled={loading}
className="rounded-sm bg-slate-200 px-1 py-1 text-sm font-semibold text-black disabled:opacity-50"
>
{loading ? 'Logging in…' : 'Login'}
</button>
</form>
<div className="mt-1 flex gap-1 text-sm">
<button
type="button"
onClick={() => setRole('user')}
className="flex-1 rounded-sm bg-black/40 px-1 py-1 text-slate-200"
>
Driver
</button>
<button
type="button"
onClick={() => setRole('spectator')}
className="flex-1 rounded-sm bg-black/40 px-1 py-1 text-slate-200"
>
Spectator
</button>
</div>
</div>
);
}
+71
View File
@@ -0,0 +1,71 @@
import { useDriveControl } from '../context/DriveControlContext.jsx';
import { useTelemetryFrame } from '../context/TelemetryContext.jsx';
export default function DrivePanel() {
const { roverId, runStartDockFull, seekDock } = useDriveControl();
const frame = useTelemetryFrame(roverId);
const sensors = frame?.sensors || {};
const drivingMode = (sensors.oiMode?.label || '').toLowerCase() === 'full';
const docked = Boolean(sensors.chargingSources?.homeBase);
const charging = Boolean(
sensors.chargingState?.label && sensors.chargingState.label.toLowerCase() !== 'not charging',
);
return (
<section className="rounded-sm bg-[#242a32] p-1 text-base text-slate-100">
<div className="flex items-center justify-between text-sm text-slate-300">
<span>Drive control</span>
<span>{roverId ? `Rover ${roverId}` : 'unassigned'}</span>
</div>
<div className="mt-1 space-y-1">
<ActionCard
title="Start Driving"
description="Press to enable driving mode, then start moving. The headlamps should illuminate."
statuses={[{ label: drivingMode ? 'Ready!' : 'Not Ready!', active: drivingMode }]}
tone="emerald"
onClick={runStartDockFull}
disabled={!roverId}
/>
<ActionCard
title="Dock and Charge"
description="Line the rover up about a foot from the dock, then trigger an automatic approach."
statuses={[
{ label: docked ? 'Docked!' : 'Not Docked!', active: docked },
{ label: charging ? 'Charging!' : 'Not Charging!', active: charging },
]}
tone="indigo"
onClick={seekDock}
disabled={!roverId}
/>
</div>
</section>
);
}
function ActionCard({ title, description, statuses, tone, onClick, disabled, footnote }) {
const baseColor = tone === 'indigo' ? 'bg-indigo-600' : 'bg-emerald-600';
return (
<button
type="button"
onClick={onClick}
disabled={disabled}
className={`w-full rounded-lg ${baseColor} px-1 py-1 text-left text-white disabled:opacity-40`}
>
<p className="text-base font-semibold">{title}</p>
<p className="text-sm text-white/90">{description}</p>
<div className="mt-1 flex flex-wrap gap-1">
{statuses.map((status) => (
<span
key={status.label}
className={`rounded-full px-1 py-0.5 text-xs font-semibold ${
status.active ? 'bg-lime-300 text-emerald-900' : 'bg-emerald-900 text-emerald-100'
}`}
>
{status.label}
</span>
))}
</div>
{footnote && <p className="mt-1 text-xs text-emerald-50/80">{footnote}</p>}
</button>
);
}
+35
View File
@@ -0,0 +1,35 @@
import { useSession } from '../context/SessionContext.jsx';
import { useTelemetryFrame } from '../context/TelemetryContext.jsx';
import { useVideoRequests } from '../hooks/useVideoRequests.js';
import VideoTile from './VideoTile.jsx';
export default function DriverVideoPanel() {
const { session } = useSession();
const roverId = session?.assignment?.roverId;
const sources = useVideoRequests(roverId ? [roverId] : []);
const info = roverId ? sources[roverId] : null;
const frame = useTelemetryFrame(roverId);
const batteryRecord =
roverId && session?.roster
? session.roster.find((item) => String(item.id) === String(roverId))
: null;
const batteryConfig = batteryRecord?.battery ?? null;
return (
<section className="rounded-sm bg-[#242a32] p-1">
{roverId ? (
<div className="w-full">
<VideoTile
sessionInfo={info}
label={roverId}
muted={false}
telemetryFrame={frame}
batteryConfig={batteryConfig}
/>
</div>
) : (
<p className="text-sm text-slate-400">Assign a rover to view the video feed.</p>
)}
</section>
);
}
+32
View File
@@ -0,0 +1,32 @@
export default function HelpPanel({ layout }) {
const presets = {
desktop: [
'Use WASD + Shift to drive. Video must stay focused.',
'Macros on the left prep the rover before moving.',
'Switch tabs on the right for telemetry, room, and advanced tools.',
],
'mobile-portrait': [
'Keep joystick centered when not driving.',
'Auxiliary buttons sit beside the joystick use them carefully.',
'Scroll to access drive/telemetry if screen space is limited.',
],
'mobile-landscape': [
'Joystick and video are side by side for game-style control.',
'Rotate back to portrait if you need admin controls quickly.',
'Tabs are desktop-only; mobile shows sections stacked.',
],
};
const tips = presets[layout] || presets.desktop;
return (
<section className="rounded-sm bg-[#242a32] p-1 text-base text-slate-100">
<p className="text-sm text-slate-400">Help</p>
<ul className="mt-1 space-y-1 text-sm">
{tips.map((tip) => (
<li key={tip} className="rounded-sm bg-black/30 px-1 py-1 text-slate-200">
{tip}
</li>
))}
</ul>
</section>
);
}
+30
View File
@@ -0,0 +1,30 @@
import { useSession } from '../context/SessionContext.jsx';
export default function LogPanel() {
const { logs } = useSession();
return (
<div className="rounded-sm bg-[#242a32] p-1 text-base text-slate-100">
<div className="flex items-center justify-between text-sm text-slate-400">
<span>Server logs</span>
<span>{logs.length}</span>
</div>
<div className="mt-1 h-64 overflow-y-auto rounded-sm bg-black/40 p-1 font-mono text-xs">
{logs.length === 0 ? (
<p>No logs yet.</p>
) : (
logs
.slice()
.reverse()
.map((entry) => (
<div key={entry.id} className="mb-1">
<span className="text-amber-400">{entry.timestamp}</span>{' '}
<span className="text-lime-400">[{entry.level}]</span>{' '}
{entry.label && <span className="text-teal-400">[{entry.label}]</span>}{' '}
<span>{entry.message}</span>
</div>
))
)}
</div>
</div>
);
}
+132
View File
@@ -0,0 +1,132 @@
import { useCallback } from 'react';
import { Joystick } from 'react-joystick-component';
import { useDriveControl } from '../context/DriveControlContext.jsx';
function clampUnit(value = 0) {
if (typeof value !== 'number' || Number.isNaN(value)) return 0;
return Math.max(-1, Math.min(1, value));
}
function MobileJoystick() {
const { roverId, driveWithVector, stopMotors } = useDriveControl();
const disabled = !roverId;
const handleMove = useCallback(
(event = {}) => {
if (disabled) return;
const x = clampUnit(event.x ?? 0);
const y = clampUnit(event.y ?? 0);
driveWithVector({ x, y });
},
[disabled, driveWithVector],
);
const handleStop = useCallback(() => {
if (disabled) return;
driveWithVector({ x: 0, y: 0 });
}, [disabled, driveWithVector]);
return (
<section className="rounded-sm bg-[#242a32] p-1 text-sm text-slate-100">
<div className="text-xs text-slate-400">Joystick</div>
<div className="mt-1 flex items-center justify-center">
<Joystick
size={120}
baseColor="#0f172a"
stickColor="#38bdf8"
throttle={75}
move={handleMove}
stop={handleStop}
disabled={disabled}
/>
</div>
<p className="mt-1 text-xs text-slate-400">
Drag to drive. Release to stop sending drive commands.
</p>
<button
type="button"
onClick={stopMotors}
disabled={disabled}
className="mt-1 w-full rounded-sm bg-black/40 px-1 py-1 text-xs text-slate-200 disabled:opacity-40"
>
Panic Stop
</button>
</section>
);
}
function AuxMotorControls() {
const { roverId, setAuxMotors } = useDriveControl();
const disabled = !roverId;
const runAllForward = () => {
if (disabled) return;
setAuxMotors({ main: 127, side: 127, vacuum: 127 });
};
const stopAll = () => {
if (disabled) return;
setAuxMotors({ main: 0, side: 0, vacuum: 0 });
};
const auxButtons = [
{ label: 'Main +', values: { main: 127 } },
{ label: 'Main -', values: { main: -127 } },
{ label: 'Side +', values: { side: 127 } },
{ label: 'Side -', values: { side: -127 } },
{ label: 'Vacuum Max', values: { vacuum: 127 } },
{ label: 'Vacuum Off', values: { vacuum: 0 } },
];
return (
<section className="rounded-sm bg-[#242a32] p-1 text-sm text-slate-100">
<div className="text-xs text-slate-400">Aux motors</div>
<div className="mt-1 flex flex-wrap gap-1">
<button
type="button"
onClick={runAllForward}
disabled={disabled}
className="flex-1 rounded-sm bg-black/40 px-1 py-1 text-xs text-slate-200 disabled:opacity-40"
>
All forward
</button>
<button
type="button"
onClick={stopAll}
disabled={disabled}
className="flex-1 rounded-sm bg-black/40 px-1 py-1 text-xs text-slate-200 disabled:opacity-40"
>
Stop all
</button>
</div>
<div className="mt-1 grid grid-cols-2 gap-1 text-[0.75rem]">
{auxButtons.map((btn) => (
<button
key={btn.label}
type="button"
onClick={() => !disabled && setAuxMotors(btn.values)}
disabled={disabled}
className="rounded-sm bg-black/30 px-1 py-1 text-slate-200 disabled:opacity-30"
>
{btn.label}
</button>
))}
</div>
</section>
);
}
export default function MobileControlsStack() {
return (
<div className="flex flex-col gap-1 sm:flex-row">
<div className="flex-1">
<MobileJoystick />
</div>
<div className="flex-1">
<AuxMotorControls />
</div>
</div>
);
}
export { MobileJoystick, AuxMotorControls };
+52
View File
@@ -0,0 +1,52 @@
import AuthPanel from './AuthPanel.jsx';
import { useSession } from '../context/SessionContext.jsx';
const PRIVILEGED_ROLES = new Set(['admin', 'lockdown', 'lockdown-admin']);
const RESTRICTED_MODES = new Set(['admin', 'lockdown']);
function getModeDetails(mode = 'admin') {
if (mode === 'lockdown') {
return {
title: 'Lockdown in effect',
description:
'Only the lockdown admin can remain connected right now. If you have credentials, log in below. Otherwise please stand by until the server re-opens.',
};
}
return {
title: 'Admin mode active',
description:
'The server is temporarily restricted to administrators. Log in below if you are authorized, or wait for admin mode to end.',
};
}
export default function ModeGateOverlay() {
const { session } = useSession();
const mode = session?.mode;
const role = session?.role;
const restricted = RESTRICTED_MODES.has(mode);
const privileged = PRIVILEGED_ROLES.has(role);
if (!restricted || privileged) {
return null;
}
const details = getModeDetails(mode);
return (
<div className="pointer-events-auto fixed inset-0 z-50 flex items-center justify-center bg-black/85 px-2 py-2">
<div className="w-full max-w-md space-y-2 rounded-lg bg-[#141821] p-2 text-slate-100 shadow-2xl">
<div className="space-y-1">
<p className="text-lg font-semibold">{details.title}</p>
<p className="text-sm text-slate-300">{details.description}</p>
</div>
<div className="rounded-md bg-black/40 p-1">
<AuthPanel />
</div>
<p className="text-xs text-slate-500">
Your controls are paused until access is granted. You will automatically regain the interface once the mode
changes or after a successful login.
</p>
</div>
</div>
);
}
+68
View File
@@ -0,0 +1,68 @@
import { useState } from 'react';
import TelemetryPanel from './TelemetryPanel.jsx';
import DrivePanel from './DrivePanel.jsx';
import RoomCameraPanel from './RoomCameraPanel.jsx';
import AdvancedSettings from './AdvancedSettings.jsx';
import HelpPanel from './HelpPanel.jsx';
function TabButton({ active, onClick, children }) {
return (
<button
type="button"
onClick={onClick}
className={`flex-1 rounded-sm px-1 py-1 text-xs font-semibold ${
active ? 'bg-slate-100 text-slate-900' : 'bg-black/30 text-slate-300'
}`}
>
{children}
</button>
);
}
function RoomControlsPlaceholder() {
return (
<div className="rounded-sm bg-[#242a32] p-1 text-sm text-slate-100">
<p className="text-xs text-slate-400">Room controls</p>
<p className="text-sm text-slate-200">Coming soon: lighting, docks, and environmental toggles.</p>
</div>
);
}
export default function RightPaneTabs({ layout }) {
const [active, setActive] = useState('telemetry');
return (
<section className="rounded-sm bg-[#1a1d23] p-1 text-base text-slate-100">
<div className="flex gap-1">
<TabButton active={active === 'telemetry'} onClick={() => setActive('telemetry')}>
Rover Controls
</TabButton>
<TabButton active={active === 'room'} onClick={() => setActive('room')}>
Room Controls
</TabButton>
<TabButton active={active === 'advanced'} onClick={() => setActive('advanced')}>
Advanced
</TabButton>
<TabButton active={active === 'help'} onClick={() => setActive('help')}>
Help
</TabButton>
</div>
<div className="mt-1 space-y-1">
{active === 'telemetry' && (
<div className="space-y-1">
<DrivePanel />
<TelemetryPanel />
</div>
)}
{active === 'room' && (
<div className="space-y-1">
<RoomCameraPanel />
<RoomControlsPlaceholder />
</div>
)}
{active === 'advanced' && <AdvancedSettings />}
{active === 'help' && <HelpPanel layout={layout} />}
</div>
</section>
);
}
+8
View File
@@ -0,0 +1,8 @@
export default function RoomCameraPanel() {
return (
<div className="min-h-[8rem] rounded-sm bg-[#242a32] p-1 text-base text-slate-100">
<p className="text-center text-sm text-slate-400">Room camera</p>
<p className="mt-1 text-center">Feed placeholder. Wire upcoming room cam here.</p>
</div>
);
}
+13
View File
@@ -0,0 +1,13 @@
import { useMemo } from 'react';
import { useSession } from '../context/SessionContext.jsx';
export default function SessionSnapshot() {
const { session } = useSession();
const payload = useMemo(() => JSON.stringify(session ?? {}, null, 2), [session]);
return (
<div className="rounded-sm bg-[#242a32] p-1 text-xs text-slate-100">
<p className="text-sm text-slate-400">Session snapshot</p>
<pre className="mt-1 h-64 overflow-y-auto bg-black/40 p-1 text-[0.7rem] text-lime-300">{payload}</pre>
</div>
);
}
+145
View File
@@ -0,0 +1,145 @@
import { useMemo, useState } from 'react';
import { useSession } from '../context/SessionContext.jsx';
import { useTelemetryFrame } from '../context/TelemetryContext.jsx';
function formatMetric(value, fallback = '--') {
if (value == null || value === '') return fallback;
return value;
}
export default function TelemetryPanel() {
const { connected, session, requestControl } = useSession();
const roverId = session?.assignment?.roverId;
const frame = useTelemetryFrame(roverId);
const sensors = frame?.sensors || {};
const voltage = sensors.voltageMv != null ? `${(sensors.voltageMv / 1000).toFixed(2)} V` : null;
const current = sensors.currentMa != null ? `${sensors.currentMa} mA` : null;
const charge = sensors.batteryChargeMah;
const capacity = sensors.batteryCapacityMah;
const updated = frame?.receivedAt ? new Date(frame.receivedAt).toLocaleTimeString() : null;
// const rawSnippet = frame?.raw ? (frame.raw.length > 80 ? `${frame.raw.slice(0, 80)}…` : frame.raw) : null;
const rawSnippet = frame?.raw ? frame.raw : null;
const roster = session?.roster ?? [];
const activeDriverId = roverId ? session?.activeDrivers?.[roverId] : null;
const driverLabel = !roverId
? 'n/a'
: activeDriverId
? activeDriverId === session?.socketId
? 'You'
: activeDriverId.slice(0, 6)
: 'Available';
const isAdmin = useMemo(
() => session?.role === 'admin' || session?.role === 'lockdown' || session?.role === 'lockdown-admin',
[session?.role],
);
const [pending, setPending] = useState({});
async function handleRequest(roverId) {
if (!roverId) return;
setPending((prev) => ({ ...prev, [roverId]: true }));
try {
await requestControl(roverId);
} catch (err) {
alert(err.message);
} finally {
setPending((prev) => ({ ...prev, [roverId]: false }));
}
}
return (
<section className="rounded-sm bg-[#242a32] p-1 text-base text-slate-100">
<div className="text-sm text-slate-400">
<span>{connected ? 'online' : 'offline'}</span>
<span> · role {session?.role || 'unknown'}</span>
<span> · mode {session?.mode || '--'}</span>
{updated && <span> · sensors {updated}</span>}
<span> · driver {driverLabel}</span>
</div>
{!roverId ? (
<p className="mt-1 text-[0.75rem] text-slate-400">Assign a rover to view sensors.</p>
) : !frame ? (
<p className="mt-1 text-[0.75rem] text-slate-400">Waiting for sensor frames</p>
) : (
<>
<div className="flex flex-wrap gap-0.5">
<Metric label="Charge" value={formatMetric(charge != null && capacity != null ? `${charge}/${capacity}` : null)} />
<Metric label="Charging" value={formatMetric(sensors.chargingState?.label)} />
<Metric label="OI mode" value={formatMetric(sensors.oiMode?.label)} />
<Metric label="Voltage" value={formatMetric(voltage)} />
<Metric label="Current" value={formatMetric(current)} />
<Metric label="Left Encoder" value={formatMetric(sensors.encoderCountsLeft)} />
<Metric label="Right Encoder" value={formatMetric(sensors.encoderCountsRight)} />
</div>
<div className="mt-1 flex gap-0.5">
<CliffBar value={sensors.cliffLeftSignal} />
<CliffBar value={sensors.cliffFrontLeftSignal} />
<CliffBar value={sensors.cliffFrontRightSignal} />
<CliffBar value={sensors.cliffRightSignal} />
</div>
</>
)}
{rawSnippet && (
// console.log('rawSnippet:', rawSnippet),
<pre className="mt-1 text-wrap break-words p-1 text-xs text-lime-300 bg-gray-700">
{rawSnippet}
</pre>
)}
<div className="mt-1">
<p className="text-sm text-slate-400">Rovers</p>
{roster.length === 0 ? (
// if there ARE NO rovers
<p className="text-sm text-slate-500">No roster data.</p>
) : (
// if there ARE rovers
<ul className="mt-1 space-y-1 text-sm">
{roster.map((rover) => (
<li key={rover.id} className="flex items-center justify-between gap-1">
<div>
<p className="text-slate-200">{rover.name}</p>
<p className="text-xs text-slate-500">
{rover.locked ? 'locked' : 'free'} · {rover.lastSeen ? 'seen' : 'unknown'}
</p>
</div>
{isAdmin && (
<button
type="button"
onClick={() => handleRequest(rover.id)}
disabled={pending[rover.id]}
className="rounded-sm bg-black/40 px-1 py-0.5 text-xs text-slate-200 disabled:opacity-40"
>
{pending[rover.id] ? '...' : 'request'}
</button>
)}
</li>
))}
</ul>
)}
</div>
</section>
);
}
function CliffBar({ value }) {
// Render a small vertical bar; value is clamped to 0-100 for display
// const pct = value == null ? 0 : Math.max(0, Math.min(100, value));
// goes from 0 to 4095. calculate percentage for height
const pct = value == null ? 0 : Math.max(0, Math.min(100, (value / 4095) * 100));
const height = `${pct}%`;
return (
<div className="w-1/4 h-6 bg-gray-700 rounded-sm overflow-hidden">
<div className="w-full bg-amber-400" style={{ height }} />
</div>
);
}
function Metric({ label, value }) {
return (
<div className="bg-gray-700 rounded-sm p-0.5 text-sm">
{label}: {value}
</div>
);
}
+251
View File
@@ -0,0 +1,251 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { WhepPlayer } from '../lib/whepPlayer.js';
const RESTART_DELAY_MS = 2000;
const UNMUTE_RETRY_MS = 3000;
export default function VideoTile({ sessionInfo, label, forceMute = false, telemetryFrame, batteryConfig }) {
const videoRef = useRef(null);
const restartTimer = useRef(null);
const unmuteTimer = useRef(null);
const [status, setStatus] = useState('idle');
const [detail, setDetail] = useState(null);
const [restartToken, setRestartToken] = useState(0);
const [muted, setMuted] = useState(true);
const sensors = telemetryFrame?.sensors;
const batteryCharge = sensors?.batteryChargeMah ?? null;
// console.log('[BatteryBarDebug]', {
// frameSensors: sensors,
// batteryCharge,
// batteryCapacity,
// config: batteryConfig,
// });
const wheelOvercurrents = sensors?.wheelOvercurrents || null;
const overcurrentActive = Boolean(
wheelOvercurrents && Object.values(wheelOvercurrents).some((value) => Boolean(value)),
);
const scheduleRestart = useCallback(() => {
clearTimeout(restartTimer.current);
restartTimer.current = setTimeout(() => setRestartToken(Date.now()), RESTART_DELAY_MS);
}, []);
const attemptUnmute = useCallback(
(delay = 0) => {
if (forceMute) return;
clearTimeout(unmuteTimer.current);
const scheduleRetry = () => {
clearTimeout(unmuteTimer.current);
unmuteTimer.current = setTimeout(() => {
if (!forceMute) {
tryPlay();
}
}, UNMUTE_RETRY_MS);
};
const tryPlay = async () => {
const video = videoRef.current;
if (!video) return;
try {
video.muted = false;
await video.play();
setMuted(false);
} catch {
video.muted = true;
setMuted(true);
scheduleRetry();
}
};
unmuteTimer.current = setTimeout(tryPlay, delay);
},
[forceMute],
);
useEffect(
() => () => {
clearTimeout(restartTimer.current);
clearTimeout(unmuteTimer.current);
},
[],
);
useEffect(() => {
if (status === 'playing') {
attemptUnmute(0);
}
}, [status, attemptUnmute]);
useEffect(() => {
if (!sessionInfo?.url || !videoRef.current) {
return undefined;
}
let active = true;
let player;
const resetMuteId = setTimeout(() => setMuted(true), 0);
const handleStatus = (nextStatus, info) => {
if (!active) return;
setStatus(nextStatus);
setDetail(info || null);
if (['error', 'failed', 'disconnected', 'closed'].includes(nextStatus)) {
scheduleRestart();
}
};
player = new WhepPlayer({
url: sessionInfo.url,
token: sessionInfo.token,
video: videoRef.current,
onStatus: handleStatus,
});
player.start().catch((err) => {
if (!active) return;
setStatus('error');
setDetail(err.message);
scheduleRestart();
});
return () => {
active = false;
clearTimeout(resetMuteId);
player?.stop();
};
}, [sessionInfo?.url, sessionInfo?.token, restartToken, scheduleRestart]);
useEffect(() => {
if (status === 'stopped' && sessionInfo?.url) {
scheduleRestart();
}
}, [status, sessionInfo?.url, scheduleRestart]);
const renderedStatus = !sessionInfo?.url
? 'waiting'
: status === 'error'
? `Error: ${detail || 'unknown'}`
: detail
? `${status} (${detail})`
: status;
return (
<div className="flex flex-col gap-1">
<div className="relative w-full overflow-hidden rounded-sm bg-black aspect-video">
<video
ref={videoRef}
muted={forceMute || muted}
playsInline
autoPlay
controls={false}
className="h-full w-full object-contain"
/>
<HudOverlay frame={telemetryFrame} />
<OvercurrentOverlay active={overcurrentActive} />
</div>
<BatteryBar charge={batteryCharge} config={batteryConfig} label={label} status={renderedStatus} />
</div>
);
}
function BatteryBar({ charge, config, label, status }) {
const full = config?.Full;
const warn = config?.Warn;
const urgent = config?.Urgent ?? null;
if (charge == null || full == null || warn == null) {
return (
<div className="rounded-sm bg-[#1e1e1e] px-1 py-1 text-sm text-slate-200">
<div className="flex items-center justify-between text-xs text-slate-400">
<span>{label}</span>
<span>{status}</span>
</div>
<p className="mt-1 text-xs text-slate-400">Battery telemetry unavailable</p>
</div>
);
}
const span = full - warn;
if (span <= 0) return null;
const normalized = (charge - warn) / span;
const percent = Math.min(1, Math.max(0, normalized));
const percentDisplay = Math.round(percent * 100);
const percentText = `${percentDisplay}%`;
const depleted = normalized <= 0;
const warnTriggered = urgent != null && charge <= urgent;
const barClass = depleted ? 'bg-red-500 animate-pulse' : warnTriggered ? 'bg-amber-400' : 'bg-emerald-500';
return (
<div className="space-y-2 rounded-sm bg-[#1e1e1e] px-1 py-1 text-sm text-slate-200">
<div className="flex items-center justify-between text-xs text-slate-400">
<span>{label}</span>
<span>{status}</span>
</div>
<div className="relative h-4 w-full rounded-full bg-slate-800">
<div className={`h-full rounded-full transition-[width] ${barClass}`} style={{ width: `${percentDisplay}%` }} />
<span className="absolute inset-0 flex items-center justify-center text-xs font-semibold text-black/80">
{percentText}
</span>
</div>
</div>
);
}
function HudOverlay({ frame }) {
const sensors = frame?.sensors;
const bumps = sensors?.bumpsAndWheelDrops || {};
const [now, setNow] = useState(() => Date.now());
useEffect(() => {
const interval = setInterval(() => setNow(Date.now()), 150);
return () => clearInterval(interval);
}, []);
const pulse = frame?.receivedAt ? now - frame.receivedAt < 200 : false;
const bumperBadges = [
{ label: 'B-L', active: bumps.bumpLeft },
{ label: 'B-R', active: bumps.bumpRight },
];
const wheelBadges = [
{ label: 'Drop L', active: bumps.wheelDropLeft },
{ label: 'Drop R', active: bumps.wheelDropRight },
];
return (
<div className="pointer-events-none absolute inset-0 flex flex-col justify-between p-1 text-[0.65rem] text-slate-200">
<div className="flex items-center justify-between gap-1">
<div className="flex items-center gap-1 text-[0.55rem] text-slate-400">
<span className={`h-2 w-2 rounded-full ${pulse ? 'bg-emerald-300 shadow-[0_0_4px_rgba(16,185,129,0.8)]' : 'bg-slate-700'}`} />
<span>sensor</span>
</div>
<div className="flex gap-1">
{bumperBadges.map((badge) => (
<HudBadge key={badge.label} label={badge.label} active={badge.active} />
))}
</div>
</div>
<div className="flex justify-end gap-1">
{wheelBadges.map((badge) => (
<HudBadge key={badge.label} label={badge.label} active={badge.active} />
))}
</div>
</div>
);
}
function HudBadge({ label, active }) {
return (
<span
className={`rounded-sm px-1 py-0.5 text-[0.55rem] ${active ? 'bg-emerald-500/30 text-emerald-100' : 'bg-black/40 text-slate-500'}`}
>
{label}
</span>
);
}
function OvercurrentOverlay({ active }) {
if (!active) return null;
return (
<div className="pointer-events-none absolute inset-0 flex items-center justify-center bg-red-900/60">
<div className="text-center text-sm font-semibold text-red-100 animate-pulse">Overcurrent</div>
</div>
);
}
+19
View File
@@ -0,0 +1,19 @@
import { createContext, useContext } from 'react';
import { useDriveControls as useDriveControlsHook } from '../hooks/useDriveControls.js';
/* eslint-disable react-refresh/only-export-components */
const DriveControlContext = createContext(null);
export function DriveControlProvider({ children }) {
const controls = useDriveControlsHook();
return <DriveControlContext.Provider value={controls}>{children}</DriveControlContext.Provider>;
}
export function useDriveControl() {
const context = useContext(DriveControlContext);
if (!context) {
throw new Error('useDriveControl must be used within DriveControlProvider');
}
return context;
}
+112
View File
@@ -0,0 +1,112 @@
/* eslint-disable react-refresh/only-export-components */
import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react';
import { useSocket } from './SocketContext.jsx';
const SessionContext = createContext({
connected: false,
session: null,
logs: [],
login: async () => {},
setRole: async () => {},
requestControl: async () => {},
releaseControl: async () => {},
subscribeAll: async () => {},
});
function useAckEmitter(socket) {
return useCallback(
(event, payload = {}) =>
new Promise((resolve, reject) => {
socket.emit(event, payload, (resp = {}) => {
if (resp.error) {
reject(new Error(resp.error));
} else {
resolve(resp);
}
});
}),
[socket],
);
}
export function SessionProvider({ children }) {
const socket = useSocket();
const emitWithAck = useAckEmitter(socket);
const [session, setSession] = useState(null);
const [logs, setLogs] = useState([]);
const [alerts, setAlerts] = useState([]);
const [connected, setConnected] = useState(socket.connected);
useEffect(() => {
const onConnect = () => setConnected(true);
const onDisconnect = () => setConnected(false);
socket.on('connect', onConnect);
socket.on('disconnect', onDisconnect);
return () => {
socket.off('connect', onConnect);
socket.off('disconnect', onDisconnect);
};
}, [socket]);
useEffect(() => {
function handleSession(payload) {
setSession(payload);
}
function handleLogInit(entries = []) {
setLogs(entries);
}
function handleLogEntry(entry) {
setLogs((prev) => [...prev.slice(-199), entry]);
}
socket.on('session:sync', handleSession);
socket.on('log:init', handleLogInit);
socket.on('log:entry', handleLogEntry);
socket.on('alert:new', (payload = {}) => {
setAlerts((prev) => [
...prev.slice(-49),
{
...payload,
receivedAt: Date.now(),
},
]);
});
return () => {
socket.off('session:sync', handleSession);
socket.off('log:init', handleLogInit);
socket.off('log:entry', handleLogEntry);
socket.off('alert:new');
};
}, [socket]);
const actions = useMemo(
() => ({
login: (username, password) => emitWithAck('auth:login', { username, password }),
setRole: (role) => emitWithAck('session:setRole', { role }),
requestControl: (roverId, options = {}) =>
emitWithAck('session:requestControl', { roverId, ...options }),
releaseControl: (roverId) => emitWithAck('session:releaseControl', { roverId }),
subscribeAll: () => emitWithAck('session:subscribeAll'),
lockRover: (roverId, locked) => emitWithAck('session:lockRover', { roverId, locked }),
setMode: (mode) => emitWithAck('setMode', { mode }),
}),
[emitWithAck],
);
const value = useMemo(
() => ({
connected,
session,
logs,
alerts,
...actions,
}),
[actions, alerts, connected, logs, session],
);
return <SessionContext.Provider value={value}>{children}</SessionContext.Provider>;
}
export function useSession() {
return useContext(SessionContext);
}
+11
View File
@@ -0,0 +1,11 @@
/* eslint-disable react-refresh/only-export-components */
// src/context/SocketContext.jsx
import { createContext, useContext } from "react";
import { socket } from "../lib/socket";
const SocketContext = createContext(socket);
export const SocketProvider = ({ children }) => (
<SocketContext.Provider value={socket}>{children}</SocketContext.Provider>
);
export const useSocket = () => useContext(SocketContext);
+44
View File
@@ -0,0 +1,44 @@
/* eslint-disable react-refresh/only-export-components */
import { createContext, useContext, useMemo, useState, useEffect } from 'react';
import { useSocket } from './SocketContext.jsx';
const TelemetryContext = createContext({ frames: {} });
export function TelemetryProvider({ children }) {
const socket = useSocket();
const [frames, setFrames] = useState({});
useEffect(() => {
function handleSensorFrame({ roverId, sensors = {}, frame = {} }) {
if (!roverId) return;
setFrames((prev) => ({
...prev,
[roverId]: {
roverId,
sensors,
raw: frame?.data || null,
receivedAt: Date.now(),
},
}));
}
socket.on('sensorFrame', handleSensorFrame);
return () => {
socket.off('sensorFrame', handleSensorFrame);
};
}, [socket]);
const value = useMemo(() => ({ frames }), [frames]);
return <TelemetryContext.Provider value={value}>{children}</TelemetryContext.Provider>;
}
export function useTelemetryFrames() {
return useContext(TelemetryContext).frames;
}
export function useTelemetryFrame(roverId) {
const frames = useTelemetryFrames();
if (!roverId) return null;
return frames[roverId] ?? null;
}
+253
View File
@@ -0,0 +1,253 @@
/* global Buffer */
import { useCallback, useEffect, useRef, useState } from 'react';
import { useSocket } from '../context/SocketContext.jsx';
import { useSession } from '../context/SessionContext.jsx';
const OI_COMMANDS = {
start: [128],
safe: [131],
full: [132],
passive: [128],
dock: [143],
};
const AUX_LIMITS = {
main: [-127, 127],
side: [-127, 127],
vacuum: [0, 127],
};
const COMMAND_DELAY_MS = 200;
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
function clamp(value, min, max) {
return Math.max(min, Math.min(max, value));
}
function computeSpeeds(keys) {
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 clampUnit(value) {
if (typeof value !== 'number' || Number.isNaN(value)) return 0;
return Math.max(-1, Math.min(1, value));
}
function shouldIgnoreEvent(event) {
const target = event.target;
if (!target) return false;
const tag = target.tagName;
return (
tag === 'INPUT' ||
tag === 'TEXTAREA' ||
target.isContentEditable ||
(tag === 'SELECT' && ['w', 'a', 's', 'd', 'Shift'].includes(event.key))
);
}
function bytesToBase64(bytes) {
const binary = String.fromCharCode(...bytes);
if (typeof btoa === 'function') return btoa(binary);
if (typeof Buffer !== 'undefined') {
return Buffer.from(bytes).toString('base64');
}
throw new Error('No base64 encoder available');
}
export function useDriveControls() {
const socket = useSocket();
const { session } = useSession();
const roverId = session?.assignment?.roverId;
const keysRef = useRef(new Set());
const lastSpeedsRef = useRef({ left: 0, right: 0 });
const [currentSpeeds, setCurrentSpeeds] = useState(() => ({ left: 0, right: 0 }));
const emitCommand = useCallback(
(payload, cb) => {
if (!roverId) return;
socket.emit('command', { roverId, ...payload }, cb);
},
[socket, roverId],
);
const enableSensorStream = useCallback(() => {
if (!roverId) return;
emitCommand({
type: 'sensorStream',
data: { sensorStream: { enable: true } },
});
}, [emitCommand, roverId]);
const sendDriveUpdate = useCallback(() => {
if (!roverId) return;
const speeds = computeSpeeds(keysRef.current);
const last = lastSpeedsRef.current;
if (speeds.left === last.left && speeds.right === last.right) return;
lastSpeedsRef.current = speeds;
setCurrentSpeeds(speeds);
emitCommand({
type: 'drive',
data: { driveDirect: speeds },
});
}, [emitCommand, roverId]);
const driveWithVector = useCallback(
({ x = 0, y = 0, boost = false } = {}) => {
if (!roverId) return;
const base = boost ? 400 : 250;
const forward = clampUnit(y) * base;
const turn = clampUnit(x) * base;
const speeds = {
left: clamp(Math.round(forward + turn), -500, 500),
right: clamp(Math.round(forward - turn), -500, 500),
};
lastSpeedsRef.current = speeds;
setCurrentSpeeds(speeds);
emitCommand({
type: 'drive',
data: { driveDirect: speeds },
});
},
[emitCommand, roverId],
);
const sendMotorPwm = useCallback(
({ main = 0, side = 0, vacuum = 0 } = {}) => {
if (!roverId) return;
const payload = {
main: clamp(main, AUX_LIMITS.main[0], AUX_LIMITS.main[1]),
side: clamp(side, AUX_LIMITS.side[0], AUX_LIMITS.side[1]),
vacuum: clamp(vacuum, AUX_LIMITS.vacuum[0], AUX_LIMITS.vacuum[1]),
};
emitCommand({
type: 'motors',
data: { motorPwm: payload },
});
},
[emitCommand, roverId],
);
const stopMotors = useCallback(() => {
if (!roverId) return;
keysRef.current.clear();
lastSpeedsRef.current = { left: 0, right: 0 };
setCurrentSpeeds(lastSpeedsRef.current);
sendMotorPwm({ main: 0, side: 0, vacuum: 0 });
}, [roverId, sendMotorPwm]);
const sendOiCommand = useCallback(
(key) => {
if (!roverId) return;
const bytes = OI_COMMANDS[key];
if (!bytes) return;
emitCommand({
type: 'raw',
data: { raw: bytesToBase64(bytes) },
});
enableSensorStream();
},
[emitCommand, enableSensorStream, roverId],
);
const runStartDockFull = useCallback(async () => {
if (!roverId) return;
for (const key of ['start', 'dock', 'full']) {
sendOiCommand(key);
// brief pause so the commands aren't collapsed by the rover
await sleep(COMMAND_DELAY_MS);
}
}, [roverId, sendOiCommand]);
const seekDock = useCallback(() => {
sendOiCommand('dock');
}, [sendOiCommand]);
const setAuxMotors = useCallback(
(values) => {
if (!roverId) return;
sendMotorPwm(values);
},
[roverId, sendMotorPwm],
);
useEffect(() => {
if (!roverId) {
keysRef.current.clear();
lastSpeedsRef.current = { left: 0, right: 0 };
setCurrentSpeeds(lastSpeedsRef.current);
return undefined;
}
function onKeyDown(event) {
if (shouldIgnoreEvent(event)) return;
const key = event.key.toLowerCase();
if (!['w', 'a', 's', 'd', 'shift'].includes(key)) return;
if (keysRef.current.has(key)) return;
keysRef.current.add(key);
sendDriveUpdate();
}
function onKeyUp(event) {
const key = event.key.toLowerCase();
if (!keysRef.current.has(key)) return;
keysRef.current.delete(key);
sendDriveUpdate();
}
window.addEventListener('keydown', onKeyDown);
window.addEventListener('keyup', onKeyUp);
enableSensorStream();
return () => {
window.removeEventListener('keydown', onKeyDown);
window.removeEventListener('keyup', onKeyUp);
};
}, [roverId, sendDriveUpdate, enableSensorStream]);
return {
roverId,
speeds: currentSpeeds,
stopMotors,
sendOiCommand,
runStartDockFull,
seekDock,
setAuxMotors,
driveWithVector,
};
}
+30
View File
@@ -0,0 +1,30 @@
import { useEffect, useState } from 'react';
import { useSession } from '../context/SessionContext.jsx';
export function useSpectatorMode() {
const { session, setRole, subscribeAll } = useSession();
const [ready, setReady] = useState(false);
useEffect(() => {
let cancelled = false;
async function ensureSpectator() {
try {
if (session?.role !== 'spectator') {
await setRole('spectator');
}
await subscribeAll();
if (!cancelled) {
setReady(true);
}
} catch (err) {
console.error('Failed to enter spectator mode', err);
}
}
ensureSpectator();
return () => {
cancelled = true;
};
}, [session?.role, setRole, subscribeAll]);
return ready;
}
+43
View File
@@ -0,0 +1,43 @@
import { useEffect, useMemo, useState } from 'react';
import { useSocket } from '../context/SocketContext.jsx';
export function useVideoRequests(roverIds = []) {
const socket = useSocket();
const [sources, setSources] = useState({});
const normalizedKey = Array.isArray(roverIds) ? roverIds.filter(Boolean).join('|') : '';
useEffect(() => {
if (!normalizedKey) {
return undefined;
}
const ids = normalizedKey.split('|');
let cancelled = false;
ids.forEach((roverId) => {
socket.emit('video:request', { roverId }, (resp = {}) => {
if (cancelled) return;
setSources((prev) => ({
...prev,
[roverId]: resp,
}));
});
});
return () => {
cancelled = true;
};
}, [socket, normalizedKey]);
const filtered = useMemo(() => {
if (!normalizedKey) return {};
const ids = normalizedKey.split('|');
const next = {};
ids.forEach((id) => {
if (sources[id]) {
next[id] = sources[id];
}
});
return next;
}, [normalizedKey, sources]);
return filtered;
}
+16
View File
@@ -0,0 +1,16 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
:root {
font-family: 'Inter', system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI',
sans-serif;
color: #0f172a;
background-color: #f8fafc;
}
body {
margin: 0;
min-height: 100vh;
background-color: #0f172a;
}
+10
View File
@@ -0,0 +1,10 @@
import { io } from 'socket.io-client';
const configured = import.meta.env.VITE_ROVERD_URL?.trim();
const resolvedUrl = configured && configured.length > 0 ? configured : window.location.origin;
console.info('[socket] connecting to', resolvedUrl);
export const socket = io(resolvedUrl, {
transports: ['polling', 'websocket'],
timeout: 15000,
});
socket.on('connect_error', (err) => console.error('connect_error', err.code, err.message, err.data));
+139
View File
@@ -0,0 +1,139 @@
/* global Buffer */
const RTC_CONFIG = {
iceServers: [
{ urls: 'stun:stun.l.google.com:19302' },
{ urls: 'turn:your.turn.server:3478', username: 'user', credential: 'pass' },
],
bundlePolicy: 'max-bundle',
rtcpMuxPolicy: 'require',
};
function encodeBase64(value) {
if (typeof btoa === 'function') {
return btoa(value);
}
if (typeof Buffer !== 'undefined') {
return Buffer.from(value).toString('base64');
}
throw new Error('No base64 encoder available');
}
function buildAuthHeader(token) {
if (!token) return {};
const credential = `${token}:${token}`;
const encoded = encodeBase64(credential);
return { Authorization: `Basic ${encoded}` };
}
export class WhepPlayer {
constructor({ url, token, video, onStatus }) {
this.url = url;
this.token = token;
this.video = video;
this.pc = null;
this.abortController = null;
this.onStatus = onStatus;
}
notify(status, detail) {
if (typeof this.onStatus === 'function') {
this.onStatus(status, detail);
}
}
configureVideoElement() {
if (!this.video) return;
this.video.playsInline = true;
this.video.autoplay = true;
this.video.disableRemotePlayback = true;
if ('latencyHint' in HTMLMediaElement.prototype) {
this.video.latencyHint = 'interactive';
}
}
async start() {
if (!this.url || !this.video) {
throw new Error('Video target missing');
}
this.stop();
this.notify('connecting');
this.configureVideoElement();
this.abortController = new AbortController();
const pc = new RTCPeerConnection(RTC_CONFIG);
this.pc = pc;
const stream = new MediaStream();
pc.ontrack = (event) => {
event.streams[0]?.getTracks().forEach((mediaTrack) => stream.addTrack(mediaTrack));
this.video.srcObject = stream;
if (event.track?.kind === 'video' && 'playoutDelayHint' in event.receiver) {
event.receiver.playoutDelayHint = 0;
}
};
pc.addTransceiver('video', { direction: 'recvonly' });
pc.addTransceiver('audio', { direction: 'recvonly' });
pc.onconnectionstatechange = () => {
const state = pc.connectionState;
this.notify(state);
};
pc.oniceconnectionstatechange = () => {
const state = pc.iceConnectionState;
if (state === 'failed' || state === 'disconnected') {
this.notify(state);
}
};
try {
const offer = await pc.createOffer({
offerToReceiveAudio: true,
offerToReceiveVideo: true,
});
await pc.setLocalDescription(offer);
const response = await fetch(this.url, {
method: 'POST',
headers: {
'Content-Type': 'application/sdp',
...buildAuthHeader(this.token),
},
body: offer.sdp,
signal: this.abortController.signal,
});
if (!response.ok) {
throw new Error(`WHEP request failed: ${response.status}`);
}
const answerSdp = await response.text();
await pc.setRemoteDescription({ type: 'answer', sdp: answerSdp });
pc.getReceivers().forEach((receiver) => {
if ('playoutDelayHint' in receiver) {
receiver.playoutDelayHint = 0;
}
});
await this.video.play().catch(() => {});
this.notify('playing');
} catch (err) {
this.notify('error', err.message);
this.stop();
throw err;
}
}
stop() {
if (this.abortController) {
this.abortController.abort();
this.abortController = null;
}
if (this.pc) {
this.pc.getSenders().forEach((sender) => sender.track?.stop());
this.pc.getReceivers().forEach((receiver) => receiver.track?.stop());
this.pc.close();
this.pc = null;
}
if (this.video) {
this.video.srcObject = null;
}
this.notify('stopped');
}
}
+26
View File
@@ -0,0 +1,26 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import { BrowserRouter, Route, Routes } from 'react-router-dom'
import './index.css'
import App from './App.jsx'
import { SocketProvider } from './context/SocketContext.jsx'
import { SessionProvider } from './context/SessionContext.jsx'
import { TelemetryProvider } from './context/TelemetryContext.jsx'
import SpectatorApp from './spectate/SpectatorApp.jsx'
createRoot(document.getElementById('root')).render(
<StrictMode>
<SocketProvider>
<SessionProvider>
<TelemetryProvider>
<BrowserRouter>
<Routes>
<Route path="/" element={<App />} />
<Route path="/spectate" element={<SpectatorApp />} />
</Routes>
</BrowserRouter>
</TelemetryProvider>
</SessionProvider>
</SocketProvider>
</StrictMode>,
)
+127
View File
@@ -0,0 +1,127 @@
import { useMemo } from 'react';
import { useSession } from '../context/SessionContext.jsx';
import { useSpectatorMode } from '../hooks/useSpectatorMode.js';
import { useTelemetryFrames } from '../context/TelemetryContext.jsx';
import { useVideoRequests } from '../hooks/useVideoRequests.js';
import VideoTile from '../components/VideoTile.jsx';
function SpectatorStatus({ connected, ready, rosterCount }) {
return (
<div className="flex flex-wrap items-center gap-3 text-sm">
<span className={`rounded-full px-3 py-1 font-semibold ${connected ? 'bg-emerald-500/20 text-emerald-200' : 'bg-red-500/20 text-red-200'}`}>
{connected ? 'Connected' : 'Disconnected'}
</span>
<span className={`rounded-full px-3 py-1 ${ready ? 'bg-blue-600/40 text-blue-100' : 'bg-slate-700 text-slate-200'}`}>
Spectator mode {ready ? 'active' : 'pending…'}
</span>
<span className="rounded-full bg-slate-800 px-3 py-1 text-slate-200">
Rovers: {rosterCount}
</span>
</div>
);
}
function LogStream({ logs }) {
return (
<section className="rounded-3xl border border-slate-800 bg-slate-900/70 p-4">
<header className="mb-3 flex items-center justify-between">
<h2 className="text-lg font-semibold text-white">Server logs</h2>
<span className="text-xs uppercase tracking-[0.3em] text-slate-500">latest {logs.length}</span>
</header>
<div className="h-60 overflow-y-auto rounded-2xl bg-slate-950/40 p-3 text-xs font-mono text-slate-300">
{logs.length === 0 ? (
<p>No log entries yet.</p>
) : (
logs
.slice()
.reverse()
.map((entry) => (
<div key={entry.id} className="mb-2">
<span className="text-slate-500">{entry.timestamp}</span>{' '}
<span className="text-cyan-300">[{entry.level}]</span>{' '}
{entry.label && <span className="text-pink-300">[{entry.label}]</span>}{' '}
<span>{entry.message}</span>
</div>
))
)}
</div>
</section>
);
}
function formatSensors(sensors = {}) {
return Object.entries(sensors).map(([key, value]) => ({
key,
value: typeof value === 'object' ? JSON.stringify(value) : value,
}));
}
function RoverSpectatorCard({ rover, frame, videoInfo }) {
const sensorList = formatSensors(frame?.sensors);
return (
<article className="space-y-4 rounded-3xl border border-slate-800 bg-slate-900/70 p-5">
<header className="flex flex-col gap-1">
<h3 className="text-2xl font-semibold text-white">{rover.name}</h3>
<p className="text-sm text-slate-400">
{frame?.receivedAt ? `Updated ${new Date(frame.receivedAt).toLocaleTimeString()}` : 'Waiting for telemetry…'}
</p>
</header>
<VideoTile sessionInfo={videoInfo} label={rover.name} forceMute />
<section className="rounded-2xl border border-slate-800/80 bg-slate-950/50 p-4">
<p className="text-xs uppercase tracking-[0.3em] text-slate-500">Telemetry</p>
{sensorList.length === 0 ? (
<p className="mt-3 text-sm text-slate-400">No sensor data yet.</p>
) : (
<dl className="mt-3 grid gap-2 text-sm text-slate-200">
{sensorList.map(({ key, value }) => (
<div key={key} className="flex justify-between gap-4">
<dt className="text-slate-500">{key}</dt>
<dd className="text-right font-semibold text-white">{value}</dd>
</div>
))}
</dl>
)}
</section>
</article>
);
}
export default function SpectatorApp() {
const { connected, session, logs } = useSession();
const spectatorReady = useSpectatorMode();
const frames = useTelemetryFrames();
const roster = session?.roster ?? [];
const videoSources = useVideoRequests(roster.map((rover) => rover.id));
const status = useMemo(
() => ({ connected, ready: spectatorReady, rosterCount: roster.length }),
[connected, spectatorReady, roster.length],
);
return (
<div className="min-h-screen bg-slate-950 text-slate-50">
<main className="mx-auto flex max-w-7xl flex-col gap-6 px-6 py-10">
<header className="space-y-3">
<h1 className="text-4xl font-semibold text-white">Spectator Console</h1>
<p className="text-slate-400">Live rover telemetry & logs. This view is read-only.</p>
<SpectatorStatus {...status} />
</header>
<section className="grid gap-6 lg:grid-cols-3">
{roster.length === 0 ? (
<p className="col-span-full text-slate-400">No rovers registered.</p>
) : (
roster.map((rover) => (
<RoverSpectatorCard
key={rover.id}
rover={rover}
frame={frames[rover.id]}
videoInfo={videoSources[rover.id]}
/>
))
)}
</section>
<LogStream logs={logs} />
</main>
</div>
);
}
+11
View File
@@ -0,0 +1,11 @@
/** @type {import('tailwindcss').Config} */
export default {
content: [
"./index.html",
"./src/**/*.{js,ts,jsx,tsx}",
],
theme: {
extend: {},
},
plugins: [],
}
+11
View File
@@ -0,0 +1,11 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
// https://vite.dev/config/
export default defineConfig({
plugins: [react()],
build: {
outDir: '../server/public',
emptyOutDir: true,
},
})