@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/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 splitAnnexBToNalPayloads, C as extractSpsFromAnnexB, D as md5StrModern, E as isH265Irap, M as talkTraceLog, N as traceLog, O as normalizeDebugOptions, S as extractPpsFromAnnexB, T as getH265NalType, _ as convertToAnnexB, a as BC_MAGIC, b as deriveAesKey, c as BaichuanVideoStream, d as __require$1, f as aesDecrypt, g as bcHeaderHasPayloadOffset, h as bcEncrypt, i as BC_CLASS_MODERN_24, j as splitAnnexBToNalPayloads2, k as recordingsTraceLog, l as BcMediaAnnexBDecoder, m as bcDecrypt, n as BC_CLASS_LEGACY, o as BC_MAGIC_REV, p as aesEncrypt, r as BC_CLASS_MODERN_20, t as BC_CLASS_FILE_DOWNLOAD, v as convertToAnnexB2, w as extractVpsFromAnnexB, x as eventTraceLog, y as debugLog } from "./chunk-ZBACPERX-cDJK1KXE.mjs";
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-De1BvmWo.mjs
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-UIQ527B6.js
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-GNC5U3YA.js
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) return;
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
- fail(/* @__PURE__ */ new Error(`Baichuan timeout waiting FileInfoList replay binary chunks cmdId=${cmdId} channelId=${channelId} streamType=${expectedStreamType}`));
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 (/^01\d{14}$/.test(trimmed)) return trimmed;
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 `01${start}`;
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-FOE2DBVZ-94yIU75L.mjs");
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
- ...isNvr ? { channelIdOverride: headerChannelIdOverride ?? 82 } : {},
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-ZC7I7QM6-9NV95vRN.mjs");
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-ZC7I7QM6-9NV95vRN.mjs");
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.slice(0, 500),
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.slice(0, 500),
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.slice(0, 500),
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
- const run = (lanes.get(hostKey) ?? Promise.resolve()).then(() => runAttempts(input)).finally(() => {
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
- await api.fileInfoListReplayBinaryDownload({
151209
- channel: device.channel,
151210
- ...device.uid !== void 0 ? { uid: device.uid } : {},
151211
- fileName,
151212
- idleTimeoutMs: 300,
151213
- onChunk: (chunk) => this.onChunk(decoder, chunk)
151214
- });
151215
- } catch (err) {
151216
- return this.onTransferFailed(err);
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
- return this.onTransferEnded();
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 === null) {
151235
- this.probe = [...this.probe, unit];
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.feed(unit.annexB);
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.fpsSource !== "timestamps") this.deps.logger.warn("videoclips: no measured frame rate for this clip stream", {
151305
- tags: this.tags,
151306
- meta: {
151307
- clipKey: row.clipKey,
151308
- twin,
151309
- branch: "fps-unmeasured",
151310
- probeAccessUnits: this.rate.accessUnits
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.stdinGate.dispose();
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 === "timestamps" ? timing.durationSeconds : null;
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$1(node, key) {
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$1(child, key);
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$1(value, key);
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$1(raw, "mount");
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` last flipped. Drives a
158868
- * hysteresis window that filters out the lib's UDP sleep-inference
158869
- * flapping. The lib runs an internal `getSleepStatus` every 2s on
158870
- * UDP/battery cameras and emits awake/sleeping events from socket
158871
- * I/O patterns alone — those events do NOT reflect real firmware
158872
- * state, especially on battery cams with idle-disconnect where
158873
- * socket activity drops periodically. Without this window every
158874
- * inferred flip would close active streams + emit a cap event.
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
- * @param source - `'hub-summary'` is AUTHORITATIVE (the NVR reports
160158
- * per-channel sleep state from its own firmware, not from socket
160159
- * I/O inference) and therefore bypasses the hysteresis — with ONE
160160
- * exception: the summary LAGS real push state (measured >75 s behind
160161
- * an `awake` push, 2026-08-16), so an awake→sleeping flip from the
160162
- * summary is refused while a fresher committed state is still inside
160163
- * `HUB_SUMMARY_SLEEP_GRACE_MS`. Without the guard the 60 s reconcile
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
- if (this.sleeping === next) return false;
160173
- if (source === "confirmed-wake" && next === false) this.sleepStateChangedAt = Date.now();
160174
- else if (source !== "hub-summary" && !this.acceptSleepingTransition(next)) return false;
160175
- if (source === "hub-summary") {
160176
- const sinceCommitMs = Date.now() - this.sleepStateChangedAt;
160177
- if (next === true && this.sleepStateChangedAt > 0 && sinceCommitMs < ReolinkCamera.HUB_SUMMARY_SLEEP_GRACE_MS) {
160178
- this.ctx.logger.debug("hub-summary sleep flip refused — fresher state inside grace", {
160179
- tags: { deviceId: this.id },
160180
- meta: {
160181
- sinceCommitMs,
160182
- graceMs: ReolinkCamera.HUB_SUMMARY_SLEEP_GRACE_MS
160183
- }
160184
- });
160185
- return false;
160186
- }
160187
- this.sleepStateChangedAt = Date.now();
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-summary") && !sleeping) this.onWakeTransition("hub-summary").catch(() => {});
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) => {