@camstack/addon-provider-reolink 1.2.93 → 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
@@ -29779,6 +29779,287 @@ var ptzAutotrackCapability = {
29779
29779
  durability: "session"
29780
29780
  };
29781
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
+ /**
29782
30063
  * reboot — device-scoped capability for "soft" device reboots (firmware
29783
30064
  * reboot via vendor protocol; cameras, NVRs, doorbells). Surfaces a
29784
30065
  * single mutation so the UI can offer a confirm-and-reboot button for
@@ -33291,6 +33572,7 @@ var DEVICE_LOCAL_STATE_CAPS = {
33291
33572
  motionTrigger: motionTriggerCapability,
33292
33573
  motionZones: motionZonesCapability,
33293
33574
  nativeObjectDetection: nativeObjectDetectionCapability,
33575
+ navigation: navigationCapability,
33294
33576
  networkLink: networkLinkCapability,
33295
33577
  notifier: notifierCapability,
33296
33578
  numericSensor: numericSensorCapability,
@@ -37308,6 +37590,66 @@ Object.freeze({
37308
37590
  addonId: null,
37309
37591
  access: "create"
37310
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
+ },
37311
37653
  "networkAccess.getEndpoint": {
37312
37654
  capName: "network-access",
37313
37655
  capScope: "system",
@@ -41403,6 +41745,56 @@ Object.freeze({
41403
41745
  form: "single",
41404
41746
  optional: false
41405
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
+ }],
41406
41798
  "networkQuality.getDeviceStats": [{
41407
41799
  name: "deviceId",
41408
41800
  form: "single",
@@ -233706,22 +234098,24 @@ async function populateReolinkMetadata(api, channel, target) {
233706
234098
  }
233707
234099
  /**
233708
234100
  * 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.
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.
233711
234105
  */
233712
234106
  function linkTypeOf(readout) {
233713
234107
  const label = (readout.activeLink ?? "").toLowerCase();
233714
234108
  if (label.includes("wifi") || label.includes("wlan") || label.includes("wireless")) return "wifi";
233715
234109
  if (label.includes("lan") || label.includes("eth") || label.includes("wire")) return "ethernet";
233716
234110
  if (/\b(4g|5g|lte|cell|sim)\b/.test(label)) return "cellular";
233717
- if (readout.wifiSignal !== void 0) return "wifi";
234111
+ if (readout.ssid !== void 0 && readout.ssid !== "") return "wifi";
233718
234112
  return "unknown";
233719
234113
  }
233720
234114
  /** True when the label names a wireless link — the only case worth a signal read. */
233721
234115
  function isWirelessLabel(activeLink) {
233722
234116
  const type = linkTypeOf({
233723
234117
  activeLink,
233724
- wifiSignal: void 0
234118
+ ssid: void 0
233725
234119
  });
233726
234120
  return type === "wifi" || type === "cellular";
233727
234121
  }
@@ -236580,7 +236974,8 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
236580
236974
  wifiSignal,
236581
236975
  ssid: askWireless && wifiSignal !== void 0 ? (await api.getWifi(channel, { timeoutMs }).catch(() => ({ ssid: void 0 }))).ssid : void 0
236582
236976
  }, Date.now());
