mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-17 10:00:46 -04:00
tpms
This commit is contained in:
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -11,8 +11,8 @@
|
|||||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||||
<meta name="apple-mobile-web-app-title" content="Multi Roomba Rover" />
|
<meta name="apple-mobile-web-app-title" content="Multi Roomba Rover" />
|
||||||
<title>Multi Roomba Rover</title>
|
<title>Multi Roomba Rover</title>
|
||||||
<script type="module" crossorigin src="/assets/index-B_C-RuNb.js"></script>
|
<script type="module" crossorigin src="/assets/index-D5R4QPMU.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="/assets/index-Dl56RSiM.css">
|
<link rel="stylesheet" crossorigin href="/assets/index-BTAtpc9C.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ const { subscribe } = require('./eventBus');
|
|||||||
const { getRoster, lockRover, rovers } = require('./roverManager');
|
const { getRoster, lockRover, rovers } = require('./roverManager');
|
||||||
const { MODES, getMode, setMode } = require('./modeManager');
|
const { MODES, getMode, setMode } = require('./modeManager');
|
||||||
const { sendExternalMessage } = require('./chatService');
|
const { sendExternalMessage } = require('./chatService');
|
||||||
const { getRoomCameras } = require('./roomCameraService');
|
const { getRoomCameras, getRoomCamera } = require('./roomCameraService');
|
||||||
const {
|
const {
|
||||||
getRoomCameraFrames,
|
getRoomCameraFrames,
|
||||||
getRoomCameraReplayDelayMs,
|
getRoomCameraReplayDelayMs,
|
||||||
@@ -199,7 +199,7 @@ function formatHelp() {
|
|||||||
'**Rover Bot Commands**',
|
'**Rover Bot Commands**',
|
||||||
'`rs help` — show this help',
|
'`rs help` — show this help',
|
||||||
'`rs status [id]` — show rover status (all or one)',
|
'`rs status [id]` — show rover status (all or one)',
|
||||||
'`rs replay` — send room camera instant replay',
|
'`rs replay [camera]` — send room camera instant replay',
|
||||||
'`rs lock <id>` — lock a rover',
|
'`rs lock <id>` — lock a rover',
|
||||||
'`rs unlock <id>` — unlock a rover',
|
'`rs unlock <id>` — unlock a rover',
|
||||||
'`rs mode <open|turns|admin|lockdown>` — change server mode',
|
'`rs mode <open|turns|admin|lockdown>` — change server mode',
|
||||||
@@ -250,8 +250,39 @@ function buildDriverCaption() {
|
|||||||
return `Drivers: ${entries.join(', ')}`;
|
return `Drivers: ${entries.join(', ')}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function buildReplayVideo() {
|
function normalizeCameraQuery(input) {
|
||||||
|
return String(input || '').trim().toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveReplayCamera(query) {
|
||||||
|
const cleaned = normalizeCameraQuery(query);
|
||||||
|
if (!cleaned || cleaned === 'all' || cleaned === '*') return { camera: null };
|
||||||
const cameras = getRoomCameras();
|
const cameras = getRoomCameras();
|
||||||
|
const direct = cameras.find(
|
||||||
|
(camera) =>
|
||||||
|
String(camera.id).toLowerCase() === cleaned ||
|
||||||
|
String(camera.name || '').toLowerCase() === cleaned,
|
||||||
|
);
|
||||||
|
if (direct) return { camera: direct };
|
||||||
|
const starts = cameras.filter(
|
||||||
|
(camera) =>
|
||||||
|
String(camera.id).toLowerCase().startsWith(cleaned) ||
|
||||||
|
String(camera.name || '').toLowerCase().startsWith(cleaned),
|
||||||
|
);
|
||||||
|
if (starts.length === 1) return { camera: starts[0] };
|
||||||
|
if (starts.length > 1) return { error: 'Ambiguous camera name', matches: starts };
|
||||||
|
const includes = cameras.filter(
|
||||||
|
(camera) =>
|
||||||
|
String(camera.id).toLowerCase().includes(cleaned) ||
|
||||||
|
String(camera.name || '').toLowerCase().includes(cleaned),
|
||||||
|
);
|
||||||
|
if (includes.length === 1) return { camera: includes[0] };
|
||||||
|
if (includes.length > 1) return { error: 'Ambiguous camera name', matches: includes };
|
||||||
|
return { error: 'Camera not found', matches: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function buildReplayVideo({ cameraId = null } = {}) {
|
||||||
|
const cameras = cameraId ? [getRoomCamera(cameraId)].filter(Boolean) : getRoomCameras();
|
||||||
if (!cameras.length) {
|
if (!cameras.length) {
|
||||||
throw new Error('No room cameras configured');
|
throw new Error('No room cameras configured');
|
||||||
}
|
}
|
||||||
@@ -300,25 +331,30 @@ async function buildReplayVideo() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildReplayCaption(requester) {
|
function buildReplayCaption(requester, camera) {
|
||||||
const requesterLabel = requester || 'unknown';
|
const requesterLabel = requester || 'unknown';
|
||||||
|
const cameraLabel = camera ? `Camera: ${camera.name || camera.id}.` : null;
|
||||||
return [
|
return [
|
||||||
`Replay requested by ${requesterLabel}.`,
|
`Replay requested by ${requesterLabel}.`,
|
||||||
|
cameraLabel,
|
||||||
buildDriverCaption(),
|
buildDriverCaption(),
|
||||||
].join(' ');
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' ');
|
||||||
}
|
}
|
||||||
|
|
||||||
async function sendReplayToChannel(channelId, requester) {
|
async function sendReplayToChannel(channelId, requester, cameraId = null) {
|
||||||
if (!channelId) {
|
if (!channelId) {
|
||||||
throw new Error('Replay channel not configured');
|
throw new Error('Replay channel not configured');
|
||||||
}
|
}
|
||||||
const buffer = await buildReplayVideo();
|
const buffer = await buildReplayVideo({ cameraId });
|
||||||
const attachment = new AttachmentBuilder(buffer, { name: 'replay.mp4' });
|
const attachment = new AttachmentBuilder(buffer, { name: 'replay.mp4' });
|
||||||
const caption = buildReplayCaption(requester);
|
const camera = cameraId ? getRoomCamera(cameraId) : null;
|
||||||
|
const caption = buildReplayCaption(requester, camera);
|
||||||
await sendToChannel(channelId, caption, { files: [attachment] }, { parse: [] });
|
await sendToChannel(channelId, caption, { files: [attachment] }, { parse: [] });
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleReplayCommand(message) {
|
async function handleReplayCommand(message, query) {
|
||||||
if (getMode() === MODES.LOCKDOWN) {
|
if (getMode() === MODES.LOCKDOWN) {
|
||||||
await message.reply({
|
await message.reply({
|
||||||
content: 'Replay is disabled while the server is in lockdown.',
|
content: 'Replay is disabled while the server is in lockdown.',
|
||||||
@@ -338,12 +374,25 @@ async function handleReplayCommand(message) {
|
|||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const resolved = resolveReplayCamera(query);
|
||||||
|
if (resolved?.error) {
|
||||||
|
const matches = resolved.matches || [];
|
||||||
|
const list = matches.length
|
||||||
|
? `Matches: ${matches.map((cam) => cam.name || cam.id).join(', ')}`
|
||||||
|
: 'No matching cameras found.';
|
||||||
|
await message.reply({
|
||||||
|
content: sanitizeMentions(`${resolved.error}. ${list}`),
|
||||||
|
allowedMentions: { parse: [], repliedUser: false },
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const cameraId = resolved.camera?.id || null;
|
||||||
const requester =
|
const requester =
|
||||||
message.member?.nickname || message.author?.globalName || message.author?.username || 'Discord';
|
message.member?.nickname || message.author?.globalName || message.author?.username || 'Discord';
|
||||||
try {
|
try {
|
||||||
const buffer = await buildReplayVideo();
|
const buffer = await buildReplayVideo({ cameraId });
|
||||||
const attachment = new AttachmentBuilder(buffer, { name: 'replay.mp4' });
|
const attachment = new AttachmentBuilder(buffer, { name: 'replay.mp4' });
|
||||||
const caption = buildReplayCaption(requester);
|
const caption = buildReplayCaption(requester, resolved.camera || null);
|
||||||
await message.reply({
|
await message.reply({
|
||||||
content: sanitizeMentions(caption),
|
content: sanitizeMentions(caption),
|
||||||
files: [attachment],
|
files: [attachment],
|
||||||
@@ -428,7 +477,7 @@ async function handleCommand(message) {
|
|||||||
await handleStatusCommand(message, tokens[0]);
|
await handleStatusCommand(message, tokens[0]);
|
||||||
break;
|
break;
|
||||||
case 'replay':
|
case 'replay':
|
||||||
await handleReplayCommand(message);
|
await handleReplayCommand(message, tokens.join(' '));
|
||||||
break;
|
break;
|
||||||
case 'lock':
|
case 'lock':
|
||||||
await handleLockCommand(message, tokens[0], true);
|
await handleLockCommand(message, tokens[0], true);
|
||||||
@@ -717,7 +766,7 @@ function handleBusEvent(event) {
|
|||||||
updatePresence();
|
updatePresence();
|
||||||
break;
|
break;
|
||||||
case 'replay.requested':
|
case 'replay.requested':
|
||||||
sendReplayToChannel(payload?.channelId, payload?.requester).catch((err) => {
|
sendReplayToChannel(payload?.channelId, payload?.requester, payload?.cameraId || null).catch((err) => {
|
||||||
logger.warn('Replay send failed', err.message);
|
logger.warn('Replay send failed', err.message);
|
||||||
});
|
});
|
||||||
break;
|
break;
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ const { publishEvent } = require('./eventBus');
|
|||||||
const { tryTriggerReplay } = require('./replayService');
|
const { tryTriggerReplay } = require('./replayService');
|
||||||
const { getNickname } = require('./nicknameService');
|
const { getNickname } = require('./nicknameService');
|
||||||
const { loadConfig } = require('../helpers/configLoader');
|
const { loadConfig } = require('../helpers/configLoader');
|
||||||
|
const { getRoomCamera } = require('./roomCameraService');
|
||||||
|
|
||||||
const config = loadConfig();
|
const config = loadConfig();
|
||||||
const discordConfig = config.discord || {};
|
const discordConfig = config.discord || {};
|
||||||
@@ -24,6 +25,11 @@ io.on('connection', (socket) => {
|
|||||||
cb({ error: 'Replay channel not configured', state: null });
|
cb({ error: 'Replay channel not configured', state: null });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const requestedCameraId = payload?.cameraId ? String(payload.cameraId) : null;
|
||||||
|
if (requestedCameraId && !getRoomCamera(requestedCameraId)) {
|
||||||
|
cb({ error: 'Unknown camera', state: null });
|
||||||
|
return;
|
||||||
|
}
|
||||||
const requester = buildRequesterLabel(socket);
|
const requester = buildRequesterLabel(socket);
|
||||||
const attempt = tryTriggerReplay({ by: { source: 'web', requester } });
|
const attempt = tryTriggerReplay({ by: { source: 'web', requester } });
|
||||||
if (!attempt.ok) {
|
if (!attempt.ok) {
|
||||||
@@ -36,6 +42,7 @@ io.on('connection', (socket) => {
|
|||||||
payload: {
|
payload: {
|
||||||
channelId,
|
channelId,
|
||||||
requester,
|
requester,
|
||||||
|
cameraId: requestedCameraId,
|
||||||
requestedBy: { socketId: socket.id },
|
requestedBy: { socketId: socket.id },
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,8 +2,8 @@ const EventEmitter = require('events');
|
|||||||
const logger = require('../globals/logger').child('roomCameraSnapshot');
|
const logger = require('../globals/logger').child('roomCameraSnapshot');
|
||||||
const { getRoomCameras, roomCameraEvents } = require('./roomCameraService');
|
const { getRoomCameras, roomCameraEvents } = require('./roomCameraService');
|
||||||
|
|
||||||
const POLL_INTERVAL_MS = 800;
|
const POLL_INTERVAL_MS = 100;
|
||||||
const REPLAY_FRAME_COUNT = 15;
|
const REPLAY_FRAME_COUNT = 50;
|
||||||
const FETCH_TIMEOUT_MS = 2000;
|
const FETCH_TIMEOUT_MS = 2000;
|
||||||
|
|
||||||
const cameraState = new Map(); // id -> {frame, ts, error, failures, fetching}
|
const cameraState = new Map(); // id -> {frame, ts, error, failures, fetching}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ const { roomCameraStreamEvents, getRoomCameraState } = require('./roomCameraSnap
|
|||||||
|
|
||||||
const SUBSCRIBE_LIMIT = 50;
|
const SUBSCRIBE_LIMIT = 50;
|
||||||
const SUBSCRIBE_WINDOW_MS = 10000;
|
const SUBSCRIBE_WINDOW_MS = 10000;
|
||||||
|
const STREAM_INTERVAL_MS = 800;
|
||||||
|
|
||||||
function passesMode(socket) {
|
function passesMode(socket) {
|
||||||
const mode = getMode();
|
const mode = getMode();
|
||||||
@@ -27,6 +28,7 @@ function canViewRoomCamera(socket) {
|
|||||||
const cameraSubscribers = new Map(); // id -> Set(socketId)
|
const cameraSubscribers = new Map(); // id -> Set(socketId)
|
||||||
const socketSubscriptions = new Map(); // socketId -> Set(id)
|
const socketSubscriptions = new Map(); // socketId -> Set(id)
|
||||||
const subscribeBuckets = new Map(); // socketId -> { start, count }
|
const subscribeBuckets = new Map(); // socketId -> { start, count }
|
||||||
|
const lastSentBySocket = new Map(); // socketId -> Map(cameraId -> ts)
|
||||||
|
|
||||||
function addSubscription(socket, cameraId) {
|
function addSubscription(socket, cameraId) {
|
||||||
if (!cameraSubscribers.has(cameraId)) {
|
if (!cameraSubscribers.has(cameraId)) {
|
||||||
@@ -88,6 +90,17 @@ roomCameraStreamEvents.on('frame', ({ id, buffer, ts }) => {
|
|||||||
bucket.forEach((socketId) => {
|
bucket.forEach((socketId) => {
|
||||||
const socket = io.sockets.sockets.get(socketId);
|
const socket = io.sockets.sockets.get(socketId);
|
||||||
if (!socket) return;
|
if (!socket) return;
|
||||||
|
let lastMap = lastSentBySocket.get(socketId);
|
||||||
|
if (!lastMap) {
|
||||||
|
lastMap = new Map();
|
||||||
|
lastSentBySocket.set(socketId, lastMap);
|
||||||
|
}
|
||||||
|
const lastSent = lastMap.get(id) || 0;
|
||||||
|
const now = ts || Date.now();
|
||||||
|
if (now - lastSent < STREAM_INTERVAL_MS) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
lastMap.set(id, now);
|
||||||
sendFrame(socket, id, { ts }, buffer);
|
sendFrame(socket, id, { ts }, buffer);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -149,5 +162,6 @@ io.on('connection', (socket) => {
|
|||||||
socket.on('disconnect', () => {
|
socket.on('disconnect', () => {
|
||||||
removeAllSubscriptions(socket.id);
|
removeAllSubscriptions(socket.id);
|
||||||
subscribeBuckets.delete(socket.id);
|
subscribeBuckets.delete(socket.id);
|
||||||
|
lastSentBySocket.delete(socket.id);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -33,12 +33,17 @@ export default function RoomCameraPanel({
|
|||||||
const cameras = session?.roomCameras || [];
|
const cameras = session?.roomCameras || [];
|
||||||
const feedMap = useRoomCameraSnapshots(cameras.map((camera) => ({ id: camera.id })));
|
const feedMap = useRoomCameraSnapshots(cameras.map((camera) => ({ id: camera.id })));
|
||||||
const { value: orientationSettings, save: saveOrientationSettings } = useSettingsNamespace('roomCameraPanels', {});
|
const { value: orientationSettings, save: saveOrientationSettings } = useSettingsNamespace('roomCameraPanels', {});
|
||||||
|
const { value: replaySettings, save: saveReplaySettings } = useSettingsNamespace('roomCameraReplay', {});
|
||||||
const [orientation, setOrientation] = useState(() =>
|
const [orientation, setOrientation] = useState(() =>
|
||||||
normalizeOrientation(
|
normalizeOrientation(
|
||||||
panelId ? orientationSettings?.[panelId] : defaultOrientation,
|
panelId ? orientationSettings?.[panelId] : defaultOrientation,
|
||||||
'horizontal',
|
'horizontal',
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
const [selectedReplayCamera, setSelectedReplayCamera] = useState(() => {
|
||||||
|
if (!panelId) return 'all';
|
||||||
|
return replaySettings?.[panelId] || 'all';
|
||||||
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!panelId) return;
|
if (!panelId) return;
|
||||||
@@ -47,6 +52,12 @@ export default function RoomCameraPanel({
|
|||||||
setOrientation(normalizeOrientation(stored, 'horizontal'));
|
setOrientation(normalizeOrientation(stored, 'horizontal'));
|
||||||
// only respond to changes for this panel id
|
// only respond to changes for this panel id
|
||||||
}, [panelId, orientationSettings?.[panelId]]);
|
}, [panelId, orientationSettings?.[panelId]]);
|
||||||
|
useEffect(() => {
|
||||||
|
if (!panelId) return;
|
||||||
|
const stored = replaySettings?.[panelId];
|
||||||
|
if (!stored) return;
|
||||||
|
setSelectedReplayCamera(stored);
|
||||||
|
}, [panelId, replaySettings?.[panelId]]);
|
||||||
const effectiveOrientation = forcedOrientation
|
const effectiveOrientation = forcedOrientation
|
||||||
? normalizeOrientation(forcedOrientation, 'horizontal')
|
? normalizeOrientation(forcedOrientation, 'horizontal')
|
||||||
: orientation;
|
: orientation;
|
||||||
@@ -82,13 +93,21 @@ export default function RoomCameraPanel({
|
|||||||
setReplayError(null);
|
setReplayError(null);
|
||||||
setReplayBusy(true);
|
setReplayBusy(true);
|
||||||
try {
|
try {
|
||||||
await triggerReplay();
|
const cameraId = selectedReplayCamera === 'all' ? null : selectedReplayCamera;
|
||||||
|
await triggerReplay(cameraId);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setReplayError(err.message);
|
setReplayError(err.message);
|
||||||
} finally {
|
} finally {
|
||||||
setReplayBusy(false);
|
setReplayBusy(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
const handleReplayCameraChange = (event) => {
|
||||||
|
const value = event.target.value;
|
||||||
|
setSelectedReplayCamera(value);
|
||||||
|
if (panelId) {
|
||||||
|
saveReplaySettings((current) => ({ ...(current || {}), [panelId]: value }));
|
||||||
|
}
|
||||||
|
};
|
||||||
const applyOrientation = (next) => {
|
const applyOrientation = (next) => {
|
||||||
setOrientation(next);
|
setOrientation(next);
|
||||||
if (panelId) {
|
if (panelId) {
|
||||||
@@ -109,6 +128,19 @@ export default function RoomCameraPanel({
|
|||||||
<span className="text-xs text-slate-500">{cameras.length}</span>
|
<span className="text-xs text-slate-500">{cameras.length}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap items-center gap-1 text-xs">
|
<div className="flex flex-wrap items-center gap-1 text-xs">
|
||||||
|
<select
|
||||||
|
className="rounded border border-slate-700 bg-black/40 px-1 py-0.5 text-slate-200"
|
||||||
|
value={selectedReplayCamera}
|
||||||
|
onChange={handleReplayCameraChange}
|
||||||
|
aria-label="Replay camera"
|
||||||
|
>
|
||||||
|
<option value="all">All cameras</option>
|
||||||
|
{cameras.map((camera) => (
|
||||||
|
<option key={camera.id} value={camera.id}>
|
||||||
|
{camera.name || camera.id}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={`rounded border px-1.5 py-0.5 ${
|
className={`rounded border px-1.5 py-0.5 ${
|
||||||
|
|||||||
@@ -97,7 +97,7 @@ export function SessionProvider({ children }) {
|
|||||||
homeAssistantSetState: (entityId, state) =>
|
homeAssistantSetState: (entityId, state) =>
|
||||||
emitWithAck('homeAssistant:setState', { entityId, state }),
|
emitWithAck('homeAssistant:setState', { entityId, state }),
|
||||||
setNickname: (nickname) => emitWithAck('nickname:set', { nickname }),
|
setNickname: (nickname) => emitWithAck('nickname:set', { nickname }),
|
||||||
triggerReplay: () => emitWithAck('replay:trigger', {}),
|
triggerReplay: (cameraId = null) => emitWithAck('replay:trigger', { cameraId }),
|
||||||
pushAlert: (alert) =>
|
pushAlert: (alert) =>
|
||||||
setAlerts((prev) => [
|
setAlerts((prev) => [
|
||||||
...prev.slice(-49),
|
...prev.slice(-49),
|
||||||
|
|||||||
Reference in New Issue
Block a user