@camstack/addon-provider-dreame 0.2.106 → 0.2.108
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 +433 -17
- package/dist/addon.mjs +433 -17
- package/package.json +2 -2
package/dist/addon.js
CHANGED
|
@@ -46670,6 +46670,22 @@ objectType({
|
|
|
46670
46670
|
msg: stringType().nullish(),
|
|
46671
46671
|
data: objectType({ data: objectType({ familyId: stringType() }).passthrough() }).passthrough()
|
|
46672
46672
|
}).passthrough();
|
|
46673
|
+
objectType({
|
|
46674
|
+
code: numberType().optional(),
|
|
46675
|
+
msg: stringType().nullish(),
|
|
46676
|
+
data: objectType({ data: objectType({
|
|
46677
|
+
productId: stringType(),
|
|
46678
|
+
deviceName: stringType(),
|
|
46679
|
+
deviceId: stringType().optional(),
|
|
46680
|
+
secretId: stringType().optional(),
|
|
46681
|
+
secretKey: stringType().optional()
|
|
46682
|
+
}).passthrough() }).passthrough()
|
|
46683
|
+
}).passthrough();
|
|
46684
|
+
objectType({
|
|
46685
|
+
code: numberType().optional(),
|
|
46686
|
+
msg: stringType().nullish(),
|
|
46687
|
+
data: objectType({ data: objectType({ p2pInfo: stringType() }).passthrough() }).passthrough()
|
|
46688
|
+
}).passthrough();
|
|
46673
46689
|
var DeviceVideoInfoSchema = objectType({
|
|
46674
46690
|
model: stringType().optional(),
|
|
46675
46691
|
displayName: stringType().optional(),
|
|
@@ -47129,6 +47145,15 @@ var DreameVideoSession = class {
|
|
|
47129
47145
|
return (await this.getStreamInfo(iotId, { encrypted: false })).relayUrl;
|
|
47130
47146
|
}
|
|
47131
47147
|
};
|
|
47148
|
+
function explainNoCameraChannel(profile) {
|
|
47149
|
+
if (profile.iotId) return null;
|
|
47150
|
+
const who = `${profile.model || "unknown model"} (${profile.did || "no did"})`;
|
|
47151
|
+
if (!profile.videoCapable) return `${who} is not a camera: the cloud lists no video permit and no supported video vendor for it, so there is no channel to open.`;
|
|
47152
|
+
if (!profile.online) return `${who} is a camera but is OFFLINE. The video binding is published by the device itself, so there is nothing to read until it connects \u2014 this is not a provisioning fault.`;
|
|
47153
|
+
const supports = profile.supportedVendors.length > 0 ? profile.supportedVendors.join(", ") : "none";
|
|
47154
|
+
if (profile.currentVendor === "tx") return `${who} is a camera and online, but is provisioned on the TENCENT video vendor (vendor: tx; supports: ${supports}), and this library implements only the Aliyun LinkVisual path \u2014 the channel id it needs lives on Aliyun and a tx device has none. Re-pairing or re-opening the camera in the app will not change this.`;
|
|
47155
|
+
return `${who} is a camera and online, but has no video channel yet (vendor: ${profile.currentVendor ?? "none"}; supports: ${supports}). The device has not bound to the video service \u2014 open its camera once in the Dreame app to create the binding, then retry.`;
|
|
47156
|
+
}
|
|
47132
47157
|
var MONITOR_SIID = 10001;
|
|
47133
47158
|
var MONITOR_AIID = {
|
|
47134
47159
|
/** Stream lifecycle + keep-alive (monitor start/end, keep_alive). */
|
|
@@ -47322,6 +47347,33 @@ function firstOutValue(res) {
|
|
|
47322
47347
|
const out = r?.out ?? r?.result?.out;
|
|
47323
47348
|
return Array.isArray(out) && out.length > 0 ? out[0]?.value : void 0;
|
|
47324
47349
|
}
|
|
47350
|
+
function videoVendorSwitchParams(vendor) {
|
|
47351
|
+
return { vendor };
|
|
47352
|
+
}
|
|
47353
|
+
function parseVideoVendorStatus(raw) {
|
|
47354
|
+
const text = typeof raw === "string" ? raw : typeof raw === "object" && raw !== null ? null : null;
|
|
47355
|
+
const source = text === null ? raw : safeJson(text);
|
|
47356
|
+
if (typeof source !== "object" || source === null) return {
|
|
47357
|
+
vendor: null,
|
|
47358
|
+
initStatus: null
|
|
47359
|
+
};
|
|
47360
|
+
const v = Reflect.get(source, "vendor");
|
|
47361
|
+
const s = Reflect.get(source, "initStatus");
|
|
47362
|
+
return {
|
|
47363
|
+
vendor: v === "ali" || v === "tx" ? v : null,
|
|
47364
|
+
initStatus: typeof s === "number" ? s : null
|
|
47365
|
+
};
|
|
47366
|
+
}
|
|
47367
|
+
function safeJson(text) {
|
|
47368
|
+
try {
|
|
47369
|
+
return JSON.parse(text);
|
|
47370
|
+
} catch {
|
|
47371
|
+
return null;
|
|
47372
|
+
}
|
|
47373
|
+
}
|
|
47374
|
+
function vendorSwitchSettled(status, wanted) {
|
|
47375
|
+
return status.vendor === wanted && status.initStatus === 1;
|
|
47376
|
+
}
|
|
47325
47377
|
var DEFAULT_KEEP_ALIVE_MS = 1e4;
|
|
47326
47378
|
var DEFAULT_AREA = "4";
|
|
47327
47379
|
var DreameCameraController = class {
|
|
@@ -50181,12 +50233,16 @@ var VacuumDevice = class _VacuumDevice extends BaseDevice$1 {
|
|
|
50181
50233
|
session,
|
|
50182
50234
|
region
|
|
50183
50235
|
});
|
|
50184
|
-
|
|
50185
|
-
|
|
50186
|
-
|
|
50187
|
-
|
|
50188
|
-
|
|
50189
|
-
|
|
50236
|
+
let iotId = opts?.iotId ?? null;
|
|
50237
|
+
if (!iotId) {
|
|
50238
|
+
const profile = await getDeviceVideoProfile({
|
|
50239
|
+
session,
|
|
50240
|
+
region,
|
|
50241
|
+
did: this.deviceId
|
|
50242
|
+
});
|
|
50243
|
+
iotId = profile.iotId;
|
|
50244
|
+
if (!iotId) throw new DreameError(explainNoCameraChannel(profile) ?? "device has no video channel");
|
|
50245
|
+
}
|
|
50190
50246
|
return new DreameCameraController({
|
|
50191
50247
|
device: this,
|
|
50192
50248
|
relay,
|
|
@@ -50201,6 +50257,54 @@ var VacuumDevice = class _VacuumDevice extends BaseDevice$1 {
|
|
|
50201
50257
|
...opts?.log ? { log: opts.log } : {}
|
|
50202
50258
|
});
|
|
50203
50259
|
}
|
|
50260
|
+
/**
|
|
50261
|
+
* What video backend this robot is on, and whether that backend's SDK is up.
|
|
50262
|
+
*
|
|
50263
|
+
* A CACHED read by default: the cloud shadow answers for a robot on its dock
|
|
50264
|
+
* without waking it, which is the whole point of asking before deciding how
|
|
50265
|
+
* to stream. Pass `{ live: true }` when the answer must be current — while a
|
|
50266
|
+
* switch settles, for instance.
|
|
50267
|
+
*/
|
|
50268
|
+
async readVideoVendorStatus(opts) {
|
|
50269
|
+
const props = [{
|
|
50270
|
+
siid: MONITOR_SIID,
|
|
50271
|
+
piid: MONITOR_PIID.VIDEO_VENDOR_STATUS
|
|
50272
|
+
}];
|
|
50273
|
+
return parseVideoVendorStatus((opts?.live === true ? await this.refreshProperties(props) : await this.refreshCachedProperties(props))[0]?.value);
|
|
50274
|
+
}
|
|
50275
|
+
/**
|
|
50276
|
+
* Move this robot onto a video backend, and WAIT until it is really there.
|
|
50277
|
+
*
|
|
50278
|
+
* The two backends are not interchangeable: measured on an X50 on
|
|
50279
|
+
* 2026-09-16, a robot on `tx` offers only Tencent's proprietary UDP P2P
|
|
50280
|
+
* plane (`getRtcInfo` answers 404), while the same robot on `ali` streams
|
|
50281
|
+
* through a plain RTMP relay this library implements end to end. So which
|
|
50282
|
+
* one it sits on decides whether it can be streamed at all.
|
|
50283
|
+
*
|
|
50284
|
+
* The device reports the NEW vendor before its SDK is up, which is why the
|
|
50285
|
+
* app polls rather than trusting the action's return — and why this resolves
|
|
50286
|
+
* on `initStatus === 1` and not a moment earlier. A half-switched robot
|
|
50287
|
+
* answers questions about a backend it cannot yet serve.
|
|
50288
|
+
*
|
|
50289
|
+
* Only meaningful on a dual-vendor device (`videoDynamicVendor`, with the
|
|
50290
|
+
* target in `defaultVendors`); a device that cannot move simply never
|
|
50291
|
+
* settles, and this reports that as a timeout rather than a silent success.
|
|
50292
|
+
*/
|
|
50293
|
+
async setVideoVendor(vendor, opts) {
|
|
50294
|
+
const already = await this.readVideoVendorStatus({ live: true });
|
|
50295
|
+
if (vendorSwitchSettled(already, vendor)) return already;
|
|
50296
|
+
const session = makeMonitorSession(this.currentSession().uid);
|
|
50297
|
+
await this.callAction(MONITOR_SIID, MONITOR_AIID.VIDEO_VENDOR, [buildActionInput(MONITOR_PIID.VIDEO_VENDOR_STATUS, videoVendorSwitchParams(vendor), session)]);
|
|
50298
|
+
const every = opts?.pollIntervalMs ?? 5e3;
|
|
50299
|
+
const deadline = Date.now() + (opts?.timeoutMs ?? every * 10);
|
|
50300
|
+
let last = already;
|
|
50301
|
+
while (Date.now() < deadline) {
|
|
50302
|
+
await new Promise((r) => setTimeout(r, every));
|
|
50303
|
+
last = await this.readVideoVendorStatus({ live: true });
|
|
50304
|
+
if (vendorSwitchSettled(last, vendor)) return last;
|
|
50305
|
+
}
|
|
50306
|
+
throw new DreameError(`video vendor did not settle on "${vendor}" within the timeout (last seen: vendor=${last.vendor ?? "unknown"}, initStatus=${String(last.initStatus)})`);
|
|
50307
|
+
}
|
|
50204
50308
|
/** Props worth seeding on start() / polling — exported for the facade. */
|
|
50205
50309
|
static DEFAULT_PROPS = [
|
|
50206
50310
|
VACUUM_PROP.STATE,
|
|
@@ -66169,6 +66273,107 @@ var ExposeInputSchema = object({
|
|
|
66169
66273
|
});
|
|
66170
66274
|
var UnexposeInputSchema = object({ deviceId: string() });
|
|
66171
66275
|
method(_void(), DeviceExportStatusSchema), method(_void(), array(DeviceKindSchema)), method(_void(), array(ExposedDeviceSchema)), method(ExposeInputSchema, _void(), { kind: "mutation" }), method(UnexposeInputSchema, _void(), { kind: "mutation" });
|
|
66276
|
+
/** Wire/storage schema for one icon id. */
|
|
66277
|
+
var LocationIconIdSchema = _enum([
|
|
66278
|
+
"map-pin",
|
|
66279
|
+
"house",
|
|
66280
|
+
"door-open",
|
|
66281
|
+
"sofa",
|
|
66282
|
+
"utensils",
|
|
66283
|
+
"bed-double",
|
|
66284
|
+
"bath",
|
|
66285
|
+
"briefcase",
|
|
66286
|
+
"car",
|
|
66287
|
+
"circle-parking",
|
|
66288
|
+
"route",
|
|
66289
|
+
"trees",
|
|
66290
|
+
"umbrella",
|
|
66291
|
+
"waves-ladder",
|
|
66292
|
+
"fence",
|
|
66293
|
+
"package",
|
|
66294
|
+
"warehouse",
|
|
66295
|
+
"store",
|
|
66296
|
+
"sun",
|
|
66297
|
+
"radar"
|
|
66298
|
+
]);
|
|
66299
|
+
/**
|
|
66300
|
+
* One (location, icon) assignment as it travels.
|
|
66301
|
+
*
|
|
66302
|
+
* `locationKey` is ALWAYS {@link locationIconKey} of the label — never the
|
|
66303
|
+
* operator's casing. The registry matches labels trimmed + case-insensitively
|
|
66304
|
+
* everywhere (`addLocation`, `removeLocation`, `renameLocation`), and a
|
|
66305
|
+
* case-only rename (`cucina` → `Cucina`) is a real edit this repo supports; a
|
|
66306
|
+
* map keyed on the displayed casing would orphan its entry on exactly that
|
|
66307
|
+
* edit. Consumers join by keying their own label through `locationIconKey`.
|
|
66308
|
+
*/
|
|
66309
|
+
var LocationIconAssignmentSchema = object({
|
|
66310
|
+
locationKey: string(),
|
|
66311
|
+
icon: LocationIconIdSchema
|
|
66312
|
+
});
|
|
66313
|
+
/**
|
|
66314
|
+
* One (location, description) assignment as it travels.
|
|
66315
|
+
*
|
|
66316
|
+
* Same key discipline as {@link LocationIconAssignmentSchema}, and for the same
|
|
66317
|
+
* reason: this lived for a while in the post-analysis addon's own settings,
|
|
66318
|
+
* keyed by a HAND-TYPED label matched exactly and case-sensitively. A rename in
|
|
66319
|
+
* the Locations page carried the icon and silently orphaned the description —
|
|
66320
|
+
* two authorities for one thing, joined by two different rules. Now both hang
|
|
66321
|
+
* off the location itself, on `locationIconKey`.
|
|
66322
|
+
*/
|
|
66323
|
+
var LocationDescriptionAssignmentSchema = object({
|
|
66324
|
+
locationKey: string(),
|
|
66325
|
+
description: string()
|
|
66326
|
+
});
|
|
66327
|
+
/**
|
|
66328
|
+
* A SITE ZONE — a named part of the property that several locations belong to.
|
|
66329
|
+
*
|
|
66330
|
+
* "Zona notte" holds every bedroom; "Zona giorno" the living room and the
|
|
66331
|
+
* kitchen. A camera belongs to exactly one location, and a location to at most
|
|
66332
|
+
* one zone, so a camera's zone is derived and never stored on the device: the
|
|
66333
|
+
* whole point of this entity is that grouping rooms touches no device row.
|
|
66334
|
+
*
|
|
66335
|
+
* ## Why not `zone`
|
|
66336
|
+
*
|
|
66337
|
+
* Because `zone` is already the most loaded word in this product: 612 source
|
|
66338
|
+
* files and 24 Italian strings where it means a DETECTION zone drawn on a
|
|
66339
|
+
* camera. The two are unrelated — one is a polygon in a frame, the other is a
|
|
66340
|
+
* set of rooms — and a reader who confuses them will look for a camera's
|
|
66341
|
+
* bedrooms. So the code says `siteZone` everywhere, and the UI frees the plain
|
|
66342
|
+
* word by calling the drawn one what it actually is: a detection zone.
|
|
66343
|
+
*
|
|
66344
|
+
* ## The key
|
|
66345
|
+
*
|
|
66346
|
+
* `siteZoneKey(label)` — trimmed, lower-cased, the SAME derivation locations
|
|
66347
|
+
* use (`locationIconKey`). Membership and the icon both hang off it, so a
|
|
66348
|
+
* case-only rename keeps them, which is exactly the orphaning that cost us a
|
|
66349
|
+
* silently-dropped location description.
|
|
66350
|
+
*
|
|
66351
|
+
* The icon vocabulary is the location one, unchanged: a zone is drawn on the
|
|
66352
|
+
* same two surfaces by the same glyph map, so a second vocabulary would be a
|
|
66353
|
+
* second thing to keep drawable (`scripts/check-location-icons-drawable.ts`).
|
|
66354
|
+
*/
|
|
66355
|
+
/**
|
|
66356
|
+
* One zone as it travels: its key, the label the operator typed, its icon and
|
|
66357
|
+
* the locations inside it.
|
|
66358
|
+
*
|
|
66359
|
+
* The LABEL is carried alongside the key because the key is lossy on purpose —
|
|
66360
|
+
* a surface must show "Zona notte", not "zona notte". Locations are carried
|
|
66361
|
+
* here, rather than left to a second call, because every consumer wants both
|
|
66362
|
+
* at once and a viewer that asked twice could render a zone whose membership
|
|
66363
|
+
* it has not fetched yet.
|
|
66364
|
+
*
|
|
66365
|
+
* `icon: null` means nobody chose one, which is NOT the fallback pin: only one
|
|
66366
|
+
* of those two is a choice, and a picker has to draw them differently.
|
|
66367
|
+
*/
|
|
66368
|
+
var SiteZoneSchema = object({
|
|
66369
|
+
zoneKey: string(),
|
|
66370
|
+
label: string(),
|
|
66371
|
+
icon: LocationIconIdSchema.nullable(),
|
|
66372
|
+
/** Folded location keys, sorted. A location listed here need not be
|
|
66373
|
+
* REGISTERED — devices carry labels nobody registered, and those rooms are
|
|
66374
|
+
* the ones that most want grouping. */
|
|
66375
|
+
locationKeys: array(string())
|
|
66376
|
+
});
|
|
66172
66377
|
var ProviderStatusSchema = object({
|
|
66173
66378
|
connected: boolean(),
|
|
66174
66379
|
deviceCount: number(),
|
|
@@ -66761,6 +66966,39 @@ method(object({
|
|
|
66761
66966
|
}), object({ moved: number() }), {
|
|
66762
66967
|
kind: "mutation",
|
|
66763
66968
|
auth: "admin"
|
|
66969
|
+
}), method(_void(), array(LocationIconAssignmentSchema)), method(object({
|
|
66970
|
+
location: string(),
|
|
66971
|
+
icon: LocationIconIdSchema.nullable()
|
|
66972
|
+
}), _void(), {
|
|
66973
|
+
kind: "mutation",
|
|
66974
|
+
auth: "admin"
|
|
66975
|
+
}), method(_void(), array(LocationDescriptionAssignmentSchema)), method(object({
|
|
66976
|
+
location: string(),
|
|
66977
|
+
description: string().max(400).nullable()
|
|
66978
|
+
}), _void(), {
|
|
66979
|
+
kind: "mutation",
|
|
66980
|
+
auth: "admin"
|
|
66981
|
+
}), method(_void(), array(SiteZoneSchema)), method(object({
|
|
66982
|
+
name: string(),
|
|
66983
|
+
icon: LocationIconIdSchema.nullable().optional()
|
|
66984
|
+
}), _void(), {
|
|
66985
|
+
kind: "mutation",
|
|
66986
|
+
auth: "admin"
|
|
66987
|
+
}), method(object({ name: string() }), _void(), {
|
|
66988
|
+
kind: "mutation",
|
|
66989
|
+
auth: "admin"
|
|
66990
|
+
}), method(object({
|
|
66991
|
+
from: string(),
|
|
66992
|
+
to: string()
|
|
66993
|
+
}), _void(), {
|
|
66994
|
+
kind: "mutation",
|
|
66995
|
+
auth: "admin"
|
|
66996
|
+
}), method(object({
|
|
66997
|
+
location: string(),
|
|
66998
|
+
zone: string().nullable()
|
|
66999
|
+
}), _void(), {
|
|
67000
|
+
kind: "mutation",
|
|
67001
|
+
auth: "admin"
|
|
66764
67002
|
}), method(object({
|
|
66765
67003
|
deviceId: number(),
|
|
66766
67004
|
disabled: boolean()
|
|
@@ -89844,6 +90082,18 @@ Object.freeze({
|
|
|
89844
90082
|
addonId: null,
|
|
89845
90083
|
access: "view"
|
|
89846
90084
|
},
|
|
90085
|
+
"deviceManager.listLocationDescriptions": {
|
|
90086
|
+
capName: "device-manager",
|
|
90087
|
+
capScope: "system",
|
|
90088
|
+
addonId: null,
|
|
90089
|
+
access: "view"
|
|
90090
|
+
},
|
|
90091
|
+
"deviceManager.listLocationIcons": {
|
|
90092
|
+
capName: "device-manager",
|
|
90093
|
+
capScope: "system",
|
|
90094
|
+
addonId: null,
|
|
90095
|
+
access: "view"
|
|
90096
|
+
},
|
|
89847
90097
|
"deviceManager.listLocations": {
|
|
89848
90098
|
capName: "device-manager",
|
|
89849
90099
|
capScope: "system",
|
|
@@ -89856,6 +90106,12 @@ Object.freeze({
|
|
|
89856
90106
|
addonId: null,
|
|
89857
90107
|
access: "view"
|
|
89858
90108
|
},
|
|
90109
|
+
"deviceManager.listSiteZones": {
|
|
90110
|
+
capName: "device-manager",
|
|
90111
|
+
capScope: "system",
|
|
90112
|
+
addonId: null,
|
|
90113
|
+
access: "view"
|
|
90114
|
+
},
|
|
89859
90115
|
"deviceManager.listWrappersForCap": {
|
|
89860
90116
|
capName: "device-manager",
|
|
89861
90117
|
capScope: "system",
|
|
@@ -89940,12 +90196,24 @@ Object.freeze({
|
|
|
89940
90196
|
addonId: null,
|
|
89941
90197
|
access: "delete"
|
|
89942
90198
|
},
|
|
90199
|
+
"deviceManager.removeSiteZone": {
|
|
90200
|
+
capName: "device-manager",
|
|
90201
|
+
capScope: "system",
|
|
90202
|
+
addonId: null,
|
|
90203
|
+
access: "delete"
|
|
90204
|
+
},
|
|
89943
90205
|
"deviceManager.renameLocation": {
|
|
89944
90206
|
capName: "device-manager",
|
|
89945
90207
|
capScope: "system",
|
|
89946
90208
|
addonId: null,
|
|
89947
90209
|
access: "create"
|
|
89948
90210
|
},
|
|
90211
|
+
"deviceManager.renameSiteZone": {
|
|
90212
|
+
capName: "device-manager",
|
|
90213
|
+
capScope: "system",
|
|
90214
|
+
addonId: null,
|
|
90215
|
+
access: "create"
|
|
90216
|
+
},
|
|
89949
90217
|
"deviceManager.runDeviceAction": {
|
|
89950
90218
|
capName: "device-manager",
|
|
89951
90219
|
capScope: "system",
|
|
@@ -89988,6 +90256,24 @@ Object.freeze({
|
|
|
89988
90256
|
addonId: null,
|
|
89989
90257
|
access: "create"
|
|
89990
90258
|
},
|
|
90259
|
+
"deviceManager.setLocationDescription": {
|
|
90260
|
+
capName: "device-manager",
|
|
90261
|
+
capScope: "system",
|
|
90262
|
+
addonId: null,
|
|
90263
|
+
access: "create"
|
|
90264
|
+
},
|
|
90265
|
+
"deviceManager.setLocationIcon": {
|
|
90266
|
+
capName: "device-manager",
|
|
90267
|
+
capScope: "system",
|
|
90268
|
+
addonId: null,
|
|
90269
|
+
access: "create"
|
|
90270
|
+
},
|
|
90271
|
+
"deviceManager.setLocationZone": {
|
|
90272
|
+
capName: "device-manager",
|
|
90273
|
+
capScope: "system",
|
|
90274
|
+
addonId: null,
|
|
90275
|
+
access: "create"
|
|
90276
|
+
},
|
|
89991
90277
|
"deviceManager.setMetadata": {
|
|
89992
90278
|
capName: "device-manager",
|
|
89993
90279
|
capScope: "system",
|
|
@@ -90018,6 +90304,12 @@ Object.freeze({
|
|
|
90018
90304
|
addonId: null,
|
|
90019
90305
|
access: "create"
|
|
90020
90306
|
},
|
|
90307
|
+
"deviceManager.setSiteZone": {
|
|
90308
|
+
capName: "device-manager",
|
|
90309
|
+
capScope: "system",
|
|
90310
|
+
addonId: null,
|
|
90311
|
+
access: "create"
|
|
90312
|
+
},
|
|
90021
90313
|
"deviceManager.setStreamProfileMap": {
|
|
90022
90314
|
capName: "device-manager",
|
|
90023
90315
|
capScope: "system",
|
|
@@ -98763,6 +99055,15 @@ var dreameChildSchema = object({
|
|
|
98763
99055
|
/** Privacy PIN set at pairing — required to wake a coded camera (camera child only). */
|
|
98764
99056
|
navigationAccessCode: string().optional(),
|
|
98765
99057
|
/**
|
|
99058
|
+
* The video backend the robot should sit on (camera child only).
|
|
99059
|
+
*
|
|
99060
|
+
* Not a preference the addon enforces: it is written THROUGH to the device
|
|
99061
|
+
* by `applySettingsPatch`, and the device is the authority on where it
|
|
99062
|
+
* actually is. Stored so the settings form has a value to show before the
|
|
99063
|
+
* first read answers.
|
|
99064
|
+
*/
|
|
99065
|
+
videoVendor: _enum(["ali", "tx"]).optional(),
|
|
99066
|
+
/**
|
|
98766
99067
|
* ms-epoch of the ONE-SHOT recording seed (camera child only, D380).
|
|
98767
99068
|
*
|
|
98768
99069
|
* A MARK, never the value: it says this camera was seeded, which stays true
|
|
@@ -145673,6 +145974,58 @@ function navigationSlice(prev, patch, now) {
|
|
|
145673
145974
|
* for two separate things (admin UI: two tabs; viewer: one panel that shows the
|
|
145674
145975
|
* navigation controls when the device has the cap).
|
|
145675
145976
|
*/
|
|
145977
|
+
/**
|
|
145978
|
+
* The camera child's settings form.
|
|
145979
|
+
*
|
|
145980
|
+
* A PURE export so the shape can be asserted without constructing a device —
|
|
145981
|
+
* the same discipline `navigationSlice` follows in this file. What matters
|
|
145982
|
+
* here is that the backend selector offers exactly the two vendors this
|
|
145983
|
+
* library can stream, in the order that puts the working one first.
|
|
145984
|
+
*/
|
|
145985
|
+
var CAMERA_SETTINGS_SCHEMA = { sections: [{
|
|
145986
|
+
id: "camera-backend",
|
|
145987
|
+
title: "Video backend",
|
|
145988
|
+
description: "Which cloud video service this robot streams through. The two are not interchangeable: only the Aliyun path is implemented here — a robot on Tencent can be seen in the Dreame app but not by CamStack, because Tencent offers it a proprietary P2P plane we do not speak. Changing this asks the ROBOT to switch and waits until its SDK reports ready; the robot is the authority on where it actually is.",
|
|
145989
|
+
fields: [{
|
|
145990
|
+
type: "select",
|
|
145991
|
+
key: "videoVendor",
|
|
145992
|
+
label: "Backend",
|
|
145993
|
+
description: "Aliyun streams through a plain relay and works today. Tencent is offered so the other path can be tested once it exists.",
|
|
145994
|
+
options: [{
|
|
145995
|
+
value: "ali",
|
|
145996
|
+
label: "Aliyun LinkVisual (supported)"
|
|
145997
|
+
}, {
|
|
145998
|
+
value: "tx",
|
|
145999
|
+
label: "Tencent IoT-Video (not implemented yet)"
|
|
146000
|
+
}]
|
|
146001
|
+
}]
|
|
146002
|
+
}, {
|
|
146003
|
+
id: "camera-access",
|
|
146004
|
+
title: "Camera access",
|
|
146005
|
+
description: "The privacy code set when the robot camera was paired. The robot refuses to open its camera without it.",
|
|
146006
|
+
fields: [{
|
|
146007
|
+
type: "password",
|
|
146008
|
+
key: "navigationAccessCode",
|
|
146009
|
+
label: "Camera access code",
|
|
146010
|
+
showToggle: true
|
|
146011
|
+
}]
|
|
146012
|
+
}] };
|
|
146013
|
+
/**
|
|
146014
|
+
* The values the form opens with.
|
|
146015
|
+
*
|
|
146016
|
+
* The DEVICE wins: it is the authority on which backend it is on, and the
|
|
146017
|
+
* stored preference is only what the operator last asked for. Showing the
|
|
146018
|
+
* preference while the robot sits elsewhere would tell the operator the switch
|
|
146019
|
+
* had worked when it had not. Before the first read has answered there is
|
|
146020
|
+
* nothing to show but the preference, and an empty string when there is
|
|
146021
|
+
* neither — unknown, never a default that looks like a fact (D393).
|
|
146022
|
+
*/
|
|
146023
|
+
function cameraSettingsValues(input) {
|
|
146024
|
+
return {
|
|
146025
|
+
navigationAccessCode: input.accessCode ?? "",
|
|
146026
|
+
videoVendor: input.vendorNow ?? input.storedVendor ?? ""
|
|
146027
|
+
};
|
|
146028
|
+
}
|
|
145676
146029
|
var DreameCameraDevice = class extends DreameChildDevice {
|
|
145677
146030
|
features = [DeviceFeature.BatteryOperated];
|
|
145678
146031
|
constructor(ctx) {
|
|
@@ -145748,6 +146101,31 @@ var DreameCameraDevice = class extends DreameChildDevice {
|
|
|
145748
146101
|
});
|
|
145749
146102
|
}
|
|
145750
146103
|
/** One reader of the robot's link, shared in shape with the vacuum's. */
|
|
146104
|
+
/**
|
|
146105
|
+
* Where the robot says its video backend is, as last read.
|
|
146106
|
+
*
|
|
146107
|
+
* The settings schema is built synchronously and this is a device read, so
|
|
146108
|
+
* it is cached and refreshed in the background when the form is opened — the
|
|
146109
|
+
* serve-and-revalidate discipline D224 describes.
|
|
146110
|
+
*
|
|
146111
|
+
* `null` means never read, which the form shows as the stored preference
|
|
146112
|
+
* instead — unknown is not the same fact as "on ali" (D393).
|
|
146113
|
+
*/
|
|
146114
|
+
vendorNow = null;
|
|
146115
|
+
/** Refresh {@link vendorNow}. Never throws: the settings page must open
|
|
146116
|
+
* whether or not the robot answered. */
|
|
146117
|
+
async refreshVendorNow() {
|
|
146118
|
+
const handle = this.resolveHandle();
|
|
146119
|
+
if (!(handle instanceof VacuumDevice)) return;
|
|
146120
|
+
try {
|
|
146121
|
+
this.vendorNow = (await handle.readVideoVendorStatus({ live: true })).vendor;
|
|
146122
|
+
} catch (err) {
|
|
146123
|
+
this.ctx.logger.debug("robot camera: video backend read failed", {
|
|
146124
|
+
tags: { deviceId: this.id },
|
|
146125
|
+
meta: { error: errMsg(err) }
|
|
146126
|
+
});
|
|
146127
|
+
}
|
|
146128
|
+
}
|
|
145751
146129
|
networkLink = new DreameNetworkLinkMonitor({
|
|
145752
146130
|
deviceId: this.id,
|
|
145753
146131
|
logger: this.ctx.logger,
|
|
@@ -146074,20 +146452,58 @@ var DreameCameraDevice = class extends DreameChildDevice {
|
|
|
146074
146452
|
* offers is a setting that does not exist.
|
|
146075
146453
|
*/
|
|
146076
146454
|
getSettingsUISchema() {
|
|
146077
|
-
|
|
146078
|
-
|
|
146079
|
-
|
|
146080
|
-
|
|
146081
|
-
|
|
146082
|
-
|
|
146083
|
-
key: "navigationAccessCode",
|
|
146084
|
-
label: "Camera access code",
|
|
146085
|
-
showToggle: true
|
|
146086
|
-
}]
|
|
146087
|
-
}] }, { navigationAccessCode: this.config.get("navigationAccessCode") ?? "" });
|
|
146455
|
+
this.refreshVendorNow();
|
|
146456
|
+
return hydrateSchema(CAMERA_SETTINGS_SCHEMA, cameraSettingsValues({
|
|
146457
|
+
accessCode: this.config.get("navigationAccessCode"),
|
|
146458
|
+
vendorNow: this.vendorNow,
|
|
146459
|
+
storedVendor: this.config.get("videoVendor")
|
|
146460
|
+
}));
|
|
146088
146461
|
}
|
|
146089
146462
|
async applySettingsPatch(patch) {
|
|
146463
|
+
this.ctx.logger.info("robot camera: settings patch received", {
|
|
146464
|
+
tags: { deviceId: this.id },
|
|
146465
|
+
meta: {
|
|
146466
|
+
keys: Object.keys(patch).sort(),
|
|
146467
|
+
vendorNow: this.vendorNow
|
|
146468
|
+
}
|
|
146469
|
+
});
|
|
146090
146470
|
await super.applySettingsPatch(patch);
|
|
146471
|
+
const wanted = patch["videoVendor"];
|
|
146472
|
+
if ((wanted === "ali" || wanted === "tx") && wanted !== this.vendorNow) {
|
|
146473
|
+
const handle = this.resolveHandle();
|
|
146474
|
+
if (handle instanceof VacuumDevice) {
|
|
146475
|
+
await this.closeStream("removed");
|
|
146476
|
+
this.controller = null;
|
|
146477
|
+
this.controllerHandle = null;
|
|
146478
|
+
this.ctx.logger.info("robot camera: switching video backend", {
|
|
146479
|
+
tags: { deviceId: this.id },
|
|
146480
|
+
meta: {
|
|
146481
|
+
from: this.vendorNow,
|
|
146482
|
+
to: wanted
|
|
146483
|
+
}
|
|
146484
|
+
});
|
|
146485
|
+
try {
|
|
146486
|
+
const settled = await handle.setVideoVendor(wanted);
|
|
146487
|
+
this.vendorNow = settled.vendor;
|
|
146488
|
+
this.ctx.logger.info("robot camera: video backend switched", {
|
|
146489
|
+
tags: { deviceId: this.id },
|
|
146490
|
+
meta: {
|
|
146491
|
+
vendor: settled.vendor,
|
|
146492
|
+
initStatus: settled.initStatus
|
|
146493
|
+
}
|
|
146494
|
+
});
|
|
146495
|
+
} catch (err) {
|
|
146496
|
+
this.ctx.logger.warn("robot camera: video backend switch refused", {
|
|
146497
|
+
tags: { deviceId: this.id },
|
|
146498
|
+
meta: {
|
|
146499
|
+
to: wanted,
|
|
146500
|
+
error: errMsg(err)
|
|
146501
|
+
}
|
|
146502
|
+
});
|
|
146503
|
+
throw err;
|
|
146504
|
+
}
|
|
146505
|
+
}
|
|
146506
|
+
}
|
|
146091
146507
|
if (!("navigationAccessCode" in patch)) return;
|
|
146092
146508
|
await this.closeStream("removed");
|
|
146093
146509
|
this.controller = null;
|
package/dist/addon.mjs
CHANGED
|
@@ -46667,6 +46667,22 @@ objectType({
|
|
|
46667
46667
|
msg: stringType().nullish(),
|
|
46668
46668
|
data: objectType({ data: objectType({ familyId: stringType() }).passthrough() }).passthrough()
|
|
46669
46669
|
}).passthrough();
|
|
46670
|
+
objectType({
|
|
46671
|
+
code: numberType().optional(),
|
|
46672
|
+
msg: stringType().nullish(),
|
|
46673
|
+
data: objectType({ data: objectType({
|
|
46674
|
+
productId: stringType(),
|
|
46675
|
+
deviceName: stringType(),
|
|
46676
|
+
deviceId: stringType().optional(),
|
|
46677
|
+
secretId: stringType().optional(),
|
|
46678
|
+
secretKey: stringType().optional()
|
|
46679
|
+
}).passthrough() }).passthrough()
|
|
46680
|
+
}).passthrough();
|
|
46681
|
+
objectType({
|
|
46682
|
+
code: numberType().optional(),
|
|
46683
|
+
msg: stringType().nullish(),
|
|
46684
|
+
data: objectType({ data: objectType({ p2pInfo: stringType() }).passthrough() }).passthrough()
|
|
46685
|
+
}).passthrough();
|
|
46670
46686
|
var DeviceVideoInfoSchema = objectType({
|
|
46671
46687
|
model: stringType().optional(),
|
|
46672
46688
|
displayName: stringType().optional(),
|
|
@@ -47126,6 +47142,15 @@ var DreameVideoSession = class {
|
|
|
47126
47142
|
return (await this.getStreamInfo(iotId, { encrypted: false })).relayUrl;
|
|
47127
47143
|
}
|
|
47128
47144
|
};
|
|
47145
|
+
function explainNoCameraChannel(profile) {
|
|
47146
|
+
if (profile.iotId) return null;
|
|
47147
|
+
const who = `${profile.model || "unknown model"} (${profile.did || "no did"})`;
|
|
47148
|
+
if (!profile.videoCapable) return `${who} is not a camera: the cloud lists no video permit and no supported video vendor for it, so there is no channel to open.`;
|
|
47149
|
+
if (!profile.online) return `${who} is a camera but is OFFLINE. The video binding is published by the device itself, so there is nothing to read until it connects \u2014 this is not a provisioning fault.`;
|
|
47150
|
+
const supports = profile.supportedVendors.length > 0 ? profile.supportedVendors.join(", ") : "none";
|
|
47151
|
+
if (profile.currentVendor === "tx") return `${who} is a camera and online, but is provisioned on the TENCENT video vendor (vendor: tx; supports: ${supports}), and this library implements only the Aliyun LinkVisual path \u2014 the channel id it needs lives on Aliyun and a tx device has none. Re-pairing or re-opening the camera in the app will not change this.`;
|
|
47152
|
+
return `${who} is a camera and online, but has no video channel yet (vendor: ${profile.currentVendor ?? "none"}; supports: ${supports}). The device has not bound to the video service \u2014 open its camera once in the Dreame app to create the binding, then retry.`;
|
|
47153
|
+
}
|
|
47129
47154
|
var MONITOR_SIID = 10001;
|
|
47130
47155
|
var MONITOR_AIID = {
|
|
47131
47156
|
/** Stream lifecycle + keep-alive (monitor start/end, keep_alive). */
|
|
@@ -47319,6 +47344,33 @@ function firstOutValue(res) {
|
|
|
47319
47344
|
const out = r?.out ?? r?.result?.out;
|
|
47320
47345
|
return Array.isArray(out) && out.length > 0 ? out[0]?.value : void 0;
|
|
47321
47346
|
}
|
|
47347
|
+
function videoVendorSwitchParams(vendor) {
|
|
47348
|
+
return { vendor };
|
|
47349
|
+
}
|
|
47350
|
+
function parseVideoVendorStatus(raw) {
|
|
47351
|
+
const text = typeof raw === "string" ? raw : typeof raw === "object" && raw !== null ? null : null;
|
|
47352
|
+
const source = text === null ? raw : safeJson(text);
|
|
47353
|
+
if (typeof source !== "object" || source === null) return {
|
|
47354
|
+
vendor: null,
|
|
47355
|
+
initStatus: null
|
|
47356
|
+
};
|
|
47357
|
+
const v = Reflect.get(source, "vendor");
|
|
47358
|
+
const s = Reflect.get(source, "initStatus");
|
|
47359
|
+
return {
|
|
47360
|
+
vendor: v === "ali" || v === "tx" ? v : null,
|
|
47361
|
+
initStatus: typeof s === "number" ? s : null
|
|
47362
|
+
};
|
|
47363
|
+
}
|
|
47364
|
+
function safeJson(text) {
|
|
47365
|
+
try {
|
|
47366
|
+
return JSON.parse(text);
|
|
47367
|
+
} catch {
|
|
47368
|
+
return null;
|
|
47369
|
+
}
|
|
47370
|
+
}
|
|
47371
|
+
function vendorSwitchSettled(status, wanted) {
|
|
47372
|
+
return status.vendor === wanted && status.initStatus === 1;
|
|
47373
|
+
}
|
|
47322
47374
|
var DEFAULT_KEEP_ALIVE_MS = 1e4;
|
|
47323
47375
|
var DEFAULT_AREA = "4";
|
|
47324
47376
|
var DreameCameraController = class {
|
|
@@ -50178,12 +50230,16 @@ var VacuumDevice = class _VacuumDevice extends BaseDevice$1 {
|
|
|
50178
50230
|
session,
|
|
50179
50231
|
region
|
|
50180
50232
|
});
|
|
50181
|
-
|
|
50182
|
-
|
|
50183
|
-
|
|
50184
|
-
|
|
50185
|
-
|
|
50186
|
-
|
|
50233
|
+
let iotId = opts?.iotId ?? null;
|
|
50234
|
+
if (!iotId) {
|
|
50235
|
+
const profile = await getDeviceVideoProfile({
|
|
50236
|
+
session,
|
|
50237
|
+
region,
|
|
50238
|
+
did: this.deviceId
|
|
50239
|
+
});
|
|
50240
|
+
iotId = profile.iotId;
|
|
50241
|
+
if (!iotId) throw new DreameError(explainNoCameraChannel(profile) ?? "device has no video channel");
|
|
50242
|
+
}
|
|
50187
50243
|
return new DreameCameraController({
|
|
50188
50244
|
device: this,
|
|
50189
50245
|
relay,
|
|
@@ -50198,6 +50254,54 @@ var VacuumDevice = class _VacuumDevice extends BaseDevice$1 {
|
|
|
50198
50254
|
...opts?.log ? { log: opts.log } : {}
|
|
50199
50255
|
});
|
|
50200
50256
|
}
|
|
50257
|
+
/**
|
|
50258
|
+
* What video backend this robot is on, and whether that backend's SDK is up.
|
|
50259
|
+
*
|
|
50260
|
+
* A CACHED read by default: the cloud shadow answers for a robot on its dock
|
|
50261
|
+
* without waking it, which is the whole point of asking before deciding how
|
|
50262
|
+
* to stream. Pass `{ live: true }` when the answer must be current — while a
|
|
50263
|
+
* switch settles, for instance.
|
|
50264
|
+
*/
|
|
50265
|
+
async readVideoVendorStatus(opts) {
|
|
50266
|
+
const props = [{
|
|
50267
|
+
siid: MONITOR_SIID,
|
|
50268
|
+
piid: MONITOR_PIID.VIDEO_VENDOR_STATUS
|
|
50269
|
+
}];
|
|
50270
|
+
return parseVideoVendorStatus((opts?.live === true ? await this.refreshProperties(props) : await this.refreshCachedProperties(props))[0]?.value);
|
|
50271
|
+
}
|
|
50272
|
+
/**
|
|
50273
|
+
* Move this robot onto a video backend, and WAIT until it is really there.
|
|
50274
|
+
*
|
|
50275
|
+
* The two backends are not interchangeable: measured on an X50 on
|
|
50276
|
+
* 2026-09-16, a robot on `tx` offers only Tencent's proprietary UDP P2P
|
|
50277
|
+
* plane (`getRtcInfo` answers 404), while the same robot on `ali` streams
|
|
50278
|
+
* through a plain RTMP relay this library implements end to end. So which
|
|
50279
|
+
* one it sits on decides whether it can be streamed at all.
|
|
50280
|
+
*
|
|
50281
|
+
* The device reports the NEW vendor before its SDK is up, which is why the
|
|
50282
|
+
* app polls rather than trusting the action's return — and why this resolves
|
|
50283
|
+
* on `initStatus === 1` and not a moment earlier. A half-switched robot
|
|
50284
|
+
* answers questions about a backend it cannot yet serve.
|
|
50285
|
+
*
|
|
50286
|
+
* Only meaningful on a dual-vendor device (`videoDynamicVendor`, with the
|
|
50287
|
+
* target in `defaultVendors`); a device that cannot move simply never
|
|
50288
|
+
* settles, and this reports that as a timeout rather than a silent success.
|
|
50289
|
+
*/
|
|
50290
|
+
async setVideoVendor(vendor, opts) {
|
|
50291
|
+
const already = await this.readVideoVendorStatus({ live: true });
|
|
50292
|
+
if (vendorSwitchSettled(already, vendor)) return already;
|
|
50293
|
+
const session = makeMonitorSession(this.currentSession().uid);
|
|
50294
|
+
await this.callAction(MONITOR_SIID, MONITOR_AIID.VIDEO_VENDOR, [buildActionInput(MONITOR_PIID.VIDEO_VENDOR_STATUS, videoVendorSwitchParams(vendor), session)]);
|
|
50295
|
+
const every = opts?.pollIntervalMs ?? 5e3;
|
|
50296
|
+
const deadline = Date.now() + (opts?.timeoutMs ?? every * 10);
|
|
50297
|
+
let last = already;
|
|
50298
|
+
while (Date.now() < deadline) {
|
|
50299
|
+
await new Promise((r) => setTimeout(r, every));
|
|
50300
|
+
last = await this.readVideoVendorStatus({ live: true });
|
|
50301
|
+
if (vendorSwitchSettled(last, vendor)) return last;
|
|
50302
|
+
}
|
|
50303
|
+
throw new DreameError(`video vendor did not settle on "${vendor}" within the timeout (last seen: vendor=${last.vendor ?? "unknown"}, initStatus=${String(last.initStatus)})`);
|
|
50304
|
+
}
|
|
50201
50305
|
/** Props worth seeding on start() / polling — exported for the facade. */
|
|
50202
50306
|
static DEFAULT_PROPS = [
|
|
50203
50307
|
VACUUM_PROP.STATE,
|
|
@@ -66166,6 +66270,107 @@ var ExposeInputSchema = object({
|
|
|
66166
66270
|
});
|
|
66167
66271
|
var UnexposeInputSchema = object({ deviceId: string() });
|
|
66168
66272
|
method(_void(), DeviceExportStatusSchema), method(_void(), array(DeviceKindSchema)), method(_void(), array(ExposedDeviceSchema)), method(ExposeInputSchema, _void(), { kind: "mutation" }), method(UnexposeInputSchema, _void(), { kind: "mutation" });
|
|
66273
|
+
/** Wire/storage schema for one icon id. */
|
|
66274
|
+
var LocationIconIdSchema = _enum([
|
|
66275
|
+
"map-pin",
|
|
66276
|
+
"house",
|
|
66277
|
+
"door-open",
|
|
66278
|
+
"sofa",
|
|
66279
|
+
"utensils",
|
|
66280
|
+
"bed-double",
|
|
66281
|
+
"bath",
|
|
66282
|
+
"briefcase",
|
|
66283
|
+
"car",
|
|
66284
|
+
"circle-parking",
|
|
66285
|
+
"route",
|
|
66286
|
+
"trees",
|
|
66287
|
+
"umbrella",
|
|
66288
|
+
"waves-ladder",
|
|
66289
|
+
"fence",
|
|
66290
|
+
"package",
|
|
66291
|
+
"warehouse",
|
|
66292
|
+
"store",
|
|
66293
|
+
"sun",
|
|
66294
|
+
"radar"
|
|
66295
|
+
]);
|
|
66296
|
+
/**
|
|
66297
|
+
* One (location, icon) assignment as it travels.
|
|
66298
|
+
*
|
|
66299
|
+
* `locationKey` is ALWAYS {@link locationIconKey} of the label — never the
|
|
66300
|
+
* operator's casing. The registry matches labels trimmed + case-insensitively
|
|
66301
|
+
* everywhere (`addLocation`, `removeLocation`, `renameLocation`), and a
|
|
66302
|
+
* case-only rename (`cucina` → `Cucina`) is a real edit this repo supports; a
|
|
66303
|
+
* map keyed on the displayed casing would orphan its entry on exactly that
|
|
66304
|
+
* edit. Consumers join by keying their own label through `locationIconKey`.
|
|
66305
|
+
*/
|
|
66306
|
+
var LocationIconAssignmentSchema = object({
|
|
66307
|
+
locationKey: string(),
|
|
66308
|
+
icon: LocationIconIdSchema
|
|
66309
|
+
});
|
|
66310
|
+
/**
|
|
66311
|
+
* One (location, description) assignment as it travels.
|
|
66312
|
+
*
|
|
66313
|
+
* Same key discipline as {@link LocationIconAssignmentSchema}, and for the same
|
|
66314
|
+
* reason: this lived for a while in the post-analysis addon's own settings,
|
|
66315
|
+
* keyed by a HAND-TYPED label matched exactly and case-sensitively. A rename in
|
|
66316
|
+
* the Locations page carried the icon and silently orphaned the description —
|
|
66317
|
+
* two authorities for one thing, joined by two different rules. Now both hang
|
|
66318
|
+
* off the location itself, on `locationIconKey`.
|
|
66319
|
+
*/
|
|
66320
|
+
var LocationDescriptionAssignmentSchema = object({
|
|
66321
|
+
locationKey: string(),
|
|
66322
|
+
description: string()
|
|
66323
|
+
});
|
|
66324
|
+
/**
|
|
66325
|
+
* A SITE ZONE — a named part of the property that several locations belong to.
|
|
66326
|
+
*
|
|
66327
|
+
* "Zona notte" holds every bedroom; "Zona giorno" the living room and the
|
|
66328
|
+
* kitchen. A camera belongs to exactly one location, and a location to at most
|
|
66329
|
+
* one zone, so a camera's zone is derived and never stored on the device: the
|
|
66330
|
+
* whole point of this entity is that grouping rooms touches no device row.
|
|
66331
|
+
*
|
|
66332
|
+
* ## Why not `zone`
|
|
66333
|
+
*
|
|
66334
|
+
* Because `zone` is already the most loaded word in this product: 612 source
|
|
66335
|
+
* files and 24 Italian strings where it means a DETECTION zone drawn on a
|
|
66336
|
+
* camera. The two are unrelated — one is a polygon in a frame, the other is a
|
|
66337
|
+
* set of rooms — and a reader who confuses them will look for a camera's
|
|
66338
|
+
* bedrooms. So the code says `siteZone` everywhere, and the UI frees the plain
|
|
66339
|
+
* word by calling the drawn one what it actually is: a detection zone.
|
|
66340
|
+
*
|
|
66341
|
+
* ## The key
|
|
66342
|
+
*
|
|
66343
|
+
* `siteZoneKey(label)` — trimmed, lower-cased, the SAME derivation locations
|
|
66344
|
+
* use (`locationIconKey`). Membership and the icon both hang off it, so a
|
|
66345
|
+
* case-only rename keeps them, which is exactly the orphaning that cost us a
|
|
66346
|
+
* silently-dropped location description.
|
|
66347
|
+
*
|
|
66348
|
+
* The icon vocabulary is the location one, unchanged: a zone is drawn on the
|
|
66349
|
+
* same two surfaces by the same glyph map, so a second vocabulary would be a
|
|
66350
|
+
* second thing to keep drawable (`scripts/check-location-icons-drawable.ts`).
|
|
66351
|
+
*/
|
|
66352
|
+
/**
|
|
66353
|
+
* One zone as it travels: its key, the label the operator typed, its icon and
|
|
66354
|
+
* the locations inside it.
|
|
66355
|
+
*
|
|
66356
|
+
* The LABEL is carried alongside the key because the key is lossy on purpose —
|
|
66357
|
+
* a surface must show "Zona notte", not "zona notte". Locations are carried
|
|
66358
|
+
* here, rather than left to a second call, because every consumer wants both
|
|
66359
|
+
* at once and a viewer that asked twice could render a zone whose membership
|
|
66360
|
+
* it has not fetched yet.
|
|
66361
|
+
*
|
|
66362
|
+
* `icon: null` means nobody chose one, which is NOT the fallback pin: only one
|
|
66363
|
+
* of those two is a choice, and a picker has to draw them differently.
|
|
66364
|
+
*/
|
|
66365
|
+
var SiteZoneSchema = object({
|
|
66366
|
+
zoneKey: string(),
|
|
66367
|
+
label: string(),
|
|
66368
|
+
icon: LocationIconIdSchema.nullable(),
|
|
66369
|
+
/** Folded location keys, sorted. A location listed here need not be
|
|
66370
|
+
* REGISTERED — devices carry labels nobody registered, and those rooms are
|
|
66371
|
+
* the ones that most want grouping. */
|
|
66372
|
+
locationKeys: array(string())
|
|
66373
|
+
});
|
|
66169
66374
|
var ProviderStatusSchema = object({
|
|
66170
66375
|
connected: boolean(),
|
|
66171
66376
|
deviceCount: number(),
|
|
@@ -66758,6 +66963,39 @@ method(object({
|
|
|
66758
66963
|
}), object({ moved: number() }), {
|
|
66759
66964
|
kind: "mutation",
|
|
66760
66965
|
auth: "admin"
|
|
66966
|
+
}), method(_void(), array(LocationIconAssignmentSchema)), method(object({
|
|
66967
|
+
location: string(),
|
|
66968
|
+
icon: LocationIconIdSchema.nullable()
|
|
66969
|
+
}), _void(), {
|
|
66970
|
+
kind: "mutation",
|
|
66971
|
+
auth: "admin"
|
|
66972
|
+
}), method(_void(), array(LocationDescriptionAssignmentSchema)), method(object({
|
|
66973
|
+
location: string(),
|
|
66974
|
+
description: string().max(400).nullable()
|
|
66975
|
+
}), _void(), {
|
|
66976
|
+
kind: "mutation",
|
|
66977
|
+
auth: "admin"
|
|
66978
|
+
}), method(_void(), array(SiteZoneSchema)), method(object({
|
|
66979
|
+
name: string(),
|
|
66980
|
+
icon: LocationIconIdSchema.nullable().optional()
|
|
66981
|
+
}), _void(), {
|
|
66982
|
+
kind: "mutation",
|
|
66983
|
+
auth: "admin"
|
|
66984
|
+
}), method(object({ name: string() }), _void(), {
|
|
66985
|
+
kind: "mutation",
|
|
66986
|
+
auth: "admin"
|
|
66987
|
+
}), method(object({
|
|
66988
|
+
from: string(),
|
|
66989
|
+
to: string()
|
|
66990
|
+
}), _void(), {
|
|
66991
|
+
kind: "mutation",
|
|
66992
|
+
auth: "admin"
|
|
66993
|
+
}), method(object({
|
|
66994
|
+
location: string(),
|
|
66995
|
+
zone: string().nullable()
|
|
66996
|
+
}), _void(), {
|
|
66997
|
+
kind: "mutation",
|
|
66998
|
+
auth: "admin"
|
|
66761
66999
|
}), method(object({
|
|
66762
67000
|
deviceId: number(),
|
|
66763
67001
|
disabled: boolean()
|
|
@@ -89841,6 +90079,18 @@ Object.freeze({
|
|
|
89841
90079
|
addonId: null,
|
|
89842
90080
|
access: "view"
|
|
89843
90081
|
},
|
|
90082
|
+
"deviceManager.listLocationDescriptions": {
|
|
90083
|
+
capName: "device-manager",
|
|
90084
|
+
capScope: "system",
|
|
90085
|
+
addonId: null,
|
|
90086
|
+
access: "view"
|
|
90087
|
+
},
|
|
90088
|
+
"deviceManager.listLocationIcons": {
|
|
90089
|
+
capName: "device-manager",
|
|
90090
|
+
capScope: "system",
|
|
90091
|
+
addonId: null,
|
|
90092
|
+
access: "view"
|
|
90093
|
+
},
|
|
89844
90094
|
"deviceManager.listLocations": {
|
|
89845
90095
|
capName: "device-manager",
|
|
89846
90096
|
capScope: "system",
|
|
@@ -89853,6 +90103,12 @@ Object.freeze({
|
|
|
89853
90103
|
addonId: null,
|
|
89854
90104
|
access: "view"
|
|
89855
90105
|
},
|
|
90106
|
+
"deviceManager.listSiteZones": {
|
|
90107
|
+
capName: "device-manager",
|
|
90108
|
+
capScope: "system",
|
|
90109
|
+
addonId: null,
|
|
90110
|
+
access: "view"
|
|
90111
|
+
},
|
|
89856
90112
|
"deviceManager.listWrappersForCap": {
|
|
89857
90113
|
capName: "device-manager",
|
|
89858
90114
|
capScope: "system",
|
|
@@ -89937,12 +90193,24 @@ Object.freeze({
|
|
|
89937
90193
|
addonId: null,
|
|
89938
90194
|
access: "delete"
|
|
89939
90195
|
},
|
|
90196
|
+
"deviceManager.removeSiteZone": {
|
|
90197
|
+
capName: "device-manager",
|
|
90198
|
+
capScope: "system",
|
|
90199
|
+
addonId: null,
|
|
90200
|
+
access: "delete"
|
|
90201
|
+
},
|
|
89940
90202
|
"deviceManager.renameLocation": {
|
|
89941
90203
|
capName: "device-manager",
|
|
89942
90204
|
capScope: "system",
|
|
89943
90205
|
addonId: null,
|
|
89944
90206
|
access: "create"
|
|
89945
90207
|
},
|
|
90208
|
+
"deviceManager.renameSiteZone": {
|
|
90209
|
+
capName: "device-manager",
|
|
90210
|
+
capScope: "system",
|
|
90211
|
+
addonId: null,
|
|
90212
|
+
access: "create"
|
|
90213
|
+
},
|
|
89946
90214
|
"deviceManager.runDeviceAction": {
|
|
89947
90215
|
capName: "device-manager",
|
|
89948
90216
|
capScope: "system",
|
|
@@ -89985,6 +90253,24 @@ Object.freeze({
|
|
|
89985
90253
|
addonId: null,
|
|
89986
90254
|
access: "create"
|
|
89987
90255
|
},
|
|
90256
|
+
"deviceManager.setLocationDescription": {
|
|
90257
|
+
capName: "device-manager",
|
|
90258
|
+
capScope: "system",
|
|
90259
|
+
addonId: null,
|
|
90260
|
+
access: "create"
|
|
90261
|
+
},
|
|
90262
|
+
"deviceManager.setLocationIcon": {
|
|
90263
|
+
capName: "device-manager",
|
|
90264
|
+
capScope: "system",
|
|
90265
|
+
addonId: null,
|
|
90266
|
+
access: "create"
|
|
90267
|
+
},
|
|
90268
|
+
"deviceManager.setLocationZone": {
|
|
90269
|
+
capName: "device-manager",
|
|
90270
|
+
capScope: "system",
|
|
90271
|
+
addonId: null,
|
|
90272
|
+
access: "create"
|
|
90273
|
+
},
|
|
89988
90274
|
"deviceManager.setMetadata": {
|
|
89989
90275
|
capName: "device-manager",
|
|
89990
90276
|
capScope: "system",
|
|
@@ -90015,6 +90301,12 @@ Object.freeze({
|
|
|
90015
90301
|
addonId: null,
|
|
90016
90302
|
access: "create"
|
|
90017
90303
|
},
|
|
90304
|
+
"deviceManager.setSiteZone": {
|
|
90305
|
+
capName: "device-manager",
|
|
90306
|
+
capScope: "system",
|
|
90307
|
+
addonId: null,
|
|
90308
|
+
access: "create"
|
|
90309
|
+
},
|
|
90018
90310
|
"deviceManager.setStreamProfileMap": {
|
|
90019
90311
|
capName: "device-manager",
|
|
90020
90312
|
capScope: "system",
|
|
@@ -98760,6 +99052,15 @@ var dreameChildSchema = object({
|
|
|
98760
99052
|
/** Privacy PIN set at pairing — required to wake a coded camera (camera child only). */
|
|
98761
99053
|
navigationAccessCode: string().optional(),
|
|
98762
99054
|
/**
|
|
99055
|
+
* The video backend the robot should sit on (camera child only).
|
|
99056
|
+
*
|
|
99057
|
+
* Not a preference the addon enforces: it is written THROUGH to the device
|
|
99058
|
+
* by `applySettingsPatch`, and the device is the authority on where it
|
|
99059
|
+
* actually is. Stored so the settings form has a value to show before the
|
|
99060
|
+
* first read answers.
|
|
99061
|
+
*/
|
|
99062
|
+
videoVendor: _enum(["ali", "tx"]).optional(),
|
|
99063
|
+
/**
|
|
98763
99064
|
* ms-epoch of the ONE-SHOT recording seed (camera child only, D380).
|
|
98764
99065
|
*
|
|
98765
99066
|
* A MARK, never the value: it says this camera was seeded, which stays true
|
|
@@ -145670,6 +145971,58 @@ function navigationSlice(prev, patch, now) {
|
|
|
145670
145971
|
* for two separate things (admin UI: two tabs; viewer: one panel that shows the
|
|
145671
145972
|
* navigation controls when the device has the cap).
|
|
145672
145973
|
*/
|
|
145974
|
+
/**
|
|
145975
|
+
* The camera child's settings form.
|
|
145976
|
+
*
|
|
145977
|
+
* A PURE export so the shape can be asserted without constructing a device —
|
|
145978
|
+
* the same discipline `navigationSlice` follows in this file. What matters
|
|
145979
|
+
* here is that the backend selector offers exactly the two vendors this
|
|
145980
|
+
* library can stream, in the order that puts the working one first.
|
|
145981
|
+
*/
|
|
145982
|
+
var CAMERA_SETTINGS_SCHEMA = { sections: [{
|
|
145983
|
+
id: "camera-backend",
|
|
145984
|
+
title: "Video backend",
|
|
145985
|
+
description: "Which cloud video service this robot streams through. The two are not interchangeable: only the Aliyun path is implemented here — a robot on Tencent can be seen in the Dreame app but not by CamStack, because Tencent offers it a proprietary P2P plane we do not speak. Changing this asks the ROBOT to switch and waits until its SDK reports ready; the robot is the authority on where it actually is.",
|
|
145986
|
+
fields: [{
|
|
145987
|
+
type: "select",
|
|
145988
|
+
key: "videoVendor",
|
|
145989
|
+
label: "Backend",
|
|
145990
|
+
description: "Aliyun streams through a plain relay and works today. Tencent is offered so the other path can be tested once it exists.",
|
|
145991
|
+
options: [{
|
|
145992
|
+
value: "ali",
|
|
145993
|
+
label: "Aliyun LinkVisual (supported)"
|
|
145994
|
+
}, {
|
|
145995
|
+
value: "tx",
|
|
145996
|
+
label: "Tencent IoT-Video (not implemented yet)"
|
|
145997
|
+
}]
|
|
145998
|
+
}]
|
|
145999
|
+
}, {
|
|
146000
|
+
id: "camera-access",
|
|
146001
|
+
title: "Camera access",
|
|
146002
|
+
description: "The privacy code set when the robot camera was paired. The robot refuses to open its camera without it.",
|
|
146003
|
+
fields: [{
|
|
146004
|
+
type: "password",
|
|
146005
|
+
key: "navigationAccessCode",
|
|
146006
|
+
label: "Camera access code",
|
|
146007
|
+
showToggle: true
|
|
146008
|
+
}]
|
|
146009
|
+
}] };
|
|
146010
|
+
/**
|
|
146011
|
+
* The values the form opens with.
|
|
146012
|
+
*
|
|
146013
|
+
* The DEVICE wins: it is the authority on which backend it is on, and the
|
|
146014
|
+
* stored preference is only what the operator last asked for. Showing the
|
|
146015
|
+
* preference while the robot sits elsewhere would tell the operator the switch
|
|
146016
|
+
* had worked when it had not. Before the first read has answered there is
|
|
146017
|
+
* nothing to show but the preference, and an empty string when there is
|
|
146018
|
+
* neither — unknown, never a default that looks like a fact (D393).
|
|
146019
|
+
*/
|
|
146020
|
+
function cameraSettingsValues(input) {
|
|
146021
|
+
return {
|
|
146022
|
+
navigationAccessCode: input.accessCode ?? "",
|
|
146023
|
+
videoVendor: input.vendorNow ?? input.storedVendor ?? ""
|
|
146024
|
+
};
|
|
146025
|
+
}
|
|
145673
146026
|
var DreameCameraDevice = class extends DreameChildDevice {
|
|
145674
146027
|
features = [DeviceFeature.BatteryOperated];
|
|
145675
146028
|
constructor(ctx) {
|
|
@@ -145745,6 +146098,31 @@ var DreameCameraDevice = class extends DreameChildDevice {
|
|
|
145745
146098
|
});
|
|
145746
146099
|
}
|
|
145747
146100
|
/** One reader of the robot's link, shared in shape with the vacuum's. */
|
|
146101
|
+
/**
|
|
146102
|
+
* Where the robot says its video backend is, as last read.
|
|
146103
|
+
*
|
|
146104
|
+
* The settings schema is built synchronously and this is a device read, so
|
|
146105
|
+
* it is cached and refreshed in the background when the form is opened — the
|
|
146106
|
+
* serve-and-revalidate discipline D224 describes.
|
|
146107
|
+
*
|
|
146108
|
+
* `null` means never read, which the form shows as the stored preference
|
|
146109
|
+
* instead — unknown is not the same fact as "on ali" (D393).
|
|
146110
|
+
*/
|
|
146111
|
+
vendorNow = null;
|
|
146112
|
+
/** Refresh {@link vendorNow}. Never throws: the settings page must open
|
|
146113
|
+
* whether or not the robot answered. */
|
|
146114
|
+
async refreshVendorNow() {
|
|
146115
|
+
const handle = this.resolveHandle();
|
|
146116
|
+
if (!(handle instanceof VacuumDevice)) return;
|
|
146117
|
+
try {
|
|
146118
|
+
this.vendorNow = (await handle.readVideoVendorStatus({ live: true })).vendor;
|
|
146119
|
+
} catch (err) {
|
|
146120
|
+
this.ctx.logger.debug("robot camera: video backend read failed", {
|
|
146121
|
+
tags: { deviceId: this.id },
|
|
146122
|
+
meta: { error: errMsg(err) }
|
|
146123
|
+
});
|
|
146124
|
+
}
|
|
146125
|
+
}
|
|
145748
146126
|
networkLink = new DreameNetworkLinkMonitor({
|
|
145749
146127
|
deviceId: this.id,
|
|
145750
146128
|
logger: this.ctx.logger,
|
|
@@ -146071,20 +146449,58 @@ var DreameCameraDevice = class extends DreameChildDevice {
|
|
|
146071
146449
|
* offers is a setting that does not exist.
|
|
146072
146450
|
*/
|
|
146073
146451
|
getSettingsUISchema() {
|
|
146074
|
-
|
|
146075
|
-
|
|
146076
|
-
|
|
146077
|
-
|
|
146078
|
-
|
|
146079
|
-
|
|
146080
|
-
key: "navigationAccessCode",
|
|
146081
|
-
label: "Camera access code",
|
|
146082
|
-
showToggle: true
|
|
146083
|
-
}]
|
|
146084
|
-
}] }, { navigationAccessCode: this.config.get("navigationAccessCode") ?? "" });
|
|
146452
|
+
this.refreshVendorNow();
|
|
146453
|
+
return hydrateSchema(CAMERA_SETTINGS_SCHEMA, cameraSettingsValues({
|
|
146454
|
+
accessCode: this.config.get("navigationAccessCode"),
|
|
146455
|
+
vendorNow: this.vendorNow,
|
|
146456
|
+
storedVendor: this.config.get("videoVendor")
|
|
146457
|
+
}));
|
|
146085
146458
|
}
|
|
146086
146459
|
async applySettingsPatch(patch) {
|
|
146460
|
+
this.ctx.logger.info("robot camera: settings patch received", {
|
|
146461
|
+
tags: { deviceId: this.id },
|
|
146462
|
+
meta: {
|
|
146463
|
+
keys: Object.keys(patch).sort(),
|
|
146464
|
+
vendorNow: this.vendorNow
|
|
146465
|
+
}
|
|
146466
|
+
});
|
|
146087
146467
|
await super.applySettingsPatch(patch);
|
|
146468
|
+
const wanted = patch["videoVendor"];
|
|
146469
|
+
if ((wanted === "ali" || wanted === "tx") && wanted !== this.vendorNow) {
|
|
146470
|
+
const handle = this.resolveHandle();
|
|
146471
|
+
if (handle instanceof VacuumDevice) {
|
|
146472
|
+
await this.closeStream("removed");
|
|
146473
|
+
this.controller = null;
|
|
146474
|
+
this.controllerHandle = null;
|
|
146475
|
+
this.ctx.logger.info("robot camera: switching video backend", {
|
|
146476
|
+
tags: { deviceId: this.id },
|
|
146477
|
+
meta: {
|
|
146478
|
+
from: this.vendorNow,
|
|
146479
|
+
to: wanted
|
|
146480
|
+
}
|
|
146481
|
+
});
|
|
146482
|
+
try {
|
|
146483
|
+
const settled = await handle.setVideoVendor(wanted);
|
|
146484
|
+
this.vendorNow = settled.vendor;
|
|
146485
|
+
this.ctx.logger.info("robot camera: video backend switched", {
|
|
146486
|
+
tags: { deviceId: this.id },
|
|
146487
|
+
meta: {
|
|
146488
|
+
vendor: settled.vendor,
|
|
146489
|
+
initStatus: settled.initStatus
|
|
146490
|
+
}
|
|
146491
|
+
});
|
|
146492
|
+
} catch (err) {
|
|
146493
|
+
this.ctx.logger.warn("robot camera: video backend switch refused", {
|
|
146494
|
+
tags: { deviceId: this.id },
|
|
146495
|
+
meta: {
|
|
146496
|
+
to: wanted,
|
|
146497
|
+
error: errMsg(err)
|
|
146498
|
+
}
|
|
146499
|
+
});
|
|
146500
|
+
throw err;
|
|
146501
|
+
}
|
|
146502
|
+
}
|
|
146503
|
+
}
|
|
146088
146504
|
if (!("navigationAccessCode" in patch)) return;
|
|
146089
146505
|
await this.closeStream("removed");
|
|
146090
146506
|
this.controller = null;
|
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.108",
|
|
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",
|
|
@@ -80,7 +80,7 @@
|
|
|
80
80
|
"publish:npm": "npm publish --access public"
|
|
81
81
|
},
|
|
82
82
|
"devDependencies": {
|
|
83
|
-
"@apocaliss92/nodedreame": "^1.12.
|
|
83
|
+
"@apocaliss92/nodedreame": "^1.12.4",
|
|
84
84
|
"@apocaliss92/nodelink-js": "^0.6.7",
|
|
85
85
|
"@camstack/types": "*",
|
|
86
86
|
"typescript": "~6.0.3",
|