@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.
- package/dist/addon.js +266 -197
- package/dist/addon.mjs +266 -197
- 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
|
-
|
|
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
|
-
|
|
80573
|
-
|
|
80574
|
-
|
|
80575
|
-
|
|
80576
|
-
|
|
80577
|
-
|
|
80578
|
-
|
|
80579
|
-
|
|
80580
|
-
|
|
80581
|
-
|
|
80582
|
-
|
|
80583
|
-
|
|
80584
|
-
|
|
80585
|
-
|
|
80586
|
-
|
|
80587
|
-
|
|
80588
|
-
|
|
80589
|
-
|
|
80590
|
-
|
|
80591
|
-
|
|
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
|
-
*
|
|
96676
|
-
*
|
|
96677
|
-
*
|
|
96678
|
-
*
|
|
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.
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
80573
|
-
|
|
80574
|
-
|
|
80575
|
-
|
|
80576
|
-
|
|
80577
|
-
|
|
80578
|
-
|
|
80579
|
-
|
|
80580
|
-
|
|
80581
|
-
|
|
80582
|
-
|
|
80583
|
-
|
|
80584
|
-
|
|
80585
|
-
|
|
80586
|
-
|
|
80587
|
-
|
|
80588
|
-
|
|
80589
|
-
|
|
80590
|
-
|
|
80591
|
-
|
|
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
|
-
*
|
|
96676
|
-
*
|
|
96677
|
-
*
|
|
96678
|
-
*
|
|
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.
|
|
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
|
-
|
|
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(
|
|
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.
|
|
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",
|