@camstack/addon-provider-reolink 1.2.92 → 1.2.94

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
@@ -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
@@ -29670,6 +29779,287 @@ var ptzAutotrackCapability = {
29670
29779
  durability: "session"
29671
29780
  };
29672
29781
  /**
29782
+ * `navigation` — a device-scoped capability that natively expresses the FULL
29783
+ * navigation / action surface of a robot that DRIVES ITSELF and carries an
29784
+ * on-board camera (the Dreame robot-vacuum camera is the first provider).
29785
+ *
29786
+ * Why a NEW cap rather than overloading `ptz`:
29787
+ * - `ptz` moves a *gimbal* on a fixed camera (pan/tilt/zoom of the lens). A
29788
+ * robot vacuum has no gimbal — the whole chassis drives, turns and spins.
29789
+ * The two are different physical models: PTZ is absolute-position + presets,
29790
+ * navigation is momentary drive nudges + discrete robot ACTIONS
29791
+ * (dock / spot-clean / follow-pet / go-to-point / …).
29792
+ * - This cap is the SOURCE OF TRUTH. Two consumers adapt from it rather than
29793
+ * the reverse:
29794
+ * 1. a native CamStack navigation panel (data-driven from `listActions`
29795
+ * / `getOptions`), and
29796
+ * 2. the PTZ surface — a thin adapter mimics `ptz` from `navigation` so a
29797
+ * robot camera shows up in the existing PTZ control path without every
29798
+ * PTZ provider learning about robots. The mapping lives in the adapter,
29799
+ * not here (see the addon design note):
29800
+ * ptz.continuousMove({pan,tilt}) → navigation.move({pan,tilt})
29801
+ * ptz.stop() → navigation.stop()
29802
+ * ptz.goHome() → navigation.runAction('goHome')
29803
+ * ptz.getPresets() → navigation.listActions() (id→preset)
29804
+ * ptz.goToPreset(id) → navigation.runAction(id)
29805
+ *
29806
+ * ## Continuous drive
29807
+ *
29808
+ * `move` is MOMENTARY. Fluid navigation comes from the UI (or the PTZ adapter)
29809
+ * sending `move({pan,tilt})` REPEATEDLY at ~1 Hz while a direction is held, and
29810
+ * one `stop()` on release — exactly like the robot app's remote-drive joystick.
29811
+ * The provider forwards EACH `move` to one drive write; it must NOT debounce or
29812
+ * coalesce them. The UI owns the cadence.
29813
+ *
29814
+ * ## The action dictionary
29815
+ *
29816
+ * The discrete controls (dock / locate / spot-clean / follow / flash / sounds)
29817
+ * are a DATA-DRIVEN dictionary the cap exposes via `listActions()`. Each entry
29818
+ * carries `{ id, kind, label, icon }` (plus `soundId` for sound entries) so the
29819
+ * native panel AND the PTZ mimic render buttons WITHOUT hardcoding a
29820
+ * vendor-specific list. `kind: 'action'` entries are triggered with
29821
+ * `runAction({ actionId })`; `kind: 'sound'` entries with `playSound({ soundId })`
29822
+ * (the entry carries the `soundId` to pass). The general primitives — `move`,
29823
+ * `stop`, `goToPoint` — stay as first-class methods, not dictionary entries.
29824
+ *
29825
+ * NOTE (provider wiring): the first provider (Dreame) drives this with the RAW
29826
+ * `callAction(siid,aiid,in)` / `setProperty({siid,piid,value})` MIoT primitives
29827
+ * that the currently-published `@apocaliss92/nodedreame` already exposes on
29828
+ * every device handle. A future nodedreame publish adds a typed
29829
+ * `DreameCameraController` (drive / playPetSound / spotClean / findPet / …); the
29830
+ * provider can then swap the raw calls for the typed methods with no change to
29831
+ * THIS contract.
29832
+ */
29833
+ /**
29834
+ * A momentary drive nudge. The robot MOVES (no gimbal) for as long as the caller
29835
+ * keeps sending nudges (~1 Hz); an explicit `stop` (or letting the nudges lapse)
29836
+ * halts it.
29837
+ *
29838
+ * - `pan` — turn: negative = left, positive = right, 0 = straight.
29839
+ * - `tilt` — throttle: positive = forward, negative = spin / turn-around.
29840
+ * - `speed` — optional intensity hint [0, 1]; the provider may scale the drive
29841
+ * vector by it (drivers without proportional drive ignore it).
29842
+ *
29843
+ * `pan` / `tilt` are normalized [-1, 1]. Both optional so a caller can nudge one
29844
+ * axis alone; an all-undefined nudge is a no-op.
29845
+ */
29846
+ var NavigationMoveCommandSchema = object({
29847
+ pan: number().min(-1).max(1).optional(),
29848
+ tilt: number().min(-1).max(1).optional(),
29849
+ speed: number().min(0).max(1).optional()
29850
+ });
29851
+ /**
29852
+ * The enumerated discrete actions a navigation-capable robot can perform via
29853
+ * `runAction`. This is the CLOSED vocabulary; a given device advertises the
29854
+ * subset it supports through `listActions`. Sounds are NOT here — they go through
29855
+ * `playSound` (see the `sound` dictionary entries).
29856
+ */
29857
+ var NavigationActionIdSchema = _enum([
29858
+ "goHome",
29859
+ "locate",
29860
+ "spotClean",
29861
+ "findPet",
29862
+ "personFollow",
29863
+ "stop",
29864
+ "startClean",
29865
+ "pauseClean",
29866
+ "dockWash",
29867
+ "autoEmpty",
29868
+ "flashOn",
29869
+ "flashOff"
29870
+ ]);
29871
+ /** Whether a dictionary entry is a `runAction` action or a `playSound` sound. */
29872
+ var NavigationEntryKindSchema = _enum(["action", "sound"]);
29873
+ /**
29874
+ * One entry in the navigation action dictionary — the DATA-DRIVEN unit both the
29875
+ * native panel and the PTZ mimic render as a button.
29876
+ *
29877
+ * - `id` — stable id. For `kind:'action'` it is a {@link NavigationActionId}
29878
+ * (pass to `runAction`); for `kind:'sound'` it is a namespaced id
29879
+ * (`sound:meow`) whose `soundId` is passed to `playSound`.
29880
+ * - `icon` — icon HINT (lucide-style name; the UI maps it to its own set).
29881
+ * - `label` — operator-facing English label.
29882
+ * - `soundId` — wire sound id, present only on `kind:'sound'` entries.
29883
+ * - `enabled` — per-device FEATURE FLAG. `listActions` reports it so the UI /
29884
+ * PTZ render ONLY enabled entries. Data-driven: the provider
29885
+ * flips it from config, never by editing code.
29886
+ */
29887
+ var NavigationActionEntrySchema = object({
29888
+ id: string(),
29889
+ kind: NavigationEntryKindSchema,
29890
+ label: string(),
29891
+ icon: string(),
29892
+ /** Present only on `kind:'sound'` entries — the id to pass to `playSound`. */
29893
+ soundId: number().int().optional(),
29894
+ /** Per-device feature flag — render this entry only when true. */
29895
+ enabled: boolean()
29896
+ });
29897
+ /** Coordinates for `goToPoint` — a point on the robot's live map. */
29898
+ var NavigationPointSchema = object({
29899
+ x: number(),
29900
+ y: number()
29901
+ });
29902
+ /**
29903
+ * Per-device FEATURE-FLAG report for the general (non-dictionary) primitives.
29904
+ * The cap reports which are enabled so the UI / PTZ render only the controls
29905
+ * that are turned on for THIS device. Data-driven: the provider derives these
29906
+ * from config + probe, never hardcoded in the UI. The per-DICTIONARY-entry flags
29907
+ * live on {@link NavigationActionEntrySchema.enabled}; these gate the primitives
29908
+ * that are not dictionary entries.
29909
+ *
29910
+ * - `move` / `stop` — the momentary drive joystick.
29911
+ * - `goToPoint` — send-to-map-coordinate. Ships OFF on Dreame until the
29912
+ * map-coordinate plumbing is wired.
29913
+ * - `runAction` — the discrete action buttons (dictionary `kind:'action'`).
29914
+ * - `playSound` — the sound buttons (dictionary `kind:'sound'`).
29915
+ * - `light` — the on/off fill-light toggle (works anytime).
29916
+ * - `lightMode` — the auto/manual selector + manual level slider (a
29917
+ * camera-service control; needs an active stream).
29918
+ */
29919
+ var NavigationFeaturesSchema = object({
29920
+ move: boolean(),
29921
+ stop: boolean(),
29922
+ goToPoint: boolean(),
29923
+ runAction: boolean(),
29924
+ playSound: boolean(),
29925
+ light: boolean(),
29926
+ lightMode: boolean()
29927
+ });
29928
+ /** Light mode: `auto` lets the camera choose brightness; `manual` uses `level`. */
29929
+ var NavigationLightModeSchema = _enum(["auto", "manual"]);
29930
+ /**
29931
+ * Live navigation state so the UI can reflect what the robot is doing:
29932
+ * - `mode` — coarse activity (idle / cleaning / following / …).
29933
+ * - `following` — person/pet follow is currently armed.
29934
+ * - `flash` — the on-camera fill light is on.
29935
+ * - `lightMode` — auto vs manual fill-light mode.
29936
+ * - `lightLevel` — manual fill-light level (40..100); meaningful when
29937
+ * `lightMode === 'manual'`.
29938
+ */
29939
+ var NavigationStatusSchema = object({
29940
+ mode: _enum([
29941
+ "idle",
29942
+ "cleaning",
29943
+ "spot",
29944
+ "following",
29945
+ "goto",
29946
+ "returning",
29947
+ "paused",
29948
+ "unknown"
29949
+ ]),
29950
+ following: boolean(),
29951
+ flash: boolean(),
29952
+ lightMode: NavigationLightModeSchema,
29953
+ lightLevel: number().min(40).max(100),
29954
+ /** Ms epoch when the slice was last updated. */
29955
+ lastChangedAt: number()
29956
+ });
29957
+ /**
29958
+ * Runtime-state slice owned by this cap (kernel-managed: validated, mirrored,
29959
+ * observable). Adds `lastFetchedAt` on top of the status shape per the
29960
+ * convention.
29961
+ */
29962
+ var NavigationRuntimeStateSchema = NavigationStatusSchema.extend({ lastFetchedAt: number() });
29963
+ var navigationCapability = {
29964
+ name: "navigation",
29965
+ scope: "device",
29966
+ deviceNative: true,
29967
+ mode: "singleton",
29968
+ deviceTypes: [DeviceType.Camera],
29969
+ deviceConfig: { ui: {
29970
+ kind: "widget",
29971
+ widgetId: "host/navigation-panel",
29972
+ tab: "navigation",
29973
+ topTab: true,
29974
+ label: "Navigation",
29975
+ order: 0
29976
+ } },
29977
+ methods: {
29978
+ /**
29979
+ * Momentary drive nudge (the robot moves). `protected` — mirrors
29980
+ * `ptz.continuousMove` so the Viewer navigation panel (and the PTZ-mimic
29981
+ * path) works for any authenticated user, not admin-only. The UI sends
29982
+ * these at ~1 Hz while a control is held; the provider forwards each one to
29983
+ * a single drive write WITHOUT debouncing.
29984
+ */
29985
+ move: method(NavigationMoveCommandSchema.extend({ deviceId: number() }), _void(), { kind: "mutation" }),
29986
+ /** Halt all motion immediately (zero drive vector). */
29987
+ stop: method(object({ deviceId: number() }), _void(), { kind: "mutation" }),
29988
+ /** Send the robot to a point on its live map. */
29989
+ goToPoint: method(NavigationPointSchema.extend({ deviceId: number() }), _void(), { kind: "mutation" }),
29990
+ /**
29991
+ * Enumerate the discrete controls THIS device supports (data-driven UI +
29992
+ * PTZ mimic). Camera-probed subset of {@link NAVIGATION_ACTION_CATALOG}.
29993
+ */
29994
+ listActions: method(object({ deviceId: number() }), array(NavigationActionEntrySchema)),
29995
+ /**
29996
+ * Run one discrete action (a `kind:'action'` dictionary entry). Invalid /
29997
+ * unsupported action ids are rejected by the provider.
29998
+ */
29999
+ runAction: method(object({
30000
+ deviceId: number(),
30001
+ actionId: NavigationActionIdSchema
30002
+ }), _void(), { kind: "mutation" }),
30003
+ /** Play a sound by its wire id (the `soundId` of a `kind:'sound'` entry). */
30004
+ playSound: method(object({
30005
+ deviceId: number(),
30006
+ soundId: number().int()
30007
+ }), _void(), { kind: "mutation" }),
30008
+ /**
30009
+ * Turn the on-camera fill light on / off (the `OpenFullLight` control —
30010
+ * works anytime, no active stream required).
30011
+ */
30012
+ setLightOn: method(object({
30013
+ deviceId: number(),
30014
+ on: boolean()
30015
+ }), _void(), { kind: "mutation" }),
30016
+ /**
30017
+ * Set the fill-light mode (auto vs manual). `manual` optionally carries the
30018
+ * initial `level`. The auto/manual + level control is a CAMERA-service
30019
+ * action that generally needs an active camera stream/monitor session — the
30020
+ * UI shows the manual level slider ONLY when `mode === 'manual'`.
30021
+ */
30022
+ setLightMode: method(object({
30023
+ deviceId: number(),
30024
+ mode: NavigationLightModeSchema,
30025
+ level: number().min(40).max(100).optional()
30026
+ }), _void(), { kind: "mutation" }),
30027
+ /** Set the MANUAL fill-light level (40..100). Implies `manual` mode. */
30028
+ setLightLevel: method(object({
30029
+ deviceId: number(),
30030
+ level: number().min(40).max(100)
30031
+ }), _void(), { kind: "mutation" }),
30032
+ /**
30033
+ * Per-device FEATURE-FLAG report for the general primitives — drives which
30034
+ * controls the UI shows (the per-entry flags for the dictionary come back on
30035
+ * `listActions`).
30036
+ */
30037
+ getFeatures: method(object({ deviceId: number() }), NavigationFeaturesSchema)
30038
+ },
30039
+ events: { onStatusChanged: { data: object({
30040
+ deviceId: number(),
30041
+ status: NavigationStatusSchema
30042
+ }) } },
30043
+ status: {
30044
+ schema: NavigationStatusSchema,
30045
+ kind: "push"
30046
+ },
30047
+ /**
30048
+ * Runtime-state slice mirrored by the kernel. The navigation panel watches it
30049
+ * for live mode / follow / flash changes.
30050
+ */
30051
+ runtimeState: NavigationRuntimeStateSchema,
30052
+ /**
30053
+ * Runtime-state durability: **session** — like `vacuum-control`, a restored
30054
+ * `mode: cleaning` / `following: true` is a robot that is not actually doing
30055
+ * that. The live handle re-publishes on connect.
30056
+ *
30057
+ * See `RuntimeStateDurability`. Enforced by
30058
+ * `scripts/check-runtime-state-durability.ts`.
30059
+ */
30060
+ durability: "session"
30061
+ };
30062
+ /**
29673
30063
  * reboot — device-scoped capability for "soft" device reboots (firmware
29674
30064
  * reboot via vendor protocol; cameras, NVRs, doorbells). Surfaces a
29675
30065
  * single mutation so the UI can offer a confirm-and-reboot button for
@@ -33182,6 +33572,8 @@ var DEVICE_LOCAL_STATE_CAPS = {
33182
33572
  motionTrigger: motionTriggerCapability,
33183
33573
  motionZones: motionZonesCapability,
33184
33574
  nativeObjectDetection: nativeObjectDetectionCapability,
33575
+ navigation: navigationCapability,
33576
+ networkLink: networkLinkCapability,
33185
33577
  notifier: notifierCapability,
33186
33578
  numericSensor: numericSensorCapability,
33187
33579
  petFeeder: petFeederCapability,
@@ -37198,6 +37590,66 @@ Object.freeze({
37198
37590
  addonId: null,
37199
37591
  access: "create"
37200
37592
  },
37593
+ "navigation.getFeatures": {
37594
+ capName: "navigation",
37595
+ capScope: "device",
37596
+ addonId: null,
37597
+ access: "view"
37598
+ },
37599
+ "navigation.goToPoint": {
37600
+ capName: "navigation",
37601
+ capScope: "device",
37602
+ addonId: null,
37603
+ access: "create"
37604
+ },
37605
+ "navigation.listActions": {
37606
+ capName: "navigation",
37607
+ capScope: "device",
37608
+ addonId: null,
37609
+ access: "view"
37610
+ },
37611
+ "navigation.move": {
37612
+ capName: "navigation",
37613
+ capScope: "device",
37614
+ addonId: null,
37615
+ access: "create"
37616
+ },
37617
+ "navigation.playSound": {
37618
+ capName: "navigation",
37619
+ capScope: "device",
37620
+ addonId: null,
37621
+ access: "create"
37622
+ },
37623
+ "navigation.runAction": {
37624
+ capName: "navigation",
37625
+ capScope: "device",
37626
+ addonId: null,
37627
+ access: "create"
37628
+ },
37629
+ "navigation.setLightLevel": {
37630
+ capName: "navigation",
37631
+ capScope: "device",
37632
+ addonId: null,
37633
+ access: "create"
37634
+ },
37635
+ "navigation.setLightMode": {
37636
+ capName: "navigation",
37637
+ capScope: "device",
37638
+ addonId: null,
37639
+ access: "create"
37640
+ },
37641
+ "navigation.setLightOn": {
37642
+ capName: "navigation",
37643
+ capScope: "device",
37644
+ addonId: null,
37645
+ access: "create"
37646
+ },
37647
+ "navigation.stop": {
37648
+ capName: "navigation",
37649
+ capScope: "device",
37650
+ addonId: null,
37651
+ access: "create"
37652
+ },
37201
37653
  "networkAccess.getEndpoint": {
37202
37654
  capName: "network-access",
37203
37655
  capScope: "system",
@@ -41293,6 +41745,56 @@ Object.freeze({
41293
41745
  form: "single",
41294
41746
  optional: false
41295
41747
  }],
41748
+ "navigation.getFeatures": [{
41749
+ name: "deviceId",
41750
+ form: "single",
41751
+ optional: false
41752
+ }],
41753
+ "navigation.goToPoint": [{
41754
+ name: "deviceId",
41755
+ form: "single",
41756
+ optional: false
41757
+ }],
41758
+ "navigation.listActions": [{
41759
+ name: "deviceId",
41760
+ form: "single",
41761
+ optional: false
41762
+ }],
41763
+ "navigation.move": [{
41764
+ name: "deviceId",
41765
+ form: "single",
41766
+ optional: false
41767
+ }],
41768
+ "navigation.playSound": [{
41769
+ name: "deviceId",
41770
+ form: "single",
41771
+ optional: false
41772
+ }],
41773
+ "navigation.runAction": [{
41774
+ name: "deviceId",
41775
+ form: "single",
41776
+ optional: false
41777
+ }],
41778
+ "navigation.setLightLevel": [{
41779
+ name: "deviceId",
41780
+ form: "single",
41781
+ optional: false
41782
+ }],
41783
+ "navigation.setLightMode": [{
41784
+ name: "deviceId",
41785
+ form: "single",
41786
+ optional: false
41787
+ }],
41788
+ "navigation.setLightOn": [{
41789
+ name: "deviceId",
41790
+ form: "single",
41791
+ optional: false
41792
+ }],
41793
+ "navigation.stop": [{
41794
+ name: "deviceId",
41795
+ form: "single",
41796
+ optional: false
41797
+ }],
41296
41798
  "networkQuality.getDeviceStats": [{
41297
41799
  name: "deviceId",
41298
41800
  form: "single",
@@ -233594,6 +234096,52 @@ async function populateReolinkMetadata(api, channel, target) {
233594
234096
  });
233595
234097
  }
233596
234098
  }
234099
+ /**
234100
+ * The link the label names. A label the firmware did not give is `unknown`,
234101
+ * UNLESS the camera also named the network it joined — an SSID is a joined
234102
+ * wifi link whatever the label says. A bare signal number is NOT enough:
234103
+ * measured 2026-09-06, an E1 Outdoor PoE on its cable answered
234104
+ * `getWifiSignal` with -10 and no SSID, and -10 dBm is not a wifi reading.
234105
+ */
234106
+ function linkTypeOf(readout) {
234107
+ const label = (readout.activeLink ?? "").toLowerCase();
234108
+ if (label.includes("wifi") || label.includes("wlan") || label.includes("wireless")) return "wifi";
234109
+ if (label.includes("lan") || label.includes("eth") || label.includes("wire")) return "ethernet";
234110
+ if (/\b(4g|5g|lte|cell|sim)\b/.test(label)) return "cellular";
234111
+ if (readout.ssid !== void 0 && readout.ssid !== "") return "wifi";
234112
+ return "unknown";
234113
+ }
234114
+ /** True when the label names a wireless link — the only case worth a signal read. */
234115
+ function isWirelessLabel(activeLink) {
234116
+ const type = linkTypeOf({
234117
+ activeLink,
234118
+ ssid: void 0
234119
+ });
234120
+ return type === "wifi" || type === "cellular";
234121
+ }
234122
+ /** 0..4 → bars; 5..100 → percent; negative → dBm; anything else → not a reading. */
234123
+ function signalPercentOf(raw) {
234124
+ if (raw === void 0 || !Number.isFinite(raw)) return null;
234125
+ if (raw < 0) return signalPercentFromRssi(raw);
234126
+ if (raw <= 4) return signalPercentFromBars(raw, 4);
234127
+ if (raw <= 100) return Math.round(raw);
234128
+ return null;
234129
+ }
234130
+ function mapNetworkReadout(readout, now) {
234131
+ const type = linkTypeOf(readout);
234132
+ const raw = readout.wifiSignal;
234133
+ const wireless = type === "wifi" || type === "cellular";
234134
+ const signalPercent = wireless ? signalPercentOf(raw) : null;
234135
+ const rssiDbm = wireless && raw !== void 0 && Number.isFinite(raw) && raw < 0 ? raw : void 0;
234136
+ const ssid = wireless && readout.ssid !== void 0 && readout.ssid !== "" ? readout.ssid : void 0;
234137
+ return {
234138
+ type,
234139
+ signalPercent,
234140
+ ...rssiDbm !== void 0 ? { rssiDbm } : {},
234141
+ ...ssid !== void 0 ? { ssid } : {},
234142
+ lastUpdated: now
234143
+ };
234144
+ }
233597
234145
  //#endregion
