mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-16 01:21:20 -04:00
aeaeawa
This commit is contained in:
@@ -34,8 +34,6 @@ const SNAPSHOT_STREAM_INTERVAL_MS = 2000;
|
||||
const SPOTLIGHT_VERIFY_DELAY_MS = 1200;
|
||||
const PUBLISHER_STDERR_SYNC_MS = 10000;
|
||||
const PUBLISHER_RTSP_TIMEOUT_US = 10000000;
|
||||
const REOLINK_API_RETRY_MS = 1000;
|
||||
const REOLINK_API_WRITE_INTERVAL_MS = 1000;
|
||||
|
||||
const events = new EventEmitter();
|
||||
const config = loadConfig();
|
||||
@@ -74,8 +72,6 @@ const state = {
|
||||
reolinkApi: {
|
||||
connected: false,
|
||||
connecting: false,
|
||||
retryCount: 0,
|
||||
retryAt: null,
|
||||
lastError: null,
|
||||
lastConnectedAt: null,
|
||||
lastEvent: 'idle',
|
||||
@@ -83,7 +79,6 @@ const state = {
|
||||
};
|
||||
|
||||
let onvifCam = null;
|
||||
let reolinkClient = null;
|
||||
let reolinkModulePromise = null;
|
||||
let turnTimer = null;
|
||||
let publisherProcess = null;
|
||||
@@ -92,9 +87,6 @@ let publisherStderrSyncTimer = null;
|
||||
let snapshotTimer = null;
|
||||
let spotlightVerifyTimer = null;
|
||||
let vendorStatePromise = Promise.resolve();
|
||||
let reolinkApiLogNextAt = 0;
|
||||
let reolinkWriteInFlight = false;
|
||||
let lastReolinkWriteAcceptedAt = 0;
|
||||
let lastSnapshotState = null;
|
||||
const snapshotSubscribers = new Map();
|
||||
const socketSnapshotSubscriptions = new Map();
|
||||
@@ -133,12 +125,6 @@ function updateReolinkApiState(patch = {}, reason = 'reolink-api') {
|
||||
emitChange(reason);
|
||||
}
|
||||
|
||||
function sleep(ms) {
|
||||
return new Promise((resolve) => {
|
||||
setTimeout(resolve, ms);
|
||||
});
|
||||
}
|
||||
|
||||
function parsePublisherProgressLine(line) {
|
||||
/*
|
||||
ffmpeg's "-progress pipe:2" emits simple key=value telemetry on stderr.
|
||||
@@ -651,10 +637,10 @@ function getErrorMessage(err) {
|
||||
|
||||
async function closeReolinkClient(client, reason = 'reset') {
|
||||
/*
|
||||
The Reolink SDK keeps a long-mode login session and may also own background
|
||||
resources internally. close() is documented as idempotent, so use it as the
|
||||
preferred cleanup path and ignore cleanup failures because the whole point
|
||||
of this branch is that the old client/session may already be broken.
|
||||
Each Reolink operation owns a short-lived long-mode session. close() logs
|
||||
out and frees any SDK resources; cleanup failures are logged at debug level
|
||||
because the command result has already been determined by the time finally
|
||||
cleanup runs.
|
||||
*/
|
||||
if (!client || typeof client.close !== 'function') return;
|
||||
try {
|
||||
@@ -664,18 +650,13 @@ async function closeReolinkClient(client, reason = 'reset') {
|
||||
}
|
||||
}
|
||||
|
||||
async function resetReolinkClient(reason = 'reset') {
|
||||
const previous = reolinkClient;
|
||||
reolinkClient = null;
|
||||
await closeReolinkClient(previous, reason);
|
||||
}
|
||||
|
||||
async function createReolinkClient() {
|
||||
async function createReolinkClientSession() {
|
||||
/*
|
||||
reolink-nvr-api is published as an ESM-only package. This server is still
|
||||
CommonJS, so a top-level require() fails before the service can even start.
|
||||
Dynamic import keeps the server bootable and only loads the vendor SDK when
|
||||
spotlight or IR state is actually queried.
|
||||
Dynamic import keeps the server bootable while still creating a fresh camera
|
||||
API client for every command. Avoiding a cached long-lived client keeps one
|
||||
wedged Reolink session from poisoning later light/IR commands.
|
||||
*/
|
||||
if (!reolinkModulePromise) {
|
||||
reolinkModulePromise = import('reolink-nvr-api');
|
||||
@@ -690,88 +671,63 @@ async function createReolinkClient() {
|
||||
timeout: 10000,
|
||||
});
|
||||
await client.login();
|
||||
reolinkClient = client;
|
||||
updateReolinkApiState({
|
||||
connected: true,
|
||||
connecting: false,
|
||||
retryAt: null,
|
||||
lastError: null,
|
||||
lastConnectedAt: Date.now(),
|
||||
lastEvent: 'connected',
|
||||
}, 'reolink-api-connected');
|
||||
return reolinkClient;
|
||||
return client;
|
||||
}
|
||||
|
||||
async function ensureReolinkClient() {
|
||||
if (reolinkClient) return reolinkClient;
|
||||
async function callReolinkApi(command, payload = {}) {
|
||||
/*
|
||||
Commands should not depend on the previous command's session or observed
|
||||
state. Open a fresh Reolink session, send exactly the requested API command,
|
||||
and close it. If the camera rejects or ignores the command, the failure is
|
||||
allowed to surface to the caller instead of being hidden behind retries that
|
||||
can make the UI look successful while the physical emitter never changed.
|
||||
*/
|
||||
if (!enabled) throw new Error('PTZ camera disabled');
|
||||
updateReolinkApiState({
|
||||
connected: false,
|
||||
connecting: true,
|
||||
lastEvent: 'connecting',
|
||||
}, 'reolink-api-connecting');
|
||||
return createReolinkClient();
|
||||
}
|
||||
|
||||
async function callReolinkApi(command, payload = {}) {
|
||||
/*
|
||||
Reolink's HTTP API is stateful in long mode. When the camera restarts or the
|
||||
SDK session wedges, retrying the same cached client can leave all later
|
||||
light/IR operations dead until the Node process restarts. This loop treats
|
||||
any API/login failure as a disposable session, creates a fresh client, and
|
||||
retries at one fixed cadence until the command succeeds. The caller usually
|
||||
sits inside serializeVendorState(), so rapid UI toggles stay ordered behind
|
||||
the reconnecting operation instead of racing multiple login attempts.
|
||||
*/
|
||||
while (enabled) {
|
||||
let client = null;
|
||||
try {
|
||||
const client = await ensureReolinkClient();
|
||||
client = await createReolinkClientSession();
|
||||
const result = await client.api(command, payload);
|
||||
updateReolinkApiState({
|
||||
connected: true,
|
||||
connected: false,
|
||||
connecting: false,
|
||||
retryAt: null,
|
||||
lastError: null,
|
||||
lastConnectedAt: Date.now(),
|
||||
lastEvent: 'api-ok',
|
||||
lastEvent: 'api-ok-closed',
|
||||
}, 'reolink-api-ok');
|
||||
return result;
|
||||
} catch (err) {
|
||||
const message = getErrorMessage(err);
|
||||
const retryAt = Date.now() + REOLINK_API_RETRY_MS;
|
||||
/*
|
||||
Do not let one bad long-mode session poison the whole service. Clearing
|
||||
the cached client before the fixed sleep makes the next loop iteration
|
||||
perform a full login rather than reusing the session that just failed.
|
||||
*/
|
||||
await resetReolinkClient(`api-failed:${command}`);
|
||||
updateReolinkApiState({
|
||||
connected: false,
|
||||
connecting: true,
|
||||
retryCount: Number(state.reolinkApi?.retryCount || 0) + 1,
|
||||
retryAt,
|
||||
connecting: false,
|
||||
lastError: message,
|
||||
lastEvent: 'retrying',
|
||||
}, 'reolink-api-retry');
|
||||
if (Date.now() >= reolinkApiLogNextAt) {
|
||||
reolinkApiLogNextAt = Date.now() + 30000;
|
||||
logger.warn('Reolink API failed; retrying at fixed interval', {
|
||||
command,
|
||||
retryMs: REOLINK_API_RETRY_MS,
|
||||
error: message,
|
||||
});
|
||||
lastEvent: 'api-error',
|
||||
}, 'reolink-api-error');
|
||||
logger.warn('Reolink API command failed', { command, error: message });
|
||||
throw err;
|
||||
} finally {
|
||||
await closeReolinkClient(client, `api:${command}`);
|
||||
}
|
||||
await sleep(REOLINK_API_RETRY_MS);
|
||||
}
|
||||
}
|
||||
throw new Error('PTZ camera disabled');
|
||||
}
|
||||
|
||||
async function refreshVendorState() {
|
||||
if (!enabled) return;
|
||||
/*
|
||||
Read these sequentially so a failed request can reset and rebuild the SDK
|
||||
client before the next request starts. Running both in parallel would let
|
||||
two calls fight over the same broken cached client during reconnect.
|
||||
Read these sequentially so the camera sees one fresh-session API request at
|
||||
a time. Parallel reads are not useful here, and avoiding overlap keeps the
|
||||
vendor API behavior easier to reason about when it is already acting flaky.
|
||||
*/
|
||||
const white = await callReolinkApi('GetWhiteLed', { channel: 0 });
|
||||
const ir = await callReolinkApi('GetIrLights', { channel: 0 });
|
||||
@@ -818,47 +774,6 @@ function serializeVendorState(operation) {
|
||||
return vendorStatePromise;
|
||||
}
|
||||
|
||||
function reserveReolinkWriteSlot(control) {
|
||||
/*
|
||||
Reolink light/IR writes are physical camera API writes, not high-frequency
|
||||
control signals. Rejecting too-fast requests here, before they enter
|
||||
serializeVendorState(), is what makes this a true rate limit instead of a
|
||||
delayed queue. A request either gets the current write slot immediately or
|
||||
fails immediately; the server never stores skipped toggle states to replay
|
||||
later.
|
||||
*/
|
||||
if (reolinkWriteInFlight) {
|
||||
const err = new Error('PTZ API rate limited');
|
||||
err.code = 'PTZ_REOLINK_RATE_LIMITED';
|
||||
err.control = control;
|
||||
err.retryAfterMs = REOLINK_API_WRITE_INTERVAL_MS;
|
||||
throw err;
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
const elapsed = now - lastReolinkWriteAcceptedAt;
|
||||
if (elapsed < REOLINK_API_WRITE_INTERVAL_MS) {
|
||||
const err = new Error('PTZ API rate limited');
|
||||
err.code = 'PTZ_REOLINK_RATE_LIMITED';
|
||||
err.control = control;
|
||||
err.retryAfterMs = REOLINK_API_WRITE_INTERVAL_MS - elapsed;
|
||||
throw err;
|
||||
}
|
||||
|
||||
reolinkWriteInFlight = true;
|
||||
lastReolinkWriteAcceptedAt = now;
|
||||
}
|
||||
|
||||
function releaseReolinkWriteSlot() {
|
||||
/*
|
||||
Keep the in-flight flag separate from the timestamp. The flag blocks
|
||||
overlap while an accepted write is still talking to the camera or waiting
|
||||
through API reconnect; the timestamp blocks a second accepted write from
|
||||
starting immediately after the first one finishes.
|
||||
*/
|
||||
reolinkWriteInFlight = false;
|
||||
}
|
||||
|
||||
async function initialize() {
|
||||
if (!enabled || state.initialized || state.initializing) return;
|
||||
state.initializing = true;
|
||||
@@ -868,10 +783,8 @@ async function initialize() {
|
||||
/*
|
||||
Reolink light/IR state is useful, but it must not block PTZ startup. The
|
||||
vendor API can be unavailable while ONVIF and RTSP are already healthy;
|
||||
because callReolinkApi() retries until reconnect, awaiting this refresh
|
||||
here would keep the publisher and queue disabled until the HTTP API comes
|
||||
back. Queue it instead so later light/IR operations naturally wait behind
|
||||
the reconnecting refresh while video startup continues.
|
||||
awaiting this refresh here would keep the publisher and queue disabled
|
||||
until the HTTP API responds. Queue it instead so video startup continues.
|
||||
*/
|
||||
serializeVendorState(() => refreshVendorState()).catch((err) => {
|
||||
logger.warn('initial Reolink state refresh failed', { error: getErrorMessage(err) });
|
||||
@@ -1217,9 +1130,7 @@ async function removePreset(socket, payload = {}) {
|
||||
|
||||
async function setSpotlight(socket, payload = {}) {
|
||||
requireOperator(socket);
|
||||
reserveReolinkWriteSlot('spotlight');
|
||||
return serializeVendorState(async () => {
|
||||
try {
|
||||
let current = state.light ? normalizeSpotlightState(state.light) : null;
|
||||
if (payload.state === undefined && !current) {
|
||||
/*
|
||||
@@ -1248,27 +1159,21 @@ async function setSpotlight(socket, payload = {}) {
|
||||
next.bright = bright;
|
||||
}
|
||||
/*
|
||||
The camera accepts a minimal WhiteLed payload and reports success before
|
||||
GetWhiteLed reflects the new state. Send only the fields we intend to
|
||||
change, then keep the optimistic state until the delayed verification read
|
||||
has a real chance to observe the update.
|
||||
Send the explicit requested state through a fresh API session before
|
||||
changing public state. If the camera/API rejects the command, the UI should
|
||||
not be left showing an optimistic state that never reached the device.
|
||||
*/
|
||||
state.light = next;
|
||||
emitChange('light-pending');
|
||||
await callReolinkApi('SetWhiteLed', { WhiteLed: cameraPayload });
|
||||
state.light = next;
|
||||
emitChange('light');
|
||||
scheduleSpotlightVerification();
|
||||
return state.light;
|
||||
} finally {
|
||||
releaseReolinkWriteSlot();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function setIr(socket, payload = {}) {
|
||||
requireOperator(socket);
|
||||
reserveReolinkWriteSlot('ir');
|
||||
return serializeVendorState(async () => {
|
||||
try {
|
||||
const nextState = normalizeIrState(payload.state);
|
||||
/*
|
||||
The camera requires channel inside IrLights. Without it, SetIrLights
|
||||
@@ -1276,15 +1181,17 @@ async function setIr(socket, payload = {}) {
|
||||
look like the command worked. Keep the optimistic state, but send the
|
||||
minimal payload the camera actually accepts.
|
||||
*/
|
||||
state.ir = { ...(state.ir || {}), channel: 0, state: nextState };
|
||||
emitChange('ir-pending');
|
||||
const next = { ...(state.ir || {}), channel: 0, state: nextState };
|
||||
/*
|
||||
As with spotlight, update session state after the fresh-session command
|
||||
succeeds so a rejected Reolink request does not make the UI claim the IR
|
||||
mode changed when the camera never accepted it.
|
||||
*/
|
||||
await callReolinkApi('SetIrLights', { IrLights: { channel: 0, state: nextState } });
|
||||
await refreshVendorState();
|
||||
state.ir = next;
|
||||
emitChange('ir');
|
||||
await refreshVendorState();
|
||||
return state.ir;
|
||||
} finally {
|
||||
releaseReolinkWriteSlot();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user