@camstack/addon-provider-reolink 1.2.91 → 1.2.93

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.
package/dist/addon.js CHANGED
@@ -26,7 +26,7 @@ let fs_promises = require("fs/promises");
26
26
  fs_promises = require_chunk.__toESM(fs_promises, 1);
27
27
  let node_os = require("node:os");
28
28
  node_os = require_chunk.__toESM(node_os);
29
- //#region ../types/dist/event-category-BZL-fdNj.mjs
29
+ //#region ../types/dist/event-category-zAv7pMUz.mjs
30
30
  var EventCategory = /* @__PURE__ */ function(EventCategory) {
31
31
  EventCategory["SystemBoot"] = "system.boot";
32
32
  EventCategory["SystemAddonsReady"] = "system.addons-ready";
@@ -398,6 +398,13 @@ var EventCategory = /* @__PURE__ */ function(EventCategory) {
398
398
  */
399
399
  EventCategory["BatteryOnStatusChanged"] = "battery.onStatusChanged";
400
400
  /**
401
+ * Cap event fired by every device that registers the `network-link`
402
+ * capability. Mirrors the cap definition's `onStatusChanged`. Carries
403
+ * `{ deviceId, status: NetworkLinkStatus }` — a link switch or a signal
404
+ * reading that moved.
405
+ */
406
+ EventCategory["NetworkLinkOnStatusChanged"] = "network-link.onStatusChanged";
407
+ /**
401
408
  * Emitted by the battery cap provider WHEN `wakeForStream` enters the
402
409
  * "wake in progress" window — between the Baichuan wake-up issue and
403
410
  * the camera's first dialed-back RTP packet. The stream-broker
@@ -20034,6 +20041,33 @@ var RecentTracksQueryInput = object({
20034
20041
  */
20035
20042
  classes: array(string()).optional()
20036
20043
  });