233598
234146
  //#region src/error-classifier.ts
233599
234147
  /**
@@ -236393,6 +236941,62 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
236393
236941
  meta: { error: err instanceof Error ? err.message : String(err) }
236394
236942
  });
236395
236943
  });
236944
+ await this.refreshNetworkLinkFromApi(api, "probe");
236945
+ }
236946
+ /**
236947
+ * Register the `network-link` cap: the read path serves the slice, the
236948
+ * writers are {@link refreshNetworkLinkFromApi}. Seeded unknown so the
236949
+ * restored slice (if any) stays valid and a fresh device draws no link.
236950
+ */
236951
+ registerNetworkLink() {
236952
+ this.ctx.registerNativeCap(networkLinkCapability, { getStatus: async ({ deviceId }) => {
236953
+ if (deviceId !== this.id) throw new Error(`ReolinkCamera: deviceId mismatch, expected ${this.id}, got ${deviceId}`);
236954
+ return this.state.networkLink;
236955
+ } });
236956
+ if (this.getCapSlice(networkLinkCapability) === null) this.setCapSlice(networkLinkCapability, NETWORK_LINK_UNKNOWN);
236957
+ }
236958
+ /**
236959
+ * Read the active link and, on a wireless one, its signal and network name
236960
+ * over a socket that is ALREADY up. Called post-login and after a
236961
+ * successful battery read — never on its own timer, so a sleeping battery
236962
+ * camera is never woken for a bar count. Each read is best-effort with its
236963
+ * own bound; a failure keeps the last slice and says so at debug level.
236964
+ */
236965
+ async refreshNetworkLinkFromApi(api, reason) {
236966
+ const channel = this.getChannel();
236967
+ const timeoutMs = ReolinkCamera.NETWORK_LINK_READ_TIMEOUT_MS;
236968
+ try {
236969
+ const activeLink = (await api.getNetworkInfo(channel, { timeoutMs }))?.activeLink;
236970
+ const askWireless = activeLink === void 0 || isWirelessLabel(activeLink);
236971
+ const wifiSignal = askWireless ? (await api.getWifiSignal(channel, { timeoutMs }).catch(() => ({ signal: void 0 }))).signal : void 0;
236972
+ const mapped = mapNetworkReadout({
236973
+ activeLink,
236974
+ wifiSignal,
236975
+ ssid: askWireless && wifiSignal !== void 0 ? (await api.getWifi(channel, { timeoutMs }).catch(() => ({ ssid: void 0 }))).ssid : void 0
236976
+ }, Date.now());
236977
+ const changed = this.state.networkLink.type !== mapped.type || !this.networkLinkReported;
236978
+ this.ctx.logger[changed ? "info" : "debug"]("network link read", {
236979
+ tags: { deviceId: this.id },
236980
+ meta: {
236981
+ reason,
236982
+ activeLink,
236983
+ rawSignal: wifiSignal,
236984
+ ...mapped
236985
+ }
236986
+ });
236987
+ this.networkLinkReported = true;
236988
+ this.setCapSlice(networkLinkCapability, mapped);
236989
+ } catch (err) {
236990
+ const level = this.networkLinkFailureWarned ? "debug" : "warn";
236991
+ this.networkLinkFailureWarned = true;
236992
+ this.ctx.logger[level]("network link read failed — keeping the last slice", {
236993
+ tags: { deviceId: this.id },
236994
+ meta: {
236995
+ reason,
236996
+ error: err instanceof Error ? err.message : String(err)
236997
+ }
236998
+ });
236999
+ }
236396
237000
  }
