@camstack/addon-provider-reolink 1.2.160 → 1.2.161

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, …).
@@ -27977,6 +28181,511 @@ var dayNightCapability = {
27977
28181
  volatileStateFields: ["lastFetchedAt"]
27978
28182
  };
27979
28183
  /**
28184
+ * Vendor-neutral **onboard** recording + storage cap — what the CAMERA
28185
+ * writes to the CAMERA's own card, on the camera's own schedule.
28186
+ *
28187
+ * This is NOT `recording.cap.ts`. That one is CamStack's recorder: our
28188
+ * footage ledger, our storage locations, our retention. This one has a
28189
+ * different authority — the camera's firmware — and per D62 it stores
28190
+ * nothing of its own. Every value here is read from the camera and every
28191
+ * write goes back to the camera; there is no CamStack-side mirror that
28192
+ * could disagree with the device.
28193
+ *
28194
+ * ## One shape, two firmwares
28195
+ *
28196
+ * Measured 2026-09-22 against the live fleet:
28197
+ *
28198
+ * | fact | Hikvision (ISAPI) | Reolink (Baichuan) |
28199
+ * | --- | --- | --- |
28200
+ * | storage | `ContentMgmt/Storage` `<hdd>` rows: status, capacity, freeSpace (MB) | `getHddInfoList` (cmd 102): `mount`, `format`, `capacity` GB + `capacityM` MB remainder |
28201
+ * | tracks | several (101 **and** 103 on both 1436 and 3833), each with its own schedule | one per channel |
28202
+ * | schedule | per track, 7 `ScheduleAction` blocks: DayOfWeek + TimeOfDay range + ONE `ActionRecordingMode` | per trigger type, a 168-char weekly HOUR mask |
28203
+ * | triggers | `CMR`, `MOTION` | `Normal`, `MD`, `people`, `vehicle`, `dog_cat`, `crossline`, `intrude`, `loitering` |
28204
+ * | pre-record | `PreRecordTimeSeconds` | `preRecordTime` |
28205
+ * | post-record | `PostRecordTimeSeconds` | `recordDelayTime` |
28206
+ * | overwrite | per track `LoopEnable` | `cycle`, with `cycleList` enumerating the accepted values |
28207
+ * | segment length | not exposed on V5.7.1 | `packageTime` (minutes) |
28208
+ *
28209
+ * The two schedule models look different and are the same thing in
28210
+ * different coordinates: both answer "for this trigger, during which
28211
+ * weekly windows does the camera record". {@link RecordWindow} is that
28212
+ * question in one shape — Hikvision's ranges map straight onto it,
28213
+ * Reolink's mask expands into hour-aligned windows.
28214
+ *
28215
+ * ## Union, not intersection
28216
+ *
28217
+ * **The same fields exist on every camera.** What differs per device is
28218
+ * which VALUES that device accepts, and that is what {@link
28219
+ * RecordingOnboardOptions} reports — a `{ readable, writable, reason }`
28220
+ * per field plus the schedule's own limits. A control a camera cannot
28221
+ * honour is rendered DISABLED WITH ITS REASON, never missing and never
28222
+ * dead: disabled must not look like broken.
28223
+ *
28224
+ * ## Refusal by name
28225
+ *
28226
+ * A write a camera cannot honour is refused with a sentence the operator
28227
+ * can read — never accepted and dropped. Both providers refuse through
28228
+ * {@link describeOnboardRefusal}, so the vocabulary is one function and
28229
+ * one test, not two hand-written vendor opinions.
28230
+ *
28231
+ * Follows the D14 `deviceConfig` archetype (see `stream-params.cap.ts`):
28232
+ * `getOptions` advertises per-camera availability, `getStatus` (auto-
28233
+ * injected from `status`) reports the live values, and a single
28234
+ * `setSettings` mutation applies a partial change. No hand-written
28235
+ * settings-contribution methods.
28236
+ */
28237
+ /**
28238
+ * What makes the camera start recording during a window.
28239
+ *
28240
+ * The union of both vendors' vocabularies. `continuous` is Hikvision's
28241
+ * `CMR` and Reolink's `Normal`; `motion` is `MOTION` / `MD`. The
28242
+ * object-class triggers are Reolink-only today and the smart-event ones
28243
+ * (`lineCrossing`, `intrusion`, `loitering`) are Reolink-only on the
28244
+ * firmwares measured — a camera that cannot record on a trigger simply
28245
+ * does not list it in `options.schedule.triggers`, and a window naming
28246
+ * it is REFUSED, not dropped.
28247
+ */
28248
+ var RecordTriggerSchema = _enum([
28249
+ "continuous",
28250
+ "motion",
28251
+ "person",
28252
+ "vehicle",
28253
+ "animal",
28254
+ "lineCrossing",
28255
+ "intrusion",
28256
+ "loitering",
28257
+ "alarmInput"
28258
+ ]);
28259
+ /**
28260
+ * One weekly recording window: "on `day`, from `startMinute` to
28261
+ * `endMinute`, record on `trigger`".
28262
+ *
28263
+ * `day` is 0 = Monday … 6 = Sunday (ISO order, which is also the order
28264
+ * both firmwares enumerate). Minutes are local camera time since
28265
+ * midnight; `endMinute` may be 1440, meaning end of day — that is
28266
+ * Hikvision's literal `24:00` and Reolink's 24th mask slot, and
28267
+ * collapsing it to 0 would turn a whole-day window into an empty one.
28268
+ */
28269
+ var RecordWindowSchema = object({
28270
+ trigger: RecordTriggerSchema,
28271
+ day: number().int().min(0).max(6),
28272
+ startMinute: number().int().min(0).max(1439),
28273
+ endMinute: number().int().min(1).max(1440)
28274
+ });
28275
+ /** Status of one physical volume, as the camera itself describes it. */
28276
+ var OnboardStorageVolumeSchema = object({
28277
+ /** The camera's own id for the volume (`hdd/id`, Reolink `HddInfo/number`). */
28278
+ id: string(),
28279
+ /** The camera's own name for it, when it gives one (`hddName`). */
28280
+ label: string().optional(),
28281
+ status: _enum([
28282
+ "ok",
28283
+ "unformatted",
28284
+ "error",
28285
+ "offline",
28286
+ "unknown"
28287
+ ]),
28288
+ /**
28289
+ * Total size in MB, or **null when the camera did not say**.
28290
+ *
28291
+ * Never 0 for an unreadable value: a measurement that failed is not a
28292
+ * measurement (D393), and a card whose size is unknown must not be
28293
+ * rendered as a card of size zero.
28294
+ */
28295
+ capacityMb: number().nullable(),
28296
+ /**
28297
+ * Free space in MB, or null when unknown.
28298
+ *
28299
+ * **Not a proxy for "has footage".** Measured 2026-09-22: 1436 and
28300
+ * 1439 both report exactly 11776 MB free — the fixed reserve a looping
28301
+ * card converges on once it has wrapped. At loop steady state the
28302
+ * number is identical whether the camera recorded yesterday or stopped
28303
+ * a month ago.
28304
+ */
28305
+ freeMb: number().nullable(),
28306
+ /** True when the camera reports the volume writable (`property` RW). */
28307
+ writable: boolean().optional()
28308
+ });
28309
+ /**
28310
+ * What the camera is doing with its own storage, right now.
28311
+ *
28312
+ * Every scalar is nullable and **null means the camera did not answer**,
28313
+ * never a default. A form that seeds `0` from an unanswered read invites
28314
+ * the operator to save that 0 back onto the camera.
28315
+ */
28316
+ var RecordingOnboardStatusSchema = object({
28317
+ storage: discriminatedUnion("kind", [
28318
+ object({
28319
+ kind: literal("present"),
28320
+ volumes: array(OnboardStorageVolumeSchema)
28321
+ }),
28322
+ object({
28323
+ kind: literal("absent"),
28324
+ reason: string()
28325
+ }),
28326
+ object({
28327
+ kind: literal("unknown"),
28328
+ reason: string()
28329
+ })
28330
+ ]),
28331
+ tracks: array(object({
28332
+ id: string(),
28333
+ enabled: boolean(),
28334
+ isVideo: boolean(),
28335
+ /** From the camera's own track description. Null when it does not say. */
28336
+ codec: string().nullable(),
28337
+ resolution: string().nullable(),
28338
+ /** Per-track overwrite flag, where the firmware keeps it per track. */
28339
+ overwriteWhenFull: boolean().nullable()
28340
+ })),
28341
+ /**
28342
+ * The track the write path targets — the enabled VIDEO one. Null when
28343
+ * no track could be identified, which is itself a refusal reason.
28344
+ */
28345
+ primaryTrackId: string().nullable(),
28346
+ /** Master "record to the card at all" switch. */
28347
+ enabled: boolean().nullable(),
28348
+ overwriteWhenFull: boolean().nullable(),
28349
+ preRecordSec: number().nullable(),
28350
+ postRecordSec: number().nullable(),
28351
+ /** Length of one recorded file, in minutes. */
28352
+ segmentMinutes: number().nullable(),
28353
+ /** The primary track's weekly windows, flattened. */
28354
+ windows: array(RecordWindowSchema),
28355
+ /**
28356
+ * How many windows the camera described that CamStack could NOT read —
28357
+ * an unrecognised trigger, an unparseable clock, a weekday it does not
28358
+ * name.
28359
+ *
28360
+ * A dropped window is work the reader threw away, and a schedule that
28361
+ * silently shows fewer rows than the camera holds is how an operator
28362
+ * saves back a schedule shorter than the one they were looking at
28363
+ * (D391). Non-zero means the window list is INCOMPLETE and a write
28364
+ * that replaces it would delete what was not shown — which is why a
28365
+ * provider reporting a non-zero count also reports the schedule as not
28366
+ * writable.
28367
+ */
28368
+ unreadableWindows: number(),
28369
+ /**
28370
+ * The camera is scheduled to record and has NO usable storage.
28371
+ *
28372
+ * A first-class fact because it is the fleet's most common silent
28373
+ * defect: measured 2026-09-22, 1441 and 3831 are both motion-recording
28374
+ * to a card that is not there. Neither the schedule nor the storage
28375
+ * read says anything wrong on its own; only the pair does.
28376
+ */
28377
+ recordingToNowhere: boolean(),
28378
+ lastFetchedAt: number()
28379
+ });
28380
+ /** Numeric range descriptor — `{ min, max, step }` per the getOptions convention. */
28381
+ var RangeSchema = object({
28382
+ min: number(),
28383
+ max: number(),
28384
+ step: number()
28385
+ });
28386
+ /**
28387
+ * The values a camera actually takes for a numeric field, when they are a SET
28388
+ * rather than a range.
28389
+ *
28390
+ * `{min,max,step}` cannot say what these two firmwares do. Measured on 1436
28391
+ * (I91DN) on 2026-09-22 by writing each value and reading it back:
28392
+ *
28393
+ * - pre-record: `0, 5, 10, 15, 20, 25, 30` and `2147483647` (INT32_MAX, the
28394
+ * camera's "no limit" — `-1` and `4294967295` both land on it);
28395
+ * - post-record: `5, 10, 30, 60, 120, 300, 600`.
28396
+ *
28397
+ * Neither is expressible as a step: the first has a sentinel two billion away
28398
+ * from its neighbours, the second doubles and then jumps. A range that tried
28399
+ * would forbid values the camera takes AND permit values it silently replaces
28400
+ * with 5 — wrong in both directions at once.
28401
+ *
28402
+ * `sentinel` names the member that is not a duration, so a surface can render
28403
+ * "no limit" instead of `2147483647` seconds.
28404
+ */
28405
+ var AllowedValuesSchema = object({
28406
+ values: array(number()).min(1),
28407
+ sentinel: object({
28408
+ value: number(),
28409
+ meaning: _enum(["no-limit", "disabled"])
28410
+ }).optional()
28411
+ });
28412
+ /**
28413
+ * Per-field availability on ONE camera.
28414
+ *
28415
+ * The field exists on every camera — this says whether this one can be
28416
+ * read and whether it can be written, and `reason` says why not when
28417
+ * either is false. The UI renders the control DISABLED with the reason
28418
+ * rather than hiding it, so a limitation is legible instead of looking
28419
+ * like a missing feature.
28420
+ */
28421
+ var OnboardFieldSupportSchema = object({
28422
+ readable: boolean(),
28423
+ writable: boolean(),
28424
+ /** Required whenever `readable` or `writable` is false. */
28425
+ reason: string().optional()
28426
+ });
28427
+ /** What this camera's schedule model can express. */
28428
+ var OnboardScheduleSupportSchema = object({
28429
+ support: OnboardFieldSupportSchema,
28430
+ /**
28431
+ * The smallest time step the camera can express, in minutes.
28432
+ *
28433
+ * Hikvision takes arbitrary minutes (`00:05:00`–`23:57:00` observed on
28434
+ * 1436's track 103). Reolink's schedule is a 7×24 HOUR mask, so 60. A
28435
+ * window whose edges are not a multiple of this is REFUSED rather than
28436
+ * quietly rounded — rounding is how an operator's 06:30 becomes 06:00
28437
+ * and nothing says so.
28438
+ */
28439
+ granularityMinutes: number(),
28440
+ /** Triggers this camera can record on. A window naming another is refused. */
28441
+ triggers: array(RecordTriggerSchema),
28442
+ /**
28443
+ * False when the camera stores ONE trigger per time range, so two
28444
+ * windows overlapping on the same day cannot carry different triggers.
28445
+ * True on Reolink, whose mask is per-trigger and independent.
28446
+ */
28447
+ supportsOverlappingTriggers: boolean()
28448
+ });
28449
+ var RecordingOnboardOptionsSchema = object({
28450
+ enabled: OnboardFieldSupportSchema,
28451
+ overwriteWhenFull: OnboardFieldSupportSchema,
28452
+ preRecordSec: OnboardFieldSupportSchema,
28453
+ preRecordSecRange: RangeSchema.optional(),
28454
+ /** Preferred over the range when the camera takes a SET, not a span. */
28455
+ preRecordSecAllowed: AllowedValuesSchema.optional(),
28456
+ postRecordSec: OnboardFieldSupportSchema,
28457
+ postRecordSecRange: RangeSchema.optional(),
28458
+ /** Preferred over the range when the camera takes a SET, not a span. */
28459
+ postRecordSecAllowed: AllowedValuesSchema.optional(),
28460
+ segmentMinutes: OnboardFieldSupportSchema,
28461
+ segmentMinutesRange: RangeSchema.optional(),
28462
+ /** Preferred over the range when the camera takes a SET, not a span. */
28463
+ segmentMinutesAllowed: AllowedValuesSchema.optional(),
28464
+ schedule: OnboardScheduleSupportSchema
28465
+ });
28466
+ /**
28467
+ * A partial change. Every field optional.
28468
+ *
28469
+ * Unlike the other `deviceConfig` caps, a provider here does **NOT**
28470
+ * silently ignore a field it cannot support — it refuses, by name,
28471
+ * through {@link describeOnboardRefusal}. Silence on a recording setting
28472
+ * is the failure D62 exists to prevent: the operator believes the camera
28473
+ * is recording the way the form says, and it is not.
28474
+ */
28475
+ var RecordingOnboardPatchSchema = object({
28476
+ enabled: boolean().optional(),
28477
+ overwriteWhenFull: boolean().optional(),
28478
+ preRecordSec: number().optional(),
28479
+ postRecordSec: number().optional(),
28480
+ segmentMinutes: number().optional(),
28481
+ /** The complete new window set for the primary track — not a delta. */
28482
+ windows: array(RecordWindowSchema).optional()
28483
+ });
28484
+ var recordingOnboardCapability = {
28485
+ name: "recording-onboard",
28486
+ scope: "device",
28487
+ deviceNative: true,
28488
+ mode: "singleton",
28489
+ deviceTypes: [DeviceType.Camera],
28490
+ deviceConfig: { ui: {
28491
+ kind: "derived-form",
28492
+ builderId: "recording-onboard",
28493
+ tab: "recording"
28494
+ } },
28495
+ methods: {
28496
+ getOptions: method(object({ deviceId: number() }), RecordingOnboardOptionsSchema),
28497
+ setSettings: method(object({
28498
+ deviceId: number(),
28499
+ settings: RecordingOnboardPatchSchema
28500
+ }), _void(), {
28501
+ kind: "mutation",
28502
+ auth: "admin"
28503
+ })
28504
+ },
28505
+ status: {
28506
+ schema: RecordingOnboardStatusSchema,
28507
+ kind: "poll"
28508
+ },
28509
+ runtimeState: RecordingOnboardStatusSchema,
28510
+ /**
28511
+ * Runtime-state durability: **restored** — operator-set camera-side
28512
+ * recording config; mutation-driven, and the storage half is the last
28513
+ * thing the camera said about its own card.
28514
+ *
28515
+ * See `RuntimeStateDurability`. Enforced by
28516
+ * `scripts/check-runtime-state-durability.ts`.
28517
+ */
28518
+ durability: "restored",
28519
+ /** Clock fields: written, but excluded from the compare that decides
28520
+ * whether persisting is worth a SQLite commit. */
28521
+ volatileStateFields: ["lastFetchedAt"]
28522
+ };
28523
+ /**
28524
+ * Day-of-week names in the cap's index order (0 = Monday), for messages
28525
+ * an operator reads and for Hikvision's `<DayOfWeek>` element.
28526
+ */
28527
+ var DAY_NAMES = [
28528
+ "Monday",
28529
+ "Tuesday",
28530
+ "Wednesday",
28531
+ "Thursday",
28532
+ "Friday",
28533
+ "Saturday",
28534
+ "Sunday"
28535
+ ];
28536
+ /**
28537
+ * Does `patch` ask this camera for something it cannot do?
28538
+ *
28539
+ * Returns the operator-readable reason, or `null` when every field in
28540
+ * the patch is within what `options` says this camera accepts. A
28541
+ * provider calls this BEFORE touching the camera and throws the string:
28542
+ * refusing is the point, and refusing identically on both vendors is why
28543
+ * this is one function.
28544
+ *
28545
+ * The order of checks is the order an operator would read them: the
28546
+ * scalar knobs first, then the schedule, because a schedule complaint is
28547
+ * longer and a scalar one is usually the real problem.
28548
+ */
28549
+ function describeOnboardRefusal(options, patch) {
28550
+ if (patch.enabled !== void 0 && !options.enabled.writable) return unwritable("the master recording switch", options.enabled.reason);
28551
+ if (patch.overwriteWhenFull !== void 0 && !options.overwriteWhenFull.writable) return unwritable("overwrite-when-full", options.overwriteWhenFull.reason);
28552
+ const preRefusal = refuseNumeric("pre-record seconds", patch.preRecordSec, options.preRecordSec, options.preRecordSecRange, options.preRecordSecAllowed);
28553
+ if (preRefusal) return preRefusal;
28554
+ const postRefusal = refuseNumeric("post-record seconds", patch.postRecordSec, options.postRecordSec, options.postRecordSecRange, options.postRecordSecAllowed);
28555
+ if (postRefusal) return postRefusal;
28556
+ const segmentRefusal = refuseNumeric("segment length (minutes)", patch.segmentMinutes, options.segmentMinutes, options.segmentMinutesRange, options.segmentMinutesAllowed);
28557
+ if (segmentRefusal) return segmentRefusal;
28558
+ if (patch.windows !== void 0) {
28559
+ const scheduleRefusal = refuseSchedule(options, patch.windows);
28560
+ if (scheduleRefusal) return scheduleRefusal;
28561
+ }
28562
+ return null;
28563
+ }
28564
+ function unwritable(what, reason) {
28565
+ return reason ? `this camera cannot change ${what}: ${reason}` : `this camera cannot change ${what}`;
28566
+ }
28567
+ function refuseNumeric(what, value, support, range, allowed) {
28568
+ if (value === void 0) return null;
28569
+ if (!support.writable) return unwritable(what, support.reason);
28570
+ if (!Number.isFinite(value)) return `${what} must be a number, got ${String(value)}`;
28571
+ if (allowed) {
28572
+ if (allowed.values.includes(value)) return null;
28573
+ const say = (v) => allowed.sentinel !== void 0 && v === allowed.sentinel.value ? allowed.sentinel.meaning === "no-limit" ? "no limit" : "off" : String(v);
28574
+ return `this camera accepts ${what} only as ${allowed.values.map(say).join(", ")}; ${say(value)} is not one of them`;
28575
+ }
28576
+ if (!range) return null;
28577
+ 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`;
28578
+ 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`;
28579
+ return null;
28580
+ }
28581
+ function refuseSchedule(options, windows) {
28582
+ const schedule = options.schedule;
28583
+ if (!schedule.support.writable) return unwritable("the recording schedule", schedule.support.reason);
28584
+ const allowed = new Set(schedule.triggers);
28585
+ for (const window of windows) {
28586
+ if (!allowed.has(window.trigger)) {
28587
+ const offer = schedule.triggers.length > 0 ? schedule.triggers.join(", ") : "none";
28588
+ return `this camera cannot record on "${window.trigger}"; it records on: ${offer}`;
28589
+ }
28590
+ if (window.endMinute <= window.startMinute) return `a window on ${dayName(window.day)} ends at or before it starts (${String(window.startMinute)} → ${String(window.endMinute)})`;
28591
+ const step = schedule.granularityMinutes;
28592
+ 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`;
28593
+ }
28594
+ if (!schedule.supportsOverlappingTriggers) {
28595
+ const clash = findOverlapWithDifferentTrigger(windows);
28596
+ 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))}`;
28597
+ }
28598
+ return null;
28599
+ }
28600
+ function findOverlapWithDifferentTrigger(windows) {
28601
+ for (let i = 0; i < windows.length; i += 1) for (let j = i + 1; j < windows.length; j += 1) {
28602
+ const a = windows[i];
28603
+ const b = windows[j];
28604
+ if (!a || !b) continue;
28605
+ if (a.day !== b.day) continue;
28606
+ if (a.trigger === b.trigger) continue;
28607
+ if (a.startMinute < b.endMinute && b.startMinute < a.endMinute) return {
28608
+ a,
28609
+ b
28610
+ };
28611
+ }
28612
+ return null;
28613
+ }
28614
+ function dayName(day) {
28615
+ return DAY_NAMES[day] ?? `day ${String(day)}`;
28616
+ }
28617
+ /** `510` → `08:30`. For messages, not for the wire. */
28618
+ function formatMinutes(minute) {
28619
+ const hour = Math.floor(minute / 60);
28620
+ const rest = minute % 60;
28621
+ return `${String(hour).padStart(2, "0")}:${String(rest).padStart(2, "0")}`;
28622
+ }
28623
+ /**
28624
+ * Index of the mask character covering `day` at `hour`.
28625
+ *
28626
+ * **Layout: day-major, Monday first** — `day * 24 + hour`.
28627
+ *
28628
+ * This is an ASSUMPTION, and it is recorded as one because the fleet
28629
+ * cannot currently confirm it: every `valueTable` on every Reolink
28630
+ * measured on 2026-09-22 is uniform (all `1` or all `0`), and a uniform
28631
+ * mask reads identically under either layout. To settle it, set a single
28632
+ * hour on one day from the Reolink app and re-read `getRecordSchedule`.
28633
+ * Until then, a NON-uniform mask is reported as windows under this
28634
+ * layout and the day labels may be transposed; a uniform one is exact.
28635
+ */
28636
+ function weeklyMaskIndex(day, hour) {
28637
+ return day * 24 + hour;
28638
+ }
28639
+ /**
28640
+ * Expand one trigger's weekly hour mask into windows, merging adjacent
28641
+ * set hours into one window per run so a full day is ONE window rather
28642
+ * than twenty-four.
28643
+ *
28644
+ * A mask of the wrong length yields no windows: a truncated table is not
28645
+ * a schedule of fewer days, it is an answer we did not understand.
28646
+ */
28647
+ function weeklyMaskToWindows(mask, trigger) {
28648
+ if (mask.length !== 168) return [];
28649
+ const out = [];
28650
+ for (let day = 0; day < 7; day += 1) {
28651
+ let runStart = null;
28652
+ for (let hour = 0; hour <= 24; hour += 1) {
28653
+ const set = hour < 24 && mask[weeklyMaskIndex(day, hour)] === "1";
28654
+ if (set && runStart === null) runStart = hour;
28655
+ else if (!set && runStart !== null) {
28656
+ out.push({
28657
+ trigger,
28658
+ day,
28659
+ startMinute: runStart * 60,
28660
+ endMinute: hour * 60
28661
+ });
28662
+ runStart = null;
28663
+ }
28664
+ }
28665
+ }
28666
+ return out;
28667
+ }
28668
+ /**
28669
+ * Collapse this trigger's windows back into a weekly hour mask.
28670
+ *
28671
+ * Windows for other triggers are ignored — the mask is per trigger. A
28672
+ * window that is not hour-aligned is REFUSED upstream by
28673
+ * {@link describeOnboardRefusal}; this function rounds nothing, it
28674
+ * simply sets every hour the window fully or partly covers, so a caller
28675
+ * that skipped the refusal cannot silently lose coverage.
28676
+ */
28677
+ function windowsToWeeklyMask(windows, trigger) {
28678
+ const slots = new Array(168).fill("0");
28679
+ for (const window of windows) {
28680
+ if (window.trigger !== trigger) continue;
28681
+ if (window.day < 0 || window.day >= 7) continue;
28682
+ const firstHour = Math.floor(window.startMinute / 60);
28683
+ const lastHour = Math.ceil(window.endMinute / 60);
28684
+ for (let hour = firstHour; hour < lastHour && hour < 24; hour += 1) slots[weeklyMaskIndex(window.day, hour)] = "1";
28685
+ }
28686
+ return slots.join("");
28687
+ }
28688
+ /**
27980
28689
  * Generic device-level status snapshot. Auto-registered by `BaseDevice`
27981
28690
  * for every device, regardless of provider — the kernel needs a uniform
27982
28691
  * cap-keyed slice for the basic device flags every consumer expects to
@@ -36979,6 +37688,7 @@ var DEVICE_LOCAL_STATE_CAPS = {
36979
37688
  pressureSensor: pressureSensorCapability,
36980
37689
  privacyMask: privacyMaskCapability,
36981
37690
  ptzAutotrack: ptzAutotrackCapability,
37691
+ recordingOnboard: recordingOnboardCapability,
36982
37692
  recordingSignal: recordingSignalCapability,
36983
37693
  sceneMonitor: sceneMonitorCapability,
36984
37694
  scriptRunner: scriptRunnerCapability,
@@ -40929,6 +41639,12 @@ Object.freeze({
40929
41639
  addonId: null,
40930
41640
  access: "view"
40931
41641
  },
41642
+ "deviceAdminLink.getAdminLink": {
41643
+ capName: "device-admin-link",
41644
+ capScope: "device",
41645
+ addonId: null,
41646
+ access: "view"
41647
+ },
40932
41648
  "deviceAdoption.adopt": {
40933
41649
  capName: "device-adoption",
40934
41650
  capScope: "system",
@@ -44967,6 +45683,24 @@ Object.freeze({
44967
45683
  addonId: null,
44968
45684
  access: "view"
44969
45685
  },
45686
+ "recordingOnboard.getOptions": {
45687
+ capName: "recording-onboard",
45688
+ capScope: "device",
45689
+ addonId: null,
45690
+ access: "view"
45691
+ },
45692
+ "recordingOnboard.getStatus": {
45693
+ capName: "recording-onboard",
45694
+ capScope: "device",
45695
+ addonId: null,
45696
+ access: "view"
45697
+ },
45698
+ "recordingOnboard.setSettings": {
45699
+ capName: "recording-onboard",
45700
+ capScope: "device",
45701
+ addonId: null,
45702
+ access: "create"
45703
+ },
44970
45704
  "recordingSignal.getStatus": {
44971
45705
  capName: "recording-signal",
44972
45706
  capScope: "device",
@@ -46853,6 +47587,11 @@ Object.freeze({
46853
47587
  form: "single",
46854
47588
  optional: true
46855
47589
  }],
47590
+ "deviceAdminLink.getAdminLink": [{
47591
+ name: "deviceId",
47592
+ form: "single",
47593
+ optional: false
47594
+ }],
46856
47595
  "deviceAdoption.release": [{
46857
47596
  name: "camDeviceId",
46858
47597
  form: "single",
@@ -48238,6 +48977,16 @@ Object.freeze({
48238
48977
  form: "single",
48239
48978
  optional: true
48240
48979
  }],
48980
+ "recordingOnboard.getOptions": [{
48981
+ name: "deviceId",
48982
+ form: "single",
48983
+ optional: false
48984
+ }],
48985
+ "recordingOnboard.setSettings": [{
48986
+ name: "deviceId",
48987
+ form: "single",
48988
+ optional: false
48989
+ }],
48241
48990
  "recordingSignal.getStatus": [{
48242
48991
  name: "deviceId",
48243
48992
  form: "single",
@@ -71797,7 +72546,7 @@ var require_yazl = /* @__PURE__ */ __commonJSMin(((exports) => {
71797
72546
  }
71798
72547
  }));
