@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.js CHANGED
@@ -5386,7 +5386,7 @@ var ZodIssueCode = {
5386
5386
  var ZodFirstPartyTypeKind;
5387
5387
  ZodFirstPartyTypeKind || (ZodFirstPartyTypeKind = {});
5388
5388
  //#endregion
5389
- //#region ../types/dist/sleep-Bs3xPtSh.mjs
5389
+ //#region ../types/dist/sleep-CiR4z7r9.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).
@@ -6528,6 +6528,17 @@ var CamProfileSchema = _enum([
6528
6528
  "mid",
6529
6529
  "low"
6530
6530
  ]);
6531
+ /**
6532
+ * Which rung a per-camera consumer listens on: an explicit profile, or `auto`.
6533
+ *
6534
+ * `auto` is a REAL value, not an absent one — it means "resolve it by the rule
6535
+ * that consumer owns". It lives beside `CamProfile` because a rung is the only
6536
+ * operator-facing way to name a stream: a cam-stream id is broker-internal and
6537
+ * moves when a slot is reassigned, so a stored id silently stops meaning what
6538
+ * the operator picked. Today's one user is the audio analyser, whose rule is
6539
+ * "the lowest AVAILABLE native rung" (D537).
6540
+ */
6541
+ var AudioStreamPickSchema = union([CamProfileSchema, literal("auto")]);
6531
6542
  var CamStreamKindSchema = _enum([
6532
6543
  "pull-rtsp",
6533
6544
  "pull-rtmp",
@@ -7794,6 +7805,7 @@ var CameraSwitchIdSchema = _enum([
7794
7805
  "device-audio",
7795
7806
  "broker-audio",
7796
7807
  "audio-analysis",
7808
+ "event-saving",
7797
7809
  "recording",
7798
7810
  "notifications"
7799
7811
  ]);
@@ -8268,11 +8280,31 @@ var RecordingBandTriggersSchema = RecordingTriggersSchema;
8268
8280
  * band engine's `applies` rule). `start`/`end` are `HH:MM`; an `end <= start`
8269
8281
  * span wraps past midnight (handled by the band engine).
8270
8282
  */
8283
+ /**
8284
+ * Whether a band's footage carries sound.
8285
+ *
8286
+ * The recorder has always attached with `audio: 'aac'`, so every band of every
8287
+ * camera recorded sound whether anyone wanted it or not. The broker already
8288
+ * serves the other answer — `audio: 'none'` takes the video-only `/muted`
8289
+ * restream — so this was a choice the product could always have offered.
8290
+ *
8291
+ * It belongs on the BAND rather than the camera because one camera can want
8292
+ * both: silent across the shop floor during opening hours, sound overnight.
8293
+ * And it applies to BOTH modes — an events band firing on motion has the same
8294
+ * question to answer as a continuous one.
8295
+ */
8296
+ var RecordingBandAudioSchema = _enum(["on", "muted"]);
8271
8297
  var RecordingBandSchema = object({
8272
8298
  days: array(RecordingWeekdaySchema),
8273
8299
  start: string().regex(HHMM),
8274
8300
  end: string().regex(HHMM),
8275
8301
  mode: RecordingBandModeSchema,
8302
+ /**
8303
+ * Sound, or silence. OPTIONAL and absent-means-`on`: every recording
8304
+ * written before this existed carried sound, and a band whose key is
8305
+ * missing must keep meaning what it meant. See {@link RecordingBandAudioSchema}.
8306
+ */
8307
+ audio: RecordingBandAudioSchema.optional(),
8276
8308
  triggers: RecordingBandTriggersSchema.optional(),
8277
8309
  /**
8278
8310
  * RETIRED (D381). A band no longer carries a pre-buffer of its own.
@@ -20894,6 +20926,7 @@ var MediaFileKindEnum = _enum([
20894
20926
  "keyFrameSmall",
20895
20927
  "thumbnailSmall",
20896
20928
  "confirmationCrop",
20929
+ "debugCrop",
20897
20930
  "sceneFrame"
20898
20931
  ]);
20899
20932
  /**
@@ -22558,6 +22591,18 @@ var RunnerCameraConfigSchema = object({
22558
22591
  motionStreamId: string(),
22559
22592
  detectionStreamId: string(),
22560
22593
  /**
22594
+ * Which rung this camera's audio analysis listens on — `auto` (the lowest
22595
+ * AVAILABLE native rung) or an explicit one.
22596
+ *
22597
+ * Not consumed by the runner: carried here so it travels with the camera's
22598
+ * config the way `audioMotionWindowMs` does, and so the orchestrator's
22599
+ * watchdog resolves the same stream the audio subscription did. OPTIONAL
22600
+ * with no default — absent means `auto`, which is what every camera written
22601
+ * before this field had, and an absent optional adds nothing to the
22602
+ * generated cap-input defaults (D537).
22603
+ */
22604
+ audioStreamPick: AudioStreamPickSchema.optional(),
22605
+ /**
22561
22606
  * Motion sources that can activate the pipeline. At least one must
22562
22607
  * be present. Multiple sources OR-ed: any one firing arms the
22563
22608
  * cooldown timer and transitions watching → active. The same
@@ -25153,6 +25198,104 @@ var WebrtcStreamChoiceSchema = object({
25153
25198
  /** Bitrate in kbps (null for adaptive). */
25154
25199
  bitrateKbps: number().nullable()
25155
25200
  });
25201
+ /**
25202
+ * The last move the adaptive ladder made on this session, and the numbers it
25203
+ * rested on. Mirrors `LadderDecision` in the stream-broker's
25204
+ * `adaptive-controller.ts` — the log line's evidence, made readable.
25205
+ *
25206
+ * Nullability is load-bearing: `lossRatio: null` means the loss tracker had
25207
+ * no window to report, which is NOT a link with zero loss.
25208
+ */
25209
+ var WebrtcLadderDecisionSchema = object({
25210
+ /** `loss` — RTCP / client-reported packet loss; `stall` — the picture
25211
+ * stopped moving; `healthy` — a promotion. */
25212
+ cause: _enum([
25213
+ "loss",
25214
+ "stall",
25215
+ "healthy"
25216
+ ]),
25217
+ /** The rung the ladder moved TO. Null for a move that changed no rung
25218
+ * (an MTU reduction, a forced transcode-down at the bottom). */
25219
+ tier: CamProfileSchema.nullable(),
25220
+ /** Whether that move forced the egress transcode-down rung. */
25221
+ forcedTranscodeDown: boolean(),
25222
+ /** Loss ratio over the tracker's window at the moment of the decision. */
25223
+ lossRatio: number().nullable(),
25224
+ /** The client's reported downlink at that moment, in kbps. */
25225
+ clientKbps: number().nullable(),
25226
+ /** How long the stall detector had been seeing nothing arrive. */
25227
+ stalledForMs: number().nullable(),
25228
+ /** Whether the broker WAS sending while that was measured — the half that
25229
+ * separates "the camera stopped" from "the link stopped". */
25230
+ serverSending: boolean().nullable(),
25231
+ /** Epoch ms. The overlay prints its age; a decision from 40 minutes ago
25232
+ * must not read like one from this second. */
25233
+ at: number()
25234
+ });
25235
+ /** Per-tier count of MEASURED downgrades this viewer session has suffered. */
25236
+ var WebrtcTierFailureSchema = object({
25237
+ tier: CamProfileSchema,
25238
+ failures: number().int().nonnegative()
25239
+ });
25240
+ /**
25241
+ * What the SERVER decided for one WebRTC session — the half the client cannot
25242
+ * measure. See `getSessionDebug`.
25243
+ */
25244
+ var WebrtcSessionDebugSchema = object({
25245
+ sessionId: string(),
25246
+ /** Whether the session still exists here. Everything below is null when
25247
+ * it does not — a dead session has no measurements, not zero ones. */
25248
+ alive: boolean(),
25249
+ /** Epoch ms this snapshot was taken, so the overlay can show its age. */
25250
+ observedAt: number(),
25251
+ /** The broker actually feeding this session (`<deviceId>/<camStreamId>`). */
25252
+ brokerId: string().nullable(),
25253
+ camStreamId: string().nullable(),
25254
+ /** `derived` = this hub BUILT the rung being served (a transcode/scale of
25255
+ * another stream); `native` = the camera's own encode. */
25256
+ sourceKind: _enum([
25257
+ "native",
25258
+ "derived",
25259
+ "unknown"
25260
+ ]),
25261
+ /** Whether the ladder is running at all (an `adaptive` target). */
25262
+ adaptive: boolean(),
25263
+ /** The rung `auto` ACTUALLY resolved to right now. Null when the broker
25264
+ * is bound to no profile slot (a manually-activated stream). */
25265
+ servedTier: CamProfileSchema.nullable(),
25266
+ /** Which node ingests the source. Null when unresolved. */
25267
+ ingestNodeId: string().nullable(),
25268
+ /** Whether that node is a remote one (the media crosses a node boundary
25269
+ * before it reaches this session). Null when unresolved. */
25270
+ ingestIsRemote: boolean().nullable(),
25271
+ /** Codec off the source, and the one negotiated with this client. */
25272
+ sourceCodec: string().nullable(),
25273
+ negotiatedCodec: string().nullable(),
25274
+ /** Whether the EGRESS leg re-encodes for this client or forwards the
25275
+ * source bytes untouched. */
25276
+ egressLeg: _enum([
25277
+ "copy",
25278
+ "transcode",
25279
+ "unknown"
25280
+ ]),
25281
+ /** What the broker itself reports about the rung it is serving. */
25282
+ brokerStatus: string().nullable(),
25283
+ brokerCodec: string().nullable(),
25284
+ brokerBitrateKbps: number().nullable(),
25285
+ brokerInputFps: number().nullable(),
25286
+ brokerUptimeMs: number().nullable(),
25287
+ /** RTP packets this session has sent — the server's own "am I sending?". */
25288
+ rtpPacketsSent: number().nullable(),
25289
+ /** D42: the track may be carrying the recorded feeder, not live. The ladder
25290
+ * is suspended there, so a frozen picture in `recorded` mode means
25291
+ * something entirely different. */
25292
+ trackMode: _enum(["live", "recorded"]).nullable(),
25293
+ lastDecision: WebrtcLadderDecisionSchema.nullable(),
25294
+ tierFailures: array(WebrtcTierFailureSchema),
25295
+ /** Successful promotions in this viewer session (drives the progressive
25296
+ * healthy timer). Null when no ladder is running. */
25297
+ upgradeCount: number().nullable()
25298
+ });
25156
25299
  DeviceType.Camera, method(object({ deviceId: number().int().nonnegative() }), array(WebrtcStreamChoiceSchema)), method(object({
25157
25300
  deviceId: number().int().nonnegative(),
25158
25301
  target: WebrtcStreamTargetSchema,
@@ -25317,7 +25460,10 @@ DeviceType.Camera, method(object({ deviceId: number().int().nonnegative() }), ar
25317
25460
  * the same answer as "it ended": either way nothing is holding it up.
25318
25461
  */
25319
25462
  alive: boolean()
25320
- }));
25463
+ })), method(object({
25464
+ deviceId: number().int().nonnegative(),
25465
+ sessionId: string()
25466
+ }), WebrtcSessionDebugSchema, { kind: "query" });
25321
25467
  object({
25322
25468
  /** All accessory children of the parent. */
25323
25469
  childDeviceIds: array(number()).readonly(),
@@ -29652,11 +29798,15 @@ var motionTriggerCapability = {
29652
29798
  *
29653
29799
  * Adding a fifth name to that list would have been the wrong fix twice over:
29654
29800
  * that page is per-camera DETECTION tuning, and a grid's geometry belongs
29655
- * beside PTZ and motion zones on the camera itself — which is exactly where the
29656
- * framework already puts a widget, when the widget is declared on a CAPABILITY.
29657
- * `deviceConfig.ui` is the mechanism (`motion-zones`, `ptz`, `ptz-autotrack`
29658
- * all use it): the framework emits the structural section and the widget
29659
- * self-persists through the cap's own mutations.
29801
+ * beside PTZ and motion zones on the camera itself. The device page is
29802
+ * BINDING-driven (D12), so the way in is a capability bound to the device —
29803
+ * and this cap carries its section the way `recording` does, by RETURNING it
29804
+ * from `getDeviceSettingsContribution`.
29805
+ *
29806
+ * Seven other widgets are still declared the other way, through a
29807
+ * `deviceConfig.ui` block the framework derives a section from. That route
29808
+ * gives the addon no say in where its own panel lands and no way to decline
29809
+ * for a device the panel does not suit, which is why this one does not use it.
29660
29810
  *
29661
29811
  * ## Why one addon may implement it
29662
29812
  *
@@ -29712,16 +29862,37 @@ var GridProfileOfferSchema = object({
29712
29862
  ]),
29713
29863
  offered: boolean(),
29714
29864
  /** Sources that cannot serve it. Empty when it is offered, or when there are no cells. */
29715
- missingSources: array(number().int().positive())
29865
+ missingSources: array(number().int().positive()),
29866
+ /**
29867
+ * The canvas this profile composes onto, `WxH`, or empty when it is not
29868
+ * offered. DERIVED from the cells and the sources' own size at this profile —
29869
+ * it is reported because nothing else in the system would ever say what the
29870
+ * grid came out as, and because it is the number an operator would otherwise
29871
+ * expect to type.
29872
+ */
29873
+ canvas: string(),
29874
+ /**
29875
+ * Whether this profile is PUBLISHED, of the ones the grid could serve.
29876
+ *
29877
+ * A grid's `high` is composed of its sources' `high`, so on a 4K fleet it is
29878
+ * a 4K canvas built from 4K decodes — something to opt into, not something a
29879
+ * viewer's adaptive should be handed by climbing to the top rung it can see.
29880
+ * Default is `mid` + `low`.
29881
+ */
29882
+ published: boolean()
29716
29883
  });
29717
29884
  var GridLayoutViewSchema = object({
29718
29885
  /** The persisted grid row this camera was declared from. */
29719
29886
  instanceId: string(),
29720
29887
  deviceId: number().int().nonnegative(),
29721
29888
  name: string(),
29722
- /** The canvas the operator laid out — this grid's `high`. mid/low are scaled from it. */
29723
- width: number().int(),
29724
- height: number().int(),
29889
+ /**
29890
+ * NO canvas size. A grid's resolution is not authored: each profile derives
29891
+ * its own from the cells and its sources' dimensions. The two numbers that
29892
+ * used to be here were a text field that silently decided both how much the
29893
+ * composite cost and how sharp it was — see `profiles[].canvas` for what it
29894
+ * came out as.
29895
+ */
29725
29896
  fps: number().int(),
29726
29897
  cells: array(GridLayoutCellSchema),
29727
29898
  /** What the catalog will publish, and what it refuses to. Read-only. */
@@ -29730,9 +29901,13 @@ var GridLayoutViewSchema = object({
29730
29901
  var GridLayoutPatchSchema = object({
29731
29902
  deviceId: number().int().nonnegative(),
29732
29903
  name: string().min(1).max(160).optional(),
29733
- width: number().int().min(160).max(7680).optional(),
29734
- height: number().int().min(120).max(4320).optional(),
29735
29904
  fps: number().int().min(1).max(60).optional(),
29905
+ /** Which profiles to publish. See `GridProfileOffer.published`. */
29906
+ publishedProfiles: array(_enum([
29907
+ "high",
29908
+ "mid",
29909
+ "low"
29910
+ ])).max(3).optional(),
29736
29911
  /**
29737
29912
  * The whole cell list at once. A per-cell patch would need an ordering the
29738
29913
  * editor does not have, and a half-applied layout is a picture nobody asked
@@ -32147,6 +32322,25 @@ var ptzAutotrackCapability = {
32147
32322
  scope: "device",
32148
32323
  deviceNative: true,
32149
32324
  mode: "singleton",
32325
+ /**
32326
+ * Two kinds of provider behind one name, the same shape `snapshot` and
32327
+ * `doorbell` use:
32328
+ *
32329
+ * - **Native**: the camera's own firmware autotrack, registered per device by
32330
+ * the driver when the probe says it exists (Reolink: yes; Hikvision:
32331
+ * model-dependent).
32332
+ * - **Wrapper**: the emulated implementation (`@camstack/system`'s `autotrack` builtin),
32333
+ * which follows a subject with `ptz.move` pulses driven by the detection
32334
+ * pipeline. `defaultActive: false` — the operator binds it per camera in
32335
+ * the bindings UI, exactly as `virtual-doorbell` is bound.
32336
+ *
32337
+ * The binding chooses WHICH implementation answers; `setEnabled` stays the
32338
+ * one switch for the function either way. A camera never has both driving its
32339
+ * head, which is the whole reason this is not a second cap with a second
32340
+ * toggle (D62, D520).
32341
+ */
32342
+ kind: "wrapper",
32343
+ defaultActive: false,
32150
32344
  deviceTypes: [DeviceType.Camera],
32151
32345
  deviceConfig: { ui: {
32152
32346
  kind: "widget",
@@ -43518,6 +43712,12 @@ Object.freeze({
43518
43712
  addonId: null,
43519
43713
  access: "view"
43520
43714
  },
43715
+ "webrtcSession.getSessionDebug": {
43716
+ capName: "webrtc-session",
43717
+ capScope: "device",
43718
+ addonId: null,
43719
+ access: "view"
43720
+ },
43521
43721
  "webrtcSession.getSessionState": {
43522
43722
  capName: "webrtc-session",
43523
43723
  capScope: "device",
@@ -45501,6 +45701,11 @@ Object.freeze({
45501
45701
  form: "single",
45502
45702
  optional: false
45503
45703
  }],
45704
+ "webrtcSession.getSessionDebug": [{
45705
+ name: "deviceId",
45706
+ form: "single",
45707
+ optional: false
45708
+ }],
45504
45709
  "webrtcSession.getSessionState": [{
45505
45710
  name: "deviceId",
45506
45711
  form: "single",
@@ -75186,7 +75391,7 @@ var require_lz4 = /* @__PURE__ */ require_chunk.__commonJSMin(((exports) => {
75186
75391
  };
75187
75392
  }));
75188
75393
  //#endregion
75189
- //#region node_modules/@apocaliss92/nodelink-js/dist/chunk-CE77DTET.js
75394
+ //#region node_modules/@apocaliss92/nodelink-js/dist/chunk-7JMYBUEB.js
75190
75395
  var import_fxp = require_fxp();
75191
75396
  var import_lz4 = /* @__PURE__ */ require_chunk.__toESM(require_lz4(), 1);
75192
75397
  function encodeHeader(h) {
@@ -83646,10 +83851,14 @@ function applyOsdDatetimePatch(rawXml, patch) {
83646
83851
  return xml;
83647
83852
  }
83648
83853
  var OSD_POSITION_MAX = 65536;
83854
+ var OSD_POSITION_START_WRITE = 1;
83855
+ var OSD_POSITION_CENTER = 65537;
83649
83856
  var OSD_CORNERS = [
83650
83857
  "top-left",
83858
+ "top-center",
83651
83859
  "top-right",
83652
83860
  "bottom-left",
83861
+ "bottom-center",
83653
83862
  "bottom-right"
83654
83863
  ];
83655
83864
  function isStartEdge(value) {
@@ -83661,7 +83870,7 @@ function isEndEdge(value) {
83661
83870
  function readOsdPosition(x, y) {
83662
83871
  if (typeof x !== "number" || typeof y !== "number") return { kind: "unknown" };
83663
83872
  if (!Number.isFinite(x) || !Number.isFinite(y)) return { kind: "unknown" };
83664
- const horizontal = isStartEdge(x) ? "left" : isEndEdge(x) ? "right" : null;
83873
+ const horizontal = x === OSD_POSITION_CENTER ? "center" : isStartEdge(x) ? "left" : isEndEdge(x) ? "right" : null;
83665
83874
  const vertical = isStartEdge(y) ? "top" : isEndEdge(y) ? "bottom" : null;
83666
83875
  if (horizontal === null || vertical === null) return {
83667
83876
  kind: "custom",
@@ -83675,8 +83884,8 @@ function readOsdPosition(x, y) {
83675
83884
  }
83676
83885
  function coordsForOsdCorner(corner) {
83677
83886
  return {
83678
- x: corner.endsWith("-right") ? OSD_POSITION_MAX : 0,
83679
- y: corner.startsWith("bottom-") ? OSD_POSITION_MAX : 0
83887
+ x: corner.endsWith("-center") ? OSD_POSITION_CENTER : corner.endsWith("-right") ? OSD_POSITION_MAX : OSD_POSITION_START_WRITE,
83888
+ y: corner.startsWith("bottom-") ? OSD_POSITION_MAX : OSD_POSITION_START_WRITE
83680
83889
  };
83681
83890
  }
83682
83891
  function isOsdCorner(value) {
@@ -83687,8 +83896,10 @@ function formatOsdCoords(x, y) {
83687
83896
  }
83688
83897
  var OSD_CORNER_LABELS = {
83689
83898
  "top-left": "Top left",
83899
+ "top-center": "Top centre",
83690
83900
  "top-right": "Top right",
83691
83901
  "bottom-left": "Bottom left",
83902
+ "bottom-center": "Bottom centre",
83692
83903
  "bottom-right": "Bottom right"
83693
83904
  };
83694
83905
  function wireFloat(value) {
@@ -88215,8 +88426,12 @@ var ReolinkBaichuanApi = class _ReolinkBaichuanApi {
88215
88426
  /** Generic Baichuan cmd_id call, returns XML (if any). */
88216
88427
  async sendXml(params, retry = 3) {
88217
88428
  if (!this.client.loggedIn) await this.client.login();
88218
- const frame = await this.client.sendFrame(params);
88219
- if (frame.header.responseCode === 400) return await this.handleSendXml400(params, frame, retry);
88429
+ const addressed = params.channel != null && params.extensionXml == null ? {
88430
+ ...params,
88431
+ extensionXml: buildChannelExtensionXml(params.channel)
88432
+ } : params;
88433
+ const frame = await this.client.sendFrame(addressed);
88434
+ if (frame.header.responseCode === 400) return await this.handleSendXml400(addressed, frame, retry);
88220
88435
  if (frame.body.length === 0) return "";
88221
88436
  return this.client.tryDecryptXml(frame.body, frame.header.channelId, this.client.enc);
88222
88437
  }
@@ -94517,6 +94732,7 @@ ${xml}`);
94517
94732
  await this.sendXml({
94518
94733
  cmdId: 45,
94519
94734
  channel: ch,
94735
+ extensionXml: buildChannelExtensionXml(ch),
94520
94736
  payloadXml: ensureXmlHeader(xml),
94521
94737
  ...timeoutOpts
94522
94738
  });
@@ -145551,6 +145767,7 @@ var RAW_READ_CATALOG = {
145551
145767
  kind: "deviceCache",
145552
145768
  snapshotKey: "autoFocusSnapshot"
145553
145769
  },
145770
+ needs: "ptz",
145554
145771
  invoke: (api, channel) => api.getAutoFocus(channel, { timeoutMs: 1500 })
145555
145772
  },
145556
145773
  netPort: {
@@ -145747,6 +145964,7 @@ var RawReadResultSchema = discriminatedUnion("ok", [object({
145747
145964
  slice: RawReadSliceSchema,
145748
145965
  reason: _enum([
145749
145966
  "sleeping",
145967
+ "unsupported",
145750
145968
  "login-failed",
145751
145969
  "read-failed"
145752
145970
  ]),
@@ -145790,6 +146008,20 @@ function toPlainJson(value) {
145790
146008
  async function performRawRead(slice, deps) {
145791
146009
  const entry = RAW_READ_CATALOG[slice];
145792
146010
  const believed = believedState(deps, entry);
146011
+ if (entry.needs === "ptz" && !deps.hasPtz) {
146012
+ deps.logger.info("reolink raw read refused — the camera has no PTZ head", {
146013
+ tags: { deviceId: deps.deviceId },
146014
+ meta: { slice }
146015
+ });
146016
+ return {
146017
+ ok: false,
146018
+ deviceId: deps.deviceId,
146019
+ slice,
146020
+ reason: "unsupported",
146021
+ 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.`,
146022
+ believed
146023
+ };
146024
+ }
145793
146025
  const servedWithoutTouchingCamera = deps.hubChild && entry.servedByParentHub === true;
145794
146026
  if (deps.sleeping && !servedWithoutTouchingCamera) {
145795
146027
  deps.logger.info("reolink raw read refused — battery cam is sleeping", {
@@ -149453,7 +149685,6 @@ var reolinkCameraSchema = object({
149453
149685
  * read via 52). Toggles the `<Shelter><enable>` flag. Zone
149454
149686
  * editing is a separate flow not exposed by this driver.
149455
149687
  */
149456
- privacyMaskEnabled: boolean().optional(),
149457
149688
  /**
149458
149689
  * Audio noise reduction level — pushed via `setAudioNoise`
149459
149690
  * (cmd_id 440, read via 439). 0 disables; 1..3 maps to
@@ -150287,7 +150518,6 @@ function slicesForPatch(patch) {
150287
150518
  if (has("motionEnabled", "motionSensitivity")) out.add("motion");
150288
150519
  if (Object.keys(patch).some((k) => /^ai(Person|Vehicle|Animal|Face|Package)/.test(k))) out.add("ai");
150289
150520
  if (has("gop")) out.add("enc");
150290
- if (has("privacyMaskEnabled")) out.add("mask");
150291
150521
  if (has("audioNoiseLevel")) out.add("audioNoise");
150292
150522
  if (has("autoFocusEnabled")) out.add("autofocus");
150293
150523
  if (has("netHttpEnable", "netHttpPort", "netHttpsEnable", "netHttpsPort", "netRtspEnable", "netRtspPort", "netRtmpEnable", "netRtmpPort", "netOnvifEnable", "netOnvifPort")) out.add("netPort");
@@ -150299,6 +150529,20 @@ function slicesForPatch(patch) {
150299
150529
  if (has("timeZoneId")) out.add("systemGeneral");
150300
150530
  return out;
150301
150531
  }
150532
+ /**
150533
+ * What the operator's "Refresh from camera" actually managed.
150534
+ *
150535
+ * Returned rather than logged, because the button is the one place a person
150536
+ * asks "is what I am looking at true right now" — and an answer of `void`
150537
+ * cannot distinguish a full re-read from a camera that never woke.
150538
+ */
150539
+ var RefreshSettingsResultSchema = object({
150540
+ /** False when a battery camera did not come up — everything below is then stale. */
150541
+ awake: boolean(),
150542
+ snapshotRefreshed: boolean(),
150543
+ capsWarmed: number(),
150544
+ capsTotal: number()
150545
+ });
150302
150546
  var ReolinkCamera = class ReolinkCamera extends BaseDevice {
150303
150547
  type = DeviceType.Camera;
150304
150548
  /**
@@ -150768,9 +151012,12 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
150768
151012
  * `setVideoInput`); motion is writable via `setMotionAlarm` so its
150769
151013
  * snapshot also drives the dispatch's known-good baseline.
150770
151014
  */
150771
- async refreshParentSettingsSnapshot(slices) {
150772
- if (this.isBattery && this.sleeping) {
150773
- this.ctx.logger.info("refreshParentSettingsSnapshot skipped — battery cam is sleeping", { tags: { deviceId: this.id } });
151015
+ async refreshParentSettingsSnapshot(slices, reason = "background") {
151016
+ if (this.isBattery && this.sleeping && reason !== "post-write") {
151017
+ this.ctx.logger.info("refreshParentSettingsSnapshot skipped — battery cam is sleeping", { tags: {
151018
+ deviceId: this.id,
151019
+ reason
151020
+ } });
150774
151021
  return;
150775
151022
  }
150776
151023
  let api;
@@ -151015,6 +151262,7 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
151015
151262
  channel: this.getChannel(),
151016
151263
  sleeping: this.isBattery && this.sleeping,
151017
151264
  hubChild: this.isHubChild(),
151265
+ hasPtz: this.getProbeFlags().hasPtz === true,
151018
151266
  getApi: () => this.ensureApi(),
151019
151267
  cache: this.config.get("deviceCache"),
151020
151268
  batteryState: this.runtimeState.getCapState("battery"),
@@ -151108,6 +151356,10 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
151108
151356
  });
151109
151357
  await sleep$2(1500);
151110
151358
  })(), new Promise((_, rej) => setTimeout(() => rej(/* @__PURE__ */ new Error("wakeIfSleeping timeout")), timeoutMs))]);
151359
+ if (this.commitSleepState(false, "confirmed-wake")) {
151360
+ this.ctx.logger.info("proactive action: camera is up — committing the mirror", { tags: { deviceId: this.id } });
151361
+ this.onWakeTransition("confirmed-wake").catch(() => {});
151362
+ }
151111
151363
  return true;
151112
151364
  } catch (err) {
151113
151365
  this.ctx.logger.warn("proactive action: wake did not complete in time", {
@@ -151279,8 +151531,26 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
151279
151531
  this.registerDeviceAction("auto-configure-email-push", deviceCustomAction(unknown(), _void(), { kind: "mutation" }), async () => {
151280
151532
  await this.autoConfigureEmailPush();
151281
151533
  });
151282
- this.registerDeviceAction("refresh-settings", deviceCustomAction(unknown(), _void(), { kind: "mutation" }), async () => {
151283
- await Promise.allSettled([this.refreshParentSettingsSnapshot(), this.refreshStreamParamsFromCamera?.() ?? Promise.resolve()]);
151534
+ this.registerDeviceAction("refresh-settings", deviceCustomAction(unknown(), RefreshSettingsResultSchema, { kind: "mutation" }), async () => {
151535
+ const awake = await this.wakeIfSleeping();
151536
+ const [snapshot, warm] = await Promise.allSettled([this.refreshParentSettingsSnapshot(void 0, "operator"), this.warmCapSlicesOnWake({ force: true })]);
151537
+ const warmed = warm.status === "fulfilled" ? warm.value.warmed : 0;
151538
+ const capsTotal = this.capWarmers.size;
151539
+ this.ctx.logger.info("refresh from camera: operator rehydrate", {
151540
+ tags: { deviceId: this.id },
151541
+ meta: {
151542
+ awake,
151543
+ snapshot: snapshot.status,
151544
+ warmed,
151545
+ capsTotal
151546
+ }
151547
+ });
151548
+ return {
151549
+ awake,
151550
+ snapshotRefreshed: snapshot.status === "fulfilled",
151551
+ capsWarmed: warmed,
151552
+ capsTotal
151553
+ };
151284
151554
  });
151285
151555
  this.registerDeviceAction("refresh-sessions", deviceCustomAction(unknown(), _void(), { kind: "mutation" }), async () => {
151286
151556
  await this.refreshSessionsSnapshot();
@@ -151320,7 +151590,6 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
151320
151590
  * registration so the general "Refresh from camera" button can warm
151321
151591
  * the `stream-params` runtime-state slice (the editable Stream
151322
151592
  * section's current values read from it). */
151323
- refreshStreamParamsFromCamera = null;
151324
151593
  /** Single-flight guard for the motion-zones cap refresh. */
151325
151594
  motionZonesRefreshInFlight = null;
151326
151595
  /** Single-flight guard for the privacy-mask cap refresh. */
@@ -151454,6 +151723,16 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
151454
151723
  };
151455
151724
  }
151456
151725
  if (confirmed && !observedAwake && parent !== null) this.watchHubChildAwake(parent).catch(() => {});
151726
+ if (this.commitSleepState(false, "confirmed-wake")) {
151727
+ this.ctx.logger.info("battery wakeForStream: answering awake — committing the mirror", {
151728
+ tags: { deviceId: this.id },
151729
+ meta: {
151730
+ confirmedMs: Date.now() - startedAt,
151731
+ observedAwake
151732
+ }
151733
+ });
151734
+ this.onWakeTransition("confirmed-wake").catch(() => {});
151735
+ }
151457
151736
  return {
151458
151737
  awoke: true,
151459
151738
  durationMs: Date.now() - startedAt
@@ -151579,7 +151858,8 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
151579
151858
  commitSleepState(next, source) {
151580
151859
  if (!this.isBattery) return false;
151581
151860
  if (this.sleeping === next) return false;
151582
- if (source !== "hub-summary" && !this.acceptSleepingTransition(next)) return false;
151861
+ if (source === "confirmed-wake" && next === false) this.sleepStateChangedAt = Date.now();
151862
+ else if (source !== "hub-summary" && !this.acceptSleepingTransition(next)) return false;
151583
151863
  if (source === "hub-summary") {
151584
151864
  const sinceCommitMs = Date.now() - this.sleepStateChangedAt;
151585
151865
  if (next === true && this.sleepStateChangedAt > 0 && sinceCommitMs < ReolinkCamera.HUB_SUMMARY_SLEEP_GRACE_MS) {
@@ -152715,14 +152995,14 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
152715
152995
  * swallowed: the window is short, partial progress accumulates across
152716
152996
  * wakes because the slices persist.
152717
152997
  */
152718
- async warmCapSlicesOnWake() {
152998
+ async warmCapSlicesOnWake(opts) {
152719
152999
  const now = Date.now();
152720
153000
  let warmed = 0;
152721
153001
  let skipped = 0;
152722
153002
  for (const [capName, warm] of this.capWarmers) {
152723
153003
  const slice = this.runtimeState.getCapState(capName);
152724
153004
  const fetchedAt = typeof slice?.lastFetchedAt === "number" ? slice.lastFetchedAt : 0;
152725
- if (fetchedAt > 0 && now - fetchedAt < ReolinkCamera.WAKE_WARM_MAX_AGE_MS) {
153005
+ if (opts?.force !== true && fetchedAt > 0 && now - fetchedAt < ReolinkCamera.WAKE_WARM_MAX_AGE_MS) {
152726
153006
  skipped += 1;
152727
153007
  continue;
152728
153008
  }
@@ -152747,6 +153027,10 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
152747
153027
  total: this.capWarmers.size
152748
153028
  }
152749
153029
  });
153030
+ return {
153031
+ warmed,
153032
+ skipped
153033
+ };
152750
153034
  }
152751
153035
  /**
152752
153036
  * Ask the snapshot wrapper for a frame while the camera is up, so its
@@ -152812,7 +153096,6 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
152812
153096
  registerStreamParamsCap() {
152813
153097
  const channel = this.getChannel();
152814
153098
  const CAP_NAME = "stream-params";
152815
- const STALE_MS = 1e4;
152816
153099
  /** Round-trip `getEnc`, map into the cap status schema, write slice. */
152817
153100
  const refreshFromCamera = async () => {
152818
153101
  if (this.streamParamsRefreshInFlight) return this.streamParamsRefreshInFlight;
@@ -152845,13 +153128,13 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
152845
153128
  this.streamParamsRefreshInFlight = null;
152846
153129
  }
152847
153130
  };
152848
- this.refreshStreamParamsFromCamera = refreshFromCamera;
152849
153131
  const bridge = createRuntimeStateBridge({
152850
153132
  runtimeState: this.runtimeState,
152851
153133
  cap: streamParamsCapability,
152852
153134
  ownDeviceId: this.id,
152853
153135
  refresh: this.sleepGatedRefresh(CAP_NAME, refreshFromCamera),
152854
- staleMs: STALE_MS,
153136
+ staleMs: OPERATOR_WRITTEN_STALE_MS,
153137
+ staleRead: "serve-and-revalidate",
152855
153138
  empty: () => ({ lastFetchedAt: 0 })
152856
153139
  });
152857
153140
  const resolveOptions = async () => this.resolveCapOptions({
@@ -152936,7 +153219,6 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
152936
153219
  registerMotionZonesCap() {
152937
153220
  const channel = this.getChannel();
152938
153221
  const CAP_NAME = "motion-zones";
152939
- const STALE_MS = 1e4;
152940
153222
  /** Round-trip `getMotionAlarm`, map into the cap status schema, write slice. */
152941
153223
  const refreshFromCamera = async () => {
152942
153224
  if (this.motionZonesRefreshInFlight) return this.motionZonesRefreshInFlight;
@@ -152986,7 +153268,8 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
152986
153268
  cap: motionZonesCapability,
152987
153269
  ownDeviceId: this.id,
152988
153270
  refresh: this.sleepGatedRefresh(CAP_NAME, refreshFromCamera),
152989
- staleMs: STALE_MS,
153271
+ staleMs: OPERATOR_WRITTEN_STALE_MS,
153272
+ staleRead: "serve-and-revalidate",
152990
153273
  empty: () => ({
152991
153274
  enabled: false,
152992
153275
  sensitivity: 1,
@@ -153317,7 +153600,6 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
153317
153600
  registerDayNightCap() {
153318
153601
  const channel = this.getChannel();
153319
153602
  const CAP_NAME = "day-night";
153320
- const STALE_MS = 1e4;
153321
153603
  /** Round-trip `getVideoInput` + `getDayNightThreshold`, map into the
153322
153604
  * cap status schema, write slice. */
153323
153605
  const refreshFromCamera = async () => {
@@ -153360,7 +153642,8 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
153360
153642
  cap: dayNightCapability,
153361
153643
  ownDeviceId: this.id,
153362
153644
  refresh: this.sleepGatedRefresh(CAP_NAME, refreshFromCamera),
153363
- staleMs: STALE_MS,
153645
+ staleMs: OPERATOR_WRITTEN_STALE_MS,
153646
+ staleRead: "serve-and-revalidate",
153364
153647
  empty: () => ({
153365
153648
  mode: "auto",
153366
153649
  lastFetchedAt: 0
@@ -153453,7 +153736,6 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
153453
153736
  registerImageSettingsCap() {
153454
153737
  const channel = this.getChannel();
153455
153738
  const CAP_NAME = "image-settings";
153456
- const STALE_MS = 1e4;
153457
153739
  const SLIDER_MIN = 0;
153458
153740
  const SLIDER_MAX = 255;
153459
153741
  const NORMALIZED_RANGE = {
@@ -153497,7 +153779,8 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
153497
153779
  cap: imageSettingsCapability,
153498
153780
  ownDeviceId: this.id,
153499
153781
  refresh: this.sleepGatedRefresh(CAP_NAME, refreshFromCamera),
153500
- staleMs: STALE_MS,
153782
+ staleMs: OPERATOR_WRITTEN_STALE_MS,
153783
+ staleRead: "serve-and-revalidate",
153501
153784
  empty: () => ({ lastFetchedAt: 0 })
153502
153785
  }).getStatus,
153503
153786
  getOptions: async ({ deviceId }) => {
@@ -153814,7 +154097,6 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
153814
154097
  /** Stale-after window for served-from-cache reads. ~10s is short
153815
154098
  * enough that the UI's 5s poll always sees a fresh round-trip,
153816
154099
  * long enough that two consumers in the same tick fold into one. */
153817
- const STALE_MS = 1e4;
153818
154100
  const SUPPORTED_TARGET_TYPES = [
153819
154101
  {
153820
154102
  value: "people",
@@ -153880,7 +154162,8 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
153880
154162
  cap: ptzAutotrackCapability,
153881
154163
  ownDeviceId: this.id,
153882
154164
  refresh: this.sleepGatedRefresh(CAP_NAME, refreshFromCamera),
153883
- staleMs: STALE_MS,
154165
+ staleMs: OPERATOR_WRITTEN_STALE_MS,
154166
+ staleRead: "serve-and-revalidate",
153884
154167
  empty: () => ({
153885
154168
  enabled: false,
153886
154169
  lastChangedAt: 0,
@@ -155641,7 +155924,7 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
155641
155924
  id: "driver-refresh",
155642
155925
  tab: "general",
155643
155926
  title: "Camera data",
155644
- 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.",
155927
+ 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.",
155645
155928
  columns: 1,
155646
155929
  fields: [{
155647
155930
  type: "device-action-button",
@@ -155649,7 +155932,8 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
155649
155932
  label: "Refresh from camera",
155650
155933
  buttonLabel: "Refresh now",
155651
155934
  action: "refresh-settings",
155652
- variant: "default"
155935
+ variant: "default",
155936
+ successMessage: "Camera awake: {awake} · settings re-read: {snapshotRefreshed} · slices refreshed: {capsWarmed}/{capsTotal}"
155653
155937
  }]
155654
155938
  },
155655
155939
  {
@@ -156136,20 +156420,6 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
156136
156420
  sleeping: this.isBattery && this.sleeping,
156137
156421
  now: Date.now()
156138
156422
  }),
156139
- {
156140
- id: "privacy-mask",
156141
- tab: "image",
156142
- title: "Privacy mask",
156143
- 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.",
156144
- columns: 1,
156145
- fields: [{
156146
- type: "boolean",
156147
- key: "privacyMaskEnabled",
156148
- label: "Privacy mask enabled",
156149
- default: cache?.maskSnapshot?.enabled ?? false,
156150
- style: "switch"
156151
- }]
156152
- },
156153
156423
  ...cache?.autoFocusSnapshot?.supported === true ? [{
156154
156424
  id: "auto-focus",
156155
156425
  tab: "image",
@@ -156504,7 +156774,6 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
156504
156774
  audioVolume: this.config.get("audioVolume") ?? 50,
156505
156775
  audioTalkAndReplyVolume: this.config.get("audioTalkAndReplyVolume") ?? 50,
156506
156776
  audioVisitorVolume: this.config.get("audioVisitorVolume") ?? 50,
156507
- privacyMaskEnabled: this.config.get("privacyMaskEnabled") ?? cache?.maskSnapshot?.enabled ?? false,
156508
156777
  audioNoiseLevel: this.config.get("audioNoiseLevel") ?? (cache?.audioNoiseSnapshot?.enabled ? cache.audioNoiseSnapshot?.level ?? 1 : 0),
156509
156778
  autoFocusEnabled: this.config.get("autoFocusEnabled") ?? cache?.autoFocusSnapshot?.enabled ?? true,
156510
156779
  pirReduceFalseAlarm: this.config.get("pirReduceFalseAlarm") ?? cache?.pirSnapshot?.reduceFalseAlarm ?? false,
@@ -156520,6 +156789,7 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
156520
156789
  }
156521
156790
  async applySettingsPatch(patch) {
156522
156791
  const { emailSmtpServer, emailSmtpPort, emailUserName, emailPassword, emailSsl, emailRecipient1, emailRecipient2, emailRecipient3, emailSendNickname, emailScheduleEnabled, osdChannelEnabled, osdChannelName, osdChannelPosition, osdTimeEnabled, osdTimePosition, osdWatermark, ...rest } = patch;
156792
+ 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 } });
156523
156793
  const emailFields = {
156524
156794
  emailSmtpServer,
156525
156795
  emailSmtpPort,
@@ -156830,13 +157100,6 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
156830
157100
  this.ctx.logger.warn("reolink setNtp push failed", { meta: { error: err instanceof Error ? err.message : String(err) } });
156831
157101
  }
156832
157102
  }
156833
- if ("privacyMaskEnabled" in patch) try {
156834
- const api = await this.ensureApi();
156835
- const v = typedPatch.privacyMaskEnabled;
156836
- if (typeof v === "boolean") await api.setMask(this.getChannel(), { enable: v });
156837
- } catch (err) {
156838
- this.ctx.logger.warn("mask push failed", { meta: { error: err instanceof Error ? err.message : String(err) } });
156839
- }
156840
157103
  if ("audioNoiseLevel" in patch) try {
156841
157104
  const api = await this.ensureApi();
156842
157105
  const v = typedPatch.audioNoiseLevel;
@@ -156894,15 +157157,40 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
156894
157157
  }
156895
157158
  }
156896
157159
  const changedSlices = slicesForPatch(patch);
156897
- if (changedSlices.size > 0) await this.refreshParentSettingsSnapshot(changedSlices).catch((err) => {
156898
- this.ctx.logger.info("reolink targeted settings refresh failed — snapshots left stale", {
156899
- tags: { deviceId: this.id },
156900
- meta: {
156901
- slices: [...changedSlices],
156902
- error: err instanceof Error ? err.message : String(err)
156903
- }
157160
+ if (changedSlices.size > 0) {
157161
+ await this.refreshParentSettingsSnapshot(changedSlices, "post-write").catch((err) => {
157162
+ this.ctx.logger.info("reolink targeted settings refresh failed — snapshots left stale", {
157163
+ tags: { deviceId: this.id },
157164
+ meta: {
157165
+ slices: [...changedSlices],
157166
+ error: err instanceof Error ? err.message : String(err)
157167
+ }
157168
+ });
156904
157169
  });
156905
- });
157170
+ this.assertOsdWriteLanded(patch);
157171
+ }
157172
+ }
157173
+ /**
157174
+ * Compare what the operator asked for with what the camera now reports, and
157175
+ * REFUSE to call a lost write a success.
157176
+ *
157177
+ * Nothing used to do this, so an applied write and a write the camera never
157178
+ * took were indistinguishable — to the code and to the operator, who was
157179
+ * told `{success: true}` for a change the camera discarded (3825,
157180
+ * 2026-09-19). Throwing is the right shape: the admin UI's in-flight
157181
+ * overlay hands the operator their own edit back, marked unsaved, instead
157182
+ * of quietly reverting the field to the old value.
157183
+ *
157184
+ * Silence is the pass. A slice we could not re-read at all is already
157185
+ * reported by the caller as "snapshots left stale" — absence of a reading
157186
+ * is not evidence of a lost write, and must never be treated as one (D393).
157187
+ */
157188
+ assertOsdWriteLanded(patch) {
157189
+ const asked = patch.osdTimePosition;
157190
+ if (typeof asked !== "string") return;
157191
+ const got = resolveOsdValues(this.config.get("deviceCache")?.osdSnapshot).osdTimePosition;
157192
+ if (typeof got !== "string" || got === asked) return;
157193
+ throw new Error(`the camera did not take the timestamp position: asked "${asked}", it reports "${got}"`);
156906
157194
  }
156907
157195
  /**
156908
157196
  * Optionally inject an already-logged-in Baichuan api — used by