@camstack/addon-provider-reolink 1.2.142 → 1.2.144

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.
Files changed (3) hide show
  1. package/dist/addon.js +361 -73
  2. package/dist/addon.mjs +361 -73
  3. package/package.json +2 -2
package/dist/addon.mjs CHANGED
@@ -5381,7 +5381,7 @@ var ZodIssueCode = {
5381
5381
  var ZodFirstPartyTypeKind;
5382
5382
  ZodFirstPartyTypeKind || (ZodFirstPartyTypeKind = {});
5383
5383
  //#endregion
5384
- //#region ../types/dist/sleep-Bs3xPtSh.mjs
5384
+ //#region ../types/dist/sleep-CiR4z7r9.mjs
5385
5385
  /**
5386
5386
  * The audio chunk plane's byte format, and the ONE expansion from a coded
5387
5387
  * window to float samples (D455).
@@ -6523,6 +6523,17 @@ var CamProfileSchema = _enum([
6523
6523
  "mid",
6524
6524
  "low"
6525
6525
  ]);
6526
+ /**
6527
+ * Which rung a per-camera consumer listens on: an explicit profile, or `auto`.
6528
+ *
6529
+ * `auto` is a REAL value, not an absent one — it means "resolve it by the rule
6530
+ * that consumer owns". It lives beside `CamProfile` because a rung is the only
6531
+ * operator-facing way to name a stream: a cam-stream id is broker-internal and
6532
+ * moves when a slot is reassigned, so a stored id silently stops meaning what
6533
+ * the operator picked. Today's one user is the audio analyser, whose rule is
6534
+ * "the lowest AVAILABLE native rung" (D537).
6535
+ */
6536
+ var AudioStreamPickSchema = union([CamProfileSchema, literal("auto")]);
6526
6537
  var CamStreamKindSchema = _enum([
6527
6538
  "pull-rtsp",
6528
6539
  "pull-rtmp",
@@ -7789,6 +7800,7 @@ var CameraSwitchIdSchema = _enum([
7789
7800
  "device-audio",
7790
7801
  "broker-audio",
7791
7802
  "audio-analysis",
7803
+ "event-saving",
7792
7804
  "recording",
7793
7805
  "notifications"
7794
7806
  ]);
@@ -8263,11 +8275,31 @@ var RecordingBandTriggersSchema = RecordingTriggersSchema;
8263
8275
  * band engine's `applies` rule). `start`/`end` are `HH:MM`; an `end <= start`
8264
8276
  * span wraps past midnight (handled by the band engine).
8265
8277
  */
8278
+ /**
8279
+ * Whether a band's footage carries sound.
8280
+ *
8281
+ * The recorder has always attached with `audio: 'aac'`, so every band of every
8282
+ * camera recorded sound whether anyone wanted it or not. The broker already
8283
+ * serves the other answer — `audio: 'none'` takes the video-only `/muted`
8284
+ * restream — so this was a choice the product could always have offered.
8285
+ *
8286
+ * It belongs on the BAND rather than the camera because one camera can want
8287
+ * both: silent across the shop floor during opening hours, sound overnight.
8288
+ * And it applies to BOTH modes — an events band firing on motion has the same
8289
+ * question to answer as a continuous one.
8290
+ */
8291
+ var RecordingBandAudioSchema = _enum(["on", "muted"]);
8266
8292
  var RecordingBandSchema = object({
8267
8293
  days: array(RecordingWeekdaySchema),
8268
8294
  start: string().regex(HHMM),
8269
8295
  end: string().regex(HHMM),
8270
8296
  mode: RecordingBandModeSchema,
8297
+ /**
8298
+ * Sound, or silence. OPTIONAL and absent-means-`on`: every recording
8299
+ * written before this existed carried sound, and a band whose key is
8300
+ * missing must keep meaning what it meant. See {@link RecordingBandAudioSchema}.
8301
+ */
8302
+ audio: RecordingBandAudioSchema.optional(),
8271
8303
  triggers: RecordingBandTriggersSchema.optional(),
8272
8304
  /**
8273
8305
  * RETIRED (D381). A band no longer carries a pre-buffer of its own.
@@ -20889,6 +20921,7 @@ var MediaFileKindEnum = _enum([
20889
20921
  "keyFrameSmall",
20890
20922
  "thumbnailSmall",
20891
20923
  "confirmationCrop",
20924
+ "debugCrop",
20892
20925
  "sceneFrame"
20893
20926
  ]);
20894
20927
  /**
@@ -22553,6 +22586,18 @@ var RunnerCameraConfigSchema = object({
22553
22586
  motionStreamId: string(),
22554
22587
  detectionStreamId: string(),
22555
22588
  /**
22589
+ * Which rung this camera's audio analysis listens on — `auto` (the lowest
22590
+ * AVAILABLE native rung) or an explicit one.
22591
+ *
22592
+ * Not consumed by the runner: carried here so it travels with the camera's
22593
+ * config the way `audioMotionWindowMs` does, and so the orchestrator's
22594
+ * watchdog resolves the same stream the audio subscription did. OPTIONAL
22595
+ * with no default — absent means `auto`, which is what every camera written
22596
+ * before this field had, and an absent optional adds nothing to the
22597
+ * generated cap-input defaults (D537).
22598
+ */
22599
+ audioStreamPick: AudioStreamPickSchema.optional(),
22600
+ /**
22556
22601
  * Motion sources that can activate the pipeline. At least one must
22557
22602
  * be present. Multiple sources OR-ed: any one firing arms the
22558
22603
  * cooldown timer and transitions watching → active. The same
@@ -25148,6 +25193,104 @@ var WebrtcStreamChoiceSchema = object({
25148
25193
  /** Bitrate in kbps (null for adaptive). */
25149
25194
  bitrateKbps: number().nullable()
25150
25195
  });
25196
+ /**
25197
+ * The last move the adaptive ladder made on this session, and the numbers it
25198
+ * rested on. Mirrors `LadderDecision` in the stream-broker's
25199
+ * `adaptive-controller.ts` — the log line's evidence, made readable.
25200
+ *
25201
+ * Nullability is load-bearing: `lossRatio: null` means the loss tracker had
25202
+ * no window to report, which is NOT a link with zero loss.
25203
+ */
25204
+ var WebrtcLadderDecisionSchema = object({
25205
+ /** `loss` — RTCP / client-reported packet loss; `stall` — the picture
25206
+ * stopped moving; `healthy` — a promotion. */
25207
+ cause: _enum([
25208
+ "loss",
25209
+ "stall",
25210
+ "healthy"
25211
+ ]),
25212
+ /** The rung the ladder moved TO. Null for a move that changed no rung
25213
+ * (an MTU reduction, a forced transcode-down at the bottom). */
25214
+ tier: CamProfileSchema.nullable(),
25215
+ /** Whether that move forced the egress transcode-down rung. */
25216
+ forcedTranscodeDown: boolean(),
25217
+ /** Loss ratio over the tracker's window at the moment of the decision. */
25218
+ lossRatio: number().nullable(),
25219
+ /** The client's reported downlink at that moment, in kbps. */
25220
+ clientKbps: number().nullable(),
25221
+ /** How long the stall detector had been seeing nothing arrive. */
25222
+ stalledForMs: number().nullable(),
25223
+ /** Whether the broker WAS sending while that was measured — the half that
25224
+ * separates "the camera stopped" from "the link stopped". */
25225
+ serverSending: boolean().nullable(),
25226
+ /** Epoch ms. The overlay prints its age; a decision from 40 minutes ago
25227
+ * must not read like one from this second. */
25228
+ at: number()
25229
+ });
25230
+ /** Per-tier count of MEASURED downgrades this viewer session has suffered. */
25231
+ var WebrtcTierFailureSchema = object({
25232
+ tier: CamProfileSchema,
25233
+ failures: number().int().nonnegative()
25234
+ });
25235
+ /**
25236
+ * What the SERVER decided for one WebRTC session — the half the client cannot
25237
+ * measure. See `getSessionDebug`.
25238
+ */
25239
+ var WebrtcSessionDebugSchema = object({
25240
+ sessionId: string(),
25241
+ /** Whether the session still exists here. Everything below is null when
25242
+ * it does not — a dead session has no measurements, not zero ones. */
25243
+ alive: boolean(),
25244
+ /** Epoch ms this snapshot was taken, so the overlay can show its age. */
25245
+ observedAt: number(),
25246
+ /** The broker actually feeding this session (`<deviceId>/<camStreamId>`). */
25247
+ brokerId: string().nullable(),
25248
+ camStreamId: string().nullable(),
25249
+ /** `derived` = this hub BUILT the rung being served (a transcode/scale of
25250
+ * another stream); `native` = the camera's own encode. */
25251
+ sourceKind: _enum([
25252
+ "native",
25253
+ "derived",
25254
+ "unknown"
25255
+ ]),
25256
+ /** Whether the ladder is running at all (an `adaptive` target). */
25257
+ adaptive: boolean(),
25258
+ /** The rung `auto` ACTUALLY resolved to right now. Null when the broker
25259
+ * is bound to no profile slot (a manually-activated stream). */
25260
+ servedTier: CamProfileSchema.nullable(),
25261
+ /** Which node ingests the source. Null when unresolved. */
25262
+ ingestNodeId: string().nullable(),
25263
+ /** Whether that node is a remote one (the media crosses a node boundary
25264
+ * before it reaches this session). Null when unresolved. */
25265
+ ingestIsRemote: boolean().nullable(),
25266
+ /** Codec off the source, and the one negotiated with this client. */
25267
+ sourceCodec: string().nullable(),
25268
+ negotiatedCodec: string().nullable(),
25269
+ /** Whether the EGRESS leg re-encodes for this client or forwards the
25270
+ * source bytes untouched. */
25271
+ egressLeg: _enum([
25272
+ "copy",
25273
+ "transcode",
25274
+ "unknown"
25275
+ ]),
25276
+ /** What the broker itself reports about the rung it is serving. */
25277
+ brokerStatus: string().nullable(),
25278
+ brokerCodec: string().nullable(),
25279
+ brokerBitrateKbps: number().nullable(),
25280
+ brokerInputFps: number().nullable(),
25281
+ brokerUptimeMs: number().nullable(),
25282
+ /** RTP packets this session has sent — the server's own "am I sending?". */
25283
+ rtpPacketsSent: number().nullable(),
25284
+ /** D42: the track may be carrying the recorded feeder, not live. The ladder
25285
+ * is suspended there, so a frozen picture in `recorded` mode means
25286
+ * something entirely different. */
25287
+ trackMode: _enum(["live", "recorded"]).nullable(),
25288
+ lastDecision: WebrtcLadderDecisionSchema.nullable(),
25289
+ tierFailures: array(WebrtcTierFailureSchema),
25290
+ /** Successful promotions in this viewer session (drives the progressive
25291
+ * healthy timer). Null when no ladder is running. */
25292
+ upgradeCount: number().nullable()
25293
+ });
25151
25294
  DeviceType.Camera, method(object({ deviceId: number().int().nonnegative() }), array(WebrtcStreamChoiceSchema)), method(object({
25152
25295
  deviceId: number().int().nonnegative(),
25153
25296
  target: WebrtcStreamTargetSchema,
@@ -25312,7 +25455,10 @@ DeviceType.Camera, method(object({ deviceId: number().int().nonnegative() }), ar
25312
25455
  * the same answer as "it ended": either way nothing is holding it up.
25313
25456
  */
25314
25457
  alive: boolean()
25315
- }));
25458
+ })), method(object({
25459
+ deviceId: number().int().nonnegative(),
25460
+ sessionId: string()
25461
+ }), WebrtcSessionDebugSchema, { kind: "query" });
25316
25462
  object({
25317
25463
  /** All accessory children of the parent. */
25318
25464
  childDeviceIds: array(number()).readonly(),
@@ -29647,11 +29793,15 @@ var motionTriggerCapability = {
29647
29793
  *
29648
29794
  * Adding a fifth name to that list would have been the wrong fix twice over:
29649
29795
  * that page is per-camera DETECTION tuning, and a grid's geometry belongs
29650
- * beside PTZ and motion zones on the camera itself — which is exactly where the
29651
- * framework already puts a widget, when the widget is declared on a CAPABILITY.
29652
- * `deviceConfig.ui` is the mechanism (`motion-zones`, `ptz`, `ptz-autotrack`
29653
- * all use it): the framework emits the structural section and the widget
29654
- * self-persists through the cap's own mutations.
29796
+ * beside PTZ and motion zones on the camera itself. The device page is
29797
+ * BINDING-driven (D12), so the way in is a capability bound to the device —
29798
+ * and this cap carries its section the way `recording` does, by RETURNING it
29799
+ * from `getDeviceSettingsContribution`.
29800
+ *
29801
+ * Seven other widgets are still declared the other way, through a
29802
+ * `deviceConfig.ui` block the framework derives a section from. That route
29803
+ * gives the addon no say in where its own panel lands and no way to decline
29804
+ * for a device the panel does not suit, which is why this one does not use it.
29655
29805
  *
29656
29806
  * ## Why one addon may implement it
29657
29807
  *
@@ -29707,16 +29857,37 @@ var GridProfileOfferSchema = object({
29707
29857
  ]),
29708
29858
  offered: boolean(),
29709
29859
  /** Sources that cannot serve it. Empty when it is offered, or when there are no cells. */
29710
- missingSources: array(number().int().positive())
29860
+ missingSources: array(number().int().positive()),
29861
+ /**
29862
+ * The canvas this profile composes onto, `WxH`, or empty when it is not
29863
+ * offered. DERIVED from the cells and the sources' own size at this profile —
29864
+ * it is reported because nothing else in the system would ever say what the
29865
+ * grid came out as, and because it is the number an operator would otherwise
29866
+ * expect to type.
29867
+ */
29868
+ canvas: string(),
29869
+ /**
29870
+ * Whether this profile is PUBLISHED, of the ones the grid could serve.
29871
+ *
29872
+ * A grid's `high` is composed of its sources' `high`, so on a 4K fleet it is
29873
+ * a 4K canvas built from 4K decodes — something to opt into, not something a
29874
+ * viewer's adaptive should be handed by climbing to the top rung it can see.
29875
+ * Default is `mid` + `low`.
29876
+ */
29877
+ published: boolean()
29711
29878
  });
29712
29879
  var GridLayoutViewSchema = object({
29713
29880
  /** The persisted grid row this camera was declared from. */
29714
29881
  instanceId: string(),
29715
29882
  deviceId: number().int().nonnegative(),
29716
29883
  name: string(),
29717
- /** The canvas the operator laid out — this grid's `high`. mid/low are scaled from it. */
29718
- width: number().int(),
29719
- height: number().int(),
29884
+ /**
29885
+ * NO canvas size. A grid's resolution is not authored: each profile derives
29886
+ * its own from the cells and its sources' dimensions. The two numbers that
29887
+ * used to be here were a text field that silently decided both how much the
29888
+ * composite cost and how sharp it was — see `profiles[].canvas` for what it
29889
+ * came out as.
29890
+ */
29720
29891
  fps: number().int(),
29721
29892
  cells: array(GridLayoutCellSchema),
29722
29893
  /** What the catalog will publish, and what it refuses to. Read-only. */
@@ -29725,9 +29896,13 @@ var GridLayoutViewSchema = object({
29725
29896
  var GridLayoutPatchSchema = object({
29726
29897
  deviceId: number().int().nonnegative(),
29727
29898
  name: string().min(1).max(160).optional(),
29728
- width: number().int().min(160).max(7680).optional(),
29729
- height: number().int().min(120).max(4320).optional(),
29730
29899
  fps: number().int().min(1).max(60).optional(),
29900
+ /** Which profiles to publish. See `GridProfileOffer.published`. */
29901
+ publishedProfiles: array(_enum([
29902
+ "high",
29903
+ "mid",
29904
+ "low"
29905
+ ])).max(3).optional(),
29731
29906
  /**
29732
29907
  * The whole cell list at once. A per-cell patch would need an ordering the
29733
29908
  * editor does not have, and a half-applied layout is a picture nobody asked
@@ -32142,6 +32317,25 @@ var ptzAutotrackCapability = {
32142
32317
  scope: "device",
32143
32318
  deviceNative: true,
32144
32319
  mode: "singleton",
32320
+ /**
32321
+ * Two kinds of provider behind one name, the same shape `snapshot` and
32322
+ * `doorbell` use:
32323
+ *
32324
+ * - **Native**: the camera's own firmware autotrack, registered per device by
32325
+ * the driver when the probe says it exists (Reolink: yes; Hikvision:
32326
+ * model-dependent).
32327
+ * - **Wrapper**: the emulated implementation (`@camstack/system`'s `autotrack` builtin),
32328
+ * which follows a subject with `ptz.move` pulses driven by the detection
32329
+ * pipeline. `defaultActive: false` — the operator binds it per camera in
32330
+ * the bindings UI, exactly as `virtual-doorbell` is bound.
32331
+ *
32332
+ * The binding chooses WHICH implementation answers; `setEnabled` stays the
32333
+ * one switch for the function either way. A camera never has both driving its
32334
+ * head, which is the whole reason this is not a second cap with a second
32335
+ * toggle (D62, D520).
32336
+ */
32337
+ kind: "wrapper",
32338
+ defaultActive: false,
32145
32339
  deviceTypes: [DeviceType.Camera],
32146
32340
  deviceConfig: { ui: {
32147
32341
  kind: "widget",
@@ -43513,6 +43707,12 @@ Object.freeze({
43513
43707
  addonId: null,
43514
43708
  access: "view"
43515
43709
  },
43710
+ "webrtcSession.getSessionDebug": {
43711
+ capName: "webrtc-session",
43712
+ capScope: "device",
43713
+ addonId: null,
43714
+ access: "view"
43715
+ },
43516
43716
  "webrtcSession.getSessionState": {
43517
43717
  capName: "webrtc-session",
43518
43718
  capScope: "device",
@@ -45496,6 +45696,11 @@ Object.freeze({
45496
45696
  form: "single",
45497
45697
  optional: false
45498
45698
  }],
45699
+ "webrtcSession.getSessionDebug": [{
45700
+ name: "deviceId",
45701
+ form: "single",
45702
+ optional: false
45703
+ }],
45499
45704
  "webrtcSession.getSessionState": [{
45500
45705
  name: "deviceId",
45501
45706
  form: "single",
@@ -75181,7 +75386,7 @@ var require_lz4 = /* @__PURE__ */ __commonJSMin(((exports) => {
75181
75386
  };
75182
75387
  }));
75183
75388
  //#endregion
75184
- //#region node_modules/@apocaliss92/nodelink-js/dist/chunk-CE77DTET.js
75389
+ //#region node_modules/@apocaliss92/nodelink-js/dist/chunk-7JMYBUEB.js
75185
75390
  var import_fxp = require_fxp();
75186
75391
  var import_lz4 = /* @__PURE__ */ __toESM(require_lz4(), 1);
75187
75392
  function encodeHeader(h) {
@@ -83641,10 +83846,14 @@ function applyOsdDatetimePatch(rawXml, patch) {
83641
83846
  return xml;
83642
83847
  }
83643
83848
  var OSD_POSITION_MAX = 65536;
83849
+ var OSD_POSITION_START_WRITE = 1;
83850
+ var OSD_POSITION_CENTER = 65537;
83644
83851
  var OSD_CORNERS = [
83645
83852
  "top-left",
83853
+ "top-center",
83646
83854
  "top-right",
83647
83855
  "bottom-left",
83856
+ "bottom-center",
83648
83857
  "bottom-right"
83649
83858
  ];
83650
83859
  function isStartEdge(value) {
@@ -83656,7 +83865,7 @@ function isEndEdge(value) {
83656
83865
  function readOsdPosition(x, y) {
83657
83866
  if (typeof x !== "number" || typeof y !== "number") return { kind: "unknown" };
83658
83867
  if (!Number.isFinite(x) || !Number.isFinite(y)) return { kind: "unknown" };
83659
- const horizontal = isStartEdge(x) ? "left" : isEndEdge(x) ? "right" : null;
83868
+ const horizontal = x === OSD_POSITION_CENTER ? "center" : isStartEdge(x) ? "left" : isEndEdge(x) ? "right" : null;
83660
83869
  const vertical = isStartEdge(y) ? "top" : isEndEdge(y) ? "bottom" : null;
83661
83870
  if (horizontal === null || vertical === null) return {
83662
83871
  kind: "custom",
@@ -83670,8 +83879,8 @@ function readOsdPosition(x, y) {
83670
83879
  }
83671
83880
  function coordsForOsdCorner(corner) {
83672
83881
  return {
83673
- x: corner.endsWith("-right") ? OSD_POSITION_MAX : 0,
83674
- y: corner.startsWith("bottom-") ? OSD_POSITION_MAX : 0
83882
+ x: corner.endsWith("-center") ? OSD_POSITION_CENTER : corner.endsWith("-right") ? OSD_POSITION_MAX : OSD_POSITION_START_WRITE,
83883
+ y: corner.startsWith("bottom-") ? OSD_POSITION_MAX : OSD_POSITION_START_WRITE
83675
83884
  };
83676
83885
  }
83677
83886
  function isOsdCorner(value) {
@@ -83682,8 +83891,10 @@ function formatOsdCoords(x, y) {
83682
83891
  }
83683
83892
  var OSD_CORNER_LABELS = {
83684
83893
  "top-left": "Top left",
83894
+ "top-center": "Top centre",
83685
83895
  "top-right": "Top right",
83686
83896
  "bottom-left": "Bottom left",
83897
+ "bottom-center": "Bottom centre",
83687
83898
  "bottom-right": "Bottom right"
83688
83899
  };
83689
83900
  function wireFloat(value) {
@@ -88210,8 +88421,12 @@ var ReolinkBaichuanApi = class _ReolinkBaichuanApi {
88210
88421
  /** Generic Baichuan cmd_id call, returns XML (if any). */
88211
88422
  async sendXml(params, retry = 3) {
88212
88423
  if (!this.client.loggedIn) await this.client.login();
88213
- const frame = await this.client.sendFrame(params);
88214
- if (frame.header.responseCode === 400) return await this.handleSendXml400(params, frame, retry);
88424
+ const addressed = params.channel != null && params.extensionXml == null ? {
88425
+ ...params,
88426
+ extensionXml: buildChannelExtensionXml(params.channel)
88427
+ } : params;
88428
+ const frame = await this.client.sendFrame(addressed);
88429
+ if (frame.header.responseCode === 400) return await this.handleSendXml400(addressed, frame, retry);
88215
88430
  if (frame.body.length === 0) return "";
88216
88431
  return this.client.tryDecryptXml(frame.body, frame.header.channelId, this.client.enc);
88217
88432
  }
@@ -94512,6 +94727,7 @@ ${xml}`);
94512
94727
  await this.sendXml({
94513
94728
  cmdId: 45,
94514
94729
  channel: ch,
94730
+ extensionXml: buildChannelExtensionXml(ch),
94515
94731
  payloadXml: ensureXmlHeader(xml),
94516
94732
  ...timeoutOpts
94517
94733
  });
@@ -145546,6 +145762,7 @@ var RAW_READ_CATALOG = {
145546
145762
  kind: "deviceCache",
145547
145763
  snapshotKey: "autoFocusSnapshot"
145548
145764
  },
145765
+ needs: "ptz",
145549
145766
  invoke: (api, channel) => api.getAutoFocus(channel, { timeoutMs: 1500 })
145550
145767
  },
145551
145768
  netPort: {
@@ -145742,6 +145959,7 @@ var RawReadResultSchema = discriminatedUnion("ok", [object({
145742
145959
  slice: RawReadSliceSchema,
145743
145960
  reason: _enum([
145744
145961
  "sleeping",
145962
+ "unsupported",
145745
145963
  "login-failed",
145746
145964
  "read-failed"
145747
145965
  ]),
@@ -145785,6 +146003,20 @@ function toPlainJson(value) {
145785
146003
  async function performRawRead(slice, deps) {
145786
146004
  const entry = RAW_READ_CATALOG[slice];
145787
146005
  const believed = believedState(deps, entry);
146006
+ if (entry.needs === "ptz" && !deps.hasPtz) {
146007
+ deps.logger.info("reolink raw read refused — the camera has no PTZ head", {
146008
+ tags: { deviceId: deps.deviceId },
146009
+ meta: { slice }
146010
+ });
146011
+ return {
146012
+ ok: false,
146013
+ deviceId: deps.deviceId,
146014
+ slice,
146015
+ reason: "unsupported",
146016
+ message: `This camera has no pan/tilt/zoom head, so it has no focus motor and no "${slice}" to read — the Reolink app does not ask it either. Nothing is wrong with the camera.`,
146017
+ believed
146018
+ };
146019
+ }
145788
146020
  const servedWithoutTouchingCamera = deps.hubChild && entry.servedByParentHub === true;
145789
146021
  if (deps.sleeping && !servedWithoutTouchingCamera) {
145790
146022
  deps.logger.info("reolink raw read refused — battery cam is sleeping", {
@@ -149448,7 +149680,6 @@ var reolinkCameraSchema = object({
149448
149680
  * read via 52). Toggles the `<Shelter><enable>` flag. Zone
149449
149681
  * editing is a separate flow not exposed by this driver.
149450
149682
  */
149451
- privacyMaskEnabled: boolean().optional(),
149452
149683
  /**
149453
149684
  * Audio noise reduction level — pushed via `setAudioNoise`
149454
149685
  * (cmd_id 440, read via 439). 0 disables; 1..3 maps to
@@ -150282,7 +150513,6 @@ function slicesForPatch(patch) {
150282
150513
  if (has("motionEnabled", "motionSensitivity")) out.add("motion");
150283
150514
  if (Object.keys(patch).some((k) => /^ai(Person|Vehicle|Animal|Face|Package)/.test(k))) out.add("ai");
150284
150515
  if (has("gop")) out.add("enc");
150285
- if (has("privacyMaskEnabled")) out.add("mask");
150286
150516
  if (has("audioNoiseLevel")) out.add("audioNoise");
150287
150517
  if (has("autoFocusEnabled")) out.add("autofocus");
150288
150518
  if (has("netHttpEnable", "netHttpPort", "netHttpsEnable", "netHttpsPort", "netRtspEnable", "netRtspPort", "netRtmpEnable", "netRtmpPort", "netOnvifEnable", "netOnvifPort")) out.add("netPort");
@@ -150294,6 +150524,20 @@ function slicesForPatch(patch) {
150294
150524
  if (has("timeZoneId")) out.add("systemGeneral");
150295
150525
  return out;
150296
150526
  }
150527
+ /**
150528
+ * What the operator's "Refresh from camera" actually managed.
150529
+ *
150530
+ * Returned rather than logged, because the button is the one place a person
150531
+ * asks "is what I am looking at true right now" — and an answer of `void`
150532
+ * cannot distinguish a full re-read from a camera that never woke.
150533
+ */
150534
+ var RefreshSettingsResultSchema = object({
150535
+ /** False when a battery camera did not come up — everything below is then stale. */
150536
+ awake: boolean(),
150537
+ snapshotRefreshed: boolean(),
150538
+ capsWarmed: number(),
150539
+ capsTotal: number()
150540
+ });
150297
150541
  var ReolinkCamera = class ReolinkCamera extends BaseDevice {
150298
150542
  type = DeviceType.Camera;
150299
150543
  /**
@@ -150763,9 +151007,12 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
150763
151007
  * `setVideoInput`); motion is writable via `setMotionAlarm` so its
150764
151008
  * snapshot also drives the dispatch's known-good baseline.
150765
151009
  */
150766
- async refreshParentSettingsSnapshot(slices) {
150767
- if (this.isBattery && this.sleeping) {
150768
- this.ctx.logger.info("refreshParentSettingsSnapshot skipped — battery cam is sleeping", { tags: { deviceId: this.id } });
151010
+ async refreshParentSettingsSnapshot(slices, reason = "background") {
151011
+ if (this.isBattery && this.sleeping && reason !== "post-write") {
151012
+ this.ctx.logger.info("refreshParentSettingsSnapshot skipped — battery cam is sleeping", { tags: {
151013
+ deviceId: this.id,
151014
+ reason
151015
+ } });
150769
151016
  return;
150770
151017
  }
150771
151018
  let api;
@@ -151010,6 +151257,7 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
151010
151257
  channel: this.getChannel(),
151011
151258
  sleeping: this.isBattery && this.sleeping,
151012
151259
  hubChild: this.isHubChild(),
151260
+ hasPtz: this.getProbeFlags().hasPtz === true,
151013
151261
  getApi: () => this.ensureApi(),
151014
151262
  cache: this.config.get("deviceCache"),
151015
151263
  batteryState: this.runtimeState.getCapState("battery"),
@@ -151103,6 +151351,10 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
151103
151351
  });
151104
151352
  await sleep$2(1500);
151105
151353
  })(), new Promise((_, rej) => setTimeout(() => rej(/* @__PURE__ */ new Error("wakeIfSleeping timeout")), timeoutMs))]);
151354
+ if (this.commitSleepState(false, "confirmed-wake")) {
151355
+ this.ctx.logger.info("proactive action: camera is up — committing the mirror", { tags: { deviceId: this.id } });
151356
+ this.onWakeTransition("confirmed-wake").catch(() => {});
151357
+ }
151106
151358
  return true;
151107
151359
  } catch (err) {
151108
151360
  this.ctx.logger.warn("proactive action: wake did not complete in time", {
@@ -151274,8 +151526,26 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
151274
151526
  this.registerDeviceAction("auto-configure-email-push", deviceCustomAction(unknown(), _void(), { kind: "mutation" }), async () => {
151275
151527
  await this.autoConfigureEmailPush();
151276
151528
  });
151277
- this.registerDeviceAction("refresh-settings", deviceCustomAction(unknown(), _void(), { kind: "mutation" }), async () => {
151278
- await Promise.allSettled([this.refreshParentSettingsSnapshot(), this.refreshStreamParamsFromCamera?.() ?? Promise.resolve()]);
151529
+ this.registerDeviceAction("refresh-settings", deviceCustomAction(unknown(), RefreshSettingsResultSchema, { kind: "mutation" }), async () => {
151530
+ const awake = await this.wakeIfSleeping();
151531
+ const [snapshot, warm] = await Promise.allSettled([this.refreshParentSettingsSnapshot(void 0, "operator"), this.warmCapSlicesOnWake({ force: true })]);
151532
+ const warmed = warm.status === "fulfilled" ? warm.value.warmed : 0;
151533
+ const capsTotal = this.capWarmers.size;
151534
+ this.ctx.logger.info("refresh from camera: operator rehydrate", {
151535
+ tags: { deviceId: this.id },
151536
+ meta: {
151537
+ awake,
151538
+ snapshot: snapshot.status,
151539
+ warmed,
151540
+ capsTotal
151541
+ }
151542
+ });
151543
+ return {
151544
+ awake,
151545
+ snapshotRefreshed: snapshot.status === "fulfilled",
151546
+ capsWarmed: warmed,
151547
+ capsTotal
151548
+ };
151279
151549
  });
151280
151550
  this.registerDeviceAction("refresh-sessions", deviceCustomAction(unknown(), _void(), { kind: "mutation" }), async () => {
151281
151551
  await this.refreshSessionsSnapshot();
@@ -151315,7 +151585,6 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
151315
151585
  * registration so the general "Refresh from camera" button can warm
151316
151586
  * the `stream-params` runtime-state slice (the editable Stream
151317
151587
  * section's current values read from it). */
151318
- refreshStreamParamsFromCamera = null;
151319
151588
  /** Single-flight guard for the motion-zones cap refresh. */
151320
151589
  motionZonesRefreshInFlight = null;
151321
151590
  /** Single-flight guard for the privacy-mask cap refresh. */
@@ -151449,6 +151718,16 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
151449
151718
  };
151450
151719
  }
151451
151720
  if (confirmed && !observedAwake && parent !== null) this.watchHubChildAwake(parent).catch(() => {});
151721
+ if (this.commitSleepState(false, "confirmed-wake")) {
151722
+ this.ctx.logger.info("battery wakeForStream: answering awake — committing the mirror", {
151723
+ tags: { deviceId: this.id },
151724
+ meta: {
151725
+ confirmedMs: Date.now() - startedAt,
151726
+ observedAwake
151727
+ }
151728
+ });
151729
+ this.onWakeTransition("confirmed-wake").catch(() => {});
151730
+ }
151452
151731
  return {
151453
151732
  awoke: true,
151454
151733
  durationMs: Date.now() - startedAt
@@ -151574,7 +151853,8 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
151574
151853
  commitSleepState(next, source) {
151575
151854
  if (!this.isBattery) return false;
151576
151855
  if (this.sleeping === next) return false;
151577
- if (source !== "hub-summary" && !this.acceptSleepingTransition(next)) return false;
151856
+ if (source === "confirmed-wake" && next === false) this.sleepStateChangedAt = Date.now();
151857
+ else if (source !== "hub-summary" && !this.acceptSleepingTransition(next)) return false;
151578
151858
  if (source === "hub-summary") {
151579
151859
  const sinceCommitMs = Date.now() - this.sleepStateChangedAt;
151580
151860
  if (next === true && this.sleepStateChangedAt > 0 && sinceCommitMs < ReolinkCamera.HUB_SUMMARY_SLEEP_GRACE_MS) {
@@ -152710,14 +152990,14 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
152710
152990
  * swallowed: the window is short, partial progress accumulates across
152711
152991
  * wakes because the slices persist.
152712
152992
  */
152713
- async warmCapSlicesOnWake() {
152993
+ async warmCapSlicesOnWake(opts) {
152714
152994
  const now = Date.now();
152715
152995
  let warmed = 0;
152716
152996
  let skipped = 0;
152717
152997
  for (const [capName, warm] of this.capWarmers) {
152718
152998
  const slice = this.runtimeState.getCapState(capName);
152719
152999
  const fetchedAt = typeof slice?.lastFetchedAt === "number" ? slice.lastFetchedAt : 0;
152720
- if (fetchedAt > 0 && now - fetchedAt < ReolinkCamera.WAKE_WARM_MAX_AGE_MS) {
153000
+ if (opts?.force !== true && fetchedAt > 0 && now - fetchedAt < ReolinkCamera.WAKE_WARM_MAX_AGE_MS) {
152721
153001
  skipped += 1;
152722
153002
  continue;
152723
153003
  }
@@ -152742,6 +153022,10 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
152742
153022
  total: this.capWarmers.size
152743
153023
  }
152744
153024
  });
153025
+ return {
153026
+ warmed,
153027
+ skipped
153028
+ };
152745
153029
  }
152746
153030
  /**
152747
153031
  * Ask the snapshot wrapper for a frame while the camera is up, so its
@@ -152807,7 +153091,6 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
152807
153091
  registerStreamParamsCap() {
152808
153092
  const channel = this.getChannel();
152809
153093
  const CAP_NAME = "stream-params";
152810
- const STALE_MS = 1e4;
152811
153094
  /** Round-trip `getEnc`, map into the cap status schema, write slice. */
152812
153095
  const refreshFromCamera = async () => {
152813
153096
  if (this.streamParamsRefreshInFlight) return this.streamParamsRefreshInFlight;
@@ -152840,13 +153123,13 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
152840
153123
  this.streamParamsRefreshInFlight = null;
152841
153124
  }
152842
153125
  };
152843
- this.refreshStreamParamsFromCamera = refreshFromCamera;
152844
153126
  const bridge = createRuntimeStateBridge({
152845
153127
  runtimeState: this.runtimeState,
152846
153128
  cap: streamParamsCapability,
152847
153129
  ownDeviceId: this.id,
152848
153130
  refresh: this.sleepGatedRefresh(CAP_NAME, refreshFromCamera),
152849
- staleMs: STALE_MS,
153131
+ staleMs: OPERATOR_WRITTEN_STALE_MS,
153132
+ staleRead: "serve-and-revalidate",
152850
153133
  empty: () => ({ lastFetchedAt: 0 })
152851
153134
  });
152852
153135
  const resolveOptions = async () => this.resolveCapOptions({
@@ -152931,7 +153214,6 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
152931
153214
  registerMotionZonesCap() {
152932
153215
  const channel = this.getChannel();
152933
153216
  const CAP_NAME = "motion-zones";
152934
- const STALE_MS = 1e4;
152935
153217
  /** Round-trip `getMotionAlarm`, map into the cap status schema, write slice. */
152936
153218
  const refreshFromCamera = async () => {
152937
153219
  if (this.motionZonesRefreshInFlight) return this.motionZonesRefreshInFlight;
@@ -152981,7 +153263,8 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
152981
153263
  cap: motionZonesCapability,
152982
153264
  ownDeviceId: this.id,
152983
153265
  refresh: this.sleepGatedRefresh(CAP_NAME, refreshFromCamera),
152984
- staleMs: STALE_MS,
153266
+ staleMs: OPERATOR_WRITTEN_STALE_MS,
153267
+ staleRead: "serve-and-revalidate",
152985
153268
  empty: () => ({
152986
153269
  enabled: false,
152987
153270
  sensitivity: 1,
@@ -153312,7 +153595,6 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
153312
153595
  registerDayNightCap() {
153313
153596
  const channel = this.getChannel();
153314
153597
  const CAP_NAME = "day-night";
153315
- const STALE_MS = 1e4;
153316
153598
  /** Round-trip `getVideoInput` + `getDayNightThreshold`, map into the
153317
153599
  * cap status schema, write slice. */
153318
153600
  const refreshFromCamera = async () => {
@@ -153355,7 +153637,8 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
153355
153637
  cap: dayNightCapability,
153356
153638
  ownDeviceId: this.id,
153357
153639
  refresh: this.sleepGatedRefresh(CAP_NAME, refreshFromCamera),
153358
- staleMs: STALE_MS,
153640
+ staleMs: OPERATOR_WRITTEN_STALE_MS,
153641
+ staleRead: "serve-and-revalidate",
153359
153642
  empty: () => ({
153360
153643
  mode: "auto",
153361
153644
  lastFetchedAt: 0
@@ -153448,7 +153731,6 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
153448
153731
  registerImageSettingsCap() {
153449
153732
  const channel = this.getChannel();
153450
153733
  const CAP_NAME = "image-settings";
153451
- const STALE_MS = 1e4;
153452
153734
  const SLIDER_MIN = 0;
153453
153735
  const SLIDER_MAX = 255;
153454
153736
  const NORMALIZED_RANGE = {
@@ -153492,7 +153774,8 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
153492
153774
  cap: imageSettingsCapability,
153493
153775
  ownDeviceId: this.id,
153494
153776
  refresh: this.sleepGatedRefresh(CAP_NAME, refreshFromCamera),
153495
- staleMs: STALE_MS,
153777
+ staleMs: OPERATOR_WRITTEN_STALE_MS,
153778
+ staleRead: "serve-and-revalidate",
153496
153779
  empty: () => ({ lastFetchedAt: 0 })
153497
153780
  }).getStatus,
153498
153781
  getOptions: async ({ deviceId }) => {
@@ -153809,7 +154092,6 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
153809
154092
  /** Stale-after window for served-from-cache reads. ~10s is short
153810
154093
  * enough that the UI's 5s poll always sees a fresh round-trip,
153811
154094
  * long enough that two consumers in the same tick fold into one. */
153812
- const STALE_MS = 1e4;
153813
154095
  const SUPPORTED_TARGET_TYPES = [
153814
154096
  {
153815
154097
  value: "people",
@@ -153875,7 +154157,8 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
153875
154157
  cap: ptzAutotrackCapability,
153876
154158
  ownDeviceId: this.id,
153877
154159
  refresh: this.sleepGatedRefresh(CAP_NAME, refreshFromCamera),
153878
- staleMs: STALE_MS,
154160
+ staleMs: OPERATOR_WRITTEN_STALE_MS,
154161
+ staleRead: "serve-and-revalidate",
153879
154162
  empty: () => ({
153880
154163
  enabled: false,
153881
154164
  lastChangedAt: 0,
@@ -155636,7 +155919,7 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
155636
155919
  id: "driver-refresh",
155637
155920
  tab: "general",
155638
155921
  title: "Camera data",
155639
- description: "Re-read the camera's current configuration (encoder, ports, time, image, motion). Use this after changing settings from the Reolink app so the form reflects the camera's live state.",
155922
+ description: "Go and ask the camera now. Every other read here is deliberately cheap about a battery camera — it serves what it already knows and refreshes quietly behind you, and never wakes a sleeping camera just because you opened this tab. This button is the exception: it wakes the camera, re-reads everything, and tells you what it got. Use it after changing settings from the Reolink app, or whenever you want to be sure the form is showing the camera and not a memory of it.",
155640
155923
  columns: 1,
155641
155924
  fields: [{
155642
155925
  type: "device-action-button",
@@ -155644,7 +155927,8 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
155644
155927
  label: "Refresh from camera",
155645
155928
  buttonLabel: "Refresh now",
155646
155929
  action: "refresh-settings",
155647
- variant: "default"
155930
+ variant: "default",
155931
+ successMessage: "Camera awake: {awake} · settings re-read: {snapshotRefreshed} · slices refreshed: {capsWarmed}/{capsTotal}"
155648
155932
  }]
155649
155933
  },
155650
155934
  {
@@ -156131,20 +156415,6 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
156131
156415
  sleeping: this.isBattery && this.sleeping,
156132
156416
  now: Date.now()
156133
156417
  }),
156134
- {
156135
- id: "privacy-mask",
156136
- tab: "image",
156137
- title: "Privacy mask",
156138
- description: "Master enable for the privacy zones configured on the camera (set up via the Reolink mobile app + camera UI). Pushed via `SetMask` (cmd_id 53). Disabling shows the full frame; enabling re-applies the persisted zones.",
156139
- columns: 1,
156140
- fields: [{
156141
- type: "boolean",
156142
- key: "privacyMaskEnabled",
156143
- label: "Privacy mask enabled",
156144
- default: cache?.maskSnapshot?.enabled ?? false,
156145
- style: "switch"
156146
- }]
156147
- },
156148
156418
  ...cache?.autoFocusSnapshot?.supported === true ? [{
156149
156419
  id: "auto-focus",
156150
156420
  tab: "image",
@@ -156499,7 +156769,6 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
156499
156769
  audioVolume: this.config.get("audioVolume") ?? 50,
156500
156770
  audioTalkAndReplyVolume: this.config.get("audioTalkAndReplyVolume") ?? 50,
156501
156771
  audioVisitorVolume: this.config.get("audioVisitorVolume") ?? 50,
156502
- privacyMaskEnabled: this.config.get("privacyMaskEnabled") ?? cache?.maskSnapshot?.enabled ?? false,
156503
156772
  audioNoiseLevel: this.config.get("audioNoiseLevel") ?? (cache?.audioNoiseSnapshot?.enabled ? cache.audioNoiseSnapshot?.level ?? 1 : 0),
156504
156773
  autoFocusEnabled: this.config.get("autoFocusEnabled") ?? cache?.autoFocusSnapshot?.enabled ?? true,
156505
156774
  pirReduceFalseAlarm: this.config.get("pirReduceFalseAlarm") ?? cache?.pirSnapshot?.reduceFalseAlarm ?? false,
@@ -156515,6 +156784,7 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
156515
156784
  }
156516
156785
  async applySettingsPatch(patch) {
156517
156786
  const { emailSmtpServer, emailSmtpPort, emailUserName, emailPassword, emailSsl, emailRecipient1, emailRecipient2, emailRecipient3, emailSendNickname, emailScheduleEnabled, osdChannelEnabled, osdChannelName, osdChannelPosition, osdTimeEnabled, osdTimePosition, osdWatermark, ...rest } = patch;
156787
+ if ((osdChannelEnabled !== void 0 || osdChannelName !== void 0 || osdChannelPosition !== void 0 || osdTimeEnabled !== void 0 || osdTimePosition !== void 0 || osdWatermark !== void 0 || emailSmtpServer !== void 0 || emailRecipient1 !== void 0) && !await this.wakeIfSleeping()) this.ctx.logger.warn("settings write: battery cam did not wake — writing anyway", { tags: { deviceId: this.id } });
156518
156788
  const emailFields = {
156519
156789
  emailSmtpServer,
156520
156790
  emailSmtpPort,
@@ -156825,13 +157095,6 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
156825
157095
  this.ctx.logger.warn("reolink setNtp push failed", { meta: { error: err instanceof Error ? err.message : String(err) } });
156826
157096
  }
156827
157097
  }
156828
- if ("privacyMaskEnabled" in patch) try {
156829
- const api = await this.ensureApi();
156830
- const v = typedPatch.privacyMaskEnabled;
156831
- if (typeof v === "boolean") await api.setMask(this.getChannel(), { enable: v });
156832
- } catch (err) {
156833
- this.ctx.logger.warn("mask push failed", { meta: { error: err instanceof Error ? err.message : String(err) } });
156834
- }
156835
157098
  if ("audioNoiseLevel" in patch) try {
156836
157099
  const api = await this.ensureApi();
156837
157100
  const v = typedPatch.audioNoiseLevel;
@@ -156889,15 +157152,40 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
156889
157152
  }
156890
157153
  }
156891
157154
  const changedSlices = slicesForPatch(patch);
156892
- if (changedSlices.size > 0) await this.refreshParentSettingsSnapshot(changedSlices).catch((err) => {
156893
- this.ctx.logger.info("reolink targeted settings refresh failed — snapshots left stale", {
156894
- tags: { deviceId: this.id },
156895
- meta: {
156896
- slices: [...changedSlices],
156897
- error: err instanceof Error ? err.message : String(err)
156898
- }
157155
+ if (changedSlices.size > 0) {
157156
+ await this.refreshParentSettingsSnapshot(changedSlices, "post-write").catch((err) => {
157157
+ this.ctx.logger.info("reolink targeted settings refresh failed — snapshots left stale", {
157158
+ tags: { deviceId: this.id },
157159
+ meta: {
157160
+ slices: [...changedSlices],
157161
+ error: err instanceof Error ? err.message : String(err)
157162
+ }
157163
+ });
156899
157164
  });
156900
- });
157165
+ this.assertOsdWriteLanded(patch);
157166
+ }
157167
+ }
157168
+ /**
157169
+ * Compare what the operator asked for with what the camera now reports, and
157170
+ * REFUSE to call a lost write a success.
157171
+ *
157172
+ * Nothing used to do this, so an applied write and a write the camera never
157173
+ * took were indistinguishable — to the code and to the operator, who was
157174
+ * told `{success: true}` for a change the camera discarded (3825,
157175
+ * 2026-09-19). Throwing is the right shape: the admin UI's in-flight
157176
+ * overlay hands the operator their own edit back, marked unsaved, instead
157177
+ * of quietly reverting the field to the old value.
157178
+ *
157179
+ * Silence is the pass. A slice we could not re-read at all is already
157180
+ * reported by the caller as "snapshots left stale" — absence of a reading
157181
+ * is not evidence of a lost write, and must never be treated as one (D393).
157182
+ */
157183
+ assertOsdWriteLanded(patch) {
157184
+ const asked = patch.osdTimePosition;
157185
+ if (typeof asked !== "string") return;
157186
+ const got = resolveOsdValues(this.config.get("deviceCache")?.osdSnapshot).osdTimePosition;
157187
+ if (typeof got !== "string" || got === asked) return;
157188
+ throw new Error(`the camera did not take the timestamp position: asked "${asked}", it reports "${got}"`);
156901
157189
  }
156902
157190
  /**
156903
157191
  * Optionally inject an already-logged-in Baichuan api — used by