@camstack/addon-provider-reolink 1.2.160 → 1.2.162
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/BaichuanVideoStream-UG3DIMIE-BRbIHCdL.mjs +2 -0
- package/dist/BaichuanVideoStream-UG3DIMIE-DViuQDJ4.js +3 -0
- package/dist/addon.js +2472 -573
- package/dist/addon.mjs +2312 -413
- package/dist/{chunk-ZBACPERX-cDJK1KXE.mjs → chunk-OSVYEFMM-Bl0F4OJc.mjs} +5 -4
- package/dist/{chunk-ZBACPERX-BM6Ll-rh.js → chunk-OSVYEFMM-Czx77gto.js} +10 -3
- package/package.json +5 -2
- package/dist/BaichuanVideoStream-FOE2DBVZ-94yIU75L.mjs +0 -2
- package/dist/BaichuanVideoStream-FOE2DBVZ-DJcEkVak.js +0 -3
- /package/dist/{DiagnosticsTools-ZC7I7QM6-9NV95vRN.mjs → DiagnosticsTools-YIWXO7IJ-9NV95vRN.mjs} +0 -0
- /package/dist/{DiagnosticsTools-ZC7I7QM6-CD4rRF8a.js → DiagnosticsTools-YIWXO7IJ-CD4rRF8a.js} +0 -0
package/dist/addon.mjs
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { a as __toCommonJS, i as __require, n as __esmMin, o as __toESM, r as __exportAll, t as __commonJSMin } from "./chunk-CNf5ZN-e.mjs";
|
|
2
|
-
import { A as
|
|
2
|
+
import { A as recordingsTraceLog, C as extractPpsFromAnnexB, D as isH265Irap, E as getH265NalType, M as splitAnnexBToNalPayloads2, N as talkTraceLog, O as md5StrModern, P as traceLog, S as eventTraceLog, T as extractVpsFromAnnexB, _ as bcHeaderHasPayloadOffset, a as BC_MAGIC, b as debugLog, c as BaichuanVideoStream, f as __require$1, g as bcEncrypt, h as bcDecrypt, i as BC_CLASS_MODERN_24, j as splitAnnexBToNalPayloads, k as normalizeDebugOptions, l as BcMediaAnnexBDecoder, m as aesEncrypt, n as BC_CLASS_LEGACY, o as BC_MAGIC_REV, p as aesDecrypt, r as BC_CLASS_MODERN_20, t as BC_CLASS_FILE_DOWNLOAD, v as convertToAnnexB, w as extractSpsFromAnnexB, x as deriveAesKey, y as convertToAnnexB2 } from "./chunk-OSVYEFMM-Bl0F4OJc.mjs";
|
|
3
3
|
import { createHash, randomBytes } from "node:crypto";
|
|
4
4
|
import { EventEmitter } from "events";
|
|
5
5
|
import * as fs2 from "fs";
|
|
@@ -5386,7 +5386,7 @@ var ZodIssueCode = {
|
|
|
5386
5386
|
var ZodFirstPartyTypeKind;
|
|
5387
5387
|
ZodFirstPartyTypeKind || (ZodFirstPartyTypeKind = {});
|
|
5388
5388
|
//#endregion
|
|
5389
|
-
//#region ../types/dist/sleep-
|
|
5389
|
+
//#region ../types/dist/sleep-i3eUVc-d.mjs
|
|
5390
5390
|
/**
|
|
5391
5391
|
* The audio chunk plane's byte format, and the ONE expansion from a coded
|
|
5392
5392
|
* window to float samples (D455).
|
|
@@ -14149,6 +14149,210 @@ method(object({ integrationId: string() }), object({ filters: array(AdoptionFilt
|
|
|
14149
14149
|
auth: "admin"
|
|
14150
14150
|
});
|
|
14151
14151
|
/**
|
|
14152
|
+
* device-admin-link — "this device has a management page of its own, and here
|
|
14153
|
+
* is its address".
|
|
14154
|
+
*
|
|
14155
|
+
* ## Why this is not a `deviceConfig` cap
|
|
14156
|
+
*
|
|
14157
|
+
* There is nothing to edit. A `deviceConfig` cap (D14) exists so the framework
|
|
14158
|
+
* can DERIVE a settings form from `getOptions` + `getStatus` and route a flat
|
|
14159
|
+
* patch back through a setter; it costs a `builderId` reducer in
|
|
14160
|
+
* `device-config-contribution.ts` and a `*-config-schema.ts` beside it, and it
|
|
14161
|
+
* renders a form section. This cap answers ONE question with ONE read and
|
|
14162
|
+
* renders a button. Nothing about it is a form, so it carries no `deviceConfig`
|
|
14163
|
+
* block, no `settings`, no `runtimeState` and no reducer — exactly like
|
|
14164
|
+
* `reboot`, the other pure-RPC device-native cap.
|
|
14165
|
+
*
|
|
14166
|
+
* ## Absent, and the difference between "no page" and "we cannot say"
|
|
14167
|
+
*
|
|
14168
|
+
* The two are answered at DIFFERENT layers, on purpose:
|
|
14169
|
+
*
|
|
14170
|
+
* - **"We cannot say"** → the provider never registers the cap for that
|
|
14171
|
+
* device. A VeSync humidifier, a Petkit feeder, a Dreame vacuum and a Dreo
|
|
14172
|
+
* fan are reached only through a vendor cloud; there is no address to hand
|
|
14173
|
+
* out and no page to open. A Tuya plug, a Wyze camera and a Gree air
|
|
14174
|
+
* conditioner DO have a LAN IP, and still have no HTTP management page
|
|
14175
|
+
* behind it. None of them register, so `deviceManager.getBindings` never
|
|
14176
|
+
* lists the cap and no surface asks.
|
|
14177
|
+
* - **"This device has no page, and I know that"** → the provider registers
|
|
14178
|
+
* and `getAdminLink` returns `null`. This is the answer for a device whose
|
|
14179
|
+
* sibling DOES have a page: a Reolink battery camera reached over UDP by
|
|
14180
|
+
* `uid` with a blank `host`, an Ecowitt gateway configured in `listener`
|
|
14181
|
+
* transport, a Home Assistant broker authenticated by supervisor token
|
|
14182
|
+
* (which carries no `baseUrl` at all).
|
|
14183
|
+
*
|
|
14184
|
+
* Both draw NOTHING. A button that opens a browser error is worse than no
|
|
14185
|
+
* button, and D62 is the same rule from the other side: an off switch is
|
|
14186
|
+
* reported off, never made to look broken. There is no third state where the
|
|
14187
|
+
* UI renders a disabled button "because the device might have a page".
|
|
14188
|
+
*
|
|
14189
|
+
* ## The URL never carries credentials
|
|
14190
|
+
*
|
|
14191
|
+
* Not in userinfo, not in a query string. Every provider builds through
|
|
14192
|
+
* `buildDeviceAdminUrl` (`device-admin-link-url.ts`), which takes host, port,
|
|
14193
|
+
* scheme and path as separate arguments — there is no parameter a secret could
|
|
14194
|
+
* arrive in — and re-checks its own output for the `scheme://user:pass@` shape
|
|
14195
|
+
* that `scripts/check-no-credential-urls-in-fixtures.ts` bans from recorded
|
|
14196
|
+
* output. `scripts/check-admin-link-builder-is-the-only-url-source.ts` is what
|
|
14197
|
+
* keeps providers from hand-rolling one anyway.
|
|
14198
|
+
*
|
|
14199
|
+
* This matters here more than anywhere else in the repo, because every provider
|
|
14200
|
+
* that knows a device's host knows its PASSWORD too: `{ host, port, username,
|
|
14201
|
+
* password }` sit in one object on Hikvision, Amcrest, Reolink and ONVIF alike,
|
|
14202
|
+
* and `http://admin:hunter2@192.168.50.139/` is a URL a browser accepts. The
|
|
14203
|
+
* camera's own page will ask for its own login. That is correct, and pre-
|
|
14204
|
+
* filling it is the operator's business, not ours.
|
|
14205
|
+
*
|
|
14206
|
+
* ## It is a LAN fact
|
|
14207
|
+
*
|
|
14208
|
+
* The URL addresses the device where the NODE can see it. It is not proxied,
|
|
14209
|
+
* not made reachable from outside, and not sent anywhere. A surface renders it
|
|
14210
|
+
* as a link the operator's own browser follows, on the operator's own network,
|
|
14211
|
+
* or renders nothing.
|
|
14212
|
+
*/
|
|
14213
|
+
/**
|
|
14214
|
+
* Whose page is it. The distinction is for the OPERATOR, who needs to know
|
|
14215
|
+
* before clicking whether he is about to land on a camera's own web server or
|
|
14216
|
+
* inside Home Assistant.
|
|
14217
|
+
*/
|
|
14218
|
+
var AdminLinkTargetEnum = _enum(["device", "integration"]);
|
|
14219
|
+
var DeviceAdminLinkSchema = object({
|
|
14220
|
+
/**
|
|
14221
|
+
* Absolute `http(s)://` URL. Built by `buildDeviceAdminUrl` and therefore
|
|
14222
|
+
* free of userinfo and of any credential-shaped query key.
|
|
14223
|
+
*/
|
|
14224
|
+
url: string(),
|
|
14225
|
+
/**
|
|
14226
|
+
* What the surface calls it — "Web UI", "Home Assistant", "UniFi controller".
|
|
14227
|
+
* The PROVIDER names it, because only the provider knows what the page is;
|
|
14228
|
+
* a UI that invented the label from the addon id would call the Home
|
|
14229
|
+
* Assistant device page "Provider Homeassistant".
|
|
14230
|
+
*/
|
|
14231
|
+
label: string(),
|
|
14232
|
+
target: AdminLinkTargetEnum,
|
|
14233
|
+
/**
|
|
14234
|
+
* Host the URL points at, without scheme, port or path — for the tooltip, so
|
|
14235
|
+
* an operator can see WHERE the button goes before he follows it. Redundant
|
|
14236
|
+
* with `url` by construction; carried separately so no surface has to parse
|
|
14237
|
+
* a URL to show it.
|
|
14238
|
+
*/
|
|
14239
|
+
host: string()
|
|
14240
|
+
});
|
|
14241
|
+
var deviceAdminLinkCapability = {
|
|
14242
|
+
name: "device-admin-link",
|
|
14243
|
+
scope: "device",
|
|
14244
|
+
deviceNative: true,
|
|
14245
|
+
mode: "singleton",
|
|
14246
|
+
methods: {
|
|
14247
|
+
/**
|
|
14248
|
+
* The device's management page, or `null` when this device has none.
|
|
14249
|
+
*
|
|
14250
|
+
* `auth: 'admin'` deliberately. This is administration, not actuation —
|
|
14251
|
+
* the same bucket as `reboot` and `camera-credentials`, and explicitly NOT
|
|
14252
|
+
* the actuation set `scripts/check-actuation-not-admin.ts` protects (D403).
|
|
14253
|
+
* The URL is also a statement about the LAN, which a household member with
|
|
14254
|
+
* a `view` grant on a light has no reason to be handed.
|
|
14255
|
+
*
|
|
14256
|
+
* The surfaces gate on the QUERY, never on a role they guessed: a caller
|
|
14257
|
+
* without the right loses the query and draws nothing, which is the same
|
|
14258
|
+
* thing a device with no page draws. There is no path on which a button
|
|
14259
|
+
* appears and then fails — the D403 failure mode, from the other end.
|
|
14260
|
+
*/
|
|
14261
|
+
getAdminLink: method(object({ deviceId: number().int().nonnegative() }), DeviceAdminLinkSchema.nullable(), { auth: "admin" }) }
|
|
14262
|
+
};
|
|
14263
|
+
/**
|
|
14264
|
+
* Query keys that may never appear on an admin link. A credential smuggled as
|
|
14265
|
+
* `?password=` is the same leak as userinfo, in a form the userinfo check
|
|
14266
|
+
* cannot see: it survives copy-paste, referrer headers and proxy logs
|
|
14267
|
+
* identically.
|
|
14268
|
+
*/
|
|
14269
|
+
var CREDENTIAL_QUERY_KEYS = [
|
|
14270
|
+
"password",
|
|
14271
|
+
"passwd",
|
|
14272
|
+
"pwd",
|
|
14273
|
+
"pass",
|
|
14274
|
+
"user",
|
|
14275
|
+
"username",
|
|
14276
|
+
"usr",
|
|
14277
|
+
"login",
|
|
14278
|
+
"token",
|
|
14279
|
+
"access_token",
|
|
14280
|
+
"auth",
|
|
14281
|
+
"authorization",
|
|
14282
|
+
"apikey",
|
|
14283
|
+
"api_key",
|
|
14284
|
+
"secret",
|
|
14285
|
+
"credential",
|
|
14286
|
+
"credentials",
|
|
14287
|
+
"session",
|
|
14288
|
+
"sessionid",
|
|
14289
|
+
"key"
|
|
14290
|
+
];
|
|
14291
|
+
/**
|
|
14292
|
+
* The same userinfo shape `scripts/check-no-credential-urls-in-fixtures.ts`
|
|
14293
|
+
* scans recorded fixtures for, applied to what we are about to EMIT. Kept
|
|
14294
|
+
* structurally identical on purpose: a URL this function returns is a URL that
|
|
14295
|
+
* guard would pass.
|
|
14296
|
+
*/
|
|
14297
|
+
var CREDENTIAL_URL = /[a-z][a-z0-9+.-]*:\/\/[^/\s:@'"]+:[^/\s:@'"]+@/i;
|
|
14298
|
+
/** A host that is safe to place in an authority component verbatim. */
|
|
14299
|
+
var PLAIN_HOST = /^[a-zA-Z0-9._-]+$/;
|
|
14300
|
+
/** A bracketed IPv6 literal, the only other authority form we emit. */
|
|
14301
|
+
var IPV6_HOST = /^\[[0-9a-fA-F:.]+\]$/;
|
|
14302
|
+
function defaultPortFor(scheme) {
|
|
14303
|
+
return scheme === "https" ? 443 : 80;
|
|
14304
|
+
}
|
|
14305
|
+
/**
|
|
14306
|
+
* Build the admin-page URL, or refuse by name.
|
|
14307
|
+
*
|
|
14308
|
+
* The refusal is never thrown: a provider answering "no page for this device"
|
|
14309
|
+
* is an ordinary answer (`getAdminLink` → `null`), and a throw here would turn
|
|
14310
|
+
* a missing host on one device into a failed query for the whole surface.
|
|
14311
|
+
*/
|
|
14312
|
+
function buildDeviceAdminUrl(parts) {
|
|
14313
|
+
const host = parts.host.trim();
|
|
14314
|
+
if (host.length === 0) return {
|
|
14315
|
+
ok: false,
|
|
14316
|
+
reason: "empty-host"
|
|
14317
|
+
};
|
|
14318
|
+
if (host.includes("@")) return {
|
|
14319
|
+
ok: false,
|
|
14320
|
+
reason: "host-carries-userinfo"
|
|
14321
|
+
};
|
|
14322
|
+
if (!PLAIN_HOST.test(host) && !IPV6_HOST.test(host)) return {
|
|
14323
|
+
ok: false,
|
|
14324
|
+
reason: "host-not-plain"
|
|
14325
|
+
};
|
|
14326
|
+
if (parts.port !== null) {
|
|
14327
|
+
if (!Number.isInteger(parts.port) || parts.port < 1 || parts.port > 65535) return {
|
|
14328
|
+
ok: false,
|
|
14329
|
+
reason: "port-out-of-range"
|
|
14330
|
+
};
|
|
14331
|
+
}
|
|
14332
|
+
if (!parts.path.startsWith("/")) return {
|
|
14333
|
+
ok: false,
|
|
14334
|
+
reason: "path-not-absolute"
|
|
14335
|
+
};
|
|
14336
|
+
const query = parts.query ?? {};
|
|
14337
|
+
for (const key of Object.keys(query)) if (CREDENTIAL_QUERY_KEYS.includes(key.toLowerCase())) return {
|
|
14338
|
+
ok: false,
|
|
14339
|
+
reason: "credential-query-key"
|
|
14340
|
+
};
|
|
14341
|
+
const authority = parts.port === null || parts.port === defaultPortFor(parts.scheme) ? host : `${host}:${String(parts.port)}`;
|
|
14342
|
+
const search = new URLSearchParams();
|
|
14343
|
+
for (const [key, value] of Object.entries(query)) search.append(key, value);
|
|
14344
|
+
const suffix = search.size > 0 ? `?${search.toString()}` : "";
|
|
14345
|
+
const url = `${parts.scheme}://${authority}${parts.path}${suffix}`;
|
|
14346
|
+
if (CREDENTIAL_URL.test(url)) return {
|
|
14347
|
+
ok: false,
|
|
14348
|
+
reason: "userinfo-in-result"
|
|
14349
|
+
};
|
|
14350
|
+
return {
|
|
14351
|
+
ok: true,
|
|
14352
|
+
url
|
|
14353
|
+
};
|
|
14354
|
+
}
|
|
14355
|
+
/**
|
|
14152
14356
|
* `device-export` — collection cap for addons that export camstack
|
|
14153
14357
|
* devices to external ecosystems (HomeAssistant via MQTT discovery,
|
|
14154
14358
|
* HomeKit/HAP, Alexa Smart Home, …).
|
|
@@ -25151,6 +25355,39 @@ _enum([
|
|
|
25151
25355
|
_enum(["deferred", "final"]);
|
|
25152
25356
|
/** The camera's / provider's own words. Kept verbatim, never parsed. */
|
|
25153
25357
|
var CLIP_STILL_REASON_HEADER = "x-camstack-reason";
|
|
25358
|
+
/**
|
|
25359
|
+
* Make a refusal sentence safe to put in an HTTP header.
|
|
25360
|
+
*
|
|
25361
|
+
* **An HTTP header value is Latin-1.** Node throws `Invalid character in
|
|
25362
|
+
* header content` on anything above U+00FF, the handler dies mid-response,
|
|
25363
|
+
* and the client gets a socket hang up — no status, no reason, nothing. The
|
|
25364
|
+
* mechanism that exists to EXPLAIN a refusal is then the thing that silences
|
|
25365
|
+
* it, and the surface shows "nothing happened".
|
|
25366
|
+
*
|
|
25367
|
+
* That is not hypothetical: measured 2026-09-23 on 1436, a Hikvision replay
|
|
25368
|
+
* refusal read `SETUP of the video track answered 500 — the camera refused
|
|
25369
|
+
* this replay`, and the EM DASH in it threw on every clip the operator
|
|
25370
|
+
* opened. This repository writes its refusals in prose, and its prose uses
|
|
25371
|
+
* `—`, `’` and `…` everywhere, so every reason string is a candidate.
|
|
25372
|
+
*
|
|
25373
|
+
* Substitutes rather than strips: an operator reading a log or a response
|
|
25374
|
+
* should get a readable sentence, not one with holes in it. The typographic
|
|
25375
|
+
* characters this repo actually writes have exact ASCII equivalents; anything
|
|
25376
|
+
* else non-Latin-1 becomes `?`, which is visible rather than silent. Control
|
|
25377
|
+
* characters go too — a newline in a header value is a response-splitting
|
|
25378
|
+
* bug, not a formatting one.
|
|
25379
|
+
*/
|
|
25380
|
+
function clipReasonHeaderValue(reason) {
|
|
25381
|
+
const folded = reason.replace(/[\u2013\u2014\u2212]/g, "-").replace(/[\u2018\u2019\u201b]/g, "'").replace(/[\u201c\u201d]/g, "\"").replace(/\u2026/g, "...").replace(/[\u00a0\u2007\u202f]/g, " ");
|
|
25382
|
+
let out = "";
|
|
25383
|
+
for (const ch of folded) {
|
|
25384
|
+
const code = ch.codePointAt(0) ?? 0;
|
|
25385
|
+
if (code < 32 || code === 127) out += " ";
|
|
25386
|
+
else if (code > 255) out += "?";
|
|
25387
|
+
else out += ch;
|
|
25388
|
+
}
|
|
25389
|
+
return out.slice(0, 500).trim();
|
|
25390
|
+
}
|
|
25154
25391
|
/** One {@link ClipStillRefusalCodeSchema} token, alone. */
|
|
25155
25392
|
var CLIP_STILL_REASON_CODE_HEADER = "x-camstack-reason-code";
|
|
25156
25393
|
/** One {@link ClipStillDispositionSchema} token, alone. */
|
|
@@ -27977,6 +28214,511 @@ var dayNightCapability = {
|
|
|
27977
28214
|
volatileStateFields: ["lastFetchedAt"]
|
|
27978
28215
|
};
|
|
27979
28216
|
/**
|
|
28217
|
+
* Vendor-neutral **onboard** recording + storage cap — what the CAMERA
|
|
28218
|
+
* writes to the CAMERA's own card, on the camera's own schedule.
|
|
28219
|
+
*
|
|
28220
|
+
* This is NOT `recording.cap.ts`. That one is CamStack's recorder: our
|
|
28221
|
+
* footage ledger, our storage locations, our retention. This one has a
|
|
28222
|
+
* different authority — the camera's firmware — and per D62 it stores
|
|
28223
|
+
* nothing of its own. Every value here is read from the camera and every
|
|
28224
|
+
* write goes back to the camera; there is no CamStack-side mirror that
|
|
28225
|
+
* could disagree with the device.
|
|
28226
|
+
*
|
|
28227
|
+
* ## One shape, two firmwares
|
|
28228
|
+
*
|
|
28229
|
+
* Measured 2026-09-22 against the live fleet:
|
|
28230
|
+
*
|
|
28231
|
+
* | fact | Hikvision (ISAPI) | Reolink (Baichuan) |
|
|
28232
|
+
* | --- | --- | --- |
|
|
28233
|
+
* | storage | `ContentMgmt/Storage` `<hdd>` rows: status, capacity, freeSpace (MB) | `getHddInfoList` (cmd 102): `mount`, `format`, `capacity` GB + `capacityM` MB remainder |
|
|
28234
|
+
* | tracks | several (101 **and** 103 on both 1436 and 3833), each with its own schedule | one per channel |
|
|
28235
|
+
* | schedule | per track, 7 `ScheduleAction` blocks: DayOfWeek + TimeOfDay range + ONE `ActionRecordingMode` | per trigger type, a 168-char weekly HOUR mask |
|
|
28236
|
+
* | triggers | `CMR`, `MOTION` | `Normal`, `MD`, `people`, `vehicle`, `dog_cat`, `crossline`, `intrude`, `loitering` |
|
|
28237
|
+
* | pre-record | `PreRecordTimeSeconds` | `preRecordTime` |
|
|
28238
|
+
* | post-record | `PostRecordTimeSeconds` | `recordDelayTime` |
|
|
28239
|
+
* | overwrite | per track `LoopEnable` | `cycle`, with `cycleList` enumerating the accepted values |
|
|
28240
|
+
* | segment length | not exposed on V5.7.1 | `packageTime` (minutes) |
|
|
28241
|
+
*
|
|
28242
|
+
* The two schedule models look different and are the same thing in
|
|
28243
|
+
* different coordinates: both answer "for this trigger, during which
|
|
28244
|
+
* weekly windows does the camera record". {@link RecordWindow} is that
|
|
28245
|
+
* question in one shape — Hikvision's ranges map straight onto it,
|
|
28246
|
+
* Reolink's mask expands into hour-aligned windows.
|
|
28247
|
+
*
|
|
28248
|
+
* ## Union, not intersection
|
|
28249
|
+
*
|
|
28250
|
+
* **The same fields exist on every camera.** What differs per device is
|
|
28251
|
+
* which VALUES that device accepts, and that is what {@link
|
|
28252
|
+
* RecordingOnboardOptions} reports — a `{ readable, writable, reason }`
|
|
28253
|
+
* per field plus the schedule's own limits. A control a camera cannot
|
|
28254
|
+
* honour is rendered DISABLED WITH ITS REASON, never missing and never
|
|
28255
|
+
* dead: disabled must not look like broken.
|
|
28256
|
+
*
|
|
28257
|
+
* ## Refusal by name
|
|
28258
|
+
*
|
|
28259
|
+
* A write a camera cannot honour is refused with a sentence the operator
|
|
28260
|
+
* can read — never accepted and dropped. Both providers refuse through
|
|
28261
|
+
* {@link describeOnboardRefusal}, so the vocabulary is one function and
|
|
28262
|
+
* one test, not two hand-written vendor opinions.
|
|
28263
|
+
*
|
|
28264
|
+
* Follows the D14 `deviceConfig` archetype (see `stream-params.cap.ts`):
|
|
28265
|
+
* `getOptions` advertises per-camera availability, `getStatus` (auto-
|
|
28266
|
+
* injected from `status`) reports the live values, and a single
|
|
28267
|
+
* `setSettings` mutation applies a partial change. No hand-written
|
|
28268
|
+
* settings-contribution methods.
|
|
28269
|
+
*/
|
|
28270
|
+
/**
|
|
28271
|
+
* What makes the camera start recording during a window.
|
|
28272
|
+
*
|
|
28273
|
+
* The union of both vendors' vocabularies. `continuous` is Hikvision's
|
|
28274
|
+
* `CMR` and Reolink's `Normal`; `motion` is `MOTION` / `MD`. The
|
|
28275
|
+
* object-class triggers are Reolink-only today and the smart-event ones
|
|
28276
|
+
* (`lineCrossing`, `intrusion`, `loitering`) are Reolink-only on the
|
|
28277
|
+
* firmwares measured — a camera that cannot record on a trigger simply
|
|
28278
|
+
* does not list it in `options.schedule.triggers`, and a window naming
|
|
28279
|
+
* it is REFUSED, not dropped.
|
|
28280
|
+
*/
|
|
28281
|
+
var RecordTriggerSchema = _enum([
|
|
28282
|
+
"continuous",
|
|
28283
|
+
"motion",
|
|
28284
|
+
"person",
|
|
28285
|
+
"vehicle",
|
|
28286
|
+
"animal",
|
|
28287
|
+
"lineCrossing",
|
|
28288
|
+
"intrusion",
|
|
28289
|
+
"loitering",
|
|
28290
|
+
"alarmInput"
|
|
28291
|
+
]);
|
|
28292
|
+
/**
|
|
28293
|
+
* One weekly recording window: "on `day`, from `startMinute` to
|
|
28294
|
+
* `endMinute`, record on `trigger`".
|
|
28295
|
+
*
|
|
28296
|
+
* `day` is 0 = Monday … 6 = Sunday (ISO order, which is also the order
|
|
28297
|
+
* both firmwares enumerate). Minutes are local camera time since
|
|
28298
|
+
* midnight; `endMinute` may be 1440, meaning end of day — that is
|
|
28299
|
+
* Hikvision's literal `24:00` and Reolink's 24th mask slot, and
|
|
28300
|
+
* collapsing it to 0 would turn a whole-day window into an empty one.
|
|
28301
|
+
*/
|
|
28302
|
+
var RecordWindowSchema = object({
|
|
28303
|
+
trigger: RecordTriggerSchema,
|
|
28304
|
+
day: number().int().min(0).max(6),
|
|
28305
|
+
startMinute: number().int().min(0).max(1439),
|
|
28306
|
+
endMinute: number().int().min(1).max(1440)
|
|
28307
|
+
});
|
|
28308
|
+
/** Status of one physical volume, as the camera itself describes it. */
|
|
28309
|
+
var OnboardStorageVolumeSchema = object({
|
|
28310
|
+
/** The camera's own id for the volume (`hdd/id`, Reolink `HddInfo/number`). */
|
|
28311
|
+
id: string(),
|
|
28312
|
+
/** The camera's own name for it, when it gives one (`hddName`). */
|
|
28313
|
+
label: string().optional(),
|
|
28314
|
+
status: _enum([
|
|
28315
|
+
"ok",
|
|
28316
|
+
"unformatted",
|
|
28317
|
+
"error",
|
|
28318
|
+
"offline",
|
|
28319
|
+
"unknown"
|
|
28320
|
+
]),
|
|
28321
|
+
/**
|
|
28322
|
+
* Total size in MB, or **null when the camera did not say**.
|
|
28323
|
+
*
|
|
28324
|
+
* Never 0 for an unreadable value: a measurement that failed is not a
|
|
28325
|
+
* measurement (D393), and a card whose size is unknown must not be
|
|
28326
|
+
* rendered as a card of size zero.
|
|
28327
|
+
*/
|
|
28328
|
+
capacityMb: number().nullable(),
|
|
28329
|
+
/**
|
|
28330
|
+
* Free space in MB, or null when unknown.
|
|
28331
|
+
*
|
|
28332
|
+
* **Not a proxy for "has footage".** Measured 2026-09-22: 1436 and
|
|
28333
|
+
* 1439 both report exactly 11776 MB free — the fixed reserve a looping
|
|
28334
|
+
* card converges on once it has wrapped. At loop steady state the
|
|
28335
|
+
* number is identical whether the camera recorded yesterday or stopped
|
|
28336
|
+
* a month ago.
|
|
28337
|
+
*/
|
|
28338
|
+
freeMb: number().nullable(),
|
|
28339
|
+
/** True when the camera reports the volume writable (`property` RW). */
|
|
28340
|
+
writable: boolean().optional()
|
|
28341
|
+
});
|
|
28342
|
+
/**
|
|
28343
|
+
* What the camera is doing with its own storage, right now.
|
|
28344
|
+
*
|
|
28345
|
+
* Every scalar is nullable and **null means the camera did not answer**,
|
|
28346
|
+
* never a default. A form that seeds `0` from an unanswered read invites
|
|
28347
|
+
* the operator to save that 0 back onto the camera.
|
|
28348
|
+
*/
|
|
28349
|
+
var RecordingOnboardStatusSchema = object({
|
|
28350
|
+
storage: discriminatedUnion("kind", [
|
|
28351
|
+
object({
|
|
28352
|
+
kind: literal("present"),
|
|
28353
|
+
volumes: array(OnboardStorageVolumeSchema)
|
|
28354
|
+
}),
|
|
28355
|
+
object({
|
|
28356
|
+
kind: literal("absent"),
|
|
28357
|
+
reason: string()
|
|
28358
|
+
}),
|
|
28359
|
+
object({
|
|
28360
|
+
kind: literal("unknown"),
|
|
28361
|
+
reason: string()
|
|
28362
|
+
})
|
|
28363
|
+
]),
|
|
28364
|
+
tracks: array(object({
|
|
28365
|
+
id: string(),
|
|
28366
|
+
enabled: boolean(),
|
|
28367
|
+
isVideo: boolean(),
|
|
28368
|
+
/** From the camera's own track description. Null when it does not say. */
|
|
28369
|
+
codec: string().nullable(),
|
|
28370
|
+
resolution: string().nullable(),
|
|
28371
|
+
/** Per-track overwrite flag, where the firmware keeps it per track. */
|
|
28372
|
+
overwriteWhenFull: boolean().nullable()
|
|
28373
|
+
})),
|
|
28374
|
+
/**
|
|
28375
|
+
* The track the write path targets — the enabled VIDEO one. Null when
|
|
28376
|
+
* no track could be identified, which is itself a refusal reason.
|
|
28377
|
+
*/
|
|
28378
|
+
primaryTrackId: string().nullable(),
|
|
28379
|
+
/** Master "record to the card at all" switch. */
|
|
28380
|
+
enabled: boolean().nullable(),
|
|
28381
|
+
overwriteWhenFull: boolean().nullable(),
|
|
28382
|
+
preRecordSec: number().nullable(),
|
|
28383
|
+
postRecordSec: number().nullable(),
|
|
28384
|
+
/** Length of one recorded file, in minutes. */
|
|
28385
|
+
segmentMinutes: number().nullable(),
|
|
28386
|
+
/** The primary track's weekly windows, flattened. */
|
|
28387
|
+
windows: array(RecordWindowSchema),
|
|
28388
|
+
/**
|
|
28389
|
+
* How many windows the camera described that CamStack could NOT read —
|
|
28390
|
+
* an unrecognised trigger, an unparseable clock, a weekday it does not
|
|
28391
|
+
* name.
|
|
28392
|
+
*
|
|
28393
|
+
* A dropped window is work the reader threw away, and a schedule that
|
|
28394
|
+
* silently shows fewer rows than the camera holds is how an operator
|
|
28395
|
+
* saves back a schedule shorter than the one they were looking at
|
|
28396
|
+
* (D391). Non-zero means the window list is INCOMPLETE and a write
|
|
28397
|
+
* that replaces it would delete what was not shown — which is why a
|
|
28398
|
+
* provider reporting a non-zero count also reports the schedule as not
|
|
28399
|
+
* writable.
|
|
28400
|
+
*/
|
|
28401
|
+
unreadableWindows: number(),
|
|
28402
|
+
/**
|
|
28403
|
+
* The camera is scheduled to record and has NO usable storage.
|
|
28404
|
+
*
|
|
28405
|
+
* A first-class fact because it is the fleet's most common silent
|
|
28406
|
+
* defect: measured 2026-09-22, 1441 and 3831 are both motion-recording
|
|
28407
|
+
* to a card that is not there. Neither the schedule nor the storage
|
|
28408
|
+
* read says anything wrong on its own; only the pair does.
|
|
28409
|
+
*/
|
|
28410
|
+
recordingToNowhere: boolean(),
|
|
28411
|
+
lastFetchedAt: number()
|
|
28412
|
+
});
|
|
28413
|
+
/** Numeric range descriptor — `{ min, max, step }` per the getOptions convention. */
|
|
28414
|
+
var RangeSchema = object({
|
|
28415
|
+
min: number(),
|
|
28416
|
+
max: number(),
|
|
28417
|
+
step: number()
|
|
28418
|
+
});
|
|
28419
|
+
/**
|
|
28420
|
+
* The values a camera actually takes for a numeric field, when they are a SET
|
|
28421
|
+
* rather than a range.
|
|
28422
|
+
*
|
|
28423
|
+
* `{min,max,step}` cannot say what these two firmwares do. Measured on 1436
|
|
28424
|
+
* (I91DN) on 2026-09-22 by writing each value and reading it back:
|
|
28425
|
+
*
|
|
28426
|
+
* - pre-record: `0, 5, 10, 15, 20, 25, 30` and `2147483647` (INT32_MAX, the
|
|
28427
|
+
* camera's "no limit" — `-1` and `4294967295` both land on it);
|
|
28428
|
+
* - post-record: `5, 10, 30, 60, 120, 300, 600`.
|
|
28429
|
+
*
|
|
28430
|
+
* Neither is expressible as a step: the first has a sentinel two billion away
|
|
28431
|
+
* from its neighbours, the second doubles and then jumps. A range that tried
|
|
28432
|
+
* would forbid values the camera takes AND permit values it silently replaces
|
|
28433
|
+
* with 5 — wrong in both directions at once.
|
|
28434
|
+
*
|
|
28435
|
+
* `sentinel` names the member that is not a duration, so a surface can render
|
|
28436
|
+
* "no limit" instead of `2147483647` seconds.
|
|
28437
|
+
*/
|
|
28438
|
+
var AllowedValuesSchema = object({
|
|
28439
|
+
values: array(number()).min(1),
|
|
28440
|
+
sentinel: object({
|
|
28441
|
+
value: number(),
|
|
28442
|
+
meaning: _enum(["no-limit", "disabled"])
|
|
28443
|
+
}).optional()
|
|
28444
|
+
});
|
|
28445
|
+
/**
|
|
28446
|
+
* Per-field availability on ONE camera.
|
|
28447
|
+
*
|
|
28448
|
+
* The field exists on every camera — this says whether this one can be
|
|
28449
|
+
* read and whether it can be written, and `reason` says why not when
|
|
28450
|
+
* either is false. The UI renders the control DISABLED with the reason
|
|
28451
|
+
* rather than hiding it, so a limitation is legible instead of looking
|
|
28452
|
+
* like a missing feature.
|
|
28453
|
+
*/
|
|
28454
|
+
var OnboardFieldSupportSchema = object({
|
|
28455
|
+
readable: boolean(),
|
|
28456
|
+
writable: boolean(),
|
|
28457
|
+
/** Required whenever `readable` or `writable` is false. */
|
|
28458
|
+
reason: string().optional()
|
|
28459
|
+
});
|
|
28460
|
+
/** What this camera's schedule model can express. */
|
|
28461
|
+
var OnboardScheduleSupportSchema = object({
|
|
28462
|
+
support: OnboardFieldSupportSchema,
|
|
28463
|
+
/**
|
|
28464
|
+
* The smallest time step the camera can express, in minutes.
|
|
28465
|
+
*
|
|
28466
|
+
* Hikvision takes arbitrary minutes (`00:05:00`–`23:57:00` observed on
|
|
28467
|
+
* 1436's track 103). Reolink's schedule is a 7×24 HOUR mask, so 60. A
|
|
28468
|
+
* window whose edges are not a multiple of this is REFUSED rather than
|
|
28469
|
+
* quietly rounded — rounding is how an operator's 06:30 becomes 06:00
|
|
28470
|
+
* and nothing says so.
|
|
28471
|
+
*/
|
|
28472
|
+
granularityMinutes: number(),
|
|
28473
|
+
/** Triggers this camera can record on. A window naming another is refused. */
|
|
28474
|
+
triggers: array(RecordTriggerSchema),
|
|
28475
|
+
/**
|
|
28476
|
+
* False when the camera stores ONE trigger per time range, so two
|
|
28477
|
+
* windows overlapping on the same day cannot carry different triggers.
|
|
28478
|
+
* True on Reolink, whose mask is per-trigger and independent.
|
|
28479
|
+
*/
|
|
28480
|
+
supportsOverlappingTriggers: boolean()
|
|
28481
|
+
});
|
|
28482
|
+
var RecordingOnboardOptionsSchema = object({
|
|
28483
|
+
enabled: OnboardFieldSupportSchema,
|
|
28484
|
+
overwriteWhenFull: OnboardFieldSupportSchema,
|
|
28485
|
+
preRecordSec: OnboardFieldSupportSchema,
|
|
28486
|
+
preRecordSecRange: RangeSchema.optional(),
|
|
28487
|
+
/** Preferred over the range when the camera takes a SET, not a span. */
|
|
28488
|
+
preRecordSecAllowed: AllowedValuesSchema.optional(),
|
|
28489
|
+
postRecordSec: OnboardFieldSupportSchema,
|
|
28490
|
+
postRecordSecRange: RangeSchema.optional(),
|
|
28491
|
+
/** Preferred over the range when the camera takes a SET, not a span. */
|
|
28492
|
+
postRecordSecAllowed: AllowedValuesSchema.optional(),
|
|
28493
|
+
segmentMinutes: OnboardFieldSupportSchema,
|
|
28494
|
+
segmentMinutesRange: RangeSchema.optional(),
|
|
28495
|
+
/** Preferred over the range when the camera takes a SET, not a span. */
|
|
28496
|
+
segmentMinutesAllowed: AllowedValuesSchema.optional(),
|
|
28497
|
+
schedule: OnboardScheduleSupportSchema
|
|
28498
|
+
});
|
|
28499
|
+
/**
|
|
28500
|
+
* A partial change. Every field optional.
|
|
28501
|
+
*
|
|
28502
|
+
* Unlike the other `deviceConfig` caps, a provider here does **NOT**
|
|
28503
|
+
* silently ignore a field it cannot support — it refuses, by name,
|
|
28504
|
+
* through {@link describeOnboardRefusal}. Silence on a recording setting
|
|
28505
|
+
* is the failure D62 exists to prevent: the operator believes the camera
|
|
28506
|
+
* is recording the way the form says, and it is not.
|
|
28507
|
+
*/
|
|
28508
|
+
var RecordingOnboardPatchSchema = object({
|
|
28509
|
+
enabled: boolean().optional(),
|
|
28510
|
+
overwriteWhenFull: boolean().optional(),
|
|
28511
|
+
preRecordSec: number().optional(),
|
|
28512
|
+
postRecordSec: number().optional(),
|
|
28513
|
+
segmentMinutes: number().optional(),
|
|
28514
|
+
/** The complete new window set for the primary track — not a delta. */
|
|
28515
|
+
windows: array(RecordWindowSchema).optional()
|
|
28516
|
+
});
|
|
28517
|
+
var recordingOnboardCapability = {
|
|
28518
|
+
name: "recording-onboard",
|
|
28519
|
+
scope: "device",
|
|
28520
|
+
deviceNative: true,
|
|
28521
|
+
mode: "singleton",
|
|
28522
|
+
deviceTypes: [DeviceType.Camera],
|
|
28523
|
+
deviceConfig: { ui: {
|
|
28524
|
+
kind: "derived-form",
|
|
28525
|
+
builderId: "recording-onboard",
|
|
28526
|
+
tab: "recording"
|
|
28527
|
+
} },
|
|
28528
|
+
methods: {
|
|
28529
|
+
getOptions: method(object({ deviceId: number() }), RecordingOnboardOptionsSchema),
|
|
28530
|
+
setSettings: method(object({
|
|
28531
|
+
deviceId: number(),
|
|
28532
|
+
settings: RecordingOnboardPatchSchema
|
|
28533
|
+
}), _void(), {
|
|
28534
|
+
kind: "mutation",
|
|
28535
|
+
auth: "admin"
|
|
28536
|
+
})
|
|
28537
|
+
},
|
|
28538
|
+
status: {
|
|
28539
|
+
schema: RecordingOnboardStatusSchema,
|
|
28540
|
+
kind: "poll"
|
|
28541
|
+
},
|
|
28542
|
+
runtimeState: RecordingOnboardStatusSchema,
|
|
28543
|
+
/**
|
|
28544
|
+
* Runtime-state durability: **restored** — operator-set camera-side
|
|
28545
|
+
* recording config; mutation-driven, and the storage half is the last
|
|
28546
|
+
* thing the camera said about its own card.
|
|
28547
|
+
*
|
|
28548
|
+
* See `RuntimeStateDurability`. Enforced by
|
|
28549
|
+
* `scripts/check-runtime-state-durability.ts`.
|
|
28550
|
+
*/
|
|
28551
|
+
durability: "restored",
|
|
28552
|
+
/** Clock fields: written, but excluded from the compare that decides
|
|
28553
|
+
* whether persisting is worth a SQLite commit. */
|
|
28554
|
+
volatileStateFields: ["lastFetchedAt"]
|
|
28555
|
+
};
|
|
28556
|
+
/**
|
|
28557
|
+
* Day-of-week names in the cap's index order (0 = Monday), for messages
|
|
28558
|
+
* an operator reads and for Hikvision's `<DayOfWeek>` element.
|
|
28559
|
+
*/
|
|
28560
|
+
var DAY_NAMES = [
|
|
28561
|
+
"Monday",
|
|
28562
|
+
"Tuesday",
|
|
28563
|
+
"Wednesday",
|
|
28564
|
+
"Thursday",
|
|
28565
|
+
"Friday",
|
|
28566
|
+
"Saturday",
|
|
28567
|
+
"Sunday"
|
|
28568
|
+
];
|
|
28569
|
+
/**
|
|
28570
|
+
* Does `patch` ask this camera for something it cannot do?
|
|
28571
|
+
*
|
|
28572
|
+
* Returns the operator-readable reason, or `null` when every field in
|
|
28573
|
+
* the patch is within what `options` says this camera accepts. A
|
|
28574
|
+
* provider calls this BEFORE touching the camera and throws the string:
|
|
28575
|
+
* refusing is the point, and refusing identically on both vendors is why
|
|
28576
|
+
* this is one function.
|
|
28577
|
+
*
|
|
28578
|
+
* The order of checks is the order an operator would read them: the
|
|
28579
|
+
* scalar knobs first, then the schedule, because a schedule complaint is
|
|
28580
|
+
* longer and a scalar one is usually the real problem.
|
|
28581
|
+
*/
|
|
28582
|
+
function describeOnboardRefusal(options, patch) {
|
|
28583
|
+
if (patch.enabled !== void 0 && !options.enabled.writable) return unwritable("the master recording switch", options.enabled.reason);
|
|
28584
|
+
if (patch.overwriteWhenFull !== void 0 && !options.overwriteWhenFull.writable) return unwritable("overwrite-when-full", options.overwriteWhenFull.reason);
|
|
28585
|
+
const preRefusal = refuseNumeric("pre-record seconds", patch.preRecordSec, options.preRecordSec, options.preRecordSecRange, options.preRecordSecAllowed);
|
|
28586
|
+
if (preRefusal) return preRefusal;
|
|
28587
|
+
const postRefusal = refuseNumeric("post-record seconds", patch.postRecordSec, options.postRecordSec, options.postRecordSecRange, options.postRecordSecAllowed);
|
|
28588
|
+
if (postRefusal) return postRefusal;
|
|
28589
|
+
const segmentRefusal = refuseNumeric("segment length (minutes)", patch.segmentMinutes, options.segmentMinutes, options.segmentMinutesRange, options.segmentMinutesAllowed);
|
|
28590
|
+
if (segmentRefusal) return segmentRefusal;
|
|
28591
|
+
if (patch.windows !== void 0) {
|
|
28592
|
+
const scheduleRefusal = refuseSchedule(options, patch.windows);
|
|
28593
|
+
if (scheduleRefusal) return scheduleRefusal;
|
|
28594
|
+
}
|
|
28595
|
+
return null;
|
|
28596
|
+
}
|
|
28597
|
+
function unwritable(what, reason) {
|
|
28598
|
+
return reason ? `this camera cannot change ${what}: ${reason}` : `this camera cannot change ${what}`;
|
|
28599
|
+
}
|
|
28600
|
+
function refuseNumeric(what, value, support, range, allowed) {
|
|
28601
|
+
if (value === void 0) return null;
|
|
28602
|
+
if (!support.writable) return unwritable(what, support.reason);
|
|
28603
|
+
if (!Number.isFinite(value)) return `${what} must be a number, got ${String(value)}`;
|
|
28604
|
+
if (allowed) {
|
|
28605
|
+
if (allowed.values.includes(value)) return null;
|
|
28606
|
+
const say = (v) => allowed.sentinel !== void 0 && v === allowed.sentinel.value ? allowed.sentinel.meaning === "no-limit" ? "no limit" : "off" : String(v);
|
|
28607
|
+
return `this camera accepts ${what} only as ${allowed.values.map(say).join(", ")}; ${say(value)} is not one of them`;
|
|
28608
|
+
}
|
|
28609
|
+
if (!range) return null;
|
|
28610
|
+
if (value < range.min || value > range.max) return `this camera accepts ${what} between ${String(range.min)} and ${String(range.max)}; ${String(value)} is outside that`;
|
|
28611
|
+
if (range.step > 0 && (value - range.min) % range.step !== 0) return `this camera accepts ${what} in steps of ${String(range.step)} from ${String(range.min)}; ${String(value)} is not one of them`;
|
|
28612
|
+
return null;
|
|
28613
|
+
}
|
|
28614
|
+
function refuseSchedule(options, windows) {
|
|
28615
|
+
const schedule = options.schedule;
|
|
28616
|
+
if (!schedule.support.writable) return unwritable("the recording schedule", schedule.support.reason);
|
|
28617
|
+
const allowed = new Set(schedule.triggers);
|
|
28618
|
+
for (const window of windows) {
|
|
28619
|
+
if (!allowed.has(window.trigger)) {
|
|
28620
|
+
const offer = schedule.triggers.length > 0 ? schedule.triggers.join(", ") : "none";
|
|
28621
|
+
return `this camera cannot record on "${window.trigger}"; it records on: ${offer}`;
|
|
28622
|
+
}
|
|
28623
|
+
if (window.endMinute <= window.startMinute) return `a window on ${dayName(window.day)} ends at or before it starts (${String(window.startMinute)} → ${String(window.endMinute)})`;
|
|
28624
|
+
const step = schedule.granularityMinutes;
|
|
28625
|
+
if (step > 1 && (window.startMinute % step !== 0 || window.endMinute % step !== 0)) return `this camera's schedule only moves in ${String(step)}-minute steps; the ${dayName(window.day)} window ${formatMinutes(window.startMinute)}–${formatMinutes(window.endMinute)} is not aligned to them`;
|
|
28626
|
+
}
|
|
28627
|
+
if (!schedule.supportsOverlappingTriggers) {
|
|
28628
|
+
const clash = findOverlapWithDifferentTrigger(windows);
|
|
28629
|
+
if (clash) return `this camera stores one trigger per time range, so "${clash.a.trigger}" and "${clash.b.trigger}" cannot both cover ${dayName(clash.a.day)} ${formatMinutes(Math.max(clash.a.startMinute, clash.b.startMinute))}–${formatMinutes(Math.min(clash.a.endMinute, clash.b.endMinute))}`;
|
|
28630
|
+
}
|
|
28631
|
+
return null;
|
|
28632
|
+
}
|
|
28633
|
+
function findOverlapWithDifferentTrigger(windows) {
|
|
28634
|
+
for (let i = 0; i < windows.length; i += 1) for (let j = i + 1; j < windows.length; j += 1) {
|
|
28635
|
+
const a = windows[i];
|
|
28636
|
+
const b = windows[j];
|
|
28637
|
+
if (!a || !b) continue;
|
|
28638
|
+
if (a.day !== b.day) continue;
|
|
28639
|
+
if (a.trigger === b.trigger) continue;
|
|
28640
|
+
if (a.startMinute < b.endMinute && b.startMinute < a.endMinute) return {
|
|
28641
|
+
a,
|
|
28642
|
+
b
|
|
28643
|
+
};
|
|
28644
|
+
}
|
|
28645
|
+
return null;
|
|
28646
|
+
}
|
|
28647
|
+
function dayName(day) {
|
|
28648
|
+
return DAY_NAMES[day] ?? `day ${String(day)}`;
|
|
28649
|
+
}
|
|
28650
|
+
/** `510` → `08:30`. For messages, not for the wire. */
|
|
28651
|
+
function formatMinutes(minute) {
|
|
28652
|
+
const hour = Math.floor(minute / 60);
|
|
28653
|
+
const rest = minute % 60;
|
|
28654
|
+
return `${String(hour).padStart(2, "0")}:${String(rest).padStart(2, "0")}`;
|
|
28655
|
+
}
|
|
28656
|
+
/**
|
|
28657
|
+
* Index of the mask character covering `day` at `hour`.
|
|
28658
|
+
*
|
|
28659
|
+
* **Layout: day-major, Monday first** — `day * 24 + hour`.
|
|
28660
|
+
*
|
|
28661
|
+
* This is an ASSUMPTION, and it is recorded as one because the fleet
|
|
28662
|
+
* cannot currently confirm it: every `valueTable` on every Reolink
|
|
28663
|
+
* measured on 2026-09-22 is uniform (all `1` or all `0`), and a uniform
|
|
28664
|
+
* mask reads identically under either layout. To settle it, set a single
|
|
28665
|
+
* hour on one day from the Reolink app and re-read `getRecordSchedule`.
|
|
28666
|
+
* Until then, a NON-uniform mask is reported as windows under this
|
|
28667
|
+
* layout and the day labels may be transposed; a uniform one is exact.
|
|
28668
|
+
*/
|
|
28669
|
+
function weeklyMaskIndex(day, hour) {
|
|
28670
|
+
return day * 24 + hour;
|
|
28671
|
+
}
|
|
28672
|
+
/**
|
|
28673
|
+
* Expand one trigger's weekly hour mask into windows, merging adjacent
|
|
28674
|
+
* set hours into one window per run so a full day is ONE window rather
|
|
28675
|
+
* than twenty-four.
|
|
28676
|
+
*
|
|
28677
|
+
* A mask of the wrong length yields no windows: a truncated table is not
|
|
28678
|
+
* a schedule of fewer days, it is an answer we did not understand.
|
|
28679
|
+
*/
|
|
28680
|
+
function weeklyMaskToWindows(mask, trigger) {
|
|
28681
|
+
if (mask.length !== 168) return [];
|
|
28682
|
+
const out = [];
|
|
28683
|
+
for (let day = 0; day < 7; day += 1) {
|
|
28684
|
+
let runStart = null;
|
|
28685
|
+
for (let hour = 0; hour <= 24; hour += 1) {
|
|
28686
|
+
const set = hour < 24 && mask[weeklyMaskIndex(day, hour)] === "1";
|
|
28687
|
+
if (set && runStart === null) runStart = hour;
|
|
28688
|
+
else if (!set && runStart !== null) {
|
|
28689
|
+
out.push({
|
|
28690
|
+
trigger,
|
|
28691
|
+
day,
|
|
28692
|
+
startMinute: runStart * 60,
|
|
28693
|
+
endMinute: hour * 60
|
|
28694
|
+
});
|
|
28695
|
+
runStart = null;
|
|
28696
|
+
}
|
|
28697
|
+
}
|
|
28698
|
+
}
|
|
28699
|
+
return out;
|
|
28700
|
+
}
|
|
28701
|
+
/**
|
|
28702
|
+
* Collapse this trigger's windows back into a weekly hour mask.
|
|
28703
|
+
*
|
|
28704
|
+
* Windows for other triggers are ignored — the mask is per trigger. A
|
|
28705
|
+
* window that is not hour-aligned is REFUSED upstream by
|
|
28706
|
+
* {@link describeOnboardRefusal}; this function rounds nothing, it
|
|
28707
|
+
* simply sets every hour the window fully or partly covers, so a caller
|
|
28708
|
+
* that skipped the refusal cannot silently lose coverage.
|
|
28709
|
+
*/
|
|
28710
|
+
function windowsToWeeklyMask(windows, trigger) {
|
|
28711
|
+
const slots = new Array(168).fill("0");
|
|
28712
|
+
for (const window of windows) {
|
|
28713
|
+
if (window.trigger !== trigger) continue;
|
|
28714
|
+
if (window.day < 0 || window.day >= 7) continue;
|
|
28715
|
+
const firstHour = Math.floor(window.startMinute / 60);
|
|
28716
|
+
const lastHour = Math.ceil(window.endMinute / 60);
|
|
28717
|
+
for (let hour = firstHour; hour < lastHour && hour < 24; hour += 1) slots[weeklyMaskIndex(window.day, hour)] = "1";
|
|
28718
|
+
}
|
|
28719
|
+
return slots.join("");
|
|
28720
|
+
}
|
|
28721
|
+
/**
|
|
27980
28722
|
* Generic device-level status snapshot. Auto-registered by `BaseDevice`
|
|
27981
28723
|
* for every device, regardless of provider — the kernel needs a uniform
|
|
27982
28724
|
* cap-keyed slice for the basic device flags every consumer expects to
|
|
@@ -36979,6 +37721,7 @@ var DEVICE_LOCAL_STATE_CAPS = {
|
|
|
36979
37721
|
pressureSensor: pressureSensorCapability,
|
|
36980
37722
|
privacyMask: privacyMaskCapability,
|
|
36981
37723
|
ptzAutotrack: ptzAutotrackCapability,
|
|
37724
|
+
recordingOnboard: recordingOnboardCapability,
|
|
36982
37725
|
recordingSignal: recordingSignalCapability,
|
|
36983
37726
|
sceneMonitor: sceneMonitorCapability,
|
|
36984
37727
|
scriptRunner: scriptRunnerCapability,
|
|
@@ -40929,6 +41672,12 @@ Object.freeze({
|
|
|
40929
41672
|
addonId: null,
|
|
40930
41673
|
access: "view"
|
|
40931
41674
|
},
|
|
41675
|
+
"deviceAdminLink.getAdminLink": {
|
|
41676
|
+
capName: "device-admin-link",
|
|
41677
|
+
capScope: "device",
|
|
41678
|
+
addonId: null,
|
|
41679
|
+
access: "view"
|
|
41680
|
+
},
|
|
40932
41681
|
"deviceAdoption.adopt": {
|
|
40933
41682
|
capName: "device-adoption",
|
|
40934
41683
|
capScope: "system",
|
|
@@ -44967,6 +45716,24 @@ Object.freeze({
|
|
|
44967
45716
|
addonId: null,
|
|
44968
45717
|
access: "view"
|
|
44969
45718
|
},
|
|
45719
|
+
"recordingOnboard.getOptions": {
|
|
45720
|
+
capName: "recording-onboard",
|
|
45721
|
+
capScope: "device",
|
|
45722
|
+
addonId: null,
|
|
45723
|
+
access: "view"
|
|
45724
|
+
},
|
|
45725
|
+
"recordingOnboard.getStatus": {
|
|
45726
|
+
capName: "recording-onboard",
|
|
45727
|
+
capScope: "device",
|
|
45728
|
+
addonId: null,
|
|
45729
|
+
access: "view"
|
|
45730
|
+
},
|
|
45731
|
+
"recordingOnboard.setSettings": {
|
|
45732
|
+
capName: "recording-onboard",
|
|
45733
|
+
capScope: "device",
|
|
45734
|
+
addonId: null,
|
|
45735
|
+
access: "create"
|
|
45736
|
+
},
|
|
44970
45737
|
"recordingSignal.getStatus": {
|
|
44971
45738
|
capName: "recording-signal",
|
|
44972
45739
|
capScope: "device",
|
|
@@ -46853,6 +47620,11 @@ Object.freeze({
|
|
|
46853
47620
|
form: "single",
|
|
46854
47621
|
optional: true
|
|
46855
47622
|
}],
|
|
47623
|
+
"deviceAdminLink.getAdminLink": [{
|
|
47624
|
+
name: "deviceId",
|
|
47625
|
+
form: "single",
|
|
47626
|
+
optional: false
|
|
47627
|
+
}],
|
|
46856
47628
|
"deviceAdoption.release": [{
|
|
46857
47629
|
name: "camDeviceId",
|
|
46858
47630
|
form: "single",
|
|
@@ -48238,6 +49010,16 @@ Object.freeze({
|
|
|
48238
49010
|
form: "single",
|
|
48239
49011
|
optional: true
|
|
48240
49012
|
}],
|
|
49013
|
+
"recordingOnboard.getOptions": [{
|
|
49014
|
+
name: "deviceId",
|
|
49015
|
+
form: "single",
|
|
49016
|
+
optional: false
|
|
49017
|
+
}],
|
|
49018
|
+
"recordingOnboard.setSettings": [{
|
|
49019
|
+
name: "deviceId",
|
|
49020
|
+
form: "single",
|
|
49021
|
+
optional: false
|
|
49022
|
+
}],
|
|
48241
49023
|
"recordingSignal.getStatus": [{
|
|
48242
49024
|
name: "deviceId",
|
|
48243
49025
|
form: "single",
|
|
@@ -71797,7 +72579,7 @@ var require_yazl = /* @__PURE__ */ __commonJSMin(((exports) => {
|
|
|
71797
72579
|
}
|
|
71798
72580
|
}));
|
|
71799
72581
|
//#endregion
|
|
71800
|
-
//#region node_modules/@apocaliss92/nodelink-js/dist/chunk-
|
|
72582
|
+
//#region node_modules/@apocaliss92/nodelink-js/dist/chunk-E2N7WTCC.js
|
|
71801
72583
|
var import_undici = require_undici();
|
|
71802
72584
|
var import_yazl = /* @__PURE__ */ __toESM(require_yazl(), 1);
|
|
71803
72585
|
var formatterCache = /* @__PURE__ */ new Map();
|
|
@@ -78283,7 +79065,7 @@ var require_lz4 = /* @__PURE__ */ __commonJSMin(((exports) => {
|
|
|
78283
79065
|
};
|
|
78284
79066
|
}));
|
|
78285
79067
|
//#endregion
|
|
78286
|
-
//#region node_modules/@apocaliss92/nodelink-js/dist/chunk-
|
|
79068
|
+
//#region node_modules/@apocaliss92/nodelink-js/dist/chunk-IPKY5AWA.js
|
|
78287
79069
|
var import_fxp = require_fxp();
|
|
78288
79070
|
var import_lz4 = /* @__PURE__ */ __toESM(require_lz4(), 1);
|
|
78289
79071
|
function encodeHeader(h) {
|
|
@@ -80315,6 +81097,17 @@ var BaichuanClient = class _BaichuanClient extends EventEmitter {
|
|
|
80315
81097
|
* Impact: Snapshots are ~0–50ms slower per camera (negligible for users).
|
|
80316
81098
|
*/
|
|
80317
81099
|
snapshotQueueTail = Promise.resolve();
|
|
81100
|
+
/**
|
|
81101
|
+
* Live cmd 5 replay transfers on this socket.
|
|
81102
|
+
*
|
|
81103
|
+
* Opening a replay SUPERSEDES every earlier one: nothing on the wire ends a
|
|
81104
|
+
* cmd 5 transfer, so an abandoned one keeps delivering (measured: 8 126
|
|
81105
|
+
* further frames over 3 475 ms after the consumer let go) and its successor
|
|
81106
|
+
* used to adopt them. A superseded session now refuses every frame and
|
|
81107
|
+
* counts it.
|
|
81108
|
+
*/
|
|
81109
|
+
replaySessions = /* @__PURE__ */ new Set();
|
|
81110
|
+
replaySessionSeq = 0;
|
|
80318
81111
|
opts;
|
|
80319
81112
|
debugCfg;
|
|
80320
81113
|
logger;
|
|
@@ -81772,6 +82565,85 @@ var BaichuanClient = class _BaichuanClient extends EventEmitter {
|
|
|
81772
82565
|
this.alarmEventState.set(fallbackChannel, curr);
|
|
81773
82566
|
return out;
|
|
81774
82567
|
}
|
|
82568
|
+
/**
|
|
82569
|
+
* Register a cmd 5 replay transfer and supersede every earlier one on this
|
|
82570
|
+
* socket.
|
|
82571
|
+
*/
|
|
82572
|
+
openReplaySession(discriminator) {
|
|
82573
|
+
for (const prev of this.replaySessions) {
|
|
82574
|
+
if (prev.superseded) continue;
|
|
82575
|
+
prev.superseded = true;
|
|
82576
|
+
this.logger?.warn?.(`[BaichuanClient] cmd 5 replay session #${prev.id} (channelId=${prev.discriminator.channelId}, ${prev.discriminator.kind}) superseded by #${this.replaySessionSeq + 1} after ${prev.acceptedFrames} accepted frame(s). Frames still in flight for #${prev.id} will be dropped, not handed to its successor.`);
|
|
82577
|
+
}
|
|
82578
|
+
const session = {
|
|
82579
|
+
id: ++this.replaySessionSeq,
|
|
82580
|
+
discriminator,
|
|
82581
|
+
superseded: false,
|
|
82582
|
+
acceptedFrames: 0,
|
|
82583
|
+
droppedFrames: 0,
|
|
82584
|
+
droppedChannelIds: /* @__PURE__ */ new Set(),
|
|
82585
|
+
reportedDrop: false
|
|
82586
|
+
};
|
|
82587
|
+
this.replaySessions.add(session);
|
|
82588
|
+
return session;
|
|
82589
|
+
}
|
|
82590
|
+
closeReplaySession(session) {
|
|
82591
|
+
this.replaySessions.delete(session);
|
|
82592
|
+
if (session.droppedFrames > 0) this.logger?.debug?.(`[BaichuanClient] cmd 5 replay session #${session.id} (channelId=${session.discriminator.channelId}) ended: ${session.acceptedFrames} frame(s) accepted, ${session.droppedFrames} dropped from channelId ${[...session.droppedChannelIds].join(",")}.`);
|
|
82593
|
+
}
|
|
82594
|
+
/**
|
|
82595
|
+
* Count a cmd 5 frame this transfer refused, and say so the first time.
|
|
82596
|
+
*
|
|
82597
|
+
* Silence here is what made the original defect invisible: the successor
|
|
82598
|
+
* transfer simply returned a few extra access units and a short duration,
|
|
82599
|
+
* with nothing anywhere saying a frame had changed hands.
|
|
82600
|
+
*/
|
|
82601
|
+
countReplayDrop(session, frame, reason) {
|
|
82602
|
+
session.droppedFrames++;
|
|
82603
|
+
session.droppedChannelIds.add(frame.header.channelId);
|
|
82604
|
+
if (!session.reportedDrop) {
|
|
82605
|
+
session.reportedDrop = true;
|
|
82606
|
+
this.logger?.warn?.(`[BaichuanClient] cmd 5 replay session #${session.id} dropping frames (${reason}): frame channelId=${frame.header.channelId} streamType=${frame.header.streamType} msgNum=${frame.header.msgNum} vs session channelId=${session.discriminator.channelId} (${session.discriminator.kind}). Further drops for this session are counted, not logged.`);
|
|
82607
|
+
}
|
|
82608
|
+
}
|
|
82609
|
+
/**
|
|
82610
|
+
* Wait until the socket has gone quiet of cmd 5 frames.
|
|
82611
|
+
*
|
|
82612
|
+
* A cmd 7 stop is acknowledged `rc=200` before the camera has finished
|
|
82613
|
+
* sending: four of eleven stopped sessions in the Reolink app's own capture
|
|
82614
|
+
* were still delivering when the ack came back (26–91 further frames). A
|
|
82615
|
+
* caller that opens the next replay the instant the ack lands is racing that
|
|
82616
|
+
* tail; this is how it waits for it instead.
|
|
82617
|
+
*
|
|
82618
|
+
* @returns how many cmd 5 frames arrived during the drain.
|
|
82619
|
+
*/
|
|
82620
|
+
async drainReplayFrames(options) {
|
|
82621
|
+
const quietMs = options?.quietMs ?? 300;
|
|
82622
|
+
const maxMs = options?.maxMs ?? 4e3;
|
|
82623
|
+
let drained = 0;
|
|
82624
|
+
await new Promise((resolve) => {
|
|
82625
|
+
let quiet;
|
|
82626
|
+
const hard = setTimeout(() => settle(), maxMs);
|
|
82627
|
+
const onFrame = (frame) => {
|
|
82628
|
+
if (frame.header.cmdId !== 5) return;
|
|
82629
|
+
drained++;
|
|
82630
|
+
if (quiet) clearTimeout(quiet);
|
|
82631
|
+
quiet = setTimeout(() => settle(), quietMs);
|
|
82632
|
+
};
|
|
82633
|
+
let settled = false;
|
|
82634
|
+
const settle = () => {
|
|
82635
|
+
if (settled) return;
|
|
82636
|
+
settled = true;
|
|
82637
|
+
clearTimeout(hard);
|
|
82638
|
+
if (quiet) clearTimeout(quiet);
|
|
82639
|
+
this.off("frame", onFrame);
|
|
82640
|
+
resolve();
|
|
82641
|
+
};
|
|
82642
|
+
this.on("frame", onFrame);
|
|
82643
|
+
quiet = setTimeout(() => settle(), quietMs);
|
|
82644
|
+
});
|
|
82645
|
+
return drained;
|
|
82646
|
+
}
|
|
81775
82647
|
nextMsgNum() {
|
|
81776
82648
|
this.msgNum = this.msgNum + 1 & 65535;
|
|
81777
82649
|
return this.msgNum;
|
|
@@ -82325,6 +83197,13 @@ var BaichuanClient = class _BaichuanClient extends EventEmitter {
|
|
|
82325
83197
|
params.channel ?? this.opts.channel;
|
|
82326
83198
|
const sessionCounter = this.nextMsgNum();
|
|
82327
83199
|
const channelId = params.channelIdOverride ?? sessionCounter;
|
|
83200
|
+
const discriminator = params.channelIdOverride == null ? {
|
|
83201
|
+
kind: "minted",
|
|
83202
|
+
channelId
|
|
83203
|
+
} : {
|
|
83204
|
+
kind: "pinned",
|
|
83205
|
+
channelId
|
|
83206
|
+
};
|
|
82328
83207
|
const msgNum = params.msgNumOverride ?? 0;
|
|
82329
83208
|
const cmdId = params.cmdId;
|
|
82330
83209
|
const extXml = params.extensionXml ?? "";
|
|
@@ -82350,9 +83229,10 @@ var BaichuanClient = class _BaichuanClient extends EventEmitter {
|
|
|
82350
83229
|
const idleTimeoutMs = params.idleTimeoutMs ?? 2e3;
|
|
82351
83230
|
const chunks = [];
|
|
82352
83231
|
let streamMsgNum;
|
|
82353
|
-
let lockedChannelId;
|
|
83232
|
+
let lockedChannelId = discriminator.kind === "minted" ? discriminator.channelId : void 0;
|
|
82354
83233
|
let lockedStreamType;
|
|
82355
83234
|
let idleTimer;
|
|
83235
|
+
const session = this.openReplaySession(discriminator);
|
|
82356
83236
|
const looksLikeXml = (buf) => {
|
|
82357
83237
|
let i = 0;
|
|
82358
83238
|
while (i < buf.length && (buf[i] === 0 || buf[i] === 9 || buf[i] === 10 || buf[i] === 13 || buf[i] === 32)) i++;
|
|
@@ -82402,6 +83282,7 @@ var BaichuanClient = class _BaichuanClient extends EventEmitter {
|
|
|
82402
83282
|
this.off("frame", onFrame);
|
|
82403
83283
|
if (timeout) clearTimeout(timeout);
|
|
82404
83284
|
if (idleTimer) clearTimeout(idleTimer);
|
|
83285
|
+
this.closeReplaySession(session);
|
|
82405
83286
|
};
|
|
82406
83287
|
const finish = (buf) => {
|
|
82407
83288
|
if (done) return;
|
|
@@ -82423,8 +83304,15 @@ var BaichuanClient = class _BaichuanClient extends EventEmitter {
|
|
|
82423
83304
|
};
|
|
82424
83305
|
const onFrame = (frame) => {
|
|
82425
83306
|
if (frame.header.cmdId !== cmdId) return;
|
|
83307
|
+
if (session.superseded) {
|
|
83308
|
+
this.countReplayDrop(session, frame, "superseded");
|
|
83309
|
+
return;
|
|
83310
|
+
}
|
|
82426
83311
|
if (lockedStreamType !== void 0 && frame.header.streamType !== lockedStreamType) return;
|
|
82427
|
-
if (lockedChannelId !== void 0 && frame.header.channelId !== lockedChannelId)
|
|
83312
|
+
if (lockedChannelId !== void 0 && frame.header.channelId !== lockedChannelId) {
|
|
83313
|
+
this.countReplayDrop(session, frame, "foreign-channel");
|
|
83314
|
+
return;
|
|
83315
|
+
}
|
|
82428
83316
|
if (streamMsgNum !== void 0 && frame.header.msgNum !== streamMsgNum) return;
|
|
82429
83317
|
const rc = frame.header.responseCode;
|
|
82430
83318
|
if (rc >= 400 && rc < 6e4 && frame.header.msgNum === msgNum) {
|
|
@@ -82448,6 +83336,7 @@ var BaichuanClient = class _BaichuanClient extends EventEmitter {
|
|
|
82448
83336
|
lockedChannelId = frame.header.channelId;
|
|
82449
83337
|
lockedStreamType = frame.header.streamType;
|
|
82450
83338
|
}
|
|
83339
|
+
session.acceptedFrames++;
|
|
82451
83340
|
params.onChunk?.(decrypted);
|
|
82452
83341
|
chunks.push(decrypted);
|
|
82453
83342
|
armIdleFinish();
|
|
@@ -82461,7 +83350,8 @@ var BaichuanClient = class _BaichuanClient extends EventEmitter {
|
|
|
82461
83350
|
finish(Buffer.concat(chunks));
|
|
82462
83351
|
return;
|
|
82463
83352
|
}
|
|
82464
|
-
|
|
83353
|
+
const seen = session.droppedFrames > 0 ? ` (rejected ${session.droppedFrames} cmd ${cmdId} frame(s) on channelId ${[...session.droppedChannelIds].join(",")})` : "";
|
|
83354
|
+
fail(/* @__PURE__ */ new Error(`Baichuan timeout waiting FileInfoList replay binary chunks cmdId=${cmdId} channelId=${channelId} streamType=${expectedStreamType}${seen}`));
|
|
82465
83355
|
}, timeoutMs);
|
|
82466
83356
|
this.on("frame", onFrame);
|
|
82467
83357
|
try {
|
|
@@ -85897,6 +86787,433 @@ var MpegTsMuxer = class {
|
|
|
85897
86787
|
this.tablesSent = false;
|
|
85898
86788
|
}
|
|
85899
86789
|
};
|
|
86790
|
+
var buildDetectionClasses = (parsed, recordType) => {
|
|
86791
|
+
const classes = [];
|
|
86792
|
+
const flags = parsed?.flags;
|
|
86793
|
+
if (flags) {
|
|
86794
|
+
if (flags.aiPerson) classes.push("person");
|
|
86795
|
+
if (flags.aiVehicle) classes.push("vehicle");
|
|
86796
|
+
if (flags.aiAnimal) classes.push("animal");
|
|
86797
|
+
if (flags.aiFace) classes.push("face");
|
|
86798
|
+
if (flags.motion) classes.push("motion");
|
|
86799
|
+
if (flags.schedule) classes.push("schedule");
|
|
86800
|
+
if (flags.doorbell) classes.push("doorbell");
|
|
86801
|
+
if (flags.package) classes.push("package");
|
|
86802
|
+
if (flags.rf) classes.push("rf");
|
|
86803
|
+
if (flags.aiOther) classes.push("other");
|
|
86804
|
+
}
|
|
86805
|
+
if (classes.length === 0 && recordType) {
|
|
86806
|
+
const rt = recordType.toLowerCase();
|
|
86807
|
+
if (rt.includes("people") || rt.includes("person")) classes.push("person");
|
|
86808
|
+
if (rt.includes("vehicle")) classes.push("vehicle");
|
|
86809
|
+
if (rt.includes("dog_cat") || rt.includes("animal")) classes.push("animal");
|
|
86810
|
+
if (rt.includes("face")) classes.push("face");
|
|
86811
|
+
if (rt.includes("md") || rt.includes("motion")) classes.push("motion");
|
|
86812
|
+
if (rt.includes("sched")) classes.push("schedule");
|
|
86813
|
+
if (rt.includes("visitor") || rt.includes("doorbell")) classes.push("doorbell");
|
|
86814
|
+
if (rt.includes("package")) classes.push("package");
|
|
86815
|
+
if (rt.includes("rf")) classes.push("rf");
|
|
86816
|
+
}
|
|
86817
|
+
if (classes.length === 0) classes.push("motion");
|
|
86818
|
+
return classes;
|
|
86819
|
+
};
|
|
86820
|
+
var getXmlTexts = (xml, tags) => {
|
|
86821
|
+
const out = {};
|
|
86822
|
+
for (const tag2 of tags) {
|
|
86823
|
+
const v = getXmlText(xml, tag2);
|
|
86824
|
+
if (v !== void 0) out[tag2] = v;
|
|
86825
|
+
}
|
|
86826
|
+
return out;
|
|
86827
|
+
};
|
|
86828
|
+
var getXmlBlocks = (xml, tagName) => {
|
|
86829
|
+
const re = new RegExp(`<${tagName}\\b[^>]*>([\\s\\S]*?)<\\/${tagName}>`, "g");
|
|
86830
|
+
const out = [];
|
|
86831
|
+
let match;
|
|
86832
|
+
while (match = re.exec(xml)) out.push(match[1] ?? "");
|
|
86833
|
+
return out;
|
|
86834
|
+
};
|
|
86835
|
+
var parseXmlDateTimeBlock = (block, timeZone) => {
|
|
86836
|
+
const year = Number.parseInt(getXmlText(block, "year") ?? "", 10);
|
|
86837
|
+
const month = Number.parseInt(getXmlText(block, "month") ?? "", 10);
|
|
86838
|
+
const day = Number.parseInt(getXmlText(block, "day") ?? "", 10);
|
|
86839
|
+
const hour = Number.parseInt(getXmlText(block, "hour") ?? "", 10);
|
|
86840
|
+
const minute = Number.parseInt(getXmlText(block, "minute") ?? "", 10);
|
|
86841
|
+
const second = Number.parseInt(getXmlText(block, "second") ?? "", 10);
|
|
86842
|
+
if ([
|
|
86843
|
+
year,
|
|
86844
|
+
month,
|
|
86845
|
+
day,
|
|
86846
|
+
hour,
|
|
86847
|
+
minute,
|
|
86848
|
+
second
|
|
86849
|
+
].every(Number.isFinite)) return dateFromWallClock({
|
|
86850
|
+
year,
|
|
86851
|
+
month,
|
|
86852
|
+
day,
|
|
86853
|
+
hour,
|
|
86854
|
+
minute,
|
|
86855
|
+
second
|
|
86856
|
+
}, timeZone);
|
|
86857
|
+
const m = block.replace(/<[^>]*>/g, "").trim().match(/(\d{4})[-/](\d{1,2})[-/](\d{1,2})(?:[ T](\d{1,2}):(\d{1,2})(?::(\d{1,2}))?)?/);
|
|
86858
|
+
if (!m) return void 0;
|
|
86859
|
+
const y = Number.parseInt(m[1] ?? "", 10);
|
|
86860
|
+
const mo = Number.parseInt(m[2] ?? "", 10);
|
|
86861
|
+
const da = Number.parseInt(m[3] ?? "", 10);
|
|
86862
|
+
const ho = Number.parseInt(m[4] ?? "0", 10);
|
|
86863
|
+
const mi = Number.parseInt(m[5] ?? "0", 10);
|
|
86864
|
+
const se = Number.parseInt(m[6] ?? "0", 10);
|
|
86865
|
+
if (![
|
|
86866
|
+
y,
|
|
86867
|
+
mo,
|
|
86868
|
+
da,
|
|
86869
|
+
ho,
|
|
86870
|
+
mi,
|
|
86871
|
+
se
|
|
86872
|
+
].every(Number.isFinite)) return void 0;
|
|
86873
|
+
return dateFromWallClock({
|
|
86874
|
+
year: y,
|
|
86875
|
+
month: mo,
|
|
86876
|
+
day: da,
|
|
86877
|
+
hour: ho,
|
|
86878
|
+
minute: mi,
|
|
86879
|
+
second: se
|
|
86880
|
+
}, timeZone);
|
|
86881
|
+
};
|
|
86882
|
+
var parseRecordingFilesFromXml = (xml, options) => {
|
|
86883
|
+
const timeZone = options?.timeZone;
|
|
86884
|
+
const fileNameOptions = timeZone !== void 0 ? { timeZone } : void 0;
|
|
86885
|
+
const out = [];
|
|
86886
|
+
const fileInfoBlocks = getXmlBlocks(xml, "FileInfo");
|
|
86887
|
+
for (const b of fileInfoBlocks) {
|
|
86888
|
+
const id = getXmlText(b, "Id") ?? getXmlText(b, "ID") ?? getXmlText(b, "id");
|
|
86889
|
+
const name = getXmlText(b, "name") ?? getXmlText(b, "fileName");
|
|
86890
|
+
const chosen = (id ?? name)?.trim();
|
|
86891
|
+
if (!chosen) continue;
|
|
86892
|
+
const item = { fileName: chosen };
|
|
86893
|
+
if (name != null && name.trim()) item.name = name.trim();
|
|
86894
|
+
if (id != null && id.trim()) item.id = id.trim();
|
|
86895
|
+
const recordType = getXmlText(b, "type") ?? getXmlText(b, "recordType") ?? getXmlText(b, "alarmType");
|
|
86896
|
+
if (recordType != null) item.recordType = recordType;
|
|
86897
|
+
const sizeText = getXmlText(b, "size") ?? getXmlText(b, "fileSize");
|
|
86898
|
+
const sizeBytes = sizeText ? Number.parseInt(sizeText, 10) : void 0;
|
|
86899
|
+
if (sizeBytes != null && Number.isFinite(sizeBytes)) item.sizeBytes = sizeBytes;
|
|
86900
|
+
const start = getXmlBlocks(b, "startTime")[0];
|
|
86901
|
+
const end = getXmlBlocks(b, "endTime")[0];
|
|
86902
|
+
const startDt = start ? parseXmlDateTimeBlock(start, timeZone) : void 0;
|
|
86903
|
+
const endDt = end ? parseXmlDateTimeBlock(end, timeZone) : void 0;
|
|
86904
|
+
if (startDt) item.startTime = startDt;
|
|
86905
|
+
if (endDt) item.endTime = endDt;
|
|
86906
|
+
const parsed = parseRecordingFileName(item.name ?? item.fileName, fileNameOptions);
|
|
86907
|
+
const parsedFromPath = item.fileName !== item.name ? parseRecordingFileName(item.fileName, fileNameOptions) : void 0;
|
|
86908
|
+
const bestParsed = parsedFromPath?.sizeBytes != null ? parsedFromPath : parsed;
|
|
86909
|
+
if (bestParsed) {
|
|
86910
|
+
item.parsedFileName = bestParsed;
|
|
86911
|
+
if (!item.startTime) item.startTime = bestParsed.start;
|
|
86912
|
+
if (!item.endTime) item.endTime = bestParsed.end;
|
|
86913
|
+
if (item.sizeBytes == null && bestParsed.sizeBytes != null) item.sizeBytes = bestParsed.sizeBytes;
|
|
86914
|
+
}
|
|
86915
|
+
item.detectionClasses = buildDetectionClasses(parsed, item.recordType);
|
|
86916
|
+
out.push(item);
|
|
86917
|
+
}
|
|
86918
|
+
const fileBlocks = getXmlBlocks(xml, "File");
|
|
86919
|
+
for (const b of fileBlocks) {
|
|
86920
|
+
const fileName = (getXmlText(b, "fileName") ?? getXmlText(b, "name"))?.trim();
|
|
86921
|
+
if (!fileName) continue;
|
|
86922
|
+
const sizeText = getXmlText(b, "size") ?? getXmlText(b, "fileSize");
|
|
86923
|
+
const sizeBytes = sizeText ? Number.parseInt(sizeText, 10) : void 0;
|
|
86924
|
+
const recordType = getXmlText(b, "type") ?? getXmlText(b, "recordType") ?? getXmlText(b, "alarmType");
|
|
86925
|
+
const start = getXmlBlocks(b, "startTime")[0];
|
|
86926
|
+
const end = getXmlBlocks(b, "endTime")[0];
|
|
86927
|
+
const item = { fileName };
|
|
86928
|
+
if (sizeBytes != null && Number.isFinite(sizeBytes)) item.sizeBytes = sizeBytes;
|
|
86929
|
+
if (recordType != null) item.recordType = recordType;
|
|
86930
|
+
const startDt = start ? parseXmlDateTimeBlock(start, timeZone) : void 0;
|
|
86931
|
+
const endDt = end ? parseXmlDateTimeBlock(end, timeZone) : void 0;
|
|
86932
|
+
if (startDt) item.startTime = startDt;
|
|
86933
|
+
if (endDt) item.endTime = endDt;
|
|
86934
|
+
const parsed = parseRecordingFileName(item.fileName, fileNameOptions);
|
|
86935
|
+
if (parsed) {
|
|
86936
|
+
item.parsedFileName = parsed;
|
|
86937
|
+
if (!item.startTime) item.startTime = parsed.start;
|
|
86938
|
+
if (!item.endTime) item.endTime = parsed.end;
|
|
86939
|
+
if (item.sizeBytes == null && parsed.sizeBytes != null) item.sizeBytes = parsed.sizeBytes;
|
|
86940
|
+
}
|
|
86941
|
+
item.detectionClasses = buildDetectionClasses(parsed, item.recordType);
|
|
86942
|
+
out.push(item);
|
|
86943
|
+
}
|
|
86944
|
+
if (out.length === 0) {
|
|
86945
|
+
const re = /<fileName>([\s\S]*?)<\/fileName>/g;
|
|
86946
|
+
const seenNames = /* @__PURE__ */ new Set();
|
|
86947
|
+
let match;
|
|
86948
|
+
while (match = re.exec(xml)) {
|
|
86949
|
+
const fileName = (match[1] ?? "").trim();
|
|
86950
|
+
if (!fileName) continue;
|
|
86951
|
+
if (seenNames.has(fileName)) continue;
|
|
86952
|
+
seenNames.add(fileName);
|
|
86953
|
+
const item = { fileName };
|
|
86954
|
+
const parsed = parseRecordingFileName(fileName, fileNameOptions);
|
|
86955
|
+
if (parsed) {
|
|
86956
|
+
item.parsedFileName = parsed;
|
|
86957
|
+
item.startTime = parsed.start;
|
|
86958
|
+
item.endTime = parsed.end;
|
|
86959
|
+
}
|
|
86960
|
+
item.detectionClasses = buildDetectionClasses(parsed, void 0);
|
|
86961
|
+
out.push(item);
|
|
86962
|
+
}
|
|
86963
|
+
}
|
|
86964
|
+
const alarmBlocks = getXmlBlocks(xml, "alarmVideo");
|
|
86965
|
+
if (alarmBlocks.length > 0) {
|
|
86966
|
+
const byName = /* @__PURE__ */ new Map();
|
|
86967
|
+
for (const existing of out) {
|
|
86968
|
+
const key = existing.fileName.trim();
|
|
86969
|
+
if (!key) continue;
|
|
86970
|
+
if (!byName.has(key)) byName.set(key, existing);
|
|
86971
|
+
}
|
|
86972
|
+
for (const b of alarmBlocks) {
|
|
86973
|
+
const fileName = (getXmlText(b, "fileName") ?? getXmlText(b, "name"))?.trim();
|
|
86974
|
+
if (!fileName) continue;
|
|
86975
|
+
const alarmId = (getXmlText(b, "fileId") ?? getXmlText(b, "recFileId") ?? getXmlText(b, "recFileName") ?? getXmlText(b, "recFile") ?? getXmlText(b, "recordFile") ?? getXmlText(b, "Id") ?? getXmlText(b, "ID") ?? getXmlText(b, "id") ?? "").trim();
|
|
86976
|
+
const alarmType = getXmlText(b, "alarmType")?.trim();
|
|
86977
|
+
const start = getXmlBlocks(b, "startTime")[0];
|
|
86978
|
+
const end = getXmlBlocks(b, "endTime")[0];
|
|
86979
|
+
const startDt = start ? parseXmlDateTimeBlock(start, timeZone) : void 0;
|
|
86980
|
+
const endDt = end ? parseXmlDateTimeBlock(end, timeZone) : void 0;
|
|
86981
|
+
const target = byName.get(fileName) ?? { fileName };
|
|
86982
|
+
if (alarmId && !target.id) target.id = alarmId;
|
|
86983
|
+
if (alarmType) target.recordType = alarmType;
|
|
86984
|
+
if (startDt) target.startTime = startDt;
|
|
86985
|
+
if (endDt) target.endTime = endDt;
|
|
86986
|
+
if (!target.parsedFileName) {
|
|
86987
|
+
const parsed = parseRecordingFileName(target.fileName, fileNameOptions);
|
|
86988
|
+
if (parsed) {
|
|
86989
|
+
target.parsedFileName = parsed;
|
|
86990
|
+
if (!target.startTime) target.startTime = parsed.start;
|
|
86991
|
+
if (!target.endTime) target.endTime = parsed.end;
|
|
86992
|
+
}
|
|
86993
|
+
}
|
|
86994
|
+
if (!byName.has(fileName)) {
|
|
86995
|
+
out.push(target);
|
|
86996
|
+
byName.set(fileName, target);
|
|
86997
|
+
}
|
|
86998
|
+
}
|
|
86999
|
+
}
|
|
87000
|
+
const seen = /* @__PURE__ */ new Set();
|
|
87001
|
+
return out.filter((f) => {
|
|
87002
|
+
const key = f.fileName.trim();
|
|
87003
|
+
if (seen.has(key)) return false;
|
|
87004
|
+
seen.add(key);
|
|
87005
|
+
return true;
|
|
87006
|
+
});
|
|
87007
|
+
};
|
|
87008
|
+
function parseTalkAudioConfig(block) {
|
|
87009
|
+
const audioType = getXmlText(block, "audioType");
|
|
87010
|
+
const sampleRate = Number.parseInt(getXmlText(block, "sampleRate") ?? "", 10);
|
|
87011
|
+
const samplePrecision = Number.parseInt(getXmlText(block, "samplePrecision") ?? "", 10);
|
|
87012
|
+
const lengthPerEncoder = Number.parseInt(getXmlText(block, "lengthPerEncoder") ?? "", 10);
|
|
87013
|
+
const soundTrack = getXmlText(block, "soundTrack");
|
|
87014
|
+
const priorityText = getXmlText(block, "priority");
|
|
87015
|
+
if (!audioType || !Number.isFinite(sampleRate) || !Number.isFinite(samplePrecision) || !Number.isFinite(lengthPerEncoder) || !soundTrack) return null;
|
|
87016
|
+
const config = {
|
|
87017
|
+
audioType,
|
|
87018
|
+
sampleRate,
|
|
87019
|
+
samplePrecision,
|
|
87020
|
+
lengthPerEncoder,
|
|
87021
|
+
soundTrack
|
|
87022
|
+
};
|
|
87023
|
+
if (priorityText !== void 0) {
|
|
87024
|
+
const pr = Number.parseInt(priorityText, 10);
|
|
87025
|
+
if (Number.isFinite(pr)) config.priority = pr;
|
|
87026
|
+
}
|
|
87027
|
+
return config;
|
|
87028
|
+
}
|
|
87029
|
+
var parseTalkAbilityXml = (xml) => {
|
|
87030
|
+
const talkAbilityBlock = getXmlBlocks(xml, "TalkAbility")[0];
|
|
87031
|
+
if (!talkAbilityBlock) throw new Error("TalkAbility XML not found in response");
|
|
87032
|
+
return {
|
|
87033
|
+
duplexList: getXmlBlocks(talkAbilityBlock, "duplexList").map((b) => getXmlText(b, "duplex")).filter((v) => Boolean(v)),
|
|
87034
|
+
audioStreamModeList: getXmlBlocks(talkAbilityBlock, "audioStreamModeList").map((b) => getXmlText(b, "audioStreamMode")).filter((v) => Boolean(v)),
|
|
87035
|
+
audioConfigList: getXmlBlocks(talkAbilityBlock, "audioConfig").map(parseTalkAudioConfig).filter((v) => Boolean(v))
|
|
87036
|
+
};
|
|
87037
|
+
};
|
|
87038
|
+
function parseNumber(v) {
|
|
87039
|
+
if (v == null) return void 0;
|
|
87040
|
+
const n = Number(v);
|
|
87041
|
+
return Number.isFinite(n) ? n : void 0;
|
|
87042
|
+
}
|
|
87043
|
+
function parseBoolean01(v) {
|
|
87044
|
+
if (v == null) return void 0;
|
|
87045
|
+
const s = v.trim();
|
|
87046
|
+
if (s === "1" || s.toLowerCase() === "true") return true;
|
|
87047
|
+
if (s === "0" || s.toLowerCase() === "false") return false;
|
|
87048
|
+
}
|
|
87049
|
+
var BYTES_PER_MB = 1024 * 1024;
|
|
87050
|
+
var num = (xml, tag2) => {
|
|
87051
|
+
const value = parseNumber(getXmlText(xml, tag2));
|
|
87052
|
+
return value === void 0 ? null : value;
|
|
87053
|
+
};
|
|
87054
|
+
var megabytes$1 = (bytes, whole, remainder) => {
|
|
87055
|
+
if (bytes !== null) return bytes / BYTES_PER_MB;
|
|
87056
|
+
if (whole === null && remainder === null) return null;
|
|
87057
|
+
return (whole ?? 0) * 1024 + (remainder ?? 0);
|
|
87058
|
+
};
|
|
87059
|
+
var parseHddInfoListXml = (xml) => {
|
|
87060
|
+
const list = getXmlBlocks(xml, "HddInfoList")[0];
|
|
87061
|
+
if (list === void 0) return [];
|
|
87062
|
+
return getXmlBlocks(list, "HddInfo").map((block) => {
|
|
87063
|
+
const capacity = num(block, "capacity");
|
|
87064
|
+
const capacityM = num(block, "capacityM");
|
|
87065
|
+
const capacityBytes = num(block, "capacityV2");
|
|
87066
|
+
const remainSize = num(block, "remainSize");
|
|
87067
|
+
const remainSizeM = num(block, "remainSizeM");
|
|
87068
|
+
const remainSizeBytes = num(block, "remainSizeV2");
|
|
87069
|
+
return {
|
|
87070
|
+
number: num(block, "number"),
|
|
87071
|
+
capacity,
|
|
87072
|
+
capacityM,
|
|
87073
|
+
capacityBytes,
|
|
87074
|
+
remainSize,
|
|
87075
|
+
remainSizeM,
|
|
87076
|
+
remainSizeBytes,
|
|
87077
|
+
format: num(block, "format"),
|
|
87078
|
+
mount: num(block, "mount"),
|
|
87079
|
+
type: num(block, "type"),
|
|
87080
|
+
index: num(block, "index"),
|
|
87081
|
+
capacityMb: megabytes$1(capacityBytes, capacity, capacityM),
|
|
87082
|
+
remainMb: megabytes$1(remainSizeBytes, remainSize, remainSizeM)
|
|
87083
|
+
};
|
|
87084
|
+
});
|
|
87085
|
+
};
|
|
87086
|
+
var DEFAULT_PRE_RECORD_SECONDS = 10;
|
|
87087
|
+
var parseRecordCfgLimits = (xml) => {
|
|
87088
|
+
const block = getXmlBlocks(xml, "RecordCfg")[0] ?? "";
|
|
87089
|
+
const numbersIn = (container, tag2) => {
|
|
87090
|
+
const outer = getXmlBlocks(block, container)[0];
|
|
87091
|
+
if (outer === void 0) return [];
|
|
87092
|
+
return getXmlBlocks(outer, tag2).map((t) => parseNumber(t.trim())).filter((n) => n !== void 0);
|
|
87093
|
+
};
|
|
87094
|
+
const pre = parseNumber(getXmlText(block, "preRecordTime"));
|
|
87095
|
+
return {
|
|
87096
|
+
cycleList: numbersIn("cyclelist", "item"),
|
|
87097
|
+
recordDelayTimeChoices: numbersIn("timeList", "time"),
|
|
87098
|
+
preRecordSupported: pre !== void 0,
|
|
87099
|
+
preRecordSeconds: pre === void 0 || pre === 0 ? null : pre
|
|
87100
|
+
};
|
|
87101
|
+
};
|
|
87102
|
+
var parseRecordScheduleRows = (xml) => {
|
|
87103
|
+
const block = getXmlBlocks(xml, "Record")[0];
|
|
87104
|
+
const list = block === void 0 ? void 0 : getXmlBlocks(block, "typeScheduleList")[0];
|
|
87105
|
+
if (list === void 0) return [];
|
|
87106
|
+
const rows = [];
|
|
87107
|
+
for (const item of getXmlBlocks(list, "item")) {
|
|
87108
|
+
const type = (getXmlText(item, "type") ?? "").trim();
|
|
87109
|
+
if (type === "") continue;
|
|
87110
|
+
const index = parseNumber(getXmlText(item, "index"));
|
|
87111
|
+
rows.push({
|
|
87112
|
+
type,
|
|
87113
|
+
index: index === void 0 ? null : index,
|
|
87114
|
+
valueTable: (getXmlText(item, "valueTable") ?? "").trim()
|
|
87115
|
+
});
|
|
87116
|
+
}
|
|
87117
|
+
return rows;
|
|
87118
|
+
};
|
|
87119
|
+
var BaichuanRecordConfigError = class extends Error {
|
|
87120
|
+
constructor(message) {
|
|
87121
|
+
super(message);
|
|
87122
|
+
this.name = "BaichuanRecordConfigError";
|
|
87123
|
+
}
|
|
87124
|
+
};
|
|
87125
|
+
var requireInteger = (field, value) => {
|
|
87126
|
+
if (!Number.isInteger(value)) throw new BaichuanRecordConfigError(`${field} must be a whole number, got ${String(value)}`);
|
|
87127
|
+
};
|
|
87128
|
+
var validateRecordCfgPatch = (patch, limits) => {
|
|
87129
|
+
if (patch.cycle !== void 0) {
|
|
87130
|
+
requireInteger("cycle", patch.cycle);
|
|
87131
|
+
if (limits.cycleList.length > 0 && !limits.cycleList.includes(patch.cycle)) throw new BaichuanRecordConfigError(`cycle ${patch.cycle} is not one of the values this camera accepts (${limits.cycleList.join(", ")}); it would answer 200 and keep the old value`);
|
|
87132
|
+
}
|
|
87133
|
+
if (patch.recordDelayTime !== void 0) {
|
|
87134
|
+
requireInteger("recordDelayTime", patch.recordDelayTime);
|
|
87135
|
+
if (patch.recordDelayTime < 0) throw new BaichuanRecordConfigError(`recordDelayTime must not be negative, got ${patch.recordDelayTime}`);
|
|
87136
|
+
}
|
|
87137
|
+
if (patch.packageTime !== void 0) {
|
|
87138
|
+
requireInteger("packageTime", patch.packageTime);
|
|
87139
|
+
if (patch.packageTime < 1) throw new BaichuanRecordConfigError(`packageTime is a length in minutes and must be at least 1, got ${patch.packageTime}`);
|
|
87140
|
+
}
|
|
87141
|
+
if (patch.preRecordEnabled !== void 0 && !limits.preRecordSupported) throw new BaichuanRecordConfigError("this camera does not report a pre-record setting, so writing one would do nothing");
|
|
87142
|
+
};
|
|
87143
|
+
var isWeeklyMask = (mask) => mask.length === 168 && /^[01]+$/.test(mask);
|
|
87144
|
+
var validateRecordSchedulePatch = (patch, rows) => {
|
|
87145
|
+
for (const entry of patch.entries ?? []) {
|
|
87146
|
+
if (!isWeeklyMask(entry.valueTable)) throw new BaichuanRecordConfigError(`the weekly mask for ${describeEntry(entry)} must be exactly 168 characters of 0 or 1, got ${entry.valueTable.length}; the camera would answer 200 and keep the old mask`);
|
|
87147
|
+
if (!rows.some((row) => row.type === entry.type && (entry.index === void 0 || row.index === entry.index))) throw new BaichuanRecordConfigError(`this camera has no recording schedule for ${describeEntry(entry)}; it lists ${rows.map(describeRow).join(", ") || "nothing"}`);
|
|
87148
|
+
}
|
|
87149
|
+
};
|
|
87150
|
+
var describeEntry = (entry) => entry.index === void 0 ? entry.type : `${entry.type}[${entry.index}]`;
|
|
87151
|
+
var describeRow = (row) => row.index === null ? row.type : `${row.type}[${row.index}]`;
|
|
87152
|
+
var replaceTag = (xml, tag2, value) => xml.replace(new RegExp(`<${tag2}>[^<]*</${tag2}>`), `<${tag2}>${value}</${tag2}>`);
|
|
87153
|
+
var buildRecordCfgSetXml = (currentXml, patch, limits) => {
|
|
87154
|
+
let xml = currentXml;
|
|
87155
|
+
if (patch.cycle !== void 0) xml = replaceTag(xml, "cycle", String(patch.cycle));
|
|
87156
|
+
if (patch.recordDelayTime !== void 0) xml = replaceTag(xml, "recordDelayTime", String(patch.recordDelayTime));
|
|
87157
|
+
if (patch.packageTime !== void 0) xml = replaceTag(xml, "packageTime", String(patch.packageTime));
|
|
87158
|
+
if (patch.preRecordEnabled !== void 0) {
|
|
87159
|
+
const seconds = patch.preRecordEnabled ? limits.preRecordSeconds ?? DEFAULT_PRE_RECORD_SECONDS : 0;
|
|
87160
|
+
xml = replaceTag(xml, "preRecordTime", String(seconds));
|
|
87161
|
+
}
|
|
87162
|
+
return xml;
|
|
87163
|
+
};
|
|
87164
|
+
var buildRecordScheduleSetXml = (currentXml, patch) => {
|
|
87165
|
+
let xml = currentXml;
|
|
87166
|
+
if (patch.enable !== void 0) xml = replaceTag(xml, "enable", patch.enable ? "1" : "0");
|
|
87167
|
+
for (const entry of patch.entries ?? []) xml = replaceItemValueTable(xml, entry);
|
|
87168
|
+
return xml;
|
|
87169
|
+
};
|
|
87170
|
+
var ITEM_RE = /<item>([\s\S]*?)<\/item>/g;
|
|
87171
|
+
var replaceItemValueTable = (xml, entry) => xml.replace(ITEM_RE, (whole, inner) => {
|
|
87172
|
+
if ((getXmlText(inner, "type") ?? "").trim() !== entry.type) return whole;
|
|
87173
|
+
if (entry.index !== void 0) {
|
|
87174
|
+
if (parseNumber(getXmlText(inner, "index")) !== entry.index) return whole;
|
|
87175
|
+
}
|
|
87176
|
+
if (getXmlText(inner, "valueTable") === void 0) return whole;
|
|
87177
|
+
return `<item>${replaceTag(inner, "valueTable", entry.valueTable)}</item>`;
|
|
87178
|
+
});
|
|
87179
|
+
var verdict = (outcomes) => ({
|
|
87180
|
+
outcomes,
|
|
87181
|
+
allApplied: outcomes.every((o) => o.status === "applied")
|
|
87182
|
+
});
|
|
87183
|
+
var compare = (field, requested, actual) => actual === requested ? {
|
|
87184
|
+
field,
|
|
87185
|
+
status: "applied",
|
|
87186
|
+
value: requested
|
|
87187
|
+
} : {
|
|
87188
|
+
field,
|
|
87189
|
+
status: "ignored",
|
|
87190
|
+
requested,
|
|
87191
|
+
actual: actual ?? "(absent)"
|
|
87192
|
+
};
|
|
87193
|
+
var diffRecordCfg = (patch, afterXml) => {
|
|
87194
|
+
const block = getXmlBlocks(afterXml, "RecordCfg")[0] ?? "";
|
|
87195
|
+
const outcomes = [];
|
|
87196
|
+
if (patch.cycle !== void 0) outcomes.push(compare("cycle", String(patch.cycle), getXmlText(block, "cycle")));
|
|
87197
|
+
if (patch.recordDelayTime !== void 0) outcomes.push(compare("recordDelayTime", String(patch.recordDelayTime), getXmlText(block, "recordDelayTime")));
|
|
87198
|
+
if (patch.packageTime !== void 0) outcomes.push(compare("packageTime", String(patch.packageTime), getXmlText(block, "packageTime")));
|
|
87199
|
+
if (patch.preRecordEnabled !== void 0) {
|
|
87200
|
+
const seconds = parseNumber(getXmlText(block, "preRecordTime"));
|
|
87201
|
+
const on = seconds !== void 0 && seconds !== 0;
|
|
87202
|
+
outcomes.push(compare("preRecordEnabled", String(patch.preRecordEnabled), seconds === void 0 ? void 0 : String(on)));
|
|
87203
|
+
}
|
|
87204
|
+
return verdict(outcomes);
|
|
87205
|
+
};
|
|
87206
|
+
var diffRecordSchedule = (patch, afterXml) => {
|
|
87207
|
+
const outcomes = [];
|
|
87208
|
+
const block = getXmlBlocks(afterXml, "Record")[0] ?? "";
|
|
87209
|
+
if (patch.enable !== void 0) outcomes.push(compare("enable", patch.enable ? "1" : "0", getXmlText(block, "enable")));
|
|
87210
|
+
const rows = parseRecordScheduleRows(afterXml);
|
|
87211
|
+
for (const entry of patch.entries ?? []) {
|
|
87212
|
+
const row = rows.find((r) => r.type === entry.type && (entry.index === void 0 || r.index === entry.index));
|
|
87213
|
+
outcomes.push(compare(`schedule.${describeEntry(entry)}`, entry.valueTable, row === void 0 ? void 0 : row.valueTable));
|
|
87214
|
+
}
|
|
87215
|
+
return verdict(outcomes);
|
|
87216
|
+
};
|
|
85900
87217
|
function toNumberOrUndefined(value) {
|
|
85901
87218
|
if (value == null) return void 0;
|
|
85902
87219
|
const n = Number(value);
|
|
@@ -86510,254 +87827,6 @@ var estimateVideoTiming = (params) => {
|
|
|
86510
87827
|
fpsSource: "unknown"
|
|
86511
87828
|
};
|
|
86512
87829
|
};
|
|
86513
|
-
var buildDetectionClasses = (parsed, recordType) => {
|
|
86514
|
-
const classes = [];
|
|
86515
|
-
const flags = parsed?.flags;
|
|
86516
|
-
if (flags) {
|
|
86517
|
-
if (flags.aiPerson) classes.push("person");
|
|
86518
|
-
if (flags.aiVehicle) classes.push("vehicle");
|
|
86519
|
-
if (flags.aiAnimal) classes.push("animal");
|
|
86520
|
-
if (flags.aiFace) classes.push("face");
|
|
86521
|
-
if (flags.motion) classes.push("motion");
|
|
86522
|
-
if (flags.schedule) classes.push("schedule");
|
|
86523
|
-
if (flags.doorbell) classes.push("doorbell");
|
|
86524
|
-
if (flags.package) classes.push("package");
|
|
86525
|
-
if (flags.rf) classes.push("rf");
|
|
86526
|
-
if (flags.aiOther) classes.push("other");
|
|
86527
|
-
}
|
|
86528
|
-
if (classes.length === 0 && recordType) {
|
|
86529
|
-
const rt = recordType.toLowerCase();
|
|
86530
|
-
if (rt.includes("people") || rt.includes("person")) classes.push("person");
|
|
86531
|
-
if (rt.includes("vehicle")) classes.push("vehicle");
|
|
86532
|
-
if (rt.includes("dog_cat") || rt.includes("animal")) classes.push("animal");
|
|
86533
|
-
if (rt.includes("face")) classes.push("face");
|
|
86534
|
-
if (rt.includes("md") || rt.includes("motion")) classes.push("motion");
|
|
86535
|
-
if (rt.includes("sched")) classes.push("schedule");
|
|
86536
|
-
if (rt.includes("visitor") || rt.includes("doorbell")) classes.push("doorbell");
|
|
86537
|
-
if (rt.includes("package")) classes.push("package");
|
|
86538
|
-
if (rt.includes("rf")) classes.push("rf");
|
|
86539
|
-
}
|
|
86540
|
-
if (classes.length === 0) classes.push("motion");
|
|
86541
|
-
return classes;
|
|
86542
|
-
};
|
|
86543
|
-
var getXmlTexts = (xml, tags) => {
|
|
86544
|
-
const out = {};
|
|
86545
|
-
for (const tag2 of tags) {
|
|
86546
|
-
const v = getXmlText(xml, tag2);
|
|
86547
|
-
if (v !== void 0) out[tag2] = v;
|
|
86548
|
-
}
|
|
86549
|
-
return out;
|
|
86550
|
-
};
|
|
86551
|
-
var getXmlBlocks = (xml, tagName) => {
|
|
86552
|
-
const re = new RegExp(`<${tagName}\\b[^>]*>([\\s\\S]*?)<\\/${tagName}>`, "g");
|
|
86553
|
-
const out = [];
|
|
86554
|
-
let match;
|
|
86555
|
-
while (match = re.exec(xml)) out.push(match[1] ?? "");
|
|
86556
|
-
return out;
|
|
86557
|
-
};
|
|
86558
|
-
var parseXmlDateTimeBlock = (block, timeZone) => {
|
|
86559
|
-
const year = Number.parseInt(getXmlText(block, "year") ?? "", 10);
|
|
86560
|
-
const month = Number.parseInt(getXmlText(block, "month") ?? "", 10);
|
|
86561
|
-
const day = Number.parseInt(getXmlText(block, "day") ?? "", 10);
|
|
86562
|
-
const hour = Number.parseInt(getXmlText(block, "hour") ?? "", 10);
|
|
86563
|
-
const minute = Number.parseInt(getXmlText(block, "minute") ?? "", 10);
|
|
86564
|
-
const second = Number.parseInt(getXmlText(block, "second") ?? "", 10);
|
|
86565
|
-
if ([
|
|
86566
|
-
year,
|
|
86567
|
-
month,
|
|
86568
|
-
day,
|
|
86569
|
-
hour,
|
|
86570
|
-
minute,
|
|
86571
|
-
second
|
|
86572
|
-
].every(Number.isFinite)) return dateFromWallClock({
|
|
86573
|
-
year,
|
|
86574
|
-
month,
|
|
86575
|
-
day,
|
|
86576
|
-
hour,
|
|
86577
|
-
minute,
|
|
86578
|
-
second
|
|
86579
|
-
}, timeZone);
|
|
86580
|
-
const m = block.replace(/<[^>]*>/g, "").trim().match(/(\d{4})[-/](\d{1,2})[-/](\d{1,2})(?:[ T](\d{1,2}):(\d{1,2})(?::(\d{1,2}))?)?/);
|
|
86581
|
-
if (!m) return void 0;
|
|
86582
|
-
const y = Number.parseInt(m[1] ?? "", 10);
|
|
86583
|
-
const mo = Number.parseInt(m[2] ?? "", 10);
|
|
86584
|
-
const da = Number.parseInt(m[3] ?? "", 10);
|
|
86585
|
-
const ho = Number.parseInt(m[4] ?? "0", 10);
|
|
86586
|
-
const mi = Number.parseInt(m[5] ?? "0", 10);
|
|
86587
|
-
const se = Number.parseInt(m[6] ?? "0", 10);
|
|
86588
|
-
if (![
|
|
86589
|
-
y,
|
|
86590
|
-
mo,
|
|
86591
|
-
da,
|
|
86592
|
-
ho,
|
|
86593
|
-
mi,
|
|
86594
|
-
se
|
|
86595
|
-
].every(Number.isFinite)) return void 0;
|
|
86596
|
-
return dateFromWallClock({
|
|
86597
|
-
year: y,
|
|
86598
|
-
month: mo,
|
|
86599
|
-
day: da,
|
|
86600
|
-
hour: ho,
|
|
86601
|
-
minute: mi,
|
|
86602
|
-
second: se
|
|
86603
|
-
}, timeZone);
|
|
86604
|
-
};
|
|
86605
|
-
var parseRecordingFilesFromXml = (xml, options) => {
|
|
86606
|
-
const timeZone = options?.timeZone;
|
|
86607
|
-
const fileNameOptions = timeZone !== void 0 ? { timeZone } : void 0;
|
|
86608
|
-
const out = [];
|
|
86609
|
-
const fileInfoBlocks = getXmlBlocks(xml, "FileInfo");
|
|
86610
|
-
for (const b of fileInfoBlocks) {
|
|
86611
|
-
const id = getXmlText(b, "Id") ?? getXmlText(b, "ID") ?? getXmlText(b, "id");
|
|
86612
|
-
const name = getXmlText(b, "name") ?? getXmlText(b, "fileName");
|
|
86613
|
-
const chosen = (id ?? name)?.trim();
|
|
86614
|
-
if (!chosen) continue;
|
|
86615
|
-
const item = { fileName: chosen };
|
|
86616
|
-
if (name != null && name.trim()) item.name = name.trim();
|
|
86617
|
-
if (id != null && id.trim()) item.id = id.trim();
|
|
86618
|
-
const recordType = getXmlText(b, "type") ?? getXmlText(b, "recordType") ?? getXmlText(b, "alarmType");
|
|
86619
|
-
if (recordType != null) item.recordType = recordType;
|
|
86620
|
-
const sizeText = getXmlText(b, "size") ?? getXmlText(b, "fileSize");
|
|
86621
|
-
const sizeBytes = sizeText ? Number.parseInt(sizeText, 10) : void 0;
|
|
86622
|
-
if (sizeBytes != null && Number.isFinite(sizeBytes)) item.sizeBytes = sizeBytes;
|
|
86623
|
-
const start = getXmlBlocks(b, "startTime")[0];
|
|
86624
|
-
const end = getXmlBlocks(b, "endTime")[0];
|
|
86625
|
-
const startDt = start ? parseXmlDateTimeBlock(start, timeZone) : void 0;
|
|
86626
|
-
const endDt = end ? parseXmlDateTimeBlock(end, timeZone) : void 0;
|
|
86627
|
-
if (startDt) item.startTime = startDt;
|
|
86628
|
-
if (endDt) item.endTime = endDt;
|
|
86629
|
-
const parsed = parseRecordingFileName(item.name ?? item.fileName, fileNameOptions);
|
|
86630
|
-
const parsedFromPath = item.fileName !== item.name ? parseRecordingFileName(item.fileName, fileNameOptions) : void 0;
|
|
86631
|
-
const bestParsed = parsedFromPath?.sizeBytes != null ? parsedFromPath : parsed;
|
|
86632
|
-
if (bestParsed) {
|
|
86633
|
-
item.parsedFileName = bestParsed;
|
|
86634
|
-
if (!item.startTime) item.startTime = bestParsed.start;
|
|
86635
|
-
if (!item.endTime) item.endTime = bestParsed.end;
|
|
86636
|
-
if (item.sizeBytes == null && bestParsed.sizeBytes != null) item.sizeBytes = bestParsed.sizeBytes;
|
|
86637
|
-
}
|
|
86638
|
-
item.detectionClasses = buildDetectionClasses(parsed, item.recordType);
|
|
86639
|
-
out.push(item);
|
|
86640
|
-
}
|
|
86641
|
-
const fileBlocks = getXmlBlocks(xml, "File");
|
|
86642
|
-
for (const b of fileBlocks) {
|
|
86643
|
-
const fileName = (getXmlText(b, "fileName") ?? getXmlText(b, "name"))?.trim();
|
|
86644
|
-
if (!fileName) continue;
|
|
86645
|
-
const sizeText = getXmlText(b, "size") ?? getXmlText(b, "fileSize");
|
|
86646
|
-
const sizeBytes = sizeText ? Number.parseInt(sizeText, 10) : void 0;
|
|
86647
|
-
const recordType = getXmlText(b, "type") ?? getXmlText(b, "recordType") ?? getXmlText(b, "alarmType");
|
|
86648
|
-
const start = getXmlBlocks(b, "startTime")[0];
|
|
86649
|
-
const end = getXmlBlocks(b, "endTime")[0];
|
|
86650
|
-
const item = { fileName };
|
|
86651
|
-
if (sizeBytes != null && Number.isFinite(sizeBytes)) item.sizeBytes = sizeBytes;
|
|
86652
|
-
if (recordType != null) item.recordType = recordType;
|
|
86653
|
-
const startDt = start ? parseXmlDateTimeBlock(start, timeZone) : void 0;
|
|
86654
|
-
const endDt = end ? parseXmlDateTimeBlock(end, timeZone) : void 0;
|
|
86655
|
-
if (startDt) item.startTime = startDt;
|
|
86656
|
-
if (endDt) item.endTime = endDt;
|
|
86657
|
-
const parsed = parseRecordingFileName(item.fileName, fileNameOptions);
|
|
86658
|
-
if (parsed) {
|
|
86659
|
-
item.parsedFileName = parsed;
|
|
86660
|
-
if (!item.startTime) item.startTime = parsed.start;
|
|
86661
|
-
if (!item.endTime) item.endTime = parsed.end;
|
|
86662
|
-
if (item.sizeBytes == null && parsed.sizeBytes != null) item.sizeBytes = parsed.sizeBytes;
|
|
86663
|
-
}
|
|
86664
|
-
item.detectionClasses = buildDetectionClasses(parsed, item.recordType);
|
|
86665
|
-
out.push(item);
|
|
86666
|
-
}
|
|
86667
|
-
if (out.length === 0) {
|
|
86668
|
-
const re = /<fileName>([\s\S]*?)<\/fileName>/g;
|
|
86669
|
-
const seenNames = /* @__PURE__ */ new Set();
|
|
86670
|
-
let match;
|
|
86671
|
-
while (match = re.exec(xml)) {
|
|
86672
|
-
const fileName = (match[1] ?? "").trim();
|
|
86673
|
-
if (!fileName) continue;
|
|
86674
|
-
if (seenNames.has(fileName)) continue;
|
|
86675
|
-
seenNames.add(fileName);
|
|
86676
|
-
const item = { fileName };
|
|
86677
|
-
const parsed = parseRecordingFileName(fileName, fileNameOptions);
|
|
86678
|
-
if (parsed) {
|
|
86679
|
-
item.parsedFileName = parsed;
|
|
86680
|
-
item.startTime = parsed.start;
|
|
86681
|
-
item.endTime = parsed.end;
|
|
86682
|
-
}
|
|
86683
|
-
item.detectionClasses = buildDetectionClasses(parsed, void 0);
|
|
86684
|
-
out.push(item);
|
|
86685
|
-
}
|
|
86686
|
-
}
|
|
86687
|
-
const alarmBlocks = getXmlBlocks(xml, "alarmVideo");
|
|
86688
|
-
if (alarmBlocks.length > 0) {
|
|
86689
|
-
const byName = /* @__PURE__ */ new Map();
|
|
86690
|
-
for (const existing of out) {
|
|
86691
|
-
const key = existing.fileName.trim();
|
|
86692
|
-
if (!key) continue;
|
|
86693
|
-
if (!byName.has(key)) byName.set(key, existing);
|
|
86694
|
-
}
|
|
86695
|
-
for (const b of alarmBlocks) {
|
|
86696
|
-
const fileName = (getXmlText(b, "fileName") ?? getXmlText(b, "name"))?.trim();
|
|
86697
|
-
if (!fileName) continue;
|
|
86698
|
-
const alarmId = (getXmlText(b, "fileId") ?? getXmlText(b, "recFileId") ?? getXmlText(b, "recFileName") ?? getXmlText(b, "recFile") ?? getXmlText(b, "recordFile") ?? getXmlText(b, "Id") ?? getXmlText(b, "ID") ?? getXmlText(b, "id") ?? "").trim();
|
|
86699
|
-
const alarmType = getXmlText(b, "alarmType")?.trim();
|
|
86700
|
-
const start = getXmlBlocks(b, "startTime")[0];
|
|
86701
|
-
const end = getXmlBlocks(b, "endTime")[0];
|
|
86702
|
-
const startDt = start ? parseXmlDateTimeBlock(start, timeZone) : void 0;
|
|
86703
|
-
const endDt = end ? parseXmlDateTimeBlock(end, timeZone) : void 0;
|
|
86704
|
-
const target = byName.get(fileName) ?? { fileName };
|
|
86705
|
-
if (alarmId && !target.id) target.id = alarmId;
|
|
86706
|
-
if (alarmType) target.recordType = alarmType;
|
|
86707
|
-
if (startDt) target.startTime = startDt;
|
|
86708
|
-
if (endDt) target.endTime = endDt;
|
|
86709
|
-
if (!target.parsedFileName) {
|
|
86710
|
-
const parsed = parseRecordingFileName(target.fileName, fileNameOptions);
|
|
86711
|
-
if (parsed) {
|
|
86712
|
-
target.parsedFileName = parsed;
|
|
86713
|
-
if (!target.startTime) target.startTime = parsed.start;
|
|
86714
|
-
if (!target.endTime) target.endTime = parsed.end;
|
|
86715
|
-
}
|
|
86716
|
-
}
|
|
86717
|
-
if (!byName.has(fileName)) {
|
|
86718
|
-
out.push(target);
|
|
86719
|
-
byName.set(fileName, target);
|
|
86720
|
-
}
|
|
86721
|
-
}
|
|
86722
|
-
}
|
|
86723
|
-
const seen = /* @__PURE__ */ new Set();
|
|
86724
|
-
return out.filter((f) => {
|
|
86725
|
-
const key = f.fileName.trim();
|
|
86726
|
-
if (seen.has(key)) return false;
|
|
86727
|
-
seen.add(key);
|
|
86728
|
-
return true;
|
|
86729
|
-
});
|
|
86730
|
-
};
|
|
86731
|
-
function parseTalkAudioConfig(block) {
|
|
86732
|
-
const audioType = getXmlText(block, "audioType");
|
|
86733
|
-
const sampleRate = Number.parseInt(getXmlText(block, "sampleRate") ?? "", 10);
|
|
86734
|
-
const samplePrecision = Number.parseInt(getXmlText(block, "samplePrecision") ?? "", 10);
|
|
86735
|
-
const lengthPerEncoder = Number.parseInt(getXmlText(block, "lengthPerEncoder") ?? "", 10);
|
|
86736
|
-
const soundTrack = getXmlText(block, "soundTrack");
|
|
86737
|
-
const priorityText = getXmlText(block, "priority");
|
|
86738
|
-
if (!audioType || !Number.isFinite(sampleRate) || !Number.isFinite(samplePrecision) || !Number.isFinite(lengthPerEncoder) || !soundTrack) return null;
|
|
86739
|
-
const config = {
|
|
86740
|
-
audioType,
|
|
86741
|
-
sampleRate,
|
|
86742
|
-
samplePrecision,
|
|
86743
|
-
lengthPerEncoder,
|
|
86744
|
-
soundTrack
|
|
86745
|
-
};
|
|
86746
|
-
if (priorityText !== void 0) {
|
|
86747
|
-
const pr = Number.parseInt(priorityText, 10);
|
|
86748
|
-
if (Number.isFinite(pr)) config.priority = pr;
|
|
86749
|
-
}
|
|
86750
|
-
return config;
|
|
86751
|
-
}
|
|
86752
|
-
var parseTalkAbilityXml = (xml) => {
|
|
86753
|
-
const talkAbilityBlock = getXmlBlocks(xml, "TalkAbility")[0];
|
|
86754
|
-
if (!talkAbilityBlock) throw new Error("TalkAbility XML not found in response");
|
|
86755
|
-
return {
|
|
86756
|
-
duplexList: getXmlBlocks(talkAbilityBlock, "duplexList").map((b) => getXmlText(b, "duplex")).filter((v) => Boolean(v)),
|
|
86757
|
-
audioStreamModeList: getXmlBlocks(talkAbilityBlock, "audioStreamModeList").map((b) => getXmlText(b, "audioStreamMode")).filter((v) => Boolean(v)),
|
|
86758
|
-
audioConfigList: getXmlBlocks(talkAbilityBlock, "audioConfig").map(parseTalkAudioConfig).filter((v) => Boolean(v))
|
|
86759
|
-
};
|
|
86760
|
-
};
|
|
86761
87830
|
function daysInMonth(year, month) {
|
|
86762
87831
|
return new Date(Date.UTC(year, month, 0)).getUTCDate();
|
|
86763
87832
|
}
|
|
@@ -87366,17 +88435,6 @@ var TtlCache = class {
|
|
|
87366
88435
|
this.entries.clear();
|
|
87367
88436
|
}
|
|
87368
88437
|
};
|
|
87369
|
-
function parseNumber(v) {
|
|
87370
|
-
if (v == null) return void 0;
|
|
87371
|
-
const n = Number(v);
|
|
87372
|
-
return Number.isFinite(n) ? n : void 0;
|
|
87373
|
-
}
|
|
87374
|
-
function parseBoolean01(v) {
|
|
87375
|
-
if (v == null) return void 0;
|
|
87376
|
-
const s = v.trim();
|
|
87377
|
-
if (s === "1" || s.toLowerCase() === "true") return true;
|
|
87378
|
-
if (s === "0" || s.toLowerCase() === "false") return false;
|
|
87379
|
-
}
|
|
87380
88438
|
function parseOsdDatetimeXml(rawXml) {
|
|
87381
88439
|
const osdBlock = getXmlBlocks(rawXml, "OsdDatetime")[0];
|
|
87382
88440
|
const nameBlock = getXmlBlocks(rawXml, "OsdChannelName")[0];
|
|
@@ -88719,12 +89777,12 @@ var parseRecStartParamIfPresent = (fileName) => {
|
|
|
88719
89777
|
if (!m) return void 0;
|
|
88720
89778
|
return `${m[2]}${m[3]}`;
|
|
88721
89779
|
};
|
|
88722
|
-
var buildReplayStopNameFromFileName = (fileName) => {
|
|
89780
|
+
var buildReplayStopNameFromFileName = (fileName, channel = 0) => {
|
|
88723
89781
|
const trimmed = (fileName ?? "").trim();
|
|
88724
|
-
if (
|
|
89782
|
+
if (/^\d{2}\d{14}$/.test(trimmed)) return trimmed;
|
|
88725
89783
|
const start = parseRecStartParamIfPresent(fileName);
|
|
88726
89784
|
if (!start) return void 0;
|
|
88727
|
-
return
|
|
89785
|
+
return `${String(channel + 1).padStart(2, "0")}${start}`;
|
|
88728
89786
|
};
|
|
88729
89787
|
var buildFileInfoListReplayByIdXml = (params) => {
|
|
88730
89788
|
const st = params.streamType ?? "mainStream";
|
|
@@ -91424,7 +92482,7 @@ var ReolinkBaichuanApi = class _ReolinkBaichuanApi {
|
|
|
91424
92482
|
return;
|
|
91425
92483
|
}
|
|
91426
92484
|
entry.startInFlight = (async () => {
|
|
91427
|
-
const { BaichuanVideoStream: BaichuanVideoStream2 } = await import("./BaichuanVideoStream-
|
|
92485
|
+
const { BaichuanVideoStream: BaichuanVideoStream2 } = await import("./BaichuanVideoStream-UG3DIMIE-BRbIHCdL.mjs");
|
|
91428
92486
|
const sessionKey = `live:object-detections:ch${entry.channel}:${entry.profile}`;
|
|
91429
92487
|
const dedicated = await this.createDedicatedSession(sessionKey);
|
|
91430
92488
|
const stream = new BaichuanVideoStream2({
|
|
@@ -93384,7 +94442,7 @@ var ReolinkBaichuanApi = class _ReolinkBaichuanApi {
|
|
|
93384
94442
|
const stop = async () => {
|
|
93385
94443
|
if (tornDown) return;
|
|
93386
94444
|
tornDown = true;
|
|
93387
|
-
const stopName = buildReplayStopNameFromFileName(params.fileName);
|
|
94445
|
+
const stopName = buildReplayStopNameFromFileName(params.fileName, channel);
|
|
93388
94446
|
if (started && stopName) try {
|
|
93389
94447
|
const stopXml = buildFileInfoListStopXml({
|
|
93390
94448
|
channel,
|
|
@@ -93493,7 +94551,7 @@ var ReolinkBaichuanApi = class _ReolinkBaichuanApi {
|
|
|
93493
94551
|
const stop = async () => {
|
|
93494
94552
|
if (tornDown) return;
|
|
93495
94553
|
tornDown = true;
|
|
93496
|
-
const stopName = buildReplayStopNameFromFileName(params.fileName);
|
|
94554
|
+
const stopName = buildReplayStopNameFromFileName(params.fileName, channel);
|
|
93497
94555
|
if (started && stopName) try {
|
|
93498
94556
|
const stopXml = buildFileInfoListStopXml({
|
|
93499
94557
|
channel,
|
|
@@ -94445,11 +95503,13 @@ ${stderr}`));
|
|
|
94445
95503
|
});
|
|
94446
95504
|
const timeoutMs = params.timeoutMs ?? 12e4;
|
|
94447
95505
|
trace(`download: channel=${channel} uid=${uid || "(missing)"} ident=${ident} streamType=${streamType} isNvr=${isNvr} timeoutMs=${timeoutMs}`);
|
|
95506
|
+
const pinnedHeaderChannelId = params.headerChannelIdOverride;
|
|
95507
|
+
let abandoned = false;
|
|
94448
95508
|
try {
|
|
94449
95509
|
return await this.client.sendBinary({
|
|
94450
95510
|
cmdId: 5,
|
|
94451
95511
|
channel,
|
|
94452
|
-
...
|
|
95512
|
+
...pinnedHeaderChannelId != null ? { channelIdOverride: pinnedHeaderChannelId } : {},
|
|
94453
95513
|
msgNumOverride: 0,
|
|
94454
95514
|
messageClass: BC_CLASS_MODERN_24,
|
|
94455
95515
|
payloadXml,
|
|
@@ -94460,8 +95520,53 @@ ${stderr}`));
|
|
|
94460
95520
|
});
|
|
94461
95521
|
} catch (e) {
|
|
94462
95522
|
trace(`download failed: ${e instanceof Error ? e.message : String(e)}`);
|
|
95523
|
+
abandoned = true;
|
|
94463
95524
|
throw e;
|
|
95525
|
+
} finally {
|
|
95526
|
+
await this.stopFileInfoListReplay({
|
|
95527
|
+
channel,
|
|
95528
|
+
fileName: ident,
|
|
95529
|
+
streamType,
|
|
95530
|
+
drain: abandoned
|
|
95531
|
+
});
|
|
95532
|
+
}
|
|
95533
|
+
}
|
|
95534
|
+
/**
|
|
95535
|
+
* Close a cmd 5 replay session at the camera (cmd 7) and wait for the wire to
|
|
95536
|
+
* go quiet.
|
|
95537
|
+
*
|
|
95538
|
+
* Best-effort by design: a session that has already ended answers with an
|
|
95539
|
+
* error on some firmwares and there is nothing to do about it. The DRAIN is
|
|
95540
|
+
* not best-effort — the ack comes back before the camera has finished
|
|
95541
|
+
* sending (the Reolink app's own capture shows 26–91 further frames after
|
|
95542
|
+
* four of eleven stops), and the next replay must not be opened into that
|
|
95543
|
+
* tail.
|
|
95544
|
+
*/
|
|
95545
|
+
async stopFileInfoListReplay(params) {
|
|
95546
|
+
const stopName = buildReplayStopNameFromFileName(params.fileName, params.channel);
|
|
95547
|
+
if (!stopName) {
|
|
95548
|
+
this.logger?.debug?.(`[stopFileInfoListReplay] no stop name derivable from ${params.fileName}; the session is left to time out at the camera`);
|
|
95549
|
+
return;
|
|
95550
|
+
}
|
|
95551
|
+
try {
|
|
95552
|
+
await this.client.sendXml({
|
|
95553
|
+
cmdId: 7,
|
|
95554
|
+
channel: params.channel,
|
|
95555
|
+
payloadXml: buildFileInfoListStopXml({
|
|
95556
|
+
channel: params.channel,
|
|
95557
|
+
name: stopName,
|
|
95558
|
+
streamType: params.streamType
|
|
95559
|
+
}),
|
|
95560
|
+
messageClass: BC_CLASS_MODERN_24,
|
|
95561
|
+
timeoutMs: 4e3,
|
|
95562
|
+
internal: true
|
|
95563
|
+
});
|
|
95564
|
+
} catch (e) {
|
|
95565
|
+
this.logger?.debug?.(`[stopFileInfoListReplay] stop <name>${stopName}</name> failed: ${e instanceof Error ? e.message : String(e)}`);
|
|
94464
95566
|
}
|
|
95567
|
+
if (!params.drain) return;
|
|
95568
|
+
const drained = await this.client.drainReplayFrames();
|
|
95569
|
+
if (drained > 0) this.logger?.debug?.(`[stopFileInfoListReplay] ${drained} cmd 5 frame(s) still arrived after the stop (<name>${stopName}</name>); drained before releasing the socket`);
|
|
94465
95570
|
}
|
|
94466
95571
|
async downloadRecording(params) {
|
|
94467
95572
|
this.logger?.debug?.(`[downloadRecording] Queuing download for: ${params.fileName}, channel=${params.channel}`);
|
|
@@ -97581,7 +98686,7 @@ ${xml}`);
|
|
|
97581
98686
|
* @returns Test results for all stream types and profiles
|
|
97582
98687
|
*/
|
|
97583
98688
|
async testChannelStreams(channel, logger) {
|
|
97584
|
-
const { testChannelStreams } = await import("./DiagnosticsTools-
|
|
98689
|
+
const { testChannelStreams } = await import("./DiagnosticsTools-YIWXO7IJ-9NV95vRN.mjs");
|
|
97585
98690
|
return await testChannelStreams({
|
|
97586
98691
|
api: this,
|
|
97587
98692
|
channel: this.normalizeChannel(channel),
|
|
@@ -97597,7 +98702,7 @@ ${xml}`);
|
|
|
97597
98702
|
* @returns Complete diagnostics for all channels and streams
|
|
97598
98703
|
*/
|
|
97599
98704
|
async collectMultifocalDiagnostics(logger) {
|
|
97600
|
-
const { collectMultifocalDiagnostics } = await import("./DiagnosticsTools-
|
|
98705
|
+
const { collectMultifocalDiagnostics } = await import("./DiagnosticsTools-YIWXO7IJ-9NV95vRN.mjs");
|
|
97601
98706
|
return await collectMultifocalDiagnostics({
|
|
97602
98707
|
api: this,
|
|
97603
98708
|
logger
|
|
@@ -98419,6 +99524,115 @@ ${xml}`);
|
|
|
98419
99524
|
};
|
|
98420
99525
|
})() : {};
|
|
98421
99526
|
}
|
|
99527
|
+
/**
|
|
99528
|
+
* The limits THIS camera states for `<RecordCfg>` (cmd 54), read from its
|
|
99529
|
+
* own reply — `<cyclelist>`, `<timeList>`, whether pre-record exists at all.
|
|
99530
|
+
*
|
|
99531
|
+
* Read this before offering a control: a firmware that stops accepting a
|
|
99532
|
+
* value stops listing it, and {@link setRecordCfg} refuses against the same
|
|
99533
|
+
* facts.
|
|
99534
|
+
*/
|
|
99535
|
+
async getRecordCfgLimits(channel, options) {
|
|
99536
|
+
return parseRecordCfgLimits(await this.sendPcapDerivedSettingsGetXml({
|
|
99537
|
+
cmdId: 54,
|
|
99538
|
+
channel,
|
|
99539
|
+
...options?.timeoutMs != null ? { timeoutMs: options.timeoutMs } : {}
|
|
99540
|
+
}));
|
|
99541
|
+
}
|
|
99542
|
+
/**
|
|
99543
|
+
* SetRecCfg (cmd 55) — write the onboard recording configuration.
|
|
99544
|
+
*
|
|
99545
|
+
* Read-modify-write over the camera's own cmd 54 document, then a re-read to
|
|
99546
|
+
* find out what it actually kept. Both halves are load-bearing, because this
|
|
99547
|
+
* command **never reports a bad value**: measured on 2026-09-22, a
|
|
99548
|
+
* `preRecordTime` of 9999 and a `cycle` outside the camera's own
|
|
99549
|
+
* `<cyclelist>` were each answered `200` and discarded. The returned
|
|
99550
|
+
* {@link BaichuanRecordWriteResult} is the only honest answer to "did that
|
|
99551
|
+
* work", so callers should look at `allApplied` rather than at the absence
|
|
99552
|
+
* of a thrown error.
|
|
99553
|
+
*
|
|
99554
|
+
* Values the camera would certainly drop are refused BEFORE the wire with a
|
|
99555
|
+
* {@link BaichuanRecordConfigError}.
|
|
99556
|
+
*
|
|
99557
|
+
* `preRecordEnabled` is a boolean on purpose — see `utils/recordConfig.ts`.
|
|
99558
|
+
*
|
|
99559
|
+
* Verified live: `packageTime` 5 → 10 and `recordDelayTime` 15 → 30 on E1
|
|
99560
|
+
* Outdoor PoE v3.1.0.5223, `recordDelayTime` 15 → 30 → 20 on a Home Hub
|
|
99561
|
+
* v3.3.0.456 channel 0, each read back and restored.
|
|
99562
|
+
*/
|
|
99563
|
+
async setRecordCfg(channel, patch, options) {
|
|
99564
|
+
const ch = this.normalizeChannel(channel);
|
|
99565
|
+
const timeoutOpts = options?.timeoutMs != null ? { timeoutMs: options.timeoutMs } : {};
|
|
99566
|
+
const currentXml = await this.sendXml({
|
|
99567
|
+
cmdId: 54,
|
|
99568
|
+
channel: ch,
|
|
99569
|
+
...timeoutOpts
|
|
99570
|
+
});
|
|
99571
|
+
const limits = parseRecordCfgLimits(currentXml);
|
|
99572
|
+
validateRecordCfgPatch(patch, limits);
|
|
99573
|
+
await this.sendXml({
|
|
99574
|
+
cmdId: 55,
|
|
99575
|
+
channel: ch,
|
|
99576
|
+
payloadXml: ensureXmlHeader(buildRecordCfgSetXml(currentXml, patch, limits)),
|
|
99577
|
+
...timeoutOpts
|
|
99578
|
+
});
|
|
99579
|
+
return diffRecordCfg(patch, await this.sendXml({
|
|
99580
|
+
cmdId: 54,
|
|
99581
|
+
channel: ch,
|
|
99582
|
+
...timeoutOpts
|
|
99583
|
+
}));
|
|
99584
|
+
}
|
|
99585
|
+
/**
|
|
99586
|
+
* The schedule rows THIS camera lists (cmd 81), `(type, index)` and the
|
|
99587
|
+
* current mask for each.
|
|
99588
|
+
*
|
|
99589
|
+
* `<index>` matters: an E1 Outdoor Pro lists `crossline`, `intrude` and
|
|
99590
|
+
* `loitering` three times each, one row per configured rule, and a write
|
|
99591
|
+
* addressed by `type` alone would overwrite all three.
|
|
99592
|
+
*/
|
|
99593
|
+
async getRecordScheduleRows(channel, options) {
|
|
99594
|
+
return parseRecordScheduleRows(await this.sendPcapDerivedSettingsGetXml({
|
|
99595
|
+
cmdId: 81,
|
|
99596
|
+
channel,
|
|
99597
|
+
...options?.timeoutMs != null ? { timeoutMs: options.timeoutMs } : {}
|
|
99598
|
+
}));
|
|
99599
|
+
}
|
|
99600
|
+
/**
|
|
99601
|
+
* SetRec (cmd 82) — write the weekly recording schedule.
|
|
99602
|
+
*
|
|
99603
|
+
* Same contract as {@link setRecordCfg}: refuse what is certainly bad before
|
|
99604
|
+
* the wire, then verify by re-reading. A 167-character mask and a `<type>`
|
|
99605
|
+
* the camera does not have were each answered `200` and dropped, and the
|
|
99606
|
+
* drop is **per item** — one document carrying a bad `Normal` mask and a
|
|
99607
|
+
* good `MD` mask applied the `MD` one and discarded the other. So a caller
|
|
99608
|
+
* cannot treat this write as atomic, and the per-field
|
|
99609
|
+
* {@link BaichuanRecordWriteResult} says which rows landed.
|
|
99610
|
+
*
|
|
99611
|
+
* Verified live on E1 Outdoor PoE v3.1.0.5223: `Normal` hour 0 flipped on
|
|
99612
|
+
* and restored, via both a full document and a one-item document (the camera
|
|
99613
|
+
* merges per row and leaves the other rows alone).
|
|
99614
|
+
*/
|
|
99615
|
+
async setRecordSchedule(channel, patch, options) {
|
|
99616
|
+
const ch = this.normalizeChannel(channel);
|
|
99617
|
+
const timeoutOpts = options?.timeoutMs != null ? { timeoutMs: options.timeoutMs } : {};
|
|
99618
|
+
const currentXml = await this.sendXml({
|
|
99619
|
+
cmdId: 81,
|
|
99620
|
+
channel: ch,
|
|
99621
|
+
...timeoutOpts
|
|
99622
|
+
});
|
|
99623
|
+
validateRecordSchedulePatch(patch, parseRecordScheduleRows(currentXml));
|
|
99624
|
+
await this.sendXml({
|
|
99625
|
+
cmdId: 82,
|
|
99626
|
+
channel: ch,
|
|
99627
|
+
payloadXml: ensureXmlHeader(buildRecordScheduleSetXml(currentXml, patch)),
|
|
99628
|
+
...timeoutOpts
|
|
99629
|
+
});
|
|
99630
|
+
return diffRecordSchedule(patch, await this.sendXml({
|
|
99631
|
+
cmdId: 81,
|
|
99632
|
+
channel: ch,
|
|
99633
|
+
...timeoutOpts
|
|
99634
|
+
}));
|
|
99635
|
+
}
|
|
98422
99636
|
async getWifiSignal(channel, options) {
|
|
98423
99637
|
const signal = parseNumber(getXmlText(await this.sendPcapDerivedSettingsGetXml({
|
|
98424
99638
|
cmdId: 115,
|
|
@@ -98617,6 +99831,22 @@ ${xml}`);
|
|
|
98617
99831
|
}));
|
|
98618
99832
|
}
|
|
98619
99833
|
/**
|
|
99834
|
+
* cmd 102 `<HddInfoList>`, typed — one record per volume with the size
|
|
99835
|
+
* arithmetic already done.
|
|
99836
|
+
*
|
|
99837
|
+
* Prefer this over {@link getHddInfoList}. The raw reply splits size across
|
|
99838
|
+
* `capacity` (whole GB) and `capacityM` (MB remainder), and newer firmwares
|
|
99839
|
+
* add exact byte counts in `capacityV2` / `remainSizeV2`; `capacityMb` and
|
|
99840
|
+
* `remainMb` fold whichever pair the camera sent, and are **null when it
|
|
99841
|
+
* sent neither** rather than 0.
|
|
99842
|
+
*/
|
|
99843
|
+
async getHddInfo(options) {
|
|
99844
|
+
return parseHddInfoListXml(await this.sendXml({
|
|
99845
|
+
cmdId: 102,
|
|
99846
|
+
...options?.timeoutMs != null ? { timeoutMs: options.timeoutMs } : {}
|
|
99847
|
+
}));
|
|
99848
|
+
}
|
|
99849
|
+
/**
|
|
98620
99850
|
* cmd 142 `<DayRecords>` — which days of a month have footage, for one
|
|
98621
99851
|
* channel. The calendar the official app draws.
|
|
98622
99852
|
*
|
|
@@ -150399,7 +151629,7 @@ function writeRefusal(res, refusal, deviceId, logger, clipKey) {
|
|
|
150399
151629
|
else logger.debug(line, extras);
|
|
150400
151630
|
const retryAfterMs = refusal.disposition === "deferred" ? refusal.retryAfterMs : void 0;
|
|
150401
151631
|
res.writeHead(204, {
|
|
150402
|
-
[CLIP_STILL_REASON_HEADER]: refusal.reason
|
|
151632
|
+
[CLIP_STILL_REASON_HEADER]: clipReasonHeaderValue(refusal.reason),
|
|
150403
151633
|
[CLIP_STILL_REASON_CODE_HEADER]: refusal.code,
|
|
150404
151634
|
[CLIP_STILL_DISPOSITION_HEADER]: refusal.disposition,
|
|
150405
151635
|
...retryAfterMs === void 0 ? {} : { "retry-after": String(Math.max(1, Math.ceil(retryAfterMs / 1e3))) }
|
|
@@ -150596,7 +151826,7 @@ function createClipStreamHandler(deps) {
|
|
|
150596
151826
|
status: resolved.status
|
|
150597
151827
|
} });
|
|
150598
151828
|
res.writeHead(resolved.status, {
|
|
150599
|
-
[CLIP_STILL_REASON_HEADER]: resolved.detail
|
|
151829
|
+
[CLIP_STILL_REASON_HEADER]: clipReasonHeaderValue(resolved.detail),
|
|
150600
151830
|
[CLIP_STILL_REASON_CODE_HEADER]: resolved.code
|
|
150601
151831
|
});
|
|
150602
151832
|
res.end();
|
|
@@ -150669,7 +151899,7 @@ function createClipStreamHandler(deps) {
|
|
|
150669
151899
|
}
|
|
150670
151900
|
});
|
|
150671
151901
|
res.writeHead(streamRefusalStatus(outcome.code), {
|
|
150672
|
-
[CLIP_STILL_REASON_HEADER]: outcome.detail
|
|
151902
|
+
[CLIP_STILL_REASON_HEADER]: clipReasonHeaderValue(outcome.detail),
|
|
150673
151903
|
[CLIP_STILL_REASON_CODE_HEADER]: outcome.code
|
|
150674
151904
|
});
|
|
150675
151905
|
res.end();
|
|
@@ -150943,6 +152173,14 @@ function buildClipStreamMuxArgs(input) {
|
|
|
150943
152173
|
* not push the deadline out. `drain` — the hop's own word that it took what
|
|
150944
152174
|
* it held — disarms it. Stall fires at most once, and a disposed gate is
|
|
150945
152175
|
* silent for ever.
|
|
152176
|
+
*
|
|
152177
|
+
* **A gate must be DISPOSED when its hop stops taking bytes for good** — when
|
|
152178
|
+
* the input is ended, and when the run is over. Node emits no `drain` after
|
|
152179
|
+
* `end()` (`afterWrite` guards it with `!state.ending`), so a gate armed at
|
|
152180
|
+
* EOF can never be disarmed: it fires `timeoutMs` later and severs a stream
|
|
152181
|
+
* that was already served. Measured on real bytes: a 42 s clip delivered at
|
|
152182
|
+
* 18× leaves the audio pipe armed at EOF every time, with zero `drain`
|
|
152183
|
+
* events after `end()`. (D604)
|
|
150946
152184
|
*/
|
|
150947
152185
|
function createDrainGate(deps) {
|
|
150948
152186
|
let handle = null;
|
|
@@ -151103,7 +152341,33 @@ function createClipStreamProducer(deps) {
|
|
|
151103
152341
|
}
|
|
151104
152342
|
});
|
|
151105
152343
|
activeByHost.set(hostKey, ahead + 1);
|
|
151106
|
-
|
|
152344
|
+
let abandonedAt = null;
|
|
152345
|
+
input.sink.onceClose(() => {
|
|
152346
|
+
abandonedAt ??= Date.now();
|
|
152347
|
+
});
|
|
152348
|
+
const queuedAt = Date.now();
|
|
152349
|
+
const run = (lanes.get(hostKey) ?? Promise.resolve()).then(async () => {
|
|
152350
|
+
if (abandonedAt !== null) {
|
|
152351
|
+
const waitedMs = abandonedAt - queuedAt;
|
|
152352
|
+
deps.logger.info("videoclips: a queued clip stream was dropped, its peer had gone", {
|
|
152353
|
+
tags: { deviceId },
|
|
152354
|
+
meta: {
|
|
152355
|
+
clipKey: input.row.clipKey,
|
|
152356
|
+
twin: input.twin,
|
|
152357
|
+
branch: "queued-peer-gone",
|
|
152358
|
+
waitedMs,
|
|
152359
|
+
aheadWhenQueued: ahead
|
|
152360
|
+
}
|
|
152361
|
+
});
|
|
152362
|
+
return {
|
|
152363
|
+
kind: "refused",
|
|
152364
|
+
code: "fetch-failed",
|
|
152365
|
+
detail: `the peer left after ${String(waitedMs)} ms in the queue, so this clip was never fetched`,
|
|
152366
|
+
attempts: 0
|
|
152367
|
+
};
|
|
152368
|
+
}
|
|
152369
|
+
return await runAttempts(input);
|
|
152370
|
+
}).finally(() => {
|
|
151107
152371
|
const left = (activeByHost.get(hostKey) ?? 1) - 1;
|
|
151108
152372
|
if (left <= 0) activeByHost.delete(hostKey);
|
|
151109
152373
|
else activeByHost.set(hostKey, left);
|
|
@@ -151147,8 +152411,18 @@ var ClipStreamRun = class {
|
|
|
151147
152411
|
accessUnits: 0
|
|
151148
152412
|
};
|
|
151149
152413
|
videoType = null;
|
|
152414
|
+
/** The last access unit's timestamp, to catch a transfer that went BACK. */
|
|
152415
|
+
lastUs = null;
|
|
152416
|
+
/** Units of a PREVIOUS transfer, dropped before the mux existed (D604). */
|
|
152417
|
+
residueUnits = 0;
|
|
152418
|
+
residueAudioFrames = 0;
|
|
152419
|
+
/** A backwards timestamp that arrived too late to drop: the duration of
|
|
152420
|
+
* this pass cannot be measured, and is never guessed (D393). */
|
|
152421
|
+
timestampsBroken = false;
|
|
151150
152422
|
begun = false;
|
|
151151
152423
|
ended = false;
|
|
152424
|
+
/** The run is over. Every late callback is silent from here (D604). */
|
|
152425
|
+
done = false;
|
|
151152
152426
|
bytesOut = 0;
|
|
151153
152427
|
cut = null;
|
|
151154
152428
|
stdinGate;
|
|
@@ -151205,17 +152479,28 @@ var ClipStreamRun = class {
|
|
|
151205
152479
|
};
|
|
151206
152480
|
}
|
|
151207
152481
|
try {
|
|
151208
|
-
|
|
151209
|
-
|
|
151210
|
-
|
|
151211
|
-
|
|
151212
|
-
|
|
151213
|
-
|
|
151214
|
-
|
|
151215
|
-
|
|
151216
|
-
|
|
152482
|
+
try {
|
|
152483
|
+
await api.fileInfoListReplayBinaryDownload({
|
|
152484
|
+
channel: device.channel,
|
|
152485
|
+
...device.uid !== void 0 ? { uid: device.uid } : {},
|
|
152486
|
+
fileName,
|
|
152487
|
+
idleTimeoutMs: 300,
|
|
152488
|
+
onChunk: (chunk) => this.onChunk(decoder, chunk)
|
|
152489
|
+
});
|
|
152490
|
+
} catch (err) {
|
|
152491
|
+
return this.onTransferFailed(err);
|
|
152492
|
+
}
|
|
152493
|
+
return await this.onTransferEnded();
|
|
152494
|
+
} finally {
|
|
152495
|
+
this.done = true;
|
|
152496
|
+
this.disposeGates();
|
|
151217
152497
|
}
|
|
151218
|
-
|
|
152498
|
+
}
|
|
152499
|
+
/** Every hop's drain gate, silenced. Idempotent. */
|
|
152500
|
+
disposeGates() {
|
|
152501
|
+
this.stdinGate.dispose();
|
|
152502
|
+
this.audioStdinGate.dispose();
|
|
152503
|
+
this.peerGate.dispose();
|
|
151219
152504
|
}
|
|
151220
152505
|
/** The camera's bytes, as they arrive. A cut run THROWS here: the only stop. */
|
|
151221
152506
|
onChunk(decoder, chunk) {
|
|
@@ -151229,14 +152514,81 @@ var ClipStreamRun = class {
|
|
|
151229
152514
|
}
|
|
151230
152515
|
onAccessUnit(unit) {
|
|
151231
152516
|
if (this.cut !== null) return;
|
|
152517
|
+
const previousUs = this.lastUs;
|
|
152518
|
+
this.lastUs = unit.microseconds;
|
|
152519
|
+
if (previousUs !== null && unit.microseconds < previousUs) this.onTimestampsWentBack(previousUs, unit.microseconds);
|
|
151232
152520
|
this.accessUnits += 1;
|
|
151233
152521
|
this.timestampsUs.push(unit.microseconds);
|
|
151234
|
-
if (this.mux
|
|
151235
|
-
this.
|
|
151236
|
-
if (this.probe.length >= 16) this.startMux();
|
|
152522
|
+
if (this.mux !== null) {
|
|
152523
|
+
this.feed(unit.annexB);
|
|
151237
152524
|
return;
|
|
151238
152525
|
}
|
|
151239
|
-
this.
|
|
152526
|
+
this.probe = [...this.probe, unit];
|
|
152527
|
+
if (this.probe.length < 16) return;
|
|
152528
|
+
if (probeClipStreamRate(this.timestampsUs).fpsSource === "timestamps" || this.probe.length >= 128) this.startMux();
|
|
152529
|
+
}
|
|
152530
|
+
/**
|
|
152531
|
+
* A timestamp that went BACKWARD: this is a second transfer's first frame.
|
|
152532
|
+
*
|
|
152533
|
+
* The library's replay listener cannot tell one transfer from the next —
|
|
152534
|
+
* every cmd 5 replay carries `msgNum: 0` and, on a hub child, the same
|
|
152535
|
+
* header channel id — and a CUT transfer is never stopped at the camera
|
|
152536
|
+
* (nothing on the wire aborts one; the producer's only lever is to throw
|
|
152537
|
+
* from the next chunk). So the frames still in flight for the previous,
|
|
152538
|
+
* aborted stream are handed to THIS one, ahead of its own first frame.
|
|
152539
|
+
*
|
|
152540
|
+
* Measured on 3628, 2026-09-22 16:41: a stream cut at 55 access units was
|
|
152541
|
+
* followed 1 ms later by another, which decoded 640 units instead of 635
|
|
152542
|
+
* and 666 audio frames instead of 661 — the same five extra of each — and
|
|
152543
|
+
* spanned 38.52 s instead of 42.21 s, exactly the 3.69 s the cut stream had
|
|
152544
|
+
* already delivered. Its rate probe therefore measured a NEGATIVE span and
|
|
152545
|
+
* refused to name a rate, which is the only way `estimateVideoTiming` can
|
|
152546
|
+
* answer anything but `timestamps` here.
|
|
152547
|
+
*
|
|
152548
|
+
* Before the mux exists the answer is to DROP what was held and probe
|
|
152549
|
+
* again: nothing has reached the peer, so the residue can simply not be
|
|
152550
|
+
* part of this stream. After it exists there is nothing to drop — the bytes
|
|
152551
|
+
* are out — so it is named, counted, and this pass's duration becomes
|
|
152552
|
+
* unmeasurable rather than a number measured across two transfers.
|
|
152553
|
+
*/
|
|
152554
|
+
onTimestampsWentBack(previousUs, currentUs) {
|
|
152555
|
+
const { row, twin } = this.input;
|
|
152556
|
+
const meta = {
|
|
152557
|
+
clipKey: row.clipKey,
|
|
152558
|
+
twin,
|
|
152559
|
+
previousUs,
|
|
152560
|
+
currentUs
|
|
152561
|
+
};
|
|
152562
|
+
if (this.mux !== null) {
|
|
152563
|
+
if (this.timestampsBroken) return;
|
|
152564
|
+
this.timestampsBroken = true;
|
|
152565
|
+
this.deps.logger.warn("videoclips: a clip stream’s timestamps went backwards mid-mux", {
|
|
152566
|
+
tags: this.tags,
|
|
152567
|
+
meta: {
|
|
152568
|
+
...meta,
|
|
152569
|
+
branch: "stream-timestamp-jump",
|
|
152570
|
+
accessUnits: this.accessUnits
|
|
152571
|
+
}
|
|
152572
|
+
});
|
|
152573
|
+
return;
|
|
152574
|
+
}
|
|
152575
|
+
this.residueUnits += this.probe.length;
|
|
152576
|
+
this.residueAudioFrames += this.audioProbe.length;
|
|
152577
|
+
this.audioFramesDropped += this.audioProbe.length;
|
|
152578
|
+
this.probe = [];
|
|
152579
|
+
this.audioProbe = [];
|
|
152580
|
+
this.audioProbeBytes = 0;
|
|
152581
|
+
this.timestampsUs.length = 0;
|
|
152582
|
+
this.accessUnits = 0;
|
|
152583
|
+
this.deps.logger.warn("videoclips: a clip stream dropped the previous transfer’s frames", {
|
|
152584
|
+
tags: this.tags,
|
|
152585
|
+
meta: {
|
|
152586
|
+
...meta,
|
|
152587
|
+
branch: "stream-transfer-residue",
|
|
152588
|
+
residueAccessUnits: this.residueUnits,
|
|
152589
|
+
residueAudioFrames: this.residueAudioFrames
|
|
152590
|
+
}
|
|
152591
|
+
});
|
|
151240
152592
|
}
|
|
151241
152593
|
/**
|
|
151242
152594
|
* One audio frame from the decoder.
|
|
@@ -151301,15 +152653,20 @@ var ClipStreamRun = class {
|
|
|
151301
152653
|
this.rate = probeClipStreamRate(this.timestampsUs);
|
|
151302
152654
|
this.decideAudio();
|
|
151303
152655
|
const { row, twin } = this.input;
|
|
151304
|
-
if (this.rate.
|
|
151305
|
-
|
|
151306
|
-
|
|
151307
|
-
|
|
151308
|
-
|
|
151309
|
-
|
|
151310
|
-
|
|
151311
|
-
|
|
151312
|
-
|
|
152656
|
+
if (this.rate.fps === null || this.rate.fpsSource !== "timestamps") {
|
|
152657
|
+
this.deps.logger.warn("videoclips: no measured frame rate for this clip stream", {
|
|
152658
|
+
tags: this.tags,
|
|
152659
|
+
meta: {
|
|
152660
|
+
clipKey: row.clipKey,
|
|
152661
|
+
twin,
|
|
152662
|
+
branch: "fps-unmeasured",
|
|
152663
|
+
probeAccessUnits: this.rate.accessUnits,
|
|
152664
|
+
residueAccessUnits: this.residueUnits
|
|
152665
|
+
}
|
|
152666
|
+
});
|
|
152667
|
+
this.sever("rate-unmeasured", `no frame rate could be measured over ${String(this.rate.accessUnits)} access units`);
|
|
152668
|
+
return;
|
|
152669
|
+
}
|
|
151313
152670
|
let mux;
|
|
151314
152671
|
try {
|
|
151315
152672
|
mux = this.deps.spawnMux(buildClipStreamMuxArgs({
|
|
@@ -151414,14 +152771,12 @@ var ClipStreamRun = class {
|
|
|
151414
152771
|
}
|
|
151415
152772
|
/** Sever every hop, once, and say why. */
|
|
151416
152773
|
sever(reason, detail) {
|
|
151417
|
-
if (this.cut !== null) return;
|
|
152774
|
+
if (this.cut !== null || this.done) return;
|
|
151418
152775
|
this.cut = {
|
|
151419
152776
|
reason,
|
|
151420
152777
|
detail
|
|
151421
152778
|
};
|
|
151422
|
-
this.
|
|
151423
|
-
this.audioStdinGate.dispose();
|
|
151424
|
-
this.peerGate.dispose();
|
|
152779
|
+
this.disposeGates();
|
|
151425
152780
|
this.mux?.kill("SIGKILL");
|
|
151426
152781
|
this.deps.logger.warn("videoclips: a clip stream was cut", {
|
|
151427
152782
|
tags: this.tags,
|
|
@@ -151482,6 +152837,8 @@ var ClipStreamRun = class {
|
|
|
151482
152837
|
});
|
|
151483
152838
|
mux.stdin?.end();
|
|
151484
152839
|
this.audioIn?.end();
|
|
152840
|
+
this.stdinGate.dispose();
|
|
152841
|
+
this.audioStdinGate.dispose();
|
|
151485
152842
|
const flush = setTimeout(() => this.sever("mux-hung", `ffmpeg did not exit ${this.deps.flushTimeoutMs} ms after its input ended`), this.deps.flushTimeoutMs);
|
|
151486
152843
|
const code = await this.muxClosed;
|
|
151487
152844
|
clearTimeout(flush);
|
|
@@ -151501,7 +152858,7 @@ var ClipStreamRun = class {
|
|
|
151501
152858
|
served() {
|
|
151502
152859
|
const { row, twin } = this.input;
|
|
151503
152860
|
const timing = estimateVideoTiming({ timestampsUs: this.timestampsUs });
|
|
151504
|
-
const durationSeconds = timing.fpsSource
|
|
152861
|
+
const durationSeconds = this.timestampsBroken || timing.fpsSource !== "timestamps" ? null : timing.durationSeconds;
|
|
151505
152862
|
const verdict = judgeClipDuration({
|
|
151506
152863
|
expectedMs: row.endMs - row.startMs,
|
|
151507
152864
|
decodedSeconds: durationSeconds,
|
|
@@ -151519,7 +152876,9 @@ var ClipStreamRun = class {
|
|
|
151519
152876
|
expectedMs: row.endMs - row.startMs,
|
|
151520
152877
|
fps: this.rate.fps,
|
|
151521
152878
|
fpsSource: this.rate.fpsSource,
|
|
151522
|
-
videoType: this.videoType
|
|
152879
|
+
videoType: this.videoType,
|
|
152880
|
+
residueAccessUnits: this.residueUnits,
|
|
152881
|
+
timestampsBroken: this.timestampsBroken
|
|
151523
152882
|
};
|
|
151524
152883
|
if (verdict.kind === "short") this.deps.logger.warn("videoclips: a clip stream came up short against its row", {
|
|
151525
152884
|
tags: this.tags,
|
|
@@ -152501,11 +153860,11 @@ function enumerateWallClockDays(sinceMs, untilMs, timeZone) {
|
|
|
152501
153860
|
* nowhere). Searching for the KEY rather than the path survives both, and
|
|
152502
153861
|
* survives the next firmware that nests it differently.
|
|
152503
153862
|
*/
|
|
152504
|
-
function findRecordWithKey$
|
|
153863
|
+
function findRecordWithKey$2(node, key) {
|
|
152505
153864
|
if (node === null || typeof node !== "object") return null;
|
|
152506
153865
|
if (Array.isArray(node)) {
|
|
152507
153866
|
for (const child of node) {
|
|
152508
|
-
const found = findRecordWithKey$
|
|
153867
|
+
const found = findRecordWithKey$2(child, key);
|
|
152509
153868
|
if (found !== null) return found;
|
|
152510
153869
|
}
|
|
152511
153870
|
return null;
|
|
@@ -152513,7 +153872,7 @@ function findRecordWithKey$1(node, key) {
|
|
|
152513
153872
|
const record = { ...node };
|
|
152514
153873
|
if (key in record) return record;
|
|
152515
153874
|
for (const value of Object.values(record)) {
|
|
152516
|
-
const found = findRecordWithKey$
|
|
153875
|
+
const found = findRecordWithKey$2(value, key);
|
|
152517
153876
|
if (found !== null) return found;
|
|
152518
153877
|
}
|
|
152519
153878
|
return null;
|
|
@@ -152533,7 +153892,7 @@ async function readStorageState(api) {
|
|
|
152533
153892
|
reason: err instanceof Error ? err.message : String(err)
|
|
152534
153893
|
};
|
|
152535
153894
|
}
|
|
152536
|
-
const record = findRecordWithKey$
|
|
153895
|
+
const record = findRecordWithKey$2(raw, "mount");
|
|
152537
153896
|
if (record === null) return { state: "unknown" };
|
|
152538
153897
|
const mount = asNumber(record["mount"]);
|
|
152539
153898
|
if (mount === null) return { state: "unknown" };
|
|
@@ -155122,6 +156481,313 @@ var reolinkDebugActions = defineCustomActions({ debugRawRead: customAction(RawRe
|
|
|
155122
156481
|
auth: "admin"
|
|
155123
156482
|
}) });
|
|
155124
156483
|
//#endregion
|
|
156484
|
+
//#region src/recording-onboard-mapping.ts
|
|
156485
|
+
/**
|
|
156486
|
+
* Reolink's schedule `type` → the cap's trigger vocabulary.
|
|
156487
|
+
*
|
|
156488
|
+
* `legacy` is deliberately absent: it appears on 618 and names nothing
|
|
156489
|
+
* an operator would recognise, so a window carrying it is COUNTED as
|
|
156490
|
+
* unread rather than mapped onto a guess.
|
|
156491
|
+
*/
|
|
156492
|
+
var TRIGGER_BY_TYPE = {
|
|
156493
|
+
MD: "motion",
|
|
156494
|
+
Normal: "continuous",
|
|
156495
|
+
people: "person",
|
|
156496
|
+
vehicle: "vehicle",
|
|
156497
|
+
dog_cat: "animal",
|
|
156498
|
+
crossline: "lineCrossing",
|
|
156499
|
+
intrude: "intrusion",
|
|
156500
|
+
loitering: "loitering"
|
|
156501
|
+
};
|
|
156502
|
+
/** Triggers a Reolink camera's schedule can name. */
|
|
156503
|
+
var REOLINK_TRIGGERS = [
|
|
156504
|
+
"continuous",
|
|
156505
|
+
"motion",
|
|
156506
|
+
"person",
|
|
156507
|
+
"vehicle",
|
|
156508
|
+
"animal",
|
|
156509
|
+
"lineCrossing",
|
|
156510
|
+
"intrusion",
|
|
156511
|
+
"loitering"
|
|
156512
|
+
];
|
|
156513
|
+
/**
|
|
156514
|
+
* Find the first record anywhere in the reply that carries `key`.
|
|
156515
|
+
*
|
|
156516
|
+
* Structural, not path-based, because the library's declared shape for
|
|
156517
|
+
* this reply is wrong and a path written against it finds nothing.
|
|
156518
|
+
*/
|
|
156519
|
+
function findRecordWithKey$1(node, key) {
|
|
156520
|
+
if (typeof node !== "object" || node === null) return null;
|
|
156521
|
+
if (Array.isArray(node)) {
|
|
156522
|
+
for (const item of node) {
|
|
156523
|
+
const found = findRecordWithKey$1(item, key);
|
|
156524
|
+
if (found) return found;
|
|
156525
|
+
}
|
|
156526
|
+
return null;
|
|
156527
|
+
}
|
|
156528
|
+
const record = { ...node };
|
|
156529
|
+
if (key in record) return record;
|
|
156530
|
+
for (const value of Object.values(record)) {
|
|
156531
|
+
const found = findRecordWithKey$1(value, key);
|
|
156532
|
+
if (found) return found;
|
|
156533
|
+
}
|
|
156534
|
+
return null;
|
|
156535
|
+
}
|
|
156536
|
+
function finiteNumber(value) {
|
|
156537
|
+
if (typeof value !== "number") return null;
|
|
156538
|
+
return Number.isFinite(value) ? value : null;
|
|
156539
|
+
}
|
|
156540
|
+
/**
|
|
156541
|
+
* Total size in MB from Reolink's split pair.
|
|
156542
|
+
*
|
|
156543
|
+
* Null when the camera stated NEITHER half — a size we did not read is
|
|
156544
|
+
* not a size of zero (D393). A stated 0 GB with a stated MB remainder is
|
|
156545
|
+
* a real, small number, and survives as one.
|
|
156546
|
+
*/
|
|
156547
|
+
function megabytes(whole, remainder) {
|
|
156548
|
+
const gb = finiteNumber(whole);
|
|
156549
|
+
const mb = finiteNumber(remainder);
|
|
156550
|
+
if (gb === null && mb === null) return null;
|
|
156551
|
+
return (gb ?? 0) * 1024 + (mb ?? 0);
|
|
156552
|
+
}
|
|
156553
|
+
/**
|
|
156554
|
+
* Read `getHddInfoList`.
|
|
156555
|
+
*
|
|
156556
|
+
* `mount === 0` is a camera with no card — the same signal
|
|
156557
|
+
* `clips/clip-catalog.ts` keys off. `format === 0` is a card present but
|
|
156558
|
+
* not usable, which is `absent` with a reason that says so rather than
|
|
156559
|
+
* `present` with a card nothing can be written to.
|
|
156560
|
+
*/
|
|
156561
|
+
function parseOnboardStorage(reply) {
|
|
156562
|
+
const record = findRecordWithKey$1(reply, "mount");
|
|
156563
|
+
if (!record) return {
|
|
156564
|
+
kind: "unknown",
|
|
156565
|
+
reason: "The camera answered, but with nothing this reader recognised as storage info."
|
|
156566
|
+
};
|
|
156567
|
+
if (finiteNumber(record.mount) === 0) return {
|
|
156568
|
+
kind: "absent",
|
|
156569
|
+
reason: "The camera reports no mounted storage (HddInfo mount 0)."
|
|
156570
|
+
};
|
|
156571
|
+
const formatted = finiteNumber(record.format);
|
|
156572
|
+
const id = finiteNumber(record.number);
|
|
156573
|
+
const volume = {
|
|
156574
|
+
id: id === null ? "0" : String(id),
|
|
156575
|
+
status: formatted === 0 ? "unformatted" : "ok",
|
|
156576
|
+
capacityMb: megabytes(record.capacity, record.capacityM),
|
|
156577
|
+
freeMb: megabytes(record.remainSize, record.remainSizeM)
|
|
156578
|
+
};
|
|
156579
|
+
if (formatted === 0) return {
|
|
156580
|
+
kind: "absent",
|
|
156581
|
+
reason: "The camera reports a card that is not formatted, so it cannot record to it."
|
|
156582
|
+
};
|
|
156583
|
+
return {
|
|
156584
|
+
kind: "present",
|
|
156585
|
+
volumes: [volume]
|
|
156586
|
+
};
|
|
156587
|
+
}
|
|
156588
|
+
/**
|
|
156589
|
+
* Expand every `typeScheduleList` entry into windows.
|
|
156590
|
+
*
|
|
156591
|
+
* Entries sharing a trigger are merged slot-wise before expansion, so
|
|
156592
|
+
* three `crossline` rules produce one set of line-crossing windows
|
|
156593
|
+
* rather than three overlapping copies.
|
|
156594
|
+
*
|
|
156595
|
+
* An entry whose `type` is unknown, or whose table is not 168 characters,
|
|
156596
|
+
* is counted as unread. A table of the wrong length is not a schedule of
|
|
156597
|
+
* fewer days.
|
|
156598
|
+
*/
|
|
156599
|
+
function parseSchedule(schedule) {
|
|
156600
|
+
const entries = schedule?.typeScheduleList ?? [];
|
|
156601
|
+
const merged = /* @__PURE__ */ new Map();
|
|
156602
|
+
let unreadable = 0;
|
|
156603
|
+
for (const entry of entries) {
|
|
156604
|
+
const trigger = TRIGGER_BY_TYPE[entry.type];
|
|
156605
|
+
if (trigger === void 0 || entry.valueTable.length !== 168) {
|
|
156606
|
+
if (entry.valueTable.includes("1")) unreadable += 1;
|
|
156607
|
+
continue;
|
|
156608
|
+
}
|
|
156609
|
+
const existing = merged.get(trigger);
|
|
156610
|
+
if (!existing) {
|
|
156611
|
+
merged.set(trigger, [...entry.valueTable]);
|
|
156612
|
+
continue;
|
|
156613
|
+
}
|
|
156614
|
+
for (let i = 0; i < existing.length; i += 1) if (entry.valueTable[i] === "1") existing[i] = "1";
|
|
156615
|
+
}
|
|
156616
|
+
const windows = [];
|
|
156617
|
+
for (const [trigger, slots] of merged) windows.push(...weeklyMaskToWindows(slots.join(""), trigger));
|
|
156618
|
+
return {
|
|
156619
|
+
windows,
|
|
156620
|
+
unreadable
|
|
156621
|
+
};
|
|
156622
|
+
}
|
|
156623
|
+
/**
|
|
156624
|
+
* Fold the three reads into the cap's status.
|
|
156625
|
+
*
|
|
156626
|
+
* `tracks` is empty and `primaryTrackId` null on purpose: a Reolink
|
|
156627
|
+
* channel exposes no track list, and inventing one synthetic row would
|
|
156628
|
+
* put a fact in the UI that the camera never stated. The form renders
|
|
156629
|
+
* "the camera reports no recording track", which is true.
|
|
156630
|
+
*/
|
|
156631
|
+
function buildOnboardStatus(input) {
|
|
156632
|
+
const { windows, unreadable } = parseSchedule(input.schedule);
|
|
156633
|
+
const masterOn = input.schedule?.enable ?? null;
|
|
156634
|
+
const scheduled = masterOn === true && windows.length > 0;
|
|
156635
|
+
const cycle = input.cfg?.cycle;
|
|
156636
|
+
return {
|
|
156637
|
+
storage: input.storage,
|
|
156638
|
+
tracks: [],
|
|
156639
|
+
primaryTrackId: null,
|
|
156640
|
+
enabled: masterOn,
|
|
156641
|
+
overwriteWhenFull: cycle === void 0 ? null : cycle !== 0,
|
|
156642
|
+
preRecordSec: input.cfg?.preRecordTime ?? null,
|
|
156643
|
+
postRecordSec: input.cfg?.recordDelayTime ?? null,
|
|
156644
|
+
segmentMinutes: input.cfg?.packageTime ?? null,
|
|
156645
|
+
windows: [...windows],
|
|
156646
|
+
unreadableWindows: unreadable,
|
|
156647
|
+
recordingToNowhere: scheduled && input.storage.kind === "absent",
|
|
156648
|
+
lastFetchedAt: input.now
|
|
156649
|
+
};
|
|
156650
|
+
}
|
|
156651
|
+
/** Why a Reolink camera cannot be written until CamStack has read it. */
|
|
156652
|
+
var REOLINK_NOT_READ_REASON = "CamStack has not read this camera yet";
|
|
156653
|
+
/**
|
|
156654
|
+
* Why the schedule is unwritable when one trigger occupies several rows.
|
|
156655
|
+
*
|
|
156656
|
+
* An E1 Outdoor Pro lists `crossline`, `intrude` and `loitering` three times
|
|
156657
|
+
* each — one row per configured rule. The status READ merges them, because
|
|
156658
|
+
* the cap's question is "does this camera record on line-crossing". A write
|
|
156659
|
+
* cannot un-merge them: addressed by type alone it rewrites all three rules
|
|
156660
|
+
* with one mask (measured — `replaceItemValueTable` matches every row of a
|
|
156661
|
+
* type when no index is given), and the cap's patch carries no room to say
|
|
156662
|
+
* which rule an operator meant. So the schedule is REPORTED unwritable on
|
|
156663
|
+
* such a camera rather than writable and destructive.
|
|
156664
|
+
*/
|
|
156665
|
+
var REOLINK_MERGED_ROWS_REASON = "this camera stores several rules of the same kind (one row per configured rule) and CamStack cannot tell them apart, so writing the schedule would overwrite all of them with one mask";
|
|
156666
|
+
/** How many rows each trigger occupies, from the camera's own list. */
|
|
156667
|
+
function rowsPerTrigger(schedule) {
|
|
156668
|
+
const counts = /* @__PURE__ */ new Map();
|
|
156669
|
+
for (const entry of schedule?.typeScheduleList ?? []) {
|
|
156670
|
+
const trigger = TRIGGER_BY_TYPE[entry.type];
|
|
156671
|
+
if (trigger === void 0) continue;
|
|
156672
|
+
counts.set(trigger, (counts.get(trigger) ?? 0) + 1);
|
|
156673
|
+
}
|
|
156674
|
+
return counts;
|
|
156675
|
+
}
|
|
156676
|
+
/**
|
|
156677
|
+
* What THIS Reolink accepts.
|
|
156678
|
+
*
|
|
156679
|
+
* Writability is decided per field from the camera's OWN statement, never
|
|
156680
|
+
* from a vendor default:
|
|
156681
|
+
*
|
|
156682
|
+
* - `overwriteWhenFull` needs `<cyclelist>` to offer both 0 and 1; the
|
|
156683
|
+
* cap's field is a boolean and a camera offering one value cannot be
|
|
156684
|
+
* set both ways. `<cyclelist>` IS enforced by the firmware (writing 7
|
|
156685
|
+
* was answered 200 and dropped), so it is a real refusal.
|
|
156686
|
+
* - `preRecordSec` is a two-member SET, not a span. The wire field is
|
|
156687
|
+
* seconds and the firmware honours exactly two states: 0 is off, and
|
|
156688
|
+
* 5, 15 and 30 all read back as 10 — identically on two firmwares. A
|
|
156689
|
+
* slider here would move and change nothing.
|
|
156690
|
+
* - `postRecordSec` carries NO allowed set although the camera states
|
|
156691
|
+
* `<timeList>`: that list is advisory, and a Home Hub offering
|
|
156692
|
+
* `8,15,30` accepted and applied 20. Refusing against it would block a
|
|
156693
|
+
* value the firmware honours, so the write goes through unconstrained
|
|
156694
|
+
* and the read-back diff is what reports a value the camera dropped.
|
|
156695
|
+
* - `segmentMinutes` likewise carries no bound, because no camera states
|
|
156696
|
+
* one and an invented maximum refuses values the firmware may take.
|
|
156697
|
+
*/
|
|
156698
|
+
function buildOnboardOptions(input) {
|
|
156699
|
+
const reached = input.status !== null;
|
|
156700
|
+
const notRead = {
|
|
156701
|
+
readable: false,
|
|
156702
|
+
writable: false,
|
|
156703
|
+
reason: REOLINK_NOT_READ_REASON
|
|
156704
|
+
};
|
|
156705
|
+
const writable = {
|
|
156706
|
+
readable: true,
|
|
156707
|
+
writable: true
|
|
156708
|
+
};
|
|
156709
|
+
const refuse = (reason) => ({
|
|
156710
|
+
readable: true,
|
|
156711
|
+
writable: false,
|
|
156712
|
+
reason
|
|
156713
|
+
});
|
|
156714
|
+
if (!reached || input.limits === null) return {
|
|
156715
|
+
enabled: notRead,
|
|
156716
|
+
overwriteWhenFull: notRead,
|
|
156717
|
+
preRecordSec: notRead,
|
|
156718
|
+
postRecordSec: notRead,
|
|
156719
|
+
segmentMinutes: notRead,
|
|
156720
|
+
schedule: {
|
|
156721
|
+
support: notRead,
|
|
156722
|
+
granularityMinutes: 60,
|
|
156723
|
+
triggers: [...REOLINK_TRIGGERS],
|
|
156724
|
+
supportsOverlappingTriggers: true
|
|
156725
|
+
}
|
|
156726
|
+
};
|
|
156727
|
+
const limits = input.limits;
|
|
156728
|
+
const cycleChoices = limits.cycleList;
|
|
156729
|
+
const overwrite = cycleChoices.includes(0) && cycleChoices.includes(1) ? writable : refuse(cycleChoices.length === 0 ? "this camera did not say which overwrite settings it accepts" : `this camera accepts only ${cycleChoices.join(", ")} for overwrite, so it cannot be switched both ways`);
|
|
156730
|
+
const preRecord = limits.preRecordSupported ? writable : refuse("this camera reports no pre-record setting, so writing one would do nothing");
|
|
156731
|
+
const merged = [...rowsPerTrigger(input.schedule).entries()].filter(([, count]) => count > 1);
|
|
156732
|
+
const unreadable = input.status?.unreadableWindows ?? 0;
|
|
156733
|
+
const scheduleSupport = unreadable > 0 ? refuse(`CamStack could not read ${String(unreadable)} of this camera's schedule rows, and a write would replace what it could not show`) : merged.length > 0 ? refuse(REOLINK_MERGED_ROWS_REASON) : writable;
|
|
156734
|
+
return {
|
|
156735
|
+
enabled: writable,
|
|
156736
|
+
overwriteWhenFull: overwrite,
|
|
156737
|
+
preRecordSec: preRecord,
|
|
156738
|
+
...limits.preRecordSupported ? { preRecordSecAllowed: { values: [0, limits.preRecordSeconds ?? 10] } } : {},
|
|
156739
|
+
postRecordSec: writable,
|
|
156740
|
+
segmentMinutes: writable,
|
|
156741
|
+
schedule: {
|
|
156742
|
+
support: scheduleSupport,
|
|
156743
|
+
granularityMinutes: 60,
|
|
156744
|
+
triggers: [...REOLINK_TRIGGERS],
|
|
156745
|
+
supportsOverlappingTriggers: true
|
|
156746
|
+
}
|
|
156747
|
+
};
|
|
156748
|
+
}
|
|
156749
|
+
/** The cap's trigger vocabulary → Reolink's schedule `type`. */
|
|
156750
|
+
var TYPE_BY_TRIGGER = Object.fromEntries(Object.entries(TRIGGER_BY_TYPE).filter((pair) => pair[1] !== void 0).map(([type, trigger]) => [trigger, type]));
|
|
156751
|
+
/**
|
|
156752
|
+
* Turn a cap patch into the two Baichuan writes.
|
|
156753
|
+
*
|
|
156754
|
+
* `windows` is the COMPLETE new set, so every trigger the camera lists gets
|
|
156755
|
+
* a row — including one the new set does not mention, which must be written
|
|
156756
|
+
* as an all-zero mask or the camera keeps recording on a trigger the
|
|
156757
|
+
* operator just removed. A trigger the camera does NOT list is skipped: a
|
|
156758
|
+
* row the camera never had is answered 200 and discarded, so sending it
|
|
156759
|
+
* would report success for nothing.
|
|
156760
|
+
*
|
|
156761
|
+
* `preRecordSec` collapses to the boolean the firmware really has: any
|
|
156762
|
+
* non-zero ask is "on", because 5, 15 and 30 all store as 10.
|
|
156763
|
+
*/
|
|
156764
|
+
function buildOnboardWrites(patch, schedule) {
|
|
156765
|
+
const cfg = {};
|
|
156766
|
+
if (patch.overwriteWhenFull !== void 0) cfg.cycle = patch.overwriteWhenFull ? 1 : 0;
|
|
156767
|
+
if (patch.postRecordSec !== void 0) cfg.recordDelayTime = patch.postRecordSec;
|
|
156768
|
+
if (patch.preRecordSec !== void 0) cfg.preRecordEnabled = patch.preRecordSec !== 0;
|
|
156769
|
+
if (patch.segmentMinutes !== void 0) cfg.packageTime = patch.segmentMinutes;
|
|
156770
|
+
const scheduleOut = {};
|
|
156771
|
+
if (patch.enabled !== void 0) scheduleOut.enable = patch.enabled;
|
|
156772
|
+
if (patch.windows !== void 0) {
|
|
156773
|
+
const present = new Set((schedule?.typeScheduleList ?? []).map((e) => e.type));
|
|
156774
|
+
const entries = [];
|
|
156775
|
+
for (const trigger of REOLINK_TRIGGERS) {
|
|
156776
|
+
const type = TYPE_BY_TRIGGER[trigger];
|
|
156777
|
+
if (type === void 0 || !present.has(type)) continue;
|
|
156778
|
+
entries.push({
|
|
156779
|
+
type,
|
|
156780
|
+
valueTable: windowsToWeeklyMask(patch.windows, trigger)
|
|
156781
|
+
});
|
|
156782
|
+
}
|
|
156783
|
+
scheduleOut.entries = entries;
|
|
156784
|
+
}
|
|
156785
|
+
return {
|
|
156786
|
+
cfg: Object.keys(cfg).length > 0 ? cfg : null,
|
|
156787
|
+
schedule: Object.keys(scheduleOut).length > 0 ? scheduleOut : null
|
|
156788
|
+
};
|
|
156789
|
+
}
|
|
156790
|
+
//#endregion
|
|
155125
156791
|
//#region src/battery-charging.ts
|
|
155126
156792
|
/**
|
|
155127
156793
|
* Da `adapterStatus` + `chargeStatus` alla sorgente di alimentazione.
|
|
@@ -155618,6 +157284,82 @@ function decideSnapshotCameraAccess(input) {
|
|
|
155618
157284
|
};
|
|
155619
157285
|
}
|
|
155620
157286
|
//#endregion
|
|
157287
|
+
//#region src/sleep-state-decision.ts
|
|
157288
|
+
/**
|
|
157289
|
+
* Hysteresis is ASYMMETRIC, because the two directions have very different
|
|
157290
|
+
* costs when we get them wrong.
|
|
157291
|
+
*
|
|
157292
|
+
* `→ awake` (`WAKE_HYSTERESIS_MS`): expensive if wrong. Believing an asleep
|
|
157293
|
+
* camera is awake un-gates every background probe and makes `wakeForStream`
|
|
157294
|
+
* early-out without actually waking. Keep the window >= the library's UDP
|
|
157295
|
+
* inference cycle (~12 s on battery cams with idle-disconnect) and >= its
|
|
157296
|
+
* `getIdleDisconnectTimeoutMs` (30 s default).
|
|
157297
|
+
*
|
|
157298
|
+
* `→ sleeping` (`SLEEP_HYSTERESIS_MS`): cheap if wrong. Believing an awake
|
|
157299
|
+
* camera is asleep only means we skip background probes and serve cached
|
|
157300
|
+
* values; the demand paths still drive a real wake. The old symmetric 30 s
|
|
157301
|
+
* window was LONGER than the camera's own awake window (~14 s), so a natural
|
|
157302
|
+
* wake→sleep cycle had its `sleeping` transition dropped and the slice stayed
|
|
157303
|
+
* stuck on "awake" while the camera slept.
|
|
157304
|
+
*/
|
|
157305
|
+
var WAKE_HYSTERESIS_MS = 3e4;
|
|
157306
|
+
/** See {@link WAKE_HYSTERESIS_MS}. */
|
|
157307
|
+
var SLEEP_HYSTERESIS_MS = 5e3;
|
|
157308
|
+
/**
|
|
157309
|
+
* How long a freshly committed FIRST-HAND state is protected from a lagging
|
|
157310
|
+
* summary's awake→sleeping flip. The summary lags real pushes by over a
|
|
157311
|
+
* minute (measured >75 s on 2026-08-16); 3 min covers two full
|
|
157312
|
+
* discovery-refresh cycles of lag without letting a genuinely stuck "awake"
|
|
157313
|
+
* slice stand forever — the reconcile still wins after the grace, which is
|
|
157314
|
+
* exactly its recovery role.
|
|
157315
|
+
*/
|
|
157316
|
+
var HUB_SUMMARY_SLEEP_GRACE_MS = 18e4;
|
|
157317
|
+
/** A source whose verdict comes from the parent NVR's channel summary. */
|
|
157318
|
+
function isSummarySourced(source) {
|
|
157319
|
+
return source === "hub-summary" || source === "hub-reconcile";
|
|
157320
|
+
}
|
|
157321
|
+
/**
|
|
157322
|
+
* Decide one commit. Pure: the caller owns the slice, the clock and the log.
|
|
157323
|
+
*
|
|
157324
|
+
* Order of the gates, and why:
|
|
157325
|
+
* 1. No-op first — re-asserting the current value is never a transition.
|
|
157326
|
+
* 2. A summary-sourced verdict is firmware state, not socket inference, so
|
|
157327
|
+
* the anti-flap window does not apply to it at all.
|
|
157328
|
+
* 3. A `confirmed-wake` in the `→ awake` direction is first-hand evidence
|
|
157329
|
+
* this process produced: gating it with the anti-flap window is what
|
|
157330
|
+
* stranded 3825's mirror on "asleep" while `wakeForStream` answered
|
|
157331
|
+
* `awoke: true`.
|
|
157332
|
+
* 4. Everything else is subject to the asymmetric hysteresis.
|
|
157333
|
+
* 5. A summary flip toward `sleeping` is refused while a first-hand
|
|
157334
|
+
* observation inside the grace says otherwise.
|
|
157335
|
+
*/
|
|
157336
|
+
function decideSleepCommit(input) {
|
|
157337
|
+
const { current, next, source, lastFirstHandCommitAt, nowMs } = input;
|
|
157338
|
+
if (current === next) return { kind: "unchanged" };
|
|
157339
|
+
const summarySourced = isSummarySourced(source);
|
|
157340
|
+
const firstHandWake = source === "confirmed-wake" && next === false;
|
|
157341
|
+
const sinceLastChangeMs = nowMs - lastFirstHandCommitAt;
|
|
157342
|
+
if (!summarySourced && !firstHandWake) {
|
|
157343
|
+
const windowMs = next ? SLEEP_HYSTERESIS_MS : WAKE_HYSTERESIS_MS;
|
|
157344
|
+
if (lastFirstHandCommitAt > 0 && sinceLastChangeMs < windowMs) return {
|
|
157345
|
+
kind: "refused",
|
|
157346
|
+
reason: "hysteresis",
|
|
157347
|
+
windowMs,
|
|
157348
|
+
sinceLastChangeMs
|
|
157349
|
+
};
|
|
157350
|
+
}
|
|
157351
|
+
if (summarySourced && next === true && lastFirstHandCommitAt > 0 && sinceLastChangeMs < 18e4) return {
|
|
157352
|
+
kind: "refused",
|
|
157353
|
+
reason: "summary-grace",
|
|
157354
|
+
windowMs: HUB_SUMMARY_SLEEP_GRACE_MS,
|
|
157355
|
+
sinceLastChangeMs
|
|
157356
|
+
};
|
|
157357
|
+
return {
|
|
157358
|
+
kind: "commit",
|
|
157359
|
+
stampFirstHand: source !== "hub-reconcile"
|
|
157360
|
+
};
|
|
157361
|
+
}
|
|
157362
|
+
//#endregion
|
|
155621
157363
|
//#region src/raw-state.ts
|
|
155622
157364
|
/**
|
|
155623
157365
|
* Source tag for every raw-state blob this provider emits.
|
|
@@ -158864,53 +160606,20 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
|
|
|
158864
160606
|
return this.state.battery.sleeping ?? false;
|
|
158865
160607
|
}
|
|
158866
160608
|
/**
|
|
158867
|
-
* Wall-clock ms when `this.sleeping
|
|
158868
|
-
*
|
|
158869
|
-
*
|
|
158870
|
-
* UDP
|
|
158871
|
-
*
|
|
158872
|
-
*
|
|
158873
|
-
*
|
|
158874
|
-
*
|
|
160609
|
+
* Wall-clock ms when a FIRST-HAND observation last moved `this.sleeping`.
|
|
160610
|
+
*
|
|
160611
|
+
* It drives two windows — the anti-flap hysteresis that filters the lib's
|
|
160612
|
+
* UDP sleep inference, and the grace that stops the parent's LAGGING
|
|
160613
|
+
* summary from clobbering a fresher verdict. Both want the same thing:
|
|
160614
|
+
* "when did something that was actually looking at the camera last speak".
|
|
160615
|
+
*
|
|
160616
|
+
* The parent's periodic summary sweep (`hub-reconcile`) therefore never
|
|
160617
|
+
* stamps this. It is firmware truth, but it is minutes old, and stamping
|
|
160618
|
+
* it armed the 30 s wake window against the camera's own wake push — see
|
|
160619
|
+
* `sleep-state-decision.ts` for the measurement (D603).
|
|
158875
160620
|
*/
|
|
158876
160621
|
sleepStateChangedAt = 0;
|
|
158877
160622
|
/**
|
|
158878
|
-
* Hysteresis is ASYMMETRIC, because the two directions have very
|
|
158879
|
-
* different costs when we get them wrong.
|
|
158880
|
-
*
|
|
158881
|
-
* `→ awake` (`WAKE_HYSTERESIS_MS`): expensive if wrong. Believing an
|
|
158882
|
-
* asleep camera is awake un-gates every background probe
|
|
158883
|
-
* (`refreshParentSettingsSnapshot`, the device-config cap refreshes,
|
|
158884
|
-
* aux align) and makes `wakeForStream` early-out without actually
|
|
158885
|
-
* waking. Keep the full window >= the lib's UDP inference cycle
|
|
158886
|
-
* (~12s on battery cams with idle-disconnect) and >= the lib's
|
|
158887
|
-
* `getIdleDisconnectTimeoutMs` (30s default), so neither an
|
|
158888
|
-
* inference flap nor a transient socket close→reopen can walk us
|
|
158889
|
-
* into a false "awake".
|
|
158890
|
-
*
|
|
158891
|
-
* `→ sleeping` (`SLEEP_HYSTERESIS_MS`): cheap if wrong. Believing an
|
|
158892
|
-
* awake camera is asleep only means we skip background probes and
|
|
158893
|
-
* serve cached values; the demand paths (`wakeForStream`,
|
|
158894
|
-
* `wakeIfSleeping`) still drive a real wake. The old symmetric 30s
|
|
158895
|
-
* window was LONGER than the camera's own awake window (~14s — see
|
|
158896
|
-
* `onWakeTransition`), so a natural wake→sleep cycle had its
|
|
158897
|
-
* `sleeping` transition dropped and the slice stayed stuck on
|
|
158898
|
-
* "awake" while the camera slept: the exact "at rest we lose the
|
|
158899
|
-
* sleeping state" symptom. 5s still absorbs a single 2s inference
|
|
158900
|
-
* tick while tracking the real cycle.
|
|
158901
|
-
*/
|
|
158902
|
-
static WAKE_HYSTERESIS_MS = 3e4;
|
|
158903
|
-
static SLEEP_HYSTERESIS_MS = 5e3;
|
|
158904
|
-
/**
|
|
158905
|
-
* How long a freshly committed state is protected from a hub-summary
|
|
158906
|
-
* awake→sleeping flip. The summary's per-channel sleep flag lags real
|
|
158907
|
-
* pushes by over a minute (measured >75 s on 2026-08-16); 3 min covers
|
|
158908
|
-
* two full discovery-refresh cycles of lag without letting a genuinely
|
|
158909
|
-
* stuck "awake" slice stand forever — the reconcile still wins after
|
|
158910
|
-
* the grace, which is exactly its recovery role.
|
|
158911
|
-
*/
|
|
158912
|
-
static HUB_SUMMARY_SLEEP_GRACE_MS = 18e4;
|
|
158913
|
-
/**
|
|
158914
160623
|
* Wall-clock ms of the last PROACTIVE wake — one we issued on our own
|
|
158915
160624
|
* initiative (background snapshot refresh), not because the operator
|
|
158916
160625
|
* or a stream consumer asked for the camera. Drives
|
|
@@ -159825,6 +161534,32 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
|
|
|
159825
161534
|
await (await this.ensureApi()).reboot(this.getChannel());
|
|
159826
161535
|
return { success: true };
|
|
159827
161536
|
} });
|
|
161537
|
+
this.ctx.registerNativeCap(deviceAdminLinkCapability, { getAdminLink: async ({ deviceId }) => {
|
|
161538
|
+
if (deviceId !== this.id) throw new Error(`ReolinkCamera: deviceId mismatch, expected ${this.id}, got ${deviceId}`);
|
|
161539
|
+
const host = this.config.get("host") ?? "";
|
|
161540
|
+
const built = buildDeviceAdminUrl({
|
|
161541
|
+
host,
|
|
161542
|
+
port: null,
|
|
161543
|
+
scheme: "http",
|
|
161544
|
+
path: "/"
|
|
161545
|
+
});
|
|
161546
|
+
if (!built.ok) {
|
|
161547
|
+
this.ctx.logger.debug("reolink admin link unavailable", {
|
|
161548
|
+
tags: { deviceId: this.id },
|
|
161549
|
+
meta: {
|
|
161550
|
+
reason: built.reason,
|
|
161551
|
+
transport: this.config.get("transport") ?? "tcp"
|
|
161552
|
+
}
|
|
161553
|
+
});
|
|
161554
|
+
return null;
|
|
161555
|
+
}
|
|
161556
|
+
return {
|
|
161557
|
+
url: built.url,
|
|
161558
|
+
label: "Web UI",
|
|
161559
|
+
target: "device",
|
|
161560
|
+
host
|
|
161561
|
+
};
|
|
161562
|
+
} });
|
|
159828
161563
|
this.registerNetworkLink();
|
|
159829
161564
|
this.ctx.registerNativeCap(motionCapability, { isDetected: async ({ deviceId }) => {
|
|
159830
161565
|
if (deviceId !== this.id) throw new Error(`ReolinkCamera: deviceId mismatch, expected ${this.id}, got ${deviceId}`);
|
|
@@ -159871,6 +161606,7 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
|
|
|
159871
161606
|
this.registerPrivacyMaskCap();
|
|
159872
161607
|
this.registerDayNightCap();
|
|
159873
161608
|
this.registerImageSettingsCap();
|
|
161609
|
+
this.registerRecordingOnboardCap();
|
|
159874
161610
|
this.registerStreamCatalogProvider();
|
|
159875
161611
|
this.registerNativeObjectDetectionCap();
|
|
159876
161612
|
const cache = this.config.get("deviceCache");
|
|
@@ -159914,6 +161650,8 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
|
|
|
159914
161650
|
dayNightRefreshInFlight = null;
|
|
159915
161651
|
/** Single-flight guard for the `image-settings` cap refresh. */
|
|
159916
161652
|
imageSettingsRefreshInFlight = null;
|
|
161653
|
+
/** Single-flight guard for the `recording-onboard` cap refresh. */
|
|
161654
|
+
recordingOnboardRefreshInFlight = null;
|
|
159917
161655
|
/**
|
|
159918
161656
|
* Single-flight guard for snapshot fetches (Slice 10). When two
|
|
159919
161657
|
* consumers hit `getSnapshot` concurrently we issue ONE Baichuan
|
|
@@ -160115,35 +161853,6 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
|
|
|
160115
161853
|
}
|
|
160116
161854
|
}
|
|
160117
161855
|
/**
|
|
160118
|
-
* Decide whether to honour a sleep-state transition. Returns
|
|
160119
|
-
* `false` when the lib's UDP inference is still inside the
|
|
160120
|
-
* hysteresis window so we don't flap. The window is asymmetric —
|
|
160121
|
-
* see `WAKE_HYSTERESIS_MS` / `SLEEP_HYSTERESIS_MS`. The first
|
|
160122
|
-
* transition AFTER an api login / restart is always honoured —
|
|
160123
|
-
* no prior-flip timestamp gating it.
|
|
160124
|
-
*
|
|
160125
|
-
* @param next - the desired next value of `this.sleeping`
|
|
160126
|
-
* @returns `true` if the caller should commit the new state
|
|
160127
|
-
*/
|
|
160128
|
-
acceptSleepingTransition(next) {
|
|
160129
|
-
if (this.sleeping === next) return false;
|
|
160130
|
-
const now = Date.now();
|
|
160131
|
-
const windowMs = next ? ReolinkCamera.SLEEP_HYSTERESIS_MS : ReolinkCamera.WAKE_HYSTERESIS_MS;
|
|
160132
|
-
if (this.sleepStateChangedAt > 0 && now - this.sleepStateChangedAt < windowMs) {
|
|
160133
|
-
this.ctx.logger.debug("ignoring sleep inference flap within hysteresis window", {
|
|
160134
|
-
tags: { deviceId: this.id },
|
|
160135
|
-
meta: {
|
|
160136
|
-
next,
|
|
160137
|
-
windowMs,
|
|
160138
|
-
sinceLastChangeMs: now - this.sleepStateChangedAt
|
|
160139
|
-
}
|
|
160140
|
-
});
|
|
160141
|
-
return false;
|
|
160142
|
-
}
|
|
160143
|
-
this.sleepStateChangedAt = now;
|
|
160144
|
-
return true;
|
|
160145
|
-
}
|
|
160146
|
-
/**
|
|
160147
161856
|
* THE single writer of the `battery.sleeping` slice.
|
|
160148
161857
|
*
|
|
160149
161858
|
* Before this existed the state had three independent writers with
|
|
@@ -160154,38 +161863,38 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
|
|
|
160154
161863
|
* ancient timestamp). Funnelling every writer through here keeps one
|
|
160155
161864
|
* rule set and one timestamp.
|
|
160156
161865
|
*
|
|
160157
|
-
*
|
|
160158
|
-
*
|
|
160159
|
-
*
|
|
160160
|
-
*
|
|
160161
|
-
*
|
|
160162
|
-
*
|
|
160163
|
-
*
|
|
160164
|
-
* clobbered the fresh awake right back to sleeping for a full cycle —
|
|
160165
|
-
* the viewer flashed the live frame, then the sleeping overlay, then
|
|
160166
|
-
* live again. Sleeping→awake from the summary is always honoured
|
|
160167
|
-
* (it strands nothing; the camera wakes regardless).
|
|
161866
|
+
* The rule itself lives in `sleep-state-decision.ts`, pure, so it can be
|
|
161867
|
+
* driven by a test instead of asserted from this file's source text. Read
|
|
161868
|
+
* that module for why `hub-reconcile` — the parent's periodic summary
|
|
161869
|
+
* sweep — stamps nothing: it lags real state by more than a minute, and
|
|
161870
|
+
* arming the anti-flap window from it threw away the camera's real wake
|
|
161871
|
+
* for up to 2 min 26 s on the live hub (D603).
|
|
161872
|
+
*
|
|
160168
161873
|
* @returns `true` when the slice actually changed.
|
|
160169
161874
|
*/
|
|
160170
161875
|
commitSleepState(next, source) {
|
|
160171
161876
|
if (!this.isBattery) return false;
|
|
160172
|
-
|
|
160173
|
-
|
|
160174
|
-
|
|
160175
|
-
|
|
160176
|
-
|
|
160177
|
-
|
|
160178
|
-
|
|
160179
|
-
|
|
160180
|
-
|
|
160181
|
-
|
|
160182
|
-
|
|
160183
|
-
|
|
160184
|
-
|
|
160185
|
-
|
|
160186
|
-
|
|
160187
|
-
|
|
161877
|
+
const decision = decideSleepCommit({
|
|
161878
|
+
current: this.sleeping,
|
|
161879
|
+
next,
|
|
161880
|
+
source,
|
|
161881
|
+
lastFirstHandCommitAt: this.sleepStateChangedAt,
|
|
161882
|
+
nowMs: Date.now()
|
|
161883
|
+
});
|
|
161884
|
+
if (decision.kind === "unchanged") return false;
|
|
161885
|
+
if (decision.kind === "refused") {
|
|
161886
|
+
this.ctx.logger.debug(decision.reason === "hysteresis" ? "ignoring sleep inference flap within hysteresis window" : "hub-summary sleep flip refused — fresher first-hand state inside grace", {
|
|
161887
|
+
tags: { deviceId: this.id },
|
|
161888
|
+
meta: {
|
|
161889
|
+
next,
|
|
161890
|
+
source,
|
|
161891
|
+
windowMs: decision.windowMs,
|
|
161892
|
+
sinceLastChangeMs: decision.sinceLastChangeMs
|
|
161893
|
+
}
|
|
161894
|
+
});
|
|
161895
|
+
return false;
|
|
160188
161896
|
}
|
|
161897
|
+
if (decision.stampFirstHand) this.sleepStateChangedAt = Date.now();
|
|
160189
161898
|
this.state.battery.sleeping = next;
|
|
160190
161899
|
this.ctx.logger.info("battery sleep state committed", {
|
|
160191
161900
|
tags: { deviceId: this.id },
|
|
@@ -160212,7 +161921,7 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
|
|
|
160212
161921
|
*/
|
|
160213
161922
|
applyHubSleepState(sleeping) {
|
|
160214
161923
|
if (!this.isBattery) return;
|
|
160215
|
-
if (this.commitSleepState(sleeping, "hub-
|
|
161924
|
+
if (this.commitSleepState(sleeping, "hub-reconcile") && !sleeping) this.onWakeTransition("hub-reconcile").catch(() => {});
|
|
160216
161925
|
}
|
|
160217
161926
|
/**
|
|
160218
161927
|
* Rate-limit for wakes we issue on our OWN initiative. Returns
|
|
@@ -161909,6 +163618,173 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
|
|
|
161909
163618
|
* denormalized against the camera's OWN probed threshold range
|
|
161910
163619
|
* (never a hardcoded 0..255 guess).
|
|
161911
163620
|
*/
|
|
163621
|
+
/**
|
|
163622
|
+
* Register the vendor-neutral `recording-onboard` cap.
|
|
163623
|
+
*
|
|
163624
|
+
* READ-ONLY on Reolink, and it says so per field. `@apocaliss92/
|
|
163625
|
+
* nodelink-js` exposes a reader for the record config and the record
|
|
163626
|
+
* schedule and no writer; the Baichuan SET command ids exist but
|
|
163627
|
+
* nothing in this repo has ever sent them, and the only route is
|
|
163628
|
+
* hand-built XML through the generic passthrough. So `getOptions`
|
|
163629
|
+
* reports every field `writable: false` with
|
|
163630
|
+
* {@link REOLINK_NO_WRITE_REASON}, the UI renders each control
|
|
163631
|
+
* disabled WITH that sentence — disabled must not look like broken —
|
|
163632
|
+
* and `setSettings` refuses by name instead of accepting a write and
|
|
163633
|
+
* dropping it.
|
|
163634
|
+
*
|
|
163635
|
+
* The cap's FIELD SET is identical to Hikvision's. Only the answers
|
|
163636
|
+
* differ.
|
|
163637
|
+
*/
|
|
163638
|
+
registerRecordingOnboardCap() {
|
|
163639
|
+
const channel = this.getChannel();
|
|
163640
|
+
const CAP_NAME = "recording-onboard";
|
|
163641
|
+
const emptyStatus = () => ({
|
|
163642
|
+
storage: {
|
|
163643
|
+
kind: "unknown",
|
|
163644
|
+
reason: "CamStack has not read this camera yet."
|
|
163645
|
+
},
|
|
163646
|
+
tracks: [],
|
|
163647
|
+
primaryTrackId: null,
|
|
163648
|
+
enabled: null,
|
|
163649
|
+
overwriteWhenFull: null,
|
|
163650
|
+
preRecordSec: null,
|
|
163651
|
+
postRecordSec: null,
|
|
163652
|
+
segmentMinutes: null,
|
|
163653
|
+
windows: [],
|
|
163654
|
+
unreadableWindows: 0,
|
|
163655
|
+
recordingToNowhere: false,
|
|
163656
|
+
lastFetchedAt: 0
|
|
163657
|
+
});
|
|
163658
|
+
/** Last `getRecordCfg` reply. */
|
|
163659
|
+
let lastCfg = null;
|
|
163660
|
+
/** Last `getRecordCfgLimits` — what the camera says it accepts. */
|
|
163661
|
+
let lastLimits = null;
|
|
163662
|
+
/** Last schedule read — which trigger rows this camera actually has. */
|
|
163663
|
+
let lastSchedule = null;
|
|
163664
|
+
const refreshFromCamera = async () => {
|
|
163665
|
+
if (this.recordingOnboardRefreshInFlight) return this.recordingOnboardRefreshInFlight;
|
|
163666
|
+
const promise = (async () => {
|
|
163667
|
+
try {
|
|
163668
|
+
const api = await this.ensureApi();
|
|
163669
|
+
const [cfg, limits, schedule, hdd] = await Promise.all([
|
|
163670
|
+
api.getRecordCfg(channel).catch(() => null),
|
|
163671
|
+
api.getRecordCfgLimits(channel).catch(() => null),
|
|
163672
|
+
api.getRecordSchedule(channel).catch(() => null),
|
|
163673
|
+
this.isHubChild() ? Promise.resolve(null) : api.getHddInfoList().catch(() => null)
|
|
163674
|
+
]);
|
|
163675
|
+
lastCfg = cfg;
|
|
163676
|
+
lastLimits = limits;
|
|
163677
|
+
lastSchedule = schedule;
|
|
163678
|
+
const next = buildOnboardStatus({
|
|
163679
|
+
storage: this.isHubChild() ? {
|
|
163680
|
+
kind: "unknown",
|
|
163681
|
+
reason: "This camera hangs off a Reolink Hub, so its footage lives on the Hub’s disk, not on a card of its own."
|
|
163682
|
+
} : hdd === null ? {
|
|
163683
|
+
kind: "unknown",
|
|
163684
|
+
reason: "The camera did not answer the storage read."
|
|
163685
|
+
} : parseOnboardStorage(hdd),
|
|
163686
|
+
cfg,
|
|
163687
|
+
schedule,
|
|
163688
|
+
now: Date.now()
|
|
163689
|
+
});
|
|
163690
|
+
this.runtimeState.setCapState(CAP_NAME, next);
|
|
163691
|
+
if (next.recordingToNowhere) this.ctx.logger.warn("reolink onboard recording: camera is scheduled to record and has no usable storage", {
|
|
163692
|
+
tags: { deviceId: this.id },
|
|
163693
|
+
meta: { windows: next.windows.length }
|
|
163694
|
+
});
|
|
163695
|
+
if (next.unreadableWindows > 0) this.ctx.logger.warn("reolink onboard recording: some schedule tables could not be read — the schedule shown is incomplete", {
|
|
163696
|
+
tags: { deviceId: this.id },
|
|
163697
|
+
meta: {
|
|
163698
|
+
unreadableWindows: next.unreadableWindows,
|
|
163699
|
+
readWindows: next.windows.length
|
|
163700
|
+
}
|
|
163701
|
+
});
|
|
163702
|
+
} catch (err) {
|
|
163703
|
+
this.ctx.logger.debug("reolink onboard recording refresh failed — keeping last slice", {
|
|
163704
|
+
tags: { deviceId: this.id },
|
|
163705
|
+
meta: { error: err instanceof Error ? err.message : String(err) }
|
|
163706
|
+
});
|
|
163707
|
+
}
|
|
163708
|
+
})();
|
|
163709
|
+
this.recordingOnboardRefreshInFlight = promise;
|
|
163710
|
+
try {
|
|
163711
|
+
await promise;
|
|
163712
|
+
} finally {
|
|
163713
|
+
this.recordingOnboardRefreshInFlight = null;
|
|
163714
|
+
}
|
|
163715
|
+
};
|
|
163716
|
+
const bridge = createRuntimeStateBridge({
|
|
163717
|
+
runtimeState: this.runtimeState,
|
|
163718
|
+
cap: recordingOnboardCapability,
|
|
163719
|
+
ownDeviceId: this.id,
|
|
163720
|
+
refresh: this.sleepGatedRefresh(CAP_NAME, refreshFromCamera),
|
|
163721
|
+
staleMs: OPERATOR_WRITTEN_STALE_MS,
|
|
163722
|
+
staleRead: "serve-and-revalidate",
|
|
163723
|
+
empty: emptyStatus
|
|
163724
|
+
});
|
|
163725
|
+
const readOptions = () => buildOnboardOptions({
|
|
163726
|
+
status: this.runtimeState.getCapState(CAP_NAME) ?? null,
|
|
163727
|
+
cfg: lastCfg,
|
|
163728
|
+
limits: lastLimits,
|
|
163729
|
+
schedule: lastSchedule
|
|
163730
|
+
});
|
|
163731
|
+
const provider = {
|
|
163732
|
+
getStatus: bridge.getStatus,
|
|
163733
|
+
getOptions: async ({ deviceId }) => {
|
|
163734
|
+
if (deviceId !== this.id) throw new Error(`ReolinkCamera: deviceId mismatch, expected ${this.id}, got ${deviceId}`);
|
|
163735
|
+
await bridge.ensureFresh();
|
|
163736
|
+
return readOptions();
|
|
163737
|
+
},
|
|
163738
|
+
setSettings: async ({ deviceId, settings }) => {
|
|
163739
|
+
if (deviceId !== this.id) return;
|
|
163740
|
+
if (Object.keys(settings).length === 0) return;
|
|
163741
|
+
await bridge.ensureFresh();
|
|
163742
|
+
const refusal = describeOnboardRefusal(readOptions(), settings);
|
|
163743
|
+
if (refusal !== null) {
|
|
163744
|
+
this.ctx.logger.warn("reolink onboard recording: refusing a write this camera cannot take", {
|
|
163745
|
+
tags: { deviceId: this.id },
|
|
163746
|
+
meta: {
|
|
163747
|
+
refusal,
|
|
163748
|
+
fields: Object.keys(settings)
|
|
163749
|
+
}
|
|
163750
|
+
});
|
|
163751
|
+
throw new Error(`device ${String(this.id)}: ${refusal}`);
|
|
163752
|
+
}
|
|
163753
|
+
const writes = buildOnboardWrites(settings, lastSchedule);
|
|
163754
|
+
const api = await this.ensureApi();
|
|
163755
|
+
const ignored = [];
|
|
163756
|
+
if (writes.cfg !== null) {
|
|
163757
|
+
const result = await api.setRecordCfg(channel, writes.cfg);
|
|
163758
|
+
for (const outcome of result.outcomes) if (outcome.status === "ignored") ignored.push(`${outcome.field}: asked ${outcome.requested}, camera kept ${outcome.actual}`);
|
|
163759
|
+
}
|
|
163760
|
+
if (writes.schedule !== null) {
|
|
163761
|
+
const result = await api.setRecordSchedule(channel, writes.schedule);
|
|
163762
|
+
for (const outcome of result.outcomes) if (outcome.status === "ignored") ignored.push(`schedule ${outcome.field}: the camera kept its own value`);
|
|
163763
|
+
}
|
|
163764
|
+
await refreshFromCamera();
|
|
163765
|
+
if (ignored.length > 0) {
|
|
163766
|
+
this.ctx.logger.warn("reolink onboard recording: the camera answered 200 and discarded part of the write", {
|
|
163767
|
+
tags: { deviceId: this.id },
|
|
163768
|
+
meta: {
|
|
163769
|
+
ignored,
|
|
163770
|
+
fields: Object.keys(settings)
|
|
163771
|
+
}
|
|
163772
|
+
});
|
|
163773
|
+
throw new Error(`device ${String(this.id)}: the camera accepted the request and kept its own values — ${ignored.join("; ")}`);
|
|
163774
|
+
}
|
|
163775
|
+
this.ctx.logger.info("reolink onboard recording: write applied", {
|
|
163776
|
+
tags: { deviceId: this.id },
|
|
163777
|
+
meta: { fields: Object.keys(settings) }
|
|
163778
|
+
});
|
|
163779
|
+
}
|
|
163780
|
+
};
|
|
163781
|
+
this.registerCapWarmer(CAP_NAME, async () => {
|
|
163782
|
+
await provider.getOptions({ deviceId: this.id });
|
|
163783
|
+
await refreshFromCamera();
|
|
163784
|
+
});
|
|
163785
|
+
this.ctx.registerNativeCap(recordingOnboardCapability, provider);
|
|
163786
|
+
this.ctx.logger.info("Reolink recording-onboard cap registered", { tags: { deviceId: this.id } });
|
|
163787
|
+
}
|
|
161912
163788
|
registerDayNightCap() {
|
|
161913
163789
|
const channel = this.getChannel();
|
|
161914
163790
|
const CAP_NAME = "day-night";
|
|
@@ -166338,6 +168214,29 @@ var ReolinkHub = class ReolinkHub extends BaseDevice {
|
|
|
166338
168214
|
await (await this.ensureApi()).reboot();
|
|
166339
168215
|
return { success: true };
|
|
166340
168216
|
} });
|
|
168217
|
+
this.ctx.registerNativeCap(deviceAdminLinkCapability, { getAdminLink: async ({ deviceId }) => {
|
|
168218
|
+
if (deviceId !== this.id) throw new Error(`ReolinkHub: deviceId mismatch, expected ${this.id}, got ${deviceId}`);
|
|
168219
|
+
const host = this.config.get("host") ?? "";
|
|
168220
|
+
const built = buildDeviceAdminUrl({
|
|
168221
|
+
host,
|
|
168222
|
+
port: null,
|
|
168223
|
+
scheme: "http",
|
|
168224
|
+
path: "/"
|
|
168225
|
+
});
|
|
168226
|
+
if (!built.ok) {
|
|
168227
|
+
this.ctx.logger.debug("reolink hub admin link unavailable", {
|
|
168228
|
+
tags: { deviceId: this.id },
|
|
168229
|
+
meta: { reason: built.reason }
|
|
168230
|
+
});
|
|
168231
|
+
return null;
|
|
168232
|
+
}
|
|
168233
|
+
return {
|
|
168234
|
+
url: built.url,
|
|
168235
|
+
label: "Web UI",
|
|
168236
|
+
target: "device",
|
|
168237
|
+
host
|
|
168238
|
+
};
|
|
168239
|
+
} });
|
|
166341
168240
|
}
|
|
166342
168241
|
async onActivate() {
|
|
166343
168242
|
this.ensureApi().catch((err) => {
|