This commit is contained in:
legop3
2026-07-12 14:55:21 -04:00
parent 23108241f1
commit 60eacf982c
+47 -140
View File
@@ -34,8 +34,6 @@ const SNAPSHOT_STREAM_INTERVAL_MS = 2000;
const SPOTLIGHT_VERIFY_DELAY_MS = 1200; const SPOTLIGHT_VERIFY_DELAY_MS = 1200;
const PUBLISHER_STDERR_SYNC_MS = 10000; const PUBLISHER_STDERR_SYNC_MS = 10000;
const PUBLISHER_RTSP_TIMEOUT_US = 10000000; const PUBLISHER_RTSP_TIMEOUT_US = 10000000;
const REOLINK_API_RETRY_MS = 1000;
const REOLINK_API_WRITE_INTERVAL_MS = 1000;
const events = new EventEmitter(); const events = new EventEmitter();
const config = loadConfig(); const config = loadConfig();
@@ -74,8 +72,6 @@ const state = {
reolinkApi: { reolinkApi: {
connected: false, connected: false,
connecting: false, connecting: false,
retryCount: 0,
retryAt: null,
lastError: null, lastError: null,
lastConnectedAt: null, lastConnectedAt: null,
lastEvent: 'idle', lastEvent: 'idle',
@@ -83,7 +79,6 @@ const state = {
}; };
let onvifCam = null; let onvifCam = null;
let reolinkClient = null;
let reolinkModulePromise = null; let reolinkModulePromise = null;
let turnTimer = null; let turnTimer = null;
let publisherProcess = null; let publisherProcess = null;
@@ -92,9 +87,6 @@ let publisherStderrSyncTimer = null;
let snapshotTimer = null; let snapshotTimer = null;
let spotlightVerifyTimer = null; let spotlightVerifyTimer = null;
let vendorStatePromise = Promise.resolve(); let vendorStatePromise = Promise.resolve();
let reolinkApiLogNextAt = 0;
let reolinkWriteInFlight = false;
let lastReolinkWriteAcceptedAt = 0;
let lastSnapshotState = null; let lastSnapshotState = null;
const snapshotSubscribers = new Map(); const snapshotSubscribers = new Map();
const socketSnapshotSubscriptions = new Map(); const socketSnapshotSubscriptions = new Map();
@@ -133,12 +125,6 @@ function updateReolinkApiState(patch = {}, reason = 'reolink-api') {
emitChange(reason); emitChange(reason);
} }
function sleep(ms) {
return new Promise((resolve) => {
setTimeout(resolve, ms);
});
}
function parsePublisherProgressLine(line) { function parsePublisherProgressLine(line) {
/* /*
ffmpeg's "-progress pipe:2" emits simple key=value telemetry on stderr. 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') { async function closeReolinkClient(client, reason = 'reset') {
/* /*
The Reolink SDK keeps a long-mode login session and may also own background Each Reolink operation owns a short-lived long-mode session. close() logs
resources internally. close() is documented as idempotent, so use it as the out and frees any SDK resources; cleanup failures are logged at debug level
preferred cleanup path and ignore cleanup failures because the whole point because the command result has already been determined by the time finally
of this branch is that the old client/session may already be broken. cleanup runs.
*/ */
if (!client || typeof client.close !== 'function') return; if (!client || typeof client.close !== 'function') return;
try { try {
@@ -664,18 +650,13 @@ async function closeReolinkClient(client, reason = 'reset') {
} }
} }
async function resetReolinkClient(reason = 'reset') { async function createReolinkClientSession() {
const previous = reolinkClient;
reolinkClient = null;
await closeReolinkClient(previous, reason);
}
async function createReolinkClient() {
/* /*
reolink-nvr-api is published as an ESM-only package. This server is still 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. 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 Dynamic import keeps the server bootable while still creating a fresh camera
spotlight or IR state is actually queried. API client for every command. Avoiding a cached long-lived client keeps one
wedged Reolink session from poisoning later light/IR commands.
*/ */
if (!reolinkModulePromise) { if (!reolinkModulePromise) {
reolinkModulePromise = import('reolink-nvr-api'); reolinkModulePromise = import('reolink-nvr-api');
@@ -690,88 +671,63 @@ async function createReolinkClient() {
timeout: 10000, timeout: 10000,
}); });
await client.login(); await client.login();
reolinkClient = client;
updateReolinkApiState({ updateReolinkApiState({
connected: true, connected: true,
connecting: false, connecting: false,
retryAt: null,
lastError: null, lastError: null,
lastConnectedAt: Date.now(), lastConnectedAt: Date.now(),
lastEvent: 'connected', lastEvent: 'connected',
}, 'reolink-api-connected'); }, 'reolink-api-connected');
return reolinkClient; return client;
} }
async function ensureReolinkClient() { async function callReolinkApi(command, payload = {}) {
if (reolinkClient) return reolinkClient; /*
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({ updateReolinkApiState({
connected: false, connected: false,
connecting: true, connecting: true,
lastEvent: 'connecting', lastEvent: 'connecting',
}, 'reolink-api-connecting'); }, 'reolink-api-connecting');
return createReolinkClient(); let client = null;
}
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) {
try { try {
const client = await ensureReolinkClient(); client = await createReolinkClientSession();
const result = await client.api(command, payload); const result = await client.api(command, payload);
updateReolinkApiState({ updateReolinkApiState({
connected: true, connected: false,
connecting: false, connecting: false,
retryAt: null,
lastError: null, lastError: null,
lastConnectedAt: Date.now(), lastConnectedAt: Date.now(),
lastEvent: 'api-ok', lastEvent: 'api-ok-closed',
}, 'reolink-api-ok'); }, 'reolink-api-ok');
return result; return result;
} catch (err) { } catch (err) {
const message = getErrorMessage(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({ updateReolinkApiState({
connected: false, connected: false,
connecting: true, connecting: false,
retryCount: Number(state.reolinkApi?.retryCount || 0) + 1,
retryAt,
lastError: message, lastError: message,
lastEvent: 'retrying', lastEvent: 'api-error',
}, 'reolink-api-retry'); }, 'reolink-api-error');
if (Date.now() >= reolinkApiLogNextAt) { logger.warn('Reolink API command failed', { command, error: message });
reolinkApiLogNextAt = Date.now() + 30000; throw err;
logger.warn('Reolink API failed; retrying at fixed interval', { } finally {
command, await closeReolinkClient(client, `api:${command}`);
retryMs: REOLINK_API_RETRY_MS,
error: message,
});
} }
await sleep(REOLINK_API_RETRY_MS);
}
}
throw new Error('PTZ camera disabled');
} }
async function refreshVendorState() { async function refreshVendorState() {
if (!enabled) return; if (!enabled) return;
/* /*
Read these sequentially so a failed request can reset and rebuild the SDK Read these sequentially so the camera sees one fresh-session API request at
client before the next request starts. Running both in parallel would let a time. Parallel reads are not useful here, and avoiding overlap keeps the
two calls fight over the same broken cached client during reconnect. vendor API behavior easier to reason about when it is already acting flaky.
*/ */
const white = await callReolinkApi('GetWhiteLed', { channel: 0 }); const white = await callReolinkApi('GetWhiteLed', { channel: 0 });
const ir = await callReolinkApi('GetIrLights', { channel: 0 }); const ir = await callReolinkApi('GetIrLights', { channel: 0 });
@@ -818,47 +774,6 @@ function serializeVendorState(operation) {
return vendorStatePromise; 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() { async function initialize() {
if (!enabled || state.initialized || state.initializing) return; if (!enabled || state.initialized || state.initializing) return;
state.initializing = true; state.initializing = true;
@@ -868,10 +783,8 @@ async function initialize() {
/* /*
Reolink light/IR state is useful, but it must not block PTZ startup. The 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; vendor API can be unavailable while ONVIF and RTSP are already healthy;
because callReolinkApi() retries until reconnect, awaiting this refresh awaiting this refresh here would keep the publisher and queue disabled
here would keep the publisher and queue disabled until the HTTP API comes until the HTTP API responds. Queue it instead so video startup continues.
back. Queue it instead so later light/IR operations naturally wait behind
the reconnecting refresh while video startup continues.
*/ */
serializeVendorState(() => refreshVendorState()).catch((err) => { serializeVendorState(() => refreshVendorState()).catch((err) => {
logger.warn('initial Reolink state refresh failed', { error: getErrorMessage(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 = {}) { async function setSpotlight(socket, payload = {}) {
requireOperator(socket); requireOperator(socket);
reserveReolinkWriteSlot('spotlight');
return serializeVendorState(async () => { return serializeVendorState(async () => {
try {
let current = state.light ? normalizeSpotlightState(state.light) : null; let current = state.light ? normalizeSpotlightState(state.light) : null;
if (payload.state === undefined && !current) { if (payload.state === undefined && !current) {
/* /*
@@ -1248,27 +1159,21 @@ async function setSpotlight(socket, payload = {}) {
next.bright = bright; next.bright = bright;
} }
/* /*
The camera accepts a minimal WhiteLed payload and reports success before Send the explicit requested state through a fresh API session before
GetWhiteLed reflects the new state. Send only the fields we intend to changing public state. If the camera/API rejects the command, the UI should
change, then keep the optimistic state until the delayed verification read not be left showing an optimistic state that never reached the device.
has a real chance to observe the update.
*/ */
state.light = next;
emitChange('light-pending');
await callReolinkApi('SetWhiteLed', { WhiteLed: cameraPayload }); await callReolinkApi('SetWhiteLed', { WhiteLed: cameraPayload });
state.light = next;
emitChange('light');
scheduleSpotlightVerification(); scheduleSpotlightVerification();
return state.light; return state.light;
} finally {
releaseReolinkWriteSlot();
}
}); });
} }
async function setIr(socket, payload = {}) { async function setIr(socket, payload = {}) {
requireOperator(socket); requireOperator(socket);
reserveReolinkWriteSlot('ir');
return serializeVendorState(async () => { return serializeVendorState(async () => {
try {
const nextState = normalizeIrState(payload.state); const nextState = normalizeIrState(payload.state);
/* /*
The camera requires channel inside IrLights. Without it, SetIrLights 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 look like the command worked. Keep the optimistic state, but send the
minimal payload the camera actually accepts. minimal payload the camera actually accepts.
*/ */
state.ir = { ...(state.ir || {}), channel: 0, state: nextState }; const next = { ...(state.ir || {}), channel: 0, state: nextState };
emitChange('ir-pending'); /*
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 callReolinkApi('SetIrLights', { IrLights: { channel: 0, state: nextState } });
await refreshVendorState(); state.ir = next;
emitChange('ir'); emitChange('ir');
await refreshVendorState();
return state.ir; return state.ir;
} finally {
releaseReolinkWriteSlot();
}
}); });
} }