@camstack/addon-provider-reolink 1.2.92 → 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 +211 -1
- package/dist/addon.mjs +211 -1
- package/package.json +1 -1
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-
|
|
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
|
|
@@ -24498,6 +24505,108 @@ onStatusChanged: { data: object({
|
|
|
24498
24505
|
volatileStateFields: ["lastUpdated"]
|
|
24499
24506
|
};
|
|
24500
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
|
+
/**
|
|
24501
24610
|
* Generic boolean sensor — last-resort fallback when no domain-
|
|
24502
24611
|
* specific binary cap fits (Home Assistant `binary_sensor` without a
|
|
24503
24612
|
* known `device_class`, or a domain we haven't typed yet). Pure
|
|
@@ -33182,6 +33291,7 @@ var DEVICE_LOCAL_STATE_CAPS = {
|
|
|
33182
33291
|
motionTrigger: motionTriggerCapability,
|
|
33183
33292
|
motionZones: motionZonesCapability,
|
|
33184
33293
|
nativeObjectDetection: nativeObjectDetectionCapability,
|
|
33294
|
+
networkLink: networkLinkCapability,
|
|
33185
33295
|
notifier: notifierCapability,
|
|
33186
33296
|
numericSensor: numericSensorCapability,
|
|
33187
33297
|
petFeeder: petFeederCapability,
|
|
@@ -233594,6 +233704,50 @@ async function populateReolinkMetadata(api, channel, target) {
|
|
|
233594
233704
|
});
|
|
233595
233705
|
}
|
|
233596
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
|
+
}
|
|
233597
233751
|
//#endregion
|
|
233598
233752
|
//#region src/error-classifier.ts
|
|
233599
233753
|
/**
|
|
@@ -236393,6 +236547,58 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
|
|
|
236393
236547
|
meta: { error: err instanceof Error ? err.message : String(err) }
|
|
236394
236548
|
});
|
|
236395
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
|
+
}
|
|
236396
236602
|
}
|
|
236397
236603
|
/**
|
|
236398
236604
|
* Phase 5 (kernel-driven) — fired after `onProbe()` + accessory
|
|
@@ -236950,6 +237156,7 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
|
|
|
236950
237156
|
await (await this.ensureApi()).reboot(this.getChannel());
|
|
236951
237157
|
return { success: true };
|
|
236952
237158
|
} });
|
|
237159
|
+
this.registerNetworkLink();
|
|
236953
237160
|
this.ctx.registerNativeCap(motionCapability, { isDetected: async ({ deviceId }) => {
|
|
236954
237161
|
if (deviceId !== this.id) throw new Error(`ReolinkCamera: deviceId mismatch, expected ${this.id}, got ${deviceId}`);
|
|
236955
237162
|
return this.state.motion.detected ?? false;
|
|
@@ -237204,6 +237411,7 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
|
|
|
237204
237411
|
try {
|
|
237205
237412
|
const info = await api.getBatteryInfo(this.getChannel());
|
|
237206
237413
|
this.updateBatteryCache(info);
|
|
237414
|
+
await this.refreshNetworkLinkFromApi(api, "battery");
|
|
237207
237415
|
} catch (err) {
|
|
237208
237416
|
this.ctx.logger.debug("battery refresh failed", {
|
|
237209
237417
|
tags: { deviceId: this.id },
|
|
@@ -240741,6 +240949,8 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
|
|
|
240741
240949
|
/** Write granularity for `battery.lastContactAt` — see `markPassiveContact`.
|
|
240742
240950
|
* Bounds the commit rate this field can cost at 12/hour/device, and only
|
|
240743
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;
|
|
240744
240954
|
static CONTACT_WRITE_QUANTUM_MS = 5 * 6e4;
|
|
240745
240955
|
/**
|
|
240746
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-
|
|
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
|
|
@@ -24493,6 +24500,108 @@ onStatusChanged: { data: object({
|
|
|
24493
24500
|
volatileStateFields: ["lastUpdated"]
|
|
24494
24501
|
};
|
|
24495
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
|
+
/**
|
|
24496
24605
|
* Generic boolean sensor — last-resort fallback when no domain-
|
|
24497
24606
|
* specific binary cap fits (Home Assistant `binary_sensor` without a
|
|
24498
24607
|
* known `device_class`, or a domain we haven't typed yet). Pure
|
|
@@ -33177,6 +33286,7 @@ var DEVICE_LOCAL_STATE_CAPS = {
|
|
|
33177
33286
|
motionTrigger: motionTriggerCapability,
|
|
33178
33287
|
motionZones: motionZonesCapability,
|
|
33179
33288
|
nativeObjectDetection: nativeObjectDetectionCapability,
|
|
33289
|
+
networkLink: networkLinkCapability,
|
|
33180
33290
|
notifier: notifierCapability,
|
|
33181
33291
|
numericSensor: numericSensorCapability,
|
|
33182
33292
|
petFeeder: petFeederCapability,
|
|
@@ -233574,6 +233684,50 @@ async function populateReolinkMetadata(api, channel, target) {
|
|
|
233574
233684
|
});
|
|
233575
233685
|
}
|
|
233576
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
|
+
}
|
|
233577
233731
|
//#endregion
|
|
233578
233732
|
//#region src/error-classifier.ts
|
|
233579
233733
|
/**
|
|
@@ -236373,6 +236527,58 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
|
|
|
236373
236527
|
meta: { error: err instanceof Error ? err.message : String(err) }
|
|
236374
236528
|
});
|
|
236375
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
|
+
}
|
|
236376
236582
|
}
|
|
236377
236583
|
/**
|
|
236378
236584
|
* Phase 5 (kernel-driven) — fired after `onProbe()` + accessory
|
|
@@ -236930,6 +237136,7 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
|
|
|
236930
237136
|
await (await this.ensureApi()).reboot(this.getChannel());
|
|
236931
237137
|
return { success: true };
|
|
236932
237138
|
} });
|
|
237139
|
+
this.registerNetworkLink();
|
|
236933
237140
|
this.ctx.registerNativeCap(motionCapability, { isDetected: async ({ deviceId }) => {
|
|
236934
237141
|
if (deviceId !== this.id) throw new Error(`ReolinkCamera: deviceId mismatch, expected ${this.id}, got ${deviceId}`);
|
|
236935
237142
|
return this.state.motion.detected ?? false;
|
|
@@ -237184,6 +237391,7 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
|
|
|
237184
237391
|
try {
|
|
237185
237392
|
const info = await api.getBatteryInfo(this.getChannel());
|
|
237186
237393
|
this.updateBatteryCache(info);
|
|
237394
|
+
await this.refreshNetworkLinkFromApi(api, "battery");
|
|
237187
237395
|
} catch (err) {
|
|
237188
237396
|
this.ctx.logger.debug("battery refresh failed", {
|
|
237189
237397
|
tags: { deviceId: this.id },
|
|
@@ -240721,6 +240929,8 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
|
|
|
240721
240929
|
/** Write granularity for `battery.lastContactAt` — see `markPassiveContact`.
|
|
240722
240930
|
* Bounds the commit rate this field can cost at 12/hour/device, and only
|
|
240723
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;
|
|
240724
240934
|
static CONTACT_WRITE_QUANTUM_MS = 5 * 6e4;
|
|
240725
240935
|
/**
|
|
240726
240936
|
* Shared wake-transition handler invoked by both the simpleEvent
|