@camstack/addon-provider-reolink 1.2.86 → 1.2.88

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.
@@ -0,0 +1,2 @@
1
+ import { d as testChannelStreams, i as collectMultifocalDiagnostics } from "./addon.mjs";
2
+ export { collectMultifocalDiagnostics, testChannelStreams };
package/dist/addon.js CHANGED
@@ -13532,6 +13532,24 @@ var deviceProviderCapability = {
13532
13532
  })
13533
13533
  }
13534
13534
  };
13535
+ /**
13536
+ * Device Manager capability — hub-side singleton that unifies device persistence,
13537
+ * live registry access, and all management operations into a single tRPC surface.
13538
+ *
13539
+ * Replaces:
13540
+ * - `device-persistence` capability (persistence methods absorbed here)
13541
+ * - `device-management.router.ts` (deleted in Phase 2)
13542
+ * - `device-ops.router.ts` (compat layer — deleted; device-provider ops absorbed here)
13543
+ *
13544
+ * All device provider addons (rtsp, onvif, frigate, …) are hub-local: they may
13545
+ * fork into separate processes but never run on remote cluster agents. Therefore:
13546
+ * - No nodeId routing needed — this is a pure hub singleton.
13547
+ * - The hub's DeviceRegistry is the single source of truth for all live devices.
13548
+ * - No shadow registry or cross-node aggregation required.
13549
+ *
13550
+ * Forked workers register devices back to the hub via `ctx.devices`
13551
+ * (DeviceManagerApi → ctx.api.deviceManager.registerDevice), same as today.
13552
+ */
13535
13553
  /** One child-placement directive on a container's `childLayout`. Structurally
13536
13554
  * identical to `ChildLayoutEntry` in `device-management.ts` — the cap wire
13537
13555
  * shape for the same field. The child is identified by its re-sync-stable
@@ -13630,7 +13648,12 @@ var ConfigEntrySchema = object({
13630
13648
  value: unknown(),
13631
13649
  description: string().optional()
13632
13650
  });
13633
- var LinkedDevicesModeSchema = _enum(["auto", "manual"]);
13651
+ /**
13652
+ * Only `manual` since D356: the operator picks, nothing links itself. The
13653
+ * field survives on the wire because a viewer already OTA'd parses it —
13654
+ * drop it once no shipped client reads `mode`.
13655
+ */
13656
+ var LinkedDevicesModeSchema = _enum(["manual"]);
13634
13657
  /** One resolved linked device — the compact projection consumers need. */
