@camstack/addon-provider-dreame 0.2.71 → 0.2.72

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.
Files changed (3) hide show
  1. package/dist/addon.js +266 -197
  2. package/dist/addon.mjs +266 -197
  3. package/package.json +1 -1
package/dist/addon.js CHANGED
@@ -57117,6 +57117,12 @@ Object.fromEntries([
57117
57117
  icon: "move",
57118
57118
  order: 40
57119
57119
  },
57120
+ {
57121
+ id: "navigation",
57122
+ label: "Navigation",
57123
+ icon: "compass",
57124
+ order: 41
57125
+ },
57120
57126
  {
57121
57127
  id: "consumables",
57122
57128
  label: "Consumables",
@@ -80553,10 +80559,7 @@ var PtzMoveCommandSchema = object({
80553
80559
  zoom: number().optional(),
80554
80560
  speed: number().optional()
80555
80561
  });
80556
- /** PTZ status — current position plus the camera's autofocus state.
80557
- * `getPosition` / `move` still operate on the bare position; the
80558
- * auto-injected `getStatus` returns this richer block. */
80559
- var PtzStatusSchema = PtzPositionSchema.extend({ autofocus: boolean() });
80562
+ PtzPositionSchema.extend({ autofocus: boolean() });
80560
80563
  /** Per-camera PTZ availability descriptor — drives the UI. */
80561
80564
  var PtzOptionsSchema = object({
80562
80565
  hasPan: boolean(),
@@ -80569,65 +80572,26 @@ var PtzOptionsSchema = object({
80569
80572
  * (boolean `hasX` per the getOptions availability convention). */
80570
80573
  hasAutofocus: boolean()
80571
80574
  });
80572
- var ptzCapability = {
80573
- name: "ptz",
80574
- scope: "device",
80575
- deviceNative: true,
80576
- mode: "singleton",
80577
- deviceTypes: [DeviceType.Camera],
80578
- deviceConfig: { ui: {
80579
- kind: "widget",
80580
- widgetId: "host/ptz-panel",
80581
- tab: "ptz",
80582
- topTab: true,
80583
- label: "PTZ",
80584
- order: 0
80585
- } },
80586
- methods: {
80587
- move: method(PtzMoveCommandSchema.extend({ deviceId: number() }), _void(), { kind: "mutation" }),
80588
- continuousMove: method(PtzMoveCommandSchema.extend({ deviceId: number() }), _void(), { kind: "mutation" }),
80589
- stop: method(object({ deviceId: number() }), _void(), { kind: "mutation" }),
80590
- getPresets: method(object({ deviceId: number() }), array(PtzPresetSchema)),
80591
- goToPreset: method(object({
80592
- deviceId: number(),
80593
- presetId: string()
80594
- }), _void(), { kind: "mutation" }),
80595
- savePreset: method(object({
80596
- deviceId: number(),
80597
- presetId: string(),
80598
- name: string()
80599
- }), _void(), {
80600
- kind: "mutation",
80601
- auth: "admin"
80602
- }),
80603
- deletePreset: method(object({
80604
- deviceId: number(),
80605
- presetId: string()
80606
- }), _void(), {
80607
- kind: "mutation",
80608
- auth: "admin"
80609
- }),
80610
- getOptions: method(object({ deviceId: number() }), PtzOptionsSchema),
80611
- goHome: method(object({ deviceId: number() }), _void(), { kind: "mutation" }),
80612
- /**
80613
- * Pull the current PTZ position. Redundant with the auto-injected
80614
- * `getStatus` method (see `status` below); kept for callers that
80615
- * haven't migrated. Will be folded into `getStatus` in a future
80616
- * cleanup pass once all consumers use the typed status surface.
80617
- */
80618
- getPosition: method(object({ deviceId: number() }), PtzPositionSchema),
80619
- /** Toggle the camera's autofocus. Only meaningful when
80620
- * `getOptions().hasAutofocus` is true. */
80621
- setAutofocus: method(object({
80622
- deviceId: number(),
80623
- enabled: boolean()
80624
- }), _void(), { kind: "mutation" })
80625
- },
80626
- status: {
80627
- schema: PtzStatusSchema,
80628
- kind: "command-driven"
80629
- }
80630
- };
80575
+ DeviceType.Camera, method(PtzMoveCommandSchema.extend({ deviceId: number() }), _void(), { kind: "mutation" }), method(PtzMoveCommandSchema.extend({ deviceId: number() }), _void(), { kind: "mutation" }), method(object({ deviceId: number() }), _void(), { kind: "mutation" }), method(object({ deviceId: number() }), array(PtzPresetSchema)), method(object({
80576
+ deviceId: number(),
80577
+ presetId: string()
80578
+ }), _void(), { kind: "mutation" }), method(object({
80579
+ deviceId: number(),
80580
+ presetId: string(),
80581
+ name: string()
80582
+ }), _void(), {
80583
+ kind: "mutation",
80584
+ auth: "admin"
80585
+ }), method(object({
80586
+ deviceId: number(),
80587
+ presetId: string()
80588
+ }), _void(), {
80589
+ kind: "mutation",
80590
+ auth: "admin"
80591
+ }), method(object({ deviceId: number() }), PtzOptionsSchema), method(object({ deviceId: number() }), _void(), { kind: "mutation" }), method(object({ deviceId: number() }), PtzPositionSchema), method(object({
80592
+ deviceId: number(),
80593
+ enabled: boolean()
80594
+ }), _void(), { kind: "mutation" });
80631
80595
  var PtzAutotrackSettingsSchema = object({
80632
80596
  targetType: string().describe("Vendor target string (people/vehicle/pet); empty = camera default"),
80633
80597
  stopDelaySeconds: number().int().min(0).max(300),
@@ -96331,6 +96295,65 @@ function buildVacuumConsumables(vacuum) {
96331
96295
  };
96332
96296
  }
96333
96297
  //#endregion
96298
+ //#region src/devices/dreame-camera-stream.ts
96299
+ /** The one stream the robot camera offers. Half of the broker id (`<deviceId>/live`). */
96300
+ var CAMERA_STREAM_ID = "live";
96301
+ /** How often an open monitor asks the broker whether anybody is still watching. */
96302
+ var IDLE_CHECK_MS = 6e4;
96303
+ /** `ChargingStatus` as the robot reports it (siid 3 piid 2, VERIFIED r2532a). */
96304
+ var CHARGING_STATUS_CHARGING = 1;
96305
+ function clampLevel(level) {
96306
+ if (level === null || !Number.isFinite(level)) return null;
96307
+ return Math.max(0, Math.min(100, level));
96308
+ }
96309
+ /**
96310
+ * The battery slice the broker reads before a play: `sleeping` decides
96311
+ * whether it asks for a wake, `lastContactAt` decides whether it refuses to
96312
+ * (`deriveBatteryPresence` calls hours of silence "unreachable"). A contact
96313
+ * of 0 would read as "never heard" — the field is omitted instead.
96314
+ */
96315
+ function cameraBatterySlice(facts) {
96316
+ const charging = facts.charging === null ? "unknown" : facts.charging === CHARGING_STATUS_CHARGING ? "dc" : "none";
96317
+ return {
96318
+ percentage: clampLevel(facts.level),
96319
+ charging,
96320
+ sleeping: !facts.streamOpen,
96321
+ lastUpdated: facts.now,
96322
+ ...facts.contactAt !== null && facts.contactAt > 0 ? { lastContactAt: facts.contactAt } : {}
96323
+ };
96324
+ }
96325
+ /** One `pull-rtmp` descriptor while the monitor is open; nothing while it is closed. */
96326
+ function cameraStreamCatalog(session) {
96327
+ if (session === null) return [];
96328
+ return [{
96329
+ camStreamId: CAMERA_STREAM_ID,
96330
+ kind: "pull-rtmp",
96331
+ url: session.url,
96332
+ codec: "h264",
96333
+ label: "Robot camera",
96334
+ deviceFeatures: ["battery-operated"],
96335
+ autoEligible: true
96336
+ }];
96337
+ }
96338
+ /**
96339
+ * Somebody WATCHING is a reason to keep the monitor open: an RTSP client, a
96340
+ * WebRTC session (an `encoded` subscriber), an audio listener. The detection
96341
+ * pipeline is deliberately NOT one — it subscribes decoded frames for as long
96342
+ * as a stream exists, so counting it would keep the robot's relay open, on
96343
+ * its battery and through the cloud, until the battery died. It rides along
96344
+ * while a viewer watches and stops when the monitor closes.
96345
+ */
96346
+ function isBrokerBusy(clients) {
96347
+ return clients.rtsp.length > 0 || clients.encoded.length > 0 || clients.audio.length > 0;
96348
+ }
96349
+ /** Consecutive idle checks; a busy check starts the count over. */
96350
+ function nextIdleCount(previous, busy) {
96351
+ return busy ? 0 : previous + 1;
96352
+ }
96353
+ function shouldCloseForIdle(idleCount) {
96354
+ return idleCount >= 3;
96355
+ }
96356
+ //#endregion
96334
96357
  //#region src/devices/dreame-navigation-actions.ts
96335
96358
  /** Forward throttle magnitude for a full-forward nudge (device speed units). */
96336
96359
  var FORWARD_SPEED = 200;
@@ -96537,60 +96560,6 @@ function resolveNavigationFeatures(overrides) {
96537
96560
  function resolveDisabledEntryIds(overrides) {
96538
96561
  return new Set([...DREAME_NAVIGATION_DISABLED_ENTRY_IDS, ...overrides?.disabledEntryIds ?? []]);
96539
96562
  }
96540
- //#endregion
96541
- //#region src/devices/dreame-navigation-ptz-adapter.ts
96542
- /** A gimbal descriptor for a robot: the joystick is pan/tilt, there is no lens. */
96543
- function ptzOptionsFromFeatures(features) {
96544
- return {
96545
- hasPan: features.move,
96546
- hasTilt: features.move,
96547
- hasZoom: false,
96548
- supportsPresets: features.runAction || features.playSound,
96549
- hasAutofocus: false
96550
- };
96551
- }
96552
- /** Every ENABLED dictionary entry is a preset; the label is the preset name. */
96553
- function presetsFromCatalog(entries, features) {
96554
- return entries.filter((entry) => entry.enabled).filter((entry) => entry.kind === "action" ? features.runAction : features.playSound).map((entry) => ({
96555
- id: entry.id,
96556
- name: entry.label
96557
- }));
96558
- }
96559
- /**
96560
- * Resolve a preset id back to the navigation call. `null` when the id names
96561
- * nothing enabled — the caller rejects rather than guessing an action.
96562
- */
96563
- function presetTarget(entries, presetId) {
96564
- const entry = entries.find((candidate) => candidate.id === presetId && candidate.enabled);
96565
- if (!entry) return null;
96566
- if (entry.kind === "sound") return entry.soundId === void 0 ? null : {
96567
- kind: "sound",
96568
- soundId: entry.soundId
96569
- };
96570
- const parsed = NavigationActionIdSchema.safeParse(entry.id);
96571
- return parsed.success ? {
96572
- kind: "action",
96573
- actionId: parsed.data
96574
- } : null;
96575
- }
96576
- function clampAxis(value) {
96577
- if (value === void 0 || !Number.isFinite(value)) return void 0;
96578
- return Math.max(-1, Math.min(1, value));
96579
- }
96580
- /**
96581
- * A PTZ move is a navigation nudge: pan turns, tilt drives, zoom has no
96582
- * meaning on a chassis and is dropped. Axes are clamped to the cap's [-1, 1].
96583
- */
96584
- function ptzMoveToNavigation(cmd) {
96585
- const pan = clampAxis(cmd.pan);
96586
- const tilt = clampAxis(cmd.tilt);
96587
- const speed = cmd.speed === void 0 || !Number.isFinite(cmd.speed) ? void 0 : Math.max(0, Math.min(1, cmd.speed));
96588
- return {
96589
- ...pan !== void 0 ? { pan } : {},
96590
- ...tilt !== void 0 ? { tilt } : {},
96591
- ...speed !== void 0 ? { speed } : {}
96592
- };
96593
- }
96594
96563
  /** The camera-service fill-light mode/level property. */
96595
96564
  var FILL_LIGHT_SET_SIID = 10001;
96596
96565
  /** The wire value that means "auto" on `fillinLightSet`. */
@@ -96639,14 +96608,30 @@ function decodeNavigationReadback(read) {
96639
96608
  //#endregion
96640
96609
  //#region src/devices/dreame-camera-device.ts
96641
96610
  var CAP_NAME$12 = "navigation";
96611
+ /**
96612
+ * The slice as WRITTEN: the cap's runtime-state shape (status + `lastFetchedAt`).
96613
+ * `setCapState` validates against it, so a status-only object throws at
96614
+ * activation and registers nothing — which is how the first deploy shipped
96615
+ * a camera with no navigation and no PTZ.
96616
+ */
96642
96617
  var NAV_COLD_START = {
96643
96618
  mode: "unknown",
96644
96619
  following: false,
96645
96620
  flash: false,
96646
96621
  lightMode: "auto",
96647
96622
  lightLevel: 100,
96648
- lastChangedAt: 0
96623
+ lastChangedAt: 0,
96624
+ lastFetchedAt: 0
96649
96625
  };
96626
+ /** The next slice: the previous one, the patch, and both clocks stamped `now`. */
96627
+ function navigationSlice(prev, patch, now) {
96628
+ return {
96629
+ ...prev,
96630
+ ...patch,
96631
+ lastChangedAt: now,
96632
+ lastFetchedAt: now
96633
+ };
96634
+ }
96650
96635
  /**
96651
96636
  * Dreame CAMERA + NAVIGATION device — a SEPARATE camstack device from the
96652
96637
  * `DreameVacuumDevice`, spawned only for camera-capable vacuums (those whose
@@ -96672,11 +96657,10 @@ var NAV_COLD_START = {
96672
96657
  * read-back lands, from the commands this device issued. `following` has no
96673
96658
  * confirmed read-back yet and stays command-driven.
96674
96659
  *
96675
- * PTZ MIMIC: the same device also registers `ptz`, projected from `navigation`
96676
- * through `dreame-navigation-ptz-adapter.ts` (pure, tested) — the joystick is
96677
- * pan/tilt, the enabled actions and sounds are the presets, there is no zoom
96678
- * and no autofocus. `navigation` stays the source of truth; nothing is stored
96679
- * on the PTZ side.
96660
+ * `navigation` is its own capability, deliberately NOT projected as `ptz`: a
96661
+ * gimbal and a chassis are different physical models, and the operator asked
96662
+ * for two separate things (admin UI: two tabs; viewer: one panel that shows the
96663
+ * navigation controls when the device has the cap).
96680
96664
  */
96681
96665
  var DreameCameraDevice = class extends DreameChildDevice {
96682
96666
  features = [DeviceFeature.BatteryOperated];
@@ -96687,7 +96671,148 @@ var DreameCameraDevice = class extends DreameChildDevice {
96687
96671
  const provider = this.buildNavigationProvider();
96688
96672
  this.ctx.registerNativeCap(navigationCapability, provider);
96689
96673
  this.runtimeState.setCapState(CAP_NAME$12, NAV_COLD_START);
96690
- this.ctx.registerNativeCap(ptzCapability, this.buildPtzMimic(provider));
96674
+ this.registerBatteryCap();
96675
+ this.registerStreamCatalogCap();
96676
+ }
96677
+ /** The relay the current `open()` handed out; null while the monitor is closed. */
96678
+ session = null;
96679
+ /** A single in-flight `open()` — two plays must not cold-start the robot twice. */
96680
+ opening = null;
96681
+ idleTimer = null;
96682
+ idleCount = 0;
96683
+ /** Last time the robot said anything (a state change) — the wake refusal's evidence. */
96684
+ lastContactAt = null;
96685
+ registerBatteryCap() {
96686
+ this.ctx.registerNativeCap(batteryCapability, { wakeForStream: async ({ timeoutMs }) => this.wakeForStream(timeoutMs) });
96687
+ this.publishBattery();
96688
+ this.runtimeState.subscribeCap(batteryCapability.name, (slice) => {
96689
+ if (!slice) return;
96690
+ this.ctx.eventBus.emit(createEvent(EventCategory.BatteryOnStatusChanged, {
96691
+ type: "device",
96692
+ id: this.stableId
96693
+ }, {
96694
+ deviceId: this.id,
96695
+ status: slice
96696
+ }));
96697
+ });
96698
+ }
96699
+ registerStreamCatalogCap() {
96700
+ this.ctx.registerNativeCap(streamCatalogCapability, { getCatalog: async ({ deviceId }) => deviceId === this.id ? cameraStreamCatalog(this.session) : [] });
96701
+ this.publishCatalog();
96702
+ }
96703
+ publishBattery() {
96704
+ const handle = this.resolveHandle();
96705
+ const vacuum = handle instanceof VacuumDevice ? handle : null;
96706
+ this.setCapSlice(batteryCapability, cameraBatterySlice({
96707
+ level: vacuum?.battery ?? null,
96708
+ charging: vacuum?.charging ?? null,
96709
+ streamOpen: this.session !== null,
96710
+ now: Date.now(),
96711
+ contactAt: this.lastContactAt
96712
+ }));
96713
+ }
96714
+ publishCatalog() {
96715
+ this.runtimeState.setCapState(streamCatalogCapability.name, {
96716
+ descriptors: [...cameraStreamCatalog(this.session)],
96717
+ lastFetchedAt: Date.now()
96718
+ });
96719
+ }
96720
+ /**
96721
+ * `battery.wakeForStream`: open the monitor inside the caller's bound. An
96722
+ * open that outlives the bound keeps running — the next play joins it.
96723
+ */
96724
+ async wakeForStream(timeoutMs) {
96725
+ const startedAt = Date.now();
96726
+ if (this.session !== null) return {
96727
+ awoke: true,
96728
+ durationMs: 0
96729
+ };
96730
+ const opened = this.openStream().then(() => "open", () => "failed");
96731
+ const bound = new Promise((resolve) => {
96732
+ setTimeout(() => resolve("timeout"), timeoutMs).unref?.();
96733
+ });
96734
+ const outcome = await Promise.race([opened, bound]);
96735
+ if (outcome === "timeout") this.ctx.logger.info("robot camera: the wake outlived its bound — the open keeps running", {
96736
+ tags: { deviceId: this.id },
96737
+ meta: { timeoutMs }
96738
+ });
96739
+ return {
96740
+ awoke: outcome === "open",
96741
+ durationMs: Date.now() - startedAt
96742
+ };
96743
+ }
96744
+ openStream() {
96745
+ if (this.opening !== null) return this.opening;
96746
+ const run = (async () => {
96747
+ const startedAt = Date.now();
96748
+ try {
96749
+ const handle = await (await this.requireController()).open();
96750
+ this.session = { url: handle.rtmpUrl };
96751
+ this.idleCount = 0;
96752
+ this.publishBattery();
96753
+ this.publishCatalog();
96754
+ this.startIdleWatch();
96755
+ this.ctx.logger.info("robot camera: monitor open, relay published to the catalog", {
96756
+ tags: { deviceId: this.id },
96757
+ meta: { openMs: Date.now() - startedAt }
96758
+ });
96759
+ } catch (err) {
96760
+ this.ctx.logger.warn("robot camera: monitor could not be opened", {
96761
+ tags: { deviceId: this.id },
96762
+ meta: {
96763
+ openMs: Date.now() - startedAt,
96764
+ error: errMsg(err)
96765
+ }
96766
+ });
96767
+ throw err;
96768
+ }
96769
+ })();
96770
+ this.opening = run.finally(() => {
96771
+ this.opening = null;
96772
+ });
96773
+ return this.opening;
96774
+ }
96775
+ async closeStream(reason) {
96776
+ this.stopIdleWatch();
96777
+ if (this.session === null) return;
96778
+ this.session = null;
96779
+ this.publishBattery();
96780
+ this.publishCatalog();
96781
+ this.ctx.logger.info("robot camera: monitor closed", {
96782
+ tags: { deviceId: this.id },
96783
+ meta: { reason }
96784
+ });
96785
+ const pending = this.controller;
96786
+ if (pending === null) return;
96787
+ await pending.then((controller) => controller.close()).catch((err) => {
96788
+ this.ctx.logger.debug("robot camera: close reported an error", {
96789
+ tags: { deviceId: this.id },
96790
+ meta: {
96791
+ reason,
96792
+ error: errMsg(err)
96793
+ }
96794
+ });
96795
+ });
96796
+ }
96797
+ startIdleWatch() {
96798
+ this.stopIdleWatch();
96799
+ this.idleTimer = setInterval(() => {
96800
+ this.checkIdle();
96801
+ }, IDLE_CHECK_MS);
96802
+ this.idleTimer.unref?.();
96803
+ }
96804
+ stopIdleWatch() {
96805
+ if (this.idleTimer !== null) clearInterval(this.idleTimer);
96806
+ this.idleTimer = null;
96807
+ this.idleCount = 0;
96808
+ }
96809
+ /** Ask the broker whether anybody is still watching; no broker at all counts as idle. */
96810
+ async checkIdle() {
96811
+ if (this.session === null) return;
96812
+ const clients = await this.ctx.api.streamBroker.listClients.query({ brokerId: `${String(this.id)}/${CAMERA_STREAM_ID}` }).catch(() => null);
96813
+ const busy = clients !== null && isBrokerBusy(clients);
96814
+ this.idleCount = nextIdleCount(this.idleCount, busy);
96815
+ if (shouldCloseForIdle(this.idleCount)) await this.closeStream("idle");
96691
96816
  }
96692
96817
  buildNavigationProvider() {
96693
96818
  return {
@@ -96737,81 +96862,19 @@ var DreameCameraDevice = class extends DreameChildDevice {
96737
96862
  }
96738
96863
  };
96739
96864
  }
96740
- /**
96741
- * `ptz` projected from `navigation` (the PTZ-mimic adapter). Every call
96742
- * forwards to the navigation provider through the pure mapping; a preset
96743
- * that names nothing enabled is rejected, never guessed.
96744
- */
96745
- buildPtzMimic(nav) {
96746
- const deviceId = this.id;
96747
- const noGimbal = (what) => {
96748
- throw new Error(`DreameCameraDevice: a robot camera has no ${what}`);
96749
- };
96750
- return {
96751
- getStatus: async () => ({
96752
- pan: 0,
96753
- tilt: 0,
96754
- zoom: 0,
96755
- autofocus: false
96756
- }),
96757
- getPosition: async () => ({
96758
- pan: 0,
96759
- tilt: 0,
96760
- zoom: 0
96761
- }),
96762
- getOptions: async () => ptzOptionsFromFeatures(await nav.getFeatures({ deviceId })),
96763
- move: async (cmd) => {
96764
- await nav.move({
96765
- deviceId,
96766
- ...ptzMoveToNavigation(cmd)
96767
- });
96768
- },
96769
- continuousMove: async (cmd) => {
96770
- await nav.move({
96771
- deviceId,
96772
- ...ptzMoveToNavigation(cmd)
96773
- });
96774
- },
96775
- stop: async () => {
96776
- await nav.stop({ deviceId });
96777
- },
96778
- goHome: async () => {
96779
- await nav.runAction({
96780
- deviceId,
96781
- actionId: "goHome"
96782
- });
96783
- },
96784
- getPresets: async () => presetsFromCatalog(await nav.listActions({ deviceId }), await nav.getFeatures({ deviceId })),
96785
- goToPreset: async ({ presetId }) => {
96786
- const target = presetTarget(await nav.listActions({ deviceId }), presetId);
96787
- if (target === null) throw new Error(`DreameCameraDevice: unknown or disabled navigation preset "${presetId}"`);
96788
- if (target.kind === "sound") await nav.playSound({
96789
- deviceId,
96790
- soundId: target.soundId
96791
- });
96792
- else await nav.runAction({
96793
- deviceId,
96794
- actionId: target.actionId
96795
- });
96796
- },
96797
- savePreset: async () => noGimbal("saved preset — its presets are its actions"),
96798
- deletePreset: async () => noGimbal("deletable preset — its presets are its actions"),
96799
- setAutofocus: async () => noGimbal("autofocus")
96800
- };
96801
- }
96802
96865
  recomputeSlice() {
96803
96866
  const handle = this.resolveHandle();
96804
96867
  if (handle === null) return;
96868
+ this.lastContactAt = Date.now();
96869
+ this.publishBattery();
96805
96870
  const prev = this.runtimeState.getCapState(CAP_NAME$12) ?? NAV_COLD_START;
96806
96871
  const readback = handle instanceof VacuumDevice ? decodeNavigationReadback((siid, piid) => handle.getProperty(siid, piid)?.value) : {};
96807
96872
  const following = handle instanceof VacuumDevice ? decodeFollowing(handle.status) : null;
96808
- this.runtimeState.setCapState(CAP_NAME$12, {
96809
- ...prev,
96873
+ this.runtimeState.setCapState(CAP_NAME$12, navigationSlice(prev, {
96810
96874
  ...readback,
96811
96875
  ...following !== null ? { following } : {},
96812
- mode: following === true ? "following" : this.deriveMode(handle),
96813
- lastChangedAt: Date.now()
96814
- });
96876
+ mode: following === true ? "following" : this.deriveMode(handle)
96877
+ }, Date.now()));
96815
96878
  }
96816
96879
  controller = null;
96817
96880
  controllerHandle = null;
@@ -96826,7 +96889,16 @@ var DreameCameraDevice = class extends DreameChildDevice {
96826
96889
  if (this.controller === null || this.controllerHandle !== handle) {
96827
96890
  const accessCode = this.config.get("navigationAccessCode");
96828
96891
  this.controllerHandle = handle;
96829
- this.controller = handle.createCameraController(accessCode !== void 0 && accessCode !== "" ? { accessCode } : {}).catch((err) => {
96892
+ this.controller = handle.createCameraController({
96893
+ ...accessCode !== void 0 && accessCode !== "" ? { accessCode } : {},
96894
+ onKeepAliveError: (err) => {
96895
+ this.ctx.logger.warn("robot camera: relay keep-alive failed — closing the monitor", {
96896
+ tags: { deviceId: this.id },
96897
+ meta: { error: errMsg(err) }
96898
+ });
96899
+ this.closeStream("keep-alive");
96900
+ }
96901
+ }).catch((err) => {
96830
96902
  this.controller = null;
96831
96903
  this.controllerHandle = null;
96832
96904
  throw err;
@@ -96840,6 +96912,7 @@ var DreameCameraDevice = class extends DreameChildDevice {
96840
96912
  }
96841
96913
  async removeDevice() {
96842
96914
  await super.removeDevice();
96915
+ await this.closeStream("removed");
96843
96916
  const pending = this.controller;
96844
96917
  this.controller = null;
96845
96918
  this.controllerHandle = null;
@@ -96901,11 +96974,7 @@ var DreameCameraDevice = class extends DreameChildDevice {
96901
96974
  */
96902
96975
  patchStatus(patch) {
96903
96976
  const prev = this.runtimeState.getCapState(CAP_NAME$12) ?? NAV_COLD_START;
96904
- this.runtimeState.setCapState(CAP_NAME$12, {
96905
- ...prev,
96906
- ...patch,
96907
- lastChangedAt: Date.now()
96908
- });
96977
+ this.runtimeState.setCapState(CAP_NAME$12, navigationSlice(prev, patch, Date.now()));
96909
96978
  }
96910
96979
  };
96911
96980
  //#endregion
package/dist/addon.mjs CHANGED
@@ -57117,6 +57117,12 @@ Object.fromEntries([
57117
57117
  icon: "move",
57118
57118
  order: 40
57119
57119
  },
57120
+ {
57121
+ id: "navigation",
57122
+ label: "Navigation",
57123
+ icon: "compass",
57124
+ order: 41
57125
+ },
57120
57126
  {
57121
57127
  id: "consumables",
57122
57128
  label: "Consumables",
@@ -80553,10 +80559,7 @@ var PtzMoveCommandSchema = object({
80553
80559
  zoom: number().optional(),
80554
80560
  speed: number().optional()
80555
80561
  });
80556
- /** PTZ status — current position plus the camera's autofocus state.
80557
- * `getPosition` / `move` still operate on the bare position; the
80558
- * auto-injected `getStatus` returns this richer block. */
80559
- var PtzStatusSchema = PtzPositionSchema.extend({ autofocus: boolean() });
80562
+ PtzPositionSchema.extend({ autofocus: boolean() });
80560
80563
  /** Per-camera PTZ availability descriptor — drives the UI. */
80561
80564
  var PtzOptionsSchema = object({
80562
80565
  hasPan: boolean(),
@@ -80569,65 +80572,26 @@ var PtzOptionsSchema = object({
80569
80572
  * (boolean `hasX` per the getOptions availability convention). */
80570
80573
  hasAutofocus: boolean()
80571
80574
  });
80572
- var ptzCapability = {
80573
- name: "ptz",
80574
- scope: "device",
80575
- deviceNative: true,
80576
- mode: "singleton",
80577
- deviceTypes: [DeviceType.Camera],
80578
- deviceConfig: { ui: {
80579
- kind: "widget",
80580
- widgetId: "host/ptz-panel",
80581
- tab: "ptz",
80582
- topTab: true,
80583
- label: "PTZ",
80584
- order: 0
80585
- } },
80586
- methods: {
80587
- move: method(PtzMoveCommandSchema.extend({ deviceId: number() }), _void(), { kind: "mutation" }),
80588
- continuousMove: method(PtzMoveCommandSchema.extend({ deviceId: number() }), _void(), { kind: "mutation" }),
80589
- stop: method(object({ deviceId: number() }), _void(), { kind: "mutation" }),
80590
- getPresets: method(object({ deviceId: number() }), array(PtzPresetSchema)),
80591
- goToPreset: method(object({
80592
- deviceId: number(),
80593
- presetId: string()
80594
- }), _void(), { kind: "mutation" }),
80595
- savePreset: method(object({
80596
- deviceId: number(),
80597
- presetId: string(),
80598
- name: string()
80599
- }), _void(), {
80600
- kind: "mutation",
80601
- auth: "admin"
80602
- }),
80603
- deletePreset: method(object({
80604
- deviceId: number(),
80605
- presetId: string()
80606
- }), _void(), {
80607
- kind: "mutation",
80608
- auth: "admin"
80609
- }),
80610
- getOptions: method(object({ deviceId: number() }), PtzOptionsSchema),
80611
- goHome: method(object({ deviceId: number() }), _void(), { kind: "mutation" }),
80612
- /**
80613
- * Pull the current PTZ position. Redundant with the auto-injected
80614
- * `getStatus` method (see `status` below); kept for callers that
80615
- * haven't migrated. Will be folded into `getStatus` in a future
80616
- * cleanup pass once all consumers use the typed status surface.
80617
- */
80618
- getPosition: method(object({ deviceId: number() }), PtzPositionSchema),
80619
- /** Toggle the camera's autofocus. Only meaningful when
80620
- * `getOptions().hasAutofocus` is true. */
80621
- setAutofocus: method(object({
80622
- deviceId: number(),
80623
- enabled: boolean()
80624
- }), _void(), { kind: "mutation" })
80625
- },
80626
- status: {
80627
- schema: PtzStatusSchema,
80628
- kind: "command-driven"
80629
- }
80630
- };
80575
+ DeviceType.Camera, method(PtzMoveCommandSchema.extend({ deviceId: number() }), _void(), { kind: "mutation" }), method(PtzMoveCommandSchema.extend({ deviceId: number() }), _void(), { kind: "mutation" }), method(object({ deviceId: number() }), _void(), { kind: "mutation" }), method(object({ deviceId: number() }), array(PtzPresetSchema)), method(object({
80576
+ deviceId: number(),
80577
+ presetId: string()
80578
+ }), _void(), { kind: "mutation" }), method(object({
80579
+ deviceId: number(),
80580
+ presetId: string(),
80581
+ name: string()
80582
+ }), _void(), {
80583
+ kind: "mutation",
80584
+ auth: "admin"
80585
+ }), method(object({
80586
+ deviceId: number(),
80587
+ presetId: string()
80588
+ }), _void(), {
80589
+ kind: "mutation",
80590
+ auth: "admin"
80591
+ }), method(object({ deviceId: number() }), PtzOptionsSchema), method(object({ deviceId: number() }), _void(), { kind: "mutation" }), method(object({ deviceId: number() }), PtzPositionSchema), method(object({
80592
+ deviceId: number(),
80593
+ enabled: boolean()
80594
+ }), _void(), { kind: "mutation" });
80631
80595
  var PtzAutotrackSettingsSchema = object({
80632
80596
  targetType: string().describe("Vendor target string (people/vehicle/pet); empty = camera default"),
80633
80597
  stopDelaySeconds: number().int().min(0).max(300),
@@ -96331,6 +96295,65 @@ function buildVacuumConsumables(vacuum) {
96331
96295
  };
96332
96296
  }
96333
96297
  //#endregion
96298
+ //#region src/devices/dreame-camera-stream.ts
96299
+ /** The one stream the robot camera offers. Half of the broker id (`<deviceId>/live`). */
96300
+ var CAMERA_STREAM_ID = "live";
96301
+ /** How often an open monitor asks the broker whether anybody is still watching. */
96302
+ var IDLE_CHECK_MS = 6e4;
96303
+ /** `ChargingStatus` as the robot reports it (siid 3 piid 2, VERIFIED r2532a). */
96304
+ var CHARGING_STATUS_CHARGING = 1;
96305
+ function clampLevel(level) {
96306
+ if (level === null || !Number.isFinite(level)) return null;
96307
+ return Math.max(0, Math.min(100, level));
96308
+ }
96309
+ /**
96310
+ * The battery slice the broker reads before a play: `sleeping` decides
96311
+ * whether it asks for a wake, `lastContactAt` decides whether it refuses to
96312
+ * (`deriveBatteryPresence` calls hours of silence "unreachable"). A contact
96313
+ * of 0 would read as "never heard" — the field is omitted instead.
96314
+ */
96315
+ function cameraBatterySlice(facts) {
96316
+ const charging = facts.charging === null ? "unknown" : facts.charging === CHARGING_STATUS_CHARGING ? "dc" : "none";
96317
+ return {
96318
+ percentage: clampLevel(facts.level),
96319
+ charging,
96320
+ sleeping: !facts.streamOpen,
96321
+ lastUpdated: facts.now,
96322
+ ...facts.contactAt !== null && facts.contactAt > 0 ? { lastContactAt: facts.contactAt } : {}
96323
+ };
96324
+ }
96325
+ /** One `pull-rtmp` descriptor while the monitor is open; nothing while it is closed. */
96326
+ function cameraStreamCatalog(session) {
96327
+ if (session === null) return [];
96328
+ return [{
96329
+ camStreamId: CAMERA_STREAM_ID,
96330
+ kind: "pull-rtmp",
96331
+ url: session.url,
96332
+ codec: "h264",
96333
+ label: "Robot camera",
96334
+ deviceFeatures: ["battery-operated"],
96335
+ autoEligible: true
96336
+ }];
96337
+ }
96338
+ /**
96339
+ * Somebody WATCHING is a reason to keep the monitor open: an RTSP client, a
96340
+ * WebRTC session (an `encoded` subscriber), an audio listener. The detection
96341
+ * pipeline is deliberately NOT one — it subscribes decoded frames for as long
96342
+ * as a stream exists, so counting it would keep the robot's relay open, on
96343
+ * its battery and through the cloud, until the battery died. It rides along
96344
+ * while a viewer watches and stops when the monitor closes.
96345
+ */
96346
+ function isBrokerBusy(clients) {
96347
+ return clients.rtsp.length > 0 || clients.encoded.length > 0 || clients.audio.length > 0;
96348
+ }
96349
+ /** Consecutive idle checks; a busy check starts the count over. */
96350
+ function nextIdleCount(previous, busy) {
96351
+ return busy ? 0 : previous + 1;
96352
+ }
96353
+ function shouldCloseForIdle(idleCount) {
96354
+ return idleCount >= 3;
96355
+ }
96356
+ //#endregion
96334
96357
  //#region src/devices/dreame-navigation-actions.ts
96335
96358
  /** Forward throttle magnitude for a full-forward nudge (device speed units). */
96336
96359
  var FORWARD_SPEED = 200;
@@ -96537,60 +96560,6 @@ function resolveNavigationFeatures(overrides) {
96537
96560
  function resolveDisabledEntryIds(overrides) {
96538
96561
  return new Set([...DREAME_NAVIGATION_DISABLED_ENTRY_IDS, ...overrides?.disabledEntryIds ?? []]);
96539
96562
  }
96540
- //#endregion
96541
- //#region src/devices/dreame-navigation-ptz-adapter.ts
96542
- /** A gimbal descriptor for a robot: the joystick is pan/tilt, there is no lens. */
96543
- function ptzOptionsFromFeatures(features) {
96544
- return {
96545
- hasPan: features.move,
96546
- hasTilt: features.move,
96547
- hasZoom: false,
96548
- supportsPresets: features.runAction || features.playSound,
96549
- hasAutofocus: false
96550
- };
96551
- }
96552
- /** Every ENABLED dictionary entry is a preset; the label is the preset name. */
96553
- function presetsFromCatalog(entries, features) {
96554
- return entries.filter((entry) => entry.enabled).filter((entry) => entry.kind === "action" ? features.runAction : features.playSound).map((entry) => ({
96555
- id: entry.id,
96556
- name: entry.label
96557
- }));
96558
- }
96559
- /**
96560
- * Resolve a preset id back to the navigation call. `null` when the id names
96561
- * nothing enabled — the caller rejects rather than guessing an action.
96562
- */
96563
- function presetTarget(entries, presetId) {
96564
- const entry = entries.find((candidate) => candidate.id === presetId && candidate.enabled);
96565
- if (!entry) return null;
96566
- if (entry.kind === "sound") return entry.soundId === void 0 ? null : {
96567
- kind: "sound",
96568
- soundId: entry.soundId
96569
- };
96570
- const parsed = NavigationActionIdSchema.safeParse(entry.id);
96571
- return parsed.success ? {
96572
- kind: "action",
96573
- actionId: parsed.data
96574
- } : null;
96575
- }
96576
- function clampAxis(value) {
96577
- if (value === void 0 || !Number.isFinite(value)) return void 0;
96578
- return Math.max(-1, Math.min(1, value));
96579
- }
96580
- /**
96581
- * A PTZ move is a navigation nudge: pan turns, tilt drives, zoom has no
96582
- * meaning on a chassis and is dropped. Axes are clamped to the cap's [-1, 1].
96583
- */
96584
- function ptzMoveToNavigation(cmd) {
96585
- const pan = clampAxis(cmd.pan);
96586
- const tilt = clampAxis(cmd.tilt);
96587
- const speed = cmd.speed === void 0 || !Number.isFinite(cmd.speed) ? void 0 : Math.max(0, Math.min(1, cmd.speed));
96588
- return {
96589
- ...pan !== void 0 ? { pan } : {},
96590
- ...tilt !== void 0 ? { tilt } : {},
96591
- ...speed !== void 0 ? { speed } : {}
96592
- };
96593
- }
96594
96563
  /** The camera-service fill-light mode/level property. */
96595
96564
  var FILL_LIGHT_SET_SIID = 10001;
96596
96565
  /** The wire value that means "auto" on `fillinLightSet`. */
@@ -96639,14 +96608,30 @@ function decodeNavigationReadback(read) {
96639
96608
  //#endregion
96640
96609
  //#region src/devices/dreame-camera-device.ts
96641
96610
  var CAP_NAME$12 = "navigation";
96611
+ /**
96612
+ * The slice as WRITTEN: the cap's runtime-state shape (status + `lastFetchedAt`).
96613
+ * `setCapState` validates against it, so a status-only object throws at
96614
+ * activation and registers nothing — which is how the first deploy shipped
96615
+ * a camera with no navigation and no PTZ.
96616
+ */
96642
96617
  var NAV_COLD_START = {
96643
96618
  mode: "unknown",
96644
96619
  following: false,
96645
96620
  flash: false,
96646
96621
  lightMode: "auto",
96647
96622
  lightLevel: 100,
96648
- lastChangedAt: 0
96623
+ lastChangedAt: 0,
96624
+ lastFetchedAt: 0
96649
96625
  };
96626
+ /** The next slice: the previous one, the patch, and both clocks stamped `now`. */
96627
+ function navigationSlice(prev, patch, now) {
96628
+ return {
96629
+ ...prev,
96630
+ ...patch,
96631
+ lastChangedAt: now,
96632
+ lastFetchedAt: now
96633
+ };
96634
+ }
96650
96635
  /**
96651
96636
  * Dreame CAMERA + NAVIGATION device — a SEPARATE camstack device from the
96652
96637
  * `DreameVacuumDevice`, spawned only for camera-capable vacuums (those whose
@@ -96672,11 +96657,10 @@ var NAV_COLD_START = {
96672
96657
  * read-back lands, from the commands this device issued. `following` has no
96673
96658
  * confirmed read-back yet and stays command-driven.
96674
96659
  *
96675
- * PTZ MIMIC: the same device also registers `ptz`, projected from `navigation`
96676
- * through `dreame-navigation-ptz-adapter.ts` (pure, tested) — the joystick is
96677
- * pan/tilt, the enabled actions and sounds are the presets, there is no zoom
96678
- * and no autofocus. `navigation` stays the source of truth; nothing is stored
96679
- * on the PTZ side.
96660
+ * `navigation` is its own capability, deliberately NOT projected as `ptz`: a
96661
+ * gimbal and a chassis are different physical models, and the operator asked
96662
+ * for two separate things (admin UI: two tabs; viewer: one panel that shows the
96663
+ * navigation controls when the device has the cap).
96680
96664
  */
96681
96665
  var DreameCameraDevice = class extends DreameChildDevice {
96682
96666
  features = [DeviceFeature.BatteryOperated];
@@ -96687,7 +96671,148 @@ var DreameCameraDevice = class extends DreameChildDevice {
96687
96671
  const provider = this.buildNavigationProvider();
96688
96672
  this.ctx.registerNativeCap(navigationCapability, provider);
96689
96673
  this.runtimeState.setCapState(CAP_NAME$12, NAV_COLD_START);
96690
- this.ctx.registerNativeCap(ptzCapability, this.buildPtzMimic(provider));
96674
+ this.registerBatteryCap();
96675
+ this.registerStreamCatalogCap();
96676
+ }
96677
+ /** The relay the current `open()` handed out; null while the monitor is closed. */
96678
+ session = null;
96679
+ /** A single in-flight `open()` — two plays must not cold-start the robot twice. */
96680
+ opening = null;
96681
+ idleTimer = null;
96682
+ idleCount = 0;
96683
+ /** Last time the robot said anything (a state change) — the wake refusal's evidence. */
96684
+ lastContactAt = null;
96685
+ registerBatteryCap() {
96686
+ this.ctx.registerNativeCap(batteryCapability, { wakeForStream: async ({ timeoutMs }) => this.wakeForStream(timeoutMs) });
96687
+ this.publishBattery();
96688
+ this.runtimeState.subscribeCap(batteryCapability.name, (slice) => {
96689
+ if (!slice) return;
96690
+ this.ctx.eventBus.emit(createEvent(EventCategory.BatteryOnStatusChanged, {
96691
+ type: "device",
96692
+ id: this.stableId
96693
+ }, {
96694
+ deviceId: this.id,
96695
+ status: slice
96696
+ }));
96697
+ });
96698
+ }
96699
+ registerStreamCatalogCap() {
96700
+ this.ctx.registerNativeCap(streamCatalogCapability, { getCatalog: async ({ deviceId }) => deviceId === this.id ? cameraStreamCatalog(this.session) : [] });
96701
+ this.publishCatalog();
96702
+ }
96703
+ publishBattery() {
96704
+ const handle = this.resolveHandle();
96705
+ const vacuum = handle instanceof VacuumDevice ? handle : null;
96706
+ this.setCapSlice(batteryCapability, cameraBatterySlice({
96707
+ level: vacuum?.battery ?? null,
96708
+ charging: vacuum?.charging ?? null,
96709
+ streamOpen: this.session !== null,
96710
+ now: Date.now(),
96711
+ contactAt: this.lastContactAt
96712
+ }));
96713
+ }
96714
+ publishCatalog() {
96715
+ this.runtimeState.setCapState(streamCatalogCapability.name, {
96716
+ descriptors: [...cameraStreamCatalog(this.session)],
96717
+ lastFetchedAt: Date.now()
96718
+ });
96719
+ }
96720
+ /**
96721
+ * `battery.wakeForStream`: open the monitor inside the caller's bound. An
96722
+ * open that outlives the bound keeps running — the next play joins it.
96723
+ */
96724
+ async wakeForStream(timeoutMs) {
96725
+ const startedAt = Date.now();
96726
+ if (this.session !== null) return {
96727
+ awoke: true,
96728
+ durationMs: 0
96729
+ };
96730
+ const opened = this.openStream().then(() => "open", () => "failed");
96731
+ const bound = new Promise((resolve) => {
96732
+ setTimeout(() => resolve("timeout"), timeoutMs).unref?.();
96733
+ });
96734
+ const outcome = await Promise.race([opened, bound]);
96735
+ if (outcome === "timeout") this.ctx.logger.info("robot camera: the wake outlived its bound — the open keeps running", {
96736
+ tags: { deviceId: this.id },
96737
+ meta: { timeoutMs }
96738
+ });
96739
+ return {
96740
+ awoke: outcome === "open",
96741
+ durationMs: Date.now() - startedAt
96742
+ };
96743
+ }
96744
+ openStream() {
96745
+ if (this.opening !== null) return this.opening;
96746
+ const run = (async () => {
96747
+ const startedAt = Date.now();
96748
+ try {
96749
+ const handle = await (await this.requireController()).open();
96750
+ this.session = { url: handle.rtmpUrl };
96751
+ this.idleCount = 0;
96752
+ this.publishBattery();
96753
+ this.publishCatalog();
96754
+ this.startIdleWatch();
96755
+ this.ctx.logger.info("robot camera: monitor open, relay published to the catalog", {
96756
+ tags: { deviceId: this.id },
96757
+ meta: { openMs: Date.now() - startedAt }
96758
+ });
96759
+ } catch (err) {
96760
+ this.ctx.logger.warn("robot camera: monitor could not be opened", {
96761
+ tags: { deviceId: this.id },
96762
+ meta: {
96763
+ openMs: Date.now() - startedAt,
96764
+ error: errMsg(err)
96765
+ }
96766
+ });
96767
+ throw err;
96768
+ }
96769
+ })();
96770
+ this.opening = run.finally(() => {
96771
+ this.opening = null;
96772
+ });
96773
+ return this.opening;
96774
+ }
96775
+ async closeStream(reason) {
96776
+ this.stopIdleWatch();
96777
+ if (this.session === null) return;
96778
+ this.session = null;
96779
+ this.publishBattery();
96780
+ this.publishCatalog();
96781
+ this.ctx.logger.info("robot camera: monitor closed", {
96782
+ tags: { deviceId: this.id },
96783
+ meta: { reason }
96784
+ });
96785
+ const pending = this.controller;
96786
+ if (pending === null) return;
96787
+ await pending.then((controller) => controller.close()).catch((err) => {
96788
+ this.ctx.logger.debug("robot camera: close reported an error", {
96789
+ tags: { deviceId: this.id },
96790
+ meta: {
96791
+ reason,
96792
+ error: errMsg(err)
96793
+ }
96794
+ });
96795
+ });
96796
+ }
96797
+ startIdleWatch() {
96798
+ this.stopIdleWatch();
96799
+ this.idleTimer = setInterval(() => {
96800
+ this.checkIdle();
96801
+ }, IDLE_CHECK_MS);
96802
+ this.idleTimer.unref?.();
96803
+ }
96804
+ stopIdleWatch() {
96805
+ if (this.idleTimer !== null) clearInterval(this.idleTimer);
96806
+ this.idleTimer = null;
96807
+ this.idleCount = 0;
96808
+ }
96809
+ /** Ask the broker whether anybody is still watching; no broker at all counts as idle. */
96810
+ async checkIdle() {
96811
+ if (this.session === null) return;
96812
+ const clients = await this.ctx.api.streamBroker.listClients.query({ brokerId: `${String(this.id)}/${CAMERA_STREAM_ID}` }).catch(() => null);
96813
+ const busy = clients !== null && isBrokerBusy(clients);
96814
+ this.idleCount = nextIdleCount(this.idleCount, busy);
96815
+ if (shouldCloseForIdle(this.idleCount)) await this.closeStream("idle");
96691
96816
  }
96692
96817
  buildNavigationProvider() {
96693
96818
  return {
@@ -96737,81 +96862,19 @@ var DreameCameraDevice = class extends DreameChildDevice {
96737
96862
  }
96738
96863
  };
96739
96864
  }
96740
- /**
96741
- * `ptz` projected from `navigation` (the PTZ-mimic adapter). Every call
96742
- * forwards to the navigation provider through the pure mapping; a preset
96743
- * that names nothing enabled is rejected, never guessed.
96744
- */
96745
- buildPtzMimic(nav) {
96746
- const deviceId = this.id;
96747
- const noGimbal = (what) => {
96748
- throw new Error(`DreameCameraDevice: a robot camera has no ${what}`);
96749
- };
96750
- return {
96751
- getStatus: async () => ({
96752
- pan: 0,
96753
- tilt: 0,
96754
- zoom: 0,
96755
- autofocus: false
96756
- }),
96757
- getPosition: async () => ({
96758
- pan: 0,
96759
- tilt: 0,
96760
- zoom: 0
96761
- }),
96762
- getOptions: async () => ptzOptionsFromFeatures(await nav.getFeatures({ deviceId })),
96763
- move: async (cmd) => {
96764
- await nav.move({
96765
- deviceId,
96766
- ...ptzMoveToNavigation(cmd)
96767
- });
96768
- },
96769
- continuousMove: async (cmd) => {
96770
- await nav.move({
96771
- deviceId,
96772
- ...ptzMoveToNavigation(cmd)
96773
- });
96774
- },
96775
- stop: async () => {
96776
- await nav.stop({ deviceId });
96777
- },
96778
- goHome: async () => {
96779
- await nav.runAction({
96780
- deviceId,
96781
- actionId: "goHome"
96782
- });
96783
- },
96784
- getPresets: async () => presetsFromCatalog(await nav.listActions({ deviceId }), await nav.getFeatures({ deviceId })),
96785
- goToPreset: async ({ presetId }) => {
96786
- const target = presetTarget(await nav.listActions({ deviceId }), presetId);
96787
- if (target === null) throw new Error(`DreameCameraDevice: unknown or disabled navigation preset "${presetId}"`);
96788
- if (target.kind === "sound") await nav.playSound({
96789
- deviceId,
96790
- soundId: target.soundId
96791
- });
96792
- else await nav.runAction({
96793
- deviceId,
96794
- actionId: target.actionId
96795
- });
96796
- },
96797
- savePreset: async () => noGimbal("saved preset — its presets are its actions"),
96798
- deletePreset: async () => noGimbal("deletable preset — its presets are its actions"),
96799
- setAutofocus: async () => noGimbal("autofocus")
96800
- };
96801
- }
96802
96865
  recomputeSlice() {
96803
96866
  const handle = this.resolveHandle();
96804
96867
  if (handle === null) return;
96868
+ this.lastContactAt = Date.now();
96869
+ this.publishBattery();
96805
96870
  const prev = this.runtimeState.getCapState(CAP_NAME$12) ?? NAV_COLD_START;
96806
96871
  const readback = handle instanceof VacuumDevice ? decodeNavigationReadback((siid, piid) => handle.getProperty(siid, piid)?.value) : {};
96807
96872
  const following = handle instanceof VacuumDevice ? decodeFollowing(handle.status) : null;
96808
- this.runtimeState.setCapState(CAP_NAME$12, {
96809
- ...prev,
96873
+ this.runtimeState.setCapState(CAP_NAME$12, navigationSlice(prev, {
96810
96874
  ...readback,
96811
96875
  ...following !== null ? { following } : {},
96812
- mode: following === true ? "following" : this.deriveMode(handle),
96813
- lastChangedAt: Date.now()
96814
- });
96876
+ mode: following === true ? "following" : this.deriveMode(handle)
96877
+ }, Date.now()));
96815
96878
  }
96816
96879
  controller = null;
96817
96880
  controllerHandle = null;
@@ -96826,7 +96889,16 @@ var DreameCameraDevice = class extends DreameChildDevice {
96826
96889
  if (this.controller === null || this.controllerHandle !== handle) {
96827
96890
  const accessCode = this.config.get("navigationAccessCode");
96828
96891
  this.controllerHandle = handle;
96829
- this.controller = handle.createCameraController(accessCode !== void 0 && accessCode !== "" ? { accessCode } : {}).catch((err) => {
96892
+ this.controller = handle.createCameraController({
96893
+ ...accessCode !== void 0 && accessCode !== "" ? { accessCode } : {},
96894
+ onKeepAliveError: (err) => {
96895
+ this.ctx.logger.warn("robot camera: relay keep-alive failed — closing the monitor", {
96896
+ tags: { deviceId: this.id },
96897
+ meta: { error: errMsg(err) }
96898
+ });
96899
+ this.closeStream("keep-alive");
96900
+ }
96901
+ }).catch((err) => {
96830
96902
  this.controller = null;
96831
96903
  this.controllerHandle = null;
96832
96904
  throw err;
@@ -96840,6 +96912,7 @@ var DreameCameraDevice = class extends DreameChildDevice {
96840
96912
  }
96841
96913
  async removeDevice() {
96842
96914
  await super.removeDevice();
96915
+ await this.closeStream("removed");
96843
96916
  const pending = this.controller;
96844
96917
  this.controller = null;
96845
96918
  this.controllerHandle = null;
@@ -96901,11 +96974,7 @@ var DreameCameraDevice = class extends DreameChildDevice {
96901
96974
  */
96902
96975
  patchStatus(patch) {
96903
96976
  const prev = this.runtimeState.getCapState(CAP_NAME$12) ?? NAV_COLD_START;
96904
- this.runtimeState.setCapState(CAP_NAME$12, {
96905
- ...prev,
96906
- ...patch,
96907
- lastChangedAt: Date.now()
96908
- });
96977
+ this.runtimeState.setCapState(CAP_NAME$12, navigationSlice(prev, patch, Date.now()));
96909
96978
  }
96910
96979
  };
96911
96980
  //#endregion
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@camstack/addon-provider-dreame",
3
- "version": "0.2.71",
3
+ "version": "0.2.72",
4
4
  "description": "Dreame robot-vacuum / lawn-mower device-provider addon for CamStack — wraps the @apocaliss92/nodedreame Dreamehome cloud client",
5
5
  "keywords": [
6
6
  "camstack",