@camstack/addon-provider-dreame 0.2.105 → 0.2.107

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/dist/addon.js +301 -17
  2. package/dist/addon.mjs +301 -17
  3. 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
- const iotId = opts?.iotId ?? (await getDeviceVideoProfile({
50185
- session,
50186
- region,
50187
- did: this.deviceId
50188
- })).iotId;
50189
- if (!iotId) throw new DreameError("device has no LinkVisual iotId; not a camera device or not provisioned");
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,43 @@ 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
+ });
66172
66313
  var ProviderStatusSchema = object({
66173
66314
  connected: boolean(),
66174
66315
  deviceCount: number(),
@@ -66761,6 +66902,12 @@ method(object({
66761
66902
  }), object({ moved: number() }), {
66762
66903
  kind: "mutation",
66763
66904
  auth: "admin"
66905
+ }), method(_void(), array(LocationIconAssignmentSchema)), method(object({
66906
+ location: string(),
66907
+ icon: LocationIconIdSchema.nullable()
66908
+ }), _void(), {
66909
+ kind: "mutation",
66910
+ auth: "admin"
66764
66911
  }), method(object({
66765
66912
  deviceId: number(),
66766
66913
  disabled: boolean()
@@ -72735,6 +72882,7 @@ var MediaFileKindEnum = _enum([
72735
72882
  "keyFrame",
72736
72883
  "keyFrameSmall",
72737
72884
  "thumbnailSmall",
72885
+ "confirmationCrop",
72738
72886
  "sceneFrame"
72739
72887
  ]);
72740
72888
  /**
@@ -89843,6 +89991,12 @@ Object.freeze({
89843
89991
  addonId: null,
89844
89992
  access: "view"
89845
89993
  },
89994
+ "deviceManager.listLocationIcons": {
89995
+ capName: "device-manager",
89996
+ capScope: "system",
89997
+ addonId: null,
89998
+ access: "view"
89999
+ },
89846
90000
  "deviceManager.listLocations": {
89847
90001
  capName: "device-manager",
89848
90002
  capScope: "system",
@@ -89987,6 +90141,12 @@ Object.freeze({
89987
90141
  addonId: null,
89988
90142
  access: "create"
89989
90143
  },
90144
+ "deviceManager.setLocationIcon": {
90145
+ capName: "device-manager",
90146
+ capScope: "system",
90147
+ addonId: null,
90148
+ access: "create"
90149
+ },
89990
90150
  "deviceManager.setMetadata": {
89991
90151
  capName: "device-manager",
89992
90152
  capScope: "system",
@@ -98762,6 +98922,15 @@ var dreameChildSchema = object({
98762
98922
  /** Privacy PIN set at pairing — required to wake a coded camera (camera child only). */
98763
98923
  navigationAccessCode: string().optional(),
98764
98924
  /**
98925
+ * The video backend the robot should sit on (camera child only).
98926
+ *
98927
+ * Not a preference the addon enforces: it is written THROUGH to the device
98928
+ * by `applySettingsPatch`, and the device is the authority on where it
98929
+ * actually is. Stored so the settings form has a value to show before the
98930
+ * first read answers.
98931
+ */
98932
+ videoVendor: _enum(["ali", "tx"]).optional(),
98933
+ /**
98765
98934
  * ms-epoch of the ONE-SHOT recording seed (camera child only, D380).
98766
98935
  *
98767
98936
  * A MARK, never the value: it says this camera was seeded, which stays true
@@ -145672,6 +145841,58 @@ function navigationSlice(prev, patch, now) {
145672
145841
  * for two separate things (admin UI: two tabs; viewer: one panel that shows the
145673
145842
  * navigation controls when the device has the cap).
145674
145843
  */
145844
+ /**
145845
+ * The camera child's settings form.
145846
+ *
145847
+ * A PURE export so the shape can be asserted without constructing a device —
145848
+ * the same discipline `navigationSlice` follows in this file. What matters
145849
+ * here is that the backend selector offers exactly the two vendors this
145850
+ * library can stream, in the order that puts the working one first.
145851
+ */
145852
+ var CAMERA_SETTINGS_SCHEMA = { sections: [{
145853
+ id: "camera-backend",
145854
+ title: "Video backend",
145855
+ 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.",
145856
+ fields: [{
145857
+ type: "select",
145858
+ key: "videoVendor",
145859
+ label: "Backend",
145860
+ description: "Aliyun streams through a plain relay and works today. Tencent is offered so the other path can be tested once it exists.",
145861
+ options: [{
145862
+ value: "ali",
145863
+ label: "Aliyun LinkVisual (supported)"
145864
+ }, {
145865
+ value: "tx",
145866
+ label: "Tencent IoT-Video (not implemented yet)"
145867
+ }]
145868
+ }]
145869
+ }, {
145870
+ id: "camera-access",
145871
+ title: "Camera access",
145872
+ description: "The privacy code set when the robot camera was paired. The robot refuses to open its camera without it.",
145873
+ fields: [{
145874
+ type: "password",
145875
+ key: "navigationAccessCode",
145876
+ label: "Camera access code",
145877
+ showToggle: true
145878
+ }]
145879
+ }] };
145880
+ /**
145881
+ * The values the form opens with.
145882
+ *
145883
+ * The DEVICE wins: it is the authority on which backend it is on, and the
145884
+ * stored preference is only what the operator last asked for. Showing the
145885
+ * preference while the robot sits elsewhere would tell the operator the switch
145886
+ * had worked when it had not. Before the first read has answered there is
145887
+ * nothing to show but the preference, and an empty string when there is
145888
+ * neither — unknown, never a default that looks like a fact (D393).
145889
+ */
145890
+ function cameraSettingsValues(input) {
145891
+ return {
145892
+ navigationAccessCode: input.accessCode ?? "",
145893
+ videoVendor: input.vendorNow ?? input.storedVendor ?? ""
145894
+ };
145895
+ }
145675
145896
  var DreameCameraDevice = class extends DreameChildDevice {
145676
145897
  features = [DeviceFeature.BatteryOperated];
145677
145898
  constructor(ctx) {
@@ -145747,6 +145968,31 @@ var DreameCameraDevice = class extends DreameChildDevice {
145747
145968
  });
145748
145969
  }
145749
145970
  /** One reader of the robot's link, shared in shape with the vacuum's. */
145971
+ /**
145972
+ * Where the robot says its video backend is, as last read.
145973
+ *
145974
+ * The settings schema is built synchronously and this is a device read, so
145975
+ * it is cached and refreshed in the background when the form is opened — the
145976
+ * serve-and-revalidate discipline D224 describes.
145977
+ *
145978
+ * `null` means never read, which the form shows as the stored preference
145979
+ * instead — unknown is not the same fact as "on ali" (D393).
145980
+ */
145981
+ vendorNow = null;
145982
+ /** Refresh {@link vendorNow}. Never throws: the settings page must open
145983
+ * whether or not the robot answered. */
145984
+ async refreshVendorNow() {
145985
+ const handle = this.resolveHandle();
145986
+ if (!(handle instanceof VacuumDevice)) return;
145987
+ try {
145988
+ this.vendorNow = (await handle.readVideoVendorStatus({ live: true })).vendor;
145989
+ } catch (err) {
145990
+ this.ctx.logger.debug("robot camera: video backend read failed", {
145991
+ tags: { deviceId: this.id },
145992
+ meta: { error: errMsg(err) }
145993
+ });
145994
+ }
145995
+ }
145750
145996
  networkLink = new DreameNetworkLinkMonitor({
145751
145997
  deviceId: this.id,
145752
145998
  logger: this.ctx.logger,
@@ -146073,20 +146319,58 @@ var DreameCameraDevice = class extends DreameChildDevice {
146073
146319
  * offers is a setting that does not exist.
146074
146320
  */
146075
146321
  getSettingsUISchema() {
146076
- return hydrateSchema({ sections: [{
146077
- id: "camera-access",
146078
- title: "Camera access",
146079
- description: "The privacy code set when the robot camera was paired. The robot refuses to open its camera without it.",
146080
- fields: [{
146081
- type: "password",
146082
- key: "navigationAccessCode",
146083
- label: "Camera access code",
146084
- showToggle: true
146085
- }]
146086
- }] }, { navigationAccessCode: this.config.get("navigationAccessCode") ?? "" });
146322
+ this.refreshVendorNow();
146323
+ return hydrateSchema(CAMERA_SETTINGS_SCHEMA, cameraSettingsValues({
146324
+ accessCode: this.config.get("navigationAccessCode"),
146325
+ vendorNow: this.vendorNow,
146326
+ storedVendor: this.config.get("videoVendor")
146327
+ }));
146087
146328
  }
146088
146329
  async applySettingsPatch(patch) {
146330
+ this.ctx.logger.info("robot camera: settings patch received", {
146331
+ tags: { deviceId: this.id },
146332
+ meta: {
146333
+ keys: Object.keys(patch).sort(),
146334
+ vendorNow: this.vendorNow
146335
+ }
146336
+ });
146089
146337
  await super.applySettingsPatch(patch);
146338
+ const wanted = patch["videoVendor"];
146339
+ if ((wanted === "ali" || wanted === "tx") && wanted !== this.vendorNow) {
146340
+ const handle = this.resolveHandle();
146341
+ if (handle instanceof VacuumDevice) {
146342
+ await this.closeStream("removed");
146343
+ this.controller = null;
146344
+ this.controllerHandle = null;
146345
+ this.ctx.logger.info("robot camera: switching video backend", {
146346
+ tags: { deviceId: this.id },
146347
+ meta: {
146348
+ from: this.vendorNow,
146349
+ to: wanted
146350
+ }
146351
+ });
146352
+ try {
146353
+ const settled = await handle.setVideoVendor(wanted);
146354
+ this.vendorNow = settled.vendor;
146355
+ this.ctx.logger.info("robot camera: video backend switched", {
146356
+ tags: { deviceId: this.id },
146357
+ meta: {
146358
+ vendor: settled.vendor,
146359
+ initStatus: settled.initStatus
146360
+ }
146361
+ });
146362
+ } catch (err) {
146363
+ this.ctx.logger.warn("robot camera: video backend switch refused", {
146364
+ tags: { deviceId: this.id },
146365
+ meta: {
146366
+ to: wanted,
146367
+ error: errMsg(err)
146368
+ }
146369
+ });
146370
+ throw err;
146371
+ }
146372
+ }
146373
+ }
146090
146374
  if (!("navigationAccessCode" in patch)) return;
146091
146375
  await this.closeStream("removed");
146092
146376
  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
- const iotId = opts?.iotId ?? (await getDeviceVideoProfile({
50182
- session,
50183
- region,
50184
- did: this.deviceId
50185
- })).iotId;
50186
- if (!iotId) throw new DreameError("device has no LinkVisual iotId; not a camera device or not provisioned");
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,43 @@ 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
+ });
66169
66310
  var ProviderStatusSchema = object({
66170
66311
  connected: boolean(),
66171
66312
  deviceCount: number(),
@@ -66758,6 +66899,12 @@ method(object({
66758
66899
  }), object({ moved: number() }), {
66759
66900
  kind: "mutation",
66760
66901
  auth: "admin"
66902
+ }), method(_void(), array(LocationIconAssignmentSchema)), method(object({
66903
+ location: string(),
66904
+ icon: LocationIconIdSchema.nullable()
66905
+ }), _void(), {
66906
+ kind: "mutation",
66907
+ auth: "admin"
66761
66908
  }), method(object({
66762
66909
  deviceId: number(),
66763
66910
  disabled: boolean()
@@ -72732,6 +72879,7 @@ var MediaFileKindEnum = _enum([
72732
72879
  "keyFrame",
72733
72880
  "keyFrameSmall",
72734
72881
  "thumbnailSmall",
72882
+ "confirmationCrop",
72735
72883
  "sceneFrame"
72736
72884
  ]);
72737
72885
  /**
@@ -89840,6 +89988,12 @@ Object.freeze({
89840
89988
  addonId: null,
89841
89989
  access: "view"
89842
89990
  },
89991
+ "deviceManager.listLocationIcons": {
89992
+ capName: "device-manager",
89993
+ capScope: "system",
89994
+ addonId: null,
89995
+ access: "view"
89996
+ },
89843
89997
  "deviceManager.listLocations": {
89844
89998
  capName: "device-manager",
89845
89999
  capScope: "system",
@@ -89984,6 +90138,12 @@ Object.freeze({
89984
90138
  addonId: null,
89985
90139
  access: "create"
89986
90140
  },
90141
+ "deviceManager.setLocationIcon": {
90142
+ capName: "device-manager",
90143
+ capScope: "system",
90144
+ addonId: null,
90145
+ access: "create"
90146
+ },
89987
90147
  "deviceManager.setMetadata": {
89988
90148
  capName: "device-manager",
89989
90149
  capScope: "system",
@@ -98759,6 +98919,15 @@ var dreameChildSchema = object({
98759
98919
  /** Privacy PIN set at pairing — required to wake a coded camera (camera child only). */
98760
98920
  navigationAccessCode: string().optional(),
98761
98921
  /**
98922
+ * The video backend the robot should sit on (camera child only).
98923
+ *
98924
+ * Not a preference the addon enforces: it is written THROUGH to the device
98925
+ * by `applySettingsPatch`, and the device is the authority on where it
98926
+ * actually is. Stored so the settings form has a value to show before the
98927
+ * first read answers.
98928
+ */
98929
+ videoVendor: _enum(["ali", "tx"]).optional(),
98930
+ /**
98762
98931
  * ms-epoch of the ONE-SHOT recording seed (camera child only, D380).
98763
98932
  *
98764
98933
  * A MARK, never the value: it says this camera was seeded, which stays true
@@ -145669,6 +145838,58 @@ function navigationSlice(prev, patch, now) {
145669
145838
  * for two separate things (admin UI: two tabs; viewer: one panel that shows the
145670
145839
  * navigation controls when the device has the cap).
145671
145840
  */
145841
+ /**
145842
+ * The camera child's settings form.
145843
+ *
145844
+ * A PURE export so the shape can be asserted without constructing a device —
145845
+ * the same discipline `navigationSlice` follows in this file. What matters
145846
+ * here is that the backend selector offers exactly the two vendors this
145847
+ * library can stream, in the order that puts the working one first.
145848
+ */
145849
+ var CAMERA_SETTINGS_SCHEMA = { sections: [{
145850
+ id: "camera-backend",
145851
+ title: "Video backend",
145852
+ 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.",
145853
+ fields: [{
145854
+ type: "select",
145855
+ key: "videoVendor",
145856
+ label: "Backend",
145857
+ description: "Aliyun streams through a plain relay and works today. Tencent is offered so the other path can be tested once it exists.",
145858
+ options: [{
145859
+ value: "ali",
145860
+ label: "Aliyun LinkVisual (supported)"
145861
+ }, {
145862
+ value: "tx",
145863
+ label: "Tencent IoT-Video (not implemented yet)"
145864
+ }]
145865
+ }]
145866
+ }, {
145867
+ id: "camera-access",
145868
+ title: "Camera access",
145869
+ description: "The privacy code set when the robot camera was paired. The robot refuses to open its camera without it.",
145870
+ fields: [{
145871
+ type: "password",
145872
+ key: "navigationAccessCode",
145873
+ label: "Camera access code",
145874
+ showToggle: true
145875
+ }]
145876
+ }] };
145877
+ /**
145878
+ * The values the form opens with.
145879
+ *
145880
+ * The DEVICE wins: it is the authority on which backend it is on, and the
145881
+ * stored preference is only what the operator last asked for. Showing the
145882
+ * preference while the robot sits elsewhere would tell the operator the switch
145883
+ * had worked when it had not. Before the first read has answered there is
145884
+ * nothing to show but the preference, and an empty string when there is
145885
+ * neither — unknown, never a default that looks like a fact (D393).
145886
+ */
145887
+ function cameraSettingsValues(input) {
145888
+ return {
145889
+ navigationAccessCode: input.accessCode ?? "",
145890
+ videoVendor: input.vendorNow ?? input.storedVendor ?? ""
145891
+ };
145892
+ }
145672
145893
  var DreameCameraDevice = class extends DreameChildDevice {
145673
145894
  features = [DeviceFeature.BatteryOperated];
145674
145895
  constructor(ctx) {
@@ -145744,6 +145965,31 @@ var DreameCameraDevice = class extends DreameChildDevice {
145744
145965
  });
145745
145966
  }
145746
145967
  /** One reader of the robot's link, shared in shape with the vacuum's. */
145968
+ /**
145969
+ * Where the robot says its video backend is, as last read.
145970
+ *
145971
+ * The settings schema is built synchronously and this is a device read, so
145972
+ * it is cached and refreshed in the background when the form is opened — the
145973
+ * serve-and-revalidate discipline D224 describes.
145974
+ *
145975
+ * `null` means never read, which the form shows as the stored preference
145976
+ * instead — unknown is not the same fact as "on ali" (D393).
145977
+ */
145978
+ vendorNow = null;
145979
+ /** Refresh {@link vendorNow}. Never throws: the settings page must open
145980
+ * whether or not the robot answered. */
145981
+ async refreshVendorNow() {
145982
+ const handle = this.resolveHandle();
145983
+ if (!(handle instanceof VacuumDevice)) return;
145984
+ try {
145985
+ this.vendorNow = (await handle.readVideoVendorStatus({ live: true })).vendor;
145986
+ } catch (err) {
145987
+ this.ctx.logger.debug("robot camera: video backend read failed", {
145988
+ tags: { deviceId: this.id },
145989
+ meta: { error: errMsg(err) }
145990
+ });
145991
+ }
145992
+ }
145747
145993
  networkLink = new DreameNetworkLinkMonitor({
145748
145994
  deviceId: this.id,
145749
145995
  logger: this.ctx.logger,
@@ -146070,20 +146316,58 @@ var DreameCameraDevice = class extends DreameChildDevice {
146070
146316
  * offers is a setting that does not exist.
146071
146317
  */
146072
146318
  getSettingsUISchema() {
146073
- return hydrateSchema({ sections: [{
146074
- id: "camera-access",
146075
- title: "Camera access",
146076
- description: "The privacy code set when the robot camera was paired. The robot refuses to open its camera without it.",
146077
- fields: [{
146078
- type: "password",
146079
- key: "navigationAccessCode",
146080
- label: "Camera access code",
146081
- showToggle: true
146082
- }]
146083
- }] }, { navigationAccessCode: this.config.get("navigationAccessCode") ?? "" });
146319
+ this.refreshVendorNow();
146320
+ return hydrateSchema(CAMERA_SETTINGS_SCHEMA, cameraSettingsValues({
146321
+ accessCode: this.config.get("navigationAccessCode"),
146322
+ vendorNow: this.vendorNow,
146323
+ storedVendor: this.config.get("videoVendor")
146324
+ }));
146084
146325
  }
146085
146326
  async applySettingsPatch(patch) {
146327
+ this.ctx.logger.info("robot camera: settings patch received", {
146328
+ tags: { deviceId: this.id },
146329
+ meta: {
146330
+ keys: Object.keys(patch).sort(),
146331
+ vendorNow: this.vendorNow
146332
+ }
146333
+ });
146086
146334
  await super.applySettingsPatch(patch);
146335
+ const wanted = patch["videoVendor"];
146336
+ if ((wanted === "ali" || wanted === "tx") && wanted !== this.vendorNow) {
146337
+ const handle = this.resolveHandle();
146338
+ if (handle instanceof VacuumDevice) {
146339
+ await this.closeStream("removed");
146340
+ this.controller = null;
146341
+ this.controllerHandle = null;
146342
+ this.ctx.logger.info("robot camera: switching video backend", {
146343
+ tags: { deviceId: this.id },
146344
+ meta: {
146345
+ from: this.vendorNow,
146346
+ to: wanted
146347
+ }
146348
+ });
146349
+ try {
146350
+ const settled = await handle.setVideoVendor(wanted);
146351
+ this.vendorNow = settled.vendor;
146352
+ this.ctx.logger.info("robot camera: video backend switched", {
146353
+ tags: { deviceId: this.id },
146354
+ meta: {
146355
+ vendor: settled.vendor,
146356
+ initStatus: settled.initStatus
146357
+ }
146358
+ });
146359
+ } catch (err) {
146360
+ this.ctx.logger.warn("robot camera: video backend switch refused", {
146361
+ tags: { deviceId: this.id },
146362
+ meta: {
146363
+ to: wanted,
146364
+ error: errMsg(err)
146365
+ }
146366
+ });
146367
+ throw err;
146368
+ }
146369
+ }
146370
+ }
146087
146371
  if (!("navigationAccessCode" in patch)) return;
146088
146372
  await this.closeStream("removed");
146089
146373
  this.controller = null;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@camstack/addon-provider-dreame",
3
- "version": "0.2.105",
3
+ "version": "0.2.107",
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.0",
83
+ "@apocaliss92/nodedreame": "^1.12.4",
84
84
  "@apocaliss92/nodelink-js": "^0.6.7",
85
85
  "@camstack/types": "*",
86
86
  "typescript": "~6.0.3",