236583
- this.ctx.logger.debug("network link read", {
236977
+ const changed = this.state.networkLink.type !== mapped.type || !this.networkLinkReported;
236978
+ this.ctx.logger[changed ? "info" : "debug"]("network link read", {
236584
236979
  tags: { deviceId: this.id },
236585
236980
  meta: {
236586
236981
  reason,
@@ -236589,9 +236984,12 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
236589
236984
  ...mapped
236590
236985
  }
236591
236986
  });
236987
+ this.networkLinkReported = true;
236592
236988
  this.setCapSlice(networkLinkCapability, mapped);
236593
236989
  } catch (err) {
236594
- this.ctx.logger.debug("network link read failed keeping the last slice", {
236990
+ const level = this.networkLinkFailureWarned ? "debug" : "warn";
236991
+ this.networkLinkFailureWarned = true;
236992
+ this.ctx.logger[level]("network link read failed — keeping the last slice", {
236595
236993
  tags: { deviceId: this.id },
236596
236994
  meta: {
236597
236995
  reason,
@@ -240951,6 +241349,10 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
240951
241349
  * for a device something is actually reaching. */
240952
241350
  /** Bound on each best-effort network read; three reads at most per refresh. */
240953
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;
240954
241356
  static CONTACT_WRITE_QUANTUM_MS = 5 * 6e4;
240955
241357
  /**
240956
241358
  * Shared wake-transition handler invoked by both the simpleEvent
package/dist/addon.mjs CHANGED
@@ -29774,6 +29774,287 @@ var ptzAutotrackCapability = {
29774
29774
  durability: "session"
29775
29775
  };
29776
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
+ /**
29777
30058
  * reboot — device-scoped capability for "soft" device reboots (firmware
29778
30059
  * reboot via vendor protocol; cameras, NVRs, doorbells). Surfaces a
29779
30060
  * single mutation so the UI can offer a confirm-and-reboot button for
@@ -33286,6 +33567,7 @@ var DEVICE_LOCAL_STATE_CAPS = {
33286
33567
  motionTrigger: motionTriggerCapability,
33287
33568
  motionZones: motionZonesCapability,
33288
33569
  nativeObjectDetection: nativeObjectDetectionCapability,
33570
+ navigation: navigationCapability,
33289
33571
  networkLink: networkLinkCapability,
33290
33572
  notifier: notifierCapability,
33291
33573
  numericSensor: numericSensorCapability,
@@ -37303,6 +37585,66 @@ Object.freeze({
37303
37585
  addonId: null,
37304
37586
  access: "create"
37305
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
+ },
37306
37648
  "networkAccess.getEndpoint": {
37307
37649
  capName: "network-access",
37308
37650
  capScope: "system",
@@ -41398,6 +41740,56 @@ Object.freeze({
41398
41740
  form: "single",
41399
41741
  optional: false
41400
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
+ }],
41401
41793
  "networkQuality.getDeviceStats": [{
41402
41794
  name: "deviceId",
41403
41795
  form: "single",
@@ -233686,22 +234078,24 @@ async function populateReolinkMetadata(api, channel, target) {
233686
234078
  }
233687
234079
  /**
233688
234080
  * 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.
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.
233691
234085
  */
233692
234086
  function linkTypeOf(readout) {
233693
234087
  const label = (readout.activeLink ?? "").toLowerCase();
233694
234088
  if (label.includes("wifi") || label.includes("wlan") || label.includes("wireless")) return "wifi";
233695
234089
  if (label.includes("lan") || label.includes("eth") || label.includes("wire")) return "ethernet";
233696
234090
  if (/\b(4g|5g|lte|cell|sim)\b/.test(label)) return "cellular";
233697
- if (readout.wifiSignal !== void 0) return "wifi";
234091
+ if (readout.ssid !== void 0 && readout.ssid !== "") return "wifi";
233698
234092
  return "unknown";
233699
234093
  }
233700
234094
  /** True when the label names a wireless link — the only case worth a signal read. */
233701
234095
  function isWirelessLabel(activeLink) {
233702
234096
  const type = linkTypeOf({
233703
234097
  activeLink,
233704
- wifiSignal: void 0
234098
+ ssid: void 0
233705
234099
  });
233706
234100
  return type === "wifi" || type === "cellular";
233707
234101
  }
@@ -236560,7 +236954,8 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
236560
236954
  wifiSignal,
236561
236955
  ssid: askWireless && wifiSignal !== void 0 ? (await api.getWifi(channel, { timeoutMs }).catch(() => ({ ssid: void 0 }))).ssid : void 0
236562
236956
  }, Date.now());
236563
- this.ctx.logger.debug("network link read", {
236957
+ const changed = this.state.networkLink.type !== mapped.type || !this.networkLinkReported;
236958
+ this.ctx.logger[changed ? "info" : "debug"]("network link read", {
236564
236959
  tags: { deviceId: this.id },
236565
236960
  meta: {
236566
236961
  reason,
@@ -236569,9 +236964,12 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
236569
236964
  ...mapped
236570
236965
  }
236571
236966
  });
236967
+ this.networkLinkReported = true;
236572
236968
  this.setCapSlice(networkLinkCapability, mapped);
236573
236969
  } catch (err) {
236574
- this.ctx.logger.debug("network link read failed keeping the last slice", {
236970
+ const level = this.networkLinkFailureWarned ? "debug" : "warn";
236971
+ this.networkLinkFailureWarned = true;
236972
+ this.ctx.logger[level]("network link read failed — keeping the last slice", {
236575
236973
  tags: { deviceId: this.id },
236576
236974
  meta: {
236577
236975
  reason,
@@ -240931,6 +241329,10 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
240931
241329
  * for a device something is actually reaching. */
240932
241330
  /** Bound on each best-effort network read; three reads at most per refresh. */
240933
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;
240934
241336
  static CONTACT_WRITE_QUANTUM_MS = 5 * 6e4;
240935
241337
  /**
240936
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.93",
3
+ "version": "1.2.94",
4
4
  "description": "Reolink camera device provider addon for CamStack — native Baichuan protocol",
5
5
  "keywords": [
6
6
  "camstack",