71799
72548
  //#endregion
71800
- //#region node_modules/@apocaliss92/nodelink-js/dist/chunk-UIQ527B6.js
72549
+ //#region node_modules/@apocaliss92/nodelink-js/dist/chunk-E2N7WTCC.js
71801
72550
  var import_undici = require_undici();
71802
72551
  var import_yazl = /* @__PURE__ */ __toESM(require_yazl(), 1);
71803
72552
  var formatterCache = /* @__PURE__ */ new Map();
@@ -78283,7 +79032,7 @@ var require_lz4 = /* @__PURE__ */ __commonJSMin(((exports) => {
78283
79032
  };
78284
79033
  }));
78285
79034
  //#endregion
78286
- //#region node_modules/@apocaliss92/nodelink-js/dist/chunk-GNC5U3YA.js
79035
+ //#region node_modules/@apocaliss92/nodelink-js/dist/chunk-IPKY5AWA.js
78287
79036
  var import_fxp = require_fxp();
78288
79037
  var import_lz4 = /* @__PURE__ */ __toESM(require_lz4(), 1);
78289
79038
  function encodeHeader(h) {
@@ -80315,6 +81064,17 @@ var BaichuanClient = class _BaichuanClient extends EventEmitter {
80315
81064
  * Impact: Snapshots are ~0–50ms slower per camera (negligible for users).
80316
81065
  */
80317
81066
  snapshotQueueTail = Promise.resolve();
81067
+ /**
81068
+ * Live cmd 5 replay transfers on this socket.
81069
+ *
81070
+ * Opening a replay SUPERSEDES every earlier one: nothing on the wire ends a
81071
+ * cmd 5 transfer, so an abandoned one keeps delivering (measured: 8 126
81072
+ * further frames over 3 475 ms after the consumer let go) and its successor
81073
+ * used to adopt them. A superseded session now refuses every frame and
81074
+ * counts it.
81075
+ */
81076
+ replaySessions = /* @__PURE__ */ new Set();
81077
+ replaySessionSeq = 0;
80318
81078
  opts;
80319
81079
  debugCfg;
80320
81080
  logger;
@@ -81772,6 +82532,85 @@ var BaichuanClient = class _BaichuanClient extends EventEmitter {
81772
82532
  this.alarmEventState.set(fallbackChannel, curr);
81773
82533
  return out;
81774
82534
  }
82535
+ /**
82536
+ * Register a cmd 5 replay transfer and supersede every earlier one on this
82537
+ * socket.
82538
+ */
82539
+ openReplaySession(discriminator) {
82540
+ for (const prev of this.replaySessions) {
82541
+ if (prev.superseded) continue;
82542
+ prev.superseded = true;
82543
+ 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.`);
82544
+ }
82545
+ const session = {
82546
+ id: ++this.replaySessionSeq,
82547
+ discriminator,
82548
+ superseded: false,
82549
+ acceptedFrames: 0,
82550
+ droppedFrames: 0,
82551
+ droppedChannelIds: /* @__PURE__ */ new Set(),
82552
+ reportedDrop: false
82553
+ };
82554
+ this.replaySessions.add(session);
82555
+ return session;
82556
+ }
82557
+ closeReplaySession(session) {
82558
+ this.replaySessions.delete(session);
82559
+ 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(",")}.`);
82560
+ }
82561
+ /**
82562
+ * Count a cmd 5 frame this transfer refused, and say so the first time.
82563
+ *
82564
+ * Silence here is what made the original defect invisible: the successor
82565
+ * transfer simply returned a few extra access units and a short duration,
82566
+ * with nothing anywhere saying a frame had changed hands.
82567
+ */
82568
+ countReplayDrop(session, frame, reason) {
82569
+ session.droppedFrames++;
82570
+ session.droppedChannelIds.add(frame.header.channelId);
82571
+ if (!session.reportedDrop) {
82572
+ session.reportedDrop = true;
82573
+ 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.`);
82574
+ }
82575
+ }
82576
+ /**
82577
+ * Wait until the socket has gone quiet of cmd 5 frames.
82578
+ *
82579
+ * A cmd 7 stop is acknowledged `rc=200` before the camera has finished
82580
+ * sending: four of eleven stopped sessions in the Reolink app's own capture
82581
+ * were still delivering when the ack came back (26–91 further frames). A
82582
+ * caller that opens the next replay the instant the ack lands is racing that
82583
+ * tail; this is how it waits for it instead.
82584
+ *
82585
+ * @returns how many cmd 5 frames arrived during the drain.
82586
+ */
82587
+ async drainReplayFrames(options) {
82588
+ const quietMs = options?.quietMs ?? 300;
82589
+ const maxMs = options?.maxMs ?? 4e3;
82590
+ let drained = 0;
82591
+ await new Promise((resolve) => {
82592
+ let quiet;
82593
+ const hard = setTimeout(() => settle(), maxMs);
82594
+ const onFrame = (frame) => {
82595
+ if (frame.header.cmdId !== 5) return;
82596
+ drained++;
82597
+ if (quiet) clearTimeout(quiet);
82598
+ quiet = setTimeout(() => settle(), quietMs);
82599
+ };
82600
+ let settled = false;
82601
+ const settle = () => {
82602
+ if (settled) return;
82603
+ settled = true;
82604
+ clearTimeout(hard);
82605
+ if (quiet) clearTimeout(quiet);
82606
+ this.off("frame", onFrame);
82607
+ resolve();
82608
+ };
82609
+ this.on("frame", onFrame);
82610
+ quiet = setTimeout(() => settle(), quietMs);
82611
+ });
82612
+ return drained;
82613
+ }
81775
82614
  nextMsgNum() {
81776
82615
  this.msgNum = this.msgNum + 1 & 65535;
81777
82616
  return this.msgNum;
@@ -82325,6 +83164,13 @@ var BaichuanClient = class _BaichuanClient extends EventEmitter {
82325
83164
  params.channel ?? this.opts.channel;
82326
83165
  const sessionCounter = this.nextMsgNum();
82327
83166
  const channelId = params.channelIdOverride ?? sessionCounter;
83167
+ const discriminator = params.channelIdOverride == null ? {
83168
+ kind: "minted",
83169
+ channelId
83170
+ } : {
83171
+ kind: "pinned",
83172
+ channelId
83173
+ };
82328
83174
  const msgNum = params.msgNumOverride ?? 0;
82329
83175
  const cmdId = params.cmdId;
82330
83176
  const extXml = params.extensionXml ?? "";
@@ -82350,9 +83196,10 @@ var BaichuanClient = class _BaichuanClient extends EventEmitter {
82350
83196
  const idleTimeoutMs = params.idleTimeoutMs ?? 2e3;
82351
83197
  const chunks = [];
82352
83198
  let streamMsgNum;
82353
- let lockedChannelId;
83199
+ let lockedChannelId = discriminator.kind === "minted" ? discriminator.channelId : void 0;
82354
83200
  let lockedStreamType;
82355
83201
  let idleTimer;
83202
+ const session = this.openReplaySession(discriminator);
82356
83203
  const looksLikeXml = (buf) => {
82357
83204
  let i = 0;
82358
83205
  while (i < buf.length && (buf[i] === 0 || buf[i] === 9 || buf[i] === 10 || buf[i] === 13 || buf[i] === 32)) i++;
@@ -82402,6 +83249,7 @@ var BaichuanClient = class _BaichuanClient extends EventEmitter {
82402
83249
  this.off("frame", onFrame);
82403
83250
  if (timeout) clearTimeout(timeout);
82404
83251
  if (idleTimer) clearTimeout(idleTimer);
83252
+ this.closeReplaySession(session);
82405
83253
  };
82406
83254
  const finish = (buf) => {
82407
83255
  if (done) return;
@@ -82423,8 +83271,15 @@ var BaichuanClient = class _BaichuanClient extends EventEmitter {
82423
83271
  };
82424
83272
  const onFrame = (frame) => {
82425
83273
  if (frame.header.cmdId !== cmdId) return;
83274
+ if (session.superseded) {
83275
+ this.countReplayDrop(session, frame, "superseded");
83276
+ return;
83277
+ }
82426
83278
  if (lockedStreamType !== void 0 && frame.header.streamType !== lockedStreamType) return;
82427
- if (lockedChannelId !== void 0 && frame.header.channelId !== lockedChannelId) return;
83279
+ if (lockedChannelId !== void 0 && frame.header.channelId !== lockedChannelId) {
83280
+ this.countReplayDrop(session, frame, "foreign-channel");
83281
+ return;
83282
+ }
82428
83283
  if (streamMsgNum !== void 0 && frame.header.msgNum !== streamMsgNum) return;
82429
83284
  const rc = frame.header.responseCode;
82430
83285
  if (rc >= 400 && rc < 6e4 && frame.header.msgNum === msgNum) {
@@ -82448,6 +83303,7 @@ var BaichuanClient = class _BaichuanClient extends EventEmitter {
82448
83303
  lockedChannelId = frame.header.channelId;
82449
83304
  lockedStreamType = frame.header.streamType;
82450
83305
  }
83306
+ session.acceptedFrames++;
82451
83307
  params.onChunk?.(decrypted);
82452
83308
  chunks.push(decrypted);
82453
83309
  armIdleFinish();
@@ -82461,7 +83317,8 @@ var BaichuanClient = class _BaichuanClient extends EventEmitter {
82461
83317
  finish(Buffer.concat(chunks));
82462
83318
  return;
82463
83319
  }
82464
- fail(/* @__PURE__ */ new Error(`Baichuan timeout waiting FileInfoList replay binary chunks cmdId=${cmdId} channelId=${channelId} streamType=${expectedStreamType}`));
83320
+ const seen = session.droppedFrames > 0 ? ` (rejected ${session.droppedFrames} cmd ${cmdId} frame(s) on channelId ${[...session.droppedChannelIds].join(",")})` : "";
83321
+ fail(/* @__PURE__ */ new Error(`Baichuan timeout waiting FileInfoList replay binary chunks cmdId=${cmdId} channelId=${channelId} streamType=${expectedStreamType}${seen}`));
82465
83322
  }, timeoutMs);
82466
83323
  this.on("frame", onFrame);
82467
83324
  try {
@@ -85897,6 +86754,433 @@ var MpegTsMuxer = class {
85897
86754
  this.tablesSent = false;
85898
86755
  }
85899
86756
  };
86757
+ var buildDetectionClasses = (parsed, recordType) => {
86758
+ const classes = [];
86759
+ const flags = parsed?.flags;
86760
+ if (flags) {
86761
+ if (flags.aiPerson) classes.push("person");
86762
+ if (flags.aiVehicle) classes.push("vehicle");
86763
+ if (flags.aiAnimal) classes.push("animal");
86764
+ if (flags.aiFace) classes.push("face");
86765
+ if (flags.motion) classes.push("motion");
86766
+ if (flags.schedule) classes.push("schedule");
86767
+ if (flags.doorbell) classes.push("doorbell");
86768
+ if (flags.package) classes.push("package");
86769
+ if (flags.rf) classes.push("rf");
86770
+ if (flags.aiOther) classes.push("other");
86771
+ }
86772
+ if (classes.length === 0 && recordType) {
86773
+ const rt = recordType.toLowerCase();
86774
+ if (rt.includes("people") || rt.includes("person")) classes.push("person");
86775
+ if (rt.includes("vehicle")) classes.push("vehicle");
86776
+ if (rt.includes("dog_cat") || rt.includes("animal")) classes.push("animal");
86777
+ if (rt.includes("face")) classes.push("face");
86778
+ if (rt.includes("md") || rt.includes("motion")) classes.push("motion");
86779
+ if (rt.includes("sched")) classes.push("schedule");
86780
+ if (rt.includes("visitor") || rt.includes("doorbell")) classes.push("doorbell");
86781
+ if (rt.includes("package")) classes.push("package");
86782
+ if (rt.includes("rf")) classes.push("rf");
86783
+ }
86784
+ if (classes.length === 0) classes.push("motion");
86785
+ return classes;
86786
+ };
86787
+ var getXmlTexts = (xml, tags) => {
86788
+ const out = {};
86789
+ for (const tag2 of tags) {
86790
+ const v = getXmlText(xml, tag2);
86791
+ if (v !== void 0) out[tag2] = v;
86792
+ }
86793
+ return out;
86794
+ };
86795
+ var getXmlBlocks = (xml, tagName) => {
86796
+ const re = new RegExp(`<${tagName}\\b[^>]*>([\\s\\S]*?)<\\/${tagName}>`, "g");
86797
+ const out = [];
86798
+ let match;
86799
+ while (match = re.exec(xml)) out.push(match[1] ?? "");
86800
+ return out;
86801
+ };
86802
+ var parseXmlDateTimeBlock = (block, timeZone) => {
86803
+ const year = Number.parseInt(getXmlText(block, "year") ?? "", 10);
86804
+ const month = Number.parseInt(getXmlText(block, "month") ?? "", 10);
86805
+ const day = Number.parseInt(getXmlText(block, "day") ?? "", 10);
86806
+ const hour = Number.parseInt(getXmlText(block, "hour") ?? "", 10);
86807
+ const minute = Number.parseInt(getXmlText(block, "minute") ?? "", 10);
86808
+ const second = Number.parseInt(getXmlText(block, "second") ?? "", 10);
86809
+ if ([
86810
+ year,
86811
+ month,
86812
+ day,
86813
+ hour,
86814
+ minute,
86815
+ second
86816
+ ].every(Number.isFinite)) return dateFromWallClock({
86817
+ year,
86818
+ month,
86819
+ day,
86820
+ hour,
86821
+ minute,
86822
+ second
86823
+ }, timeZone);
86824
+ 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}))?)?/);
86825
+ if (!m) return void 0;
86826
+ const y = Number.parseInt(m[1] ?? "", 10);
86827
+ const mo = Number.parseInt(m[2] ?? "", 10);
86828
+ const da = Number.parseInt(m[3] ?? "", 10);
86829
+ const ho = Number.parseInt(m[4] ?? "0", 10);
86830
+ const mi = Number.parseInt(m[5] ?? "0", 10);
86831
+ const se = Number.parseInt(m[6] ?? "0", 10);
86832
+ if (![
86833
+ y,
86834
+ mo,
86835
+ da,
86836
+ ho,
86837
+ mi,
86838
+ se
86839
+ ].every(Number.isFinite)) return void 0;
86840
+ return dateFromWallClock({
86841
+ year: y,
86842
+ month: mo,
86843
+ day: da,
86844
+ hour: ho,
86845
+ minute: mi,
86846
+ second: se
86847
+ }, timeZone);
86848
+ };
86849
+ var parseRecordingFilesFromXml = (xml, options) => {
86850
+ const timeZone = options?.timeZone;
86851
+ const fileNameOptions = timeZone !== void 0 ? { timeZone } : void 0;
86852
+ const out = [];
86853
+ const fileInfoBlocks = getXmlBlocks(xml, "FileInfo");
86854
+ for (const b of fileInfoBlocks) {
86855
+ const id = getXmlText(b, "Id") ?? getXmlText(b, "ID") ?? getXmlText(b, "id");
86856
+ const name = getXmlText(b, "name") ?? getXmlText(b, "fileName");
86857
+ const chosen = (id ?? name)?.trim();
86858
+ if (!chosen) continue;
86859
+ const item = { fileName: chosen };
86860
+ if (name != null && name.trim()) item.name = name.trim();
86861
+ if (id != null && id.trim()) item.id = id.trim();
86862
+ const recordType = getXmlText(b, "type") ?? getXmlText(b, "recordType") ?? getXmlText(b, "alarmType");
86863
+ if (recordType != null) item.recordType = recordType;
86864
+ const sizeText = getXmlText(b, "size") ?? getXmlText(b, "fileSize");
86865
+ const sizeBytes = sizeText ? Number.parseInt(sizeText, 10) : void 0;
86866
+ if (sizeBytes != null && Number.isFinite(sizeBytes)) item.sizeBytes = sizeBytes;
86867
+ const start = getXmlBlocks(b, "startTime")[0];
86868
+ const end = getXmlBlocks(b, "endTime")[0];
86869
+ const startDt = start ? parseXmlDateTimeBlock(start, timeZone) : void 0;
86870
+ const endDt = end ? parseXmlDateTimeBlock(end, timeZone) : void 0;
86871
+ if (startDt) item.startTime = startDt;
86872
+ if (endDt) item.endTime = endDt;
86873
+ const parsed = parseRecordingFileName(item.name ?? item.fileName, fileNameOptions);
86874
+ const parsedFromPath = item.fileName !== item.name ? parseRecordingFileName(item.fileName, fileNameOptions) : void 0;
86875
+ const bestParsed = parsedFromPath?.sizeBytes != null ? parsedFromPath : parsed;
86876
+ if (bestParsed) {
86877
+ item.parsedFileName = bestParsed;
86878
+ if (!item.startTime) item.startTime = bestParsed.start;
86879
+ if (!item.endTime) item.endTime = bestParsed.end;
86880
+ if (item.sizeBytes == null && bestParsed.sizeBytes != null) item.sizeBytes = bestParsed.sizeBytes;
86881
+ }
86882
+ item.detectionClasses = buildDetectionClasses(parsed, item.recordType);
86883
+ out.push(item);
86884
+ }
86885
+ const fileBlocks = getXmlBlocks(xml, "File");
86886
+ for (const b of fileBlocks) {
86887
+ const fileName = (getXmlText(b, "fileName") ?? getXmlText(b, "name"))?.trim();
86888
+ if (!fileName) continue;
86889
+ const sizeText = getXmlText(b, "size") ?? getXmlText(b, "fileSize");
86890
+ const sizeBytes = sizeText ? Number.parseInt(sizeText, 10) : void 0;
86891
+ const recordType = getXmlText(b, "type") ?? getXmlText(b, "recordType") ?? getXmlText(b, "alarmType");
86892
+ const start = getXmlBlocks(b, "startTime")[0];
86893
+ const end = getXmlBlocks(b, "endTime")[0];
86894
+ const item = { fileName };
86895
+ if (sizeBytes != null && Number.isFinite(sizeBytes)) item.sizeBytes = sizeBytes;
86896
+ if (recordType != null) item.recordType = recordType;
86897
+ const startDt = start ? parseXmlDateTimeBlock(start, timeZone) : void 0;
86898
+ const endDt = end ? parseXmlDateTimeBlock(end, timeZone) : void 0;
86899
+ if (startDt) item.startTime = startDt;
86900
+ if (endDt) item.endTime = endDt;
86901
+ const parsed = parseRecordingFileName(item.fileName, fileNameOptions);
86902
+ if (parsed) {
86903
+ item.parsedFileName = parsed;
86904
+ if (!item.startTime) item.startTime = parsed.start;
86905
+ if (!item.endTime) item.endTime = parsed.end;
86906
+ if (item.sizeBytes == null && parsed.sizeBytes != null) item.sizeBytes = parsed.sizeBytes;
86907
+ }
86908
+ item.detectionClasses = buildDetectionClasses(parsed, item.recordType);
86909
+ out.push(item);
86910
+ }
86911
+ if (out.length === 0) {
86912
+ const re = /<fileName>([\s\S]*?)<\/fileName>/g;
86913
+ const seenNames = /* @__PURE__ */ new Set();
86914
+ let match;
86915
+ while (match = re.exec(xml)) {
86916
+ const fileName = (match[1] ?? "").trim();
86917
+ if (!fileName) continue;
86918
+ if (seenNames.has(fileName)) continue;
86919
+ seenNames.add(fileName);
86920
+ const item = { fileName };
86921
+ const parsed = parseRecordingFileName(fileName, fileNameOptions);
86922
+ if (parsed) {
86923
+ item.parsedFileName = parsed;
86924
+ item.startTime = parsed.start;
86925
+ item.endTime = parsed.end;
86926
+ }
86927
+ item.detectionClasses = buildDetectionClasses(parsed, void 0);
86928
+ out.push(item);
86929
+ }
86930
+ }
86931
+ const alarmBlocks = getXmlBlocks(xml, "alarmVideo");
86932
+ if (alarmBlocks.length > 0) {
86933
+ const byName = /* @__PURE__ */ new Map();
86934
+ for (const existing of out) {
86935
+ const key = existing.fileName.trim();
86936
+ if (!key) continue;
86937
+ if (!byName.has(key)) byName.set(key, existing);
86938
+ }
86939
+ for (const b of alarmBlocks) {
86940
+ const fileName = (getXmlText(b, "fileName") ?? getXmlText(b, "name"))?.trim();
86941
+ if (!fileName) continue;
86942
+ 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();
86943
+ const alarmType = getXmlText(b, "alarmType")?.trim();
86944
+ const start = getXmlBlocks(b, "startTime")[0];
86945
+ const end = getXmlBlocks(b, "endTime")[0];
86946
+ const startDt = start ? parseXmlDateTimeBlock(start, timeZone) : void 0;
86947
+ const endDt = end ? parseXmlDateTimeBlock(end, timeZone) : void 0;
86948
+ const target = byName.get(fileName) ?? { fileName };
86949
+ if (alarmId && !target.id) target.id = alarmId;
86950
+ if (alarmType) target.recordType = alarmType;
86951
+ if (startDt) target.startTime = startDt;
86952
+ if (endDt) target.endTime = endDt;
86953
+ if (!target.parsedFileName) {
86954
+ const parsed = parseRecordingFileName(target.fileName, fileNameOptions);
86955
+ if (parsed) {
86956
+ target.parsedFileName = parsed;
86957
+ if (!target.startTime) target.startTime = parsed.start;
86958
+ if (!target.endTime) target.endTime = parsed.end;
86959
+ }
86960
+ }
86961
+ if (!byName.has(fileName)) {
86962
+ out.push(target);
86963
+ byName.set(fileName, target);
86964
+ }
86965
+ }
86966
+ }
86967
+ const seen = /* @__PURE__ */ new Set();
86968
+ return out.filter((f) => {
86969
+ const key = f.fileName.trim();
86970
+ if (seen.has(key)) return false;
86971
+ seen.add(key);
86972
+ return true;
86973
+ });
86974
+ };
86975
+ function parseTalkAudioConfig(block) {
86976
+ const audioType = getXmlText(block, "audioType");
86977
+ const sampleRate = Number.parseInt(getXmlText(block, "sampleRate") ?? "", 10);
86978
+ const samplePrecision = Number.parseInt(getXmlText(block, "samplePrecision") ?? "", 10);
86979
+ const lengthPerEncoder = Number.parseInt(getXmlText(block, "lengthPerEncoder") ?? "", 10);
86980
+ const soundTrack = getXmlText(block, "soundTrack");
86981
+ const priorityText = getXmlText(block, "priority");
86982
+ if (!audioType || !Number.isFinite(sampleRate) || !Number.isFinite(samplePrecision) || !Number.isFinite(lengthPerEncoder) || !soundTrack) return null;
86983
+ const config = {
86984
+ audioType,
86985
+ sampleRate,
86986
+ samplePrecision,
86987
+ lengthPerEncoder,
86988
+ soundTrack
86989
+ };
86990
+ if (priorityText !== void 0) {
86991
+ const pr = Number.parseInt(priorityText, 10);
86992
+ if (Number.isFinite(pr)) config.priority = pr;
86993
+ }
86994
+ return config;
86995
+ }
86996
+ var parseTalkAbilityXml = (xml) => {
86997
+ const talkAbilityBlock = getXmlBlocks(xml, "TalkAbility")[0];
86998
+ if (!talkAbilityBlock) throw new Error("TalkAbility XML not found in response");
86999
+ return {
87000
+ duplexList: getXmlBlocks(talkAbilityBlock, "duplexList").map((b) => getXmlText(b, "duplex")).filter((v) => Boolean(v)),
87001
+ audioStreamModeList: getXmlBlocks(talkAbilityBlock, "audioStreamModeList").map((b) => getXmlText(b, "audioStreamMode")).filter((v) => Boolean(v)),
87002
+ audioConfigList: getXmlBlocks(talkAbilityBlock, "audioConfig").map(parseTalkAudioConfig).filter((v) => Boolean(v))
87003
+ };
87004
+ };
87005
+ function parseNumber(v) {
87006
+ if (v == null) return void 0;
87007
+ const n = Number(v);
87008
+ return Number.isFinite(n) ? n : void 0;
87009
+ }
87010
+ function parseBoolean01(v) {
87011
+ if (v == null) return void 0;
87012
+ const s = v.trim();
87013
+ if (s === "1" || s.toLowerCase() === "true") return true;
87014
+ if (s === "0" || s.toLowerCase() === "false") return false;
87015
+ }
87016
+ var BYTES_PER_MB = 1024 * 1024;
87017
+ var num = (xml, tag2) => {
87018
+ const value = parseNumber(getXmlText(xml, tag2));
87019
+ return value === void 0 ? null : value;
87020
+ };
87021
+ var megabytes$1 = (bytes, whole, remainder) => {
87022
+ if (bytes !== null) return bytes / BYTES_PER_MB;
87023
+ if (whole === null && remainder === null) return null;
87024
+ return (whole ?? 0) * 1024 + (remainder ?? 0);
87025
+ };
87026
+ var parseHddInfoListXml = (xml) => {
87027
+ const list = getXmlBlocks(xml, "HddInfoList")[0];
87028
+ if (list === void 0) return [];
87029
+ return getXmlBlocks(list, "HddInfo").map((block) => {
87030
+ const capacity = num(block, "capacity");
87031
+ const capacityM = num(block, "capacityM");
87032
+ const capacityBytes = num(block, "capacityV2");
87033
+ const remainSize = num(block, "remainSize");
87034
+ const remainSizeM = num(block, "remainSizeM");
87035
+ const remainSizeBytes = num(block, "remainSizeV2");
87036
+ return {
87037
+ number: num(block, "number"),
87038
+ capacity,
87039
+ capacityM,
87040
+ capacityBytes,
87041
+ remainSize,
87042
+ remainSizeM,
87043
+ remainSizeBytes,
87044
+ format: num(block, "format"),
87045
+ mount: num(block, "mount"),
87046
+ type: num(block, "type"),
87047
+ index: num(block, "index"),
87048
+ capacityMb: megabytes$1(capacityBytes, capacity, capacityM),
87049
+ remainMb: megabytes$1(remainSizeBytes, remainSize, remainSizeM)
87050
+ };
87051
+ });
87052
+ };
87053
+ var DEFAULT_PRE_RECORD_SECONDS = 10;
87054
+ var parseRecordCfgLimits = (xml) => {
87055
+ const block = getXmlBlocks(xml, "RecordCfg")[0] ?? "";
87056
+ const numbersIn = (container, tag2) => {
87057
+ const outer = getXmlBlocks(block, container)[0];
87058
+ if (outer === void 0) return [];
87059
+ return getXmlBlocks(outer, tag2).map((t) => parseNumber(t.trim())).filter((n) => n !== void 0);
87060
+ };
87061
+ const pre = parseNumber(getXmlText(block, "preRecordTime"));
87062
+ return {
87063
+ cycleList: numbersIn("cyclelist", "item"),
87064
+ recordDelayTimeChoices: numbersIn("timeList", "time"),
87065
+ preRecordSupported: pre !== void 0,
87066
+ preRecordSeconds: pre === void 0 || pre === 0 ? null : pre
87067
+ };
87068
+ };
87069
+ var parseRecordScheduleRows = (xml) => {
87070
+ const block = getXmlBlocks(xml, "Record")[0];
87071
+ const list = block === void 0 ? void 0 : getXmlBlocks(block, "typeScheduleList")[0];
87072
+ if (list === void 0) return [];
87073
+ const rows = [];
87074
+ for (const item of getXmlBlocks(list, "item")) {
87075
+ const type = (getXmlText(item, "type") ?? "").trim();
87076
+ if (type === "") continue;
87077
+ const index = parseNumber(getXmlText(item, "index"));
87078
+ rows.push({
87079
+ type,
87080
+ index: index === void 0 ? null : index,
87081
+ valueTable: (getXmlText(item, "valueTable") ?? "").trim()
87082
+ });
87083
+ }
87084
+ return rows;
87085
+ };
87086
+ var BaichuanRecordConfigError = class extends Error {
87087
+ constructor(message) {
87088
+ super(message);
87089
+ this.name = "BaichuanRecordConfigError";
87090
+ }
87091
+ };
87092
+ var requireInteger = (field, value) => {
87093
+ if (!Number.isInteger(value)) throw new BaichuanRecordConfigError(`${field} must be a whole number, got ${String(value)}`);
87094
+ };
87095
+ var validateRecordCfgPatch = (patch, limits) => {
87096
+ if (patch.cycle !== void 0) {
87097
+ requireInteger("cycle", patch.cycle);
87098
+ 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`);
87099
+ }
87100
+ if (patch.recordDelayTime !== void 0) {
87101
+ requireInteger("recordDelayTime", patch.recordDelayTime);
87102
+ if (patch.recordDelayTime < 0) throw new BaichuanRecordConfigError(`recordDelayTime must not be negative, got ${patch.recordDelayTime}`);
87103
+ }
87104
+ if (patch.packageTime !== void 0) {
87105
+ requireInteger("packageTime", patch.packageTime);
87106
+ if (patch.packageTime < 1) throw new BaichuanRecordConfigError(`packageTime is a length in minutes and must be at least 1, got ${patch.packageTime}`);
87107
+ }
87108
+ 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");
87109
+ };
87110
+ var isWeeklyMask = (mask) => mask.length === 168 && /^[01]+$/.test(mask);
87111
+ var validateRecordSchedulePatch = (patch, rows) => {
87112
+ for (const entry of patch.entries ?? []) {
87113
+ 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`);
87114
+ 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"}`);
87115
+ }
87116
+ };
87117
+ var describeEntry = (entry) => entry.index === void 0 ? entry.type : `${entry.type}[${entry.index}]`;
87118
+ var describeRow = (row) => row.index === null ? row.type : `${row.type}[${row.index}]`;
87119
+ var replaceTag = (xml, tag2, value) => xml.replace(new RegExp(`<${tag2}>[^<]*</${tag2}>`), `<${tag2}>${value}</${tag2}>`);
87120
+ var buildRecordCfgSetXml = (currentXml, patch, limits) => {
87121
+ let xml = currentXml;
87122
+ if (patch.cycle !== void 0) xml = replaceTag(xml, "cycle", String(patch.cycle));
87123
+ if (patch.recordDelayTime !== void 0) xml = replaceTag(xml, "recordDelayTime", String(patch.recordDelayTime));
87124
+ if (patch.packageTime !== void 0) xml = replaceTag(xml, "packageTime", String(patch.packageTime));
87125
+ if (patch.preRecordEnabled !== void 0) {
87126
+ const seconds = patch.preRecordEnabled ? limits.preRecordSeconds ?? DEFAULT_PRE_RECORD_SECONDS : 0;
87127
+ xml = replaceTag(xml, "preRecordTime", String(seconds));
87128
+ }
87129
+ return xml;
87130
+ };
87131
+ var buildRecordScheduleSetXml = (currentXml, patch) => {
87132
+ let xml = currentXml;
87133
+ if (patch.enable !== void 0) xml = replaceTag(xml, "enable", patch.enable ? "1" : "0");
87134
+ for (const entry of patch.entries ?? []) xml = replaceItemValueTable(xml, entry);
87135
+ return xml;
87136
+ };
87137
+ var ITEM_RE = /<item>([\s\S]*?)<\/item>/g;
87138
+ var replaceItemValueTable = (xml, entry) => xml.replace(ITEM_RE, (whole, inner) => {
87139
+ if ((getXmlText(inner, "type") ?? "").trim() !== entry.type) return whole;
87140
+ if (entry.index !== void 0) {
87141
+ if (parseNumber(getXmlText(inner, "index")) !== entry.index) return whole;
87142
+ }
87143
+ if (getXmlText(inner, "valueTable") === void 0) return whole;
87144
+ return `<item>${replaceTag(inner, "valueTable", entry.valueTable)}</item>`;
87145
+ });
87146
+ var verdict = (outcomes) => ({
87147
+ outcomes,
87148
+ allApplied: outcomes.every((o) => o.status === "applied")
87149
+ });
87150
+ var compare = (field, requested, actual) => actual === requested ? {
87151
+ field,
87152
+ status: "applied",
87153
+ value: requested
87154
+ } : {
87155
+ field,
87156
+ status: "ignored",
87157
+ requested,
87158
+ actual: actual ?? "(absent)"
87159
+ };
87160
+ var diffRecordCfg = (patch, afterXml) => {
87161
+ const block = getXmlBlocks(afterXml, "RecordCfg")[0] ?? "";
87162
+ const outcomes = [];
87163
+ if (patch.cycle !== void 0) outcomes.push(compare("cycle", String(patch.cycle), getXmlText(block, "cycle")));
87164
+ if (patch.recordDelayTime !== void 0) outcomes.push(compare("recordDelayTime", String(patch.recordDelayTime), getXmlText(block, "recordDelayTime")));
87165
+ if (patch.packageTime !== void 0) outcomes.push(compare("packageTime", String(patch.packageTime), getXmlText(block, "packageTime")));
87166
+ if (patch.preRecordEnabled !== void 0) {
87167
+ const seconds = parseNumber(getXmlText(block, "preRecordTime"));
87168
+ const on = seconds !== void 0 && seconds !== 0;
87169
+ outcomes.push(compare("preRecordEnabled", String(patch.preRecordEnabled), seconds === void 0 ? void 0 : String(on)));
87170
+ }
87171
+ return verdict(outcomes);
87172
+ };
87173
+ var diffRecordSchedule = (patch, afterXml) => {
87174
+ const outcomes = [];
87175
+ const block = getXmlBlocks(afterXml, "Record")[0] ?? "";
87176
+ if (patch.enable !== void 0) outcomes.push(compare("enable", patch.enable ? "1" : "0", getXmlText(block, "enable")));
87177
+ const rows = parseRecordScheduleRows(afterXml);
87178
+ for (const entry of patch.entries ?? []) {
87179
+ const row = rows.find((r) => r.type === entry.type && (entry.index === void 0 || r.index === entry.index));
87180
+ outcomes.push(compare(`schedule.${describeEntry(entry)}`, entry.valueTable, row === void 0 ? void 0 : row.valueTable));
87181
+ }
87182
+ return verdict(outcomes);
87183
+ };
85900
87184
  function toNumberOrUndefined(value) {
85901
87185
  if (value == null) return void 0;
85902
87186
  const n = Number(value);
@@ -86510,254 +87794,6 @@ var estimateVideoTiming = (params) => {
86510
87794
  fpsSource: "unknown"
86511
87795
  };
86512
87796
  };
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
87797
  function daysInMonth(year, month) {
86762
87798
  return new Date(Date.UTC(year, month, 0)).getUTCDate();
86763
87799
  }