13635
13658
  var LinkedDeviceSchema = object({
13636
13659
  deviceId: number(),
@@ -19298,6 +19321,9 @@ var RetrainStatusSchema = _enum([
19298
19321
  *
19299
19322
  * `debug` does NOT pin; it is attention, not durability.
19300
19323
  *
19324
+ * `debugNote` is the operator's own words about WHAT should be checked on this
19325
+ * track, and it lives and dies with `debug` — see {@link MAX_TRACK_DEBUG_NOTE_LEN}.
19326
+ *
19301
19327
  * `favourited` IS a BOOLEAN pin (durability bit), not a retrain lifecycle.
19302
19328
  * A favourited track is skipped by retention the same way `staging` is, but
19303
19329
  * it does not enter `none|staging|trained` and has no staging budget.
@@ -19308,6 +19334,21 @@ var TrackFlagFields = {
19308
19334
  markForTrain: boolean().optional(),
19309
19335
  /** Operator marked this track for diagnostic attention. */
19310
19336
  debug: boolean().optional(),
19337
+ /**
19338
+ * What the operator wants CHECKED on this track, in their own words.
19339
+ *
19340
+ * The flag alone says "look at this" and nothing about what for; a debug set
19341
+ * reviewed hours later is a list of tracks with no question attached. Bound
19342
+ * to `debug` on the WRITE side — the body clears it when `debug` goes off,
19343
+ * and refuses it on a track whose debug is off — so a note can never outlive
19344
+ * the attention it belongs to.
19345
+ *
19346
+ * `''` is a real value ("marked, nothing to add"); the ABSENT key means
19347
+ * "leave whatever is stored alone", which is what lets a surface turn debug
19348
+ * on without a note (a cancelled prompt) and what lets it edit the note
19349
+ * without touching the flag.
19350
+ */
19351
+ debugNote: string().max(500).optional(),
19311
19352
  /** Operator favourited this track. Pins it against pruning. */
19312
19353
  favourited: boolean().optional()
19313
19354
  };
@@ -19334,6 +19375,12 @@ var TrackFlagsSchema = object({
19334
19375
  trackId: string(),
19335
19376
  markForTrain: boolean(),
19336
19377
  debug: boolean(),
19378
+ /** The note as it STANDS after the write — never absent here (a track with no
19379
+ * note reports `''`), for the same reason the booleans are required: a
19380
+ * surface that has just written must be able to render the note it now owns
19381
+ * without a re-fetch, and an absent key would read as "unchanged" on a
19382
+ * screen that has no previous value to keep. */
19383
+ debugNote: string(),
19337
19384
  favourited: boolean(),
19338
19385
  /** The lifecycle state the boolean was derived from. Required here (unlike on
19339
19386
  * a track row) because this shape is only ever produced by the write body,
@@ -34180,6 +34227,17 @@ var BaseDeviceProvider = class extends BaseAddon {
34180
34227
  return toDeviceSummary(device, this.addonId);
34181
34228
  }
34182
34229
  };
34230
+ /**
34231
+ * Default silence budget before a sleeping battery camera is called gone.
34232
+ *
34233
+ * Sized off the mechanism that produces contact, not off taste: a battery cam
34234
+ * on this deployment surfaces a firmware push (battery level, sleep/wake, or a
34235
+ * motion email) on the order of hours even with no visitors, and the snapshot
34236
+ * wrapper's own battery window is 1 hour. Six hours is therefore several
34237
+ * missed opportunities, not one — so a single quiet night does not raise a
34238
+ * fault, and a genuinely flat camera is named well inside a day.
34239
+ */
34240
+ var BATTERY_UNREACHABLE_AFTER_MS = 360 * 6e4;
34183
34241
  DeviceType.Cover, DeviceType.Valve, DeviceType.Humidifier, DeviceType.WaterHeater, DeviceType.Camera, DeviceType.Hub, DeviceType.Switch, DeviceType.Siren, DeviceType.Light, DeviceType.Fan, DeviceType.Sensor, DeviceType.Thermostat, DeviceType.Climate, DeviceType.Button, DeviceType.EventEmitter, DeviceType.Update, DeviceType.Generic, DeviceType.Notifier, DeviceType.Script, DeviceType.Automation, DeviceType.Lock, DeviceType.MediaPlayer, DeviceType.AlarmPanel, DeviceType.Control, DeviceType.Presence, DeviceType.Weather, DeviceType.Vacuum, DeviceType.LawnMower, DeviceType.Container, DeviceType.Image, DeviceType.PetFeeder;
34184
34242
  new Set(Object.values(DeviceType));
34185
34243
  DeviceFeature.BatteryOperated;
@@ -231298,7 +231356,8 @@ var RawReadSliceSchema = _enum([
231298
231356
  "osd",
231299
231357
  "led",
231300
231358
  "pir",
231301
- "autoReboot"
231359
+ "autoReboot",
231360
+ "battery"
231302
231361
  ]);
231303
231362
  /**
231304
231363
  * The allow-list. `Record<RawReadSlice, …>` keeps the catalog and the enum
@@ -231309,17 +231368,26 @@ var RawReadSliceSchema = _enum([
231309
231368
  var RAW_READ_CATALOG = {
231310
231369
  image: {
231311
231370
  command: "getVideoInput",
231312
- snapshotKey: "imageSnapshot",
231371
+ believed: {
231372
+ kind: "deviceCache",
231373
+ snapshotKey: "imageSnapshot"
231374
+ },
231313
231375
  invoke: (api, channel) => api.getVideoInput(channel)
231314
231376
  },
231315
231377
  motion: {
231316
231378
  command: "getMotionAlarm",
231317
- snapshotKey: "motionSnapshot",
231379
+ believed: {
231380
+ kind: "deviceCache",
231381
+ snapshotKey: "motionSnapshot"
231382
+ },
231318
231383
  invoke: (api, channel) => api.getMotionAlarm(channel)
231319
231384
  },
231320
231385
  ai: {
231321
231386
  command: "getAiDetectTypes + getAiDetectionFull (per type)",
231322
- snapshotKey: "aiSensitivitySnapshot",
231387
+ believed: {
231388
+ kind: "deviceCache",
231389
+ snapshotKey: "aiSensitivitySnapshot"
231390
+ },
231323
231391
  invoke: async (api, channel) => {
231324
231392
  const detectTypes = await api.getAiDetectTypes(channel, { timeoutMs: 1500 });
231325
231393
  const perType = {};
@@ -231336,71 +231404,204 @@ var RAW_READ_CATALOG = {
231336
231404
  },
231337
231405
  enc: {
231338
231406
  command: "getEnc",
231339
- snapshotKey: "encSnapshot",
231407
+ believed: {
231408
+ kind: "deviceCache",
231409
+ snapshotKey: "encSnapshot"
231410
+ },
231340
231411
  invoke: (api, channel) => api.getEnc(channel)
231341
231412
  },
231342
231413
  encOptions: {
231343
231414
  command: "getEncOptions",
231344
- snapshotKey: "encOptionsSnapshot",
231415
+ believed: {
231416
+ kind: "deviceCache",
231417
+ snapshotKey: "encOptionsSnapshot"
231418
+ },
231345
231419
  invoke: (api, channel) => api.getEncOptions(channel)
231346
231420
  },
231347
231421
  mask: {
231348
231422
  command: "getMask",
231349
- snapshotKey: "maskSnapshot",
231423
+ believed: {
231424
+ kind: "deviceCache",
231425
+ snapshotKey: "maskSnapshot"
231426
+ },
231350
231427
  invoke: (api, channel) => api.getMask(channel)
231351
231428
  },
231352
231429
  audioNoise: {
231353
231430
  command: "getAudioNoise",
231354
- snapshotKey: "audioNoiseSnapshot",
231431
+ believed: {
231432
+ kind: "deviceCache",
231433
+ snapshotKey: "audioNoiseSnapshot"
231434
+ },
231355
231435
  invoke: (api, channel) => api.getAudioNoise(channel)
231356
231436
  },
231357
231437
  autofocus: {
231358
231438
  command: "getAutoFocus",
231359
- snapshotKey: "autoFocusSnapshot",
231439
+ believed: {
231440
+ kind: "deviceCache",
231441
+ snapshotKey: "autoFocusSnapshot"
231442
+ },
231360
231443
  invoke: (api, channel) => api.getAutoFocus(channel, { timeoutMs: 1500 })
231361
231444
  },
231362
231445
  netPort: {
231363
231446
  command: "getNetPort",
231364
- snapshotKey: "netPortSnapshot",
231447
+ believed: {
231448
+ kind: "deviceCache",
231449
+ snapshotKey: "netPortSnapshot"
231450
+ },
231365
231451
  invoke: (api) => api.getNetPort()
231366
231452
  },
231367
231453
  ntp: {
231368
231454
  command: "getNtp",
231369
- snapshotKey: "ntpSnapshot",
231455
+ believed: {
231456
+ kind: "deviceCache",
231457
+ snapshotKey: "ntpSnapshot"
231458
+ },
231370
231459
  invoke: (api) => api.getNtp()
231371
231460
  },
231372
231461
  systemGeneral: {
231373
231462
  command: "getSystemGeneral",
231374
- snapshotKey: "systemGeneralSnapshot",
231463
+ believed: {
231464
+ kind: "deviceCache",
231465
+ snapshotKey: "systemGeneralSnapshot"
231466
+ },
231375
231467
  invoke: (api) => api.getSystemGeneral()
231376
231468
  },
231377
231469
  osd: {
231378
231470
  command: "getOsd",
231379
- snapshotKey: "osdSnapshot",
231471
+ believed: {
231472
+ kind: "deviceCache",
231473
+ snapshotKey: "osdSnapshot"
231474
+ },
231380
231475
  invoke: (api, channel) => api.getOsd(channel)
231381
231476
  },
231382
231477
  led: {
231383
231478
  command: "getIrLights",
231384
- snapshotKey: "ledSnapshot",
231479
+ believed: {
231480
+ kind: "deviceCache",
231481
+ snapshotKey: "ledSnapshot"
231482
+ },
231385
231483
  invoke: (api, channel) => api.getIrLights(channel)
231386
231484
  },
231387
231485
  pir: {
231388
231486
  command: "getPirInfo",
231389
- snapshotKey: "pirSnapshot",
231487
+ believed: {
231488
+ kind: "deviceCache",
231489
+ snapshotKey: "pirSnapshot"
231490
+ },
231390
231491
  invoke: (api, channel) => api.getPirInfo(channel)
231391
231492
  },
231392
231493
  autoReboot: {
231393
231494
  command: "getAutoReboot",
231394
- snapshotKey: "autoRebootSnapshot",
231495
+ believed: {
231496
+ kind: "deviceCache",
231497
+ snapshotKey: "autoRebootSnapshot"
231498
+ },
231395
231499
  invoke: (api) => api.getAutoReboot()
231500
+ },
231501
+ /**
231502
+ * Battery — one slice, TWO firmware reads, chosen by how the camera is
231503
+ * attached. They are not variants of one call:
231504
+ *
231505
+ * - **standalone**: `getBatteryInfo(channel)` — the camera's own
231506
+ * answer, one `BatteryInfo`;
231507
+ * - **hub child**: `getAllChannelsBatteryInfo()` — a HUB-level CGI
231508
+ * keyed by channel, projected to the requested one here so the slice
231509
+ * stays per-camera like the other fifteen (a "cluster-wide" second
231510
+ * slice shape would have to be understood by every caller).
231511
+ *
231512
+ * Both vocabularies carry `adapterStatus` next to `chargeStatus`, which
231513
+ * is why this slice exists: the provider keeps only the latter, and
231514
+ * whether `adapterStatus` MOVES was previously observable exactly once
231515
+ * per hub process (`ReolinkHub.batteryShapeLogged`). The answer is
231516
+ * returned as the firmware gave it, under a `readVia` that names the path
231517
+ * — NOT normalised into a shared shape. The paths do not carry the same
231518
+ * fields (`voltage`/`lowPower` on the camera read, `channelsReported` and
231519
+ * the channel-status row on the hub read); a field the firmware did not
231520
+ * send must be ABSENT, because a default here would be indistinguishable
231521
+ * from a reading (D315).
231522
+ *
231523
+ * A camera that cannot answer at all throws, and the read comes back
231524
+ * `read-failed` carrying the firmware's reason — "we could not read it"
231525
+ * must never be served as "it has no battery".
231526
+ */
231527
+ battery: {
231528
+ command: "getBatteryInfo (standalone) / getAllChannelsBatteryInfo projected (hub child)",
231529
+ believed: {
231530
+ kind: "capRuntimeState",
231531
+ capName: "battery"
231532
+ },
231533
+ servedByParentHub: true,
231534
+ invoke: async (api, channel, ctx) => {
231535
+ if (!ctx.hubChild) return {
231536
+ readVia: "camera",
231537
+ command: "getBatteryInfo",
231538
+ channel,
231539
+ batteryInfo: await api.getBatteryInfo(channel)
231540
+ };
231541
+ const byChannel = (await api.getAllChannelsBatteryInfo()).batteryInfoData;
231542
+ const channelsReported = Object.keys(byChannel).map(Number).filter((n) => Number.isFinite(n)).sort((a, b) => a - b);
231543
+ const info = byChannel[channel];
231544
+ if (info === void 0) throw new Error(`getAllChannelsBatteryInfo answered for channels [${channelsReported.join(", ")}] but not channel ${channel} — this camera's battery was NOT reported. That is an unanswered read, not "no battery".`);
231545
+ const [cgiBattery, channelStatus] = info.entries;
231546
+ return {
231547
+ readVia: "parentHub",
231548
+ command: "getAllChannelsBatteryInfo",
231549
+ channel,
231550
+ channelsReported,
231551
+ batteryInfo: {
231552
+ ...info,
231553
+ entries: [cgiBattery ?? null, redactChannelIdentity(channelStatus)]
231554
+ }
231555
+ };
231556
+ }
231396
231557
  }
231397
231558
  };
231559
+ /**
231560
+ * The hub's channel-status row (`entries[1]`) carries the camera's Reolink
231561
+ * P2P `uid` — a routable cloud identifier for the device. A charge-state
231562
+ * read has no reason to hand it out, so it is dropped here rather than
231563
+ * relied on not to be looked at. The drop is STATED (`uidRedacted`): a
231564
+ * removed field and a firmware that never sent one must not look alike.
231565
+ * Everything else in the row (channel, name, online, sleep, typeInfo) is
231566
+ * kept verbatim — it is the context that makes the battery row readable.
231567
+ */
231568
+ function redactChannelIdentity(entry) {
231569
+ if (entry === void 0) return null;
231570
+ const { uid, ...rest } = entry;
231571
+ return {
231572
+ ...rest,
231573
+ uidRedacted: uid !== void 0
231574
+ };
231575
+ }
231576
+ /**
231577
+ * Age of a `capRuntimeState` belief. The cap slice carries `lastUpdated`
231578
+ * rather than a freshness-map stamp, and its `empty` seed is `0` — which is
231579
+ * NOT an observation, so it resolves to `never`. A slice that was never
231580
+ * written at all is `never` too: on a battery reading, "we hold no value"
231581
+ * and "we measured 0%" are an alarm apart (see `BatteryStatusSchema`).
231582
+ */
231583
+ function capStateAge(state, now) {
231584
+ if (state === void 0) return { state: "never" };
231585
+ const lastUpdated = state.lastUpdated;
231586
+ if (typeof lastUpdated !== "number" || lastUpdated <= 0) return { state: "never" };
231587
+ return {
231588
+ state: "known",
231589
+ fetchedAt: lastUpdated,
231590
+ ageMs: Math.max(0, now - lastUpdated)
231591
+ };
231592
+ }
231398
231593
  /** What CamStack currently believes about the slice, with its own age. */
231399
231594
  var BelievedStateSchema = object({
231400
- /** The persisted projection (`deviceCache.<snapshotKey>`), verbatim. */
231595
+ /** The persisted projection / cap slice, verbatim. */
231401
231596
  snapshot: unknown(),
231402
- /** deviceCache field the projection lives in. */
231403
- snapshotKey: string(),
231597
+ /** Where that belief lives — mirrors {@link BelievedSource}. */
231598
+ source: discriminatedUnion("kind", [object({
231599
+ kind: literal("deviceCache"),
231600
+ snapshotKey: string()
231601
+ }), object({
231602
+ kind: literal("capRuntimeState"),
231603
+ capName: literal("battery")
231604
+ })]),
231404
231605
  /** Tri-state freshness — `never` / `unknown` (legacy, no stamp) / `known`. */
231405
231606
  age: union([
231406
231607
  object({ state: literal("never") }),
@@ -231439,11 +231640,17 @@ var RawReadInputSchema = object({
231439
231640
  deviceId: number().int().nonnegative(),
231440
231641
  slice: RawReadSliceSchema
231441
231642
  });
231442
- function believedState(cache, entry, now) {
231443
- const age = resolveSnapshotAge(cache, entry.snapshotKey, now);
231643
+ function believedState(deps, entry) {
231644
+ const source = entry.believed;
231645
+ if (source.kind === "capRuntimeState") return {
231646
+ snapshot: toPlainJson(deps.batteryState),
231647
+ source,
231648
+ age: capStateAge(deps.batteryState, deps.now)
231649
+ };
231650
+ const age = resolveSnapshotAge(deps.cache, source.snapshotKey, deps.now);
231444
231651
  return {
231445
- snapshot: toPlainJson(cache === void 0 ? void 0 : Reflect.get(cache, entry.snapshotKey)),
231446
- snapshotKey: entry.snapshotKey,
231652
+ snapshot: toPlainJson(deps.cache === void 0 ? void 0 : Reflect.get(deps.cache, source.snapshotKey)),
231653
+ source,
231447
231654
  age
231448
231655
  };
231449
231656
  }
@@ -231463,8 +231670,9 @@ function toPlainJson(value) {
231463
231670
  */
231464
231671
  async function performRawRead(slice, deps) {
231465
231672
  const entry = RAW_READ_CATALOG[slice];
231466
- const believed = believedState(deps.cache, entry, deps.now);
231467
- if (deps.sleeping) {
231673
+ const believed = believedState(deps, entry);
231674
+ const servedWithoutTouchingCamera = deps.hubChild && entry.servedByParentHub === true;
231675
+ if (deps.sleeping && !servedWithoutTouchingCamera) {
231468
231676
  deps.logger.info("reolink raw read refused — battery cam is sleeping", {
231469
231677
  tags: { deviceId: deps.deviceId },
231470
231678
  meta: { slice }
@@ -231474,7 +231682,7 @@ async function performRawRead(slice, deps) {
231474
231682
  deviceId: deps.deviceId,
231475
231683
  slice,
231476
231684
  reason: "sleeping",
231477
- message: "Battery camera is asleep — a raw read would wake it, so it is refused. The believed state below is what CamStack currently serves.",
231685
+ message: deps.hubChild ? `Battery camera is asleep and the "${slice}" read is addressed to the camera channel, which would wake it — refused. The believed state below is what CamStack currently serves. (The "battery" slice is answered by the parent hub and stays readable while the camera sleeps.)` : "Battery camera is asleep — a raw read would wake it, so it is refused. The believed state below is what CamStack currently serves.",
231478
231686
  believed
231479
231687
  };
231480
231688
  }
@@ -231500,12 +231708,14 @@ async function performRawRead(slice, deps) {
231500
231708
  };
231501
231709
  }
231502
231710
  try {
231503
- const payload = await entry.invoke(api, deps.channel);
231711
+ const payload = await entry.invoke(api, deps.channel, { hubChild: deps.hubChild });
231504
231712
  deps.logger.info("reolink raw read served", {
231505
231713
  tags: { deviceId: deps.deviceId },
231506
231714
  meta: {
231507
231715
  slice,
231508
- command: entry.command
231716
+ command: entry.command,
231717
+ viaParentHub: servedWithoutTouchingCamera,
231718
+ cameraWasSleeping: deps.sleeping
231509
231719
  }
231510
231720
  });
231511
231721
  return {
@@ -231564,6 +231774,67 @@ var reolinkDebugActions = defineCustomActions({ debugRawRead: customAction(RawRe
231564
231774
  auth: "admin"
231565
231775
  }) });
231566
231776
  //#endregion
231777
+ //#region src/battery-charging.ts
231778
+ /**
231779
+ * Da `adapterStatus` + `chargeStatus` alla sorgente di alimentazione.
231780
+ *
231781
+ * I due campi rispondono a DUE domande diverse, e confonderle è il bug che
231782
+ * questo modulo esiste per chiudere:
231783
+ *
231784
+ * - `adapterStatus` — l'alimentazione è COLLEGATA? Il firmware manda 0/1/2
231785
+ * (nessuna · adattatore DC · pannello solare) e il confine dell'hub lo
231786
+ * traduce in `'none' | 'dc' | 'solarPanel'` (`mapHubChargeStatus` e
231787
+ * `mapHubAdapterStatus` in `reolink-hub.ts`), perché il vocabolario del
231788
+ * percorso standalone è già a stringhe.
231789
+ * - `chargeStatus` — la CELLA cosa sta facendo? 0 ferma · 1 in carica ·
231790
+ * 2 carica completa.
231791
+ *
231792
+ * La vecchia derivazione consultava l'adattatore SOLO per cercarci 'solar', e
231793
+ * un DC collegato cadeva nel ripiego `'none'`. Due Argus 3E lo nascondevano per
231794
+ * caso: piene e collegate rispondono `chargeStatus: 2`, che il ramo `isCharging`
231795
+ * mappava su `'dc'` per la ragione sbagliata. La Argus MagiCam (batteryVersion
231796
+ * 1, firmware 2026) collegata in permanenza risponde `chargeStatus: 0`, e per
231797
+ * tutta la sua vita è stata riportata NON in carica.
231798
+ *
231799
+ * Che `adapterStatus` sia un segnale vero e non una costante è MISURATO: il
231800
+ * 2026-09-05 l'operatore ha staccato il device 3628 e il payload grezzo è
231801
+ * passato da `adapterStatus: 1` a `0`, con `chargeStatus: 0` da entrambe le
231802
+ * parti — cioè `chargeStatus` da solo NON distingue i due stati.
231803
+ *
231804
+ * ⚠️ Questa funzione non sa ancora dire "non lo so": `BatteryStatus['charging']`
231805
+ * non ha un valore `unknown`, quindi due campi illeggibili producono `'none'`,
231806
+ * che è un valore NEGATIVO al posto di un non-noto. È il difetto D315 che
231807
+ * `percentage` ha già chiuso (è `nullable`) e questo campo no; chiuderlo tocca
231808
+ * `battery.cap.ts`, cioè la closure del server, e vive nel proprio lavoro.
231809
+ */
231810
+ function deriveChargingSource(adapterStatus, chargeStatus) {
231811
+ const adapter = (adapterStatus ?? "").toLowerCase();
231812
+ const charge = (chargeStatus ?? "").toLowerCase();
231813
+ if (adapter.includes("solar")) return "solar";
231814
+ if (adapter === "dc") return "dc";
231815
+ if (charge === "charging" || charge === "chargecomplete") return "dc";
231816
+ return "none";
231817
+ }
231818
+ /**
231819
+ * Did this reading SAY anything about the power source? A battery push may
231820
+ * carry only the level (every `BatteryInfo` field is optional and nodelink
231821
+ * dispatches as soon as one is present); a caller that derives `charging`
231822
+ * from such a push turns "nothing was said" into 'none' and overwrites a known
231823
+ * 'dc'. When this is false the caller keeps its previous belief.
231824
+ */
231825
+ function reportsPowerSource(info) {
231826
+ return info.adapterStatus !== void 0 || info.chargeStatus !== void 0;
231827
+ }
231828
+ //#endregion
231829
+ //#region src/battery-contact-expiry.ts
231830
+ function shouldWakeForContact(input) {
231831
+ if (input.hubChild) return false;
231832
+ if (!input.sleeping) return false;
231833
+ const last = input.lastContactAt;
231834
+ if (typeof last !== "number" || last <= 0) return false;
231835
+ return input.nowMs - last + input.tickMs >= input.unreachableAfterMs;
231836
+ }
231837
+ //#endregion
231567
231838
  //#region src/log-channels.ts
231568
231839
  /**
231569
231840
  * The diagnostic log CHANNELS `provider-reolink` declares.
@@ -236339,8 +236610,10 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
236339
236610
  deviceId: this.id,
236340
236611
  channel: this.getChannel(),
236341
236612
  sleeping: this.isBattery && this.sleeping,
236613
+ hubChild: this.isHubChild(),
236342
236614
  getApi: () => this.ensureApi(),
236343
236615
  cache: this.config.get("deviceCache"),
236616
+ batteryState: this.runtimeState.getCapState("battery"),
236344
236617
  logger: this.ctx.logger,
236345
236618
  now: Date.now()
236346
236619
  });
@@ -236671,13 +236944,9 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
236671
236944
  * tracker is more reliable.
236672
236945
  */
236673
236946
  mapBatteryInfo(info) {
236674
- const percentage = typeof info.batteryPercent === "number" ? Math.max(0, Math.min(100, Math.round(info.batteryPercent))) : this.state.battery.percentage ?? null;
236675
- const adapter = (info.adapterStatus ?? "").toLowerCase();
236676
- const charge = (info.chargeStatus ?? "").toLowerCase();
236677
- const isCharging = charge === "charging" || charge === "chargecomplete";
236678
236947
  return {
236679
- percentage,
236680
- charging: adapter.includes("solar") ? "solar" : isCharging ? "dc" : "none",
236948
+ percentage: typeof info.batteryPercent === "number" ? Math.max(0, Math.min(100, Math.round(info.batteryPercent))) : this.state.battery.percentage ?? null,
236949
+ charging: reportsPowerSource(info) ? deriveChargingSource(info.adapterStatus, info.chargeStatus) : this.state.battery.charging ?? "none",
236681
236950
  sleeping: info.sleeping === true || this.sleeping,
236682
236951
  lastUpdated: Date.now()
236683
236952
  };
@@ -240226,6 +240495,17 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
240226
240495
  const cycle = (async () => {
240227
240496
  try {
240228
240497
  if (this.isBattery && this.sleeping) {
240498
+ if (shouldWakeForContact({
240499
+ nowMs: Date.now(),
240500
+ lastContactAt: this.state.battery.lastContactAt,
240501
+ sleeping: true,
240502
+ hubChild: this.isHubChild(),
240503
+ unreachableAfterMs: 216e5,
240504
+ tickMs: ReolinkCamera.BATTERY_UPDATE_INTERVAL_MS
240505
+ })) {
240506
+ await this.wakeForContactRefresh();
240507
+ return;
240508
+ }
240229
240509
  this.ctx.logger.debug("battery-update: cam sleeping — skipping cycle without connecting", { tags: { deviceId: this.id } });
240230
240510
  return;
240231
240511
  }
@@ -240270,6 +240550,43 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
240270
240550
  this.batteryUpdateInFlight = cycle;
240271
240551
  return cycle;
240272
240552
  }
240553
+ /**
240554
+ * D355: wake a standalone battery camera ONCE because its last passive
240555
+ * contact is about to expire, and refresh every slice while it is up.
240556
+ * Bounded by `canProactivelyWake` (the shared cooldown) and by the fact
240557
+ * that a wake that answers resets the silence. A wake that fails is left
240558
+ * alone: the silence then reaches the budget and `deriveBatteryPresence`
240559
+ * says `unreachable` — for the first time on evidence.
240560
+ */
240561
+ async wakeForContactRefresh() {
240562
+ if (!this.canProactivelyWake("contact-expiry")) return;
240563
+ this.markWakeIssued();
240564
+ const lastContactAt = this.state.battery.lastContactAt ?? 0;
240565
+ const silenceMs = Date.now() - lastContactAt;
240566
+ this.ctx.logger.info("battery contact expiring — waking once to refresh every slice (D355)", {
240567
+ tags: { deviceId: this.id },
240568
+ meta: {
240569
+ silenceMs,
240570
+ budgetMs: BATTERY_UNREACHABLE_AFTER_MS
240571
+ }
240572
+ });
240573
+ try {
240574
+ await this.ensureApi();
240575
+ } catch (err) {
240576
+ this.ctx.logger.warn("battery contact wake FAILED — silence continues and the camera will read unreachable at the budget", {
240577
+ tags: { deviceId: this.id },
240578
+ meta: {
240579
+ silenceMs,
240580
+ error: err instanceof Error ? err.message : String(err)
240581
+ }
240582
+ });
240583
+ return;
240584
+ }
240585
+ await this.refreshBatteryFromApi("demand").catch(() => {});
240586
+ await this.warmCapSlicesOnWake().catch(() => {});
240587
+ await this.alignAuxDevicesState("wake").catch(() => {});
240588
+ await this.warmSnapshotOnWake().catch(() => {});
240589
+ }
240273
240590
  /** Arm the periodic battery-cam refresh timer. No-op for wired cams
240274
240591
  * (their state is kept fresh by the 10 s aux-align poll and push
240275
240592
  * events). Idempotent. */
@@ -245192,6 +245509,7 @@ exports.collectMultifocalDiagnostics = collectMultifocalDiagnostics;
245192
245509
  exports.collectNativeDiagnostics = collectNativeDiagnostics;
245193
245510
  exports.collectNvrDiagnostics = collectNvrDiagnostics;
245194
245511
  exports.createDiagnosticsBundle = createDiagnosticsBundle;
245512
+ exports.customActions = reolinkDebugActions;
245195
245513
  exports.reolinkCameraSchema = reolinkCameraSchema;
245196
245514
  exports.reolinkDebugActions = reolinkDebugActions;
245197
245515
  exports.runAllDiagnosticsConsecutively = runAllDiagnosticsConsecutively;
package/dist/addon.mjs CHANGED
@@ -13527,6 +13527,24 @@ var deviceProviderCapability = {
13527
13527
  })
13528
13528
  }
13529
13529
  };
13530
+ /**
13531
+ * Device Manager capability — hub-side singleton that unifies device persistence,
13532
+ * live registry access, and all management operations into a single tRPC surface.
13533
+ *
13534
+ * Replaces:
13535
+ * - `device-persistence` capability (persistence methods absorbed here)
13536
+ * - `device-management.router.ts` (deleted in Phase 2)
13537
+ * - `device-ops.router.ts` (compat layer — deleted; device-provider ops absorbed here)
13538
+ *
13539
+ * All device provider addons (rtsp, onvif, frigate, …) are hub-local: they may
13540
+ * fork into separate processes but never run on remote cluster agents. Therefore:
13541
+ * - No nodeId routing needed — this is a pure hub singleton.
13542
+ * - The hub's DeviceRegistry is the single source of truth for all live devices.
13543
+ * - No shadow registry or cross-node aggregation required.
13544
+ *
13545
+ * Forked workers register devices back to the hub via `ctx.devices`
13546
+ * (DeviceManagerApi → ctx.api.deviceManager.registerDevice), same as today.
13547
+ */
13530
13548
  /** One child-placement directive on a container's `childLayout`. Structurally
13531
13549
  * identical to `ChildLayoutEntry` in `device-management.ts` — the cap wire
13532
13550
  * shape for the same field. The child is identified by its re-sync-stable
@@ -13625,7 +13643,12 @@ var ConfigEntrySchema = object({
13625
13643
  value: unknown(),
13626
13644
  description: string().optional()
13627
13645
  });
13628
- var LinkedDevicesModeSchema = _enum(["auto", "manual"]);
13646
+ /**
13647
+ * Only `manual` since D356: the operator picks, nothing links itself. The
13648
+ * field survives on the wire because a viewer already OTA'd parses it —
13649
+ * drop it once no shipped client reads `mode`.
13650
+ */
13651
+ var LinkedDevicesModeSchema = _enum(["manual"]);
13629
13652
  /** One resolved linked device — the compact projection consumers need. */
13630
13653
  var LinkedDeviceSchema = object({
13631
13654
  deviceId: number(),
@@ -19293,6 +19316,9 @@ var RetrainStatusSchema = _enum([
19293
19316
  *
19294
19317
  * `debug` does NOT pin; it is attention, not durability.
19295
19318
  *
19319
+ * `debugNote` is the operator's own words about WHAT should be checked on this
19320
+ * track, and it lives and dies with `debug` — see {@link MAX_TRACK_DEBUG_NOTE_LEN}.
19321
+ *
19296
19322
  * `favourited` IS a BOOLEAN pin (durability bit), not a retrain lifecycle.
19297
19323
  * A favourited track is skipped by retention the same way `staging` is, but
19298
19324
  * it does not enter `none|staging|trained` and has no staging budget.
@@ -19303,6 +19329,21 @@ var TrackFlagFields = {
19303
19329
  markForTrain: boolean().optional(),
19304
19330
  /** Operator marked this track for diagnostic attention. */
19305
19331
  debug: boolean().optional(),
19332
+ /**
19333
+ * What the operator wants CHECKED on this track, in their own words.
19334
+ *
19335
+ * The flag alone says "look at this" and nothing about what for; a debug set
19336
+ * reviewed hours later is a list of tracks with no question attached. Bound
19337
+ * to `debug` on the WRITE side — the body clears it when `debug` goes off,
19338
+ * and refuses it on a track whose debug is off — so a note can never outlive
19339
+ * the attention it belongs to.
19340
+ *
19341
+ * `''` is a real value ("marked, nothing to add"); the ABSENT key means
19342
+ * "leave whatever is stored alone", which is what lets a surface turn debug
19343
+ * on without a note (a cancelled prompt) and what lets it edit the note
19344
+ * without touching the flag.
19345
+ */
19346
+ debugNote: string().max(500).optional(),
19306
19347
  /** Operator favourited this track. Pins it against pruning. */
19307
19348
  favourited: boolean().optional()
19308
19349
  };
@@ -19329,6 +19370,12 @@ var TrackFlagsSchema = object({
19329
19370
  trackId: string(),
19330
19371
  markForTrain: boolean(),
19331
19372
  debug: boolean(),
19373
+ /** The note as it STANDS after the write — never absent here (a track with no
19374
+ * note reports `''`), for the same reason the booleans are required: a
19375
+ * surface that has just written must be able to render the note it now owns
19376
+ * without a re-fetch, and an absent key would read as "unchanged" on a
19377
+ * screen that has no previous value to keep. */
19378
+ debugNote: string(),
19332
19379
  favourited: boolean(),
19333
19380
  /** The lifecycle state the boolean was derived from. Required here (unlike on
19334
19381
  * a track row) because this shape is only ever produced by the write body,
@@ -34175,6 +34222,17 @@ var BaseDeviceProvider = class extends BaseAddon {
34175
34222
  return toDeviceSummary(device, this.addonId);
34176
34223
  }
34177
34224
  };
34225
+ /**
34226
+ * Default silence budget before a sleeping battery camera is called gone.
34227
+ *
34228
+ * Sized off the mechanism that produces contact, not off taste: a battery cam
34229
+ * on this deployment surfaces a firmware push (battery level, sleep/wake, or a
34230
+ * motion email) on the order of hours even with no visitors, and the snapshot
34231
+ * wrapper's own battery window is 1 hour. Six hours is therefore several
34232
+ * missed opportunities, not one — so a single quiet night does not raise a
34233
+ * fault, and a genuinely flat camera is named well inside a day.
34234
+ */
34235
+ var BATTERY_UNREACHABLE_AFTER_MS = 360 * 6e4;
34178
34236
  DeviceType.Cover, DeviceType.Valve, DeviceType.Humidifier, DeviceType.WaterHeater, DeviceType.Camera, DeviceType.Hub, DeviceType.Switch, DeviceType.Siren, DeviceType.Light, DeviceType.Fan, DeviceType.Sensor, DeviceType.Thermostat, DeviceType.Climate, DeviceType.Button, DeviceType.EventEmitter, DeviceType.Update, DeviceType.Generic, DeviceType.Notifier, DeviceType.Script, DeviceType.Automation, DeviceType.Lock, DeviceType.MediaPlayer, DeviceType.AlarmPanel, DeviceType.Control, DeviceType.Presence, DeviceType.Weather, DeviceType.Vacuum, DeviceType.LawnMower, DeviceType.Container, DeviceType.Image, DeviceType.PetFeeder;
34179
34237
  new Set(Object.values(DeviceType));
34180
34238
  DeviceFeature.BatteryOperated;
@@ -179513,7 +179571,7 @@ ${xml}`);
179513
179571
  * @returns Test results for all stream types and profiles
179514
179572
  */
179515
179573
  async testChannelStreams(channel, logger) {
179516
- const { testChannelStreams } = await import("./DiagnosticsTools-QJ3CRYGA-CcYIIfQN.mjs");
179574
+ const { testChannelStreams } = await import("./DiagnosticsTools-QJ3CRYGA-9NV95vRN.mjs");
179517
179575
  return await testChannelStreams({
179518
179576
  api: this,
179519
179577
  channel: this.normalizeChannel(channel),
@@ -179529,7 +179587,7 @@ ${xml}`);
179529
179587
  * @returns Complete diagnostics for all channels and streams
179530
179588
  */
179531
179589
  async collectMultifocalDiagnostics(logger) {
179532
- const { collectMultifocalDiagnostics } = await import("./DiagnosticsTools-QJ3CRYGA-CcYIIfQN.mjs");
179590
+ const { collectMultifocalDiagnostics } = await import("./DiagnosticsTools-QJ3CRYGA-9NV95vRN.mjs");
179533
179591
  return await collectMultifocalDiagnostics({
179534
179592
  api: this,
179535
179593
  logger
@@ -231278,7 +231336,8 @@ var RawReadSliceSchema = _enum([
231278
231336
  "osd",
231279
231337
  "led",
231280
231338
  "pir",
231281
- "autoReboot"
231339
+ "autoReboot",
231340
+ "battery"
231282
231341
  ]);
231283
231342
  /**
231284
231343
  * The allow-list. `Record<RawReadSlice, …>` keeps the catalog and the enum
@@ -231289,17 +231348,26 @@ var RawReadSliceSchema = _enum([
231289
231348
  var RAW_READ_CATALOG = {
231290
231349
  image: {
231291
231350
  command: "getVideoInput",
231292
- snapshotKey: "imageSnapshot",
231351
+ believed: {
231352
+ kind: "deviceCache",
231353
+ snapshotKey: "imageSnapshot"
231354
+ },
231293
231355
  invoke: (api, channel) => api.getVideoInput(channel)
231294
231356
  },
231295
231357
  motion: {
231296
231358
  command: "getMotionAlarm",
231297
- snapshotKey: "motionSnapshot",
231359
+ believed: {
231360
+ kind: "deviceCache",
231361
+ snapshotKey: "motionSnapshot"
231362
+ },
231298
231363
  invoke: (api, channel) => api.getMotionAlarm(channel)
231299
231364
  },
231300
231365
  ai: {
231301
231366
  command: "getAiDetectTypes + getAiDetectionFull (per type)",
231302
- snapshotKey: "aiSensitivitySnapshot",
231367
+ believed: {
231368
+ kind: "deviceCache",
231369
+ snapshotKey: "aiSensitivitySnapshot"
231370
+ },
231303
231371
  invoke: async (api, channel) => {
231304
231372
  const detectTypes = await api.getAiDetectTypes(channel, { timeoutMs: 1500 });
231305
231373
  const perType = {};
@@ -231316,71 +231384,204 @@ var RAW_READ_CATALOG = {
231316
231384
  },
231317
231385
  enc: {
231318
231386
  command: "getEnc",
231319
- snapshotKey: "encSnapshot",
231387
+ believed: {
231388
+ kind: "deviceCache",
231389
+ snapshotKey: "encSnapshot"
231390
+ },
231320
231391
  invoke: (api, channel) => api.getEnc(channel)
231321
231392
  },
231322
231393
  encOptions: {
231323
231394
  command: "getEncOptions",
231324
- snapshotKey: "encOptionsSnapshot",
231395
+ believed: {
231396
+ kind: "deviceCache",
231397
+ snapshotKey: "encOptionsSnapshot"
231398
+ },
231325
231399
  invoke: (api, channel) => api.getEncOptions(channel)
231326
231400
  },
231327
231401
  mask: {
231328
231402
  command: "getMask",
231329
- snapshotKey: "maskSnapshot",
231403
+ believed: {
231404
+ kind: "deviceCache",
231405
+ snapshotKey: "maskSnapshot"
231406
+ },
231330
231407
  invoke: (api, channel) => api.getMask(channel)
231331
231408
  },
231332
231409
  audioNoise: {
231333
231410
  command: "getAudioNoise",
231334
- snapshotKey: "audioNoiseSnapshot",
231411
+ believed: {
231412
+ kind: "deviceCache",
231413
+ snapshotKey: "audioNoiseSnapshot"
231414
+ },
231335
231415
  invoke: (api, channel) => api.getAudioNoise(channel)
231336
231416
  },
231337
231417
  autofocus: {
231338
231418
  command: "getAutoFocus",
231339
- snapshotKey: "autoFocusSnapshot",
231419
+ believed: {
231420
+ kind: "deviceCache",
231421
+ snapshotKey: "autoFocusSnapshot"
231422
+ },
231340
231423
  invoke: (api, channel) => api.getAutoFocus(channel, { timeoutMs: 1500 })
231341
231424
  },
231342
231425
  netPort: {
231343
231426
  command: "getNetPort",
231344
- snapshotKey: "netPortSnapshot",
231427
+ believed: {
231428
+ kind: "deviceCache",
231429
+ snapshotKey: "netPortSnapshot"
231430
+ },
231345
231431
  invoke: (api) => api.getNetPort()
231346
231432
  },
231347
231433
  ntp: {
231348
231434
  command: "getNtp",
231349
- snapshotKey: "ntpSnapshot",
231435
+ believed: {
231436
+ kind: "deviceCache",
231437
+ snapshotKey: "ntpSnapshot"
231438
+ },
231350
231439
  invoke: (api) => api.getNtp()
231351
231440
  },
231352
231441
  systemGeneral: {
231353
231442
  command: "getSystemGeneral",
231354
- snapshotKey: "systemGeneralSnapshot",
231443
+ believed: {
231444
+ kind: "deviceCache",
231445
+ snapshotKey: "systemGeneralSnapshot"
231446
+ },
231355
231447
  invoke: (api) => api.getSystemGeneral()
231356
231448
  },
231357
231449
  osd: {
231358
231450
  command: "getOsd",
231359
- snapshotKey: "osdSnapshot",
231451
+ believed: {
231452
+ kind: "deviceCache",
231453
+ snapshotKey: "osdSnapshot"
231454
+ },
231360
231455
  invoke: (api, channel) => api.getOsd(channel)
231361
231456
  },
231362
231457
  led: {
231363
231458
  command: "getIrLights",
231364
- snapshotKey: "ledSnapshot",
231459
+ believed: {
231460
+ kind: "deviceCache",
231461
+ snapshotKey: "ledSnapshot"
231462
+ },
231365
231463
  invoke: (api, channel) => api.getIrLights(channel)
231366
231464
  },
231367
231465
  pir: {
231368
231466
  command: "getPirInfo",
231369
- snapshotKey: "pirSnapshot",
231467
+ believed: {
231468
+ kind: "deviceCache",
231469
+ snapshotKey: "pirSnapshot"
231470
+ },
231370
231471
  invoke: (api, channel) => api.getPirInfo(channel)
231371
231472
  },
231372
231473
  autoReboot: {
231373
231474
  command: "getAutoReboot",
231374
- snapshotKey: "autoRebootSnapshot",
231475
+ believed: {
231476
+ kind: "deviceCache",
231477
+ snapshotKey: "autoRebootSnapshot"
231478
+ },
231375
231479
  invoke: (api) => api.getAutoReboot()
231480
+ },
231481
+ /**
231482
+ * Battery — one slice, TWO firmware reads, chosen by how the camera is
231483
+ * attached. They are not variants of one call:
231484
+ *
231485
+ * - **standalone**: `getBatteryInfo(channel)` — the camera's own
231486
+ * answer, one `BatteryInfo`;
231487
+ * - **hub child**: `getAllChannelsBatteryInfo()` — a HUB-level CGI
231488
+ * keyed by channel, projected to the requested one here so the slice
231489
+ * stays per-camera like the other fifteen (a "cluster-wide" second
231490
+ * slice shape would have to be understood by every caller).
231491
+ *
231492
+ * Both vocabularies carry `adapterStatus` next to `chargeStatus`, which
231493
+ * is why this slice exists: the provider keeps only the latter, and
231494
+ * whether `adapterStatus` MOVES was previously observable exactly once
231495
+ * per hub process (`ReolinkHub.batteryShapeLogged`). The answer is
231496
+ * returned as the firmware gave it, under a `readVia` that names the path
231497
+ * — NOT normalised into a shared shape. The paths do not carry the same
231498
+ * fields (`voltage`/`lowPower` on the camera read, `channelsReported` and
231499
+ * the channel-status row on the hub read); a field the firmware did not
231500
+ * send must be ABSENT, because a default here would be indistinguishable
231501
+ * from a reading (D315).
231502
+ *
231503
+ * A camera that cannot answer at all throws, and the read comes back
231504
+ * `read-failed` carrying the firmware's reason — "we could not read it"
231505
+ * must never be served as "it has no battery".
231506
+ */
231507
+ battery: {
231508
+ command: "getBatteryInfo (standalone) / getAllChannelsBatteryInfo projected (hub child)",
231509
+ believed: {
231510
+ kind: "capRuntimeState",
231511
+ capName: "battery"
231512
+ },
231513
+ servedByParentHub: true,
231514
+ invoke: async (api, channel, ctx) => {
231515
+ if (!ctx.hubChild) return {
231516
+ readVia: "camera",
231517
+ command: "getBatteryInfo",
231518
+ channel,
231519
+ batteryInfo: await api.getBatteryInfo(channel)
231520
+ };
231521
+ const byChannel = (await api.getAllChannelsBatteryInfo()).batteryInfoData;
231522
+ const channelsReported = Object.keys(byChannel).map(Number).filter((n) => Number.isFinite(n)).sort((a, b) => a - b);
231523
+ const info = byChannel[channel];
231524
+ if (info === void 0) throw new Error(`getAllChannelsBatteryInfo answered for channels [${channelsReported.join(", ")}] but not channel ${channel} — this camera's battery was NOT reported. That is an unanswered read, not "no battery".`);
231525
+ const [cgiBattery, channelStatus] = info.entries;
231526
+ return {
231527
+ readVia: "parentHub",
231528
+ command: "getAllChannelsBatteryInfo",
231529
+ channel,
231530
+ channelsReported,
231531
+ batteryInfo: {
231532
+ ...info,
231533
+ entries: [cgiBattery ?? null, redactChannelIdentity(channelStatus)]
231534
+ }
231535
+ };
231536
+ }
231376
231537
  }
231377
231538
  };
231539
+ /**
231540
+ * The hub's channel-status row (`entries[1]`) carries the camera's Reolink
231541
+ * P2P `uid` — a routable cloud identifier for the device. A charge-state
231542
+ * read has no reason to hand it out, so it is dropped here rather than
231543
+ * relied on not to be looked at. The drop is STATED (`uidRedacted`): a
231544
+ * removed field and a firmware that never sent one must not look alike.
231545
+ * Everything else in the row (channel, name, online, sleep, typeInfo) is
231546
+ * kept verbatim — it is the context that makes the battery row readable.
231547
+ */
231548
+ function redactChannelIdentity(entry) {
231549
+ if (entry === void 0) return null;
231550
+ const { uid, ...rest } = entry;
231551
+ return {
231552
+ ...rest,
231553
+ uidRedacted: uid !== void 0
231554
+ };
231555
+ }
231556
+ /**
231557
+ * Age of a `capRuntimeState` belief. The cap slice carries `lastUpdated`
231558
+ * rather than a freshness-map stamp, and its `empty` seed is `0` — which is
231559
+ * NOT an observation, so it resolves to `never`. A slice that was never
231560
+ * written at all is `never` too: on a battery reading, "we hold no value"
231561
+ * and "we measured 0%" are an alarm apart (see `BatteryStatusSchema`).
231562
+ */
231563
+ function capStateAge(state, now) {
231564
+ if (state === void 0) return { state: "never" };
231565
+ const lastUpdated = state.lastUpdated;
231566
+ if (typeof lastUpdated !== "number" || lastUpdated <= 0) return { state: "never" };
231567
+ return {
231568
+ state: "known",
231569
+ fetchedAt: lastUpdated,
231570
+ ageMs: Math.max(0, now - lastUpdated)
231571
+ };
231572
+ }
231378
231573
  /** What CamStack currently believes about the slice, with its own age. */
231379
231574
  var BelievedStateSchema = object({
231380
- /** The persisted projection (`deviceCache.<snapshotKey>`), verbatim. */
231575
+ /** The persisted projection / cap slice, verbatim. */
231381
231576
  snapshot: unknown(),
231382
- /** deviceCache field the projection lives in. */
231383
- snapshotKey: string(),
231577
+ /** Where that belief lives — mirrors {@link BelievedSource}. */
231578
+ source: discriminatedUnion("kind", [object({
231579
+ kind: literal("deviceCache"),
231580
+ snapshotKey: string()
231581
+ }), object({
231582
+ kind: literal("capRuntimeState"),
231583
+ capName: literal("battery")
231584
+ })]),
231384
231585
  /** Tri-state freshness — `never` / `unknown` (legacy, no stamp) / `known`. */
231385
231586
  age: union([
231386
231587
  object({ state: literal("never") }),
@@ -231419,11 +231620,17 @@ var RawReadInputSchema = object({
231419
231620
  deviceId: number().int().nonnegative(),
231420
231621
  slice: RawReadSliceSchema
231421
231622
  });
231422
- function believedState(cache, entry, now) {
231423
- const age = resolveSnapshotAge(cache, entry.snapshotKey, now);
231623
+ function believedState(deps, entry) {
231624
+ const source = entry.believed;
231625
+ if (source.kind === "capRuntimeState") return {
231626
+ snapshot: toPlainJson(deps.batteryState),
231627
+ source,
231628
+ age: capStateAge(deps.batteryState, deps.now)
231629
+ };
231630
+ const age = resolveSnapshotAge(deps.cache, source.snapshotKey, deps.now);
231424
231631
  return {
231425
- snapshot: toPlainJson(cache === void 0 ? void 0 : Reflect.get(cache, entry.snapshotKey)),
231426
- snapshotKey: entry.snapshotKey,
231632
+ snapshot: toPlainJson(deps.cache === void 0 ? void 0 : Reflect.get(deps.cache, source.snapshotKey)),
231633
+ source,
231427
231634
  age
231428
231635
  };
231429
231636
  }
@@ -231443,8 +231650,9 @@ function toPlainJson(value) {
231443
231650
  */
231444
231651
  async function performRawRead(slice, deps) {
231445
231652
  const entry = RAW_READ_CATALOG[slice];
231446
- const believed = believedState(deps.cache, entry, deps.now);
231447
- if (deps.sleeping) {
231653
+ const believed = believedState(deps, entry);
231654
+ const servedWithoutTouchingCamera = deps.hubChild && entry.servedByParentHub === true;
231655
+ if (deps.sleeping && !servedWithoutTouchingCamera) {
231448
231656
  deps.logger.info("reolink raw read refused — battery cam is sleeping", {
231449
231657
  tags: { deviceId: deps.deviceId },
231450
231658
  meta: { slice }
@@ -231454,7 +231662,7 @@ async function performRawRead(slice, deps) {
231454
231662
  deviceId: deps.deviceId,
231455
231663
  slice,
231456
231664
  reason: "sleeping",
231457
- message: "Battery camera is asleep — a raw read would wake it, so it is refused. The believed state below is what CamStack currently serves.",
231665
+ message: deps.hubChild ? `Battery camera is asleep and the "${slice}" read is addressed to the camera channel, which would wake it — refused. The believed state below is what CamStack currently serves. (The "battery" slice is answered by the parent hub and stays readable while the camera sleeps.)` : "Battery camera is asleep — a raw read would wake it, so it is refused. The believed state below is what CamStack currently serves.",
231458
231666
  believed
231459
231667
  };
231460
231668
  }
@@ -231480,12 +231688,14 @@ async function performRawRead(slice, deps) {
231480
231688
  };
231481
231689
  }
231482
231690
  try {
231483
- const payload = await entry.invoke(api, deps.channel);
231691
+ const payload = await entry.invoke(api, deps.channel, { hubChild: deps.hubChild });
231484
231692
  deps.logger.info("reolink raw read served", {
231485
231693
  tags: { deviceId: deps.deviceId },
231486
231694
  meta: {
231487
231695
  slice,
231488
- command: entry.command
231696
+ command: entry.command,
231697
+ viaParentHub: servedWithoutTouchingCamera,
231698
+ cameraWasSleeping: deps.sleeping
231489
231699
  }
231490
231700
  });
231491
231701
  return {
@@ -231544,6 +231754,67 @@ var reolinkDebugActions = defineCustomActions({ debugRawRead: customAction(RawRe
231544
231754
  auth: "admin"
231545
231755
  }) });
231546
231756
  //#endregion
231757
+ //#region src/battery-charging.ts
231758
+ /**
231759
+ * Da `adapterStatus` + `chargeStatus` alla sorgente di alimentazione.
231760
+ *
231761
+ * I due campi rispondono a DUE domande diverse, e confonderle è il bug che
231762
+ * questo modulo esiste per chiudere:
231763
+ *
231764
+ * - `adapterStatus` — l'alimentazione è COLLEGATA? Il firmware manda 0/1/2
231765
+ * (nessuna · adattatore DC · pannello solare) e il confine dell'hub lo
231766
+ * traduce in `'none' | 'dc' | 'solarPanel'` (`mapHubChargeStatus` e
231767
+ * `mapHubAdapterStatus` in `reolink-hub.ts`), perché il vocabolario del
231768
+ * percorso standalone è già a stringhe.
231769
+ * - `chargeStatus` — la CELLA cosa sta facendo? 0 ferma · 1 in carica ·
231770
+ * 2 carica completa.
231771
+ *
231772
+ * La vecchia derivazione consultava l'adattatore SOLO per cercarci 'solar', e
231773
+ * un DC collegato cadeva nel ripiego `'none'`. Due Argus 3E lo nascondevano per
231774
+ * caso: piene e collegate rispondono `chargeStatus: 2`, che il ramo `isCharging`
231775
+ * mappava su `'dc'` per la ragione sbagliata. La Argus MagiCam (batteryVersion
231776
+ * 1, firmware 2026) collegata in permanenza risponde `chargeStatus: 0`, e per
231777
+ * tutta la sua vita è stata riportata NON in carica.
231778
+ *
231779
+ * Che `adapterStatus` sia un segnale vero e non una costante è MISURATO: il
231780
+ * 2026-09-05 l'operatore ha staccato il device 3628 e il payload grezzo è
231781
+ * passato da `adapterStatus: 1` a `0`, con `chargeStatus: 0` da entrambe le
231782
+ * parti — cioè `chargeStatus` da solo NON distingue i due stati.
231783
+ *
231784
+ * ⚠️ Questa funzione non sa ancora dire "non lo so": `BatteryStatus['charging']`
231785
+ * non ha un valore `unknown`, quindi due campi illeggibili producono `'none'`,
231786
+ * che è un valore NEGATIVO al posto di un non-noto. È il difetto D315 che
231787
+ * `percentage` ha già chiuso (è `nullable`) e questo campo no; chiuderlo tocca
231788
+ * `battery.cap.ts`, cioè la closure del server, e vive nel proprio lavoro.
231789
+ */
231790
+ function deriveChargingSource(adapterStatus, chargeStatus) {
231791
+ const adapter = (adapterStatus ?? "").toLowerCase();
231792
+ const charge = (chargeStatus ?? "").toLowerCase();
231793
+ if (adapter.includes("solar")) return "solar";
231794
+ if (adapter === "dc") return "dc";
231795
+ if (charge === "charging" || charge === "chargecomplete") return "dc";
231796
+ return "none";
231797
+ }
231798
+ /**
231799
+ * Did this reading SAY anything about the power source? A battery push may
231800
+ * carry only the level (every `BatteryInfo` field is optional and nodelink
231801
+ * dispatches as soon as one is present); a caller that derives `charging`
231802
+ * from such a push turns "nothing was said" into 'none' and overwrites a known
231803
+ * 'dc'. When this is false the caller keeps its previous belief.
231804
+ */
231805
+ function reportsPowerSource(info) {
231806
+ return info.adapterStatus !== void 0 || info.chargeStatus !== void 0;
231807
+ }
231808
+ //#endregion
231809
+ //#region src/battery-contact-expiry.ts
231810
+ function shouldWakeForContact(input) {
231811
+ if (input.hubChild) return false;
231812
+ if (!input.sleeping) return false;
231813
+ const last = input.lastContactAt;
231814
+ if (typeof last !== "number" || last <= 0) return false;
231815
+ return input.nowMs - last + input.tickMs >= input.unreachableAfterMs;
231816
+ }
231817
+ //#endregion
231547
231818
  //#region src/log-channels.ts
231548
231819
  /**
231549
231820
  * The diagnostic log CHANNELS `provider-reolink` declares.
@@ -236319,8 +236590,10 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
236319
236590
  deviceId: this.id,
236320
236591
  channel: this.getChannel(),
236321
236592
  sleeping: this.isBattery && this.sleeping,
236593
+ hubChild: this.isHubChild(),
236322
236594
  getApi: () => this.ensureApi(),
236323
236595
  cache: this.config.get("deviceCache"),
236596
+ batteryState: this.runtimeState.getCapState("battery"),
236324
236597
  logger: this.ctx.logger,
236325
236598
  now: Date.now()
236326
236599
  });
@@ -236651,13 +236924,9 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
236651
236924
  * tracker is more reliable.
236652
236925
  */
236653
236926
  mapBatteryInfo(info) {
236654
- const percentage = typeof info.batteryPercent === "number" ? Math.max(0, Math.min(100, Math.round(info.batteryPercent))) : this.state.battery.percentage ?? null;
236655
- const adapter = (info.adapterStatus ?? "").toLowerCase();
236656
- const charge = (info.chargeStatus ?? "").toLowerCase();
236657
- const isCharging = charge === "charging" || charge === "chargecomplete";
236658
236927
  return {
236659
- percentage,
236660
- charging: adapter.includes("solar") ? "solar" : isCharging ? "dc" : "none",
236928
+ percentage: typeof info.batteryPercent === "number" ? Math.max(0, Math.min(100, Math.round(info.batteryPercent))) : this.state.battery.percentage ?? null,
236929
+ charging: reportsPowerSource(info) ? deriveChargingSource(info.adapterStatus, info.chargeStatus) : this.state.battery.charging ?? "none",
236661
236930
  sleeping: info.sleeping === true || this.sleeping,
236662
236931
  lastUpdated: Date.now()
236663
236932
  };
@@ -240206,6 +240475,17 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
240206
240475
  const cycle = (async () => {
240207
240476
  try {
240208
240477
  if (this.isBattery && this.sleeping) {
240478
+ if (shouldWakeForContact({
240479
+ nowMs: Date.now(),
240480
+ lastContactAt: this.state.battery.lastContactAt,
240481
+ sleeping: true,
240482
+ hubChild: this.isHubChild(),
240483
+ unreachableAfterMs: 216e5,
240484
+ tickMs: ReolinkCamera.BATTERY_UPDATE_INTERVAL_MS
240485
+ })) {
240486
+ await this.wakeForContactRefresh();
240487
+ return;
240488
+ }
240209
240489
  this.ctx.logger.debug("battery-update: cam sleeping — skipping cycle without connecting", { tags: { deviceId: this.id } });
240210
240490
  return;
240211
240491
  }
@@ -240250,6 +240530,43 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
240250
240530
  this.batteryUpdateInFlight = cycle;
240251
240531
  return cycle;
240252
240532
  }
240533
+ /**
240534
+ * D355: wake a standalone battery camera ONCE because its last passive
240535
+ * contact is about to expire, and refresh every slice while it is up.
240536
+ * Bounded by `canProactivelyWake` (the shared cooldown) and by the fact
240537
+ * that a wake that answers resets the silence. A wake that fails is left
240538
+ * alone: the silence then reaches the budget and `deriveBatteryPresence`
240539
+ * says `unreachable` — for the first time on evidence.
240540
+ */
240541
+ async wakeForContactRefresh() {
240542
+ if (!this.canProactivelyWake("contact-expiry")) return;
240543
+ this.markWakeIssued();
240544
+ const lastContactAt = this.state.battery.lastContactAt ?? 0;
240545
+ const silenceMs = Date.now() - lastContactAt;
240546
+ this.ctx.logger.info("battery contact expiring — waking once to refresh every slice (D355)", {
240547
+ tags: { deviceId: this.id },
240548
+ meta: {
240549
+ silenceMs,
240550
+ budgetMs: BATTERY_UNREACHABLE_AFTER_MS
240551
+ }
240552
+ });
240553
+ try {
240554
+ await this.ensureApi();
240555
+ } catch (err) {
240556
+ this.ctx.logger.warn("battery contact wake FAILED — silence continues and the camera will read unreachable at the budget", {
240557
+ tags: { deviceId: this.id },
240558
+ meta: {
240559
+ silenceMs,
240560
+ error: err instanceof Error ? err.message : String(err)
240561
+ }
240562
+ });
240563
+ return;
240564
+ }
240565
+ await this.refreshBatteryFromApi("demand").catch(() => {});
240566
+ await this.warmCapSlicesOnWake().catch(() => {});
240567
+ await this.alignAuxDevicesState("wake").catch(() => {});
240568
+ await this.warmSnapshotOnWake().catch(() => {});
240569
+ }
240253
240570
  /** Arm the periodic battery-cam refresh timer. No-op for wired cams
240254
240571
  * (their state is kept fresh by the 10 s aux-align poll and push
240255
240572
  * events). Idempotent. */
@@ -245165,4 +245482,4 @@ var ReolinkProviderAddon = class extends BaseDeviceProvider {
245165
245482
  }
245166
245483
  };
245167
245484
  //#endregion
245168
- export { ReolinkProviderAddon, collectMultifocalDiagnostics as a, createDiagnosticsBundle as c, sampleStreams as d, testChannelStreams as f, collectCgiDiagnostics as i, runAllDiagnosticsConsecutively as l, reolinkCameraSchema as n, collectNativeDiagnostics as o, reolinkDebugActions as r, collectNvrDiagnostics as s, ReolinkCamera as t, runMultifocalDiagnosticsConsecutively as u };
245485
+ export { ReolinkProviderAddon, collectNativeDiagnostics as a, runAllDiagnosticsConsecutively as c, reolinkDebugActions as customActions, testChannelStreams as d, collectMultifocalDiagnostics as i, runMultifocalDiagnosticsConsecutively as l, reolinkCameraSchema as n, collectNvrDiagnostics as o, collectCgiDiagnostics as r, createDiagnosticsBundle as s, ReolinkCamera as t, sampleStreams as u };
package/dist/index.mjs CHANGED
@@ -1,2 +1,2 @@
1
- import { ReolinkProviderAddon, n as reolinkCameraSchema, r as reolinkDebugActions, t as ReolinkCamera } from "./addon.mjs";
1
+ import { ReolinkProviderAddon, customActions as reolinkDebugActions, n as reolinkCameraSchema, t as ReolinkCamera } from "./addon.mjs";
2
2
  export { ReolinkCamera, ReolinkProviderAddon, reolinkDebugActions as customActions, reolinkCameraSchema };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@camstack/addon-provider-reolink",
3
- "version": "1.2.86",
3
+ "version": "1.2.88",
4
4
  "description": "Reolink camera device provider addon for CamStack — native Baichuan protocol",
5
5
  "keywords": [
6
6
  "camstack",
@@ -1,2 +0,0 @@
1
- import { a as collectMultifocalDiagnostics, f as testChannelStreams } from "./addon.mjs";
2
- export { collectMultifocalDiagnostics, testChannelStreams };