236397
237001
  /**
236398
237002
  * Phase 5 (kernel-driven) — fired after `onProbe()` + accessory
@@ -236950,6 +237554,7 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
236950
237554
  await (await this.ensureApi()).reboot(this.getChannel());
236951
237555
  return { success: true };
236952
237556
  } });
237557
+ this.registerNetworkLink();
236953
237558
  this.ctx.registerNativeCap(motionCapability, { isDetected: async ({ deviceId }) => {
236954
237559
  if (deviceId !== this.id) throw new Error(`ReolinkCamera: deviceId mismatch, expected ${this.id}, got ${deviceId}`);
236955
237560
  return this.state.motion.detected ?? false;
@@ -237204,6 +237809,7 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
237204
237809
  try {
237205
237810
  const info = await api.getBatteryInfo(this.getChannel());
237206
237811
  this.updateBatteryCache(info);
237812
+ await this.refreshNetworkLinkFromApi(api, "battery");
237207
237813
  } catch (err) {
237208
237814
  this.ctx.logger.debug("battery refresh failed", {
237209
237815
  tags: { deviceId: this.id },
@@ -240741,6 +241347,12 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
240741
241347
  /** Write granularity for `battery.lastContactAt` — see `markPassiveContact`.
240742
241348
  * Bounds the commit rate this field can cost at 12/hour/device, and only
240743
241349
  * for a device something is actually reaching. */
