@camstack/addon-provider-reolink 1.2.87 → 1.2.89

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 +182 -25
  2. package/dist/addon.mjs +182 -25
  3. package/package.json +1 -1
package/dist/addon.js CHANGED
@@ -7592,7 +7592,8 @@ var CameraSwitchAuthoritySchema = discriminatedUnion("kind", [
7592
7592
  var CameraSwitchUnavailableReasonSchema = _enum([
7593
7593
  "no-provider",
7594
7594
  "source-unreachable",
7595
- "not-configured"
7595
+ "not-configured",
7596
+ "device-disabled"
7596
7597
  ]);
7597
7598
  /**
7598
7599
  * One switch, resolved for one camera.
@@ -13648,7 +13649,12 @@ var ConfigEntrySchema = object({
13648
13649
  value: unknown(),
13649
13650
  description: string().optional()
13650
13651
  });
13651
- var LinkedDevicesModeSchema = _enum(["auto", "manual"]);
13652
+ /**
13653
+ * Only `manual` since D356: the operator picks, nothing links itself. The
13654
+ * field survives on the wire because a viewer already OTA'd parses it —
13655
+ * drop it once no shipped client reads `mode`.
13656
+ */
13657
+ var LinkedDevicesModeSchema = _enum(["manual"]);
13652
13658
  /** One resolved linked device — the compact projection consumers need. */
13653
13659
  var LinkedDeviceSchema = object({
13654
13660
  deviceId: number(),
@@ -24320,12 +24326,20 @@ var BatteryStatusSchema = object({
24320
24326
  * Charging source. `'dc'` covers wall/USB adapters; `'solar'` is
24321
24327
  * Reolink-specific for the Solar Panel 2 accessory (will become
24322
24328
  * common on other battery cams). `'none'` means running on battery
24323
- * alone.
24329
+ * alone — a NEGATIVE the firmware actually reported.
24330
+ *
24331
+ * `'unknown'` is the absence of an answer: the provider has not read the
24332
+ * power fields yet, or the firmware reported neither. Before it existed,
24333
+ * two unreadable fields produced `'none'`, and a camera nobody had reached
24334
+ * was drawn "on battery alone" — the same D315 defect `percentage` closed
24335
+ * by going nullable. Consumers SKIP it (no charger state published, no
24336
+ * glyph, no HomeKit ChargingState) rather than coerce it either way.
24324
24337
  */
24325
24338
  charging: _enum([
24326
24339
  "dc",
24327
24340
  "solar",
24328
- "none"
24341
+ "none",
24342
+ "unknown"
24329
24343
  ]),
24330
24344
  /**
24331
24345
  * True when the camera firmware has gone into low-power mode. Battery
@@ -24415,7 +24429,7 @@ onStatusChanged: { data: object({
24415
24429
  kind: "push",
24416
24430
  empty: {
24417
24431
  percentage: 0,
24418
- charging: "none",
24432
+ charging: "unknown",
24419
24433
  sleeping: false,
24420
24434
  lastUpdated: 0
24421
24435
  }
@@ -34123,8 +34137,17 @@ var BaseDeviceProvider = class extends BaseAddon {
34123
34137
  async onRestoreDevices(savedDevices) {
34124
34138
  const restored = /* @__PURE__ */ new Set();
34125
34139
  const failures = [];
34140
+ const liveStableIds = new Set((await this.ctx.kernel.devices.getAll()).map((device) => device.stableId));
34126
34141
  const attemptRestore = async (saved) => {
34127
34142
  if (restored.has(saved.id)) return;
34143
+ if (liveStableIds.has(saved.stableId)) {
34144
+ this.ctx.logger.debug("Restore row already live — not dialled again", { tags: {
34145
+ deviceId: saved.id,
34146
+ stableId: saved.stableId
34147
+ } });
34148
+ restored.add(saved.id);
34149
+ return;
34150
+ }
34128
34151
  const Class = this.deviceClasses[saved.type];
34129
34152
  if (!Class) throw new Error(`no device class registered for type "${saved.type}"`);
34130
34153
  if (saved.parentDeviceId !== null && !restored.has(saved.parentDeviceId)) throw new Error(`parent device ${saved.parentDeviceId} not restored`);
@@ -34222,6 +34245,17 @@ var BaseDeviceProvider = class extends BaseAddon {
34222
34245
  return toDeviceSummary(device, this.addonId);
34223
34246
  }
34224
34247
  };
34248
+ /**
34249
+ * Default silence budget before a sleeping battery camera is called gone.
34250
+ *
34251
+ * Sized off the mechanism that produces contact, not off taste: a battery cam
34252
+ * on this deployment surfaces a firmware push (battery level, sleep/wake, or a
34253
+ * motion email) on the order of hours even with no visitors, and the snapshot
34254
+ * wrapper's own battery window is 1 hour. Six hours is therefore several
34255
+ * missed opportunities, not one — so a single quiet night does not raise a
34256
+ * fault, and a genuinely flat camera is named well inside a day.
34257
+ */
34258
+ var BATTERY_UNREACHABLE_AFTER_MS = 360 * 6e4;
34225
34259
  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;
34226
34260
  new Set(Object.values(DeviceType));
34227
34261
  DeviceFeature.BatteryOperated;
@@ -231785,13 +231819,15 @@ var reolinkDebugActions = defineCustomActions({ debugRawRead: customAction(RawRe
231785
231819
  * passato da `adapterStatus: 1` a `0`, con `chargeStatus: 0` da entrambe le
231786
231820
  * parti — cioè `chargeStatus` da solo NON distingue i due stati.
231787
231821
  *
231788
- * ⚠️ Questa funzione non sa ancora dire "non lo so": `BatteryStatus['charging']`
231789
- * non ha un valore `unknown`, quindi due campi illeggibili producono `'none'`,
231790
- * che è un valore NEGATIVO al posto di un non-noto. È il difetto D315 che
231791
- * `percentage` ha già chiuso (è `nullable`) e questo campo no; chiuderlo tocca
231792
- * `battery.cap.ts`, cioè la closure del server, e vive nel proprio lavoro.
231822
+ * Due campi assenti NON sono `'none'`: `'none'` è un negativo che il firmware
231823
+ * ha detto ("nessun adattatore, cella ferma"), l'assenza di entrambi è
231824
+ * `'unknown'` lo stesso confine che `percentage` ha chiuso diventando
231825
+ * `nullable` (D315). Il chiamante (`mapBatteryInfo`) non arriva qui con un
231826
+ * push che non parla di alimentazione: `reportsPowerSource` lo filtra prima e
231827
+ * conserva la convinzione precedente.
231793
231828
  */
231794
231829
  function deriveChargingSource(adapterStatus, chargeStatus) {
231830
+ if (adapterStatus === void 0 && chargeStatus === void 0) return "unknown";
231795
231831
  const adapter = (adapterStatus ?? "").toLowerCase();
231796
231832
  const charge = (chargeStatus ?? "").toLowerCase();
231797
231833
  if (adapter.includes("solar")) return "solar";
@@ -231810,6 +231846,15 @@ function reportsPowerSource(info) {
231810
231846
  return info.adapterStatus !== void 0 || info.chargeStatus !== void 0;
231811
231847
  }
231812
231848
  //#endregion
231849
+ //#region src/battery-contact-expiry.ts
231850
+ function shouldWakeForContact(input) {
231851
+ if (input.hubChild) return false;
231852
+ if (!input.sleeping) return false;
231853
+ const last = input.lastContactAt;
231854
+ if (typeof last !== "number" || last <= 0) return false;
231855
+ return input.nowMs - last + input.tickMs >= input.unreachableAfterMs;
231856
+ }
231857
+ //#endregion
231813
231858
  //#region src/log-channels.ts
231814
231859
  /**
231815
231860
  * The diagnostic log CHANNELS `provider-reolink` declares.
@@ -236921,7 +236966,7 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
236921
236966
  mapBatteryInfo(info) {
236922
236967
  return {
236923
236968
  percentage: typeof info.batteryPercent === "number" ? Math.max(0, Math.min(100, Math.round(info.batteryPercent))) : this.state.battery.percentage ?? null,
236924
- charging: reportsPowerSource(info) ? deriveChargingSource(info.adapterStatus, info.chargeStatus) : this.state.battery.charging ?? "none",
236969
+ charging: reportsPowerSource(info) ? deriveChargingSource(info.adapterStatus, info.chargeStatus) : this.state.battery.charging ?? "unknown",
236925
236970
  sleeping: info.sleeping === true || this.sleeping,
236926
236971
  lastUpdated: Date.now()
236927
236972
  };
@@ -240470,6 +240515,17 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
240470
240515
  const cycle = (async () => {
240471
240516
  try {
240472
240517
  if (this.isBattery && this.sleeping) {
240518
+ if (shouldWakeForContact({
240519
+ nowMs: Date.now(),
240520
+ lastContactAt: this.state.battery.lastContactAt,
240521
+ sleeping: true,
240522
+ hubChild: this.isHubChild(),
240523
+ unreachableAfterMs: 216e5,
240524
+ tickMs: ReolinkCamera.BATTERY_UPDATE_INTERVAL_MS
240525
+ })) {
240526
+ await this.wakeForContactRefresh();
240527
+ return;
240528
+ }
240473
240529
  this.ctx.logger.debug("battery-update: cam sleeping — skipping cycle without connecting", { tags: { deviceId: this.id } });
240474
240530
  return;
240475
240531
  }
@@ -240514,6 +240570,43 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
240514
240570
  this.batteryUpdateInFlight = cycle;
240515
240571
  return cycle;
240516
240572
  }
240573
+ /**
240574
+ * D355: wake a standalone battery camera ONCE because its last passive
240575
+ * contact is about to expire, and refresh every slice while it is up.
240576
+ * Bounded by `canProactivelyWake` (the shared cooldown) and by the fact
240577
+ * that a wake that answers resets the silence. A wake that fails is left
240578
+ * alone: the silence then reaches the budget and `deriveBatteryPresence`
240579
+ * says `unreachable` — for the first time on evidence.
240580
+ */
240581
+ async wakeForContactRefresh() {
240582
+ if (!this.canProactivelyWake("contact-expiry")) return;
240583
+ this.markWakeIssued();
240584
+ const lastContactAt = this.state.battery.lastContactAt ?? 0;
240585
+ const silenceMs = Date.now() - lastContactAt;
240586
+ this.ctx.logger.info("battery contact expiring — waking once to refresh every slice (D355)", {
240587
+ tags: { deviceId: this.id },
240588
+ meta: {
240589
+ silenceMs,
240590
+ budgetMs: BATTERY_UNREACHABLE_AFTER_MS
240591
+ }
240592
+ });
240593
+ try {
240594
+ await this.ensureApi();
240595
+ } catch (err) {
240596
+ this.ctx.logger.warn("battery contact wake FAILED — silence continues and the camera will read unreachable at the budget", {
240597
+ tags: { deviceId: this.id },
240598
+ meta: {
240599
+ silenceMs,
240600
+ error: err instanceof Error ? err.message : String(err)
240601
+ }
240602
+ });
240603
+ return;
240604
+ }
240605
+ await this.refreshBatteryFromApi("demand").catch(() => {});
240606
+ await this.warmCapSlicesOnWake().catch(() => {});
240607
+ await this.alignAuxDevicesState("wake").catch(() => {});
240608
+ await this.warmSnapshotOnWake().catch(() => {});
240609
+ }
240517
240610
  /** Arm the periodic battery-cam refresh timer. No-op for wired cams
240518
240611
  * (their state is kept fresh by the 10 s aux-align poll and push
240519
240612
  * events). Idempotent. */
@@ -242930,6 +243023,69 @@ function asDebugSocketFlags(value) {
242930
243023
  return Array.isArray(value) ? value.filter((v) => typeof v === "string") : [];
242931
243024
  }
242932
243025
  //#endregion
243026
+ //#region src/hub-battery-fields.ts
243027
+ /**
243028
+ * The hub boundary for the per-channel battery power fields.
243029
+ *
243030
+ * The Reolink hub/NVR reports `chargeStatus` and `adapterStatus` as NUMBERS
243031
+ * (0/1/2); the standalone camera path reports the same facts as strings, and
243032
+ * `deriveChargingSource` (`battery-charging.ts`) speaks only the string
243033
+ * vocabulary. This module is the translation, and — the part that was
243034
+ * missing — it NAMES a code it cannot translate instead of dropping the field
243035
+ * silently. A dropped field reads as "the push said nothing about power", so
243036
+ * a new firmware value would have kept the previous belief forever, with not
243037
+ * one log line. The ledger lets the hub warn once per (device, field, code).
243038
+ */
243039
+ var CHARGE_STATUS_BY_CODE = {
243040
+ 0: "none",
243041
+ 1: "charging",
243042
+ 2: "chargeComplete"
243043
+ };
243044
+ var ADAPTER_STATUS_BY_CODE = {
243045
+ 0: "none",
243046
+ 1: "dc",
243047
+ 2: "solarPanel"
243048
+ };
243049
+ function mapOne(field, value, byCode) {
243050
+ if (value === void 0 || value === null) return {};
243051
+ if (typeof value === "string") return { mapped: value };
243052
+ if (typeof value === "number") {
243053
+ const mapped = byCode[value];
243054
+ if (mapped !== void 0) return { mapped };
243055
+ }
243056
+ return { unmapped: {
243057
+ field,
243058
+ value
243059
+ } };
243060
+ }
243061
+ /** Translate one channel's raw CGI power fields; never throws. */
243062
+ function mapHubBatteryPower(cgi) {
243063
+ const charge = mapOne("chargeStatus", cgi?.["chargeStatus"], CHARGE_STATUS_BY_CODE);
243064
+ const adapter = mapOne("adapterStatus", cgi?.["adapterStatus"], ADAPTER_STATUS_BY_CODE);
243065
+ return {
243066
+ fields: {
243067
+ ...charge.mapped !== void 0 ? { chargeStatus: charge.mapped } : {},
243068
+ ...adapter.mapped !== void 0 ? { adapterStatus: adapter.mapped } : {}
243069
+ },
243070
+ unmapped: [...charge.unmapped !== void 0 ? [charge.unmapped] : [], ...adapter.unmapped !== void 0 ? [adapter.unmapped] : []]
243071
+ };
243072
+ }
243073
+ /**
243074
+ * Remembers which (device, field, code) triples were already reported, so a
243075
+ * polled read warns once per finding and not once per tick. Unbounded only in
243076
+ * theory: the key space is a few cameras times two fields times the handful of
243077
+ * codes a firmware can invent.
243078
+ */
243079
+ var UnmappedCodeLedger = class {
243080
+ seen = /* @__PURE__ */ new Set();
243081
+ firstSeen(deviceId, field, value) {
243082
+ const key = `${deviceId}:${field}:${String(value)}`;
243083
+ if (this.seen.has(key)) return false;
243084
+ this.seen.add(key);
243085
+ return true;
243086
+ }
243087
+ };
243088
+ //#endregion
242933
243089
  //#region src/reolink-hub.ts
242934
243090
  var HUB_DISCOVERY_REFRESH_TIMEOUT_MS = 8e3;
242935
243091
  /**
@@ -242991,6 +243147,8 @@ var ReolinkHub = class ReolinkHub extends BaseDevice {
242991
243147
  /** One-shot: the raw per-channel battery payload is logged once per
242992
243148
  * process so the firmware's charge-field naming is on record. */
242993
243149
  batteryShapeLogged = false;
243150
+ /** Firmware power codes this hub could not translate — warned once each. */
243151
+ unmappedBatteryCodes = new UnmappedCodeLedger();
242994
243152
  /** Channels the last successful discovery refresh listed as present but
242995
243153
  * NOT adopted — their pushes are expected drops (debug), not faults. */
242996
243154
  knownUnadoptedChannels = /* @__PURE__ */ new Set();
@@ -243471,10 +243629,21 @@ var ReolinkHub = class ReolinkHub extends BaseDevice {
243471
243629
  const child = all.find((d) => d.id === deviceId);
243472
243630
  if (!(child instanceof ReolinkCamera)) continue;
243473
243631
  const cgi = info.entries[0];
243632
+ const power = mapHubBatteryPower(cgi);
243633
+ for (const { field, value } of power.unmapped) {
243634
+ if (!this.unmappedBatteryCodes.firstSeen(deviceId, field, value)) continue;
243635
+ this.ctx.logger.warn("Reolink Hub: unmapped battery power code — field dropped", {
243636
+ tags: { deviceId },
243637
+ meta: {
243638
+ channel: Number(chKey),
243639
+ field,
243640
+ value
243641
+ }
243642
+ });
243643
+ }
243474
243644
  child.applyHubBatteryInfo({
243475
243645
  batteryPercent: info.batteryLevel,
243476
- ...mapHubChargeStatus(cgi?.["chargeStatus"]) !== void 0 ? { chargeStatus: mapHubChargeStatus(cgi?.["chargeStatus"]) } : {},
243477
- ...mapHubAdapterStatus(cgi?.["adapterStatus"]) !== void 0 ? { adapterStatus: mapHubAdapterStatus(cgi?.["adapterStatus"]) } : {}
243646
+ ...power.fields
243478
243647
  });
243479
243648
  }
243480
243649
  } catch (err) {
@@ -243915,18 +244084,6 @@ function discoveredStatusFor(d) {
243915
244084
  * chargeStatus: 0 none · 1 charging · 2 charge complete
243916
244085
  * adapterStatus: 0 none · 1 DC adapter · 2 solar panel
243917
244086
  */
243918
- function mapHubChargeStatus(value) {
243919
- if (typeof value === "string") return value;
243920
- if (value === 0) return "none";
243921
- if (value === 1) return "charging";
243922
- if (value === 2) return "chargeComplete";
243923
- }
243924
- function mapHubAdapterStatus(value) {
243925
- if (typeof value === "string") return value;
243926
- if (value === 0) return "none";
243927
- if (value === 1) return "dc";
243928
- if (value === 2) return "solarPanel";
243929
- }
243930
244087
  //#endregion
243931
244088
  //#region src/creation-schema.ts
243932
244089
  /**
package/dist/addon.mjs CHANGED
@@ -7587,7 +7587,8 @@ var CameraSwitchAuthoritySchema = discriminatedUnion("kind", [
7587
7587
  var CameraSwitchUnavailableReasonSchema = _enum([
7588
7588
  "no-provider",
7589
7589
  "source-unreachable",
7590
- "not-configured"
7590
+ "not-configured",
7591
+ "device-disabled"
7591
7592
  ]);
7592
7593
  /**
7593
7594
  * One switch, resolved for one camera.
@@ -13643,7 +13644,12 @@ var ConfigEntrySchema = object({
13643
13644
  value: unknown(),
13644
13645
  description: string().optional()
13645
13646
  });
13646
- var LinkedDevicesModeSchema = _enum(["auto", "manual"]);
13647
+ /**
13648
+ * Only `manual` since D356: the operator picks, nothing links itself. The
13649
+ * field survives on the wire because a viewer already OTA'd parses it —
13650
+ * drop it once no shipped client reads `mode`.
13651
+ */
13652
+ var LinkedDevicesModeSchema = _enum(["manual"]);
13647
13653
  /** One resolved linked device — the compact projection consumers need. */
13648
13654
  var LinkedDeviceSchema = object({
13649
13655
  deviceId: number(),
@@ -24315,12 +24321,20 @@ var BatteryStatusSchema = object({
24315
24321
  * Charging source. `'dc'` covers wall/USB adapters; `'solar'` is
24316
24322
  * Reolink-specific for the Solar Panel 2 accessory (will become
24317
24323
  * common on other battery cams). `'none'` means running on battery
24318
- * alone.
24324
+ * alone — a NEGATIVE the firmware actually reported.
24325
+ *
24326
+ * `'unknown'` is the absence of an answer: the provider has not read the
24327
+ * power fields yet, or the firmware reported neither. Before it existed,
24328
+ * two unreadable fields produced `'none'`, and a camera nobody had reached
24329
+ * was drawn "on battery alone" — the same D315 defect `percentage` closed
24330
+ * by going nullable. Consumers SKIP it (no charger state published, no
24331
+ * glyph, no HomeKit ChargingState) rather than coerce it either way.
24319
24332
  */
24320
24333
  charging: _enum([
24321
24334
  "dc",
24322
24335
  "solar",
24323
- "none"
24336
+ "none",
24337
+ "unknown"
24324
24338
  ]),
24325
24339
  /**
24326
24340
  * True when the camera firmware has gone into low-power mode. Battery
@@ -24410,7 +24424,7 @@ onStatusChanged: { data: object({
24410
24424
  kind: "push",
24411
24425
  empty: {
24412
24426
  percentage: 0,
24413
- charging: "none",
24427
+ charging: "unknown",
24414
24428
  sleeping: false,
24415
24429
  lastUpdated: 0
24416
24430
  }
@@ -34118,8 +34132,17 @@ var BaseDeviceProvider = class extends BaseAddon {
34118
34132
  async onRestoreDevices(savedDevices) {
34119
34133
  const restored = /* @__PURE__ */ new Set();
34120
34134
  const failures = [];
34135
+ const liveStableIds = new Set((await this.ctx.kernel.devices.getAll()).map((device) => device.stableId));
34121
34136
  const attemptRestore = async (saved) => {
34122
34137
  if (restored.has(saved.id)) return;
34138
+ if (liveStableIds.has(saved.stableId)) {
34139
+ this.ctx.logger.debug("Restore row already live — not dialled again", { tags: {
34140
+ deviceId: saved.id,
34141
+ stableId: saved.stableId
34142
+ } });
34143
+ restored.add(saved.id);
34144
+ return;
34145
+ }
34123
34146
  const Class = this.deviceClasses[saved.type];
34124
34147
  if (!Class) throw new Error(`no device class registered for type "${saved.type}"`);
34125
34148
  if (saved.parentDeviceId !== null && !restored.has(saved.parentDeviceId)) throw new Error(`parent device ${saved.parentDeviceId} not restored`);
@@ -34217,6 +34240,17 @@ var BaseDeviceProvider = class extends BaseAddon {
34217
34240
  return toDeviceSummary(device, this.addonId);
34218
34241
  }
34219
34242
  };
34243
+ /**
34244
+ * Default silence budget before a sleeping battery camera is called gone.
34245
+ *
34246
+ * Sized off the mechanism that produces contact, not off taste: a battery cam
34247
+ * on this deployment surfaces a firmware push (battery level, sleep/wake, or a
34248
+ * motion email) on the order of hours even with no visitors, and the snapshot
34249
+ * wrapper's own battery window is 1 hour. Six hours is therefore several
34250
+ * missed opportunities, not one — so a single quiet night does not raise a
34251
+ * fault, and a genuinely flat camera is named well inside a day.
34252
+ */
34253
+ var BATTERY_UNREACHABLE_AFTER_MS = 360 * 6e4;
34220
34254
  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;
34221
34255
  new Set(Object.values(DeviceType));
34222
34256
  DeviceFeature.BatteryOperated;
@@ -231765,13 +231799,15 @@ var reolinkDebugActions = defineCustomActions({ debugRawRead: customAction(RawRe
231765
231799
  * passato da `adapterStatus: 1` a `0`, con `chargeStatus: 0` da entrambe le
231766
231800
  * parti — cioè `chargeStatus` da solo NON distingue i due stati.
231767
231801
  *
231768
- * ⚠️ Questa funzione non sa ancora dire "non lo so": `BatteryStatus['charging']`
231769
- * non ha un valore `unknown`, quindi due campi illeggibili producono `'none'`,
231770
- * che è un valore NEGATIVO al posto di un non-noto. È il difetto D315 che
231771
- * `percentage` ha già chiuso (è `nullable`) e questo campo no; chiuderlo tocca
231772
- * `battery.cap.ts`, cioè la closure del server, e vive nel proprio lavoro.
231802
+ * Due campi assenti NON sono `'none'`: `'none'` è un negativo che il firmware
231803
+ * ha detto ("nessun adattatore, cella ferma"), l'assenza di entrambi è
231804
+ * `'unknown'` lo stesso confine che `percentage` ha chiuso diventando
231805
+ * `nullable` (D315). Il chiamante (`mapBatteryInfo`) non arriva qui con un
231806
+ * push che non parla di alimentazione: `reportsPowerSource` lo filtra prima e
231807
+ * conserva la convinzione precedente.
231773
231808
  */
231774
231809
  function deriveChargingSource(adapterStatus, chargeStatus) {
231810
+ if (adapterStatus === void 0 && chargeStatus === void 0) return "unknown";
231775
231811
  const adapter = (adapterStatus ?? "").toLowerCase();
231776
231812
  const charge = (chargeStatus ?? "").toLowerCase();
231777
231813
  if (adapter.includes("solar")) return "solar";
@@ -231790,6 +231826,15 @@ function reportsPowerSource(info) {
231790
231826
  return info.adapterStatus !== void 0 || info.chargeStatus !== void 0;
231791
231827
  }
231792
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
231793
231838
  //#region src/log-channels.ts
231794
231839
  /**
231795
231840
  * The diagnostic log CHANNELS `provider-reolink` declares.
@@ -236901,7 +236946,7 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
236901
236946
  mapBatteryInfo(info) {
236902
236947
  return {
236903
236948
  percentage: typeof info.batteryPercent === "number" ? Math.max(0, Math.min(100, Math.round(info.batteryPercent))) : this.state.battery.percentage ?? null,
236904
- charging: reportsPowerSource(info) ? deriveChargingSource(info.adapterStatus, info.chargeStatus) : this.state.battery.charging ?? "none",
236949
+ charging: reportsPowerSource(info) ? deriveChargingSource(info.adapterStatus, info.chargeStatus) : this.state.battery.charging ?? "unknown",
236905
236950
  sleeping: info.sleeping === true || this.sleeping,
236906
236951
  lastUpdated: Date.now()
236907
236952
  };
@@ -240450,6 +240495,17 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
240450
240495
  const cycle = (async () => {
240451
240496
  try {
240452
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
+ }
240453
240509
  this.ctx.logger.debug("battery-update: cam sleeping — skipping cycle without connecting", { tags: { deviceId: this.id } });
240454
240510
  return;
240455
240511
  }
@@ -240494,6 +240550,43 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
240494
240550
  this.batteryUpdateInFlight = cycle;
240495
240551
  return cycle;
240496
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
+ }
240497
240590
  /** Arm the periodic battery-cam refresh timer. No-op for wired cams
240498
240591
  * (their state is kept fresh by the 10 s aux-align poll and push
240499
240592
  * events). Idempotent. */
@@ -242910,6 +243003,69 @@ function asDebugSocketFlags(value) {
242910
243003
  return Array.isArray(value) ? value.filter((v) => typeof v === "string") : [];
242911
243004
  }
242912
243005
  //#endregion
243006
+ //#region src/hub-battery-fields.ts
243007
+ /**
243008
+ * The hub boundary for the per-channel battery power fields.
243009
+ *
243010
+ * The Reolink hub/NVR reports `chargeStatus` and `adapterStatus` as NUMBERS
243011
+ * (0/1/2); the standalone camera path reports the same facts as strings, and
243012
+ * `deriveChargingSource` (`battery-charging.ts`) speaks only the string
243013
+ * vocabulary. This module is the translation, and — the part that was
243014
+ * missing — it NAMES a code it cannot translate instead of dropping the field
243015
+ * silently. A dropped field reads as "the push said nothing about power", so
243016
+ * a new firmware value would have kept the previous belief forever, with not
243017
+ * one log line. The ledger lets the hub warn once per (device, field, code).
243018
+ */
243019
+ var CHARGE_STATUS_BY_CODE = {
243020
+ 0: "none",
243021
+ 1: "charging",
243022
+ 2: "chargeComplete"
243023
+ };
243024
+ var ADAPTER_STATUS_BY_CODE = {
243025
+ 0: "none",
243026
+ 1: "dc",
243027
+ 2: "solarPanel"
243028
+ };
243029
+ function mapOne(field, value, byCode) {
243030
+ if (value === void 0 || value === null) return {};
243031
+ if (typeof value === "string") return { mapped: value };
243032
+ if (typeof value === "number") {
243033
+ const mapped = byCode[value];
243034
+ if (mapped !== void 0) return { mapped };
243035
+ }
243036
+ return { unmapped: {
243037
+ field,
243038
+ value
243039
+ } };
243040
+ }
243041
+ /** Translate one channel's raw CGI power fields; never throws. */
243042
+ function mapHubBatteryPower(cgi) {
243043
+ const charge = mapOne("chargeStatus", cgi?.["chargeStatus"], CHARGE_STATUS_BY_CODE);
243044
+ const adapter = mapOne("adapterStatus", cgi?.["adapterStatus"], ADAPTER_STATUS_BY_CODE);
243045
+ return {
243046
+ fields: {
243047
+ ...charge.mapped !== void 0 ? { chargeStatus: charge.mapped } : {},
243048
+ ...adapter.mapped !== void 0 ? { adapterStatus: adapter.mapped } : {}
243049
+ },
243050
+ unmapped: [...charge.unmapped !== void 0 ? [charge.unmapped] : [], ...adapter.unmapped !== void 0 ? [adapter.unmapped] : []]
243051
+ };
243052
+ }
243053
+ /**
243054
+ * Remembers which (device, field, code) triples were already reported, so a
243055
+ * polled read warns once per finding and not once per tick. Unbounded only in
243056
+ * theory: the key space is a few cameras times two fields times the handful of
243057
+ * codes a firmware can invent.
243058
+ */
243059
+ var UnmappedCodeLedger = class {
243060
+ seen = /* @__PURE__ */ new Set();
243061
+ firstSeen(deviceId, field, value) {
243062
+ const key = `${deviceId}:${field}:${String(value)}`;
243063
+ if (this.seen.has(key)) return false;
243064
+ this.seen.add(key);
243065
+ return true;
243066
+ }
243067
+ };
243068
+ //#endregion
242913
243069
  //#region src/reolink-hub.ts
242914
243070
  var HUB_DISCOVERY_REFRESH_TIMEOUT_MS = 8e3;
242915
243071
  /**
@@ -242971,6 +243127,8 @@ var ReolinkHub = class ReolinkHub extends BaseDevice {
242971
243127
  /** One-shot: the raw per-channel battery payload is logged once per
242972
243128
  * process so the firmware's charge-field naming is on record. */
242973
243129
  batteryShapeLogged = false;
243130
+ /** Firmware power codes this hub could not translate — warned once each. */
243131
+ unmappedBatteryCodes = new UnmappedCodeLedger();
242974
243132
  /** Channels the last successful discovery refresh listed as present but
242975
243133
  * NOT adopted — their pushes are expected drops (debug), not faults. */
242976
243134
  knownUnadoptedChannels = /* @__PURE__ */ new Set();
@@ -243451,10 +243609,21 @@ var ReolinkHub = class ReolinkHub extends BaseDevice {
243451
243609
  const child = all.find((d) => d.id === deviceId);
243452
243610
  if (!(child instanceof ReolinkCamera)) continue;
243453
243611
  const cgi = info.entries[0];
243612
+ const power = mapHubBatteryPower(cgi);
243613
+ for (const { field, value } of power.unmapped) {
243614
+ if (!this.unmappedBatteryCodes.firstSeen(deviceId, field, value)) continue;
243615
+ this.ctx.logger.warn("Reolink Hub: unmapped battery power code — field dropped", {
243616
+ tags: { deviceId },
243617
+ meta: {
243618
+ channel: Number(chKey),
243619
+ field,
243620
+ value
243621
+ }
243622
+ });
243623
+ }
243454
243624
  child.applyHubBatteryInfo({
243455
243625
  batteryPercent: info.batteryLevel,
243456
- ...mapHubChargeStatus(cgi?.["chargeStatus"]) !== void 0 ? { chargeStatus: mapHubChargeStatus(cgi?.["chargeStatus"]) } : {},
243457
- ...mapHubAdapterStatus(cgi?.["adapterStatus"]) !== void 0 ? { adapterStatus: mapHubAdapterStatus(cgi?.["adapterStatus"]) } : {}
243626
+ ...power.fields
243458
243627
  });
243459
243628
  }
243460
243629
  } catch (err) {
@@ -243895,18 +244064,6 @@ function discoveredStatusFor(d) {
243895
244064
  * chargeStatus: 0 none · 1 charging · 2 charge complete
243896
244065
  * adapterStatus: 0 none · 1 DC adapter · 2 solar panel
243897
244066
  */
243898
- function mapHubChargeStatus(value) {
243899
- if (typeof value === "string") return value;
243900
- if (value === 0) return "none";
243901
- if (value === 1) return "charging";
243902
- if (value === 2) return "chargeComplete";
243903
- }
243904
- function mapHubAdapterStatus(value) {
243905
- if (typeof value === "string") return value;
243906
- if (value === 0) return "none";
243907
- if (value === 1) return "dc";
243908
- if (value === 2) return "solarPanel";
243909
- }
243910
244067
  //#endregion
243911
244068
  //#region src/creation-schema.ts
243912
244069
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@camstack/addon-provider-reolink",
3
- "version": "1.2.87",
3
+ "version": "1.2.89",
4
4
  "description": "Reolink camera device provider addon for CamStack — native Baichuan protocol",
5
5
  "keywords": [
6
6
  "camstack",