@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.mjs CHANGED
@@ -5381,7 +5381,7 @@ var ZodIssueCode = {
5381
5381
  var ZodFirstPartyTypeKind;
5382
5382
  ZodFirstPartyTypeKind || (ZodFirstPartyTypeKind = {});
5383
5383
  //#endregion
5384
- //#region ../types/dist/sleep-GU_us3DG.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(),
@@ -29711,16 +29857,37 @@ var GridProfileOfferSchema = object({
29711
29857
  ]),
29712
29858
  offered: boolean(),
29713
29859
  /** Sources that cannot serve it. Empty when it is offered, or when there are no cells. */
29714
- 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()
29715
29878
  });
29716
29879
  var GridLayoutViewSchema = object({
29717
29880
  /** The persisted grid row this camera was declared from. */
29718
29881
  instanceId: string(),
29719
29882
  deviceId: number().int().nonnegative(),
29720
29883
  name: string(),
29721
- /** The canvas the operator laid out — this grid's `high`. mid/low are scaled from it. */
29722
- width: number().int(),
29723
- 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
+ */
29724
29891
  fps: number().int(),
29725
29892
  cells: array(GridLayoutCellSchema),
29726
29893
  /** What the catalog will publish, and what it refuses to. Read-only. */
@@ -29729,9 +29896,13 @@ var GridLayoutViewSchema = object({
29729
29896
  var GridLayoutPatchSchema = object({
29730
29897
  deviceId: number().int().nonnegative(),
29731
29898
  name: string().min(1).max(160).optional(),
29732
- width: number().int().min(160).max(7680).optional(),
29733
- height: number().int().min(120).max(4320).optional(),
29734
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(),
29735
29906
  /**
29736
29907
  * The whole cell list at once. A per-cell patch would need an ordering the
29737
29908
  * editor does not have, and a half-applied layout is a picture nobody asked
@@ -32146,6 +32317,25 @@ var ptzAutotrackCapability = {
32146
32317
  scope: "device",
32147
32318
  deviceNative: true,
32148
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,
32149
32339
  deviceTypes: [DeviceType.Camera],
32150
32340
  deviceConfig: { ui: {
32151
32341
  kind: "widget",
@@ -43517,6 +43707,12 @@ Object.freeze({
43517
43707
  addonId: null,
43518
43708
  access: "view"
43519
43709
  },
43710
+ "webrtcSession.getSessionDebug": {
43711
+ capName: "webrtc-session",
43712
+ capScope: "device",
43713
+ addonId: null,
43714
+ access: "view"
43715
+ },
43520
43716
  "webrtcSession.getSessionState": {
43521
43717
  capName: "webrtc-session",
43522
43718
  capScope: "device",
@@ -45500,6 +45696,11 @@ Object.freeze({
45500
45696
  form: "single",
45501
45697
  optional: false
45502
45698
  }],
45699
+ "webrtcSession.getSessionDebug": [{
45700
+ name: "deviceId",
45701
+ form: "single",
45702
+ optional: false
45703
+ }],
45503
45704
  "webrtcSession.getSessionState": [{
45504
45705
  name: "deviceId",
45505
45706
  form: "single",
@@ -75185,7 +75386,7 @@ var require_lz4 = /* @__PURE__ */ __commonJSMin(((exports) => {
75185
75386
  };
75186
75387
  }));
75187
75388
  //#endregion
75188
- //#region node_modules/@apocaliss92/nodelink-js/dist/chunk-CE77DTET.js
75389
+ //#region node_modules/@apocaliss92/nodelink-js/dist/chunk-7JMYBUEB.js
75189
75390
  var import_fxp = require_fxp();
75190
75391
  var import_lz4 = /* @__PURE__ */ __toESM(require_lz4(), 1);
75191
75392
  function encodeHeader(h) {
@@ -83645,10 +83846,14 @@ function applyOsdDatetimePatch(rawXml, patch) {
83645
83846
  return xml;
83646
83847
  }
83647
83848
  var OSD_POSITION_MAX = 65536;
83849
+ var OSD_POSITION_START_WRITE = 1;
83850
+ var OSD_POSITION_CENTER = 65537;
83648
83851
  var OSD_CORNERS = [
83649
83852
  "top-left",
83853
+ "top-center",
83650
83854
  "top-right",
83651
83855
  "bottom-left",
83856
+ "bottom-center",
83652
83857
  "bottom-right"
83653
83858
  ];
83654
83859
  function isStartEdge(value) {
@@ -83660,7 +83865,7 @@ function isEndEdge(value) {
83660
83865
  function readOsdPosition(x, y) {
83661
83866
  if (typeof x !== "number" || typeof y !== "number") return { kind: "unknown" };
83662
83867
  if (!Number.isFinite(x) || !Number.isFinite(y)) return { kind: "unknown" };
83663
- const horizontal = isStartEdge(x) ? "left" : isEndEdge(x) ? "right" : null;
83868
+ const horizontal = x === OSD_POSITION_CENTER ? "center" : isStartEdge(x) ? "left" : isEndEdge(x) ? "right" : null;
83664
83869
  const vertical = isStartEdge(y) ? "top" : isEndEdge(y) ? "bottom" : null;
83665
83870
  if (horizontal === null || vertical === null) return {
83666
83871
  kind: "custom",
@@ -83674,8 +83879,8 @@ function readOsdPosition(x, y) {
83674
83879
  }
83675
83880
  function coordsForOsdCorner(corner) {
83676
83881
  return {
83677
- x: corner.endsWith("-right") ? OSD_POSITION_MAX : 0,
83678
- 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
83679
83884
  };
83680
83885
  }
83681
83886
  function isOsdCorner(value) {
@@ -83686,8 +83891,10 @@ function formatOsdCoords(x, y) {
83686
83891
  }
83687
83892
  var OSD_CORNER_LABELS = {
83688
83893
  "top-left": "Top left",
83894
+ "top-center": "Top centre",
83689
83895
  "top-right": "Top right",
83690
83896
  "bottom-left": "Bottom left",
83897
+ "bottom-center": "Bottom centre",
83691
83898
  "bottom-right": "Bottom right"
83692
83899
  };
83693
83900
  function wireFloat(value) {
@@ -88214,8 +88421,12 @@ var ReolinkBaichuanApi = class _ReolinkBaichuanApi {
88214
88421
  /** Generic Baichuan cmd_id call, returns XML (if any). */
88215
88422
  async sendXml(params, retry = 3) {
88216
88423
  if (!this.client.loggedIn) await this.client.login();
88217
- const frame = await this.client.sendFrame(params);
88218
- 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);
88219
88430
  if (frame.body.length === 0) return "";
88220
88431
  return this.client.tryDecryptXml(frame.body, frame.header.channelId, this.client.enc);
88221
88432
  }
@@ -94516,6 +94727,7 @@ ${xml}`);
94516
94727
  await this.sendXml({
94517
94728
  cmdId: 45,
94518
94729
  channel: ch,
94730
+ extensionXml: buildChannelExtensionXml(ch),
94519
94731
  payloadXml: ensureXmlHeader(xml),
94520
94732
  ...timeoutOpts
94521
94733
  });
@@ -145550,6 +145762,7 @@ var RAW_READ_CATALOG = {
145550
145762
  kind: "deviceCache",
145551
145763
  snapshotKey: "autoFocusSnapshot"
145552
145764
  },
145765
+ needs: "ptz",
145553
145766
  invoke: (api, channel) => api.getAutoFocus(channel, { timeoutMs: 1500 })
145554
145767
  },
145555
145768
  netPort: {
@@ -145746,6 +145959,7 @@ var RawReadResultSchema = discriminatedUnion("ok", [object({
145746
145959
  slice: RawReadSliceSchema,
145747
145960
  reason: _enum([
145748
145961
  "sleeping",
145962
+ "unsupported",
145749
145963
  "login-failed",
145750
145964
  "read-failed"
145751
145965
  ]),
@@ -145789,6 +146003,20 @@ function toPlainJson(value) {
145789
146003
  async function performRawRead(slice, deps) {
145790
146004
  const entry = RAW_READ_CATALOG[slice];
145791
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
+ }
145792
146020
  const servedWithoutTouchingCamera = deps.hubChild && entry.servedByParentHub === true;
145793
146021
  if (deps.sleeping && !servedWithoutTouchingCamera) {
145794
146022
  deps.logger.info("reolink raw read refused — battery cam is sleeping", {
@@ -149452,7 +149680,6 @@ var reolinkCameraSchema = object({
149452
149680
  * read via 52). Toggles the `<Shelter><enable>` flag. Zone
149453
149681
  * editing is a separate flow not exposed by this driver.
149454
149682
  */
149455
- privacyMaskEnabled: boolean().optional(),
149456
149683
  /**
149457
149684
  * Audio noise reduction level — pushed via `setAudioNoise`
149458
149685
  * (cmd_id 440, read via 439). 0 disables; 1..3 maps to
@@ -150286,7 +150513,6 @@ function slicesForPatch(patch) {
150286
150513
  if (has("motionEnabled", "motionSensitivity")) out.add("motion");
150287
150514
  if (Object.keys(patch).some((k) => /^ai(Person|Vehicle|Animal|Face|Package)/.test(k))) out.add("ai");
150288
150515
  if (has("gop")) out.add("enc");
150289
- if (has("privacyMaskEnabled")) out.add("mask");
150290
150516
  if (has("audioNoiseLevel")) out.add("audioNoise");
150291
150517
  if (has("autoFocusEnabled")) out.add("autofocus");
150292
150518
  if (has("netHttpEnable", "netHttpPort", "netHttpsEnable", "netHttpsPort", "netRtspEnable", "netRtspPort", "netRtmpEnable", "netRtmpPort", "netOnvifEnable", "netOnvifPort")) out.add("netPort");
@@ -150298,6 +150524,20 @@ function slicesForPatch(patch) {
150298
150524
  if (has("timeZoneId")) out.add("systemGeneral");
150299
150525
  return out;
150300
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
+ });
150301
150541
  var ReolinkCamera = class ReolinkCamera extends BaseDevice {
150302
150542
  type = DeviceType.Camera;
150303
150543
  /**
@@ -150767,9 +151007,12 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
150767
151007
  * `setVideoInput`); motion is writable via `setMotionAlarm` so its
150768
151008
  * snapshot also drives the dispatch's known-good baseline.
150769
151009
  */
150770
- async refreshParentSettingsSnapshot(slices) {
150771
- if (this.isBattery && this.sleeping) {
150772
- 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
+ } });
150773
151016
  return;
150774
151017
  }
150775
151018
  let api;
@@ -151014,6 +151257,7 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
151014
151257
  channel: this.getChannel(),
151015
151258
  sleeping: this.isBattery && this.sleeping,
151016
151259
  hubChild: this.isHubChild(),
151260
+ hasPtz: this.getProbeFlags().hasPtz === true,
151017
151261
  getApi: () => this.ensureApi(),
151018
151262
  cache: this.config.get("deviceCache"),
151019
151263
  batteryState: this.runtimeState.getCapState("battery"),
@@ -151107,6 +151351,10 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
151107
151351
  });
151108
151352
  await sleep$2(1500);
151109
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
+ }
151110
151358
  return true;
151111
151359
  } catch (err) {
151112
151360
  this.ctx.logger.warn("proactive action: wake did not complete in time", {
@@ -151278,8 +151526,26 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
151278
151526
  this.registerDeviceAction("auto-configure-email-push", deviceCustomAction(unknown(), _void(), { kind: "mutation" }), async () => {
151279
151527
  await this.autoConfigureEmailPush();
151280
151528
  });
151281
- this.registerDeviceAction("refresh-settings", deviceCustomAction(unknown(), _void(), { kind: "mutation" }), async () => {
151282
- 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
+ };
151283
151549
  });
151284
151550
  this.registerDeviceAction("refresh-sessions", deviceCustomAction(unknown(), _void(), { kind: "mutation" }), async () => {
151285
151551
  await this.refreshSessionsSnapshot();
@@ -151319,7 +151585,6 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
151319
151585
  * registration so the general "Refresh from camera" button can warm
151320
151586
  * the `stream-params` runtime-state slice (the editable Stream
151321
151587
  * section's current values read from it). */
151322
- refreshStreamParamsFromCamera = null;
151323
151588
  /** Single-flight guard for the motion-zones cap refresh. */
151324
151589
  motionZonesRefreshInFlight = null;
151325
151590
  /** Single-flight guard for the privacy-mask cap refresh. */
@@ -151453,6 +151718,16 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
151453
151718
  };
151454
151719
  }
151455
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
+ }
151456
151731
  return {
151457
151732
  awoke: true,
151458
151733
  durationMs: Date.now() - startedAt
@@ -151578,7 +151853,8 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
151578
151853
  commitSleepState(next, source) {
151579
151854
  if (!this.isBattery) return false;
151580
151855
  if (this.sleeping === next) return false;
151581
- 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;
151582
151858
  if (source === "hub-summary") {
151583
151859
  const sinceCommitMs = Date.now() - this.sleepStateChangedAt;
151584
151860
  if (next === true && this.sleepStateChangedAt > 0 && sinceCommitMs < ReolinkCamera.HUB_SUMMARY_SLEEP_GRACE_MS) {
@@ -152714,14 +152990,14 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
152714
152990
  * swallowed: the window is short, partial progress accumulates across
152715
152991
  * wakes because the slices persist.
152716
152992
  */
152717
- async warmCapSlicesOnWake() {
152993
+ async warmCapSlicesOnWake(opts) {
152718
152994
  const now = Date.now();
152719
152995
  let warmed = 0;
152720
152996
  let skipped = 0;
152721
152997
  for (const [capName, warm] of this.capWarmers) {
152722
152998
  const slice = this.runtimeState.getCapState(capName);
152723
152999
  const fetchedAt = typeof slice?.lastFetchedAt === "number" ? slice.lastFetchedAt : 0;
152724
- 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) {
152725
153001
  skipped += 1;
152726
153002
  continue;
152727
153003
  }
@@ -152746,6 +153022,10 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
152746
153022
  total: this.capWarmers.size
152747
153023
  }
152748
153024
  });
153025
+ return {
153026
+ warmed,
153027
+ skipped
153028
+ };
152749
153029
  }
152750
153030
  /**
152751
153031
  * Ask the snapshot wrapper for a frame while the camera is up, so its
@@ -152811,7 +153091,6 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
152811
153091
  registerStreamParamsCap() {
152812
153092
  const channel = this.getChannel();
152813
153093
  const CAP_NAME = "stream-params";
152814
- const STALE_MS = 1e4;
152815
153094
  /** Round-trip `getEnc`, map into the cap status schema, write slice. */
152816
153095
  const refreshFromCamera = async () => {
152817
153096
  if (this.streamParamsRefreshInFlight) return this.streamParamsRefreshInFlight;
@@ -152844,13 +153123,13 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
152844
153123
  this.streamParamsRefreshInFlight = null;
152845
153124
  }
152846
153125
  };
152847
- this.refreshStreamParamsFromCamera = refreshFromCamera;
152848
153126
  const bridge = createRuntimeStateBridge({
152849
153127
  runtimeState: this.runtimeState,
152850
153128
  cap: streamParamsCapability,
152851
153129
  ownDeviceId: this.id,
152852
153130
  refresh: this.sleepGatedRefresh(CAP_NAME, refreshFromCamera),
152853
- staleMs: STALE_MS,
153131
+ staleMs: OPERATOR_WRITTEN_STALE_MS,
153132
+ staleRead: "serve-and-revalidate",
152854
153133
  empty: () => ({ lastFetchedAt: 0 })
152855
153134
  });
152856
153135
  const resolveOptions = async () => this.resolveCapOptions({
@@ -152935,7 +153214,6 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
152935
153214
  registerMotionZonesCap() {
152936
153215
  const channel = this.getChannel();
152937
153216
  const CAP_NAME = "motion-zones";
152938
- const STALE_MS = 1e4;
152939
153217
  /** Round-trip `getMotionAlarm`, map into the cap status schema, write slice. */
152940
153218
  const refreshFromCamera = async () => {
152941
153219
  if (this.motionZonesRefreshInFlight) return this.motionZonesRefreshInFlight;
@@ -152985,7 +153263,8 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
152985
153263
  cap: motionZonesCapability,
152986
153264
  ownDeviceId: this.id,
152987
153265
  refresh: this.sleepGatedRefresh(CAP_NAME, refreshFromCamera),
152988
- staleMs: STALE_MS,
153266
+ staleMs: OPERATOR_WRITTEN_STALE_MS,
153267
+ staleRead: "serve-and-revalidate",
152989
153268
  empty: () => ({
152990
153269
  enabled: false,
152991
153270
  sensitivity: 1,
@@ -153316,7 +153595,6 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
153316
153595
  registerDayNightCap() {
153317
153596
  const channel = this.getChannel();
153318
153597
  const CAP_NAME = "day-night";
153319
- const STALE_MS = 1e4;
153320
153598
  /** Round-trip `getVideoInput` + `getDayNightThreshold`, map into the
153321
153599
  * cap status schema, write slice. */
153322
153600
  const refreshFromCamera = async () => {
@@ -153359,7 +153637,8 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
153359
153637
  cap: dayNightCapability,
153360
153638
  ownDeviceId: this.id,
153361
153639
  refresh: this.sleepGatedRefresh(CAP_NAME, refreshFromCamera),
153362
- staleMs: STALE_MS,
153640
+ staleMs: OPERATOR_WRITTEN_STALE_MS,
153641
+ staleRead: "serve-and-revalidate",
153363
153642
  empty: () => ({
153364
153643
  mode: "auto",
153365
153644
  lastFetchedAt: 0
@@ -153452,7 +153731,6 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
153452
153731
  registerImageSettingsCap() {
153453
153732
  const channel = this.getChannel();
153454
153733
  const CAP_NAME = "image-settings";
153455
- const STALE_MS = 1e4;
153456
153734
  const SLIDER_MIN = 0;
153457
153735
  const SLIDER_MAX = 255;
153458
153736
  const NORMALIZED_RANGE = {
@@ -153496,7 +153774,8 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
153496
153774
  cap: imageSettingsCapability,
153497
153775
  ownDeviceId: this.id,
153498
153776
  refresh: this.sleepGatedRefresh(CAP_NAME, refreshFromCamera),
153499
- staleMs: STALE_MS,
153777
+ staleMs: OPERATOR_WRITTEN_STALE_MS,
153778
+ staleRead: "serve-and-revalidate",
153500
153779
  empty: () => ({ lastFetchedAt: 0 })
153501
153780
  }).getStatus,
153502
153781
  getOptions: async ({ deviceId }) => {
@@ -153813,7 +154092,6 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
153813
154092
  /** Stale-after window for served-from-cache reads. ~10s is short
153814
154093
  * enough that the UI's 5s poll always sees a fresh round-trip,
153815
154094
  * long enough that two consumers in the same tick fold into one. */
153816
- const STALE_MS = 1e4;
153817
154095
  const SUPPORTED_TARGET_TYPES = [
153818
154096
  {
153819
154097
  value: "people",
@@ -153879,7 +154157,8 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
153879
154157
  cap: ptzAutotrackCapability,
153880
154158
  ownDeviceId: this.id,
153881
154159
  refresh: this.sleepGatedRefresh(CAP_NAME, refreshFromCamera),
153882
- staleMs: STALE_MS,
154160
+ staleMs: OPERATOR_WRITTEN_STALE_MS,
154161
+ staleRead: "serve-and-revalidate",
153883
154162
  empty: () => ({
153884
154163
  enabled: false,
153885
154164
  lastChangedAt: 0,
@@ -155640,7 +155919,7 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
155640
155919
  id: "driver-refresh",
155641
155920
  tab: "general",
155642
155921
  title: "Camera data",
155643
- 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.",
155644
155923
  columns: 1,
155645
155924
  fields: [{
155646
155925
  type: "device-action-button",
@@ -155648,7 +155927,8 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
155648
155927
  label: "Refresh from camera",
155649
155928
  buttonLabel: "Refresh now",
155650
155929
  action: "refresh-settings",
155651
- variant: "default"
155930
+ variant: "default",
155931
+ successMessage: "Camera awake: {awake} · settings re-read: {snapshotRefreshed} · slices refreshed: {capsWarmed}/{capsTotal}"
155652
155932
  }]
155653
155933
  },
155654
155934
  {
@@ -156135,20 +156415,6 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
156135
156415
  sleeping: this.isBattery && this.sleeping,
156136
156416
  now: Date.now()
156137
156417
  }),
156138
- {
156139
- id: "privacy-mask",
156140
- tab: "image",
156141
- title: "Privacy mask",
156142
- 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.",
156143
- columns: 1,
156144
- fields: [{
156145
- type: "boolean",
156146
- key: "privacyMaskEnabled",
156147
- label: "Privacy mask enabled",
156148
- default: cache?.maskSnapshot?.enabled ?? false,
156149
- style: "switch"
156150
- }]
156151
- },
156152
156418
  ...cache?.autoFocusSnapshot?.supported === true ? [{
156153
156419
  id: "auto-focus",
156154
156420
  tab: "image",
@@ -156503,7 +156769,6 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
156503
156769
  audioVolume: this.config.get("audioVolume") ?? 50,
156504
156770
  audioTalkAndReplyVolume: this.config.get("audioTalkAndReplyVolume") ?? 50,
156505
156771
  audioVisitorVolume: this.config.get("audioVisitorVolume") ?? 50,
156506
- privacyMaskEnabled: this.config.get("privacyMaskEnabled") ?? cache?.maskSnapshot?.enabled ?? false,
156507
156772
  audioNoiseLevel: this.config.get("audioNoiseLevel") ?? (cache?.audioNoiseSnapshot?.enabled ? cache.audioNoiseSnapshot?.level ?? 1 : 0),
156508
156773
  autoFocusEnabled: this.config.get("autoFocusEnabled") ?? cache?.autoFocusSnapshot?.enabled ?? true,
156509
156774
  pirReduceFalseAlarm: this.config.get("pirReduceFalseAlarm") ?? cache?.pirSnapshot?.reduceFalseAlarm ?? false,
@@ -156519,6 +156784,7 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
156519
156784
  }
156520
156785
  async applySettingsPatch(patch) {
156521
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 } });
156522
156788
  const emailFields = {
156523
156789
  emailSmtpServer,
156524
156790
  emailSmtpPort,
@@ -156829,13 +157095,6 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
156829
157095
  this.ctx.logger.warn("reolink setNtp push failed", { meta: { error: err instanceof Error ? err.message : String(err) } });
156830
157096
  }
156831
157097
  }
156832
- if ("privacyMaskEnabled" in patch) try {
156833
- const api = await this.ensureApi();
156834
- const v = typedPatch.privacyMaskEnabled;
156835
- if (typeof v === "boolean") await api.setMask(this.getChannel(), { enable: v });
156836
- } catch (err) {
156837
- this.ctx.logger.warn("mask push failed", { meta: { error: err instanceof Error ? err.message : String(err) } });
156838
- }
156839
157098
  if ("audioNoiseLevel" in patch) try {
156840
157099
  const api = await this.ensureApi();
156841
157100
  const v = typedPatch.audioNoiseLevel;
@@ -156893,15 +157152,40 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
156893
157152
  }
156894
157153
  }
156895
157154
  const changedSlices = slicesForPatch(patch);
156896
- if (changedSlices.size > 0) await this.refreshParentSettingsSnapshot(changedSlices).catch((err) => {
156897
- this.ctx.logger.info("reolink targeted settings refresh failed — snapshots left stale", {
156898
- tags: { deviceId: this.id },
156899
- meta: {
156900
- slices: [...changedSlices],
156901
- error: err instanceof Error ? err.message : String(err)
156902
- }
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
+ });
156903
157164
  });
156904
- });
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}"`);
156905
157189
  }
156906
157190
  /**
156907
157191
  * Optionally inject an already-logged-in Baichuan api — used by