mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-16 09:31:20 -04:00
pete
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
File diff suppressed because one or more lines are too long
@@ -78,7 +78,7 @@
|
||||
<script defer src="https://analytics.otter.land/script.js" data-website-id="82dd56a5-db44-4279-bd1e-a4d9fee39af7" data-domains="rover.otter.land"></script>
|
||||
<script defer src="https://analytics.otter.land/recorder.js" data-website-id="82dd56a5-db44-4279-bd1e-a4d9fee39af7" data-domains="rover.otter.land" data-sample-rate="0.15" data-mask-level="moderate" data-max-duration="300000"></script>
|
||||
<title>Roomba Rover</title>
|
||||
<script type="module" crossorigin src="/assets/index-BFbU1lYU.js"></script>
|
||||
<script type="module" crossorigin src="/assets/index-B3VrYCWz.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-DwpUkSbG.css">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -58,6 +58,7 @@ let blockedTimer = null;
|
||||
let publisherProcess = null;
|
||||
let publisherRestartTimer = null;
|
||||
let snapshotTimer = null;
|
||||
let vendorStatePromise = Promise.resolve();
|
||||
let lastSnapshotState = null;
|
||||
const snapshotSubscribers = new Map();
|
||||
const socketSnapshotSubscriptions = new Map();
|
||||
@@ -194,10 +195,10 @@ function startPublisher() {
|
||||
const input = addCredentialsToRtsp(state.rtspUri);
|
||||
const output = `srt://127.0.0.1:9000?streamid=publish:${encodeURIComponent(PTZ_STREAM_PATH)}`;
|
||||
/*
|
||||
MediaMTX already treats SRT publishers as trusted local media producers.
|
||||
Copying the autotrack stream keeps the full-resolution camera feed intact;
|
||||
if browser H.265 support becomes a problem later, this is the one place to
|
||||
add a transcode without changing PTZ ownership or UI code.
|
||||
MediaMTX should receive the camera feed exactly as the camera provides it.
|
||||
The WHEP 404 issue is a path mismatch, not a codec problem, so this publisher
|
||||
keeps the autotrack stream untouched and lets playback compatibility be
|
||||
handled separately if it actually becomes the blocker.
|
||||
*/
|
||||
const proc = spawn('ffmpeg', [
|
||||
'-hide_banner',
|
||||
@@ -267,6 +268,18 @@ async function refreshVendorState() {
|
||||
state.ir = ir?.IrLights || ir || null;
|
||||
}
|
||||
|
||||
function serializeVendorState(operation) {
|
||||
/*
|
||||
The Reolink HTTP API can return stale light state when reads and writes are
|
||||
overlapped. Keep spotlight/IR changes in one narrow queue so a button mash
|
||||
becomes ordered camera operations instead of competing Get/Set requests.
|
||||
*/
|
||||
vendorStatePromise = vendorStatePromise
|
||||
.catch(() => {})
|
||||
.then(operation);
|
||||
return vendorStatePromise;
|
||||
}
|
||||
|
||||
async function initialize() {
|
||||
if (!enabled || state.initialized || state.initializing) return;
|
||||
state.initializing = true;
|
||||
@@ -330,15 +343,30 @@ function activateOperator(socket) {
|
||||
});
|
||||
state.operatorSocketId = socket.id;
|
||||
state.deadline = Date.now() + getTurnDurationMs();
|
||||
turnTimer = setTimeout(() => {
|
||||
revokeOperator('turn-expired');
|
||||
advanceQueue('turn-expired');
|
||||
}, getTurnDurationMs());
|
||||
turnTimer = setTimeout(handleTurnDeadline, getTurnDurationMs());
|
||||
socket.emit('ptzCamera:turn', { status: 'active', deadline: state.deadline });
|
||||
events.emit('operator', { socketId: socket.id, action: 'active' });
|
||||
emitChange('operator-active');
|
||||
}
|
||||
|
||||
function handleTurnDeadline() {
|
||||
turnTimer = null;
|
||||
if (!state.operatorSocketId) return;
|
||||
if (state.queue.length > 0) {
|
||||
revokeOperator('turn-expired');
|
||||
advanceQueue('turn-expired');
|
||||
return;
|
||||
}
|
||||
/*
|
||||
A turn timer only matters when somebody else is waiting. If the operator is
|
||||
alone, keep them on the PTZ camera and roll the deadline forward so the UI
|
||||
stays coherent without kicking out the only active viewer.
|
||||
*/
|
||||
state.deadline = Date.now() + getTurnDurationMs();
|
||||
turnTimer = setTimeout(handleTurnDeadline, getTurnDurationMs());
|
||||
emitChange('turn-extended-empty-queue');
|
||||
}
|
||||
|
||||
function advanceQueue(reason = 'advance') {
|
||||
clearBlockedTimer();
|
||||
state.blocked = null;
|
||||
@@ -446,30 +474,43 @@ async function getStatus(socket) {
|
||||
|
||||
async function setSpotlight(socket, payload = {}) {
|
||||
requireOperator(socket);
|
||||
const client = await ensureReolinkClient();
|
||||
const current = (await client.api('GetWhiteLed', { channel: 0 })).WhiteLed;
|
||||
const next = {
|
||||
...current,
|
||||
channel: 0,
|
||||
state: payload.state === undefined ? (current.state ? 0 : 1) : Number(Boolean(payload.state)),
|
||||
};
|
||||
if (Number.isFinite(Number(payload.bright))) {
|
||||
next.bright = Math.max(0, Math.min(100, Number(payload.bright)));
|
||||
}
|
||||
await client.api('SetWhiteLed', { WhiteLed: next });
|
||||
await refreshVendorState();
|
||||
emitChange('light');
|
||||
return state.light;
|
||||
return serializeVendorState(async () => {
|
||||
const client = await ensureReolinkClient();
|
||||
const current = (await client.api('GetWhiteLed', { channel: 0 })).WhiteLed || {};
|
||||
const next = {
|
||||
...current,
|
||||
channel: 0,
|
||||
state: payload.state === undefined ? (current.state ? 0 : 1) : Number(Boolean(payload.state)),
|
||||
};
|
||||
if (Number.isFinite(Number(payload.bright))) {
|
||||
next.bright = Math.max(0, Math.min(100, Number(payload.bright)));
|
||||
}
|
||||
/*
|
||||
Update local state optimistically before the refresh. That makes the
|
||||
headlight button feel deterministic while the follow-up read still lets
|
||||
the camera correct anything it refused or normalized.
|
||||
*/
|
||||
state.light = next;
|
||||
emitChange('light-pending');
|
||||
await client.api('SetWhiteLed', { WhiteLed: next });
|
||||
await refreshVendorState();
|
||||
emitChange('light');
|
||||
return state.light;
|
||||
});
|
||||
}
|
||||
|
||||
async function setIr(socket, payload = {}) {
|
||||
requireOperator(socket);
|
||||
const nextState = String(payload.state || '').toLowerCase() === 'off' ? 'Off' : 'Auto';
|
||||
const client = await ensureReolinkClient();
|
||||
await client.api('SetIrLights', { IrLights: { state: nextState } });
|
||||
await refreshVendorState();
|
||||
emitChange('ir');
|
||||
return state.ir;
|
||||
return serializeVendorState(async () => {
|
||||
const nextState = String(payload.state || '').toLowerCase() === 'off' ? 'Off' : 'Auto';
|
||||
const client = await ensureReolinkClient();
|
||||
state.ir = { ...(state.ir || {}), state: nextState };
|
||||
emitChange('ir-pending');
|
||||
await client.api('SetIrLights', { IrLights: { state: nextState } });
|
||||
await refreshVendorState();
|
||||
emitChange('ir');
|
||||
return state.ir;
|
||||
});
|
||||
}
|
||||
|
||||
function canRequestLiveVideo(socket) {
|
||||
|
||||
@@ -5,6 +5,7 @@ const { loadConfig } = require('../../helpers/configLoader');
|
||||
|
||||
const config = loadConfig();
|
||||
const mediaConfig = config.media || {};
|
||||
const PTZ_STREAM_PATH = 'ptz-camera';
|
||||
|
||||
function getPathPrefix() {
|
||||
const base = mediaConfig.whepBaseUrl;
|
||||
@@ -37,6 +38,14 @@ function extractStreamInfo(path) {
|
||||
const remaining = segments.slice(start, end);
|
||||
if (remaining.length === 1) {
|
||||
const rawId = remaining[0] || '';
|
||||
/*
|
||||
PTZ is intentionally published as a flat MediaMTX path so WHEP requests
|
||||
line up with the real stream name. Treat that one reserved path as PTZ
|
||||
before falling back to the normal one-segment rover parsing rules.
|
||||
*/
|
||||
if (rawId === PTZ_STREAM_PATH) {
|
||||
return { type: 'ptz', id: rawId };
|
||||
}
|
||||
if (rawId.endsWith('-fwd')) {
|
||||
return { type: 'rover', id: rawId, baseId: rawId.slice(0, -4) };
|
||||
}
|
||||
@@ -81,6 +90,10 @@ function extractStreamInfoFromBody(body = {}) {
|
||||
extractSrtStreamId(body.query);
|
||||
if (!srtId) return null;
|
||||
|
||||
if (srtId === PTZ_STREAM_PATH) {
|
||||
return { type: 'ptz', id: srtId };
|
||||
}
|
||||
|
||||
if (srtId.endsWith('-fwd')) {
|
||||
return { type: 'rover', id: srtId, baseId: srtId.slice(0, -4) };
|
||||
}
|
||||
|
||||
@@ -36,7 +36,14 @@ function buildWhepUrlForSource(source) {
|
||||
if (source.type === 'room') {
|
||||
segments.push('room', encodeURIComponent(source.id));
|
||||
} else if (source.type === 'ptz') {
|
||||
segments.push('ptz', encodeURIComponent(source.id));
|
||||
/*
|
||||
MediaMTX exposes WHEP by the exact path name that is being published.
|
||||
The PTZ ffmpeg publisher registers the single camera as "ptz-camera",
|
||||
so the browser must request "/video/ptz-camera/whep" instead of a
|
||||
namespace-like "/video/ptz/ptz-camera/whep" path that MediaMTX has never
|
||||
seen and correctly returns as 404.
|
||||
*/
|
||||
segments.push(encodeURIComponent(source.id));
|
||||
} else {
|
||||
segments.push(encodeURIComponent(source.id));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user