241350
+ /** Bound on each best-effort network read; three reads at most per refresh. */
241351
+ static NETWORK_LINK_READ_TIMEOUT_MS = 4e3;
241352
+ /** The first network read of this device was said at INFO (raw values included). */
241353
+ networkLinkReported = false;
241354
+ /** The first network read failure was said at WARN; later ones are debug. */
241355
+ networkLinkFailureWarned = false;
240744
241356
  static CONTACT_WRITE_QUANTUM_MS = 5 * 6e4;
240745
241357
  /**
240746
241358
  * 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
@@ -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
@@ -29665,6 +29774,287 @@ var ptzAutotrackCapability = {
29665
29774
  durability: "session"
29666
29775
  };
29667
29776
  /**
29777
+ * `navigation` — a device-scoped capability that natively expresses the FULL
29778
+ * navigation / action surface of a robot that DRIVES ITSELF and carries an
29779
+ * on-board camera (the Dreame robot-vacuum camera is the first provider).
29780
+ *
29781
+ * Why a NEW cap rather than overloading `ptz`:
29782
+ * - `ptz` moves a *gimbal* on a fixed camera (pan/tilt/zoom of the lens). A
29783
+ * robot vacuum has no gimbal — the whole chassis drives, turns and spins.
29784
+ * The two are different physical models: PTZ is absolute-position + presets,
29785
+ * navigation is momentary drive nudges + discrete robot ACTIONS
29786
+ * (dock / spot-clean / follow-pet / go-to-point / …).
29787
+ * - This cap is the SOURCE OF TRUTH. Two consumers adapt from it rather than
29788
+ * the reverse:
29789
+ * 1. a native CamStack navigation panel (data-driven from `listActions`
29790
+ * / `getOptions`), and
29791
+ * 2. the PTZ surface — a thin adapter mimics `ptz` from `navigation` so a
29792
+ * robot camera shows up in the existing PTZ control path without every
29793
+ * PTZ provider learning about robots. The mapping lives in the adapter,
29794
+ * not here (see the addon design note):
29795
+ * ptz.continuousMove({pan,tilt}) → navigation.move({pan,tilt})
29796
+ * ptz.stop() → navigation.stop()
29797
+ * ptz.goHome() → navigation.runAction('goHome')
29798
+ * ptz.getPresets() → navigation.listActions() (id→preset)
29799
+ * ptz.goToPreset(id) → navigation.runAction(id)
29800
+ *
29801
+ * ## Continuous drive
29802
+ *
29803
+ * `move` is MOMENTARY. Fluid navigation comes from the UI (or the PTZ adapter)
29804
+ * sending `move({pan,tilt})` REPEATEDLY at ~1 Hz while a direction is held, and
29805
+ * one `stop()` on release — exactly like the robot app's remote-drive joystick.
29806
+ * The provider forwards EACH `move` to one drive write; it must NOT debounce or
29807
+ * coalesce them. The UI owns the cadence.
29808
+ *
29809
+ * ## The action dictionary
29810
+ *
29811
+ * The discrete controls (dock / locate / spot-clean / follow / flash / sounds)
29812
+ * are a DATA-DRIVEN dictionary the cap exposes via `listActions()`. Each entry
29813
+ * carries `{ id, kind, label, icon }` (plus `soundId` for sound entries) so the
29814
+ * native panel AND the PTZ mimic render buttons WITHOUT hardcoding a
29815
+ * vendor-specific list. `kind: 'action'` entries are triggered with
29816
+ * `runAction({ actionId })`; `kind: 'sound'` entries with `playSound({ soundId })`
29817
+ * (the entry carries the `soundId` to pass). The general primitives — `move`,
29818
+ * `stop`, `goToPoint` — stay as first-class methods, not dictionary entries.
29819
+ *
29820
+ * NOTE (provider wiring): the first provider (Dreame) drives this with the RAW
29821
+ * `callAction(siid,aiid,in)` / `setProperty({siid,piid,value})` MIoT primitives
29822
+ * that the currently-published `@apocaliss92/nodedreame` already exposes on
29823
+ * every device handle. A future nodedreame publish adds a typed
29824
+ * `DreameCameraController` (drive / playPetSound / spotClean / findPet / …); the
29825
+ * provider can then swap the raw calls for the typed methods with no change to
29826
+ * THIS contract.
29827
+ */
29828
+ /**
29829
+ * A momentary drive nudge. The robot MOVES (no gimbal) for as long as the caller
29830
+ * keeps sending nudges (~1 Hz); an explicit `stop` (or letting the nudges lapse)
29831
+ * halts it.
29832
+ *
29833
+ * - `pan` — turn: negative = left, positive = right, 0 = straight.
29834
+ * - `tilt` — throttle: positive = forward, negative = spin / turn-around.
29835
+ * - `speed` — optional intensity hint [0, 1]; the provider may scale the drive
29836
+ * vector by it (drivers without proportional drive ignore it).
29837
+ *
29838
+ * `pan` / `tilt` are normalized [-1, 1]. Both optional so a caller can nudge one
29839
+ * axis alone; an all-undefined nudge is a no-op.
29840
+ */
29841
+ var NavigationMoveCommandSchema = object({
29842
+ pan: number().min(-1).max(1).optional(),
29843
+ tilt: number().min(-1).max(1).optional(),
29844
+ speed: number().min(0).max(1).optional()
29845
+ });
29846
+ /**
29847
+ * The enumerated discrete actions a navigation-capable robot can perform via
29848
+ * `runAction`. This is the CLOSED vocabulary; a given device advertises the
29849
+ * subset it supports through `listActions`. Sounds are NOT here — they go through
29850
+ * `playSound` (see the `sound` dictionary entries).
29851
+ */
29852
+ var NavigationActionIdSchema = _enum([
29853
+ "goHome",
29854
+ "locate",
29855
+ "spotClean",
29856
+ "findPet",
29857
+ "personFollow",
29858
+ "stop",
29859
+ "startClean",
29860
+ "pauseClean",
29861
+ "dockWash",
29862
+ "autoEmpty",
29863
+ "flashOn",
29864
+ "flashOff"
29865
+ ]);
29866
+ /** Whether a dictionary entry is a `runAction` action or a `playSound` sound. */
29867
+ var NavigationEntryKindSchema = _enum(["action", "sound"]);
29868
+ /**
29869
+ * One entry in the navigation action dictionary — the DATA-DRIVEN unit both the
29870
+ * native panel and the PTZ mimic render as a button.
29871
+ *
29872
+ * - `id` — stable id. For `kind:'action'` it is a {@link NavigationActionId}
29873
+ * (pass to `runAction`); for `kind:'sound'` it is a namespaced id
29874
+ * (`sound:meow`) whose `soundId` is passed to `playSound`.
29875
+ * - `icon` — icon HINT (lucide-style name; the UI maps it to its own set).
29876
+ * - `label` — operator-facing English label.
29877
+ * - `soundId` — wire sound id, present only on `kind:'sound'` entries.
29878
+ * - `enabled` — per-device FEATURE FLAG. `listActions` reports it so the UI /
29879
+ * PTZ render ONLY enabled entries. Data-driven: the provider
29880
+ * flips it from config, never by editing code.
29881
+ */
29882
+ var NavigationActionEntrySchema = object({
29883
+ id: string(),
29884
+ kind: NavigationEntryKindSchema,
29885
+ label: string(),
29886
+ icon: string(),
29887
+ /** Present only on `kind:'sound'` entries — the id to pass to `playSound`. */
29888
+ soundId: number().int().optional(),
29889
+ /** Per-device feature flag — render this entry only when true. */
29890
+ enabled: boolean()
29891
+ });
29892
+ /** Coordinates for `goToPoint` — a point on the robot's live map. */
29893
+ var NavigationPointSchema = object({
29894
+ x: number(),
29895
+ y: number()
29896
+ });
29897
+ /**
29898
+ * Per-device FEATURE-FLAG report for the general (non-dictionary) primitives.
29899
+ * The cap reports which are enabled so the UI / PTZ render only the controls
29900
+ * that are turned on for THIS device. Data-driven: the provider derives these
29901
+ * from config + probe, never hardcoded in the UI. The per-DICTIONARY-entry flags
29902
+ * live on {@link NavigationActionEntrySchema.enabled}; these gate the primitives
29903
+ * that are not dictionary entries.
29904
+ *
29905
+ * - `move` / `stop` — the momentary drive joystick.
29906
+ * - `goToPoint` — send-to-map-coordinate. Ships OFF on Dreame until the
29907
+ * map-coordinate plumbing is wired.
29908
+ * - `runAction` — the discrete action buttons (dictionary `kind:'action'`).
29909
+ * - `playSound` — the sound buttons (dictionary `kind:'sound'`).
29910
+ * - `light` — the on/off fill-light toggle (works anytime).
29911
+ * - `lightMode` — the auto/manual selector + manual level slider (a
29912
+ * camera-service control; needs an active stream).
29913
+ */
29914
+ var NavigationFeaturesSchema = object({
29915
+ move: boolean(),
29916
+ stop: boolean(),
29917
+ goToPoint: boolean(),
29918
+ runAction: boolean(),
29919
+ playSound: boolean(),
29920
+ light: boolean(),
29921
+ lightMode: boolean()
29922
+ });
29923
+ /** Light mode: `auto` lets the camera choose brightness; `manual` uses `level`. */
29924
+ var NavigationLightModeSchema = _enum(["auto", "manual"]);
29925
+ /**
29926
+ * Live navigation state so the UI can reflect what the robot is doing:
29927
+ * - `mode` — coarse activity (idle / cleaning / following / …).
29928
+ * - `following` — person/pet follow is currently armed.
29929
+ * - `flash` — the on-camera fill light is on.
29930
+ * - `lightMode` — auto vs manual fill-light mode.
29931
+ * - `lightLevel` — manual fill-light level (40..100); meaningful when
29932
+ * `lightMode === 'manual'`.
29933
+ */
29934
+ var NavigationStatusSchema = object({
29935
+ mode: _enum([
29936
+ "idle",
29937
+ "cleaning",
29938
+ "spot",
29939
+ "following",
29940
+ "goto",
29941
+ "returning",
29942
+ "paused",
29943
+ "unknown"
29944
+ ]),
29945
+ following: boolean(),
29946
+ flash: boolean(),
29947
+ lightMode: NavigationLightModeSchema,
29948
+ lightLevel: number().min(40).max(100),
29949
+ /** Ms epoch when the slice was last updated. */
29950
+ lastChangedAt: number()
29951
+ });
29952
+ /**
29953
+ * Runtime-state slice owned by this cap (kernel-managed: validated, mirrored,
29954
+ * observable). Adds `lastFetchedAt` on top of the status shape per the
29955
+ * convention.
29956
+ */
29957
+ var NavigationRuntimeStateSchema = NavigationStatusSchema.extend({ lastFetchedAt: number() });
29958
+ var navigationCapability = {
29959
+ name: "navigation",
29960
+ scope: "device",
29961
+ deviceNative: true,
29962
+ mode: "singleton",
29963
+ deviceTypes: [DeviceType.Camera],
29964
+ deviceConfig: { ui: {
29965
+ kind: "widget",
29966
+ widgetId: "host/navigation-panel",
29967
+ tab: "navigation",
29968
+ topTab: true,
29969
+ label: "Navigation",
29970
+ order: 0
29971
+ } },
29972
+ methods: {
29973
+ /**
29974
+ * Momentary drive nudge (the robot moves). `protected` — mirrors
29975
+ * `ptz.continuousMove` so the Viewer navigation panel (and the PTZ-mimic
29976
+ * path) works for any authenticated user, not admin-only. The UI sends
29977
+ * these at ~1 Hz while a control is held; the provider forwards each one to
29978
+ * a single drive write WITHOUT debouncing.
29979
+ */
29980
+ move: method(NavigationMoveCommandSchema.extend({ deviceId: number() }), _void(), { kind: "mutation" }),
29981
+ /** Halt all motion immediately (zero drive vector). */
29982
+ stop: method(object({ deviceId: number() }), _void(), { kind: "mutation" }),
29983
+ /** Send the robot to a point on its live map. */
29984
+ goToPoint: method(NavigationPointSchema.extend({ deviceId: number() }), _void(), { kind: "mutation" }),
29985
+ /**
29986
+ * Enumerate the discrete controls THIS device supports (data-driven UI +
29987
+ * PTZ mimic). Camera-probed subset of {@link NAVIGATION_ACTION_CATALOG}.
29988
+ */
29989
+ listActions: method(object({ deviceId: number() }), array(NavigationActionEntrySchema)),
29990
+ /**
29991
+ * Run one discrete action (a `kind:'action'` dictionary entry). Invalid /
29992
+ * unsupported action ids are rejected by the provider.
29993
+ */
29994
+ runAction: method(object({
29995
+ deviceId: number(),
29996
+ actionId: NavigationActionIdSchema
29997
+ }), _void(), { kind: "mutation" }),
29998
+ /** Play a sound by its wire id (the `soundId` of a `kind:'sound'` entry). */
29999
+ playSound: method(object({
30000
+ deviceId: number(),
30001
+ soundId: number().int()
30002
+ }), _void(), { kind: "mutation" }),
30003
+ /**
30004
+ * Turn the on-camera fill light on / off (the `OpenFullLight` control —
30005
+ * works anytime, no active stream required).
30006
+ */
30007
+ setLightOn: method(object({
30008
+ deviceId: number(),
30009
+ on: boolean()
30010
+ }), _void(), { kind: "mutation" }),
30011
+ /**
30012
+ * Set the fill-light mode (auto vs manual). `manual` optionally carries the
30013
+ * initial `level`. The auto/manual + level control is a CAMERA-service
30014
+ * action that generally needs an active camera stream/monitor session — the
30015
+ * UI shows the manual level slider ONLY when `mode === 'manual'`.
30016
+ */
30017
+ setLightMode: method(object({
30018
+ deviceId: number(),
30019
+ mode: NavigationLightModeSchema,
30020
+ level: number().min(40).max(100).optional()
30021
+ }), _void(), { kind: "mutation" }),
30022
+ /** Set the MANUAL fill-light level (40..100). Implies `manual` mode. */
30023
+ setLightLevel: method(object({
30024
+ deviceId: number(),
30025
+ level: number().min(40).max(100)
30026
+ }), _void(), { kind: "mutation" }),
30027
+ /**
30028
+ * Per-device FEATURE-FLAG report for the general primitives — drives which
30029
+ * controls the UI shows (the per-entry flags for the dictionary come back on
30030
+ * `listActions`).
30031
+ */
30032
+ getFeatures: method(object({ deviceId: number() }), NavigationFeaturesSchema)
30033
+ },
30034
+ events: { onStatusChanged: { data: object({
30035
+ deviceId: number(),
30036
+ status: NavigationStatusSchema
30037
+ }) } },
30038
+ status: {
30039
+ schema: NavigationStatusSchema,
30040
+ kind: "push"
30041
+ },
30042
+ /**
30043
+ * Runtime-state slice mirrored by the kernel. The navigation panel watches it
30044
+ * for live mode / follow / flash changes.
30045
+ */
30046
+ runtimeState: NavigationRuntimeStateSchema,
30047
+ /**
30048
+ * Runtime-state durability: **session** — like `vacuum-control`, a restored
30049
+ * `mode: cleaning` / `following: true` is a robot that is not actually doing
30050
+ * that. The live handle re-publishes on connect.
30051
+ *
30052
+ * See `RuntimeStateDurability`. Enforced by
30053
+ * `scripts/check-runtime-state-durability.ts`.
30054
+ */
30055
+ durability: "session"
30056
+ };
30057
+ /**
29668
30058
  * reboot — device-scoped capability for "soft" device reboots (firmware
29669
30059
  * reboot via vendor protocol; cameras, NVRs, doorbells). Surfaces a
29670
30060
  * single mutation so the UI can offer a confirm-and-reboot button for
@@ -33177,6 +33567,8 @@ var DEVICE_LOCAL_STATE_CAPS = {
33177
33567
  motionTrigger: motionTriggerCapability,
33178
33568
  motionZones: motionZonesCapability,
33179
33569
  nativeObjectDetection: nativeObjectDetectionCapability,
33570
+ navigation: navigationCapability,
33571
+ networkLink: networkLinkCapability,
33180
33572
  notifier: notifierCapability,
33181
33573
  numericSensor: numericSensorCapability,
33182
33574
  petFeeder: petFeederCapability,
@@ -37193,6 +37585,66 @@ Object.freeze({
37193
37585
  addonId: null,
37194
37586
  access: "create"
37195
37587
  },
37588
+ "navigation.getFeatures": {
37589
+ capName: "navigation",
37590
+ capScope: "device",
37591
+ addonId: null,
37592
+ access: "view"
37593
+ },
37594
+ "navigation.goToPoint": {
37595
+ capName: "navigation",
37596
+ capScope: "device",
37597
+ addonId: null,
37598
+ access: "create"
37599
+ },
37600
+ "navigation.listActions": {
37601
+ capName: "navigation",
37602
+ capScope: "device",
37603
+ addonId: null,
37604
+ access: "view"
37605
+ },
37606
+ "navigation.move": {
37607
+ capName: "navigation",
37608
+ capScope: "device",
37609
+ addonId: null,
37610
+ access: "create"
37611
+ },
37612
+ "navigation.playSound": {
37613
+ capName: "navigation",
37614
+ capScope: "device",
37615
+ addonId: null,
37616
+ access: "create"
37617
+ },
37618
+ "navigation.runAction": {
37619
+ capName: "navigation",
37620
+ capScope: "device",
37621
+ addonId: null,
37622
+ access: "create"
37623
+ },
37624
+ "navigation.setLightLevel": {
37625
+ capName: "navigation",
37626
+ capScope: "device",
37627
+ addonId: null,
37628
+ access: "create"
37629
+ },
37630
+ "navigation.setLightMode": {
37631
+ capName: "navigation",
37632
+ capScope: "device",
37633
+ addonId: null,
37634
+ access: "create"
37635
+ },
37636
+ "navigation.setLightOn": {
37637
+ capName: "navigation",
37638
+ capScope: "device",
37639
+ addonId: null,
37640
+ access: "create"
37641
+ },
37642
+ "navigation.stop": {
37643
+ capName: "navigation",
37644
+ capScope: "device",
37645
+ addonId: null,
37646
+ access: "create"
37647
+ },
37196
37648
  "networkAccess.getEndpoint": {
37197
37649
  capName: "network-access",
37198
37650
  capScope: "system",
@@ -41288,6 +41740,56 @@ Object.freeze({
41288
41740
  form: "single",
41289
41741
  optional: false
41290
41742
  }],
41743
+ "navigation.getFeatures": [{
41744
+ name: "deviceId",
41745
+ form: "single",
41746
+ optional: false
41747
+ }],
41748
+ "navigation.goToPoint": [{
41749
+ name: "deviceId",
41750
+ form: "single",
41751
+ optional: false
41752
+ }],
41753
+ "navigation.listActions": [{
41754
+ name: "deviceId",
41755
+ form: "single",
41756
+ optional: false
41757
+ }],
41758
+ "navigation.move": [{
41759
+ name: "deviceId",
41760
+ form: "single",
41761
+ optional: false
41762
+ }],
41763
+ "navigation.playSound": [{
41764
+ name: "deviceId",
41765
+ form: "single",
41766
+ optional: false
41767
+ }],
41768
+ "navigation.runAction": [{
41769
+ name: "deviceId",
41770
+ form: "single",
41771
+ optional: false
41772
+ }],
41773
+ "navigation.setLightLevel": [{
41774
+ name: "deviceId",
41775
+ form: "single",
41776
+ optional: false
41777
+ }],
41778
+ "navigation.setLightMode": [{
41779
+ name: "deviceId",
41780
+ form: "single",
41781
+ optional: false
41782
+ }],
41783
+ "navigation.setLightOn": [{
41784
+ name: "deviceId",
41785
+ form: "single",
41786
+ optional: false
41787
+ }],
41788
+ "navigation.stop": [{
41789
+ name: "deviceId",
41790
+ form: "single",
41791
+ optional: false
41792
+ }],
41291
41793
  "networkQuality.getDeviceStats": [{
41292
41794
  name: "deviceId",
41293
41795
  form: "single",
@@ -233574,6 +234076,52 @@ async function populateReolinkMetadata(api, channel, target) {
233574
234076
  });
233575
234077
  }
233576
234078
  }
234079
+ /**
234080
+ * The link the label names. A label the firmware did not give is `unknown`,
234081
+ * UNLESS the camera also named the network it joined — an SSID is a joined
234082
+ * wifi link whatever the label says. A bare signal number is NOT enough:
234083
+ * measured 2026-09-06, an E1 Outdoor PoE on its cable answered
234084
+ * `getWifiSignal` with -10 and no SSID, and -10 dBm is not a wifi reading.
234085
+ */
234086
+ function linkTypeOf(readout) {
234087
+ const label = (readout.activeLink ?? "").toLowerCase();
234088
+ if (label.includes("wifi") || label.includes("wlan") || label.includes("wireless")) return "wifi";
234089
+ if (label.includes("lan") || label.includes("eth") || label.includes("wire")) return "ethernet";
234090
+ if (/\b(4g|5g|lte|cell|sim)\b/.test(label)) return "cellular";
234091
+ if (readout.ssid !== void 0 && readout.ssid !== "") return "wifi";
234092
+ return "unknown";
234093
+ }
234094
+ /** True when the label names a wireless link — the only case worth a signal read. */
234095
+ function isWirelessLabel(activeLink) {
234096
+ const type = linkTypeOf({
234097
+ activeLink,
234098
+ ssid: void 0
234099
+ });
234100
+ return type === "wifi" || type === "cellular";
234101
+ }
234102
+ /** 0..4 → bars; 5..100 → percent; negative → dBm; anything else → not a reading. */
234103
+ function signalPercentOf(raw) {
234104
+ if (raw === void 0 || !Number.isFinite(raw)) return null;
234105
+ if (raw < 0) return signalPercentFromRssi(raw);
234106
+ if (raw <= 4) return signalPercentFromBars(raw, 4);
234107
+ if (raw <= 100) return Math.round(raw);
234108
+ return null;
234109
+ }
234110
+ function mapNetworkReadout(readout, now) {
234111
+ const type = linkTypeOf(readout);
234112
+ const raw = readout.wifiSignal;
234113
+ const wireless = type === "wifi" || type === "cellular";
234114
+ const signalPercent = wireless ? signalPercentOf(raw) : null;
234115
+ const rssiDbm = wireless && raw !== void 0 && Number.isFinite(raw) && raw < 0 ? raw : void 0;
234116
+ const ssid = wireless && readout.ssid !== void 0 && readout.ssid !== "" ? readout.ssid : void 0;
234117
+ return {
234118
+ type,
234119
+ signalPercent,
234120
+ ...rssiDbm !== void 0 ? { rssiDbm } : {},
234121
+ ...ssid !== void 0 ? { ssid } : {},
234122
+ lastUpdated: now
234123
+ };
234124
+ }
233577
234125
  //#endregion
