@camstack/addon-provider-reolink 1.2.93 → 1.2.95

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
@@ -5433,6 +5433,12 @@ Object.fromEntries([
5433
5433
  icon: "move",
5434
5434
  order: 40
5435
5435
  },
5436
+ {
5437
+ id: "navigation",
5438
+ label: "Navigation",
5439
+ icon: "compass",
5440
+ order: 41
5441
+ },
5436
5442
  {
5437
5443
  id: "consumables",
5438
5444
  label: "Consumables",
@@ -29779,6 +29785,287 @@ var ptzAutotrackCapability = {
29779
29785
  durability: "session"
29780
29786
  };
29781
29787
  /**
29788
+ * `navigation` — a device-scoped capability that natively expresses the FULL
29789
+ * navigation / action surface of a robot that DRIVES ITSELF and carries an
29790
+ * on-board camera (the Dreame robot-vacuum camera is the first provider).
29791
+ *
29792
+ * Why a NEW cap rather than overloading `ptz`:
29793
+ * - `ptz` moves a *gimbal* on a fixed camera (pan/tilt/zoom of the lens). A
29794
+ * robot vacuum has no gimbal — the whole chassis drives, turns and spins.
29795
+ * The two are different physical models: PTZ is absolute-position + presets,
29796
+ * navigation is momentary drive nudges + discrete robot ACTIONS
29797
+ * (dock / spot-clean / follow-pet / go-to-point / …).
29798
+ * - This cap is the SOURCE OF TRUTH. Two consumers adapt from it rather than
29799
+ * the reverse:
29800
+ * 1. a native CamStack navigation panel (data-driven from `listActions`
29801
+ * / `getOptions`), and
29802
+ * 2. the PTZ surface — a thin adapter mimics `ptz` from `navigation` so a
29803
+ * robot camera shows up in the existing PTZ control path without every
29804
+ * PTZ provider learning about robots. The mapping lives in the adapter,
29805
+ * not here (see the addon design note):
29806
+ * ptz.continuousMove({pan,tilt}) → navigation.move({pan,tilt})
29807
+ * ptz.stop() → navigation.stop()
29808
+ * ptz.goHome() → navigation.runAction('goHome')
29809
+ * ptz.getPresets() → navigation.listActions() (id→preset)
29810
+ * ptz.goToPreset(id) → navigation.runAction(id)
29811
+ *
29812
+ * ## Continuous drive
29813
+ *
29814
+ * `move` is MOMENTARY. Fluid navigation comes from the UI (or the PTZ adapter)
29815
+ * sending `move({pan,tilt})` REPEATEDLY at ~1 Hz while a direction is held, and
29816
+ * one `stop()` on release — exactly like the robot app's remote-drive joystick.
29817
+ * The provider forwards EACH `move` to one drive write; it must NOT debounce or
29818
+ * coalesce them. The UI owns the cadence.
29819
+ *
29820
+ * ## The action dictionary
29821
+ *
29822
+ * The discrete controls (dock / locate / spot-clean / follow / flash / sounds)
29823
+ * are a DATA-DRIVEN dictionary the cap exposes via `listActions()`. Each entry
29824
+ * carries `{ id, kind, label, icon }` (plus `soundId` for sound entries) so the
29825
+ * native panel AND the PTZ mimic render buttons WITHOUT hardcoding a
29826
+ * vendor-specific list. `kind: 'action'` entries are triggered with
29827
+ * `runAction({ actionId })`; `kind: 'sound'` entries with `playSound({ soundId })`
29828
+ * (the entry carries the `soundId` to pass). The general primitives — `move`,
29829
+ * `stop`, `goToPoint` — stay as first-class methods, not dictionary entries.
29830
+ *
29831
+ * NOTE (provider wiring): the first provider (Dreame) drives this with the RAW
29832
+ * `callAction(siid,aiid,in)` / `setProperty({siid,piid,value})` MIoT primitives
29833
+ * that the currently-published `@apocaliss92/nodedreame` already exposes on
29834
+ * every device handle. A future nodedreame publish adds a typed
29835
+ * `DreameCameraController` (drive / playPetSound / spotClean / findPet / …); the
29836
+ * provider can then swap the raw calls for the typed methods with no change to
29837
+ * THIS contract.
29838
+ */
29839
+ /**
29840
+ * A momentary drive nudge. The robot MOVES (no gimbal) for as long as the caller
29841
+ * keeps sending nudges (~1 Hz); an explicit `stop` (or letting the nudges lapse)
29842
+ * halts it.
29843
+ *
29844
+ * - `pan` — turn: negative = left, positive = right, 0 = straight.
29845
+ * - `tilt` — throttle: positive = forward, negative = spin / turn-around.
29846
+ * - `speed` — optional intensity hint [0, 1]; the provider may scale the drive
29847
+ * vector by it (drivers without proportional drive ignore it).
29848
+ *
29849
+ * `pan` / `tilt` are normalized [-1, 1]. Both optional so a caller can nudge one
29850
+ * axis alone; an all-undefined nudge is a no-op.
29851
+ */
29852
+ var NavigationMoveCommandSchema = object({
29853
+ pan: number().min(-1).max(1).optional(),
29854
+ tilt: number().min(-1).max(1).optional(),
29855
+ speed: number().min(0).max(1).optional()
29856
+ });
29857
+ /**
29858
+ * The enumerated discrete actions a navigation-capable robot can perform via
29859
+ * `runAction`. This is the CLOSED vocabulary; a given device advertises the
29860
+ * subset it supports through `listActions`. Sounds are NOT here — they go through
29861
+ * `playSound` (see the `sound` dictionary entries).
29862
+ */
29863
+ var NavigationActionIdSchema = _enum([
29864
+ "goHome",
29865
+ "locate",
29866
+ "spotClean",
29867
+ "findPet",
29868
+ "personFollow",
29869
+ "stop",
29870
+ "startClean",
29871
+ "pauseClean",
29872
+ "dockWash",
29873
+ "autoEmpty",
29874
+ "flashOn",
29875
+ "flashOff"
29876
+ ]);
29877
+ /** Whether a dictionary entry is a `runAction` action or a `playSound` sound. */
29878
+ var NavigationEntryKindSchema = _enum(["action", "sound"]);
29879
+ /**
29880
+ * One entry in the navigation action dictionary — the DATA-DRIVEN unit both the
29881
+ * native panel and the PTZ mimic render as a button.
29882
+ *
29883
+ * - `id` — stable id. For `kind:'action'` it is a {@link NavigationActionId}
29884
+ * (pass to `runAction`); for `kind:'sound'` it is a namespaced id
29885
+ * (`sound:meow`) whose `soundId` is passed to `playSound`.
29886
+ * - `icon` — icon HINT (lucide-style name; the UI maps it to its own set).
29887
+ * - `label` — operator-facing English label.
29888
+ * - `soundId` — wire sound id, present only on `kind:'sound'` entries.
29889
+ * - `enabled` — per-device FEATURE FLAG. `listActions` reports it so the UI /
29890
+ * PTZ render ONLY enabled entries. Data-driven: the provider
29891
+ * flips it from config, never by editing code.
29892
+ */
29893
+ var NavigationActionEntrySchema = object({
29894
+ id: string(),
29895
+ kind: NavigationEntryKindSchema,
29896
+ label: string(),
29897
+ icon: string(),
29898
+ /** Present only on `kind:'sound'` entries — the id to pass to `playSound`. */
29899
+ soundId: number().int().optional(),
29900
+ /** Per-device feature flag — render this entry only when true. */
29901
+ enabled: boolean()
29902
+ });
29903
+ /** Coordinates for `goToPoint` — a point on the robot's live map. */
29904
+ var NavigationPointSchema = object({
29905
+ x: number(),
29906
+ y: number()
29907
+ });
29908
+ /**
29909
+ * Per-device FEATURE-FLAG report for the general (non-dictionary) primitives.
29910
+ * The cap reports which are enabled so the UI / PTZ render only the controls
29911
+ * that are turned on for THIS device. Data-driven: the provider derives these
29912
+ * from config + probe, never hardcoded in the UI. The per-DICTIONARY-entry flags
29913
+ * live on {@link NavigationActionEntrySchema.enabled}; these gate the primitives
29914
+ * that are not dictionary entries.
29915
+ *
29916
+ * - `move` / `stop` — the momentary drive joystick.
29917
+ * - `goToPoint` — send-to-map-coordinate. Ships OFF on Dreame until the
29918
+ * map-coordinate plumbing is wired.
29919
+ * - `runAction` — the discrete action buttons (dictionary `kind:'action'`).
29920
+ * - `playSound` — the sound buttons (dictionary `kind:'sound'`).
29921
+ * - `light` — the on/off fill-light toggle (works anytime).
29922
+ * - `lightMode` — the auto/manual selector + manual level slider (a
29923
+ * camera-service control; needs an active stream).
29924
+ */
29925
+ var NavigationFeaturesSchema = object({
29926
+ move: boolean(),
29927
+ stop: boolean(),
29928
+ goToPoint: boolean(),
29929
+ runAction: boolean(),
29930
+ playSound: boolean(),
29931
+ light: boolean(),
29932
+ lightMode: boolean()
29933
+ });
29934
+ /** Light mode: `auto` lets the camera choose brightness; `manual` uses `level`. */
29935
+ var NavigationLightModeSchema = _enum(["auto", "manual"]);
29936
+ /**
29937
+ * Live navigation state so the UI can reflect what the robot is doing:
29938
+ * - `mode` — coarse activity (idle / cleaning / following / …).
29939
+ * - `following` — person/pet follow is currently armed.
29940
+ * - `flash` — the on-camera fill light is on.
29941
+ * - `lightMode` — auto vs manual fill-light mode.
29942
+ * - `lightLevel` — manual fill-light level (40..100); meaningful when
29943
+ * `lightMode === 'manual'`.
29944
+ */
29945
+ var NavigationStatusSchema = object({
29946
+ mode: _enum([
29947
+ "idle",
29948
+ "cleaning",
29949
+ "spot",
29950
+ "following",
29951
+ "goto",
29952
+ "returning",
29953
+ "paused",
29954
+ "unknown"
29955
+ ]),
29956
+ following: boolean(),
29957
+ flash: boolean(),
29958
+ lightMode: NavigationLightModeSchema,
29959
+ lightLevel: number().min(40).max(100),
29960
+ /** Ms epoch when the slice was last updated. */
29961
+ lastChangedAt: number()
29962
+ });
29963
+ /**
29964
+ * Runtime-state slice owned by this cap (kernel-managed: validated, mirrored,
29965
+ * observable). Adds `lastFetchedAt` on top of the status shape per the
29966
+ * convention.
29967
+ */
29968
+ var NavigationRuntimeStateSchema = NavigationStatusSchema.extend({ lastFetchedAt: number() });
29969
+ var navigationCapability = {
29970
+ name: "navigation",
29971
+ scope: "device",
29972
+ deviceNative: true,
29973
+ mode: "singleton",
29974
+ deviceTypes: [DeviceType.Camera],
29975
+ deviceConfig: { ui: {
29976
+ kind: "widget",
29977
+ widgetId: "host/navigation-panel",
29978
+ tab: "navigation",
29979
+ topTab: true,
29980
+ label: "Navigation",
29981
+ order: 0
29982
+ } },
29983
+ methods: {
29984
+ /**
29985
+ * Momentary drive nudge (the robot moves). `protected` — mirrors
29986
+ * `ptz.continuousMove` so the Viewer navigation panel (and the PTZ-mimic
29987
+ * path) works for any authenticated user, not admin-only. The UI sends
29988
+ * these at ~1 Hz while a control is held; the provider forwards each one to
29989
+ * a single drive write WITHOUT debouncing.
29990
+ */
29991
+ move: method(NavigationMoveCommandSchema.extend({ deviceId: number() }), _void(), { kind: "mutation" }),
29992
+ /** Halt all motion immediately (zero drive vector). */
29993
+ stop: method(object({ deviceId: number() }), _void(), { kind: "mutation" }),
29994
+ /** Send the robot to a point on its live map. */
29995
+ goToPoint: method(NavigationPointSchema.extend({ deviceId: number() }), _void(), { kind: "mutation" }),
29996
+ /**
29997
+ * Enumerate the discrete controls THIS device supports (data-driven UI +
29998
+ * PTZ mimic). Camera-probed subset of {@link NAVIGATION_ACTION_CATALOG}.
29999
+ */
30000
+ listActions: method(object({ deviceId: number() }), array(NavigationActionEntrySchema)),
30001
+ /**
30002
+ * Run one discrete action (a `kind:'action'` dictionary entry). Invalid /
30003
+ * unsupported action ids are rejected by the provider.
30004
+ */
30005
+ runAction: method(object({
30006
+ deviceId: number(),
30007
+ actionId: NavigationActionIdSchema
30008
+ }), _void(), { kind: "mutation" }),
30009
+ /** Play a sound by its wire id (the `soundId` of a `kind:'sound'` entry). */
30010
+ playSound: method(object({
30011
+ deviceId: number(),
30012
+ soundId: number().int()
30013
+ }), _void(), { kind: "mutation" }),
30014
+ /**
30015
+ * Turn the on-camera fill light on / off (the `OpenFullLight` control —
30016
+ * works anytime, no active stream required).
30017
+ */
30018
+ setLightOn: method(object({
30019
+ deviceId: number(),
30020
+ on: boolean()
30021
+ }), _void(), { kind: "mutation" }),
30022
+ /**
30023
+ * Set the fill-light mode (auto vs manual). `manual` optionally carries the
30024
+ * initial `level`. The auto/manual + level control is a CAMERA-service
30025
+ * action that generally needs an active camera stream/monitor session — the
30026
+ * UI shows the manual level slider ONLY when `mode === 'manual'`.
30027
+ */
30028
+ setLightMode: method(object({
30029
+ deviceId: number(),
30030
+ mode: NavigationLightModeSchema,
30031
+ level: number().min(40).max(100).optional()
30032
+ }), _void(), { kind: "mutation" }),
30033
+ /** Set the MANUAL fill-light level (40..100). Implies `manual` mode. */
30034
+ setLightLevel: method(object({
30035
+ deviceId: number(),
30036
+ level: number().min(40).max(100)
30037
+ }), _void(), { kind: "mutation" }),
30038
+ /**
30039
+ * Per-device FEATURE-FLAG report for the general primitives — drives which
30040
+ * controls the UI shows (the per-entry flags for the dictionary come back on
30041
+ * `listActions`).
30042
+ */
30043
+ getFeatures: method(object({ deviceId: number() }), NavigationFeaturesSchema)
30044
+ },
30045
+ events: { onStatusChanged: { data: object({
30046
+ deviceId: number(),
30047
+ status: NavigationStatusSchema
30048
+ }) } },
30049
+ status: {
30050
+ schema: NavigationStatusSchema,
30051
+ kind: "push"
30052
+ },
30053
+ /**
30054
+ * Runtime-state slice mirrored by the kernel. The navigation panel watches it
30055
+ * for live mode / follow / flash changes.
30056
+ */
30057
+ runtimeState: NavigationRuntimeStateSchema,
30058
+ /**
30059
+ * Runtime-state durability: **session** — like `vacuum-control`, a restored
30060
+ * `mode: cleaning` / `following: true` is a robot that is not actually doing
30061
+ * that. The live handle re-publishes on connect.
30062
+ *
30063
+ * See `RuntimeStateDurability`. Enforced by
30064
+ * `scripts/check-runtime-state-durability.ts`.
30065
+ */
30066
+ durability: "session"
30067
+ };
30068
+ /**
29782
30069
  * reboot — device-scoped capability for "soft" device reboots (firmware
29783
30070
  * reboot via vendor protocol; cameras, NVRs, doorbells). Surfaces a
29784
30071
  * single mutation so the UI can offer a confirm-and-reboot button for
@@ -33291,6 +33578,7 @@ var DEVICE_LOCAL_STATE_CAPS = {
33291
33578
  motionTrigger: motionTriggerCapability,
33292
33579
  motionZones: motionZonesCapability,
33293
33580
  nativeObjectDetection: nativeObjectDetectionCapability,
33581
+ navigation: navigationCapability,
33294
33582
  networkLink: networkLinkCapability,
33295
33583
  notifier: notifierCapability,
33296
33584
  numericSensor: numericSensorCapability,
@@ -37308,6 +37596,66 @@ Object.freeze({
37308
37596
  addonId: null,
37309
37597
  access: "create"
37310
37598
  },
37599
+ "navigation.getFeatures": {
37600
+ capName: "navigation",
37601
+ capScope: "device",
37602
+ addonId: null,
37603
+ access: "view"
37604
+ },
37605
+ "navigation.goToPoint": {
37606
+ capName: "navigation",
37607
+ capScope: "device",
37608
+ addonId: null,
37609
+ access: "create"
37610
+ },
37611
+ "navigation.listActions": {
37612
+ capName: "navigation",
37613
+ capScope: "device",
37614
+ addonId: null,
37615
+ access: "view"
37616
+ },
37617
+ "navigation.move": {
37618
+ capName: "navigation",
37619
+ capScope: "device",
37620
+ addonId: null,
37621
+ access: "create"
37622
+ },
37623
+ "navigation.playSound": {
37624
+ capName: "navigation",
37625
+ capScope: "device",
37626
+ addonId: null,
37627
+ access: "create"
37628
+ },
37629
+ "navigation.runAction": {
37630
+ capName: "navigation",
37631
+ capScope: "device",
37632
+ addonId: null,
37633
+ access: "create"
37634
+ },
37635
+ "navigation.setLightLevel": {
37636
+ capName: "navigation",
37637
+ capScope: "device",
37638
+ addonId: null,
37639
+ access: "create"
37640
+ },
37641
+ "navigation.setLightMode": {
37642
+ capName: "navigation",
37643
+ capScope: "device",
37644
+ addonId: null,
37645
+ access: "create"
37646
+ },
37647
+ "navigation.setLightOn": {
37648
+ capName: "navigation",
37649
+ capScope: "device",
37650
+ addonId: null,
37651
+ access: "create"
37652
+ },
37653
+ "navigation.stop": {
37654
+ capName: "navigation",
37655
+ capScope: "device",
37656
+ addonId: null,
37657
+ access: "create"
37658
+ },
37311
37659
  "networkAccess.getEndpoint": {
37312
37660
  capName: "network-access",
37313
37661
  capScope: "system",
@@ -41403,6 +41751,56 @@ Object.freeze({
41403
41751
  form: "single",
41404
41752
  optional: false
41405
41753
  }],
41754
+ "navigation.getFeatures": [{
41755
+ name: "deviceId",
41756
+ form: "single",
41757
+ optional: false
41758
+ }],
41759
+ "navigation.goToPoint": [{
41760
+ name: "deviceId",
41761
+ form: "single",
41762
+ optional: false
41763
+ }],
41764
+ "navigation.listActions": [{
41765
+ name: "deviceId",
41766
+ form: "single",
41767
+ optional: false
41768
+ }],
41769
+ "navigation.move": [{
41770
+ name: "deviceId",
41771
+ form: "single",
41772
+ optional: false
41773
+ }],
41774
+ "navigation.playSound": [{
41775
+ name: "deviceId",
41776
+ form: "single",
41777
+ optional: false
41778
+ }],
41779
+ "navigation.runAction": [{
41780
+ name: "deviceId",
41781
+ form: "single",
41782
+ optional: false
41783
+ }],
41784
+ "navigation.setLightLevel": [{
41785
+ name: "deviceId",
41786
+ form: "single",
41787
+ optional: false
41788
+ }],
41789
+ "navigation.setLightMode": [{
41790
+ name: "deviceId",
41791
+ form: "single",
41792
+ optional: false
41793
+ }],
41794
+ "navigation.setLightOn": [{
41795
+ name: "deviceId",
41796
+ form: "single",
41797
+ optional: false
41798
+ }],
41799
+ "navigation.stop": [{
41800
+ name: "deviceId",
41801
+ form: "single",
41802
+ optional: false
41803
+ }],
41406
41804
  "networkQuality.getDeviceStats": [{
41407
41805
  name: "deviceId",
41408
41806
  form: "single",
@@ -233706,22 +234104,24 @@ async function populateReolinkMetadata(api, channel, target) {
233706
234104
  }
233707
234105
  /**
233708
234106
  * 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.
234107
+ * UNLESS the camera also named the network it joined an SSID is a joined
234108
+ * wifi link whatever the label says. A bare signal number is NOT enough:
234109
+ * measured 2026-09-06, an E1 Outdoor PoE on its cable answered
234110
+ * `getWifiSignal` with -10 and no SSID, and -10 dBm is not a wifi reading.
233711
234111
  */
233712
234112
  function linkTypeOf(readout) {
233713
234113
  const label = (readout.activeLink ?? "").toLowerCase();
233714
234114
  if (label.includes("wifi") || label.includes("wlan") || label.includes("wireless")) return "wifi";
233715
234115
  if (label.includes("lan") || label.includes("eth") || label.includes("wire")) return "ethernet";
233716
234116
  if (/\b(4g|5g|lte|cell|sim)\b/.test(label)) return "cellular";
233717
- if (readout.wifiSignal !== void 0) return "wifi";
234117
+ if (readout.ssid !== void 0 && readout.ssid !== "") return "wifi";
233718
234118
  return "unknown";
233719
234119
  }
233720
234120
  /** True when the label names a wireless link — the only case worth a signal read. */
233721
234121
  function isWirelessLabel(activeLink) {
233722
234122
  const type = linkTypeOf({
233723
234123
  activeLink,
233724
- wifiSignal: void 0
234124
+ ssid: void 0
233725
234125
  });
233726
234126
  return type === "wifi" || type === "cellular";
233727
234127
  }
@@ -236580,7 +236980,8 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
236580
236980
  wifiSignal,
236581
236981
  ssid: askWireless && wifiSignal !== void 0 ? (await api.getWifi(channel, { timeoutMs }).catch(() => ({ ssid: void 0 }))).ssid : void 0
236582
236982
  }, Date.now());
