@camstack/addon-provider-reolink 1.2.143 → 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 +352 -68
  2. package/dist/addon.mjs +352 -68
  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-GU_us3DG.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(),
@@ -29716,16 +29862,37 @@ var GridProfileOfferSchema = object({
29716
29862
  ]),
29717
29863
  offered: boolean(),
29718
29864
  /** Sources that cannot serve it. Empty when it is offered, or when there are no cells. */
29719
- 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()
29720
29883
  });
29721
29884
  var GridLayoutViewSchema = object({
29722
29885
  /** The persisted grid row this camera was declared from. */
29723
29886
  instanceId: string(),
29724
29887
  deviceId: number().int().nonnegative(),
29725
29888
  name: string(),
29726
- /** The canvas the operator laid out — this grid's `high`. mid/low are scaled from it. */
29727
- width: number().int(),
29728
- 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
+ */
29729
29896
  fps: number().int(),
29730
29897
  cells: array(GridLayoutCellSchema),
29731
29898
  /** What the catalog will publish, and what it refuses to. Read-only. */
@@ -29734,9 +29901,13 @@ var GridLayoutViewSchema = object({
29734
29901
  var GridLayoutPatchSchema = object({
29735
29902
  deviceId: number().int().nonnegative(),
29736
29903
  name: string().min(1).max(160).optional(),
29737
- width: number().int().min(160).max(7680).optional(),
29738
- height: number().int().min(120).max(4320).optional(),
29739
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(),
29740
29911
  /**
29741
29912
  * The whole cell list at once. A per-cell patch would need an ordering the
29742
29913
  * editor does not have, and a half-applied layout is a picture nobody asked
@@ -32151,6 +32322,25 @@ var ptzAutotrackCapability = {
32151
32322
  scope: "device",
32152
32323
  deviceNative: true,
32153
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,
32154
32344
  deviceTypes: [DeviceType.Camera],
32155
32345
  deviceConfig: { ui: {
32156
32346
  kind: "widget",
@@ -43522,6 +43712,12 @@ Object.freeze({
43522
43712
  addonId: null,
43523
43713
  access: "view"
43524
43714
  },
43715
+ "webrtcSession.getSessionDebug": {
43716
+ capName: "webrtc-session",
43717
+ capScope: "device",
43718
+ addonId: null,
43719
+ access: "view"
43720
+ },
43525
43721
  "webrtcSession.getSessionState": {
43526
43722
  capName: "webrtc-session",
43527
43723
  capScope: "device",
@@ -45505,6 +45701,11 @@ Object.freeze({
45505
45701
  form: "single",
45506
45702
  optional: false
45507
45703
  }],
45704
+ "webrtcSession.getSessionDebug": [{
45705
+ name: "deviceId",
45706
+ form: "single",
45707
+ optional: false
45708
+ }],
45508
45709
  "webrtcSession.getSessionState": [{
45509
45710
  name: "deviceId",
45510
45711
  form: "single",
@@ -75190,7 +75391,7 @@ var require_lz4 = /* @__PURE__ */ require_chunk.__commonJSMin(((exports) => {
75190
75391
  };
75191
75392
  }));
75192
75393
  //#endregion
75193
- //#region node_modules/@apocaliss92/nodelink-js/dist/chunk-CE77DTET.js
75394
+ //#region node_modules/@apocaliss92/nodelink-js/dist/chunk-7JMYBUEB.js
75194
75395
  var import_fxp = require_fxp();
75195
75396
  var import_lz4 = /* @__PURE__ */ require_chunk.__toESM(require_lz4(), 1);
75196
75397
  function encodeHeader(h) {
@@ -83650,10 +83851,14 @@ function applyOsdDatetimePatch(rawXml, patch) {
83650
83851
  return xml;
83651
83852
  }
83652
83853
  var OSD_POSITION_MAX = 65536;
83854
+ var OSD_POSITION_START_WRITE = 1;
83855
+ var OSD_POSITION_CENTER = 65537;
83653
83856
  var OSD_CORNERS = [
83654
83857
  "top-left",
83858
+ "top-center",
83655
83859
  "top-right",
83656
83860
  "bottom-left",
83861
+ "bottom-center",
83657
83862
  "bottom-right"
83658
83863
  ];
83659
83864
  function isStartEdge(value) {
@@ -83665,7 +83870,7 @@ function isEndEdge(value) {
83665
83870
  function readOsdPosition(x, y) {
83666
83871
  if (typeof x !== "number" || typeof y !== "number") return { kind: "unknown" };
83667
83872
  if (!Number.isFinite(x) || !Number.isFinite(y)) return { kind: "unknown" };
83668
- const horizontal = isStartEdge(x) ? "left" : isEndEdge(x) ? "right" : null;
83873
+ const horizontal = x === OSD_POSITION_CENTER ? "center" : isStartEdge(x) ? "left" : isEndEdge(x) ? "right" : null;
83669
83874
  const vertical = isStartEdge(y) ? "top" : isEndEdge(y) ? "bottom" : null;
83670
83875
  if (horizontal === null || vertical === null) return {
83671
83876
  kind: "custom",
@@ -83679,8 +83884,8 @@ function readOsdPosition(x, y) {
83679
83884
  }
83680
83885
  function coordsForOsdCorner(corner) {
83681
83886
  return {
83682
- x: corner.endsWith("-right") ? OSD_POSITION_MAX : 0,
83683
- 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
83684
83889
  };
83685
83890
  }
83686
83891
  function isOsdCorner(value) {
@@ -83691,8 +83896,10 @@ function formatOsdCoords(x, y) {
83691
83896
  }
83692
83897
  var OSD_CORNER_LABELS = {
83693
83898
  "top-left": "Top left",
83899
+ "top-center": "Top centre",
83694
83900
  "top-right": "Top right",
83695
83901
  "bottom-left": "Bottom left",
83902
+ "bottom-center": "Bottom centre",
83696
83903
  "bottom-right": "Bottom right"
83697
83904
  };
83698
83905
  function wireFloat(value) {
@@ -88219,8 +88426,12 @@ var ReolinkBaichuanApi = class _ReolinkBaichuanApi {
88219
88426
  /** Generic Baichuan cmd_id call, returns XML (if any). */
88220
88427
  async sendXml(params, retry = 3) {
88221
88428
  if (!this.client.loggedIn) await this.client.login();
88222
- const frame = await this.client.sendFrame(params);
88223
- 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);
88224
88435
  if (frame.body.length === 0) return "";
88225
88436
  return this.client.tryDecryptXml(frame.body, frame.header.channelId, this.client.enc);
88226
88437
  }
@@ -94521,6 +94732,7 @@ ${xml}`);
94521
94732
  await this.sendXml({
94522
94733
  cmdId: 45,
94523
94734
  channel: ch,
94735
+ extensionXml: buildChannelExtensionXml(ch),
94524
94736
  payloadXml: ensureXmlHeader(xml),
94525
94737
  ...timeoutOpts
94526
94738
  });
@@ -145555,6 +145767,7 @@ var RAW_READ_CATALOG = {
145555
145767
  kind: "deviceCache",
145556
145768
  snapshotKey: "autoFocusSnapshot"
145557
145769
  },
145770
+ needs: "ptz",
145558
145771
  invoke: (api, channel) => api.getAutoFocus(channel, { timeoutMs: 1500 })
145559
145772
  },
145560
145773
  netPort: {
@@ -145751,6 +145964,7 @@ var RawReadResultSchema = discriminatedUnion("ok", [object({
145751
145964
  slice: RawReadSliceSchema,
145752
145965
  reason: _enum([
145753
145966
  "sleeping",
145967
+ "unsupported",
145754
145968
  "login-failed",
145755
145969
  "read-failed"
145756
145970
  ]),
@@ -145794,6 +146008,20 @@ function toPlainJson(value) {
145794
146008
  async function performRawRead(slice, deps) {
145795
146009
  const entry = RAW_READ_CATALOG[slice];
145796
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
+ }
145797
146025
  const servedWithoutTouchingCamera = deps.hubChild && entry.servedByParentHub === true;
145798
146026
  if (deps.sleeping && !servedWithoutTouchingCamera) {
145799
146027
  deps.logger.info("reolink raw read refused — battery cam is sleeping", {
@@ -149457,7 +149685,6 @@ var reolinkCameraSchema = object({
149457
149685
  * read via 52). Toggles the `<Shelter><enable>` flag. Zone
149458
149686
  * editing is a separate flow not exposed by this driver.
149459
149687
  */
149460
- privacyMaskEnabled: boolean().optional(),
149461
149688
  /**
149462
149689
  * Audio noise reduction level — pushed via `setAudioNoise`
149463
149690
  * (cmd_id 440, read via 439). 0 disables; 1..3 maps to
@@ -150291,7 +150518,6 @@ function slicesForPatch(patch) {
150291
150518
  if (has("motionEnabled", "motionSensitivity")) out.add("motion");
150292
150519
  if (Object.keys(patch).some((k) => /^ai(Person|Vehicle|Animal|Face|Package)/.test(k))) out.add("ai");
150293
150520
  if (has("gop")) out.add("enc");
150294
- if (has("privacyMaskEnabled")) out.add("mask");
150295
150521
  if (has("audioNoiseLevel")) out.add("audioNoise");
150296
150522
  if (has("autoFocusEnabled")) out.add("autofocus");
150297
150523
  if (has("netHttpEnable", "netHttpPort", "netHttpsEnable", "netHttpsPort", "netRtspEnable", "netRtspPort", "netRtmpEnable", "netRtmpPort", "netOnvifEnable", "netOnvifPort")) out.add("netPort");
@@ -150303,6 +150529,20 @@ function slicesForPatch(patch) {
150303
150529
  if (has("timeZoneId")) out.add("systemGeneral");
150304
150530
  return out;
150305
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
+ });
150306
150546
  var ReolinkCamera = class ReolinkCamera extends BaseDevice {
150307
150547
  type = DeviceType.Camera;
150308
150548
  /**
@@ -150772,9 +151012,12 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
150772
151012
  * `setVideoInput`); motion is writable via `setMotionAlarm` so its
150773
151013
  * snapshot also drives the dispatch's known-good baseline.
150774
151014
  */
150775
- async refreshParentSettingsSnapshot(slices) {
150776
- if (this.isBattery && this.sleeping) {
150777
- 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
+ } });
150778
151021
  return;
150779
151022
  }
150780
151023
  let api;
@@ -151019,6 +151262,7 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
151019
151262
  channel: this.getChannel(),
151020
151263
  sleeping: this.isBattery && this.sleeping,
151021
151264
  hubChild: this.isHubChild(),
151265
+ hasPtz: this.getProbeFlags().hasPtz === true,
151022
151266
  getApi: () => this.ensureApi(),
151023
151267
  cache: this.config.get("deviceCache"),
151024
151268
  batteryState: this.runtimeState.getCapState("battery"),
@@ -151112,6 +151356,10 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
151112
151356
  });
151113
151357
  await sleep$2(1500);
151114
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
+ }
151115
151363
  return true;
151116
151364
  } catch (err) {
151117
151365
  this.ctx.logger.warn("proactive action: wake did not complete in time", {
@@ -151283,8 +151531,26 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
151283
151531
  this.registerDeviceAction("auto-configure-email-push", deviceCustomAction(unknown(), _void(), { kind: "mutation" }), async () => {
151284
151532
  await this.autoConfigureEmailPush();
151285
151533
  });
151286
- this.registerDeviceAction("refresh-settings", deviceCustomAction(unknown(), _void(), { kind: "mutation" }), async () => {
151287
- 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
+ };
151288
151554
  });
151289
151555
  this.registerDeviceAction("refresh-sessions", deviceCustomAction(unknown(), _void(), { kind: "mutation" }), async () => {
151290
151556
  await this.refreshSessionsSnapshot();
@@ -151324,7 +151590,6 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
151324
151590
  * registration so the general "Refresh from camera" button can warm
151325
151591
  * the `stream-params` runtime-state slice (the editable Stream
151326
151592
  * section's current values read from it). */
151327
- refreshStreamParamsFromCamera = null;
151328
151593
  /** Single-flight guard for the motion-zones cap refresh. */
151329
151594
  motionZonesRefreshInFlight = null;
151330
151595
  /** Single-flight guard for the privacy-mask cap refresh. */
@@ -151458,6 +151723,16 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
151458
151723
  };
151459
151724
  }
151460
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
+ }
151461
151736
  return {
151462
151737
  awoke: true,
151463
151738
  durationMs: Date.now() - startedAt
@@ -151583,7 +151858,8 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
151583
151858
  commitSleepState(next, source) {
151584
151859
  if (!this.isBattery) return false;
151585
151860
  if (this.sleeping === next) return false;
151586
- 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;
151587
151863
  if (source === "hub-summary") {
151588
151864
  const sinceCommitMs = Date.now() - this.sleepStateChangedAt;
151589
151865
  if (next === true && this.sleepStateChangedAt > 0 && sinceCommitMs < ReolinkCamera.HUB_SUMMARY_SLEEP_GRACE_MS) {
@@ -152719,14 +152995,14 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
152719
152995
  * swallowed: the window is short, partial progress accumulates across
152720
152996
  * wakes because the slices persist.
152721
152997
  */
152722
- async warmCapSlicesOnWake() {
152998
+ async warmCapSlicesOnWake(opts) {
152723
152999
  const now = Date.now();
152724
153000
  let warmed = 0;
152725
153001
  let skipped = 0;
152726
153002
  for (const [capName, warm] of this.capWarmers) {
152727
153003
  const slice = this.runtimeState.getCapState(capName);
152728
153004
  const fetchedAt = typeof slice?.lastFetchedAt === "number" ? slice.lastFetchedAt : 0;
152729
- 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) {
152730
153006
  skipped += 1;
152731
153007
  continue;
152732
153008
  }
@@ -152751,6 +153027,10 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
152751
153027
  total: this.capWarmers.size
152752
153028
  }
152753
153029
  });
153030
+ return {
153031
+ warmed,
153032
+ skipped
153033
+ };
152754
153034
  }
152755
153035
  /**
152756
153036
  * Ask the snapshot wrapper for a frame while the camera is up, so its
@@ -152816,7 +153096,6 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
152816
153096
  registerStreamParamsCap() {
152817
153097
  const channel = this.getChannel();
152818
153098
  const CAP_NAME = "stream-params";
152819
- const STALE_MS = 1e4;
152820
153099
  /** Round-trip `getEnc`, map into the cap status schema, write slice. */
152821
153100
  const refreshFromCamera = async () => {
152822
153101
  if (this.streamParamsRefreshInFlight) return this.streamParamsRefreshInFlight;
@@ -152849,13 +153128,13 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
152849
153128
  this.streamParamsRefreshInFlight = null;
152850
153129
  }
152851
153130
  };
152852
- this.refreshStreamParamsFromCamera = refreshFromCamera;
152853
153131
  const bridge = createRuntimeStateBridge({
152854
153132
  runtimeState: this.runtimeState,
152855
153133
  cap: streamParamsCapability,
152856
153134
  ownDeviceId: this.id,
152857
153135
  refresh: this.sleepGatedRefresh(CAP_NAME, refreshFromCamera),
152858
- staleMs: STALE_MS,
153136
+ staleMs: OPERATOR_WRITTEN_STALE_MS,
153137
+ staleRead: "serve-and-revalidate",
152859
153138
  empty: () => ({ lastFetchedAt: 0 })
152860
153139
  });
152861
153140
  const resolveOptions = async () => this.resolveCapOptions({
@@ -152940,7 +153219,6 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
152940
153219
  registerMotionZonesCap() {
152941
153220
  const channel = this.getChannel();
152942
153221
  const CAP_NAME = "motion-zones";
152943
- const STALE_MS = 1e4;
152944
153222
  /** Round-trip `getMotionAlarm`, map into the cap status schema, write slice. */
152945
153223
  const refreshFromCamera = async () => {
152946
153224
  if (this.motionZonesRefreshInFlight) return this.motionZonesRefreshInFlight;
@@ -152990,7 +153268,8 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
152990
153268
  cap: motionZonesCapability,
152991
153269
  ownDeviceId: this.id,
152992
153270
  refresh: this.sleepGatedRefresh(CAP_NAME, refreshFromCamera),
152993
- staleMs: STALE_MS,
153271
+ staleMs: OPERATOR_WRITTEN_STALE_MS,
153272
+ staleRead: "serve-and-revalidate",
152994
153273
  empty: () => ({
152995
153274
  enabled: false,
152996
153275
  sensitivity: 1,
@@ -153321,7 +153600,6 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
153321
153600
  registerDayNightCap() {
153322
153601
  const channel = this.getChannel();
153323
153602
  const CAP_NAME = "day-night";
153324
- const STALE_MS = 1e4;
153325
153603
  /** Round-trip `getVideoInput` + `getDayNightThreshold`, map into the
153326
153604
  * cap status schema, write slice. */
153327
153605
  const refreshFromCamera = async () => {
@@ -153364,7 +153642,8 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
153364
153642
  cap: dayNightCapability,
153365
153643
  ownDeviceId: this.id,
153366
153644
  refresh: this.sleepGatedRefresh(CAP_NAME, refreshFromCamera),
153367
- staleMs: STALE_MS,
153645
+ staleMs: OPERATOR_WRITTEN_STALE_MS,
153646
+ staleRead: "serve-and-revalidate",
153368
153647
  empty: () => ({
153369
153648
  mode: "auto",
153370
153649
  lastFetchedAt: 0
@@ -153457,7 +153736,6 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
153457
153736
  registerImageSettingsCap() {
153458
153737
  const channel = this.getChannel();
153459
153738
  const CAP_NAME = "image-settings";
153460
- const STALE_MS = 1e4;
153461
153739
  const SLIDER_MIN = 0;
153462
153740
  const SLIDER_MAX = 255;
153463
153741
  const NORMALIZED_RANGE = {
@@ -153501,7 +153779,8 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
153501
153779
  cap: imageSettingsCapability,
153502
153780
  ownDeviceId: this.id,
153503
153781
  refresh: this.sleepGatedRefresh(CAP_NAME, refreshFromCamera),
153504
- staleMs: STALE_MS,
153782
+ staleMs: OPERATOR_WRITTEN_STALE_MS,
153783
+ staleRead: "serve-and-revalidate",
153505
153784
  empty: () => ({ lastFetchedAt: 0 })
153506
153785
  }).getStatus,
153507
153786
  getOptions: async ({ deviceId }) => {
@@ -153818,7 +154097,6 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
153818
154097
  /** Stale-after window for served-from-cache reads. ~10s is short
153819
154098
  * enough that the UI's 5s poll always sees a fresh round-trip,
153820
154099
  * long enough that two consumers in the same tick fold into one. */
153821
- const STALE_MS = 1e4;
153822
154100
  const SUPPORTED_TARGET_TYPES = [
153823
154101
  {
153824
154102
  value: "people",
@@ -153884,7 +154162,8 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
153884
154162
  cap: ptzAutotrackCapability,
153885
154163
  ownDeviceId: this.id,
153886
154164
  refresh: this.sleepGatedRefresh(CAP_NAME, refreshFromCamera),
153887
- staleMs: STALE_MS,
154165
+ staleMs: OPERATOR_WRITTEN_STALE_MS,
154166
+ staleRead: "serve-and-revalidate",
153888
154167
  empty: () => ({
153889
154168
  enabled: false,
153890
154169
  lastChangedAt: 0,
@@ -155645,7 +155924,7 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
155645
155924
  id: "driver-refresh",
155646
155925
  tab: "general",
155647
155926
  title: "Camera data",
155648
- 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.",
155649
155928
  columns: 1,
155650
155929
  fields: [{
155651
155930
  type: "device-action-button",
@@ -155653,7 +155932,8 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
155653
155932
  label: "Refresh from camera",
155654
155933
  buttonLabel: "Refresh now",
155655
155934
  action: "refresh-settings",
155656
- variant: "default"
155935
+ variant: "default",
155936
+ successMessage: "Camera awake: {awake} · settings re-read: {snapshotRefreshed} · slices refreshed: {capsWarmed}/{capsTotal}"
155657
155937
  }]
155658
155938
  },
155659
155939
  {
@@ -156140,20 +156420,6 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
156140
156420
  sleeping: this.isBattery && this.sleeping,
156141
156421
  now: Date.now()
156142
156422
  }),
156143
- {
156144
- id: "privacy-mask",
156145
- tab: "image",
156146
- title: "Privacy mask",
156147
- 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.",
156148
- columns: 1,
156149
- fields: [{
156150
- type: "boolean",
156151
- key: "privacyMaskEnabled",
156152
- label: "Privacy mask enabled",
156153
- default: cache?.maskSnapshot?.enabled ?? false,
156154
- style: "switch"
156155
- }]
156156
- },
156157
156423
  ...cache?.autoFocusSnapshot?.supported === true ? [{
156158
156424
  id: "auto-focus",
156159
156425
  tab: "image",
@@ -156508,7 +156774,6 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
156508
156774
  audioVolume: this.config.get("audioVolume") ?? 50,
156509
156775
  audioTalkAndReplyVolume: this.config.get("audioTalkAndReplyVolume") ?? 50,
156510
156776
  audioVisitorVolume: this.config.get("audioVisitorVolume") ?? 50,
156511
- privacyMaskEnabled: this.config.get("privacyMaskEnabled") ?? cache?.maskSnapshot?.enabled ?? false,
156512
156777
  audioNoiseLevel: this.config.get("audioNoiseLevel") ?? (cache?.audioNoiseSnapshot?.enabled ? cache.audioNoiseSnapshot?.level ?? 1 : 0),
156513
156778
  autoFocusEnabled: this.config.get("autoFocusEnabled") ?? cache?.autoFocusSnapshot?.enabled ?? true,
156514
156779
  pirReduceFalseAlarm: this.config.get("pirReduceFalseAlarm") ?? cache?.pirSnapshot?.reduceFalseAlarm ?? false,
@@ -156524,6 +156789,7 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
156524
156789
  }
156525
156790
  async applySettingsPatch(patch) {
156526
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 } });
156527
156793
  const emailFields = {
156528
156794
  emailSmtpServer,
156529
156795
  emailSmtpPort,
@@ -156834,13 +157100,6 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
156834
157100
  this.ctx.logger.warn("reolink setNtp push failed", { meta: { error: err instanceof Error ? err.message : String(err) } });
156835
157101
  }
156836
157102
  }
156837
- if ("privacyMaskEnabled" in patch) try {
156838
- const api = await this.ensureApi();
156839
- const v = typedPatch.privacyMaskEnabled;
156840
- if (typeof v === "boolean") await api.setMask(this.getChannel(), { enable: v });
156841
- } catch (err) {
156842
- this.ctx.logger.warn("mask push failed", { meta: { error: err instanceof Error ? err.message : String(err) } });
156843
- }
156844
157103
  if ("audioNoiseLevel" in patch) try {
156845
157104
  const api = await this.ensureApi();
156846
157105
  const v = typedPatch.audioNoiseLevel;
@@ -156898,15 +157157,40 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
156898
157157
  }
156899
157158
  }
156900
157159
  const changedSlices = slicesForPatch(patch);
156901
- if (changedSlices.size > 0) await this.refreshParentSettingsSnapshot(changedSlices).catch((err) => {
156902
- this.ctx.logger.info("reolink targeted settings refresh failed — snapshots left stale", {
156903
- tags: { deviceId: this.id },
156904
- meta: {
156905
- slices: [...changedSlices],
156906
- error: err instanceof Error ? err.message : String(err)
156907
- }
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
+ });
156908
157169
  });
156909
- });
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}"`);
156910
157194
  }
156911
157195
  /**
156912
157196
  * Optionally inject an already-logged-in Baichuan api — used by