233578
234126
  //#region src/error-classifier.ts
233579
234127
  /**
@@ -236373,6 +236921,62 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
236373
236921
  meta: { error: err instanceof Error ? err.message : String(err) }
236374
236922
  });
236375
236923
  });
236924
+ await this.refreshNetworkLinkFromApi(api, "probe");
236925
+ }
236926
+ /**
236927
+ * Register the `network-link` cap: the read path serves the slice, the
236928
+ * writers are {@link refreshNetworkLinkFromApi}. Seeded unknown so the
236929
+ * restored slice (if any) stays valid and a fresh device draws no link.
236930
+ */
236931
+ registerNetworkLink() {
236932
+ this.ctx.registerNativeCap(networkLinkCapability, { getStatus: async ({ deviceId }) => {
236933
+ if (deviceId !== this.id) throw new Error(`ReolinkCamera: deviceId mismatch, expected ${this.id}, got ${deviceId}`);
236934
+ return this.state.networkLink;
236935
+ } });
236936
+ if (this.getCapSlice(networkLinkCapability) === null) this.setCapSlice(networkLinkCapability, NETWORK_LINK_UNKNOWN);
236937
+ }
236938
+ /**
236939
+ * Read the active link and, on a wireless one, its signal and network name
236940
+ * over a socket that is ALREADY up. Called post-login and after a
236941
+ * successful battery read — never on its own timer, so a sleeping battery
236942
+ * camera is never woken for a bar count. Each read is best-effort with its
236943
+ * own bound; a failure keeps the last slice and says so at debug level.
236944
+ */
236945
+ async refreshNetworkLinkFromApi(api, reason) {
236946
+ const channel = this.getChannel();
236947
+ const timeoutMs = ReolinkCamera.NETWORK_LINK_READ_TIMEOUT_MS;
236948
+ try {
236949
+ const activeLink = (await api.getNetworkInfo(channel, { timeoutMs }))?.activeLink;
236950
+ const askWireless = activeLink === void 0 || isWirelessLabel(activeLink);
236951
+ const wifiSignal = askWireless ? (await api.getWifiSignal(channel, { timeoutMs }).catch(() => ({ signal: void 0 }))).signal : void 0;
236952
+ const mapped = mapNetworkReadout({
236953
+ activeLink,
236954
+ wifiSignal,
236955
+ ssid: askWireless && wifiSignal !== void 0 ? (await api.getWifi(channel, { timeoutMs }).catch(() => ({ ssid: void 0 }))).ssid : void 0
236956
+ }, Date.now());
236957
+ const changed = this.state.networkLink.type !== mapped.type || !this.networkLinkReported;
236958
+ this.ctx.logger[changed ? "info" : "debug"]("network link read", {
236959
+ tags: { deviceId: this.id },
236960
+ meta: {
236961
+ reason,
236962
+ activeLink,
236963
+ rawSignal: wifiSignal,
236964
+ ...mapped
236965
+ }
236966
+ });
236967
+ this.networkLinkReported = true;
236968
+ this.setCapSlice(networkLinkCapability, mapped);
236969
+ } catch (err) {
236970
+ const level = this.networkLinkFailureWarned ? "debug" : "warn";
236971
+ this.networkLinkFailureWarned = true;
236972
+ this.ctx.logger[level]("network link read failed — keeping the last slice", {
236973
+ tags: { deviceId: this.id },
236974
+ meta: {
236975
+ reason,
236976
+ error: err instanceof Error ? err.message : String(err)
236977
+ }
236978
+ });
236979
+ }
236376
236980
  }