20044
+ /**
20045
+ * A Summary (D359): the per-camera session envelope that references tracks.
20046
+ * `groupShotKey` is the media key of its group shot (`ownerKind: 'summary'`,
20047
+ * served by the event-media plane by key), null until the first frame with a
20048
+ * member visible has been materialised.
20049
+ */
20050
+ var SummarySchema = object({
20051
+ id: string(),
20052
+ deviceId: number(),
20053
+ openedAt: number(),
20054
+ lastActiveAt: number(),
20055
+ sealedAt: number().nullable(),
20056
+ memberCount: number().int().nonnegative(),
20057
+ groupShotKey: string().nullable()
20058
+ });
20059
+ var SummariesQueryInput = object({
20060
+ deviceIds: array(number()).min(1),
20061
+ since: number().optional(),
20062
+ until: number().optional(),
20063
+ limit: number().int().min(1).max(200).default(50)
20064
+ });
20065
+ var SummariesPageSchema = object({ summaries: array(SummarySchema).readonly() });
20066
+ var SummaryDetailSchema = object({
20067
+ summary: SummarySchema,
20068
+ /** Member track ids, in absorption order. */
20069
+ trackIds: array(string()).readonly()
20070
+ });
20037
20071
  var RecentTracksPageSchema = object({
20038
20072
  /** Merged page, ordered by (`lastSeen` DESC, `trackId` DESC). */
20039
20073
  tracks: array(TrackSchema).readonly(),
@@ -20434,7 +20468,7 @@ DeviceType.Camera, method(object({ deviceId: number() }), array(TrackSchema).rea
20434
20468
  * timeline would read as a camera that saw nothing.
20435
20469
  */
20436
20470
  classes: array(string()).optional()
20437
- }), array(TrackSchema).readonly()), method(RecentTracksQueryInput, RecentTracksPageSchema), method(object({ deviceId: number() }), _void(), {
20471
+ }), array(TrackSchema).readonly()), method(RecentTracksQueryInput, RecentTracksPageSchema), method(SummariesQueryInput, SummariesPageSchema), method(object({ id: string() }), SummaryDetailSchema.nullable()), method(object({ deviceId: number() }), _void(), {
20438
20472
  kind: "mutation",
20439
20473
  auth: "admin"
20440
20474
  }), method(DeviceEventQueryInput, array(MotionEventSchema).readonly()), method(ObjectEventQueryInput, array(ObjectEventSchema).readonly()), method(DeviceEventQueryInput, array(AudioEventSchema).readonly()), method(object({ deviceId: number() }), array(EventKindDescriptorSchema).readonly()), method(object({ deviceIds: array(number()).min(1).max(200) }), array(EventKindsForDeviceSchema).readonly()), method(object({
@@ -24471,6 +24505,108 @@ onStatusChanged: { data: object({
24471
24505
  volatileStateFields: ["lastUpdated"]
24472
24506
  };
24473
24507
  /**
24508
+ * Network-link snapshot. Same shape for every provider (a Reolink wifi
24509
+ * camera, a Home Assistant device with a signal-strength sensor, a Tapo
24510
+ * plug): one slice under `device.runtimeState['network-link']`, one badge,
24511
+ * one Home Assistant projection.
24512
+ */
24513
+ var NetworkLinkStatusSchema = object({
24514
+ /** The link the device is on. `'unknown'` = not read yet, not "no link". */
24515
+ type: _enum([
24516
+ "wifi",
24517
+ "ethernet",
24518
+ "cellular",
24519
+ "unknown"
24520
+ ]),
24521
+ /**
24522
+ * Link quality, 0..100 inclusive, normalised by the provider from whatever
24523
+ * the firmware reports (bars, RSSI, a vendor scale). **`null` means NOT
24524
+ * KNOWN or NOT APPLICABLE** — a wired link has no signal, and a wireless
24525
+ * one whose reading has not landed must not be drawn at 0 %. Consumers
24526
+ * SKIP a null rather than coerce it.
24527
+ */
24528
+ signalPercent: number().min(0).max(100).nullable(),
24529
+ /** Raw received signal strength in dBm, when the firmware reports one. */
24530
+ rssiDbm: number().optional(),
24531
+ /** Network name of a wireless link, when the firmware reports it. */
24532
+ ssid: string().optional(),
24533
+ /** Ms epoch of the last observation. Lets consumers reason about freshness. */
24534
+ lastUpdated: number()
24535
+ });
24536
+ /** The slice a provider seeds before its first read: nothing is known yet. */
24537
+ var NETWORK_LINK_UNKNOWN = {
24538
+ type: "unknown",
24539
+ signalPercent: null,
24540
+ lastUpdated: 0
24541
+ };
24542
+ /**
24543
+ * Normalise a signal the firmware reports as BARS (0..`maxBars`) to 0..100.
24544
+ * Out-of-range or non-finite input is not a reading: `null`.
24545
+ */
24546
+ function signalPercentFromBars(bars, maxBars) {
24547
+ if (bars === void 0 || !Number.isFinite(bars) || maxBars <= 0) return null;
24548
+ if (bars < 0 || bars > maxBars) return null;
24549
+ return Math.round(bars / maxBars * 100);
24550
+ }
24551
+ /**
24552
+ * Normalise an RSSI in dBm to 0..100 on the usual wifi scale: -100 dBm and
24553
+ * below is 0 %, -50 dBm and above is 100 %, linear between. Non-finite or
24554
+ * positive input is not an RSSI: `null`.
24555
+ */
24556
+ function signalPercentFromRssi(rssiDbm) {
24557
+ if (rssiDbm === void 0 || !Number.isFinite(rssiDbm) || rssiDbm > 0) return null;
24558
+ return Math.round((Math.min(-50, Math.max(-100, rssiDbm)) + 100) * 2);
24559
+ }
24560
+ var networkLinkCapability = {
24561
+ name: "network-link",
24562
+ scope: "device",
24563
+ deviceNative: true,
24564
+ mode: "singleton",
24565
+ deviceTypes: [
24566
+ DeviceType.Camera,
24567
+ DeviceType.Sensor,
24568
+ DeviceType.Button,
24569
+ DeviceType.Switch,
24570
+ DeviceType.Light,
24571
+ DeviceType.Lock,
24572
+ DeviceType.Siren
24573
+ ],
24574
+ methods: {},
24575
+ events: {
24576
+ /**
24577
+ * Emitted whenever the cached status changes (a link switch, a signal
24578
+ * reading that moved). Mirrored on the parent chain by the
24579
+ * DeviceEventPropagator like `battery.onStatusChanged`.
24580
+ */
24581
+ onStatusChanged: { data: object({
24582
+ deviceId: number(),
24583
+ status: NetworkLinkStatusSchema
24584
+ }) } },
24585
+ status: {
24586
+ schema: NetworkLinkStatusSchema,
24587
+ kind: "push",
24588
+ empty: NETWORK_LINK_UNKNOWN
24589
+ },
24590
+ /**
24591
+ * Runtime-state slice — every provider stores the same shape under
24592
+ * `device.runtimeState['network-link']`, read once by the badge and the
24593
+ * Home Assistant projector regardless of the driver.
24594
+ */
24595
+ runtimeState: NetworkLinkStatusSchema,
24596
+ /**
24597
+ * Runtime-state durability: **restored** — a link reading is slow to
24598
+ * change and a sleeping battery camera may not report for hours; the
24599
+ * restored slice is what the badge shows until the next read.
24600
+ *
24601
+ * See `RuntimeStateDurability`. Enforced by
24602
+ * `scripts/check-runtime-state-durability.ts`.
24603
+ */
24604
+ durability: "restored",
24605
+ /** Clock fields: written, but excluded from the compare that decides
24606
+ * whether persisting is worth a SQLite commit. */
24607
+ volatileStateFields: ["lastUpdated"]
24608
+ };
24609
+ /**
24474
24610
  * Generic boolean sensor — last-resort fallback when no domain-
24475
24611
  * specific binary cap fits (Home Assistant `binary_sensor` without a
24476
24612
  * known `device_class`, or a domain we haven't typed yet). Pure
@@ -33155,6 +33291,7 @@ var DEVICE_LOCAL_STATE_CAPS = {
33155
33291
  motionTrigger: motionTriggerCapability,
33156
33292
  motionZones: motionZonesCapability,
33157
33293
  nativeObjectDetection: nativeObjectDetectionCapability,
33294
+ networkLink: networkLinkCapability,
33158
33295
  notifier: notifierCapability,
33159
33296
  numericSensor: numericSensorCapability,
33160
33297
  petFeeder: petFeederCapability,
@@ -37735,6 +37872,12 @@ Object.freeze({
37735
37872
  addonId: null,
37736
37873
  access: "view"
37737
37874
  },
37875
+ "pipelineAnalytics.getSummary": {
37876
+ capName: "pipeline-analytics",
37877
+ capScope: "device",
37878
+ addonId: null,
37879
+ access: "view"
37880
+ },
37738
37881
  "pipelineAnalytics.getTrack": {
37739
37882
  capName: "pipeline-analytics",
37740
37883
  capScope: "device",
@@ -37813,6 +37956,12 @@ Object.freeze({
37813
37956
  addonId: null,
37814
37957
  access: "view"
37815
37958
  },
37959
+ "pipelineAnalytics.listSummaries": {
37960
+ capName: "pipeline-analytics",
37961
+ capScope: "device",
37962
+ addonId: null,
37963
+ access: "view"
37964
+ },
37816
37965
  "pipelineAnalytics.listTrackMedia": {
37817
37966
  capName: "pipeline-analytics",
37818
37967
  capScope: "device",
@@ -41508,6 +41657,11 @@ Object.freeze({
41508
41657
  form: "array",
41509
41658
  optional: true
41510
41659
  }],
41660
+ "pipelineAnalytics.listSummaries": [{
41661
+ name: "deviceIds",
41662
+ form: "array",
41663
+ optional: false
41664
+ }],
41511
41665
  "pipelineAnalytics.listTrackMedia": [{
41512
41666
  name: "deviceId",
41513
41667
  form: "single",
@@ -233550,6 +233704,50 @@ async function populateReolinkMetadata(api, channel, target) {
233550
233704
  });
233551
233705
  }
233552
233706
  }
233707
+ /**
233708
+ * The link the label names. A label the firmware did not give is `unknown`,
233709
+ * UNLESS a wifi signal was read — a camera that answers a wifi signal is on
233710
+ * wifi whatever its label says.
233711
+ */
233712
+ function linkTypeOf(readout) {
233713
+ const label = (readout.activeLink ?? "").toLowerCase();
233714
+ if (label.includes("wifi") || label.includes("wlan") || label.includes("wireless")) return "wifi";
233715
+ if (label.includes("lan") || label.includes("eth") || label.includes("wire")) return "ethernet";
233716
+ if (/\b(4g|5g|lte|cell|sim)\b/.test(label)) return "cellular";
233717
+ if (readout.wifiSignal !== void 0) return "wifi";
233718
+ return "unknown";
233719
+ }
233720
+ /** True when the label names a wireless link — the only case worth a signal read. */
233721
+ function isWirelessLabel(activeLink) {
233722
+ const type = linkTypeOf({
233723
+ activeLink,
233724
+ wifiSignal: void 0
233725
+ });
233726
+ return type === "wifi" || type === "cellular";
233727
+ }
233728
+ /** 0..4 → bars; 5..100 → percent; negative → dBm; anything else → not a reading. */
233729
+ function signalPercentOf(raw) {
233730
+ if (raw === void 0 || !Number.isFinite(raw)) return null;
233731
+ if (raw < 0) return signalPercentFromRssi(raw);
233732
+ if (raw <= 4) return signalPercentFromBars(raw, 4);
233733
+ if (raw <= 100) return Math.round(raw);
233734
+ return null;
233735
+ }
233736
+ function mapNetworkReadout(readout, now) {
233737
+ const type = linkTypeOf(readout);
233738
+ const raw = readout.wifiSignal;
233739
+ const wireless = type === "wifi" || type === "cellular";
233740
+ const signalPercent = wireless ? signalPercentOf(raw) : null;
233741
+ const rssiDbm = wireless && raw !== void 0 && Number.isFinite(raw) && raw < 0 ? raw : void 0;
233742
+ const ssid = wireless && readout.ssid !== void 0 && readout.ssid !== "" ? readout.ssid : void 0;
233743
+ return {
233744
+ type,
233745
+ signalPercent,
233746
+ ...rssiDbm !== void 0 ? { rssiDbm } : {},
233747
+ ...ssid !== void 0 ? { ssid } : {},
233748
+ lastUpdated: now
233749
+ };
233750
+ }
233553
233751
  //#endregion
233554
233752
  //#region src/error-classifier.ts
233555
233753
  /**
@@ -236349,6 +236547,58 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
236349
236547
  meta: { error: err instanceof Error ? err.message : String(err) }
236350
236548
  });
236351
236549
  });
236550
+ await this.refreshNetworkLinkFromApi(api, "probe");
236551
+ }
236552
+ /**
236553
+ * Register the `network-link` cap: the read path serves the slice, the
236554
+ * writers are {@link refreshNetworkLinkFromApi}. Seeded unknown so the
236555
+ * restored slice (if any) stays valid and a fresh device draws no link.
236556
+ */
236557
+ registerNetworkLink() {
236558
+ this.ctx.registerNativeCap(networkLinkCapability, { getStatus: async ({ deviceId }) => {
236559
+ if (deviceId !== this.id) throw new Error(`ReolinkCamera: deviceId mismatch, expected ${this.id}, got ${deviceId}`);
236560
+ return this.state.networkLink;
236561
+ } });
236562
+ if (this.getCapSlice(networkLinkCapability) === null) this.setCapSlice(networkLinkCapability, NETWORK_LINK_UNKNOWN);
236563
+ }
236564
+ /**
236565
+ * Read the active link and, on a wireless one, its signal and network name
236566
+ * over a socket that is ALREADY up. Called post-login and after a
236567
+ * successful battery read — never on its own timer, so a sleeping battery
236568
+ * camera is never woken for a bar count. Each read is best-effort with its
236569
+ * own bound; a failure keeps the last slice and says so at debug level.
236570
+ */
236571
+ async refreshNetworkLinkFromApi(api, reason) {
236572
+ const channel = this.getChannel();
236573
+ const timeoutMs = ReolinkCamera.NETWORK_LINK_READ_TIMEOUT_MS;
236574
+ try {
236575
+ const activeLink = (await api.getNetworkInfo(channel, { timeoutMs }))?.activeLink;
236576
+ const askWireless = activeLink === void 0 || isWirelessLabel(activeLink);
236577
+ const wifiSignal = askWireless ? (await api.getWifiSignal(channel, { timeoutMs }).catch(() => ({ signal: void 0 }))).signal : void 0;
236578
+ const mapped = mapNetworkReadout({
236579
+ activeLink,
236580
+ wifiSignal,
236581
+ ssid: askWireless && wifiSignal !== void 0 ? (await api.getWifi(channel, { timeoutMs }).catch(() => ({ ssid: void 0 }))).ssid : void 0
236582
+ }, Date.now());
236583
+ this.ctx.logger.debug("network link read", {
236584
+ tags: { deviceId: this.id },
236585
+ meta: {
236586
+ reason,
236587
+ activeLink,
236588
+ rawSignal: wifiSignal,
236589
+ ...mapped
236590
+ }
236591
+ });
236592
+ this.setCapSlice(networkLinkCapability, mapped);
236593
+ } catch (err) {
236594
+ this.ctx.logger.debug("network link read failed — keeping the last slice", {
236595
+ tags: { deviceId: this.id },
236596
+ meta: {
236597
+ reason,
236598
+ error: err instanceof Error ? err.message : String(err)
236599
+ }
236600
+ });
236601
+ }
236352
236602
  }
236353
236603
  /**
236354
236604
  * Phase 5 (kernel-driven) — fired after `onProbe()` + accessory
@@ -236906,6 +237156,7 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
236906
237156
  await (await this.ensureApi()).reboot(this.getChannel());
236907
237157
  return { success: true };
236908
237158
  } });
237159
+ this.registerNetworkLink();
236909
237160
  this.ctx.registerNativeCap(motionCapability, { isDetected: async ({ deviceId }) => {
236910
237161
  if (deviceId !== this.id) throw new Error(`ReolinkCamera: deviceId mismatch, expected ${this.id}, got ${deviceId}`);
236911
237162
  return this.state.motion.detected ?? false;
@@ -237160,6 +237411,7 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
237160
237411
  try {
237161
237412
  const info = await api.getBatteryInfo(this.getChannel());
237162
237413
  this.updateBatteryCache(info);
237414
+ await this.refreshNetworkLinkFromApi(api, "battery");
237163
237415
  } catch (err) {
237164
237416
  this.ctx.logger.debug("battery refresh failed", {
237165
237417
  tags: { deviceId: this.id },
@@ -240697,6 +240949,8 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
240697
240949
  /** Write granularity for `battery.lastContactAt` — see `markPassiveContact`.
240698
240950
  * Bounds the commit rate this field can cost at 12/hour/device, and only
240699
240951
  * for a device something is actually reaching. */
240952
+ /** Bound on each best-effort network read; three reads at most per refresh. */
240953
+ static NETWORK_LINK_READ_TIMEOUT_MS = 4e3;
240700
240954
  static CONTACT_WRITE_QUANTUM_MS = 5 * 6e4;
240701
240955
  /**
240702
240956
  * Shared wake-transition handler invoked by both the simpleEvent
package/dist/addon.mjs CHANGED
@@ -21,7 +21,7 @@ import netImpl from "net";
21
21
  import { fileURLToPath } from "url";
22
22
  import { mkdir } from "fs/promises";
23
23
  import os from "node:os";
24
- //#region ../types/dist/event-category-BZL-fdNj.mjs
24
+ //#region ../types/dist/event-category-zAv7pMUz.mjs
25
25
  var EventCategory = /* @__PURE__ */ function(EventCategory) {
26
26
  EventCategory["SystemBoot"] = "system.boot";
27
27
  EventCategory["SystemAddonsReady"] = "system.addons-ready";
@@ -393,6 +393,13 @@ var EventCategory = /* @__PURE__ */ function(EventCategory) {
393
393
  */
394
394
  EventCategory["BatteryOnStatusChanged"] = "battery.onStatusChanged";
395
395
  /**
396
+ * Cap event fired by every device that registers the `network-link`
397
+ * capability. Mirrors the cap definition's `onStatusChanged`. Carries
398
+ * `{ deviceId, status: NetworkLinkStatus }` — a link switch or a signal
399
+ * reading that moved.
400
+ */
401
+ EventCategory["NetworkLinkOnStatusChanged"] = "network-link.onStatusChanged";
402
+ /**
396
403
  * Emitted by the battery cap provider WHEN `wakeForStream` enters the
397
404
  * "wake in progress" window — between the Baichuan wake-up issue and
398
405
  * the camera's first dialed-back RTP packet. The stream-broker
@@ -20029,6 +20036,33 @@ var RecentTracksQueryInput = object({
20029
20036
  */
20030
20037
  classes: array(string()).optional()
20031
20038
  });
20039
+ /**
20040
+ * A Summary (D359): the per-camera session envelope that references tracks.
20041
+ * `groupShotKey` is the media key of its group shot (`ownerKind: 'summary'`,
20042
+ * served by the event-media plane by key), null until the first frame with a
20043
+ * member visible has been materialised.
20044
+ */
20045
+ var SummarySchema = object({
20046
+ id: string(),
20047
+ deviceId: number(),
20048
+ openedAt: number(),
20049
+ lastActiveAt: number(),
20050
+ sealedAt: number().nullable(),
20051
+ memberCount: number().int().nonnegative(),
20052
+ groupShotKey: string().nullable()
20053
+ });
20054
+ var SummariesQueryInput = object({
20055
+ deviceIds: array(number()).min(1),
20056
+ since: number().optional(),
20057
+ until: number().optional(),
20058
+ limit: number().int().min(1).max(200).default(50)
20059
+ });
20060
+ var SummariesPageSchema = object({ summaries: array(SummarySchema).readonly() });
20061
+ var SummaryDetailSchema = object({
20062
+ summary: SummarySchema,
20063
+ /** Member track ids, in absorption order. */
20064
+ trackIds: array(string()).readonly()
20065
+ });
20032
20066
  var RecentTracksPageSchema = object({
20033
20067
  /** Merged page, ordered by (`lastSeen` DESC, `trackId` DESC). */
20034
20068
  tracks: array(TrackSchema).readonly(),
@@ -20429,7 +20463,7 @@ DeviceType.Camera, method(object({ deviceId: number() }), array(TrackSchema).rea
20429
20463
  * timeline would read as a camera that saw nothing.
20430
20464
  */
20431
20465
  classes: array(string()).optional()
20432
- }), array(TrackSchema).readonly()), method(RecentTracksQueryInput, RecentTracksPageSchema), method(object({ deviceId: number() }), _void(), {
20466
+ }), array(TrackSchema).readonly()), method(RecentTracksQueryInput, RecentTracksPageSchema), method(SummariesQueryInput, SummariesPageSchema), method(object({ id: string() }), SummaryDetailSchema.nullable()), method(object({ deviceId: number() }), _void(), {
20433
20467
  kind: "mutation",
20434
20468
  auth: "admin"
20435
20469
  }), method(DeviceEventQueryInput, array(MotionEventSchema).readonly()), method(ObjectEventQueryInput, array(ObjectEventSchema).readonly()), method(DeviceEventQueryInput, array(AudioEventSchema).readonly()), method(object({ deviceId: number() }), array(EventKindDescriptorSchema).readonly()), method(object({ deviceIds: array(number()).min(1).max(200) }), array(EventKindsForDeviceSchema).readonly()), method(object({
@@ -24466,6 +24500,108 @@ onStatusChanged: { data: object({
24466
24500
  volatileStateFields: ["lastUpdated"]
24467
24501
  };
24468
24502
  /**
24503
+ * Network-link snapshot. Same shape for every provider (a Reolink wifi
24504
+ * camera, a Home Assistant device with a signal-strength sensor, a Tapo
24505
+ * plug): one slice under `device.runtimeState['network-link']`, one badge,
24506
+ * one Home Assistant projection.
24507
+ */
24508
+ var NetworkLinkStatusSchema = object({
24509
+ /** The link the device is on. `'unknown'` = not read yet, not "no link". */
24510
+ type: _enum([
24511
+ "wifi",
24512
+ "ethernet",
24513
+ "cellular",
24514
+ "unknown"
24515
+ ]),
24516
+ /**
24517
+ * Link quality, 0..100 inclusive, normalised by the provider from whatever
24518
+ * the firmware reports (bars, RSSI, a vendor scale). **`null` means NOT
24519
+ * KNOWN or NOT APPLICABLE** — a wired link has no signal, and a wireless
24520
+ * one whose reading has not landed must not be drawn at 0 %. Consumers
24521
+ * SKIP a null rather than coerce it.
24522
+ */
24523
+ signalPercent: number().min(0).max(100).nullable(),
24524
+ /** Raw received signal strength in dBm, when the firmware reports one. */
24525
+ rssiDbm: number().optional(),
24526
+ /** Network name of a wireless link, when the firmware reports it. */
24527
+ ssid: string().optional(),
24528
+ /** Ms epoch of the last observation. Lets consumers reason about freshness. */
24529
+ lastUpdated: number()
24530
+ });
24531
+ /** The slice a provider seeds before its first read: nothing is known yet. */
24532
+ var NETWORK_LINK_UNKNOWN = {
24533
+ type: "unknown",
24534
+ signalPercent: null,
24535
+ lastUpdated: 0
24536
+ };
24537
+ /**
24538
+ * Normalise a signal the firmware reports as BARS (0..`maxBars`) to 0..100.
24539
+ * Out-of-range or non-finite input is not a reading: `null`.
24540
+ */
24541
+ function signalPercentFromBars(bars, maxBars) {
24542
+ if (bars === void 0 || !Number.isFinite(bars) || maxBars <= 0) return null;
24543
+ if (bars < 0 || bars > maxBars) return null;
24544
+ return Math.round(bars / maxBars * 100);
24545
+ }
24546
+ /**
24547
+ * Normalise an RSSI in dBm to 0..100 on the usual wifi scale: -100 dBm and
24548
+ * below is 0 %, -50 dBm and above is 100 %, linear between. Non-finite or
24549
+ * positive input is not an RSSI: `null`.
24550
+ */
24551
+ function signalPercentFromRssi(rssiDbm) {
24552
+ if (rssiDbm === void 0 || !Number.isFinite(rssiDbm) || rssiDbm > 0) return null;
24553
+ return Math.round((Math.min(-50, Math.max(-100, rssiDbm)) + 100) * 2);
24554
+ }
24555
+ var networkLinkCapability = {
24556
+ name: "network-link",
24557
+ scope: "device",
24558
+ deviceNative: true,
24559
+ mode: "singleton",
24560
+ deviceTypes: [
24561
+ DeviceType.Camera,
24562
+ DeviceType.Sensor,
24563
+ DeviceType.Button,
24564
+ DeviceType.Switch,
24565
+ DeviceType.Light,
24566
+ DeviceType.Lock,
24567
+ DeviceType.Siren
24568
+ ],
24569
+ methods: {},
24570
+ events: {
24571
+ /**
24572
+ * Emitted whenever the cached status changes (a link switch, a signal
24573
+ * reading that moved). Mirrored on the parent chain by the
24574
+ * DeviceEventPropagator like `battery.onStatusChanged`.
24575
+ */
24576
+ onStatusChanged: { data: object({
24577
+ deviceId: number(),
24578
+ status: NetworkLinkStatusSchema
24579
+ }) } },
24580
+ status: {
24581
+ schema: NetworkLinkStatusSchema,
24582
+ kind: "push",
24583
+ empty: NETWORK_LINK_UNKNOWN
24584
+ },
24585
+ /**
24586
+ * Runtime-state slice — every provider stores the same shape under
24587
+ * `device.runtimeState['network-link']`, read once by the badge and the
24588
+ * Home Assistant projector regardless of the driver.
24589
+ */
24590
+ runtimeState: NetworkLinkStatusSchema,
24591
+ /**
24592
+ * Runtime-state durability: **restored** — a link reading is slow to
24593
+ * change and a sleeping battery camera may not report for hours; the
24594
+ * restored slice is what the badge shows until the next read.
24595
+ *
24596
+ * See `RuntimeStateDurability`. Enforced by
24597
+ * `scripts/check-runtime-state-durability.ts`.
24598
+ */
24599
+ durability: "restored",
24600
+ /** Clock fields: written, but excluded from the compare that decides
24601
+ * whether persisting is worth a SQLite commit. */
24602
+ volatileStateFields: ["lastUpdated"]
24603
+ };
24604
+ /**
24469
24605
  * Generic boolean sensor — last-resort fallback when no domain-
24470
24606
  * specific binary cap fits (Home Assistant `binary_sensor` without a
24471
24607
  * known `device_class`, or a domain we haven't typed yet). Pure
@@ -33150,6 +33286,7 @@ var DEVICE_LOCAL_STATE_CAPS = {
33150
33286
  motionTrigger: motionTriggerCapability,
33151
33287
  motionZones: motionZonesCapability,
33152
33288
  nativeObjectDetection: nativeObjectDetectionCapability,
33289
+ networkLink: networkLinkCapability,
33153
33290
  notifier: notifierCapability,
33154
33291
  numericSensor: numericSensorCapability,
33155
33292
  petFeeder: petFeederCapability,
@@ -37730,6 +37867,12 @@ Object.freeze({
37730
37867
  addonId: null,
37731
37868
  access: "view"
37732
37869
  },
37870
+ "pipelineAnalytics.getSummary": {
37871
+ capName: "pipeline-analytics",
37872
+ capScope: "device",
37873
+ addonId: null,
37874
+ access: "view"
37875
+ },
37733
37876
  "pipelineAnalytics.getTrack": {
37734
37877
  capName: "pipeline-analytics",
37735
37878
  capScope: "device",
@@ -37808,6 +37951,12 @@ Object.freeze({
37808
37951
  addonId: null,
37809
37952
  access: "view"
37810
37953
  },
37954
+ "pipelineAnalytics.listSummaries": {
37955
+ capName: "pipeline-analytics",
37956
+ capScope: "device",
37957
+ addonId: null,
37958
+ access: "view"
37959
+ },
37811
37960
  "pipelineAnalytics.listTrackMedia": {
37812
37961
  capName: "pipeline-analytics",
37813
37962
  capScope: "device",
@@ -41503,6 +41652,11 @@ Object.freeze({
41503
41652
  form: "array",
41504
41653
  optional: true
41505
41654
  }],
41655
+ "pipelineAnalytics.listSummaries": [{
41656
+ name: "deviceIds",
41657
+ form: "array",
41658
+ optional: false
41659
+ }],
41506
41660
  "pipelineAnalytics.listTrackMedia": [{
41507
41661
  name: "deviceId",
41508
41662
  form: "single",
@@ -233530,6 +233684,50 @@ async function populateReolinkMetadata(api, channel, target) {
233530
233684
  });
233531
233685
  }
233532
233686
  }
233687
+ /**
233688
+ * The link the label names. A label the firmware did not give is `unknown`,
233689
+ * UNLESS a wifi signal was read — a camera that answers a wifi signal is on
233690
+ * wifi whatever its label says.
233691
+ */
233692
+ function linkTypeOf(readout) {
233693
+ const label = (readout.activeLink ?? "").toLowerCase();
233694
+ if (label.includes("wifi") || label.includes("wlan") || label.includes("wireless")) return "wifi";
233695
+ if (label.includes("lan") || label.includes("eth") || label.includes("wire")) return "ethernet";
233696
+ if (/\b(4g|5g|lte|cell|sim)\b/.test(label)) return "cellular";
233697
+ if (readout.wifiSignal !== void 0) return "wifi";
233698
+ return "unknown";
233699
+ }
233700
+ /** True when the label names a wireless link — the only case worth a signal read. */
233701
+ function isWirelessLabel(activeLink) {
233702
+ const type = linkTypeOf({
233703
+ activeLink,
233704
+ wifiSignal: void 0
233705
+ });
233706
+ return type === "wifi" || type === "cellular";
233707
+ }
233708
+ /** 0..4 → bars; 5..100 → percent; negative → dBm; anything else → not a reading. */
233709
+ function signalPercentOf(raw) {
233710
+ if (raw === void 0 || !Number.isFinite(raw)) return null;
233711
+ if (raw < 0) return signalPercentFromRssi(raw);
233712
+ if (raw <= 4) return signalPercentFromBars(raw, 4);
233713
+ if (raw <= 100) return Math.round(raw);
233714
+ return null;
233715
+ }
233716
+ function mapNetworkReadout(readout, now) {
233717
+ const type = linkTypeOf(readout);
233718
+ const raw = readout.wifiSignal;
233719
+ const wireless = type === "wifi" || type === "cellular";
233720
+ const signalPercent = wireless ? signalPercentOf(raw) : null;
233721
+ const rssiDbm = wireless && raw !== void 0 && Number.isFinite(raw) && raw < 0 ? raw : void 0;
233722
+ const ssid = wireless && readout.ssid !== void 0 && readout.ssid !== "" ? readout.ssid : void 0;
233723
+ return {
233724
+ type,
233725
+ signalPercent,
233726
+ ...rssiDbm !== void 0 ? { rssiDbm } : {},
233727
+ ...ssid !== void 0 ? { ssid } : {},
233728
+ lastUpdated: now
233729
+ };
233730
+ }
233533
233731
  //#endregion
233534
233732
  //#region src/error-classifier.ts
233535
233733
  /**
@@ -236329,6 +236527,58 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
236329
236527
  meta: { error: err instanceof Error ? err.message : String(err) }
236330
236528
  });
236331
236529
  });
236530
+ await this.refreshNetworkLinkFromApi(api, "probe");
236531
+ }
236532
+ /**
236533
+ * Register the `network-link` cap: the read path serves the slice, the
236534
+ * writers are {@link refreshNetworkLinkFromApi}. Seeded unknown so the
236535
+ * restored slice (if any) stays valid and a fresh device draws no link.
236536
+ */
236537
+ registerNetworkLink() {
236538
+ this.ctx.registerNativeCap(networkLinkCapability, { getStatus: async ({ deviceId }) => {
236539
+ if (deviceId !== this.id) throw new Error(`ReolinkCamera: deviceId mismatch, expected ${this.id}, got ${deviceId}`);
236540
+ return this.state.networkLink;
236541
+ } });
236542
+ if (this.getCapSlice(networkLinkCapability) === null) this.setCapSlice(networkLinkCapability, NETWORK_LINK_UNKNOWN);
236543
+ }
236544
+ /**
236545
+ * Read the active link and, on a wireless one, its signal and network name
236546
+ * over a socket that is ALREADY up. Called post-login and after a
236547
+ * successful battery read — never on its own timer, so a sleeping battery
236548
+ * camera is never woken for a bar count. Each read is best-effort with its
236549
+ * own bound; a failure keeps the last slice and says so at debug level.
236550
+ */
236551
+ async refreshNetworkLinkFromApi(api, reason) {
236552
+ const channel = this.getChannel();
236553
+ const timeoutMs = ReolinkCamera.NETWORK_LINK_READ_TIMEOUT_MS;
236554
+ try {
236555
+ const activeLink = (await api.getNetworkInfo(channel, { timeoutMs }))?.activeLink;
236556
+ const askWireless = activeLink === void 0 || isWirelessLabel(activeLink);
236557
+ const wifiSignal = askWireless ? (await api.getWifiSignal(channel, { timeoutMs }).catch(() => ({ signal: void 0 }))).signal : void 0;
236558
+ const mapped = mapNetworkReadout({
236559
+ activeLink,
236560
+ wifiSignal,
236561
+ ssid: askWireless && wifiSignal !== void 0 ? (await api.getWifi(channel, { timeoutMs }).catch(() => ({ ssid: void 0 }))).ssid : void 0
236562
+ }, Date.now());
236563
+ this.ctx.logger.debug("network link read", {
236564
+ tags: { deviceId: this.id },
236565
+ meta: {
236566
+ reason,
236567
+ activeLink,
236568
+ rawSignal: wifiSignal,
236569
+ ...mapped
236570
+ }
236571
+ });
236572
+ this.setCapSlice(networkLinkCapability, mapped);
236573
+ } catch (err) {
236574
+ this.ctx.logger.debug("network link read failed — keeping the last slice", {
236575
+ tags: { deviceId: this.id },
236576
+ meta: {
236577
+ reason,
236578
+ error: err instanceof Error ? err.message : String(err)
236579
+ }
236580
+ });
236581
+ }
236332
236582
  }
236333
236583
  /**
236334
236584
  * Phase 5 (kernel-driven) — fired after `onProbe()` + accessory
@@ -236886,6 +237136,7 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
236886
237136
  await (await this.ensureApi()).reboot(this.getChannel());
236887
237137
  return { success: true };
236888
237138
  } });
237139
+ this.registerNetworkLink();
236889
237140
  this.ctx.registerNativeCap(motionCapability, { isDetected: async ({ deviceId }) => {
236890
237141
  if (deviceId !== this.id) throw new Error(`ReolinkCamera: deviceId mismatch, expected ${this.id}, got ${deviceId}`);
236891
237142
  return this.state.motion.detected ?? false;
@@ -237140,6 +237391,7 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
237140
237391
  try {
237141
237392
  const info = await api.getBatteryInfo(this.getChannel());
237142
237393
  this.updateBatteryCache(info);
237394
+ await this.refreshNetworkLinkFromApi(api, "battery");
237143
237395
  } catch (err) {
237144
237396
  this.ctx.logger.debug("battery refresh failed", {
237145
237397
  tags: { deviceId: this.id },
@@ -240677,6 +240929,8 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
240677
240929
  /** Write granularity for `battery.lastContactAt` — see `markPassiveContact`.
240678
240930
  * Bounds the commit rate this field can cost at 12/hour/device, and only
240679
240931
  * for a device something is actually reaching. */
240932
+ /** Bound on each best-effort network read; three reads at most per refresh. */
240933
+ static NETWORK_LINK_READ_TIMEOUT_MS = 4e3;
240680
240934
  static CONTACT_WRITE_QUANTUM_MS = 5 * 6e4;
240681
240935
  /**
240682
240936
  * Shared wake-transition handler invoked by both the simpleEvent
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@camstack/addon-provider-reolink",
3
- "version": "1.2.91",
3
+ "version": "1.2.93",
4
4
  "description": "Reolink camera device provider addon for CamStack — native Baichuan protocol",
5
5
  "keywords": [
6
6
  "camstack",