@@ -87366,17 +88402,6 @@ var TtlCache = class {
87366
88402
  this.entries.clear();
87367
88403
  }
87368
88404
  };
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
88405
  function parseOsdDatetimeXml(rawXml) {
87381
88406
  const osdBlock = getXmlBlocks(rawXml, "OsdDatetime")[0];
87382
88407
  const nameBlock = getXmlBlocks(rawXml, "OsdChannelName")[0];
@@ -88719,12 +89744,12 @@ var parseRecStartParamIfPresent = (fileName) => {
88719
89744
  if (!m) return void 0;
88720
89745
  return `${m[2]}${m[3]}`;
88721
89746
  };
88722
- var buildReplayStopNameFromFileName = (fileName) => {
89747
+ var buildReplayStopNameFromFileName = (fileName, channel = 0) => {
88723
89748
  const trimmed = (fileName ?? "").trim();
88724
- if (/^01\d{14}$/.test(trimmed)) return trimmed;
89749
+ if (/^\d{2}\d{14}$/.test(trimmed)) return trimmed;
88725
89750
  const start = parseRecStartParamIfPresent(fileName);
88726
89751
  if (!start) return void 0;
88727
- return `01${start}`;
89752
+ return `${String(channel + 1).padStart(2, "0")}${start}`;
88728
89753
  };
88729
89754
  var buildFileInfoListReplayByIdXml = (params) => {
88730
89755
  const st = params.streamType ?? "mainStream";
@@ -91424,7 +92449,7 @@ var ReolinkBaichuanApi = class _ReolinkBaichuanApi {
91424
92449
  return;
91425
92450
  }
91426
92451
  entry.startInFlight = (async () => {
91427
- const { BaichuanVideoStream: BaichuanVideoStream2 } = await import("./BaichuanVideoStream-FOE2DBVZ-94yIU75L.mjs");
92452
+ const { BaichuanVideoStream: BaichuanVideoStream2 } = await import("./BaichuanVideoStream-UG3DIMIE-BRbIHCdL.mjs");
91428
92453
  const sessionKey = `live:object-detections:ch${entry.channel}:${entry.profile}`;
91429
92454
  const dedicated = await this.createDedicatedSession(sessionKey);
91430
92455
  const stream = new BaichuanVideoStream2({
@@ -93384,7 +94409,7 @@ var ReolinkBaichuanApi = class _ReolinkBaichuanApi {
93384
94409
  const stop = async () => {
93385
94410
  if (tornDown) return;
93386
94411
  tornDown = true;
93387
- const stopName = buildReplayStopNameFromFileName(params.fileName);
94412
+ const stopName = buildReplayStopNameFromFileName(params.fileName, channel);
93388
94413
  if (started && stopName) try {
93389
94414
  const stopXml = buildFileInfoListStopXml({
93390
94415
  channel,
@@ -93493,7 +94518,7 @@ var ReolinkBaichuanApi = class _ReolinkBaichuanApi {
93493
94518
  const stop = async () => {
93494
94519
  if (tornDown) return;
93495
94520
  tornDown = true;
93496
- const stopName = buildReplayStopNameFromFileName(params.fileName);
94521
+ const stopName = buildReplayStopNameFromFileName(params.fileName, channel);
93497
94522
  if (started && stopName) try {
93498
94523
  const stopXml = buildFileInfoListStopXml({
93499
94524
  channel,
@@ -94445,11 +95470,13 @@ ${stderr}`));
94445
95470
  });
94446
95471
  const timeoutMs = params.timeoutMs ?? 12e4;
94447
95472
  trace(`download: channel=${channel} uid=${uid || "(missing)"} ident=${ident} streamType=${streamType} isNvr=${isNvr} timeoutMs=${timeoutMs}`);
95473
+ const pinnedHeaderChannelId = params.headerChannelIdOverride;
95474
+ let abandoned = false;
94448
95475
  try {
94449
95476
  return await this.client.sendBinary({
94450
95477
  cmdId: 5,
94451
95478
  channel,
94452
- ...isNvr ? { channelIdOverride: headerChannelIdOverride ?? 82 } : {},
95479
+ ...pinnedHeaderChannelId != null ? { channelIdOverride: pinnedHeaderChannelId } : {},
94453
95480
  msgNumOverride: 0,
94454
95481
  messageClass: BC_CLASS_MODERN_24,
94455
95482
  payloadXml,
@@ -94460,8 +95487,53 @@ ${stderr}`));
94460
95487
  });
94461
95488
  } catch (e) {
94462
95489
  trace(`download failed: ${e instanceof Error ? e.message : String(e)}`);
95490
+ abandoned = true;
94463
95491
  throw e;
95492
+ } finally {
95493
+ await this.stopFileInfoListReplay({
95494
+ channel,
95495
+ fileName: ident,
95496
+ streamType,
95497
+ drain: abandoned
95498
+ });
95499
+ }
95500
+ }
95501
+ /**
95502
+ * Close a cmd 5 replay session at the camera (cmd 7) and wait for the wire to
95503
+ * go quiet.
95504
+ *
95505
+ * Best-effort by design: a session that has already ended answers with an
95506
+ * error on some firmwares and there is nothing to do about it. The DRAIN is
95507
+ * not best-effort — the ack comes back before the camera has finished
95508
+ * sending (the Reolink app's own capture shows 26–91 further frames after
95509
+ * four of eleven stops), and the next replay must not be opened into that
95510
+ * tail.
95511
+ */
95512
+ async stopFileInfoListReplay(params) {
95513
+ const stopName = buildReplayStopNameFromFileName(params.fileName, params.channel);
95514
+ if (!stopName) {
95515
+ this.logger?.debug?.(`[stopFileInfoListReplay] no stop name derivable from ${params.fileName}; the session is left to time out at the camera`);
95516
+ return;
95517
+ }
95518
+ try {
95519
+ await this.client.sendXml({
95520
+ cmdId: 7,
95521
+ channel: params.channel,
95522
+ payloadXml: buildFileInfoListStopXml({
95523
+ channel: params.channel,
95524
+ name: stopName,
95525
+ streamType: params.streamType
95526
+ }),
95527
+ messageClass: BC_CLASS_MODERN_24,
95528
+ timeoutMs: 4e3,
95529
+ internal: true
95530
+ });
95531
+ } catch (e) {
95532
+ this.logger?.debug?.(`[stopFileInfoListReplay] stop <name>${stopName}</name> failed: ${e instanceof Error ? e.message : String(e)}`);
94464
95533
  }
95534
+ if (!params.drain) return;
95535
+ const drained = await this.client.drainReplayFrames();
95536
+ 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
95537
  }
94466
95538
  async downloadRecording(params) {
94467
95539
  this.logger?.debug?.(`[downloadRecording] Queuing download for: ${params.fileName}, channel=${params.channel}`);
@@ -97581,7 +98653,7 @@ ${xml}`);
97581
98653
  * @returns Test results for all stream types and profiles
97582
98654
  */
97583
98655
  async testChannelStreams(channel, logger) {
97584
- const { testChannelStreams } = await import("./DiagnosticsTools-ZC7I7QM6-9NV95vRN.mjs");
98656
+ const { testChannelStreams } = await import("./DiagnosticsTools-YIWXO7IJ-9NV95vRN.mjs");
97585
98657
  return await testChannelStreams({
97586
98658
  api: this,
97587
98659
  channel: this.normalizeChannel(channel),
@@ -97597,7 +98669,7 @@ ${xml}`);
97597
98669
  * @returns Complete diagnostics for all channels and streams
97598
98670
  */
97599
98671
  async collectMultifocalDiagnostics(logger) {
97600
- const { collectMultifocalDiagnostics } = await import("./DiagnosticsTools-ZC7I7QM6-9NV95vRN.mjs");
98672
+ const { collectMultifocalDiagnostics } = await import("./DiagnosticsTools-YIWXO7IJ-9NV95vRN.mjs");
97601
98673
  return await collectMultifocalDiagnostics({
97602
98674
  api: this,
97603
98675
  logger
@@ -98419,6 +99491,115 @@ ${xml}`);
98419
99491
  };
98420
99492
  })() : {};
98421
99493
  }
99494
+ /**
99495
+ * The limits THIS camera states for `<RecordCfg>` (cmd 54), read from its
99496
+ * own reply — `<cyclelist>`, `<timeList>`, whether pre-record exists at all.
99497
+ *
99498
+ * Read this before offering a control: a firmware that stops accepting a
99499
+ * value stops listing it, and {@link setRecordCfg} refuses against the same
99500
+ * facts.
99501
+ */
99502
+ async getRecordCfgLimits(channel, options) {
99503
+ return parseRecordCfgLimits(await this.sendPcapDerivedSettingsGetXml({
99504
+ cmdId: 54,
99505
+ channel,
99506
+ ...options?.timeoutMs != null ? { timeoutMs: options.timeoutMs } : {}
99507
+ }));
99508
+ }
99509
+ /**
99510
+ * SetRecCfg (cmd 55) — write the onboard recording configuration.
99511
+ *
99512
+ * Read-modify-write over the camera's own cmd 54 document, then a re-read to
99513
+ * find out what it actually kept. Both halves are load-bearing, because this
99514
+ * command **never reports a bad value**: measured on 2026-09-22, a
99515
+ * `preRecordTime` of 9999 and a `cycle` outside the camera's own
99516
+ * `<cyclelist>` were each answered `200` and discarded. The returned
99517
+ * {@link BaichuanRecordWriteResult} is the only honest answer to "did that
99518
+ * work", so callers should look at `allApplied` rather than at the absence
99519
+ * of a thrown error.
99520
+ *
99521
+ * Values the camera would certainly drop are refused BEFORE the wire with a
99522
+ * {@link BaichuanRecordConfigError}.
99523
+ *
99524
+ * `preRecordEnabled` is a boolean on purpose — see `utils/recordConfig.ts`.
99525
+ *
99526
+ * Verified live: `packageTime` 5 → 10 and `recordDelayTime` 15 → 30 on E1
99527
+ * Outdoor PoE v3.1.0.5223, `recordDelayTime` 15 → 30 → 20 on a Home Hub
99528
+ * v3.3.0.456 channel 0, each read back and restored.
99529
+ */
99530
+ async setRecordCfg(channel, patch, options) {
99531
+ const ch = this.normalizeChannel(channel);
99532
+ const timeoutOpts = options?.timeoutMs != null ? { timeoutMs: options.timeoutMs } : {};
99533
+ const currentXml = await this.sendXml({
99534
+ cmdId: 54,
99535
+ channel: ch,
99536
+ ...timeoutOpts
99537
+ });
99538
+ const limits = parseRecordCfgLimits(currentXml);
99539
+ validateRecordCfgPatch(patch, limits);
99540
+ await this.sendXml({
99541
+ cmdId: 55,
99542
+ channel: ch,
99543
+ payloadXml: ensureXmlHeader(buildRecordCfgSetXml(currentXml, patch, limits)),
99544
+ ...timeoutOpts
99545
+ });
99546
+ return diffRecordCfg(patch, await this.sendXml({
99547
+ cmdId: 54,
99548
+ channel: ch,
99549
+ ...timeoutOpts
99550
+ }));
99551
+ }
99552
+ /**
99553
+ * The schedule rows THIS camera lists (cmd 81), `(type, index)` and the
99554
+ * current mask for each.
99555
+ *
99556
+ * `<index>` matters: an E1 Outdoor Pro lists `crossline`, `intrude` and
99557
+ * `loitering` three times each, one row per configured rule, and a write
99558
+ * addressed by `type` alone would overwrite all three.
99559
+ */
99560
+ async getRecordScheduleRows(channel, options) {
99561
+ return parseRecordScheduleRows(await this.sendPcapDerivedSettingsGetXml({
99562
+ cmdId: 81,
99563
+ channel,
99564
+ ...options?.timeoutMs != null ? { timeoutMs: options.timeoutMs } : {}
99565
+ }));
99566
+ }
99567
+ /**
99568
+ * SetRec (cmd 82) — write the weekly recording schedule.
99569
+ *
99570
+ * Same contract as {@link setRecordCfg}: refuse what is certainly bad before
99571
+ * the wire, then verify by re-reading. A 167-character mask and a `<type>`
99572
+ * the camera does not have were each answered `200` and dropped, and the
99573
+ * drop is **per item** — one document carrying a bad `Normal` mask and a
99574
+ * good `MD` mask applied the `MD` one and discarded the other. So a caller
99575
+ * cannot treat this write as atomic, and the per-field
99576
+ * {@link BaichuanRecordWriteResult} says which rows landed.
99577
+ *
99578
+ * Verified live on E1 Outdoor PoE v3.1.0.5223: `Normal` hour 0 flipped on
99579
+ * and restored, via both a full document and a one-item document (the camera
99580
+ * merges per row and leaves the other rows alone).
99581
+ */
99582
+ async setRecordSchedule(channel, patch, options) {
99583
+ const ch = this.normalizeChannel(channel);
99584
+ const timeoutOpts = options?.timeoutMs != null ? { timeoutMs: options.timeoutMs } : {};
99585
+ const currentXml = await this.sendXml({
99586
+ cmdId: 81,
99587
+ channel: ch,
99588
+ ...timeoutOpts
99589
+ });
99590
+ validateRecordSchedulePatch(patch, parseRecordScheduleRows(currentXml));
99591
+ await this.sendXml({
99592
+ cmdId: 82,
99593
+ channel: ch,
99594
+ payloadXml: ensureXmlHeader(buildRecordScheduleSetXml(currentXml, patch)),
99595
+ ...timeoutOpts
99596
+ });
99597
+ return diffRecordSchedule(patch, await this.sendXml({
99598
+ cmdId: 81,
99599
+ channel: ch,
99600
+ ...timeoutOpts
99601
+ }));
99602
+ }
98422
99603
  async getWifiSignal(channel, options) {
98423
99604
  const signal = parseNumber(getXmlText(await this.sendPcapDerivedSettingsGetXml({
98424
99605
  cmdId: 115,
@@ -98617,6 +99798,22 @@ ${xml}`);
98617
99798
  }));
98618
99799
  }
98619
99800
  /**
99801
+ * cmd 102 `<HddInfoList>`, typed — one record per volume with the size
99802
+ * arithmetic already done.
99803
+ *
99804
+ * Prefer this over {@link getHddInfoList}. The raw reply splits size across
99805
+ * `capacity` (whole GB) and `capacityM` (MB remainder), and newer firmwares
99806
+ * add exact byte counts in `capacityV2` / `remainSizeV2`; `capacityMb` and
99807
+ * `remainMb` fold whichever pair the camera sent, and are **null when it
99808
+ * sent neither** rather than 0.
99809
+ */
99810
+ async getHddInfo(options) {
99811
+ return parseHddInfoListXml(await this.sendXml({
99812
+ cmdId: 102,
99813
+ ...options?.timeoutMs != null ? { timeoutMs: options.timeoutMs } : {}
99814
+ }));
99815
+ }
99816
+ /**
98620
99817
  * cmd 142 `<DayRecords>` — which days of a month have footage, for one
98621
99818
  * channel. The calendar the official app draws.
98622
99819
  *
@@ -150943,6 +152140,14 @@ function buildClipStreamMuxArgs(input) {
150943
152140
  * not push the deadline out. `drain` — the hop's own word that it took what
150944
152141
  * it held — disarms it. Stall fires at most once, and a disposed gate is
150945
152142
  * silent for ever.
152143
+ *
152144
+ * **A gate must be DISPOSED when its hop stops taking bytes for good** — when
152145
+ * the input is ended, and when the run is over. Node emits no `drain` after
152146
+ * `end()` (`afterWrite` guards it with `!state.ending`), so a gate armed at
152147
+ * EOF can never be disarmed: it fires `timeoutMs` later and severs a stream
152148
+ * that was already served. Measured on real bytes: a 42 s clip delivered at
152149
+ * 18× leaves the audio pipe armed at EOF every time, with zero `drain`
152150
+ * events after `end()`. (D604)
150946
152151
  */
150947
152152
  function createDrainGate(deps) {
150948
152153
  let handle = null;
@@ -151147,8 +152352,18 @@ var ClipStreamRun = class {
151147
152352
  accessUnits: 0
151148
152353
  };
151149
152354
  videoType = null;
152355
+ /** The last access unit's timestamp, to catch a transfer that went BACK. */
152356
+ lastUs = null;
152357
+ /** Units of a PREVIOUS transfer, dropped before the mux existed (D604). */
152358
+ residueUnits = 0;
152359
+ residueAudioFrames = 0;
152360
+ /** A backwards timestamp that arrived too late to drop: the duration of
152361
+ * this pass cannot be measured, and is never guessed (D393). */
152362
+ timestampsBroken = false;
151150
152363
  begun = false;
151151
152364
  ended = false;
152365
+ /** The run is over. Every late callback is silent from here (D604). */
152366
+ done = false;
151152
152367
  bytesOut = 0;
151153
152368
  cut = null;
151154
152369
  stdinGate;
@@ -151205,17 +152420,28 @@ var ClipStreamRun = class {
151205
152420
  };
151206
152421
  }
151207
152422
  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);
152423
+ try {
152424
+ await api.fileInfoListReplayBinaryDownload({
152425
+ channel: device.channel,
152426
+ ...device.uid !== void 0 ? { uid: device.uid } : {},
152427
+ fileName,
152428
+ idleTimeoutMs: 300,
152429
+ onChunk: (chunk) => this.onChunk(decoder, chunk)
152430
+ });
152431
+ } catch (err) {
152432
+ return this.onTransferFailed(err);
152433
+ }
152434
+ return await this.onTransferEnded();
152435
+ } finally {
152436
+ this.done = true;
152437
+ this.disposeGates();
151217
152438
  }
151218
- return this.onTransferEnded();
152439
+ }
152440
+ /** Every hop's drain gate, silenced. Idempotent. */
152441
+ disposeGates() {
152442
+ this.stdinGate.dispose();
152443
+ this.audioStdinGate.dispose();
152444
+ this.peerGate.dispose();
151219
152445
  }
151220
152446
  /** The camera's bytes, as they arrive. A cut run THROWS here: the only stop. */
151221
152447
  onChunk(decoder, chunk) {
@@ -151229,14 +152455,81 @@ var ClipStreamRun = class {
151229
152455
  }
151230
152456
  onAccessUnit(unit) {
151231
152457
  if (this.cut !== null) return;
152458
+ const previousUs = this.lastUs;
152459
+ this.lastUs = unit.microseconds;
152460
+ if (previousUs !== null && unit.microseconds < previousUs) this.onTimestampsWentBack(previousUs, unit.microseconds);
151232
152461
  this.accessUnits += 1;
151233
152462
  this.timestampsUs.push(unit.microseconds);
151234
- if (this.mux === null) {
151235
- this.probe = [...this.probe, unit];
151236
- if (this.probe.length >= 16) this.startMux();
152463
+ if (this.mux !== null) {
152464
+ this.feed(unit.annexB);
151237
152465
  return;
151238
152466
  }
151239
- this.feed(unit.annexB);
152467
+ this.probe = [...this.probe, unit];
152468
+ if (this.probe.length < 16) return;
152469
+ if (probeClipStreamRate(this.timestampsUs).fpsSource === "timestamps" || this.probe.length >= 128) this.startMux();
152470
+ }
152471
+ /**
152472
+ * A timestamp that went BACKWARD: this is a second transfer's first frame.
152473
+ *
152474
+ * The library's replay listener cannot tell one transfer from the next —
152475
+ * every cmd 5 replay carries `msgNum: 0` and, on a hub child, the same
152476
+ * header channel id — and a CUT transfer is never stopped at the camera
152477
+ * (nothing on the wire aborts one; the producer's only lever is to throw
152478
+ * from the next chunk). So the frames still in flight for the previous,
152479
+ * aborted stream are handed to THIS one, ahead of its own first frame.
152480
+ *
152481
+ * Measured on 3628, 2026-09-22 16:41: a stream cut at 55 access units was
152482
+ * followed 1 ms later by another, which decoded 640 units instead of 635
152483
+ * and 666 audio frames instead of 661 — the same five extra of each — and
152484
+ * spanned 38.52 s instead of 42.21 s, exactly the 3.69 s the cut stream had
152485
+ * already delivered. Its rate probe therefore measured a NEGATIVE span and
152486
+ * refused to name a rate, which is the only way `estimateVideoTiming` can
152487
+ * answer anything but `timestamps` here.
152488
+ *
152489
+ * Before the mux exists the answer is to DROP what was held and probe
152490
+ * again: nothing has reached the peer, so the residue can simply not be
152491
+ * part of this stream. After it exists there is nothing to drop — the bytes
152492
+ * are out — so it is named, counted, and this pass's duration becomes
152493
+ * unmeasurable rather than a number measured across two transfers.
152494
+ */
152495
+ onTimestampsWentBack(previousUs, currentUs) {
152496
+ const { row, twin } = this.input;
152497
+ const meta = {
152498
+ clipKey: row.clipKey,
152499
+ twin,
152500
+ previousUs,
152501
+ currentUs
152502
+ };
152503
+ if (this.mux !== null) {
152504
+ if (this.timestampsBroken) return;
152505
+ this.timestampsBroken = true;
152506
+ this.deps.logger.warn("videoclips: a clip stream’s timestamps went backwards mid-mux", {
152507
+ tags: this.tags,
152508
+ meta: {
152509
+ ...meta,
152510
+ branch: "stream-timestamp-jump",
152511
+ accessUnits: this.accessUnits
152512
+ }
152513
+ });
152514
+ return;
152515
+ }
152516
+ this.residueUnits += this.probe.length;
152517
+ this.residueAudioFrames += this.audioProbe.length;
152518
+ this.audioFramesDropped += this.audioProbe.length;
152519
+ this.probe = [];
152520
+ this.audioProbe = [];
152521
+ this.audioProbeBytes = 0;
152522
+ this.timestampsUs.length = 0;
152523
+ this.accessUnits = 0;
152524
+ this.deps.logger.warn("videoclips: a clip stream dropped the previous transfer’s frames", {
152525
+ tags: this.tags,
152526
+ meta: {
152527
+ ...meta,
152528
+ branch: "stream-transfer-residue",
152529
+ residueAccessUnits: this.residueUnits,
152530
+ residueAudioFrames: this.residueAudioFrames
152531
+ }
152532
+ });
151240
152533
  }
151241
152534
  /**
151242
152535
  * One audio frame from the decoder.
@@ -151301,15 +152594,20 @@ var ClipStreamRun = class {
151301
152594
  this.rate = probeClipStreamRate(this.timestampsUs);
151302
152595
  this.decideAudio();
151303
152596
  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
- });
152597
+ if (this.rate.fps === null || this.rate.fpsSource !== "timestamps") {
152598
+ this.deps.logger.warn("videoclips: no measured frame rate for this clip stream", {
152599
+ tags: this.tags,
152600
+ meta: {
152601
+ clipKey: row.clipKey,
152602
+ twin,
152603
+ branch: "fps-unmeasured",
152604
+ probeAccessUnits: this.rate.accessUnits,
152605
+ residueAccessUnits: this.residueUnits
152606
+ }
152607
+ });
152608
+ this.sever("rate-unmeasured", `no frame rate could be measured over ${String(this.rate.accessUnits)} access units`);
152609
+ return;
152610
+ }
151313
152611
  let mux;
151314
152612
  try {
151315
152613
  mux = this.deps.spawnMux(buildClipStreamMuxArgs({
@@ -151414,14 +152712,12 @@ var ClipStreamRun = class {
151414
152712
  }
151415
152713
  /** Sever every hop, once, and say why. */
151416
152714
  sever(reason, detail) {
151417
- if (this.cut !== null) return;
152715
+ if (this.cut !== null || this.done) return;
151418
152716
  this.cut = {
151419
152717
  reason,
151420
152718
  detail
151421
152719
  };
151422
- this.stdinGate.dispose();
151423
- this.audioStdinGate.dispose();
151424
- this.peerGate.dispose();
152720
+ this.disposeGates();
151425
152721
  this.mux?.kill("SIGKILL");
151426
152722
  this.deps.logger.warn("videoclips: a clip stream was cut", {
151427
152723
  tags: this.tags,
@@ -151482,6 +152778,8 @@ var ClipStreamRun = class {
151482
152778
  });
151483
152779
  mux.stdin?.end();
151484
152780
  this.audioIn?.end();
152781
+ this.stdinGate.dispose();
152782
+ this.audioStdinGate.dispose();
151485
152783
  const flush = setTimeout(() => this.sever("mux-hung", `ffmpeg did not exit ${this.deps.flushTimeoutMs} ms after its input ended`), this.deps.flushTimeoutMs);
151486
152784
  const code = await this.muxClosed;
151487
152785
  clearTimeout(flush);
@@ -151501,7 +152799,7 @@ var ClipStreamRun = class {
151501
152799
  served() {
151502
152800
  const { row, twin } = this.input;
151503
152801
  const timing = estimateVideoTiming({ timestampsUs: this.timestampsUs });
151504
- const durationSeconds = timing.fpsSource === "timestamps" ? timing.durationSeconds : null;
152802
+ const durationSeconds = this.timestampsBroken || timing.fpsSource !== "timestamps" ? null : timing.durationSeconds;
151505
152803
  const verdict = judgeClipDuration({
151506
152804
  expectedMs: row.endMs - row.startMs,
151507
152805
  decodedSeconds: durationSeconds,
@@ -151519,7 +152817,9 @@ var ClipStreamRun = class {
151519
152817
  expectedMs: row.endMs - row.startMs,
151520
152818
  fps: this.rate.fps,
151521
152819
  fpsSource: this.rate.fpsSource,
151522
- videoType: this.videoType
152820
+ videoType: this.videoType,
152821
+ residueAccessUnits: this.residueUnits,
152822
+ timestampsBroken: this.timestampsBroken
151523
152823
  };
151524
152824
  if (verdict.kind === "short") this.deps.logger.warn("videoclips: a clip stream came up short against its row", {
151525
152825
  tags: this.tags,
@@ -152501,11 +153801,11 @@ function enumerateWallClockDays(sinceMs, untilMs, timeZone) {
152501
153801
  * nowhere). Searching for the KEY rather than the path survives both, and
152502
153802
  * survives the next firmware that nests it differently.
152503
153803
  */
152504
- function findRecordWithKey$1(node, key) {
153804
+ function findRecordWithKey$2(node, key) {
152505
153805
  if (node === null || typeof node !== "object") return null;
152506
153806
  if (Array.isArray(node)) {
152507
153807
  for (const child of node) {
152508
- const found = findRecordWithKey$1(child, key);
153808
+ const found = findRecordWithKey$2(child, key);
152509
153809
  if (found !== null) return found;
152510
153810
  }
152511
153811
  return null;
@@ -152513,7 +153813,7 @@ function findRecordWithKey$1(node, key) {
152513
153813
  const record = { ...node };
152514
153814
  if (key in record) return record;
152515
153815
  for (const value of Object.values(record)) {
152516
- const found = findRecordWithKey$1(value, key);
153816
+ const found = findRecordWithKey$2(value, key);
152517
153817
  if (found !== null) return found;
152518
153818
  }
152519
153819
  return null;
@@ -152533,7 +153833,7 @@ async function readStorageState(api) {
152533
153833
  reason: err instanceof Error ? err.message : String(err)
152534
153834
  };
152535
153835
  }
152536
- const record = findRecordWithKey$1(raw, "mount");
153836
+ const record = findRecordWithKey$2(raw, "mount");
152537
153837
  if (record === null) return { state: "unknown" };
152538
153838
  const mount = asNumber(record["mount"]);
152539
153839
  if (mount === null) return { state: "unknown" };
@@ -155122,6 +156422,313 @@ var reolinkDebugActions = defineCustomActions({ debugRawRead: customAction(RawRe
155122
156422
  auth: "admin"
155123
156423
  }) });
155124
156424
  //#endregion
156425
+ //#region src/recording-onboard-mapping.ts
156426
+ /**
156427
+ * Reolink's schedule `type` → the cap's trigger vocabulary.
156428
+ *
156429
+ * `legacy` is deliberately absent: it appears on 618 and names nothing
156430
+ * an operator would recognise, so a window carrying it is COUNTED as
156431
+ * unread rather than mapped onto a guess.
156432
+ */
156433
+ var TRIGGER_BY_TYPE = {
156434
+ MD: "motion",
156435
+ Normal: "continuous",
156436
+ people: "person",
156437
+ vehicle: "vehicle",
156438
+ dog_cat: "animal",
156439
+ crossline: "lineCrossing",
156440
+ intrude: "intrusion",
156441
+ loitering: "loitering"
156442
+ };
156443
+ /** Triggers a Reolink camera's schedule can name. */
156444
+ var REOLINK_TRIGGERS = [
156445
+ "continuous",
156446
+ "motion",
156447
+ "person",
156448
+ "vehicle",
156449
+ "animal",
156450
+ "lineCrossing",
156451
+ "intrusion",
156452
+ "loitering"
156453
+ ];
156454
+ /**
156455
+ * Find the first record anywhere in the reply that carries `key`.
156456
+ *
156457
+ * Structural, not path-based, because the library's declared shape for
156458
+ * this reply is wrong and a path written against it finds nothing.
156459
+ */
156460
+ function findRecordWithKey$1(node, key) {
156461
+ if (typeof node !== "object" || node === null) return null;
156462
+ if (Array.isArray(node)) {
156463
+ for (const item of node) {
156464
+ const found = findRecordWithKey$1(item, key);
156465
+ if (found) return found;
156466
+ }
156467
+ return null;
156468
+ }
156469
+ const record = { ...node };
156470
+ if (key in record) return record;
156471
+ for (const value of Object.values(record)) {
156472
+ const found = findRecordWithKey$1(value, key);
156473
+ if (found) return found;
156474
+ }
156475
+ return null;
156476
+ }
156477
+ function finiteNumber(value) {
156478
+ if (typeof value !== "number") return null;
156479
+ return Number.isFinite(value) ? value : null;
156480
+ }
156481
+ /**
156482
+ * Total size in MB from Reolink's split pair.
156483
+ *
156484
+ * Null when the camera stated NEITHER half — a size we did not read is
156485
+ * not a size of zero (D393). A stated 0 GB with a stated MB remainder is
156486
+ * a real, small number, and survives as one.
156487
+ */
156488
+ function megabytes(whole, remainder) {
156489
+ const gb = finiteNumber(whole);
156490
+ const mb = finiteNumber(remainder);
156491
+ if (gb === null && mb === null) return null;
156492
+ return (gb ?? 0) * 1024 + (mb ?? 0);
156493
+ }
156494
+ /**
156495
+ * Read `getHddInfoList`.
156496
+ *
156497
+ * `mount === 0` is a camera with no card — the same signal
156498
+ * `clips/clip-catalog.ts` keys off. `format === 0` is a card present but
156499
+ * not usable, which is `absent` with a reason that says so rather than
156500
+ * `present` with a card nothing can be written to.
156501
+ */
156502
+ function parseOnboardStorage(reply) {
156503
+ const record = findRecordWithKey$1(reply, "mount");
156504
+ if (!record) return {
156505
+ kind: "unknown",
156506
+ reason: "The camera answered, but with nothing this reader recognised as storage info."
156507
+ };
156508
+ if (finiteNumber(record.mount) === 0) return {
156509
+ kind: "absent",
156510
+ reason: "The camera reports no mounted storage (HddInfo mount 0)."
156511
+ };
156512
+ const formatted = finiteNumber(record.format);
156513
+ const id = finiteNumber(record.number);
156514
+ const volume = {
156515
+ id: id === null ? "0" : String(id),
156516
+ status: formatted === 0 ? "unformatted" : "ok",
156517
+ capacityMb: megabytes(record.capacity, record.capacityM),
156518
+ freeMb: megabytes(record.remainSize, record.remainSizeM)
156519
+ };
156520
+ if (formatted === 0) return {
156521
+ kind: "absent",
156522
+ reason: "The camera reports a card that is not formatted, so it cannot record to it."
156523
+ };
156524
+ return {
156525
+ kind: "present",
156526
+ volumes: [volume]
156527
+ };
156528
+ }
156529
+ /**
156530
+ * Expand every `typeScheduleList` entry into windows.
156531
+ *
156532
+ * Entries sharing a trigger are merged slot-wise before expansion, so
156533
+ * three `crossline` rules produce one set of line-crossing windows
156534
+ * rather than three overlapping copies.
156535
+ *
156536
+ * An entry whose `type` is unknown, or whose table is not 168 characters,
156537
+ * is counted as unread. A table of the wrong length is not a schedule of
156538
+ * fewer days.
156539
+ */
156540
+ function parseSchedule(schedule) {
156541
+ const entries = schedule?.typeScheduleList ?? [];
156542
+ const merged = /* @__PURE__ */ new Map();
156543
+ let unreadable = 0;
156544
+ for (const entry of entries) {
156545
+ const trigger = TRIGGER_BY_TYPE[entry.type];
156546
+ if (trigger === void 0 || entry.valueTable.length !== 168) {
156547
+ if (entry.valueTable.includes("1")) unreadable += 1;
156548
+ continue;
156549
+ }
156550
+ const existing = merged.get(trigger);
156551
+ if (!existing) {
156552
+ merged.set(trigger, [...entry.valueTable]);
156553
+ continue;
156554
+ }
156555
+ for (let i = 0; i < existing.length; i += 1) if (entry.valueTable[i] === "1") existing[i] = "1";
156556
+ }
156557
+ const windows = [];
156558
+ for (const [trigger, slots] of merged) windows.push(...weeklyMaskToWindows(slots.join(""), trigger));
156559
+ return {
156560
+ windows,
156561
+ unreadable
156562
+ };
156563
+ }
156564
+ /**
156565
+ * Fold the three reads into the cap's status.
156566
+ *
156567
+ * `tracks` is empty and `primaryTrackId` null on purpose: a Reolink
156568
+ * channel exposes no track list, and inventing one synthetic row would
156569
+ * put a fact in the UI that the camera never stated. The form renders
156570
+ * "the camera reports no recording track", which is true.
156571
+ */
156572
+ function buildOnboardStatus(input) {
156573
+ const { windows, unreadable } = parseSchedule(input.schedule);
156574
+ const masterOn = input.schedule?.enable ?? null;
156575
+ const scheduled = masterOn === true && windows.length > 0;
156576
+ const cycle = input.cfg?.cycle;
156577
+ return {
156578
+ storage: input.storage,
156579
+ tracks: [],
156580
+ primaryTrackId: null,
156581
+ enabled: masterOn,
156582
+ overwriteWhenFull: cycle === void 0 ? null : cycle !== 0,
156583
+ preRecordSec: input.cfg?.preRecordTime ?? null,
156584
+ postRecordSec: input.cfg?.recordDelayTime ?? null,
156585
+ segmentMinutes: input.cfg?.packageTime ?? null,
156586
+ windows: [...windows],
156587
+ unreadableWindows: unreadable,
156588
+ recordingToNowhere: scheduled && input.storage.kind === "absent",
156589
+ lastFetchedAt: input.now
156590
+ };
156591
+ }
156592
+ /** Why a Reolink camera cannot be written until CamStack has read it. */
156593
+ var REOLINK_NOT_READ_REASON = "CamStack has not read this camera yet";
156594
+ /**
156595
+ * Why the schedule is unwritable when one trigger occupies several rows.
156596
+ *
156597
+ * An E1 Outdoor Pro lists `crossline`, `intrude` and `loitering` three times
156598
+ * each — one row per configured rule. The status READ merges them, because
156599
+ * the cap's question is "does this camera record on line-crossing". A write
156600
+ * cannot un-merge them: addressed by type alone it rewrites all three rules
156601
+ * with one mask (measured — `replaceItemValueTable` matches every row of a
156602
+ * type when no index is given), and the cap's patch carries no room to say
156603
+ * which rule an operator meant. So the schedule is REPORTED unwritable on
156604
+ * such a camera rather than writable and destructive.
156605
+ */
156606
+ 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";
156607
+ /** How many rows each trigger occupies, from the camera's own list. */
156608
+ function rowsPerTrigger(schedule) {
156609
+ const counts = /* @__PURE__ */ new Map();
156610
+ for (const entry of schedule?.typeScheduleList ?? []) {
156611
+ const trigger = TRIGGER_BY_TYPE[entry.type];
156612
+ if (trigger === void 0) continue;
156613
+ counts.set(trigger, (counts.get(trigger) ?? 0) + 1);
156614
+ }
156615
+ return counts;
156616
+ }
156617
+ /**
156618
+ * What THIS Reolink accepts.
156619
+ *
156620
+ * Writability is decided per field from the camera's OWN statement, never
156621
+ * from a vendor default:
156622
+ *
156623
+ * - `overwriteWhenFull` needs `<cyclelist>` to offer both 0 and 1; the
156624
+ * cap's field is a boolean and a camera offering one value cannot be
156625
+ * set both ways. `<cyclelist>` IS enforced by the firmware (writing 7
156626
+ * was answered 200 and dropped), so it is a real refusal.
156627
+ * - `preRecordSec` is a two-member SET, not a span. The wire field is
156628
+ * seconds and the firmware honours exactly two states: 0 is off, and
156629
+ * 5, 15 and 30 all read back as 10 — identically on two firmwares. A
156630
+ * slider here would move and change nothing.
156631
+ * - `postRecordSec` carries NO allowed set although the camera states
156632
+ * `<timeList>`: that list is advisory, and a Home Hub offering
156633
+ * `8,15,30` accepted and applied 20. Refusing against it would block a
156634
+ * value the firmware honours, so the write goes through unconstrained
156635
+ * and the read-back diff is what reports a value the camera dropped.
156636
+ * - `segmentMinutes` likewise carries no bound, because no camera states
156637
+ * one and an invented maximum refuses values the firmware may take.
156638
+ */
156639
+ function buildOnboardOptions(input) {
156640
+ const reached = input.status !== null;
156641
+ const notRead = {
156642
+ readable: false,
156643
+ writable: false,
156644
+ reason: REOLINK_NOT_READ_REASON
156645
+ };
156646
+ const writable = {
156647
+ readable: true,
156648
+ writable: true
156649
+ };
156650
+ const refuse = (reason) => ({
156651
+ readable: true,
156652
+ writable: false,
156653
+ reason
156654
+ });
156655
+ if (!reached || input.limits === null) return {
156656
+ enabled: notRead,
156657
+ overwriteWhenFull: notRead,
156658
+ preRecordSec: notRead,
156659
+ postRecordSec: notRead,
156660
+ segmentMinutes: notRead,
156661
+ schedule: {
156662
+ support: notRead,
156663
+ granularityMinutes: 60,
156664
+ triggers: [...REOLINK_TRIGGERS],
156665
+ supportsOverlappingTriggers: true
156666
+ }
156667
+ };
156668
+ const limits = input.limits;
156669
+ const cycleChoices = limits.cycleList;
156670
+ 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`);
156671
+ const preRecord = limits.preRecordSupported ? writable : refuse("this camera reports no pre-record setting, so writing one would do nothing");
156672
+ const merged = [...rowsPerTrigger(input.schedule).entries()].filter(([, count]) => count > 1);
156673
+ const unreadable = input.status?.unreadableWindows ?? 0;
156674
+ 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;
156675
+ return {
156676
+ enabled: writable,
156677
+ overwriteWhenFull: overwrite,
156678
+ preRecordSec: preRecord,
156679
+ ...limits.preRecordSupported ? { preRecordSecAllowed: { values: [0, limits.preRecordSeconds ?? 10] } } : {},
156680
+ postRecordSec: writable,
156681
+ segmentMinutes: writable,
156682
+ schedule: {
156683
+ support: scheduleSupport,
156684
+ granularityMinutes: 60,
156685
+ triggers: [...REOLINK_TRIGGERS],
156686
+ supportsOverlappingTriggers: true
156687
+ }
156688
+ };
156689
+ }
156690
+ /** The cap's trigger vocabulary → Reolink's schedule `type`. */
156691
+ var TYPE_BY_TRIGGER = Object.fromEntries(Object.entries(TRIGGER_BY_TYPE).filter((pair) => pair[1] !== void 0).map(([type, trigger]) => [trigger, type]));
156692
+ /**
156693
+ * Turn a cap patch into the two Baichuan writes.
156694
+ *
156695
+ * `windows` is the COMPLETE new set, so every trigger the camera lists gets
156696
+ * a row — including one the new set does not mention, which must be written
156697
+ * as an all-zero mask or the camera keeps recording on a trigger the
156698
+ * operator just removed. A trigger the camera does NOT list is skipped: a
156699
+ * row the camera never had is answered 200 and discarded, so sending it
156700
+ * would report success for nothing.
156701
+ *
156702
+ * `preRecordSec` collapses to the boolean the firmware really has: any
156703
+ * non-zero ask is "on", because 5, 15 and 30 all store as 10.
156704
+ */
156705
+ function buildOnboardWrites(patch, schedule) {
156706
+ const cfg = {};
156707
+ if (patch.overwriteWhenFull !== void 0) cfg.cycle = patch.overwriteWhenFull ? 1 : 0;
156708
+ if (patch.postRecordSec !== void 0) cfg.recordDelayTime = patch.postRecordSec;
156709
+ if (patch.preRecordSec !== void 0) cfg.preRecordEnabled = patch.preRecordSec !== 0;
156710
+ if (patch.segmentMinutes !== void 0) cfg.packageTime = patch.segmentMinutes;
156711
+ const scheduleOut = {};
156712
+ if (patch.enabled !== void 0) scheduleOut.enable = patch.enabled;
156713
+ if (patch.windows !== void 0) {
156714
+ const present = new Set((schedule?.typeScheduleList ?? []).map((e) => e.type));
156715
+ const entries = [];
156716
+ for (const trigger of REOLINK_TRIGGERS) {
156717
+ const type = TYPE_BY_TRIGGER[trigger];
156718
+ if (type === void 0 || !present.has(type)) continue;
156719
+ entries.push({
156720
+ type,
156721
+ valueTable: windowsToWeeklyMask(patch.windows, trigger)
156722
+ });
156723
+ }
156724
+ scheduleOut.entries = entries;
156725
+ }
156726
+ return {
156727
+ cfg: Object.keys(cfg).length > 0 ? cfg : null,
156728
+ schedule: Object.keys(scheduleOut).length > 0 ? scheduleOut : null
156729
+ };
156730
+ }
156731
+ //#endregion
155125
156732
  //#region src/battery-charging.ts
155126
156733
  /**
155127
156734
  * Da `adapterStatus` + `chargeStatus` alla sorgente di alimentazione.
@@ -155618,6 +157225,82 @@ function decideSnapshotCameraAccess(input) {
155618
157225
  };
155619
157226
  }
155620
157227
  //#endregion
157228
+ //#region src/sleep-state-decision.ts
157229
+ /**
157230
+ * Hysteresis is ASYMMETRIC, because the two directions have very different
157231
+ * costs when we get them wrong.
157232
+ *
157233
+ * `→ awake` (`WAKE_HYSTERESIS_MS`): expensive if wrong. Believing an asleep
157234
+ * camera is awake un-gates every background probe and makes `wakeForStream`
157235
+ * early-out without actually waking. Keep the window >= the library's UDP
157236
+ * inference cycle (~12 s on battery cams with idle-disconnect) and >= its
157237
+ * `getIdleDisconnectTimeoutMs` (30 s default).
157238
+ *
157239
+ * `→ sleeping` (`SLEEP_HYSTERESIS_MS`): cheap if wrong. Believing an awake
157240
+ * camera is asleep only means we skip background probes and serve cached
157241
+ * values; the demand paths still drive a real wake. The old symmetric 30 s
157242
+ * window was LONGER than the camera's own awake window (~14 s), so a natural
157243
+ * wake→sleep cycle had its `sleeping` transition dropped and the slice stayed
157244
+ * stuck on "awake" while the camera slept.
157245
+ */
157246
+ var WAKE_HYSTERESIS_MS = 3e4;
157247
+ /** See {@link WAKE_HYSTERESIS_MS}. */
157248
+ var SLEEP_HYSTERESIS_MS = 5e3;
157249
+ /**
157250
+ * How long a freshly committed FIRST-HAND state is protected from a lagging
157251
+ * summary's awake→sleeping flip. The summary lags real pushes by over a
157252
+ * minute (measured >75 s on 2026-08-16); 3 min covers two full
157253
+ * discovery-refresh cycles of lag without letting a genuinely stuck "awake"
157254
+ * slice stand forever — the reconcile still wins after the grace, which is
157255
+ * exactly its recovery role.
157256
+ */
157257
+ var HUB_SUMMARY_SLEEP_GRACE_MS = 18e4;
157258
+ /** A source whose verdict comes from the parent NVR's channel summary. */
157259
+ function isSummarySourced(source) {
157260
+ return source === "hub-summary" || source === "hub-reconcile";
157261
+ }
157262
+ /**
157263
+ * Decide one commit. Pure: the caller owns the slice, the clock and the log.
157264
+ *
157265
+ * Order of the gates, and why:
157266
+ * 1. No-op first — re-asserting the current value is never a transition.
157267
+ * 2. A summary-sourced verdict is firmware state, not socket inference, so
157268
+ * the anti-flap window does not apply to it at all.
157269
+ * 3. A `confirmed-wake` in the `→ awake` direction is first-hand evidence
157270
+ * this process produced: gating it with the anti-flap window is what
157271
+ * stranded 3825's mirror on "asleep" while `wakeForStream` answered
157272
+ * `awoke: true`.
157273
+ * 4. Everything else is subject to the asymmetric hysteresis.
157274
+ * 5. A summary flip toward `sleeping` is refused while a first-hand
157275
+ * observation inside the grace says otherwise.
157276
+ */
157277
+ function decideSleepCommit(input) {
157278
+ const { current, next, source, lastFirstHandCommitAt, nowMs } = input;
157279
+ if (current === next) return { kind: "unchanged" };
157280
+ const summarySourced = isSummarySourced(source);
157281
+ const firstHandWake = source === "confirmed-wake" && next === false;
157282
+ const sinceLastChangeMs = nowMs - lastFirstHandCommitAt;
157283
+ if (!summarySourced && !firstHandWake) {
157284
+ const windowMs = next ? SLEEP_HYSTERESIS_MS : WAKE_HYSTERESIS_MS;
157285
+ if (lastFirstHandCommitAt > 0 && sinceLastChangeMs < windowMs) return {
157286
+ kind: "refused",
157287
+ reason: "hysteresis",
157288
+ windowMs,
157289
+ sinceLastChangeMs
157290
+ };
157291
+ }
157292
+ if (summarySourced && next === true && lastFirstHandCommitAt > 0 && sinceLastChangeMs < 18e4) return {
157293
+ kind: "refused",
157294
+ reason: "summary-grace",
157295
+ windowMs: HUB_SUMMARY_SLEEP_GRACE_MS,
157296
+ sinceLastChangeMs
157297
+ };
157298
+ return {
157299
+ kind: "commit",
157300
+ stampFirstHand: source !== "hub-reconcile"
157301
+ };
157302
+ }
157303
+ //#endregion
155621
157304
  //#region src/raw-state.ts
155622
157305
  /**
155623
157306
  * Source tag for every raw-state blob this provider emits.
@@ -158864,53 +160547,20 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
158864
160547
  return this.state.battery.sleeping ?? false;
158865
160548
  }
158866
160549
  /**
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.
160550
+ * Wall-clock ms when a FIRST-HAND observation last moved `this.sleeping`.
160551
+ *
160552
+ * It drives two windows — the anti-flap hysteresis that filters the lib's
160553
+ * UDP sleep inference, and the grace that stops the parent's LAGGING
160554
+ * summary from clobbering a fresher verdict. Both want the same thing:
160555
+ * "when did something that was actually looking at the camera last speak".
160556
+ *
160557
+ * The parent's periodic summary sweep (`hub-reconcile`) therefore never
160558
+ * stamps this. It is firmware truth, but it is minutes old, and stamping
160559
+ * it armed the 30 s wake window against the camera's own wake push — see
160560
+ * `sleep-state-decision.ts` for the measurement (D603).
158875
160561
  */
158876
160562
  sleepStateChangedAt = 0;
158877
160563
  /**
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
160564
  * Wall-clock ms of the last PROACTIVE wake — one we issued on our own
158915
160565
  * initiative (background snapshot refresh), not because the operator
158916
160566
  * or a stream consumer asked for the camera. Drives
@@ -159825,6 +161475,32 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
159825
161475
  await (await this.ensureApi()).reboot(this.getChannel());
159826
161476
  return { success: true };
159827
161477
  } });
161478
+ this.ctx.registerNativeCap(deviceAdminLinkCapability, { getAdminLink: async ({ deviceId }) => {
161479
+ if (deviceId !== this.id) throw new Error(`ReolinkCamera: deviceId mismatch, expected ${this.id}, got ${deviceId}`);
161480
+ const host = this.config.get("host") ?? "";
161481
+ const built = buildDeviceAdminUrl({
161482
+ host,
161483
+ port: null,
161484
+ scheme: "http",
161485
+ path: "/"
161486
+ });
161487
+ if (!built.ok) {
161488
+ this.ctx.logger.debug("reolink admin link unavailable", {
161489
+ tags: { deviceId: this.id },
161490
+ meta: {
161491
+ reason: built.reason,
161492
+ transport: this.config.get("transport") ?? "tcp"
161493
+ }
161494
+ });
161495
+ return null;
161496
+ }
161497
+ return {
161498
+ url: built.url,
161499
+ label: "Web UI",
161500
+ target: "device",
161501
+ host
161502
+ };
161503
+ } });
159828
161504
  this.registerNetworkLink();
159829
161505
  this.ctx.registerNativeCap(motionCapability, { isDetected: async ({ deviceId }) => {
159830
161506
  if (deviceId !== this.id) throw new Error(`ReolinkCamera: deviceId mismatch, expected ${this.id}, got ${deviceId}`);
@@ -159871,6 +161547,7 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
159871
161547
  this.registerPrivacyMaskCap();
159872
161548
  this.registerDayNightCap();
159873
161549
  this.registerImageSettingsCap();
161550
+ this.registerRecordingOnboardCap();
159874
161551
  this.registerStreamCatalogProvider();
159875
161552
  this.registerNativeObjectDetectionCap();
159876
161553
  const cache = this.config.get("deviceCache");
@@ -159914,6 +161591,8 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
159914
161591
  dayNightRefreshInFlight = null;
159915
161592
  /** Single-flight guard for the `image-settings` cap refresh. */
159916
161593
  imageSettingsRefreshInFlight = null;
161594
+ /** Single-flight guard for the `recording-onboard` cap refresh. */
161595
+ recordingOnboardRefreshInFlight = null;
159917
161596
  /**
159918
161597
  * Single-flight guard for snapshot fetches (Slice 10). When two
159919
161598
  * consumers hit `getSnapshot` concurrently we issue ONE Baichuan
@@ -160115,35 +161794,6 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
160115
161794
  }
160116
161795
  }
160117
161796
  /**
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
161797
  * THE single writer of the `battery.sleeping` slice.
160148
161798
  *
160149
161799
  * Before this existed the state had three independent writers with
@@ -160154,38 +161804,38 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
160154
161804
  * ancient timestamp). Funnelling every writer through here keeps one
160155
161805
  * rule set and one timestamp.
160156
161806
  *
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).
161807
+ * The rule itself lives in `sleep-state-decision.ts`, pure, so it can be
161808
+ * driven by a test instead of asserted from this file's source text. Read
161809
+ * that module for why `hub-reconcile` — the parent's periodic summary
161810
+ * sweep — stamps nothing: it lags real state by more than a minute, and
161811
+ * arming the anti-flap window from it threw away the camera's real wake
161812
+ * for up to 2 min 26 s on the live hub (D603).
161813
+ *
160168
161814
  * @returns `true` when the slice actually changed.
160169
161815
  */
160170
161816
  commitSleepState(next, source) {
160171
161817
  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();
161818
+ const decision = decideSleepCommit({
161819
+ current: this.sleeping,
161820
+ next,
161821
+ source,
161822
+ lastFirstHandCommitAt: this.sleepStateChangedAt,
161823
+ nowMs: Date.now()
161824
+ });
161825
+ if (decision.kind === "unchanged") return false;
161826
+ if (decision.kind === "refused") {
161827
+ 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", {
161828
+ tags: { deviceId: this.id },
161829
+ meta: {
161830
+ next,
161831
+ source,
161832
+ windowMs: decision.windowMs,
161833
+ sinceLastChangeMs: decision.sinceLastChangeMs
161834
+ }
161835
+ });
161836
+ return false;
160188
161837
  }
161838
+ if (decision.stampFirstHand) this.sleepStateChangedAt = Date.now();
160189
161839
  this.state.battery.sleeping = next;
160190
161840
  this.ctx.logger.info("battery sleep state committed", {
160191
161841
  tags: { deviceId: this.id },
@@ -160212,7 +161862,7 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
160212
161862
  */
160213
161863
  applyHubSleepState(sleeping) {
160214
161864
  if (!this.isBattery) return;
160215
- if (this.commitSleepState(sleeping, "hub-summary") && !sleeping) this.onWakeTransition("hub-summary").catch(() => {});
161865
+ if (this.commitSleepState(sleeping, "hub-reconcile") && !sleeping) this.onWakeTransition("hub-reconcile").catch(() => {});
160216
161866
  }
160217
161867
  /**
160218
161868
  * Rate-limit for wakes we issue on our OWN initiative. Returns
@@ -161909,6 +163559,173 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
161909
163559
  * denormalized against the camera's OWN probed threshold range
161910
163560
  * (never a hardcoded 0..255 guess).
161911
163561
  */
163562
+ /**
163563
+ * Register the vendor-neutral `recording-onboard` cap.
163564
+ *
163565
+ * READ-ONLY on Reolink, and it says so per field. `@apocaliss92/
163566
+ * nodelink-js` exposes a reader for the record config and the record
163567
+ * schedule and no writer; the Baichuan SET command ids exist but
163568
+ * nothing in this repo has ever sent them, and the only route is
163569
+ * hand-built XML through the generic passthrough. So `getOptions`
163570
+ * reports every field `writable: false` with
163571
+ * {@link REOLINK_NO_WRITE_REASON}, the UI renders each control
163572
+ * disabled WITH that sentence — disabled must not look like broken —
163573
+ * and `setSettings` refuses by name instead of accepting a write and
163574
+ * dropping it.
163575
+ *
163576
+ * The cap's FIELD SET is identical to Hikvision's. Only the answers
163577
+ * differ.
163578
+ */
163579
+ registerRecordingOnboardCap() {
163580
+ const channel = this.getChannel();
163581
+ const CAP_NAME = "recording-onboard";
163582
+ const emptyStatus = () => ({
163583
+ storage: {
163584
+ kind: "unknown",
163585
+ reason: "CamStack has not read this camera yet."
163586
+ },
163587
+ tracks: [],
163588
+ primaryTrackId: null,
163589
+ enabled: null,
163590
+ overwriteWhenFull: null,
163591
+ preRecordSec: null,
163592
+ postRecordSec: null,
163593
+ segmentMinutes: null,
163594
+ windows: [],
163595
+ unreadableWindows: 0,
163596
+ recordingToNowhere: false,
163597
+ lastFetchedAt: 0
163598
+ });
163599
+ /** Last `getRecordCfg` reply. */
163600
+ let lastCfg = null;
163601
+ /** Last `getRecordCfgLimits` — what the camera says it accepts. */
163602
+ let lastLimits = null;
163603
+ /** Last schedule read — which trigger rows this camera actually has. */
163604
+ let lastSchedule = null;
163605
+ const refreshFromCamera = async () => {
163606
+ if (this.recordingOnboardRefreshInFlight) return this.recordingOnboardRefreshInFlight;
163607
+ const promise = (async () => {
163608
+ try {
163609
+ const api = await this.ensureApi();
163610
+ const [cfg, limits, schedule, hdd] = await Promise.all([
163611
+ api.getRecordCfg(channel).catch(() => null),
163612
+ api.getRecordCfgLimits(channel).catch(() => null),
163613
+ api.getRecordSchedule(channel).catch(() => null),
163614
+ this.isHubChild() ? Promise.resolve(null) : api.getHddInfoList().catch(() => null)
163615
+ ]);
163616
+ lastCfg = cfg;
163617
+ lastLimits = limits;
163618
+ lastSchedule = schedule;
163619
+ const next = buildOnboardStatus({
163620
+ storage: this.isHubChild() ? {
163621
+ kind: "unknown",
163622
+ reason: "This camera hangs off a Reolink Hub, so its footage lives on the Hub’s disk, not on a card of its own."
163623
+ } : hdd === null ? {
163624
+ kind: "unknown",
163625
+ reason: "The camera did not answer the storage read."
163626
+ } : parseOnboardStorage(hdd),
163627
+ cfg,
163628
+ schedule,
163629
+ now: Date.now()
163630
+ });
163631
+ this.runtimeState.setCapState(CAP_NAME, next);
163632
+ if (next.recordingToNowhere) this.ctx.logger.warn("reolink onboard recording: camera is scheduled to record and has no usable storage", {
163633
+ tags: { deviceId: this.id },
163634
+ meta: { windows: next.windows.length }
163635
+ });
163636
+ if (next.unreadableWindows > 0) this.ctx.logger.warn("reolink onboard recording: some schedule tables could not be read — the schedule shown is incomplete", {
163637
+ tags: { deviceId: this.id },
163638
+ meta: {
163639
+ unreadableWindows: next.unreadableWindows,
163640
+ readWindows: next.windows.length
163641
+ }
163642
+ });
163643
+ } catch (err) {
163644
+ this.ctx.logger.debug("reolink onboard recording refresh failed — keeping last slice", {
163645
+ tags: { deviceId: this.id },
163646
+ meta: { error: err instanceof Error ? err.message : String(err) }
163647
+ });
163648
+ }
163649
+ })();
163650
+ this.recordingOnboardRefreshInFlight = promise;
163651
+ try {
163652
+ await promise;
163653
+ } finally {
163654
+ this.recordingOnboardRefreshInFlight = null;
163655
+ }
163656
+ };
163657
+ const bridge = createRuntimeStateBridge({
163658
+ runtimeState: this.runtimeState,
163659
+ cap: recordingOnboardCapability,
163660
+ ownDeviceId: this.id,
163661
+ refresh: this.sleepGatedRefresh(CAP_NAME, refreshFromCamera),
163662
+ staleMs: OPERATOR_WRITTEN_STALE_MS,
163663
+ staleRead: "serve-and-revalidate",
163664
+ empty: emptyStatus
163665
+ });
163666
+ const readOptions = () => buildOnboardOptions({
163667
+ status: this.runtimeState.getCapState(CAP_NAME) ?? null,
163668
+ cfg: lastCfg,
163669
+ limits: lastLimits,
163670
+ schedule: lastSchedule
163671
+ });
163672
+ const provider = {
163673
+ getStatus: bridge.getStatus,
163674
+ getOptions: async ({ deviceId }) => {
163675
+ if (deviceId !== this.id) throw new Error(`ReolinkCamera: deviceId mismatch, expected ${this.id}, got ${deviceId}`);
163676
+ await bridge.ensureFresh();
163677
+ return readOptions();
163678
+ },
163679
+ setSettings: async ({ deviceId, settings }) => {
163680
+ if (deviceId !== this.id) return;
163681
+ if (Object.keys(settings).length === 0) return;
163682
+ await bridge.ensureFresh();
163683
+ const refusal = describeOnboardRefusal(readOptions(), settings);
163684
+ if (refusal !== null) {
163685
+ this.ctx.logger.warn("reolink onboard recording: refusing a write this camera cannot take", {
163686
+ tags: { deviceId: this.id },
163687
+ meta: {
163688
+ refusal,
163689
+ fields: Object.keys(settings)
163690
+ }
163691
+ });
163692
+ throw new Error(`device ${String(this.id)}: ${refusal}`);
163693
+ }
163694
+ const writes = buildOnboardWrites(settings, lastSchedule);
163695
+ const api = await this.ensureApi();
163696
+ const ignored = [];
163697
+ if (writes.cfg !== null) {
163698
+ const result = await api.setRecordCfg(channel, writes.cfg);
163699
+ for (const outcome of result.outcomes) if (outcome.status === "ignored") ignored.push(`${outcome.field}: asked ${outcome.requested}, camera kept ${outcome.actual}`);
163700
+ }
163701
+ if (writes.schedule !== null) {
163702
+ const result = await api.setRecordSchedule(channel, writes.schedule);
163703
+ for (const outcome of result.outcomes) if (outcome.status === "ignored") ignored.push(`schedule ${outcome.field}: the camera kept its own value`);
163704
+ }
163705
+ await refreshFromCamera();
163706
+ if (ignored.length > 0) {
163707
+ this.ctx.logger.warn("reolink onboard recording: the camera answered 200 and discarded part of the write", {
163708
+ tags: { deviceId: this.id },
163709
+ meta: {
163710
+ ignored,
163711
+ fields: Object.keys(settings)
163712
+ }
163713
+ });
163714
+ throw new Error(`device ${String(this.id)}: the camera accepted the request and kept its own values — ${ignored.join("; ")}`);
163715
+ }
163716
+ this.ctx.logger.info("reolink onboard recording: write applied", {
163717
+ tags: { deviceId: this.id },
163718
+ meta: { fields: Object.keys(settings) }
163719
+ });
163720
+ }
163721
+ };
163722
+ this.registerCapWarmer(CAP_NAME, async () => {
163723
+ await provider.getOptions({ deviceId: this.id });
163724
+ await refreshFromCamera();
163725
+ });
163726
+ this.ctx.registerNativeCap(recordingOnboardCapability, provider);
163727
+ this.ctx.logger.info("Reolink recording-onboard cap registered", { tags: { deviceId: this.id } });
163728
+ }
161912
163729
  registerDayNightCap() {
161913
163730
  const channel = this.getChannel();
161914
163731
  const CAP_NAME = "day-night";
@@ -166338,6 +168155,29 @@ var ReolinkHub = class ReolinkHub extends BaseDevice {
166338
168155
  await (await this.ensureApi()).reboot();
166339
168156
  return { success: true };
166340
168157
  } });
168158
+ this.ctx.registerNativeCap(deviceAdminLinkCapability, { getAdminLink: async ({ deviceId }) => {
168159
+ if (deviceId !== this.id) throw new Error(`ReolinkHub: deviceId mismatch, expected ${this.id}, got ${deviceId}`);
168160
+ const host = this.config.get("host") ?? "";
168161
+ const built = buildDeviceAdminUrl({
168162
+ host,
168163
+ port: null,
168164
+ scheme: "http",
168165
+ path: "/"
168166
+ });
168167
+ if (!built.ok) {
168168
+ this.ctx.logger.debug("reolink hub admin link unavailable", {
168169
+ tags: { deviceId: this.id },
168170
+ meta: { reason: built.reason }
168171
+ });
168172
+ return null;
168173
+ }
168174
+ return {
168175
+ url: built.url,
168176
+ label: "Web UI",
168177
+ target: "device",
168178
+ host
168179
+ };
168180
+ } });
166341
168181
  }
166342
168182
  async onActivate() {
166343
168183
  this.ensureApi().catch((err) => {