236377
236981
  /**
236378
236982
  * Phase 5 (kernel-driven) — fired after `onProbe()` + accessory
@@ -236930,6 +237534,7 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
236930
237534
  await (await this.ensureApi()).reboot(this.getChannel());
236931
237535
  return { success: true };
236932
237536
  } });
237537
+ this.registerNetworkLink();
236933
237538
  this.ctx.registerNativeCap(motionCapability, { isDetected: async ({ deviceId }) => {
236934
237539
  if (deviceId !== this.id) throw new Error(`ReolinkCamera: deviceId mismatch, expected ${this.id}, got ${deviceId}`);
236935
237540
  return this.state.motion.detected ?? false;
@@ -237184,6 +237789,7 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
237184
237789
  try {
237185
237790
  const info = await api.getBatteryInfo(this.getChannel());
237186
237791
  this.updateBatteryCache(info);
237792
+ await this.refreshNetworkLinkFromApi(api, "battery");
237187
237793
  } catch (err) {
237188
237794
  this.ctx.logger.debug("battery refresh failed", {
237189
237795
  tags: { deviceId: this.id },
@@ -240721,6 +241327,12 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
240721
241327
  /** Write granularity for `battery.lastContactAt` — see `markPassiveContact`.
240722
241328
  * Bounds the commit rate this field can cost at 12/hour/device, and only
240723
241329
  * for a device something is actually reaching. */
241330
+ /** Bound on each best-effort network read; three reads at most per refresh. */
241331
+ static NETWORK_LINK_READ_TIMEOUT_MS = 4e3;
241332
+ /** The first network read of this device was said at INFO (raw values included). */
241333
+ networkLinkReported = false;
241334
+ /** The first network read failure was said at WARN; later ones are debug. */
241335
+ networkLinkFailureWarned = false;
240724
241336
  static CONTACT_WRITE_QUANTUM_MS = 5 * 6e4;
240725
241337
  /**
240726
241338
  * 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.92",
3
+ "version": "1.2.94",
4
4
  "description": "Reolink camera device provider addon for CamStack — native Baichuan protocol",
5
5
  "keywords": [
6
6
  "camstack",