236583
- this.ctx.logger.debug("network link read", {
236983
+ const changed = this.state.networkLink.type !== mapped.type || !this.networkLinkReported;
236984
+ this.ctx.logger[changed ? "info" : "debug"]("network link read", {
236584
236985
  tags: { deviceId: this.id },
236585
236986
  meta: {
236586
236987
  reason,
@@ -236589,9 +236990,12 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
236589
236990
  ...mapped
236590
236991
  }
236591
236992
  });
236993
+ this.networkLinkReported = true;
236592
236994
  this.setCapSlice(networkLinkCapability, mapped);
236593
236995
  } catch (err) {
236594
- this.ctx.logger.debug("network link read failed keeping the last slice", {
236996
+ const level = this.networkLinkFailureWarned ? "debug" : "warn";
236997
+ this.networkLinkFailureWarned = true;
236998
+ this.ctx.logger[level]("network link read failed — keeping the last slice", {
236595
236999
  tags: { deviceId: this.id },
236596
237000
  meta: {
236597
237001
  reason,
@@ -240951,6 +241355,10 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
240951
241355
  * for a device something is actually reaching. */
240952
241356
  /** Bound on each best-effort network read; three reads at most per refresh. */
240953
241357
  static NETWORK_LINK_READ_TIMEOUT_MS = 4e3;
241358
+ /** The first network read of this device was said at INFO (raw values included). */
241359
+ networkLinkReported = false;
241360
+ /** The first network read failure was said at WARN; later ones are debug. */
241361
+ networkLinkFailureWarned = false;
240954
241362
  static CONTACT_WRITE_QUANTUM_MS = 5 * 6e4;
240955
241363
  /**
240956
241364
  * Shared wake-transition handler invoked by both the simpleEvent
package/dist/addon.mjs CHANGED
@@ -5428,6 +5428,12 @@ Object.fromEntries([
5428
5428
  icon: "move",
5429
5429
  order: 40
5430
5430
  },
5431
+ {
5432
+ id: "navigation",
5433
+ label: "Navigation",
5434
+ icon: "compass",
5435
+ order: 41
5436
+ },
5431
5437
  {
5432
5438
  id: "consumables",
5433
5439
  label: "Consumables",
@@ -29774,6 +29780,287 @@ var ptzAutotrackCapability = {
29774
29780
  durability: "session"
29775
29781
  };
29776
29782
  /**
29783
+ * `navigation` — a device-scoped capability that natively expresses the FULL
29784
+ * navigation / action surface of a robot that DRIVES ITSELF and carries an
29785
+ * on-board camera (the Dreame robot-vacuum camera is the first provider).
29786
+ *
29787
+ * Why a NEW cap rather than overloading `ptz`:
29788
+ * - `ptz` moves a *gimbal* on a fixed camera (pan/tilt/zoom of the lens). A
29789
+ * robot vacuum has no gimbal — the whole chassis drives, turns and spins.
29790
+ * The two are different physical models: PTZ is absolute-position + presets,
29791
+ * navigation is momentary drive nudges + discrete robot ACTIONS
29792
+ * (dock / spot-clean / follow-pet / go-to-point / …).
29793
+ * - This cap is the SOURCE OF TRUTH. Two consumers adapt from it rather than
29794
+ * the reverse:
29795
+ * 1. a native CamStack navigation panel (data-driven from `listActions`
29796
+ * / `getOptions`), and
29797
+ * 2. the PTZ surface — a thin adapter mimics `ptz` from `navigation` so a
29798
+ * robot camera shows up in the existing PTZ control path without every
29799
+ * PTZ provider learning about robots. The mapping lives in the adapter,
29800
+ * not here (see the addon design note):
29801
+ * ptz.continuousMove({pan,tilt}) → navigation.move({pan,tilt})
29802
+ * ptz.stop() → navigation.stop()
29803
+ * ptz.goHome() → navigation.runAction('goHome')
29804
+ * ptz.getPresets() → navigation.listActions() (id→preset)
29805
+ * ptz.goToPreset(id) → navigation.runAction(id)
29806
+ *
29807
+ * ## Continuous drive
29808
+ *
29809
+ * `move` is MOMENTARY. Fluid navigation comes from the UI (or the PTZ adapter)
29810
+ * sending `move({pan,tilt})` REPEATEDLY at ~1 Hz while a direction is held, and
29811
+ * one `stop()` on release — exactly like the robot app's remote-drive joystick.
29812
+ * The provider forwards EACH `move` to one drive write; it must NOT debounce or
29813
+ * coalesce them. The UI owns the cadence.
29814
+ *
29815
+ * ## The action dictionary
29816
+ *
29817
+ * The discrete controls (dock / locate / spot-clean / follow / flash / sounds)
29818
+ * are a DATA-DRIVEN dictionary the cap exposes via `listActions()`. Each entry
29819
+ * carries `{ id, kind, label, icon }` (plus `soundId` for sound entries) so the
29820
+ * native panel AND the PTZ mimic render buttons WITHOUT hardcoding a
29821
+ * vendor-specific list. `kind: 'action'` entries are triggered with
29822
+ * `runAction({ actionId })`; `kind: 'sound'` entries with `playSound({ soundId })`
29823
+ * (the entry carries the `soundId` to pass). The general primitives — `move`,
29824
+ * `stop`, `goToPoint` — stay as first-class methods, not dictionary entries.
29825
+ *
29826
+ * NOTE (provider wiring): the first provider (Dreame) drives this with the RAW
29827
+ * `callAction(siid,aiid,in)` / `setProperty({siid,piid,value})` MIoT primitives
29828
+ * that the currently-published `@apocaliss92/nodedreame` already exposes on
29829
+ * every device handle. A future nodedreame publish adds a typed
29830
+ * `DreameCameraController` (drive / playPetSound / spotClean / findPet / …); the
29831
+ * provider can then swap the raw calls for the typed methods with no change to
29832
+ * THIS contract.
29833
+ */
29834
+ /**
29835
+ * A momentary drive nudge. The robot MOVES (no gimbal) for as long as the caller
29836
+ * keeps sending nudges (~1 Hz); an explicit `stop` (or letting the nudges lapse)
29837
+ * halts it.
29838
+ *
29839
+ * - `pan` — turn: negative = left, positive = right, 0 = straight.
29840
+ * - `tilt` — throttle: positive = forward, negative = spin / turn-around.
29841
+ * - `speed` — optional intensity hint [0, 1]; the provider may scale the drive
29842
+ * vector by it (drivers without proportional drive ignore it).
29843
+ *
29844
+ * `pan` / `tilt` are normalized [-1, 1]. Both optional so a caller can nudge one
29845
+ * axis alone; an all-undefined nudge is a no-op.
29846
+ */
29847
+ var NavigationMoveCommandSchema = object({
29848
+ pan: number().min(-1).max(1).optional(),
29849
+ tilt: number().min(-1).max(1).optional(),
29850
+ speed: number().min(0).max(1).optional()
29851
+ });
29852
+ /**
29853
+ * The enumerated discrete actions a navigation-capable robot can perform via
29854
+ * `runAction`. This is the CLOSED vocabulary; a given device advertises the
29855
+ * subset it supports through `listActions`. Sounds are NOT here — they go through
29856
+ * `playSound` (see the `sound` dictionary entries).
29857
+ */
29858
+ var NavigationActionIdSchema = _enum([
29859
+ "goHome",
29860
+ "locate",
29861
+ "spotClean",
29862
+ "findPet",
29863
+ "personFollow",
29864
+ "stop",
29865
+ "startClean",
29866
+ "pauseClean",
29867
+ "dockWash",
29868
+ "autoEmpty",
29869
+ "flashOn",
29870
+ "flashOff"
29871
+ ]);
29872
+ /** Whether a dictionary entry is a `runAction` action or a `playSound` sound. */
29873
+ var NavigationEntryKindSchema = _enum(["action", "sound"]);
29874
+ /**
29875
+ * One entry in the navigation action dictionary — the DATA-DRIVEN unit both the
29876
+ * native panel and the PTZ mimic render as a button.
29877
+ *
29878
+ * - `id` — stable id. For `kind:'action'` it is a {@link NavigationActionId}
29879
+ * (pass to `runAction`); for `kind:'sound'` it is a namespaced id
29880
+ * (`sound:meow`) whose `soundId` is passed to `playSound`.
29881
+ * - `icon` — icon HINT (lucide-style name; the UI maps it to its own set).
29882
+ * - `label` — operator-facing English label.
29883
+ * - `soundId` — wire sound id, present only on `kind:'sound'` entries.
29884
+ * - `enabled` — per-device FEATURE FLAG. `listActions` reports it so the UI /
29885
+ * PTZ render ONLY enabled entries. Data-driven: the provider
29886
+ * flips it from config, never by editing code.
29887
+ */
29888
+ var NavigationActionEntrySchema = object({
29889
+ id: string(),
29890
+ kind: NavigationEntryKindSchema,
29891
+ label: string(),
29892
+ icon: string(),
29893
+ /** Present only on `kind:'sound'` entries — the id to pass to `playSound`. */
29894
+ soundId: number().int().optional(),
29895
+ /** Per-device feature flag — render this entry only when true. */
29896
+ enabled: boolean()
29897
+ });
29898
+ /** Coordinates for `goToPoint` — a point on the robot's live map. */
29899
+ var NavigationPointSchema = object({
29900
+ x: number(),
29901
+ y: number()
29902
+ });
29903
+ /**
29904
+ * Per-device FEATURE-FLAG report for the general (non-dictionary) primitives.
29905
+ * The cap reports which are enabled so the UI / PTZ render only the controls
29906
+ * that are turned on for THIS device. Data-driven: the provider derives these
29907
+ * from config + probe, never hardcoded in the UI. The per-DICTIONARY-entry flags
29908
+ * live on {@link NavigationActionEntrySchema.enabled}; these gate the primitives
29909
+ * that are not dictionary entries.
29910
+ *
29911
+ * - `move` / `stop` — the momentary drive joystick.
29912
+ * - `goToPoint` — send-to-map-coordinate. Ships OFF on Dreame until the
29913
+ * map-coordinate plumbing is wired.
29914
+ * - `runAction` — the discrete action buttons (dictionary `kind:'action'`).
29915
+ * - `playSound` — the sound buttons (dictionary `kind:'sound'`).
29916
+ * - `light` — the on/off fill-light toggle (works anytime).
29917
+ * - `lightMode` — the auto/manual selector + manual level slider (a
29918
+ * camera-service control; needs an active stream).
29919
+ */
29920
+ var NavigationFeaturesSchema = object({
29921
+ move: boolean(),
29922
+ stop: boolean(),
29923
+ goToPoint: boolean(),
29924
+ runAction: boolean(),
29925
+ playSound: boolean(),
29926
+ light: boolean(),
29927
+ lightMode: boolean()
29928
+ });
29929
+ /** Light mode: `auto` lets the camera choose brightness; `manual` uses `level`. */
29930
+ var NavigationLightModeSchema = _enum(["auto", "manual"]);
29931
+ /**
29932
+ * Live navigation state so the UI can reflect what the robot is doing:
29933
+ * - `mode` — coarse activity (idle / cleaning / following / …).
29934
+ * - `following` — person/pet follow is currently armed.
29935
+ * - `flash` — the on-camera fill light is on.
29936
+ * - `lightMode` — auto vs manual fill-light mode.
29937
+ * - `lightLevel` — manual fill-light level (40..100); meaningful when
29938
+ * `lightMode === 'manual'`.
29939
+ */
29940
+ var NavigationStatusSchema = object({
29941
+ mode: _enum([
29942
+ "idle",
29943
+ "cleaning",
29944
+ "spot",
29945
+ "following",
29946
+ "goto",
29947
+ "returning",
29948
+ "paused",
29949
+ "unknown"
29950
+ ]),
29951
+ following: boolean(),
29952
+ flash: boolean(),
29953
+ lightMode: NavigationLightModeSchema,
29954
+ lightLevel: number().min(40).max(100),
29955
+ /** Ms epoch when the slice was last updated. */
29956
+ lastChangedAt: number()
29957
+ });
29958
+ /**
29959
+ * Runtime-state slice owned by this cap (kernel-managed: validated, mirrored,
29960
+ * observable). Adds `lastFetchedAt` on top of the status shape per the
29961
+ * convention.
29962
+ */
29963
+ var NavigationRuntimeStateSchema = NavigationStatusSchema.extend({ lastFetchedAt: number() });
29964
+ var navigationCapability = {
29965
+ name: "navigation",
29966
+ scope: "device",
29967
+ deviceNative: true,
29968
+ mode: "singleton",
29969
+ deviceTypes: [DeviceType.Camera],
29970
+ deviceConfig: { ui: {
29971
+ kind: "widget",
29972
+ widgetId: "host/navigation-panel",
29973
+ tab: "navigation",
29974
+ topTab: true,
29975
+ label: "Navigation",
29976
+ order: 0
29977
+ } },
29978
+ methods: {
29979
+ /**
29980
+ * Momentary drive nudge (the robot moves). `protected` — mirrors
29981
+ * `ptz.continuousMove` so the Viewer navigation panel (and the PTZ-mimic
29982
+ * path) works for any authenticated user, not admin-only. The UI sends
29983
+ * these at ~1 Hz while a control is held; the provider forwards each one to
29984
+ * a single drive write WITHOUT debouncing.
29985
+ */
29986
+ move: method(NavigationMoveCommandSchema.extend({ deviceId: number() }), _void(), { kind: "mutation" }),
29987
+ /** Halt all motion immediately (zero drive vector). */
29988
+ stop: method(object({ deviceId: number() }), _void(), { kind: "mutation" }),
29989
+ /** Send the robot to a point on its live map. */
29990
+ goToPoint: method(NavigationPointSchema.extend({ deviceId: number() }), _void(), { kind: "mutation" }),
29991
+ /**
29992
+ * Enumerate the discrete controls THIS device supports (data-driven UI +
29993
+ * PTZ mimic). Camera-probed subset of {@link NAVIGATION_ACTION_CATALOG}.
29994
+ */
29995
+ listActions: method(object({ deviceId: number() }), array(NavigationActionEntrySchema)),
29996
+ /**
29997
+ * Run one discrete action (a `kind:'action'` dictionary entry). Invalid /
29998
+ * unsupported action ids are rejected by the provider.
29999
+ */
30000
+ runAction: method(object({
30001
+ deviceId: number(),
30002
+ actionId: NavigationActionIdSchema
30003
+ }), _void(), { kind: "mutation" }),
30004
+ /** Play a sound by its wire id (the `soundId` of a `kind:'sound'` entry). */
30005
+ playSound: method(object({
30006
+ deviceId: number(),
30007
+ soundId: number().int()
30008
+ }), _void(), { kind: "mutation" }),
30009
+ /**
30010
+ * Turn the on-camera fill light on / off (the `OpenFullLight` control —
30011
+ * works anytime, no active stream required).
30012
+ */
30013
+ setLightOn: method(object({
30014
+ deviceId: number(),
30015
+ on: boolean()
30016
+ }), _void(), { kind: "mutation" }),
30017
+ /**
30018
+ * Set the fill-light mode (auto vs manual). `manual` optionally carries the
30019
+ * initial `level`. The auto/manual + level control is a CAMERA-service
30020
+ * action that generally needs an active camera stream/monitor session — the
30021
+ * UI shows the manual level slider ONLY when `mode === 'manual'`.
30022
+ */
30023
+ setLightMode: method(object({
30024
+ deviceId: number(),
30025
+ mode: NavigationLightModeSchema,
30026
+ level: number().min(40).max(100).optional()
30027
+ }), _void(), { kind: "mutation" }),
30028
+ /** Set the MANUAL fill-light level (40..100). Implies `manual` mode. */
30029
+ setLightLevel: method(object({
30030
+ deviceId: number(),
30031
+ level: number().min(40).max(100)
30032
+ }), _void(), { kind: "mutation" }),
30033
+ /**
30034
+ * Per-device FEATURE-FLAG report for the general primitives — drives which
30035
+ * controls the UI shows (the per-entry flags for the dictionary come back on
30036
+ * `listActions`).
30037
+ */
30038
+ getFeatures: method(object({ deviceId: number() }), NavigationFeaturesSchema)
30039
+ },
30040
+ events: { onStatusChanged: { data: object({
30041
+ deviceId: number(),
30042
+ status: NavigationStatusSchema
30043
+ }) } },
30044
+ status: {
30045
+ schema: NavigationStatusSchema,
30046
+ kind: "push"
30047
+ },
30048
+ /**
30049
+ * Runtime-state slice mirrored by the kernel. The navigation panel watches it
30050
+ * for live mode / follow / flash changes.
30051
+ */
30052
+ runtimeState: NavigationRuntimeStateSchema,
30053
+ /**
30054
+ * Runtime-state durability: **session** — like `vacuum-control`, a restored
30055
+ * `mode: cleaning` / `following: true` is a robot that is not actually doing
30056
+ * that. The live handle re-publishes on connect.
30057
+ *
30058
+ * See `RuntimeStateDurability`. Enforced by
30059
+ * `scripts/check-runtime-state-durability.ts`.
30060
+ */
30061
+ durability: "session"
30062
+ };
30063
+ /**
29777
30064
  * reboot — device-scoped capability for "soft" device reboots (firmware
29778
30065
  * reboot via vendor protocol; cameras, NVRs, doorbells). Surfaces a
29779
30066
  * single mutation so the UI can offer a confirm-and-reboot button for
@@ -33286,6 +33573,7 @@ var DEVICE_LOCAL_STATE_CAPS = {
33286
33573
  motionTrigger: motionTriggerCapability,
33287
33574
  motionZones: motionZonesCapability,
33288
33575
  nativeObjectDetection: nativeObjectDetectionCapability,
33576
+ navigation: navigationCapability,
33289
33577
  networkLink: networkLinkCapability,
33290
33578
  notifier: notifierCapability,
33291
33579
  numericSensor: numericSensorCapability,
@@ -37303,6 +37591,66 @@ Object.freeze({
37303
37591
  addonId: null,
37304
37592
  access: "create"
37305
37593
  },
37594
+ "navigation.getFeatures": {
37595
+ capName: "navigation",
37596
+ capScope: "device",
37597
+ addonId: null,
37598
+ access: "view"
37599
+ },
37600
+ "navigation.goToPoint": {
37601
+ capName: "navigation",
37602
+ capScope: "device",
37603
+ addonId: null,
37604
+ access: "create"
37605
+ },
37606
+ "navigation.listActions": {
37607
+ capName: "navigation",
37608
+ capScope: "device",
37609
+ addonId: null,
37610
+ access: "view"
37611
+ },
37612
+ "navigation.move": {
37613
+ capName: "navigation",
37614
+ capScope: "device",
37615
+ addonId: null,
37616
+ access: "create"
37617
+ },
37618
+ "navigation.playSound": {
37619
+ capName: "navigation",
37620
+ capScope: "device",
37621
+ addonId: null,
37622
+ access: "create"
37623
+ },
37624
+ "navigation.runAction": {
37625
+ capName: "navigation",
37626
+ capScope: "device",
37627
+ addonId: null,
37628
+ access: "create"
37629
+ },
37630
+ "navigation.setLightLevel": {
37631
+ capName: "navigation",
37632
+ capScope: "device",
37633
+ addonId: null,
37634
+ access: "create"
37635
+ },
37636
+ "navigation.setLightMode": {
37637
+ capName: "navigation",
37638
+ capScope: "device",
37639
+ addonId: null,
37640
+ access: "create"
37641
+ },
37642
+ "navigation.setLightOn": {
37643
+ capName: "navigation",
37644
+ capScope: "device",
37645
+ addonId: null,
37646
+ access: "create"
37647
+ },
37648
+ "navigation.stop": {
37649
+ capName: "navigation",
37650
+ capScope: "device",
37651
+ addonId: null,
37652
+ access: "create"
37653
+ },
37306
37654
  "networkAccess.getEndpoint": {
37307
37655
  capName: "network-access",
37308
37656
  capScope: "system",
@@ -41398,6 +41746,56 @@ Object.freeze({
41398
41746
  form: "single",
41399
41747
  optional: false
41400
41748
  }],
41749
+ "navigation.getFeatures": [{
41750
+ name: "deviceId",
41751
+ form: "single",
41752
+ optional: false
41753
+ }],
41754
+ "navigation.goToPoint": [{
41755
+ name: "deviceId",
41756
+ form: "single",
41757
+ optional: false
41758
+ }],
41759
+ "navigation.listActions": [{
41760
+ name: "deviceId",
41761
+ form: "single",
41762
+ optional: false
41763
+ }],
41764
+ "navigation.move": [{
41765
+ name: "deviceId",
41766
+ form: "single",
41767
+ optional: false
41768
+ }],
41769
+ "navigation.playSound": [{
41770
+ name: "deviceId",
41771
+ form: "single",
41772
+ optional: false
41773
+ }],
41774
+ "navigation.runAction": [{
41775
+ name: "deviceId",
41776
+ form: "single",
41777
+ optional: false
41778
+ }],
41779
+ "navigation.setLightLevel": [{
41780
+ name: "deviceId",
41781
+ form: "single",
41782
+ optional: false
41783
+ }],
41784
+ "navigation.setLightMode": [{
41785
+ name: "deviceId",
41786
+ form: "single",
41787
+ optional: false
41788
+ }],
41789
+ "navigation.setLightOn": [{
41790
+ name: "deviceId",
41791
+ form: "single",
41792
+ optional: false
41793
+ }],
41794
+ "navigation.stop": [{
41795
+ name: "deviceId",
41796
+ form: "single",
41797
+ optional: false
41798
+ }],
41401
41799
  "networkQuality.getDeviceStats": [{
41402
41800
  name: "deviceId",
41403
41801
  form: "single",
@@ -233686,22 +234084,24 @@ async function populateReolinkMetadata(api, channel, target) {
233686
234084
  }
233687
234085
  /**
233688
234086
  * 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.
234087
+ * UNLESS the camera also named the network it joined an SSID is a joined
234088
+ * wifi link whatever the label says. A bare signal number is NOT enough:
234089
+ * measured 2026-09-06, an E1 Outdoor PoE on its cable answered
234090
+ * `getWifiSignal` with -10 and no SSID, and -10 dBm is not a wifi reading.
233691
234091
  */
233692
234092
  function linkTypeOf(readout) {
233693
234093
  const label = (readout.activeLink ?? "").toLowerCase();
233694
234094
  if (label.includes("wifi") || label.includes("wlan") || label.includes("wireless")) return "wifi";
233695
234095
  if (label.includes("lan") || label.includes("eth") || label.includes("wire")) return "ethernet";
233696
234096
  if (/\b(4g|5g|lte|cell|sim)\b/.test(label)) return "cellular";
233697
- if (readout.wifiSignal !== void 0) return "wifi";
234097
+ if (readout.ssid !== void 0 && readout.ssid !== "") return "wifi";
233698
234098
  return "unknown";
233699
234099
  }
233700
234100
  /** True when the label names a wireless link — the only case worth a signal read. */
233701
234101
  function isWirelessLabel(activeLink) {
233702
234102
  const type = linkTypeOf({
233703
234103
  activeLink,
233704
- wifiSignal: void 0
234104
+ ssid: void 0
233705
234105
  });
233706
234106
  return type === "wifi" || type === "cellular";
233707
234107
  }
@@ -236560,7 +236960,8 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
236560
236960
  wifiSignal,
236561
236961
  ssid: askWireless && wifiSignal !== void 0 ? (await api.getWifi(channel, { timeoutMs }).catch(() => ({ ssid: void 0 }))).ssid : void 0
236562
236962
  }, Date.now());
236563
- this.ctx.logger.debug("network link read", {
236963
+ const changed = this.state.networkLink.type !== mapped.type || !this.networkLinkReported;
236964
+ this.ctx.logger[changed ? "info" : "debug"]("network link read", {
236564
236965
  tags: { deviceId: this.id },
236565
236966
  meta: {
236566
236967
  reason,
@@ -236569,9 +236970,12 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
236569
236970
  ...mapped
236570
236971
  }
236571
236972
  });
236973
+ this.networkLinkReported = true;
236572
236974
  this.setCapSlice(networkLinkCapability, mapped);
236573
236975
  } catch (err) {
236574
- this.ctx.logger.debug("network link read failed keeping the last slice", {
236976
+ const level = this.networkLinkFailureWarned ? "debug" : "warn";
236977
+ this.networkLinkFailureWarned = true;
236978
+ this.ctx.logger[level]("network link read failed — keeping the last slice", {
236575
236979
  tags: { deviceId: this.id },
236576
236980
  meta: {
236577
236981
  reason,
@@ -240931,6 +241335,10 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
240931
241335
  * for a device something is actually reaching. */
240932
241336
  /** Bound on each best-effort network read; three reads at most per refresh. */
240933
241337
  static NETWORK_LINK_READ_TIMEOUT_MS = 4e3;
241338
+ /** The first network read of this device was said at INFO (raw values included). */
241339
+ networkLinkReported = false;
241340
+ /** The first network read failure was said at WARN; later ones are debug. */
241341
+ networkLinkFailureWarned = false;
240934
241342
  static CONTACT_WRITE_QUANTUM_MS = 5 * 6e4;
240935
241343
  /**
240936
241344
  * 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.93",
3
+ "version": "1.2.95",
4
4
  "description": "Reolink camera device provider addon for CamStack — native Baichuan protocol",
5
5
  "keywords": [
6
6
  "camstack",