mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-15 17:12:59 -04:00
bwah
This commit is contained in:
@@ -29,12 +29,15 @@ const PTZ_STREAM_PATH = 'ptz-camera';
|
|||||||
const DEFAULT_ONVIF_PORT = 8000;
|
const DEFAULT_ONVIF_PORT = 8000;
|
||||||
const DEFAULT_PROFILE_TOKEN = '003';
|
const DEFAULT_PROFILE_TOKEN = '003';
|
||||||
const DEFAULT_TURN_DURATION_MS = 5 * 60 * 1000;
|
const DEFAULT_TURN_DURATION_MS = 5 * 60 * 1000;
|
||||||
// The TrackMix reports PT1S as its minimum supported continuous-move timeout.
|
// The TrackMix exposes pan/tilt and zoom through the same ONVIF method but does
|
||||||
// Browser heartbeats arrive every 250 ms, so 650 ms allows ordinary LAN jitter
|
// not behave as if they were the same kind of motor. Pan/tilt runs smoothly from
|
||||||
// while still issuing an explicit stop well before the camera's own one-second
|
// one long ContinuousMove; zoom advances in command-sized increments. Keep the
|
||||||
// timeout becomes the final safety backstop.
|
// timings separate so zoom can repeat quickly without restarting pan/tilt.
|
||||||
const MOTION_WATCHDOG_MS = 650;
|
const MOTION_WATCHDOG_MS = 650;
|
||||||
const ONVIF_MOTION_TIMEOUT_MS = 1000;
|
const PAN_TILT_TIMEOUT_MS = 10000;
|
||||||
|
const PAN_TILT_RENEW_MS = 8000;
|
||||||
|
const ZOOM_PULSE_TIMEOUT_MS = 1000;
|
||||||
|
const ZOOM_REPEAT_MS = 120;
|
||||||
const STOP_MOTION = Object.freeze({ pan: 0, tilt: 0, zoom: 0 });
|
const STOP_MOTION = Object.freeze({ pan: 0, tilt: 0, zoom: 0 });
|
||||||
// PTZ is a normal replay source now, so capture should be on unless the feature
|
// PTZ is a normal replay source now, so capture should be on unless the feature
|
||||||
// explicitly disables replay for the camera.
|
// explicitly disables replay for the camera.
|
||||||
@@ -100,9 +103,11 @@ let snapshotTimer = null;
|
|||||||
let spotlightVerifyTimer = null;
|
let spotlightVerifyTimer = null;
|
||||||
let vendorStatePromise = Promise.resolve();
|
let vendorStatePromise = Promise.resolve();
|
||||||
let motionWatchdogTimer = null;
|
let motionWatchdogTimer = null;
|
||||||
|
let panTiltRenewTimer = null;
|
||||||
|
let zoomRepeatTimer = null;
|
||||||
let desiredMotion = STOP_MOTION;
|
let desiredMotion = STOP_MOTION;
|
||||||
let desiredMotionVersion = 0;
|
let pendingPanTiltCommand = false;
|
||||||
let appliedMotionVersion = 0;
|
let pendingZoomCommand = false;
|
||||||
let motionCommandPromise = null;
|
let motionCommandPromise = null;
|
||||||
let lastSnapshotState = null;
|
let lastSnapshotState = null;
|
||||||
const snapshotSubscribers = new Map();
|
const snapshotSubscribers = new Map();
|
||||||
@@ -1122,77 +1127,141 @@ function clearMotionWatchdog() {
|
|||||||
motionWatchdogTimer = null;
|
motionWatchdogTimer = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function runMotionCommandPump() {
|
function clearPanTiltRenewal() {
|
||||||
if (motionCommandPromise) return motionCommandPromise;
|
if (!panTiltRenewTimer) return;
|
||||||
|
clearTimeout(panTiltRenewTimer);
|
||||||
|
panTiltRenewTimer = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearZoomRepeat() {
|
||||||
|
if (!zoomRepeatTimer) return;
|
||||||
|
clearInterval(zoomRepeatTimer);
|
||||||
|
zoomRepeatTimer = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function panTiltMatches(left = STOP_MOTION, right = STOP_MOTION) {
|
||||||
|
return left.pan === right.pan && left.tilt === right.tilt;
|
||||||
|
}
|
||||||
|
|
||||||
|
function requestMotionCommands({ panTilt = false, zoom = false } = {}) {
|
||||||
|
pendingPanTiltCommand = pendingPanTiltCommand || panTilt;
|
||||||
|
pendingZoomCommand = pendingZoomCommand || zoom;
|
||||||
|
if (motionCommandPromise || (!pendingPanTiltCommand && !pendingZoomCommand)) {
|
||||||
|
return motionCommandPromise || Promise.resolve();
|
||||||
|
}
|
||||||
|
|
||||||
/*
|
/*
|
||||||
ONVIF requests are asynchronous HTTP/SOAP operations. Starting one request
|
Keep one ONVIF request in flight at a time, but coalesce independently by
|
||||||
per Socket.IO event allowed a quick move/stop/move sequence to overlap at
|
axis. A zoom timer can tick several times while the camera answers one SOAP
|
||||||
the camera, where response order is not a safe proxy for execution order.
|
request; one pending boolean preserves the newest required pulse without
|
||||||
This single pump permits exactly one camera operation at a time. If browser
|
building a delayed command backlog that would continue after release.
|
||||||
heartbeats arrive while it is busy, only the newest complete desired state
|
|
||||||
survives the loop, so intermediate input noise is coalesced rather than
|
|
||||||
replayed after the operator has already released the controls.
|
|
||||||
*/
|
*/
|
||||||
motionCommandPromise = (async () => {
|
motionCommandPromise = (async () => {
|
||||||
while (appliedMotionVersion < desiredMotionVersion) {
|
while (pendingPanTiltCommand || pendingZoomCommand) {
|
||||||
const commandVersion = desiredMotionVersion;
|
const sendPanTilt = pendingPanTiltCommand;
|
||||||
const commandMotion = desiredMotion;
|
pendingPanTiltCommand = false;
|
||||||
try {
|
if (sendPanTilt) {
|
||||||
await initialize();
|
const pan = desiredMotion.pan;
|
||||||
if (isMotionIdle(commandMotion)) {
|
const tilt = desiredMotion.tilt;
|
||||||
await callOnvif('stop', {
|
try {
|
||||||
profileToken: state.profileToken,
|
await initialize();
|
||||||
panTilt: true,
|
if (!pan && !tilt) {
|
||||||
zoom: true,
|
// Stop only pan/tilt. Omitting Zoom is essential because a zoom
|
||||||
});
|
// finger/key may still be held while direction returns to neutral.
|
||||||
} else {
|
await callOnvif('stop', {
|
||||||
/*
|
profileToken: state.profileToken,
|
||||||
Send the complete vector even when only one axis changed. The live
|
panTilt: true,
|
||||||
TrackMix accepts combined pan/tilt/zoom despite failing to advertise
|
zoom: false,
|
||||||
its continuous zoom space, and zero on an axis is how the newest
|
});
|
||||||
intent releases that axis without disturbing a non-zero sibling.
|
} else {
|
||||||
*/
|
await callOnvif('continuousMove', {
|
||||||
await callOnvif('continuousMove', {
|
profileToken: state.profileToken,
|
||||||
profileToken: state.profileToken,
|
x: pan,
|
||||||
x: commandMotion.pan,
|
y: tilt,
|
||||||
y: commandMotion.tilt,
|
onlySendPanTilt: true,
|
||||||
zoom: commandMotion.zoom,
|
timeout: PAN_TILT_TIMEOUT_MS,
|
||||||
timeout: ONVIF_MOTION_TIMEOUT_MS,
|
});
|
||||||
});
|
}
|
||||||
|
} catch (err) {
|
||||||
|
logger.warn('PTZ pan/tilt command failed', { error: getErrorMessage(err), pan, tilt });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const sendZoom = pendingZoomCommand;
|
||||||
|
pendingZoomCommand = false;
|
||||||
|
if (sendZoom) {
|
||||||
|
const zoom = desiredMotion.zoom;
|
||||||
|
try {
|
||||||
|
await initialize();
|
||||||
|
if (!zoom) {
|
||||||
|
// Stop only zoom so repeated zoom release cannot interrupt a smooth
|
||||||
|
// pan/tilt ContinuousMove that is already running on the camera.
|
||||||
|
await callOnvif('stop', {
|
||||||
|
profileToken: state.profileToken,
|
||||||
|
panTilt: false,
|
||||||
|
zoom: true,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
/*
|
||||||
|
The TrackMix does not advertise continuous zoom, but physical
|
||||||
|
testing showed each accepted zoom-only ContinuousMove advances one
|
||||||
|
step. Repeating this axis-only request restores responsive zoom
|
||||||
|
without resending or restarting the pan/tilt motor.
|
||||||
|
*/
|
||||||
|
await callOnvif('continuousMove', {
|
||||||
|
profileToken: state.profileToken,
|
||||||
|
zoom,
|
||||||
|
onlySendZoom: true,
|
||||||
|
timeout: ZOOM_PULSE_TIMEOUT_MS,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
logger.warn('PTZ zoom command failed', { error: getErrorMessage(err), zoom });
|
||||||
}
|
}
|
||||||
} catch (err) {
|
|
||||||
/*
|
|
||||||
Mark this version consumed below instead of spinning on a failing
|
|
||||||
camera. A held control supplies another heartbeat and therefore a
|
|
||||||
bounded retry; a failed stop still has the camera's one-second ONVIF
|
|
||||||
timeout as its independent final safety mechanism.
|
|
||||||
*/
|
|
||||||
logger.warn('PTZ motion command failed', {
|
|
||||||
error: getErrorMessage(err),
|
|
||||||
idle: isMotionIdle(commandMotion),
|
|
||||||
version: commandVersion,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
appliedMotionVersion = commandVersion;
|
|
||||||
}
|
}
|
||||||
})().finally(() => {
|
})().finally(() => {
|
||||||
motionCommandPromise = null;
|
motionCommandPromise = null;
|
||||||
// An intent can arrive after the loop condition but before this promise's
|
// Cover an intent arriving between the loop check and promise cleanup.
|
||||||
// finally callback. Recheck the versions so that narrow timing window does
|
if (pendingPanTiltCommand || pendingZoomCommand) requestMotionCommands();
|
||||||
// not strand the newest state without a command pump.
|
|
||||||
if (appliedMotionVersion < desiredMotionVersion) runMotionCommandPump();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return motionCommandPromise;
|
return motionCommandPromise;
|
||||||
}
|
}
|
||||||
|
|
||||||
function queueMotionIntent(motion, reason = 'input') {
|
function armPanTiltRenewal() {
|
||||||
desiredMotion = normalizeMotionIntent(motion);
|
clearPanTiltRenewal();
|
||||||
desiredMotionVersion += 1;
|
if (!desiredMotion.pan && !desiredMotion.tilt) return;
|
||||||
|
/*
|
||||||
|
The camera requires a finite timeout. Renew close to the ten-second limit,
|
||||||
|
not on every browser heartbeat, so an unusually long hold stays continuous
|
||||||
|
without bringing back the quarter-second motor restarts.
|
||||||
|
*/
|
||||||
|
panTiltRenewTimer = setTimeout(() => {
|
||||||
|
panTiltRenewTimer = null;
|
||||||
|
requestMotionCommands({ panTilt: true });
|
||||||
|
armPanTiltRenewal();
|
||||||
|
}, PAN_TILT_RENEW_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncZoomRepeater() {
|
||||||
|
clearZoomRepeat();
|
||||||
|
if (!desiredMotion.zoom) return;
|
||||||
|
// queueMotionIntent sends the first step once after configuring this timer;
|
||||||
|
// subsequent ticks retain the old fast hold cadence without a double pulse.
|
||||||
|
zoomRepeatTimer = setInterval(() => {
|
||||||
|
requestMotionCommands({ zoom: true });
|
||||||
|
}, ZOOM_REPEAT_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
function queueMotionIntent(motion, reason = 'input', options = {}) {
|
||||||
|
const nextMotion = normalizeMotionIntent(motion);
|
||||||
|
const panTiltChanged = !panTiltMatches(nextMotion, desiredMotion);
|
||||||
|
const zoomChanged = nextMotion.zoom !== desiredMotion.zoom;
|
||||||
|
desiredMotion = nextMotion;
|
||||||
clearMotionWatchdog();
|
clearMotionWatchdog();
|
||||||
|
|
||||||
if (!isMotionIdle(desiredMotion)) {
|
if (!isMotionIdle(nextMotion)) {
|
||||||
/*
|
/*
|
||||||
Socket disconnect normally arrives quickly, but it is not a suitable motor
|
Socket disconnect normally arrives quickly, but it is not a suitable motor
|
||||||
safety boundary. Every non-zero browser heartbeat replaces this timer; if
|
safety boundary. Every non-zero browser heartbeat replaces this timer; if
|
||||||
@@ -1205,7 +1274,16 @@ function queueMotionIntent(motion, reason = 'input') {
|
|||||||
}, MOTION_WATCHDOG_MS);
|
}, MOTION_WATCHDOG_MS);
|
||||||
}
|
}
|
||||||
|
|
||||||
const pending = runMotionCommandPump();
|
/*
|
||||||
|
Identical browser heartbeats refresh only the watchdog. They must not touch
|
||||||
|
either motor scheduler: pan/tilt already has a long continuous command, and
|
||||||
|
zoom has its own 120 ms axis-only repeater.
|
||||||
|
*/
|
||||||
|
const sendPanTilt = panTiltChanged || Boolean(options.forceAxes);
|
||||||
|
const sendZoom = zoomChanged || Boolean(options.forceAxes);
|
||||||
|
if (sendPanTilt) armPanTiltRenewal();
|
||||||
|
if (sendZoom) syncZoomRepeater();
|
||||||
|
const pending = requestMotionCommands({ panTilt: sendPanTilt, zoom: sendZoom });
|
||||||
pending.catch(() => {});
|
pending.catch(() => {});
|
||||||
return { ok: true, motion: desiredMotion, reason };
|
return { ok: true, motion: desiredMotion, reason };
|
||||||
}
|
}
|
||||||
@@ -1217,12 +1295,14 @@ function acceptMotionIntent(socket, payload = {}) {
|
|||||||
|
|
||||||
function forceMotionStop(reason = 'safety-stop') {
|
function forceMotionStop(reason = 'safety-stop') {
|
||||||
/*
|
/*
|
||||||
Lifecycle stops intentionally increment the version even when local state is
|
Lifecycle stops force both axis-specific Stop commands even when local state
|
||||||
already zero. The browser may have lost its final packet, or the camera may
|
is already zero. The browser may have lost its final packet, or the camera
|
||||||
have accepted a command whose response has not returned, so deduplicating a
|
may have accepted a command whose response has not returned, so deduplicating
|
||||||
safety stop would trust precisely the state we are trying to recover from.
|
a safety stop would trust precisely the state we are trying to recover from.
|
||||||
*/
|
*/
|
||||||
queueMotionIntent(STOP_MOTION, reason);
|
clearPanTiltRenewal();
|
||||||
|
clearZoomRepeat();
|
||||||
|
queueMotionIntent(STOP_MOTION, reason, { forceAxes: true });
|
||||||
return motionCommandPromise || Promise.resolve();
|
return motionCommandPromise || Promise.resolve();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user