@camstack/addon-provider-dreame 0.2.70 → 0.2.72

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/addon.mjs CHANGED
@@ -1,5 +1,5 @@
1
1
  import { createRequire } from "node:module";
2
- import { createCipheriv, createDecipheriv, createHash, randomBytes } from "crypto";
2
+ import { createCipheriv, createDecipheriv, createHash, createHmac, randomBytes, randomUUID } from "crypto";
3
3
  import { EventEmitter } from "events";
4
4
  import * as zlib from "zlib";
5
5
  //#region \0rolldown/runtime.js
@@ -44955,6 +44955,38 @@ async function postForToken(ctx, body) {
44955
44955
  if (typeof data.refresh_token === "string") session.refreshToken = data.refresh_token;
44956
44956
  return session;
44957
44957
  }
44958
+ var str = (v) => typeof v === "string" && v.length > 0 ? v : null;
44959
+ var numOrNull = (v) => typeof v === "number" && Number.isFinite(v) ? v : null;
44960
+ function parseBroker(bindDomain) {
44961
+ const s = str(bindDomain);
44962
+ if (!s) return null;
44963
+ const idx = s.lastIndexOf(":");
44964
+ if (idx < 0) return {
44965
+ host: s,
44966
+ port: null
44967
+ };
44968
+ const port = Number(s.slice(idx + 1));
44969
+ return {
44970
+ host: s.slice(0, idx),
44971
+ port: Number.isFinite(port) ? port : null
44972
+ };
44973
+ }
44974
+ function parseConnectivity(raw) {
44975
+ const deviceInfo = raw["deviceInfo"] ?? {};
44976
+ return {
44977
+ online: raw["online"] === true,
44978
+ connectionType: str(deviceInfo["scType"]),
44979
+ mac: str(raw["mac"]),
44980
+ broker: parseBroker(raw["bindDomain"]),
44981
+ region: str(raw["region"]),
44982
+ cloudVendor: str(raw["vendor"]),
44983
+ firmwareVersion: str(raw["ver"]),
44984
+ serialNumber: str(raw["sn"]),
44985
+ subModel: str(raw["subModel"]),
44986
+ battery: numOrNull(raw["battery"]),
44987
+ statusCode: numOrNull(raw["latestStatus"])
44988
+ };
44989
+ }
44958
44990
  async function listDevices(input) {
44959
44991
  const ctx = input.ctx ?? RequestContext.from({
44960
44992
  ...input,
@@ -44994,7 +45026,8 @@ function toDevice(rawIn) {
44994
45026
  name,
44995
45027
  online: rawIn.online === true || rawIn.lwt === 1 || lwtFlag === 1,
44996
45028
  raw,
44997
- cloudState: parseCloudState(raw)
45029
+ cloudState: parseCloudState(raw),
45030
+ connectivity: parseConnectivity(raw)
44998
45031
  };
44999
45032
  if (rawIn.mac) device.mac = String(rawIn.mac);
45000
45033
  if (typeof raw["ver"] === "string") device.firmwareVersion = raw["ver"];
@@ -46611,6 +46644,953 @@ var VacuumCapabilityResolver = class {
46611
46644
  return new VacuumCapabilitySet(model, tokensFor(getVacuumCapabilities(model)));
46612
46645
  }
46613
46646
  };
46647
+ objectType({
46648
+ code: numberType().optional(),
46649
+ msg: stringType().nullish(),
46650
+ data: objectType({ data: objectType({
46651
+ token: stringType(),
46652
+ userId: unionType([stringType(), numberType()]).optional(),
46653
+ expireAt: numberType().optional()
46654
+ }).passthrough() }).passthrough()
46655
+ }).passthrough();
46656
+ var AuthCodeResponseSchema = objectType({
46657
+ code: numberType().optional(),
46658
+ msg: stringType().nullish(),
46659
+ data: stringType()
46660
+ }).passthrough();
46661
+ objectType({
46662
+ code: numberType().optional(),
46663
+ msg: stringType().nullish(),
46664
+ data: objectType({ data: objectType({ familyId: stringType() }).passthrough() }).passthrough()
46665
+ }).passthrough();
46666
+ var DeviceVideoInfoSchema = objectType({
46667
+ model: stringType().optional(),
46668
+ displayName: stringType().optional(),
46669
+ permit: stringType().optional(),
46670
+ videoDynamicVendor: booleanType().optional(),
46671
+ defaultVendors: arrayType(stringType()).optional(),
46672
+ productId: unionType([stringType(), numberType()]).optional()
46673
+ }).passthrough();
46674
+ var DeviceInfoRecordSchema = objectType({
46675
+ did: unionType([stringType(), numberType()]).optional(),
46676
+ model: stringType().optional(),
46677
+ customName: stringType().optional(),
46678
+ vendor: stringType().optional(),
46679
+ online: booleanType().optional(),
46680
+ lwt: numberType().optional(),
46681
+ property: stringType().optional(),
46682
+ bindDomain: stringType().optional(),
46683
+ deviceInfo: DeviceVideoInfoSchema.optional()
46684
+ }).passthrough();
46685
+ var DeviceInfoResponseSchema = objectType({
46686
+ code: numberType().optional(),
46687
+ msg: stringType().nullish(),
46688
+ data: DeviceInfoRecordSchema
46689
+ }).passthrough();
46690
+ var KNOWN_VENDORS = ["tx", "ali"];
46691
+ function asVendor(v) {
46692
+ return v === "tx" || v === "ali" ? v : null;
46693
+ }
46694
+ function parseProperty(property) {
46695
+ if (!property) return {
46696
+ iotId: null,
46697
+ lwt: null
46698
+ };
46699
+ try {
46700
+ const p = JSON.parse(property);
46701
+ return {
46702
+ iotId: typeof p.iotId === "string" && p.iotId.length > 0 ? p.iotId : null,
46703
+ lwt: typeof p.lwt === "number" ? p.lwt : null
46704
+ };
46705
+ } catch {
46706
+ return {
46707
+ iotId: null,
46708
+ lwt: null
46709
+ };
46710
+ }
46711
+ }
46712
+ function toVideoProfile(record) {
46713
+ const info = record.deviceInfo ?? {};
46714
+ const supportedVendors = (info.defaultVendors ?? []).filter((v) => KNOWN_VENDORS.includes(v));
46715
+ const prop = parseProperty(record.property);
46716
+ const online = record.online === true || record.lwt === 1 || prop.lwt === 1;
46717
+ return {
46718
+ did: String(record.did ?? ""),
46719
+ model: String(record.model ?? info.model ?? ""),
46720
+ displayName: info.displayName ?? null,
46721
+ videoCapable: info.permit === "video" || supportedVendors.length > 0,
46722
+ currentVendor: asVendor(record.vendor),
46723
+ supportedVendors,
46724
+ dynamicVendor: info.videoDynamicVendor === true,
46725
+ iotId: prop.iotId,
46726
+ online
46727
+ };
46728
+ }
46729
+ var P_SMARTHOME = "/dreame-smarthome";
46730
+ var P_USER_IOT = "/dreame-user-iot";
46731
+ var DEFAULT_OS = "android";
46732
+ function resolveCtx(input) {
46733
+ return input.ctx ?? RequestContext.from({
46734
+ ...input,
46735
+ host: input.apiHost
46736
+ });
46737
+ }
46738
+ function passthrough(input) {
46739
+ return {
46740
+ accessToken: input.session.accessToken,
46741
+ ...input.signal !== void 0 ? { signal: input.signal } : {},
46742
+ ...input.timeoutMs !== void 0 ? { timeoutMs: input.timeoutMs } : {}
46743
+ };
46744
+ }
46745
+ async function getAliyunAuthCode(input) {
46746
+ const raw = await httpPostJsonBody({
46747
+ ctx: resolveCtx(input),
46748
+ path: `${P_SMARTHOME}/aliIot/getAuthCodeV3`,
46749
+ body: { os: DEFAULT_OS },
46750
+ context: "aliyun authCode",
46751
+ ...passthrough(input)
46752
+ });
46753
+ return AuthCodeResponseSchema.parse(raw).data;
46754
+ }
46755
+ async function getDeviceVideoProfile(input) {
46756
+ const raw = await httpPostJsonBody({
46757
+ ctx: resolveCtx(input),
46758
+ path: `${P_USER_IOT}/iotuserbind/device/info`,
46759
+ body: { did: input.did },
46760
+ context: "device video info",
46761
+ ...passthrough(input)
46762
+ });
46763
+ return toVideoProfile(DeviceInfoResponseSchema.parse(raw).data);
46764
+ }
46765
+ var ALIYUN_APP_KEY = "33974273";
46766
+ var ALIYUN_APP_SECRET = "65a62f03ae5eda97af962fecc2e3efa6";
46767
+ var ALIYUN_REGION_ID = {
46768
+ eu: "eu-central-1",
46769
+ de: "eu-central-1",
46770
+ us: "us-east-1",
46771
+ cn: "cn-shanghai",
46772
+ ru: "eu-central-1",
46773
+ sg: "ap-southeast-1",
46774
+ in: "ap-southeast-1",
46775
+ tw: "cn-shanghai"
46776
+ };
46777
+ function livingAccountHost(regionId) {
46778
+ return `living-account.${regionId}.aliyuncs.com`;
46779
+ }
46780
+ function apiIotHost(regionId) {
46781
+ return `${regionId}.api-iot.aliyuncs.com`;
46782
+ }
46783
+ var VISION_STREAM_QUERY = "/vision/customer/stream/query";
46784
+ var CT_JSON = "application/json; charset=UTF-8";
46785
+ var CT_FORM = "application/x-www-form-urlencoded; charset=UTF-8";
46786
+ function md5Base64(s) {
46787
+ return createHash("md5").update(s, "utf8").digest("base64");
46788
+ }
46789
+ function signApiGatewayRequest(input) {
46790
+ const accept = CT_JSON;
46791
+ const contentType = input.json !== void 0 ? CT_JSON : CT_FORM;
46792
+ const date = input.date ?? (/* @__PURE__ */ new Date()).toUTCString();
46793
+ let body;
46794
+ let contentMd5 = "";
46795
+ let formParams;
46796
+ if (input.json !== void 0) {
46797
+ body = JSON.stringify(input.json);
46798
+ contentMd5 = md5Base64(body);
46799
+ } else {
46800
+ const form = input.form ?? {};
46801
+ body = Object.entries(form).map(([k, v]) => `${k}=${encodeURIComponent(v)}`).join("&");
46802
+ formParams = form;
46803
+ }
46804
+ const caHeaders = {
46805
+ "X-Ca-Key": input.appKey,
46806
+ "X-Ca-Nonce": input.nonce ?? crypto.randomUUID().toUpperCase(),
46807
+ "X-Ca-Timestamp": String(input.timestamp ?? Date.now()),
46808
+ "X-Ca-Version": "1",
46809
+ ...input.extraCaHeaders ?? {}
46810
+ };
46811
+ const signedHeaderNames = Object.keys(caHeaders).sort((a, b) => a.toLowerCase() < b.toLowerCase() ? -1 : a.toLowerCase() > b.toLowerCase() ? 1 : 0);
46812
+ const headerBlock = signedHeaderNames.map((k) => `${k}:${caHeaders[k]}
46813
+ `).join("");
46814
+ let resource = input.path;
46815
+ if (formParams && Object.keys(formParams).length > 0) {
46816
+ const sorted = Object.keys(formParams).sort();
46817
+ resource += "?" + sorted.map((k) => `${k}=${formParams[k]}`).join("&");
46818
+ }
46819
+ const stringToSign = `${input.method}
46820
+ ${accept}
46821
+ ${contentMd5}
46822
+ ${contentType}
46823
+ ${date}
46824
+ ${headerBlock}${resource}`;
46825
+ const headers = {
46826
+ "Content-Type": contentType,
46827
+ Accept: accept,
46828
+ Date: date,
46829
+ "X-Ca-Signature": createHmac("sha1", input.appSecret).update(stringToSign, "utf8").digest("base64"),
46830
+ "X-Ca-Signature-Method": "HmacSHA1",
46831
+ "X-Ca-Signature-Headers": signedHeaderNames.join(","),
46832
+ ...caHeaders
46833
+ };
46834
+ if (contentMd5) headers["Content-MD5"] = contentMd5;
46835
+ return {
46836
+ headers,
46837
+ body
46838
+ };
46839
+ }
46840
+ var LoginByOauthSchema = objectType({ data: objectType({ data: objectType({ loginSuccessResult: objectType({
46841
+ sid: stringType(),
46842
+ refreshToken: stringType().optional(),
46843
+ token: stringType().optional(),
46844
+ openAccount: objectType({ openId: stringType().optional() }).passthrough().optional()
46845
+ }).passthrough() }).passthrough() }).passthrough() }).passthrough();
46846
+ var CreateSessionSchema = objectType({
46847
+ code: numberType().optional(),
46848
+ message: stringType().nullish(),
46849
+ data: objectType({
46850
+ iotToken: stringType(),
46851
+ refreshToken: stringType().optional(),
46852
+ identityId: stringType().optional(),
46853
+ iotTokenExpire: numberType().optional()
46854
+ }).passthrough()
46855
+ }).passthrough();
46856
+ async function postJson(url, headers, body, fetchImpl, ctx) {
46857
+ const res = await fetchImpl(url, {
46858
+ method: "POST",
46859
+ headers,
46860
+ body
46861
+ });
46862
+ const text = await res.text();
46863
+ try {
46864
+ return JSON.parse(text);
46865
+ } catch {
46866
+ throw new DreameApiError(`${ctx} returned non-JSON (status ${res.status})`, res.status);
46867
+ }
46868
+ }
46869
+ async function loginByOauth(input) {
46870
+ const host = livingAccountHost(input.regionId);
46871
+ const riskControlInfo = {
46872
+ createIfNotExist: "true",
46873
+ sdkVersion: "3.4.0.27",
46874
+ openaccountVersion: "3.4.0.27",
46875
+ platformName: "iOS",
46876
+ netType: "wifi",
46877
+ brand: "Apple",
46878
+ USE_H5_NC: "true",
46879
+ deviceId: input.deviceId ?? randomUUID(),
46880
+ locale: "en-US",
46881
+ platformVersion: "26.5.2",
46882
+ platform: "iPhone",
46883
+ appKey: ALIYUN_APP_KEY,
46884
+ USE_OA_PWD_ENCRYPT: "true",
46885
+ model: "iPhone16,2",
46886
+ utdid: input.utdid ?? randomUUID().replace(/-/g, "").slice(0, 20),
46887
+ appVersion: "1895"
46888
+ };
46889
+ const signed = signApiGatewayRequest({
46890
+ method: "POST",
46891
+ host,
46892
+ path: "/api/prd/loginbyoauth.json",
46893
+ appKey: ALIYUN_APP_KEY,
46894
+ appSecret: ALIYUN_APP_SECRET,
46895
+ form: { loginByOauthRequest: JSON.stringify({
46896
+ riskControlInfo,
46897
+ oauthPlateform: 23,
46898
+ oauthAppKey: ALIYUN_APP_KEY,
46899
+ authCode: input.authCode
46900
+ }) }
46901
+ });
46902
+ const json = await postJson(`https://${host}/api/prd/loginbyoauth.json`, signed.headers, signed.body, input.fetchImpl ?? defaultFetch, "loginbyoauth");
46903
+ const r = LoginByOauthSchema.parse(json).data.data.loginSuccessResult;
46904
+ return {
46905
+ sid: r.sid,
46906
+ refreshToken: r.refreshToken ?? "",
46907
+ openId: r.openAccount?.openId ?? ""
46908
+ };
46909
+ }
46910
+ async function createIotSession(input) {
46911
+ const host = apiIotHost(input.regionId);
46912
+ const path = "/account/createSessionByAuthCode";
46913
+ const signed = signApiGatewayRequest({
46914
+ method: "POST",
46915
+ host,
46916
+ path,
46917
+ appKey: ALIYUN_APP_KEY,
46918
+ appSecret: ALIYUN_APP_SECRET,
46919
+ json: {
46920
+ id: randomUUID().toUpperCase(),
46921
+ params: { request: {
46922
+ appKey: ALIYUN_APP_KEY,
46923
+ authCode: input.sid,
46924
+ accountType: "OA_SESSION"
46925
+ } },
46926
+ request: {
46927
+ apiVer: "1.0.4",
46928
+ language: "en-US",
46929
+ appKey: ALIYUN_APP_KEY
46930
+ },
46931
+ version: "1.0.0"
46932
+ },
46933
+ extraCaHeaders: { "X-Ca-Stage": "RELEASE" }
46934
+ });
46935
+ const json = await postJson(`https://${host}${path}`, signed.headers, signed.body, input.fetchImpl ?? defaultFetch, "createSessionByAuthCode");
46936
+ const parsed = CreateSessionSchema.parse(json);
46937
+ if (parsed.code !== 200 && parsed.code !== 0) throw new DreameApiError(`createSession rejected: code=${parsed.code} msg=${parsed.message ?? "?"}`, 200, parsed);
46938
+ const d = parsed.data;
46939
+ return {
46940
+ iotToken: d.iotToken,
46941
+ refreshToken: d.refreshToken ?? "",
46942
+ identityId: d.identityId ?? "",
46943
+ expiresAt: Date.now() + (d.iotTokenExpire ?? 72e3) * 1e3
46944
+ };
46945
+ }
46946
+ var VISION_DEVICE_OFFLINE = 9201;
46947
+ var StreamQueryResponseSchema = objectType({
46948
+ code: numberType().optional(),
46949
+ message: stringType().nullish(),
46950
+ data: objectType({
46951
+ typeList: arrayType(stringType()).optional(),
46952
+ relayUrl: stringType().optional(),
46953
+ relayDecryptKey: objectType({
46954
+ iv: stringType().optional(),
46955
+ key: stringType().optional()
46956
+ }).passthrough().optional(),
46957
+ p2pInfo: objectType({
46958
+ stunUrl: stringType().optional(),
46959
+ alterStunUrl: stringType().optional(),
46960
+ signalUrl: stringType().optional(),
46961
+ portDetectStunUrlList: arrayType(stringType()).optional()
46962
+ }).passthrough().optional(),
46963
+ supportVisionPtz: booleanType().optional()
46964
+ }).passthrough().optional()
46965
+ }).passthrough();
46966
+ async function streamQuery(input) {
46967
+ const appKey = input.appKey ?? ALIYUN_APP_KEY;
46968
+ const appSecret = input.appSecret ?? ALIYUN_APP_SECRET;
46969
+ const host = apiIotHost(input.regionId);
46970
+ const signed = signApiGatewayRequest({
46971
+ method: "POST",
46972
+ host,
46973
+ path: VISION_STREAM_QUERY,
46974
+ appKey,
46975
+ appSecret,
46976
+ json: {
46977
+ id: crypto.randomUUID().toUpperCase(),
46978
+ params: {
46979
+ streamType: input.streamType ?? 0,
46980
+ needDomainName: true,
46981
+ relayEncryptType: 0,
46982
+ clientType: "iOS",
46983
+ iotId: input.iotId,
46984
+ enablePortPredict: true,
46985
+ enableWebSocket: true,
46986
+ forceIFrame: true,
46987
+ cacheDuration: 3e3,
46988
+ relayEncrypted: input.relayEncrypted ?? false
46989
+ },
46990
+ request: {
46991
+ language: "en-US",
46992
+ appKey,
46993
+ iotToken: input.iotToken,
46994
+ apiVer: "2.2.0"
46995
+ },
46996
+ version: "1.0.0"
46997
+ },
46998
+ extraCaHeaders: { "X-Ca-Stage": "RELEASE" }
46999
+ });
47000
+ const res = await (input.fetchImpl ?? defaultFetch)(`https://${host}${VISION_STREAM_QUERY}`, {
47001
+ method: "POST",
47002
+ headers: signed.headers,
47003
+ body: signed.body,
47004
+ ...input.signal ? { signal: input.signal } : {}
47005
+ });
47006
+ const text = await res.text();
47007
+ let json;
47008
+ try {
47009
+ json = JSON.parse(text);
47010
+ } catch {
47011
+ throw new DreameApiError(`stream/query returned non-JSON (status ${res.status})`, res.status);
47012
+ }
47013
+ const parsed = StreamQueryResponseSchema.parse(json);
47014
+ if (parsed.code === VISION_DEVICE_OFFLINE) throw new DreameDeviceOfflineError(`stream/query: device video agent offline (code ${VISION_DEVICE_OFFLINE}) \u2014 retry to wake`, res.status, parsed);
47015
+ if (parsed.code !== 200 && parsed.code !== 0) throw new DreameApiError(`stream/query rejected: code=${parsed.code} msg=${parsed.message ?? "?"}`, res.status, parsed);
47016
+ const d = parsed.data ?? {};
47017
+ const dk = d.relayDecryptKey;
47018
+ const hasKey = dk && dk.iv && dk.key;
47019
+ const p = d.p2pInfo;
47020
+ const p2pInfo = p ? {
47021
+ ...p.stunUrl !== void 0 ? { stunUrl: p.stunUrl } : {},
47022
+ ...p.alterStunUrl !== void 0 ? { alterStunUrl: p.alterStunUrl } : {},
47023
+ ...p.signalUrl !== void 0 ? { signalUrl: p.signalUrl } : {},
47024
+ ...p.portDetectStunUrlList !== void 0 ? { portDetectStunUrlList: p.portDetectStunUrlList } : {}
47025
+ } : null;
47026
+ return {
47027
+ typeList: d.typeList ?? [],
47028
+ relayUrl: d.relayUrl ?? null,
47029
+ relayDecryptKey: hasKey ? {
47030
+ iv: dk.iv,
47031
+ key: dk.key
47032
+ } : null,
47033
+ p2pInfo,
47034
+ ...d.supportVisionPtz !== void 0 ? { supportVisionPtz: d.supportVisionPtz } : {}
47035
+ };
47036
+ }
47037
+ var DEFAULT_WAKE_TIMEOUT_MS = 3e4;
47038
+ var DEFAULT_WAKE_POLL_MS = 2e3;
47039
+ var sleep = (ms) => new Promise((r) => setTimeout(r, ms));
47040
+ var REFRESH_LEEWAY_MS = 6e4;
47041
+ var DreameVideoSession = class {
47042
+ #session;
47043
+ #region;
47044
+ #regionId;
47045
+ #fetchImpl;
47046
+ #iot = null;
47047
+ #inFlight = null;
47048
+ constructor(input) {
47049
+ this.#session = input.session;
47050
+ this.#region = input.region;
47051
+ this.#regionId = ALIYUN_REGION_ID[input.region];
47052
+ this.#fetchImpl = input.fetchImpl;
47053
+ }
47054
+ /** The resolved Aliyun IoT region id (e.g. `eu-central-1`). */
47055
+ get regionId() {
47056
+ return this.#regionId;
47057
+ }
47058
+ /** Mint (or reuse) a valid Aliyun IoT session, refreshing before expiry. */
47059
+ async ensureIotSession() {
47060
+ const cur = this.#iot;
47061
+ if (cur && Date.now() < cur.expiresAt - REFRESH_LEEWAY_MS) return cur;
47062
+ if (this.#inFlight) return this.#inFlight;
47063
+ this.#inFlight = this.#mint();
47064
+ try {
47065
+ this.#iot = await this.#inFlight;
47066
+ return this.#iot;
47067
+ } finally {
47068
+ this.#inFlight = null;
47069
+ }
47070
+ }
47071
+ async #mint() {
47072
+ return createIotSession({
47073
+ sid: (await loginByOauth({
47074
+ authCode: await getAliyunAuthCode({
47075
+ session: this.#session,
47076
+ region: this.#region,
47077
+ ...this.#fetchImpl ? { fetchImpl: this.#fetchImpl } : {}
47078
+ }),
47079
+ regionId: this.#regionId,
47080
+ ...this.#fetchImpl ? { fetchImpl: this.#fetchImpl } : {}
47081
+ })).sid,
47082
+ regionId: this.#regionId,
47083
+ ...this.#fetchImpl ? { fetchImpl: this.#fetchImpl } : {}
47084
+ });
47085
+ }
47086
+ /**
47087
+ * Mint a fresh LinkVisual stream descriptor for a device. `encrypted:false`
47088
+ * (default) yields a plain RTMP relay URL directly playable by ffmpeg.
47089
+ *
47090
+ * A camera that has been idle reports "device offline" until the query wakes
47091
+ * its video agent via the LinkVisual cloud. This transparently retries on that
47092
+ * (up to `wakeTimeoutMs`, default 30s) so a single call returns a live stream;
47093
+ * pass `wakeTimeoutMs: 0` to fail fast instead.
47094
+ */
47095
+ async getStreamInfo(iotId, opts) {
47096
+ const deadline = Date.now() + (opts?.wakeTimeoutMs ?? DEFAULT_WAKE_TIMEOUT_MS);
47097
+ const pollMs = opts?.pollMs ?? DEFAULT_WAKE_POLL_MS;
47098
+ for (;;) {
47099
+ const iot = await this.ensureIotSession();
47100
+ try {
47101
+ return await streamQuery({
47102
+ regionId: this.#regionId,
47103
+ iotToken: iot.iotToken,
47104
+ iotId,
47105
+ relayEncrypted: opts?.encrypted ?? false,
47106
+ ...this.#fetchImpl ? { fetchImpl: this.#fetchImpl } : {}
47107
+ });
47108
+ } catch (err) {
47109
+ if (err instanceof DreameDeviceOfflineError && Date.now() + pollMs < deadline) {
47110
+ await sleep(pollMs);
47111
+ continue;
47112
+ }
47113
+ throw err;
47114
+ }
47115
+ }
47116
+ }
47117
+ /**
47118
+ * Convenience: mint a fresh plain RTMP relay URL for a device, or `null` when
47119
+ * the device is not currently streaming (no relay available).
47120
+ */
47121
+ async getRtmpUrl(iotId) {
47122
+ return (await this.getStreamInfo(iotId, { encrypted: false })).relayUrl;
47123
+ }
47124
+ };
47125
+ var MONITOR_SIID = 10001;
47126
+ var MONITOR_AIID = {
47127
+ /** Stream lifecycle + keep-alive (monitor start/end, keep_alive). */
47128
+ CAMERA_OPERATE: 1,
47129
+ /** Two-way intercom start/end. */
47130
+ VOICE_OPERATE: 2,
47131
+ /** Generic property-scoped operations. */
47132
+ PROPERTY_OPERATE: 3,
47133
+ /** Privacy access-code gate (launch/verify/set/reset). */
47134
+ ACCESS_CODE_OPERATE: 4,
47135
+ /** Video-vendor SDK init / switch. */
47136
+ VIDEO_VENDOR: 7
47137
+ };
47138
+ var MONITOR_PIID = {
47139
+ MONITOR_STATUS: 1,
47140
+ MONITOR_AUDIO_STATUS: 2,
47141
+ MONITOR_RECORD_STATUS: 4,
47142
+ TAKE_PHOTO: 5,
47143
+ KEEP_ALIVE: 6,
47144
+ MONITOR_FAULTS: 7,
47145
+ FILL_LIGHT_SET: 9,
47146
+ CAMERA_LIGHT_SWITCH: 10,
47147
+ VIDEO_VENDOR_STATUS: 11,
47148
+ GET_PROPERTY: 99,
47149
+ PERSON_DATA: 110,
47150
+ VIDEO_OBSTACLE_SWITCH: 111,
47151
+ VIDEO_OBSTACLE_DATA: 112,
47152
+ MONITOR_TASK_STATUS: 103,
47153
+ UPLOAD_STATUS: 1003,
47154
+ GET_ACCESS_CODE: 1100,
47155
+ SET_ACCESS_CODE: 1101,
47156
+ VERIFY_ACCESS_CODE: 1102,
47157
+ MONITOR_REST_MODE: 1103,
47158
+ SET_ACCESS_HOT_STATUS: 1104,
47159
+ MONITOR_SPACE: 2003
47160
+ };
47161
+ var MONITOR_VENDOR_TOKEN = {
47162
+ ali: "alify",
47163
+ tx: "tx"
47164
+ };
47165
+ var VACUUM_MOVE = {
47166
+ /** Remote-drive state property (set_properties), sent ~1 Hz while held. */
47167
+ REMOTE_STATE_PIID: 15,
47168
+ /** Work-mode action instance (go-to-point, cruise, person-follow start). */
47169
+ WORK_AIID: 1,
47170
+ /** Work-mode stop action instance (person-follow stop). */
47171
+ STOP_AIID: 2,
47172
+ /** piid carrying the work-mode selector value in the WORK action. */
47173
+ MODE_PIID: 1,
47174
+ /** piid carrying the point-info JSON in the WORK action. */
47175
+ POINT_PIID: 10,
47176
+ /** Work-mode selector values. */
47177
+ MODE_SPOT: 23,
47178
+ MODE_CRUISE: 22,
47179
+ MODE_PERSON_FOLLOW: 26,
47180
+ MODE_SPOT_CLEAN: 20,
47181
+ /** Auto-switch setter property (fill-light auto/full toggle lives here). */
47182
+ AUTO_SWITCH_PIID: 50
47183
+ };
47184
+ var VACUUM_CHARGE = {
47185
+ siid: 3,
47186
+ aiid: 1
47187
+ };
47188
+ var VOICE_SIID = 7;
47189
+ var VOICE_LOCATE_AIID = 1;
47190
+ var VOICE_PLAY_SOUND_AIID = 2;
47191
+ var VOICE_SOUND_PIID = 4;
47192
+ var VACUUM_LOCATE = {
47193
+ siid: VOICE_SIID,
47194
+ aiid: VOICE_LOCATE_AIID
47195
+ };
47196
+ var PET_SOUNDS = {
47197
+ meow: 684,
47198
+ bark: 685,
47199
+ footsteps: 686,
47200
+ purring: 687,
47201
+ tickTock: 688
47202
+ };
47203
+ var VACUUM_ACTIONS = {
47204
+ startClean: {
47205
+ siid: 2,
47206
+ aiid: 1
47207
+ },
47208
+ pauseClean: {
47209
+ siid: 2,
47210
+ aiid: 2
47211
+ },
47212
+ stopClean: {
47213
+ siid: 4,
47214
+ aiid: 2
47215
+ },
47216
+ dockWash: {
47217
+ siid: 4,
47218
+ aiid: 4
47219
+ },
47220
+ autoEmpty: {
47221
+ siid: 15,
47222
+ aiid: 1
47223
+ }
47224
+ };
47225
+ function makeMonitorSession(accountId, now = Date.now()) {
47226
+ return createHash("md5").update(`${accountId}_${now}`).digest("hex");
47227
+ }
47228
+ function hashAccessCode(code) {
47229
+ return createHash("sha256").update(code).digest("hex");
47230
+ }
47231
+ function buildActionInput(piid, params, session) {
47232
+ return {
47233
+ piid,
47234
+ value: JSON.stringify({
47235
+ ...params ?? {},
47236
+ session
47237
+ })
47238
+ };
47239
+ }
47240
+ function startMonitorParams(iotId, vendor, area) {
47241
+ return {
47242
+ token: MONITOR_VENDOR_TOKEN[vendor],
47243
+ channelId: iotId,
47244
+ area,
47245
+ operType: "monitor",
47246
+ operation: "start"
47247
+ };
47248
+ }
47249
+ function stopMonitorParams() {
47250
+ return {
47251
+ operType: "monitor",
47252
+ operation: "end"
47253
+ };
47254
+ }
47255
+ function keepAliveParams(videoStatus) {
47256
+ return {
47257
+ operType: "keep_alive",
47258
+ videoStatus
47259
+ };
47260
+ }
47261
+ function accessCodeLaunchParams() {
47262
+ return { open: true };
47263
+ }
47264
+ function verifyAccessCodeParams(code, lazymode = 0) {
47265
+ return {
47266
+ oldcode: hashAccessCode(code),
47267
+ lazymode
47268
+ };
47269
+ }
47270
+ function intercomStartParams(opts) {
47271
+ return {
47272
+ operType: "intercom",
47273
+ operation: "start",
47274
+ ...opts?.needRecordSound !== void 0 ? { need_record_sound: opts.needRecordSound } : {},
47275
+ ...opts?.videoCall ? { phone: 1 } : {}
47276
+ };
47277
+ }
47278
+ function intercomStopParams() {
47279
+ return {
47280
+ operType: "intercom",
47281
+ operation: "end"
47282
+ };
47283
+ }
47284
+ function fillLightParams(value) {
47285
+ return { value: String(value) };
47286
+ }
47287
+ function takePhotoParams() {
47288
+ return {
47289
+ operType: "takephoto",
47290
+ operation: "start"
47291
+ };
47292
+ }
47293
+ function remoteDriveValue(spdv, spdw, now = Date.now()) {
47294
+ return JSON.stringify({
47295
+ spdv,
47296
+ spdw,
47297
+ audio: "false",
47298
+ random: Math.floor(Math.random() * 1e3),
47299
+ timestamp: now
47300
+ });
47301
+ }
47302
+ var DRIVE_DIRECTIONS = {
47303
+ forward: [200, 0],
47304
+ left: [0, 45],
47305
+ right: [0, -45],
47306
+ turnAround: [0, 180],
47307
+ stop: [0, 0]
47308
+ };
47309
+ function actionCode(res) {
47310
+ const r = res;
47311
+ return typeof r?.code === "number" ? r.code : null;
47312
+ }
47313
+ function firstOutValue(res) {
47314
+ const r = res;
47315
+ const out = r?.out ?? r?.result?.out;
47316
+ return Array.isArray(out) && out.length > 0 ? out[0]?.value : void 0;
47317
+ }
47318
+ var DEFAULT_KEEP_ALIVE_MS = 1e4;
47319
+ var DEFAULT_AREA = "4";
47320
+ var DreameCameraController = class {
47321
+ #device;
47322
+ #relay;
47323
+ #iotId;
47324
+ #accountId;
47325
+ #vendor;
47326
+ #accessCode;
47327
+ #area;
47328
+ #keepAliveIntervalMs;
47329
+ #wakeTimeoutMs;
47330
+ #onKeepAliveError;
47331
+ #log;
47332
+ #session = null;
47333
+ #keepAliveTimer = null;
47334
+ #opened = false;
47335
+ constructor(input) {
47336
+ this.#device = input.device;
47337
+ this.#relay = input.relay;
47338
+ this.#iotId = input.iotId;
47339
+ this.#accountId = input.accountId;
47340
+ this.#vendor = input.vendor ?? "ali";
47341
+ this.#accessCode = input.accessCode;
47342
+ this.#area = input.area ?? DEFAULT_AREA;
47343
+ this.#keepAliveIntervalMs = input.keepAliveIntervalMs ?? DEFAULT_KEEP_ALIVE_MS;
47344
+ this.#wakeTimeoutMs = input.wakeTimeoutMs;
47345
+ this.#onKeepAliveError = input.onKeepAliveError;
47346
+ this.#log = input.log ?? (() => {});
47347
+ }
47348
+ #logLine(msg) {
47349
+ this.#log(`[nodedreame:monitor] ${msg}`);
47350
+ }
47351
+ /** True while a monitor session is active (between {@link open} and {@link close}). */
47352
+ get isOpen() {
47353
+ return this.#opened;
47354
+ }
47355
+ /** Send one camera action, resolving to the raw device reply. */
47356
+ async #action(aiid, piid, params) {
47357
+ if (!this.#session) throw new DreameError("camera controller has no session; call open() first");
47358
+ const input = buildActionInput(piid, params, this.#session);
47359
+ return this.#device.callAction(MONITOR_SIID, aiid, [input]);
47360
+ }
47361
+ /**
47362
+ * Run the full cold-start sequence and return a live relay URL. Starts the
47363
+ * keep-alive loop; the caller MUST {@link close} when done to release the
47364
+ * device. Throws if the monitor cannot be started or no relay comes up.
47365
+ */
47366
+ async open() {
47367
+ if (this.#opened) throw new DreameError("camera stream already open");
47368
+ this.#session = makeMonitorSession(this.#accountId);
47369
+ this.#logLine(`open: vendor=${this.#vendor} iotId=${this.#iotId} area=${this.#area}`);
47370
+ await this.#tryPrime();
47371
+ const encryptionKey = asString(firstOutValue(await this.#startMonitorWithGate()));
47372
+ this.#logLine(`startMonitor accepted (encryptionKey=${encryptionKey ? "yes" : "no"})`);
47373
+ this.#opened = true;
47374
+ this.#startKeepAlive();
47375
+ let relayUrl;
47376
+ try {
47377
+ relayUrl = (await this.#relay.getStreamInfo(this.#iotId, {
47378
+ encrypted: false,
47379
+ ...this.#wakeTimeoutMs !== void 0 ? { wakeTimeoutMs: this.#wakeTimeoutMs } : {}
47380
+ })).relayUrl;
47381
+ } catch (err) {
47382
+ this.#logLine(`relay mint failed: ${err instanceof Error ? err.message : String(err)}`);
47383
+ await this.close();
47384
+ throw err;
47385
+ }
47386
+ if (!relayUrl) {
47387
+ await this.close();
47388
+ throw new DreameError("monitor started but no relay URL was returned");
47389
+ }
47390
+ this.#logLine("relay up");
47391
+ return {
47392
+ rtmpUrl: relayUrl,
47393
+ encryptionKey
47394
+ };
47395
+ }
47396
+ async #tryPrime() {
47397
+ try {
47398
+ await this.#action(MONITOR_AIID.ACCESS_CODE_OPERATE, MONITOR_PIID.GET_ACCESS_CODE, accessCodeLaunchParams());
47399
+ } catch {}
47400
+ try {
47401
+ await this.#action(MONITOR_AIID.VIDEO_VENDOR, MONITOR_PIID.VIDEO_VENDOR_STATUS, null);
47402
+ } catch {}
47403
+ }
47404
+ async #startMonitorWithGate() {
47405
+ const params = startMonitorParams(this.#iotId, this.#vendor, this.#area);
47406
+ let res = await this.#action(MONITOR_AIID.CAMERA_OPERATE, MONITOR_PIID.MONITOR_STATUS, params);
47407
+ if (actionCode(res) === 0) return res;
47408
+ this.#logLine(`startMonitor refused (code ${actionCode(res) ?? "?"}); verifying access code`);
47409
+ if (this.#accessCode) {
47410
+ await this.#action(MONITOR_AIID.ACCESS_CODE_OPERATE, MONITOR_PIID.VERIFY_ACCESS_CODE, verifyAccessCodeParams(this.#accessCode));
47411
+ res = await this.#action(MONITOR_AIID.CAMERA_OPERATE, MONITOR_PIID.MONITOR_STATUS, params);
47412
+ }
47413
+ if (actionCode(res) !== 0) throw new DreameError(`startMonitor refused (code ${actionCode(res) ?? "?"})` + (this.#accessCode ? "" : "; device likely requires an accessCode"));
47414
+ return res;
47415
+ }
47416
+ #startKeepAlive() {
47417
+ this.#stopKeepAlive();
47418
+ this.#keepAliveTimer = setInterval(() => {
47419
+ this.#action(MONITOR_AIID.CAMERA_OPERATE, MONITOR_PIID.KEEP_ALIVE, keepAliveParams("opened")).catch((err) => this.#onKeepAliveError?.(err));
47420
+ }, this.#keepAliveIntervalMs);
47421
+ this.#keepAliveTimer.unref?.();
47422
+ }
47423
+ #stopKeepAlive() {
47424
+ if (this.#keepAliveTimer) {
47425
+ clearInterval(this.#keepAliveTimer);
47426
+ this.#keepAliveTimer = null;
47427
+ }
47428
+ }
47429
+ /**
47430
+ * Mint a FRESH single-use RTMP relay URL for the already-open monitor. Each
47431
+ * consumer needs its own relay URL, so call this once per ffmpeg/consumer.
47432
+ * Throws if the stream is not open.
47433
+ */
47434
+ async mintRelayUrl() {
47435
+ if (!this.#opened) throw new DreameError("camera stream is not open; call open() first");
47436
+ const info = await this.#relay.getStreamInfo(this.#iotId, {
47437
+ encrypted: false,
47438
+ ...this.#wakeTimeoutMs !== void 0 ? { wakeTimeoutMs: this.#wakeTimeoutMs } : {}
47439
+ });
47440
+ if (!info.relayUrl) throw new DreameError("no relay URL available for the open monitor");
47441
+ return info.relayUrl;
47442
+ }
47443
+ /** Start the two-way intercom session (control plane only; see module docs on mic uplink). */
47444
+ async startIntercom(opts) {
47445
+ return this.#action(MONITOR_AIID.VOICE_OPERATE, MONITOR_PIID.MONITOR_AUDIO_STATUS, intercomStartParams(opts));
47446
+ }
47447
+ /** Stop the two-way intercom session. */
47448
+ async stopIntercom() {
47449
+ return this.#action(MONITOR_AIID.VOICE_OPERATE, MONITOR_PIID.MONITOR_AUDIO_STATUS, intercomStopParams());
47450
+ }
47451
+ /** Set the fill-light brightness (40–100 = manual %, below 40 / 101 = auto). */
47452
+ async setFillLight(value) {
47453
+ return this.#action(MONITOR_AIID.PROPERTY_OPERATE, MONITOR_PIID.FILL_LIGHT_SET, fillLightParams(value));
47454
+ }
47455
+ /**
47456
+ * Device-side snapshot: the robot captures a still and uploads it to cloud
47457
+ * storage (read the result back from the `uploadStatus` property). Prefer a
47458
+ * frame-grab from the live stream for an instant image.
47459
+ */
47460
+ async takePhoto() {
47461
+ return this.#action(MONITOR_AIID.CAMERA_OPERATE, MONITOR_PIID.TAKE_PHOTO, takePhotoParams());
47462
+ }
47463
+ /**
47464
+ * Remote-drive the robot ("PTZ" substitute — the camera is fixed, the robot
47465
+ * moves). `spdv` = forward speed, `spdw` = turn; send repeatedly (~1 Hz) while
47466
+ * a direction is held, then `stop`. Only valid while the stream is open.
47467
+ */
47468
+ async drive(spdv, spdw) {
47469
+ this.#requireSession();
47470
+ if (!this.#device.setProperty) throw new DreameError("device does not support setProperty; cannot remote-drive");
47471
+ return this.#device.setProperty({
47472
+ siid: 4,
47473
+ piid: VACUUM_MOVE.REMOTE_STATE_PIID,
47474
+ value: remoteDriveValue(spdv, spdw)
47475
+ });
47476
+ }
47477
+ /** Remote-drive by named direction (forward/left/right/turnAround/stop). */
47478
+ async driveDirection(direction) {
47479
+ const [spdv, spdw] = DRIVE_DIRECTIONS[direction];
47480
+ return this.drive(spdv, spdw);
47481
+ }
47482
+ /** Start person-follow mode (robot tracks a detected person). */
47483
+ async startPersonFollow() {
47484
+ this.#requireSession();
47485
+ return this.#device.callAction(4, VACUUM_MOVE.WORK_AIID, [{
47486
+ piid: VACUUM_MOVE.MODE_PIID,
47487
+ value: VACUUM_MOVE.MODE_PERSON_FOLLOW
47488
+ }]);
47489
+ }
47490
+ /**
47491
+ * Universal stop for the current work mode — stops person-follow, spot-clean,
47492
+ * go-to-point, cruise, etc. (siid 4 / aiid 2).
47493
+ */
47494
+ async stopWork() {
47495
+ return this.#device.callAction(4, VACUUM_MOVE.STOP_AIID, []);
47496
+ }
47497
+ /** Stop person-follow mode (alias for {@link stopWork}). */
47498
+ async stopPersonFollow() {
47499
+ return this.stopWork();
47500
+ }
47501
+ /** Spot-clean the robot's current position (WorkMode SpotClean). */
47502
+ async spotClean() {
47503
+ return this.#device.callAction(4, VACUUM_MOVE.WORK_AIID, [{
47504
+ piid: VACUUM_MOVE.MODE_PIID,
47505
+ value: VACUUM_MOVE.MODE_SPOT_CLEAN
47506
+ }]);
47507
+ }
47508
+ /** Play one of the robot's sound clips (e.g. pet sounds) by id. */
47509
+ async playSound(soundId) {
47510
+ return this.#device.callAction(VOICE_SIID, VOICE_PLAY_SOUND_AIID, [{
47511
+ piid: VOICE_SOUND_PIID,
47512
+ value: soundId
47513
+ }]);
47514
+ }
47515
+ /** Play a named pet-teasing sound (meow/bark/footsteps/purring/tickTock). */
47516
+ async playPetSound(sound) {
47517
+ return this.playSound(PET_SOUNDS[sound]);
47518
+ }
47519
+ /** Run a common whole-robot action (startClean/pauseClean/stopClean/dockWash/autoEmpty). */
47520
+ async runVacuumAction(action) {
47521
+ const a = VACUUM_ACTIONS[action];
47522
+ return this.#device.callAction(a.siid, a.aiid, []);
47523
+ }
47524
+ /** Find-pet: cruise the home looking for the pet. */
47525
+ async findPet() {
47526
+ this.#requireSession();
47527
+ return this.#device.callAction(4, VACUUM_MOVE.WORK_AIID, [{
47528
+ piid: VACUUM_MOVE.MODE_PIID,
47529
+ value: VACUUM_MOVE.MODE_CRUISE
47530
+ }, {
47531
+ piid: VACUUM_MOVE.POINT_PIID,
47532
+ value: JSON.stringify({ findpet: "" })
47533
+ }]);
47534
+ }
47535
+ /** Send the robot to a map point (`spoint` current, `tpoint` targets). */
47536
+ async goToPoint(spoint, tpoint) {
47537
+ this.#requireSession();
47538
+ return this.#device.callAction(4, VACUUM_MOVE.WORK_AIID, [{
47539
+ piid: VACUUM_MOVE.MODE_PIID,
47540
+ value: VACUUM_MOVE.MODE_SPOT
47541
+ }, {
47542
+ piid: VACUUM_MOVE.POINT_PIID,
47543
+ value: JSON.stringify({
47544
+ spoint,
47545
+ tpoint
47546
+ })
47547
+ }]);
47548
+ }
47549
+ /** Toggle the fill light between auto (`full=false`) and full-on (`full=true`). */
47550
+ async setFillLightAuto(full) {
47551
+ if (!this.#device.setProperty) throw new DreameError("device does not support setProperty; cannot toggle fill light");
47552
+ return this.#device.setProperty({
47553
+ siid: 4,
47554
+ piid: VACUUM_MOVE.AUTO_SWITCH_PIID,
47555
+ value: JSON.stringify({
47556
+ k: "FillinLight",
47557
+ v: full ? 1 : 0
47558
+ })
47559
+ });
47560
+ }
47561
+ /**
47562
+ * Send the robot back to its dock to charge ("go home"). Works regardless of
47563
+ * stream state — usable as a standalone command or a PTZ "home" preset.
47564
+ */
47565
+ async returnToDock() {
47566
+ return this.#device.callAction(VACUUM_CHARGE.siid, VACUUM_CHARGE.aiid, []);
47567
+ }
47568
+ /** Locate the robot — it beeps. Works regardless of stream state. */
47569
+ async locate() {
47570
+ return this.#device.callAction(VACUUM_LOCATE.siid, VACUUM_LOCATE.aiid, []);
47571
+ }
47572
+ #requireSession() {
47573
+ if (!this.#opened) throw new DreameError("camera stream is not open; call open() first");
47574
+ }
47575
+ /** Stop the keep-alive loop and release the monitor on the device. Idempotent. */
47576
+ async close() {
47577
+ this.#stopKeepAlive();
47578
+ if (!this.#opened) {
47579
+ this.#session = null;
47580
+ return;
47581
+ }
47582
+ this.#opened = false;
47583
+ this.#logLine("close: stopping monitor");
47584
+ try {
47585
+ await this.#action(MONITOR_AIID.CAMERA_OPERATE, MONITOR_PIID.MONITOR_STATUS, stopMonitorParams());
47586
+ } finally {
47587
+ this.#session = null;
47588
+ }
47589
+ }
47590
+ };
47591
+ function asString(v) {
47592
+ return typeof v === "string" ? v : null;
47593
+ }
46614
47594
  var HEADER_SIZE = 27;
46615
47595
  var ANGLE_ABSENT = 32767;
46616
47596
  var FRAME_TYPE = {
@@ -48311,6 +49291,134 @@ function drawLabel(png, cx, cy, text, color, px) {
48311
49291
  originX += glyphW + gap;
48312
49292
  }
48313
49293
  }
49294
+ var NIBBLE_TO_BARS = {
49295
+ 10: 0,
49296
+ 11: 1,
49297
+ 12: 2,
49298
+ 13: 3,
49299
+ 14: 4
49300
+ };
49301
+ var isWifiNibble = (n) => n >= 10 && n <= 14;
49302
+ var NIBBLE_RGBA = {
49303
+ 2: [
49304
+ 176,
49305
+ 182,
49306
+ 190,
49307
+ 255
49308
+ ],
49309
+ 3: [
49310
+ 242,
49311
+ 244,
49312
+ 247,
49313
+ 255
49314
+ ],
49315
+ 10: [
49316
+ 229,
49317
+ 234,
49318
+ 238,
49319
+ 255
49320
+ ],
49321
+ 11: [
49322
+ 217,
49323
+ 226,
49324
+ 239,
49325
+ 255
49326
+ ],
49327
+ 12: [
49328
+ 205,
49329
+ 218,
49330
+ 239,
49331
+ 255
49332
+ ],
49333
+ 13: [
49334
+ 161,
49335
+ 189,
49336
+ 242,
49337
+ 255
49338
+ ],
49339
+ 14: [
49340
+ 129,
49341
+ 168,
49342
+ 245,
49343
+ 255
49344
+ ]
49345
+ };
49346
+ var poseOrNull = (x, y, a) => x === 0 && y === 0 ? null : {
49347
+ x,
49348
+ y,
49349
+ angle: a === ANGLE_ABSENT ? 0 : a
49350
+ };
49351
+ function decodeWifiSignalMap(input, opts = {}) {
49352
+ const inflated = typeof input === "string" ? unwrapEnvelope(input, opts) : looksLikeBase64Zlib(input) ? unwrapEnvelope(input.toString("latin1"), opts) : input;
49353
+ const header = parseMapHeader(inflated);
49354
+ const { width, height, gridSize, left, top } = header;
49355
+ const pixelEnd = HEADER_SIZE + width * height;
49356
+ const grid = inflated.subarray(HEADER_SIZE, pixelEnd);
49357
+ const cells = new Uint8Array(width * height);
49358
+ for (let i = 0; i < cells.length; i++) cells[i] = (grid[i] ?? 0) & 15;
49359
+ const dimensions = {
49360
+ left,
49361
+ top,
49362
+ width,
49363
+ height,
49364
+ gridSize
49365
+ };
49366
+ const robot = poseOrNull(header.robotX, header.robotY, header.robotA);
49367
+ const dock = poseOrNull(header.chargerX, header.chargerY, header.chargerA);
49368
+ const signalAt = (worldX, worldY) => {
49369
+ if (gridSize <= 0) return null;
49370
+ const col = Math.floor((worldX - left) / gridSize);
49371
+ const row = Math.floor((worldY - top) / gridSize);
49372
+ if (col < 0 || col >= width || row < 0 || row >= height) return null;
49373
+ const nibble = cells[row * width + col] ?? 0;
49374
+ return isWifiNibble(nibble) ? NIBBLE_TO_BARS[nibble] : null;
49375
+ };
49376
+ return {
49377
+ dimensions,
49378
+ robot,
49379
+ dock,
49380
+ cells,
49381
+ signalAt,
49382
+ currentSignal: robot ? signalAt(robot.x, robot.y) : null
49383
+ };
49384
+ }
49385
+ function renderWifiSignalPng(map, opts = {}) {
49386
+ const scale = Math.max(1, Math.floor(opts.scale ?? 4));
49387
+ const { width, height, left, top, gridSize } = map.dimensions;
49388
+ const png = new import_png.PNG({
49389
+ width: width * scale,
49390
+ height: height * scale,
49391
+ colorType: 6
49392
+ });
49393
+ for (let row = 0; row < height; row++) for (let col = 0; col < width; col++) {
49394
+ const rgba = NIBBLE_RGBA[map.cells[row * width + col] ?? 0];
49395
+ if (!rgba) continue;
49396
+ for (let dy = 0; dy < scale; dy++) for (let dx = 0; dx < scale; dx++) {
49397
+ const o = col * scale + dx + (row * scale + dy) * width * scale << 2;
49398
+ png.data[o] = rgba[0];
49399
+ png.data[o + 1] = rgba[1];
49400
+ png.data[o + 2] = rgba[2];
49401
+ png.data[o + 3] = rgba[3];
49402
+ }
49403
+ }
49404
+ if ((opts.markRobot ?? true) && map.robot && gridSize > 0) {
49405
+ const cx = Math.floor((map.robot.x - left) / gridSize) * scale + Math.floor(scale / 2);
49406
+ const cy = Math.floor((map.robot.y - top) / gridSize) * scale + Math.floor(scale / 2);
49407
+ const r = Math.max(2, scale);
49408
+ for (let dy = -r; dy <= r; dy++) for (let dx = -r; dx <= r; dx++) {
49409
+ if (dx * dx + dy * dy > r * r) continue;
49410
+ const x = cx + dx;
49411
+ const y = cy + dy;
49412
+ if (x < 0 || y < 0 || x >= width * scale || y >= height * scale) continue;
49413
+ const o = x + y * width * scale << 2;
49414
+ png.data[o] = 255;
49415
+ png.data[o + 1] = 69;
49416
+ png.data[o + 2] = 0;
49417
+ png.data[o + 3] = 255;
49418
+ }
49419
+ }
49420
+ return import_png.PNG.sync.write(png);
49421
+ }
48314
49422
  var OSS_DOWNLOAD_PATH = "/dreame-user-iot/iotfile/getDownloadUrl";
48315
49423
  var DEFAULT_TTL_MS = 1800 * 1e3;
48316
49424
  var OssFetcher = class {
@@ -48789,11 +49897,11 @@ var VacuumDevice = class _VacuumDevice extends BaseDevice$1 {
48789
49897
  if (zones.length === 0) throw new RangeError("cleanZones: zones must not be empty");
48790
49898
  this.#requireCap(this.#caps.canCleanPerRoom, "cleanZones", "per-room cleaning");
48791
49899
  const { repeats, fan, water } = this.#resolveCleanOpts(opts);
48792
- const areas = zones.map((z4) => [
48793
- Math.round(z4.x0),
48794
- Math.round(z4.y0),
48795
- Math.round(z4.x1),
48796
- Math.round(z4.y1),
49900
+ const areas = zones.map((z7) => [
49901
+ Math.round(z7.x0),
49902
+ Math.round(z7.y0),
49903
+ Math.round(z7.x1),
49904
+ Math.round(z7.y1),
48797
49905
  repeats,
48798
49906
  fan,
48799
49907
  water
@@ -48929,6 +50037,60 @@ var VacuumDevice = class _VacuumDevice extends BaseDevice$1 {
48929
50037
  };
48930
50038
  }
48931
50039
  /**
50040
+ * Fetch the robot's Wi-Fi signal map — the last stored coverage heatmap, with
50041
+ * NO robot movement. Requests it (map service `wifiMap` action, siid 6 aiid 4),
50042
+ * polls `PropWifiMap` (6/15) for the OSS object it advertises, fetches + decodes
50043
+ * it into a per-cell signal grid + geometry + robot/dock poses. The signal at
50044
+ * any world point (incl. the robot's own position) is a lookup — see
50045
+ * {@link WifiSignalMap.signalAt} / {@link WifiSignalMap.currentSignal}.
50046
+ *
50047
+ * Pass `requestFresh: false` to skip the request and read whatever is already
50048
+ * advertised. Throws if no Wi-Fi map exists yet (never run on this device).
50049
+ */
50050
+ async getWifiSignalMap(input = {}) {
50051
+ if (input.requestFresh !== false) try {
50052
+ await this.callAction(6, 4, []);
50053
+ } catch {}
50054
+ const pollMs = input.pollMs ?? 2500;
50055
+ const deadline = Date.now() + (input.maxWaitMs ?? 2e4);
50056
+ let objectName = "";
50057
+ for (;;) {
50058
+ const val = (await this.refreshProperties([{
50059
+ siid: 6,
50060
+ piid: 15
50061
+ }]))[0]?.value;
50062
+ if (typeof val === "string" && val.length > 0) {
50063
+ objectName = val;
50064
+ break;
50065
+ }
50066
+ if (Date.now() + pollMs >= deadline) throw new DreameError("wifi signal map not available (none stored yet, or request timed out)");
50067
+ await new Promise((r) => setTimeout(r, pollMs));
50068
+ }
50069
+ const filename = objectName.replace(/^\d+,/, "");
50070
+ return decodeWifiSignalMap(await this.#fetchMapBlob({
50071
+ filename,
50072
+ ...input.fetcher ? { fetcher: input.fetcher } : {},
50073
+ ...input.host ? { host: input.host } : {},
50074
+ ...input.timeoutMs !== void 0 ? { timeoutMs: input.timeoutMs } : {},
50075
+ ...input.signal ? { signal: input.signal } : {}
50076
+ }), this.#decodeOpts(input));
50077
+ }
50078
+ /**
50079
+ * Render the Wi-Fi signal map as a simple PNG heatmap image (weakest→strongest,
50080
+ * unreached grey, robot marker) — ready to display. Convenience over
50081
+ * {@link getWifiSignalMap} + {@link renderWifiSignalPng}.
50082
+ */
50083
+ async getWifiSignalImage(input = {}) {
50084
+ return renderWifiSignalPng(await this.getWifiSignalMap(input), input);
50085
+ }
50086
+ /**
50087
+ * The one-call "current Wi-Fi signal": fetches the signal map and returns the
50088
+ * bars (1–4, 0 = unreached) at the robot's own position, or null when unknown.
50089
+ */
50090
+ async getCurrentSignal(input = {}) {
50091
+ return (await this.getWifiSignalMap(input)).currentSignal;
50092
+ }
50093
+ /**
48932
50094
  * The merge base for live P-frame streaming — always an INFLATED frame buffer.
48933
50095
  * An I-frame (re)seeds it; each P-frame merge replaces it with the merged
48934
50096
  * inflated buffer (so further P-frames stack). `null` until the first I-frame
@@ -48994,6 +50156,44 @@ var VacuumDevice = class _VacuumDevice extends BaseDevice$1 {
48994
50156
  this.emit("map", frame);
48995
50157
  return frame;
48996
50158
  }
50159
+ /**
50160
+ * Build a {@link DreameCameraController} for this device's camera, wired to
50161
+ * both planes it needs: the MIoT action transport (this device handle) and a
50162
+ * fresh Aliyun {@link DreameVideoSession} for the relay. Call
50163
+ * `controller.open()` to autonomously cold-start the stream and get a pullable
50164
+ * RTMP URL, then `controller.close()` to release it.
50165
+ *
50166
+ * The camera's per-session privacy gate means a coded camera needs its PIN:
50167
+ * pass `accessCode` (the code set at pairing). `iotId` is resolved from the
50168
+ * device's cloud video profile when not supplied.
50169
+ */
50170
+ async createCameraController(opts) {
50171
+ const session = this.currentSession();
50172
+ const region = this.region;
50173
+ const relay = opts?.relay ?? new DreameVideoSession({
50174
+ session,
50175
+ region
50176
+ });
50177
+ const iotId = opts?.iotId ?? (await getDeviceVideoProfile({
50178
+ session,
50179
+ region,
50180
+ did: this.deviceId
50181
+ })).iotId;
50182
+ if (!iotId) throw new DreameError("device has no LinkVisual iotId; not a camera device or not provisioned");
50183
+ return new DreameCameraController({
50184
+ device: this,
50185
+ relay,
50186
+ iotId,
50187
+ accountId: session.uid,
50188
+ ...opts?.vendor ? { vendor: opts.vendor } : {},
50189
+ ...opts?.accessCode !== void 0 ? { accessCode: opts.accessCode } : {},
50190
+ ...opts?.area ? { area: opts.area } : {},
50191
+ ...opts?.keepAliveIntervalMs !== void 0 ? { keepAliveIntervalMs: opts.keepAliveIntervalMs } : {},
50192
+ ...opts?.wakeTimeoutMs !== void 0 ? { wakeTimeoutMs: opts.wakeTimeoutMs } : {},
50193
+ ...opts?.onKeepAliveError ? { onKeepAliveError: opts.onKeepAliveError } : {},
50194
+ ...opts?.log ? { log: opts.log } : {}
50195
+ });
50196
+ }
48997
50197
  /** Props worth seeding on start() / polling — exported for the facade. */
48998
50198
  static DEFAULT_PROPS = [
48999
50199
  VACUUM_PROP.STATE,
@@ -49554,7 +50754,7 @@ function isRecord3(v) {
49554
50754
  function asNumber(v, fallback) {
49555
50755
  return typeof v === "number" && Number.isFinite(v) ? v : fallback;
49556
50756
  }
49557
- function asString(v, fallback) {
50757
+ function asString2(v, fallback) {
49558
50758
  return typeof v === "string" ? v : fallback;
49559
50759
  }
49560
50760
  function mapIdFromIndex(mapIndex) {
@@ -49634,7 +50834,7 @@ function parseZones(list, includeTimes) {
49634
50834
  out.push({
49635
50835
  zoneId: asNumber(id, 0),
49636
50836
  path: extractPathCoords(data["path"]),
49637
- name: asString(data["name"], ""),
50837
+ name: asString2(data["name"], ""),
49638
50838
  zoneType: asNumber(data["type"], 0),
49639
50839
  shapeType: includeTimes ? asNumber(data["shapeType"], 0) : 0,
49640
50840
  area: includeTimes ? asNumber(data["area"], 0) : 0,
@@ -49653,7 +50853,7 @@ function parseSpotAreas(list) {
49653
50853
  out.push({
49654
50854
  areaId: Math.trunc(asNumber(id, 0)),
49655
50855
  path: extractPathCoords(data["path"]),
49656
- name: asString(data["name"], ""),
50856
+ name: asString2(data["name"], ""),
49657
50857
  shapeType: asNumber(data["shapeType"], 0),
49658
50858
  area: asNumber(data["area"], 0)
49659
50859
  });
@@ -49712,7 +50912,7 @@ function parseMowerMap(mapJsonStr) {
49712
50912
  contours: parseContours(parsePolygonList(data["contours"])),
49713
50913
  boundary: parseBoundary(data["boundary"]),
49714
50914
  totalArea: asNumber(data["totalArea"], 0),
49715
- name: asString(data["name"], ""),
50915
+ name: asString2(data["name"], ""),
49716
50916
  mapIndex,
49717
50917
  mapId: mapIdFromIndex(mapIndex),
49718
50918
  mowPaths: [],
@@ -50111,7 +51311,7 @@ var MowerDevice = class _MowerDevice extends BaseDevice$1 {
50111
51311
  async startMowingZones(zoneIds) {
50112
51312
  this.#requireCap(this.#caps.canMowZones, "startMowingZones", "zone mowing");
50113
51313
  if (zoneIds.length === 0) throw new RangeError("startMowingZones: zoneIds must not be empty");
50114
- return this.#sendTask(buildZonePayload(zoneIds.map((z4) => Math.trunc(z4))));
51314
+ return this.#sendTask(buildZonePayload(zoneIds.map((z7) => Math.trunc(z7))));
50115
51315
  }
50116
51316
  /** Edge / contour mowing (2:50 o:101). Contour ids are two-int pairs [[1,0]]. */
50117
51317
  async startMowingEdges(contourIds) {
@@ -50503,6 +51703,12 @@ objectType({
50503
51703
  generatedAt: numberType()
50504
51704
  })
50505
51705
  });
51706
+ Buffer.from([
51707
+ 0,
51708
+ 0,
51709
+ 0,
51710
+ 1
51711
+ ]);
50506
51712
  //#endregion
50507
51713
  //#region ../types/dist/event-category-zAv7pMUz.mjs
50508
51714
  var EventCategory = /* @__PURE__ */ function(EventCategory) {
@@ -55911,6 +57117,12 @@ Object.fromEntries([
55911
57117
  icon: "move",
55912
57118
  order: 40
55913
57119
  },
57120
+ {
57121
+ id: "navigation",
57122
+ label: "Navigation",
57123
+ icon: "compass",
57124
+ order: 41
57125
+ },
55914
57126
  {
55915
57127
  id: "consumables",
55916
57128
  label: "Consumables",
@@ -74306,6 +75518,21 @@ var NetworkLinkStatusSchema = object({
74306
75518
  /** Ms epoch of the last observation. Lets consumers reason about freshness. */
74307
75519
  lastUpdated: number()
74308
75520
  });
75521
+ /** The slice a provider seeds before its first read: nothing is known yet. */
75522
+ var NETWORK_LINK_UNKNOWN = {
75523
+ type: "unknown",
75524
+ signalPercent: null,
75525
+ lastUpdated: 0
75526
+ };
75527
+ /**
75528
+ * Normalise a signal the firmware reports as BARS (0..`maxBars`) to 0..100.
75529
+ * Out-of-range or non-finite input is not a reading: `null`.
75530
+ */
75531
+ function signalPercentFromBars(bars, maxBars) {
75532
+ if (bars === void 0 || !Number.isFinite(bars) || maxBars <= 0) return null;
75533
+ if (bars < 0 || bars > maxBars) return null;
75534
+ return Math.round(bars / maxBars * 100);
75535
+ }
74309
75536
  var networkLinkCapability = {
74310
75537
  name: "network-link",
74311
75538
  scope: "device",
@@ -74334,11 +75561,7 @@ onStatusChanged: { data: object({
74334
75561
  status: {
74335
75562
  schema: NetworkLinkStatusSchema,
74336
75563
  kind: "push",
74337
- empty: {
74338
- type: "unknown",
74339
- signalPercent: null,
74340
- lastUpdated: 0
74341
- }
75564
+ empty: NETWORK_LINK_UNKNOWN
74342
75565
  },
74343
75566
  /**
74344
75567
  * Runtime-state slice — every provider stores the same shape under
@@ -79494,6 +80717,424 @@ var ptzAutotrackCapability = {
79494
80717
  */
79495
80718
  durability: "session"
79496
80719
  };
80720
+ /**
80721
+ * `navigation` — a device-scoped capability that natively expresses the FULL
80722
+ * navigation / action surface of a robot that DRIVES ITSELF and carries an
80723
+ * on-board camera (the Dreame robot-vacuum camera is the first provider).
80724
+ *
80725
+ * Why a NEW cap rather than overloading `ptz`:
80726
+ * - `ptz` moves a *gimbal* on a fixed camera (pan/tilt/zoom of the lens). A
80727
+ * robot vacuum has no gimbal — the whole chassis drives, turns and spins.
80728
+ * The two are different physical models: PTZ is absolute-position + presets,
80729
+ * navigation is momentary drive nudges + discrete robot ACTIONS
80730
+ * (dock / spot-clean / follow-pet / go-to-point / …).
80731
+ * - This cap is the SOURCE OF TRUTH. Two consumers adapt from it rather than
80732
+ * the reverse:
80733
+ * 1. a native CamStack navigation panel (data-driven from `listActions`
80734
+ * / `getOptions`), and
80735
+ * 2. the PTZ surface — a thin adapter mimics `ptz` from `navigation` so a
80736
+ * robot camera shows up in the existing PTZ control path without every
80737
+ * PTZ provider learning about robots. The mapping lives in the adapter,
80738
+ * not here (see the addon design note):
80739
+ * ptz.continuousMove({pan,tilt}) → navigation.move({pan,tilt})
80740
+ * ptz.stop() → navigation.stop()
80741
+ * ptz.goHome() → navigation.runAction('goHome')
80742
+ * ptz.getPresets() → navigation.listActions() (id→preset)
80743
+ * ptz.goToPreset(id) → navigation.runAction(id)
80744
+ *
80745
+ * ## Continuous drive
80746
+ *
80747
+ * `move` is MOMENTARY. Fluid navigation comes from the UI (or the PTZ adapter)
80748
+ * sending `move({pan,tilt})` REPEATEDLY at ~1 Hz while a direction is held, and
80749
+ * one `stop()` on release — exactly like the robot app's remote-drive joystick.
80750
+ * The provider forwards EACH `move` to one drive write; it must NOT debounce or
80751
+ * coalesce them. The UI owns the cadence.
80752
+ *
80753
+ * ## The action dictionary
80754
+ *
80755
+ * The discrete controls (dock / locate / spot-clean / follow / flash / sounds)
80756
+ * are a DATA-DRIVEN dictionary the cap exposes via `listActions()`. Each entry
80757
+ * carries `{ id, kind, label, icon }` (plus `soundId` for sound entries) so the
80758
+ * native panel AND the PTZ mimic render buttons WITHOUT hardcoding a
80759
+ * vendor-specific list. `kind: 'action'` entries are triggered with
80760
+ * `runAction({ actionId })`; `kind: 'sound'` entries with `playSound({ soundId })`
80761
+ * (the entry carries the `soundId` to pass). The general primitives — `move`,
80762
+ * `stop`, `goToPoint` — stay as first-class methods, not dictionary entries.
80763
+ *
80764
+ * NOTE (provider wiring): the first provider (Dreame) drives this with the RAW
80765
+ * `callAction(siid,aiid,in)` / `setProperty({siid,piid,value})` MIoT primitives
80766
+ * that the currently-published `@apocaliss92/nodedreame` already exposes on
80767
+ * every device handle. A future nodedreame publish adds a typed
80768
+ * `DreameCameraController` (drive / playPetSound / spotClean / findPet / …); the
80769
+ * provider can then swap the raw calls for the typed methods with no change to
80770
+ * THIS contract.
80771
+ */
80772
+ /**
80773
+ * A momentary drive nudge. The robot MOVES (no gimbal) for as long as the caller
80774
+ * keeps sending nudges (~1 Hz); an explicit `stop` (or letting the nudges lapse)
80775
+ * halts it.
80776
+ *
80777
+ * - `pan` — turn: negative = left, positive = right, 0 = straight.
80778
+ * - `tilt` — throttle: positive = forward, negative = spin / turn-around.
80779
+ * - `speed` — optional intensity hint [0, 1]; the provider may scale the drive
80780
+ * vector by it (drivers without proportional drive ignore it).
80781
+ *
80782
+ * `pan` / `tilt` are normalized [-1, 1]. Both optional so a caller can nudge one
80783
+ * axis alone; an all-undefined nudge is a no-op.
80784
+ */
80785
+ var NavigationMoveCommandSchema = object({
80786
+ pan: number().min(-1).max(1).optional(),
80787
+ tilt: number().min(-1).max(1).optional(),
80788
+ speed: number().min(0).max(1).optional()
80789
+ });
80790
+ /**
80791
+ * The enumerated discrete actions a navigation-capable robot can perform via
80792
+ * `runAction`. This is the CLOSED vocabulary; a given device advertises the
80793
+ * subset it supports through `listActions`. Sounds are NOT here — they go through
80794
+ * `playSound` (see the `sound` dictionary entries).
80795
+ */
80796
+ var NavigationActionIdSchema = _enum([
80797
+ "goHome",
80798
+ "locate",
80799
+ "spotClean",
80800
+ "findPet",
80801
+ "personFollow",
80802
+ "stop",
80803
+ "startClean",
80804
+ "pauseClean",
80805
+ "dockWash",
80806
+ "autoEmpty",
80807
+ "flashOn",
80808
+ "flashOff"
80809
+ ]);
80810
+ /** Whether a dictionary entry is a `runAction` action or a `playSound` sound. */
80811
+ var NavigationEntryKindSchema = _enum(["action", "sound"]);
80812
+ /**
80813
+ * One entry in the navigation action dictionary — the DATA-DRIVEN unit both the
80814
+ * native panel and the PTZ mimic render as a button.
80815
+ *
80816
+ * - `id` — stable id. For `kind:'action'` it is a {@link NavigationActionId}
80817
+ * (pass to `runAction`); for `kind:'sound'` it is a namespaced id
80818
+ * (`sound:meow`) whose `soundId` is passed to `playSound`.
80819
+ * - `icon` — icon HINT (lucide-style name; the UI maps it to its own set).
80820
+ * - `label` — operator-facing English label.
80821
+ * - `soundId` — wire sound id, present only on `kind:'sound'` entries.
80822
+ * - `enabled` — per-device FEATURE FLAG. `listActions` reports it so the UI /
80823
+ * PTZ render ONLY enabled entries. Data-driven: the provider
80824
+ * flips it from config, never by editing code.
80825
+ */
80826
+ var NavigationActionEntrySchema = object({
80827
+ id: string(),
80828
+ kind: NavigationEntryKindSchema,
80829
+ label: string(),
80830
+ icon: string(),
80831
+ /** Present only on `kind:'sound'` entries — the id to pass to `playSound`. */
80832
+ soundId: number().int().optional(),
80833
+ /** Per-device feature flag — render this entry only when true. */
80834
+ enabled: boolean()
80835
+ });
80836
+ /**
80837
+ * Canonical dictionary of every navigation control (discrete actions + sounds)
80838
+ * with its default label + icon hint. A provider filters this to the subset a
80839
+ * device supports (and may override a label). Exported so the provider AND the
80840
+ * UI share ONE list instead of re-deriving labels / ids independently.
80841
+ *
80842
+ * Icons are lucide-style hints; the viewer maps them to its own icon set. Every
80843
+ * catalog default is `enabled: true`; a provider overrides per-device from
80844
+ * config (data-driven — enabling / disabling one later is a config change, not a
80845
+ * code change).
80846
+ */
80847
+ var NAVIGATION_ACTION_CATALOG = [
80848
+ {
80849
+ id: "startClean",
80850
+ kind: "action",
80851
+ label: "Start cleaning",
80852
+ icon: "play",
80853
+ enabled: true
80854
+ },
80855
+ {
80856
+ id: "pauseClean",
80857
+ kind: "action",
80858
+ label: "Pause cleaning",
80859
+ icon: "pause",
80860
+ enabled: true
80861
+ },
80862
+ {
80863
+ id: "stop",
80864
+ kind: "action",
80865
+ label: "Stop",
80866
+ icon: "hand",
80867
+ enabled: true
80868
+ },
80869
+ {
80870
+ id: "goHome",
80871
+ kind: "action",
80872
+ label: "Return to dock",
80873
+ icon: "home",
80874
+ enabled: true
80875
+ },
80876
+ {
80877
+ id: "locate",
80878
+ kind: "action",
80879
+ label: "Locate",
80880
+ icon: "bell",
80881
+ enabled: true
80882
+ },
80883
+ {
80884
+ id: "spotClean",
80885
+ kind: "action",
80886
+ label: "Spot clean here",
80887
+ icon: "crosshair",
80888
+ enabled: true
80889
+ },
80890
+ {
80891
+ id: "findPet",
80892
+ kind: "action",
80893
+ label: "Find pet",
80894
+ icon: "paw-print",
80895
+ enabled: true
80896
+ },
80897
+ {
80898
+ id: "personFollow",
80899
+ kind: "action",
80900
+ label: "Follow me",
80901
+ icon: "footprints",
80902
+ enabled: true
80903
+ },
80904
+ {
80905
+ id: "dockWash",
80906
+ kind: "action",
80907
+ label: "Wash mop",
80908
+ icon: "droplets",
80909
+ enabled: true
80910
+ },
80911
+ {
80912
+ id: "autoEmpty",
80913
+ kind: "action",
80914
+ label: "Empty dustbin",
80915
+ icon: "trash-2",
80916
+ enabled: true
80917
+ },
80918
+ {
80919
+ id: "flashOn",
80920
+ kind: "action",
80921
+ label: "Flash on",
80922
+ icon: "flashlight",
80923
+ enabled: true
80924
+ },
80925
+ {
80926
+ id: "flashOff",
80927
+ kind: "action",
80928
+ label: "Flash off",
80929
+ icon: "flashlight-off",
80930
+ enabled: true
80931
+ },
80932
+ {
80933
+ id: "sound:meow",
80934
+ kind: "sound",
80935
+ label: "Meow",
80936
+ icon: "cat",
80937
+ soundId: 684,
80938
+ enabled: true
80939
+ },
80940
+ {
80941
+ id: "sound:bark",
80942
+ kind: "sound",
80943
+ label: "Woof",
80944
+ icon: "dog",
80945
+ soundId: 685,
80946
+ enabled: true
80947
+ },
80948
+ {
80949
+ id: "sound:footsteps",
80950
+ kind: "sound",
80951
+ label: "Footsteps",
80952
+ icon: "footprints",
80953
+ soundId: 686,
80954
+ enabled: true
80955
+ },
80956
+ {
80957
+ id: "sound:purring",
80958
+ kind: "sound",
80959
+ label: "Purring",
80960
+ icon: "cat",
80961
+ soundId: 687,
80962
+ enabled: true
80963
+ },
80964
+ {
80965
+ id: "sound:tickTock",
80966
+ kind: "sound",
80967
+ label: "Tick-tock",
80968
+ icon: "clock",
80969
+ soundId: 688,
80970
+ enabled: true
80971
+ }
80972
+ ];
80973
+ /** Coordinates for `goToPoint` — a point on the robot's live map. */
80974
+ var NavigationPointSchema = object({
80975
+ x: number(),
80976
+ y: number()
80977
+ });
80978
+ /**
80979
+ * Per-device FEATURE-FLAG report for the general (non-dictionary) primitives.
80980
+ * The cap reports which are enabled so the UI / PTZ render only the controls
80981
+ * that are turned on for THIS device. Data-driven: the provider derives these
80982
+ * from config + probe, never hardcoded in the UI. The per-DICTIONARY-entry flags
80983
+ * live on {@link NavigationActionEntrySchema.enabled}; these gate the primitives
80984
+ * that are not dictionary entries.
80985
+ *
80986
+ * - `move` / `stop` — the momentary drive joystick.
80987
+ * - `goToPoint` — send-to-map-coordinate. Ships OFF on Dreame until the
80988
+ * map-coordinate plumbing is wired.
80989
+ * - `runAction` — the discrete action buttons (dictionary `kind:'action'`).
80990
+ * - `playSound` — the sound buttons (dictionary `kind:'sound'`).
80991
+ * - `light` — the on/off fill-light toggle (works anytime).
80992
+ * - `lightMode` — the auto/manual selector + manual level slider (a
80993
+ * camera-service control; needs an active stream).
80994
+ */
80995
+ var NavigationFeaturesSchema = object({
80996
+ move: boolean(),
80997
+ stop: boolean(),
80998
+ goToPoint: boolean(),
80999
+ runAction: boolean(),
81000
+ playSound: boolean(),
81001
+ light: boolean(),
81002
+ lightMode: boolean()
81003
+ });
81004
+ /** Light mode: `auto` lets the camera choose brightness; `manual` uses `level`. */
81005
+ var NavigationLightModeSchema = _enum(["auto", "manual"]);
81006
+ /**
81007
+ * Live navigation state so the UI can reflect what the robot is doing:
81008
+ * - `mode` — coarse activity (idle / cleaning / following / …).
81009
+ * - `following` — person/pet follow is currently armed.
81010
+ * - `flash` — the on-camera fill light is on.
81011
+ * - `lightMode` — auto vs manual fill-light mode.
81012
+ * - `lightLevel` — manual fill-light level (40..100); meaningful when
81013
+ * `lightMode === 'manual'`.
81014
+ */
81015
+ var NavigationStatusSchema = object({
81016
+ mode: _enum([
81017
+ "idle",
81018
+ "cleaning",
81019
+ "spot",
81020
+ "following",
81021
+ "goto",
81022
+ "returning",
81023
+ "paused",
81024
+ "unknown"
81025
+ ]),
81026
+ following: boolean(),
81027
+ flash: boolean(),
81028
+ lightMode: NavigationLightModeSchema,
81029
+ lightLevel: number().min(40).max(100),
81030
+ /** Ms epoch when the slice was last updated. */
81031
+ lastChangedAt: number()
81032
+ });
81033
+ /**
81034
+ * Runtime-state slice owned by this cap (kernel-managed: validated, mirrored,
81035
+ * observable). Adds `lastFetchedAt` on top of the status shape per the
81036
+ * convention.
81037
+ */
81038
+ var NavigationRuntimeStateSchema = NavigationStatusSchema.extend({ lastFetchedAt: number() });
81039
+ var navigationCapability = {
81040
+ name: "navigation",
81041
+ scope: "device",
81042
+ deviceNative: true,
81043
+ mode: "singleton",
81044
+ deviceTypes: [DeviceType.Camera],
81045
+ deviceConfig: { ui: {
81046
+ kind: "widget",
81047
+ widgetId: "host/navigation-panel",
81048
+ tab: "navigation",
81049
+ topTab: true,
81050
+ label: "Navigation",
81051
+ order: 0
81052
+ } },
81053
+ methods: {
81054
+ /**
81055
+ * Momentary drive nudge (the robot moves). `protected` — mirrors
81056
+ * `ptz.continuousMove` so the Viewer navigation panel (and the PTZ-mimic
81057
+ * path) works for any authenticated user, not admin-only. The UI sends
81058
+ * these at ~1 Hz while a control is held; the provider forwards each one to
81059
+ * a single drive write WITHOUT debouncing.
81060
+ */
81061
+ move: method(NavigationMoveCommandSchema.extend({ deviceId: number() }), _void(), { kind: "mutation" }),
81062
+ /** Halt all motion immediately (zero drive vector). */
81063
+ stop: method(object({ deviceId: number() }), _void(), { kind: "mutation" }),
81064
+ /** Send the robot to a point on its live map. */
81065
+ goToPoint: method(NavigationPointSchema.extend({ deviceId: number() }), _void(), { kind: "mutation" }),
81066
+ /**
81067
+ * Enumerate the discrete controls THIS device supports (data-driven UI +
81068
+ * PTZ mimic). Camera-probed subset of {@link NAVIGATION_ACTION_CATALOG}.
81069
+ */
81070
+ listActions: method(object({ deviceId: number() }), array(NavigationActionEntrySchema)),
81071
+ /**
81072
+ * Run one discrete action (a `kind:'action'` dictionary entry). Invalid /
81073
+ * unsupported action ids are rejected by the provider.
81074
+ */
81075
+ runAction: method(object({
81076
+ deviceId: number(),
81077
+ actionId: NavigationActionIdSchema
81078
+ }), _void(), { kind: "mutation" }),
81079
+ /** Play a sound by its wire id (the `soundId` of a `kind:'sound'` entry). */
81080
+ playSound: method(object({
81081
+ deviceId: number(),
81082
+ soundId: number().int()
81083
+ }), _void(), { kind: "mutation" }),
81084
+ /**
81085
+ * Turn the on-camera fill light on / off (the `OpenFullLight` control —
81086
+ * works anytime, no active stream required).
81087
+ */
81088
+ setLightOn: method(object({
81089
+ deviceId: number(),
81090
+ on: boolean()
81091
+ }), _void(), { kind: "mutation" }),
81092
+ /**
81093
+ * Set the fill-light mode (auto vs manual). `manual` optionally carries the
81094
+ * initial `level`. The auto/manual + level control is a CAMERA-service
81095
+ * action that generally needs an active camera stream/monitor session — the
81096
+ * UI shows the manual level slider ONLY when `mode === 'manual'`.
81097
+ */
81098
+ setLightMode: method(object({
81099
+ deviceId: number(),
81100
+ mode: NavigationLightModeSchema,
81101
+ level: number().min(40).max(100).optional()
81102
+ }), _void(), { kind: "mutation" }),
81103
+ /** Set the MANUAL fill-light level (40..100). Implies `manual` mode. */
81104
+ setLightLevel: method(object({
81105
+ deviceId: number(),
81106
+ level: number().min(40).max(100)
81107
+ }), _void(), { kind: "mutation" }),
81108
+ /**
81109
+ * Per-device FEATURE-FLAG report for the general primitives — drives which
81110
+ * controls the UI shows (the per-entry flags for the dictionary come back on
81111
+ * `listActions`).
81112
+ */
81113
+ getFeatures: method(object({ deviceId: number() }), NavigationFeaturesSchema)
81114
+ },
81115
+ events: { onStatusChanged: { data: object({
81116
+ deviceId: number(),
81117
+ status: NavigationStatusSchema
81118
+ }) } },
81119
+ status: {
81120
+ schema: NavigationStatusSchema,
81121
+ kind: "push"
81122
+ },
81123
+ /**
81124
+ * Runtime-state slice mirrored by the kernel. The navigation panel watches it
81125
+ * for live mode / follow / flash changes.
81126
+ */
81127
+ runtimeState: NavigationRuntimeStateSchema,
81128
+ /**
81129
+ * Runtime-state durability: **session** — like `vacuum-control`, a restored
81130
+ * `mode: cleaning` / `following: true` is a robot that is not actually doing
81131
+ * that. The live handle re-publishes on connect.
81132
+ *
81133
+ * See `RuntimeStateDurability`. Enforced by
81134
+ * `scripts/check-runtime-state-durability.ts`.
81135
+ */
81136
+ durability: "session"
81137
+ };
79497
81138
  DeviceType.Camera, DeviceType.Sensor, DeviceType.Switch, method(object({ deviceId: number().int().nonnegative() }), object({ success: literal(true) }), {
79498
81139
  kind: "mutation",
79499
81140
  auth: "admin"
@@ -82769,6 +84410,7 @@ var DEVICE_LOCAL_STATE_CAPS = {
82769
84410
  motionTrigger: motionTriggerCapability,
82770
84411
  motionZones: motionZonesCapability,
82771
84412
  nativeObjectDetection: nativeObjectDetectionCapability,
84413
+ navigation: navigationCapability,
82772
84414
  networkLink: networkLinkCapability,
82773
84415
  notifier: notifierCapability,
82774
84416
  numericSensor: numericSensorCapability,
@@ -86623,6 +88265,66 @@ Object.freeze({
86623
88265
  addonId: null,
86624
88266
  access: "create"
86625
88267
  },
88268
+ "navigation.getFeatures": {
88269
+ capName: "navigation",
88270
+ capScope: "device",
88271
+ addonId: null,
88272
+ access: "view"
88273
+ },
88274
+ "navigation.goToPoint": {
88275
+ capName: "navigation",
88276
+ capScope: "device",
88277
+ addonId: null,
88278
+ access: "create"
88279
+ },
88280
+ "navigation.listActions": {
88281
+ capName: "navigation",
88282
+ capScope: "device",
88283
+ addonId: null,
88284
+ access: "view"
88285
+ },
88286
+ "navigation.move": {
88287
+ capName: "navigation",
88288
+ capScope: "device",
88289
+ addonId: null,
88290
+ access: "create"
88291
+ },
88292
+ "navigation.playSound": {
88293
+ capName: "navigation",
88294
+ capScope: "device",
88295
+ addonId: null,
88296
+ access: "create"
88297
+ },
88298
+ "navigation.runAction": {
88299
+ capName: "navigation",
88300
+ capScope: "device",
88301
+ addonId: null,
88302
+ access: "create"
88303
+ },
88304
+ "navigation.setLightLevel": {
88305
+ capName: "navigation",
88306
+ capScope: "device",
88307
+ addonId: null,
88308
+ access: "create"
88309
+ },
88310
+ "navigation.setLightMode": {
88311
+ capName: "navigation",
88312
+ capScope: "device",
88313
+ addonId: null,
88314
+ access: "create"
88315
+ },
88316
+ "navigation.setLightOn": {
88317
+ capName: "navigation",
88318
+ capScope: "device",
88319
+ addonId: null,
88320
+ access: "create"
88321
+ },
88322
+ "navigation.stop": {
88323
+ capName: "navigation",
88324
+ capScope: "device",
88325
+ addonId: null,
88326
+ access: "create"
88327
+ },
86626
88328
  "networkAccess.getEndpoint": {
86627
88329
  capName: "network-access",
86628
88330
  capScope: "system",
@@ -90718,6 +92420,56 @@ Object.freeze({
90718
92420
  form: "single",
90719
92421
  optional: false
90720
92422
  }],
92423
+ "navigation.getFeatures": [{
92424
+ name: "deviceId",
92425
+ form: "single",
92426
+ optional: false
92427
+ }],
92428
+ "navigation.goToPoint": [{
92429
+ name: "deviceId",
92430
+ form: "single",
92431
+ optional: false
92432
+ }],
92433
+ "navigation.listActions": [{
92434
+ name: "deviceId",
92435
+ form: "single",
92436
+ optional: false
92437
+ }],
92438
+ "navigation.move": [{
92439
+ name: "deviceId",
92440
+ form: "single",
92441
+ optional: false
92442
+ }],
92443
+ "navigation.playSound": [{
92444
+ name: "deviceId",
92445
+ form: "single",
92446
+ optional: false
92447
+ }],
92448
+ "navigation.runAction": [{
92449
+ name: "deviceId",
92450
+ form: "single",
92451
+ optional: false
92452
+ }],
92453
+ "navigation.setLightLevel": [{
92454
+ name: "deviceId",
92455
+ form: "single",
92456
+ optional: false
92457
+ }],
92458
+ "navigation.setLightMode": [{
92459
+ name: "deviceId",
92460
+ form: "single",
92461
+ optional: false
92462
+ }],
92463
+ "navigation.setLightOn": [{
92464
+ name: "deviceId",
92465
+ form: "single",
92466
+ optional: false
92467
+ }],
92468
+ "navigation.stop": [{
92469
+ name: "deviceId",
92470
+ form: "single",
92471
+ optional: false
92472
+ }],
90721
92473
  "networkQuality.getDeviceStats": [{
90722
92474
  name: "deviceId",
90723
92475
  form: "single",
@@ -93900,6 +95652,23 @@ var dreameChildSchema = object({
93900
95652
  mapShowPath: boolean().optional(),
93901
95653
  mapShowRobot: boolean().optional(),
93902
95654
  mapShowCharger: boolean().optional(),
95655
+ /**
95656
+ * `navigation` per-device overrides (camera child only): a feature flag set
95657
+ * here wins over the addon defaults, an entry id listed here is switched off.
95658
+ * Data, not code — see `dreame-navigation-config.ts`.
95659
+ */
95660
+ navigationFeatures: object({
95661
+ move: boolean().optional(),
95662
+ stop: boolean().optional(),
95663
+ goToPoint: boolean().optional(),
95664
+ runAction: boolean().optional(),
95665
+ playSound: boolean().optional(),
95666
+ light: boolean().optional(),
95667
+ lightMode: boolean().optional()
95668
+ }).optional(),
95669
+ navigationDisabledEntryIds: array(string()).optional(),
95670
+ /** Privacy PIN set at pairing — required to wake a coded camera (camera child only). */
95671
+ navigationAccessCode: string().optional(),
93903
95672
  mapShowNoGo: boolean().optional(),
93904
95673
  mapShowVirtualWalls: boolean().optional(),
93905
95674
  mapShowObstacles: boolean().optional(),
@@ -93992,7 +95761,7 @@ var DreameChildDevice = class extends BaseDevice {
93992
95761
  };
93993
95762
  //#endregion
93994
95763
  //#region src/devices/dreame-ai-switch-device.ts
93995
- var CAP_NAME$14 = "switch";
95764
+ var CAP_NAME$15 = "switch";
93996
95765
  var COLD$4 = {
93997
95766
  on: false,
93998
95767
  lastChangedAt: 0
@@ -94054,26 +95823,26 @@ var DreameAiSwitchDevice = class extends DreameChildDevice {
94054
95823
  }
94055
95824
  registerControlCap() {
94056
95825
  this.ctx.registerNativeCap(switchCapability, {
94057
- getStatus: async () => this.runtimeState.getCapState(CAP_NAME$14) ?? COLD$4,
95826
+ getStatus: async () => this.runtimeState.getCapState(CAP_NAME$15) ?? COLD$4,
94058
95827
  setState: async ({ on }) => {
94059
95828
  const vacuum = this.resolveVacuum();
94060
95829
  if (vacuum === null) throw new Error(`DreameAiSwitchDevice: vacuum handle unavailable (device=${this.dreameDeviceId}, feature=${this.#feature})`);
94061
95830
  await vacuum.setAiFeature(this.#feature, on);
94062
- this.runtimeState.setCapState(CAP_NAME$14, {
95831
+ this.runtimeState.setCapState(CAP_NAME$15, {
94063
95832
  on,
94064
95833
  lastChangedAt: Date.now()
94065
95834
  });
94066
95835
  }
94067
95836
  });
94068
- this.runtimeState.setCapState(CAP_NAME$14, COLD$4);
95837
+ this.runtimeState.setCapState(CAP_NAME$15, COLD$4);
94069
95838
  }
94070
95839
  recomputeSlice() {
94071
95840
  const vacuum = this.resolveVacuum();
94072
95841
  if (vacuum === null) return;
94073
95842
  const on = vacuum.aiFeature(this.#feature);
94074
95843
  if (on === null) return;
94075
- if (this.runtimeState.getCapState(CAP_NAME$14)?.on === on) return;
94076
- this.runtimeState.setCapState(CAP_NAME$14, {
95844
+ if (this.runtimeState.getCapState(CAP_NAME$15)?.on === on) return;
95845
+ this.runtimeState.setCapState(CAP_NAME$15, {
94077
95846
  on,
94078
95847
  lastChangedAt: Date.now()
94079
95848
  });
@@ -94098,6 +95867,14 @@ function vacuumSupportsAi(caps) {
94098
95867
  return caps !== null && caps.hasAiObstacleDetection && caps.aiFeatureCount > 0;
94099
95868
  }
94100
95869
  /**
95870
+ * Whether a vacuum carries an on-board camera (LinkVisual video) — gates the
95871
+ * separate camera + navigation child. `false` when the handle has not resolved
95872
+ * (confirmed capability only, never optimistic).
95873
+ */
95874
+ function vacuumSupportsCamera(caps) {
95875
+ return caps?.hasCamera === true;
95876
+ }
95877
+ /**
94101
95878
  * Whether a mower supports a map. `false` when the handle has not resolved.
94102
95879
  */
94103
95880
  function mowerSupportsMap(caps) {
@@ -94131,7 +95908,7 @@ function deriveContainerFeatures(input) {
94131
95908
  }
94132
95909
  //#endregion
94133
95910
  //#region src/devices/dreame-auto-switch-device.ts
94134
- var CAP_NAME$13 = "switch";
95911
+ var CAP_NAME$14 = "switch";
94135
95912
  var COLD$3 = {
94136
95913
  on: false,
94137
95914
  lastChangedAt: 0
@@ -94179,19 +95956,19 @@ var DreameAutoSwitchDevice = class extends DreameChildDevice {
94179
95956
  }
94180
95957
  registerControlCap() {
94181
95958
  this.ctx.registerNativeCap(switchCapability, {
94182
- getStatus: async () => this.runtimeState.getCapState(CAP_NAME$13) ?? COLD$3,
95959
+ getStatus: async () => this.runtimeState.getCapState(CAP_NAME$14) ?? COLD$3,
94183
95960
  setState: async ({ on }) => {
94184
95961
  const vacuum = this.#vacuum();
94185
95962
  if (vacuum === null) throw new Error(`DreameAutoSwitchDevice: handle unavailable (device=${this.dreameDeviceId}, ${this.#key})`);
94186
95963
  await vacuum.setAutoSwitchProperty(this.#key, on ? 1 : 0);
94187
- this.runtimeState.setCapState(CAP_NAME$13, {
95964
+ this.runtimeState.setCapState(CAP_NAME$14, {
94188
95965
  on,
94189
95966
  lastChangedAt: Date.now()
94190
95967
  });
94191
95968
  }
94192
95969
  });
94193
95970
  const on = this.#read();
94194
- this.runtimeState.setCapState(CAP_NAME$13, on === null ? COLD$3 : {
95971
+ this.runtimeState.setCapState(CAP_NAME$14, on === null ? COLD$3 : {
94195
95972
  on,
94196
95973
  lastChangedAt: Date.now()
94197
95974
  });
@@ -94199,8 +95976,8 @@ var DreameAutoSwitchDevice = class extends DreameChildDevice {
94199
95976
  recomputeSlice() {
94200
95977
  const on = this.#read();
94201
95978
  if (on === null) return;
94202
- if (this.runtimeState.getCapState(CAP_NAME$13)?.on === on) return;
94203
- this.runtimeState.setCapState(CAP_NAME$13, {
95979
+ if (this.runtimeState.getCapState(CAP_NAME$14)?.on === on) return;
95980
+ this.runtimeState.setCapState(CAP_NAME$14, {
94204
95981
  on,
94205
95982
  lastChangedAt: Date.now()
94206
95983
  });
@@ -94208,7 +95985,7 @@ var DreameAutoSwitchDevice = class extends DreameChildDevice {
94208
95985
  };
94209
95986
  //#endregion
94210
95987
  //#region src/devices/dreame-auto-select-device.ts
94211
- var CAP_NAME$12 = "control";
95988
+ var CAP_NAME$13 = "control";
94212
95989
  var dreameAutoSelectOptionSchema = object({
94213
95990
  value: number().int(),
94214
95991
  label: string().min(1)
@@ -94263,7 +96040,7 @@ var DreameAutoSelectDevice = class extends DreameChildDevice {
94263
96040
  }
94264
96041
  registerControlCap() {
94265
96042
  this.ctx.registerNativeCap(controlCapability, {
94266
- getStatus: async () => this.runtimeState.getCapState(CAP_NAME$12) ?? this.#slice(this.#readLabel() ?? ""),
96043
+ getStatus: async () => this.runtimeState.getCapState(CAP_NAME$13) ?? this.#slice(this.#readLabel() ?? ""),
94267
96044
  setValue: async ({ control }) => {
94268
96045
  if (control.kind !== "select") throw new Error(`DreameAutoSelectDevice: expected a select value (device=${this.dreameDeviceId}, ${this.#key})`);
94269
96046
  const option = this.#options.find((o) => o.label === control.value);
@@ -94271,16 +96048,16 @@ var DreameAutoSelectDevice = class extends DreameChildDevice {
94271
96048
  const vacuum = this.#vacuum();
94272
96049
  if (vacuum === null) throw new Error(`DreameAutoSelectDevice: handle unavailable (device=${this.dreameDeviceId}, ${this.#key})`);
94273
96050
  await vacuum.setAutoSwitchProperty(this.#key, option.value);
94274
- this.runtimeState.setCapState(CAP_NAME$12, this.#slice(option.label));
96051
+ this.runtimeState.setCapState(CAP_NAME$13, this.#slice(option.label));
94275
96052
  }
94276
96053
  });
94277
- this.runtimeState.setCapState(CAP_NAME$12, this.#slice(this.#readLabel() ?? ""));
96054
+ this.runtimeState.setCapState(CAP_NAME$13, this.#slice(this.#readLabel() ?? ""));
94278
96055
  }
94279
96056
  recomputeSlice() {
94280
96057
  const label = this.#readLabel();
94281
96058
  if (label === null) return;
94282
- if (this.runtimeState.getCapState(CAP_NAME$12)?.value === label) return;
94283
- this.runtimeState.setCapState(CAP_NAME$12, this.#slice(label));
96059
+ if (this.runtimeState.getCapState(CAP_NAME$13)?.value === label) return;
96060
+ this.runtimeState.setCapState(CAP_NAME$13, this.#slice(label));
94284
96061
  }
94285
96062
  };
94286
96063
  //#endregion
@@ -94323,6 +96100,884 @@ var DreameButtonDevice = class extends DreameChildDevice {
94323
96100
  recomputeSlice() {}
94324
96101
  };
94325
96102
  //#endregion
96103
+ //#region src/dreame-vacuum-decode.ts
96104
+ /**
96105
+ * Pure decode helpers for the Dreame vacuum. Translate the library's MIoT enum
96106
+ * values into the camstack `vacuum-control` slice shape. Unit-tested in
96107
+ * isolation (no facade, no network) — mirrors `homematic-decode.ts`.
96108
+ */
96109
+ /**
96110
+ * Map a Dreame {@link MiotState} (siid 2 piid 1) onto the camstack canonical
96111
+ * `VacuumState` lifecycle (`idle` / `cleaning` / `paused` / `returning` /
96112
+ * `docked` / `error`). A `null` state (not yet observed) settles on `idle`.
96113
+ *
96114
+ * The MIoT state space is large (~50 values); we collapse it onto the six
96115
+ * canonical states by family. Anything unrecognised falls through to `idle`
96116
+ * (the safe neutral) so a firmware-added code never throws.
96117
+ */
96118
+ function mapMiotStateToVacuumState(state) {
96119
+ if (state === null) return "idle";
96120
+ switch (state) {
96121
+ case MiotState.Charging:
96122
+ case MiotState.ChargingComplete:
96123
+ case MiotState.IntelligentCharging:
96124
+ case MiotState.CleanWashboardBase:
96125
+ case MiotState.AutoRepairBase:
96126
+ case MiotState.WaterSupplyDrainTest:
96127
+ case MiotState.CleanMopRefillWater:
96128
+ case MiotState.AutoEmptying:
96129
+ case MiotState.Emptying:
96130
+ case MiotState.Draining:
96131
+ case MiotState.AutoWaterDraining:
96132
+ case MiotState.Sanitizing: return "docked";
96133
+ case MiotState.MopDrying:
96134
+ case MiotState.DustBagDrying:
96135
+ case MiotState.SanitizingWithDry: return "drying";
96136
+ case MiotState.ReturningToCharge:
96137
+ case MiotState.ReturningToWash:
96138
+ case MiotState.ReturnInstallMop:
96139
+ case MiotState.ReturnRemoveMop:
96140
+ case MiotState.ReturnToEmpty:
96141
+ case MiotState.ReturningToDrain: return "returning";
96142
+ case MiotState.Paused:
96143
+ case MiotState.PausedAlt:
96144
+ case MiotState.PausedCleaning:
96145
+ case MiotState.DustBagDryingPaused:
96146
+ case MiotState.CameraMonitoringPaused:
96147
+ case MiotState.FindingPetPaused:
96148
+ case MiotState.InitialDeepCleanPaused:
96149
+ case MiotState.ChangingMopPaused:
96150
+ case MiotState.FloorMaintainingPaused: return "paused";
96151
+ case MiotState.Standby:
96152
+ case MiotState.WaitingForTask: return "idle";
96153
+ default: return "cleaning";
96154
+ }
96155
+ }
96156
+ /**
96157
+ * The camstack `fanSpeed` token for a Dreame {@link SuctionLevel} — the enum
96158
+ * member NAME, lower-cased (`Quiet` → `'quiet'`). Returns `null` for an
96159
+ * unobserved / unknown level.
96160
+ */
96161
+ function suctionLevelToken(level) {
96162
+ if (level === null) return null;
96163
+ const name = SuctionLevel[level];
96164
+ return typeof name === "string" ? name.toLowerCase() : null;
96165
+ }
96166
+ /**
96167
+ * Resolve a camstack `fanSpeed` token back to the Dreame {@link SuctionLevel}
96168
+ * enum value, validated against the model's `supported` levels. Returns null
96169
+ * when the token doesn't match a supported level (the caller rejects the set).
96170
+ */
96171
+ function tokenToSuctionLevel(token, supported) {
96172
+ const wanted = token.trim().toLowerCase();
96173
+ for (const level of supported) if (suctionLevelToken(level) === wanted) return level;
96174
+ return null;
96175
+ }
96176
+ /** The list of accepted `fanSpeed` tokens for a model's supported suction set. */
96177
+ function supportedFanSpeedTokens(supported) {
96178
+ const tokens = [];
96179
+ for (const level of supported) {
96180
+ const token = suctionLevelToken(level);
96181
+ if (token !== null) tokens.push(token);
96182
+ }
96183
+ return tokens;
96184
+ }
96185
+ /**
96186
+ * Clamp a raw battery reading (already a 0..100 percentage from the library)
96187
+ * to the `vacuum-control` slice's `batteryLevel` field. Returns null for a
96188
+ * null/NaN/out-of-band reading so the slice stays valid (the schema rejects
96189
+ * a number outside 0..100).
96190
+ */
96191
+ function clampBatteryLevel$1(value) {
96192
+ if (value === null || !Number.isFinite(value)) return null;
96193
+ return Math.max(0, Math.min(100, value));
96194
+ }
96195
+ /** Clamp a 0..100 percentage (progress, coverage, …), null-safe. */
96196
+ function clampPercent$1(value) {
96197
+ if (value === null || !Number.isFinite(value)) return null;
96198
+ return Math.max(0, Math.min(100, value));
96199
+ }
96200
+ /**
96201
+ * Clean-water tank `status`: prefer the richer clean-water-tank-status (27/1),
96202
+ * fall back to the coarser water-tank presence (4/6). `null` when neither maps
96203
+ * to a known good/low reading (NOT_INSTALLED / CHECKING / unknown). Pure.
96204
+ */
96205
+ function cleanWaterTankStatus(props) {
96206
+ if (props.cleanWaterTankStatus !== null) {
96207
+ if (props.cleanWaterTankStatus === 0) return "ok";
96208
+ if (props.cleanWaterTankStatus === 2) return "low";
96209
+ return null;
96210
+ }
96211
+ if (props.waterTank === 1 || props.waterTank === 10) return "ok";
96212
+ return null;
96213
+ }
96214
+ /** Detergent tank `status` from detergent-status (27/4). Pure. */
96215
+ function detergentTankStatus(value) {
96216
+ if (value === 0) return "ok";
96217
+ if (value === 2) return "low";
96218
+ return null;
96219
+ }
96220
+ /** Dust-bin `status` from dust-collection (15/3); OVER_USE reads as `full`. Pure. */
96221
+ function dustBinTankStatus(value) {
96222
+ if (value === 1) return "ok";
96223
+ if (value === 2) return "full";
96224
+ return null;
96225
+ }
96226
+ /**
96227
+ * The four `vacuum-control` tank fields (clean-water / dirty-water / detergent /
96228
+ * dust-bin) decoded from the model's {@link VacuumCapabilities}, with the
96229
+ * discrete `status` populated from the live MIoT enum props when supplied. Pure.
96230
+ *
96231
+ * The nodedreame library exposes NO tank FILL-LEVEL telemetry — the cloud shadow
96232
+ * (verified against a real `dreame.vacuum.r2538z`) carries only the water-VOLUME
96233
+ * *setting* (a mop-flow level, NOT a tank fill). We therefore never fabricate a
96234
+ * `level`; `level` is always `null`. The cap contract is honoured on PRESENCE,
96235
+ * with `status` derived from the tank-status enums:
96236
+ *
96237
+ * - a field is `null` when the hardware HAS NO such tank (capability flags), and
96238
+ * - `{ level: null, status }` when the tank EXISTS — `status` from the enum
96239
+ * prop ('ok' / 'low' / 'full'), or `null` when the device reports none.
96240
+ *
96241
+ * Presence is derived from the capability record:
96242
+ * - clean-water ← `canMop` (a mopping robot carries a clean-water reservoir)
96243
+ * - dirty-water ← `canMopWash` (a self-wash dock has a recovery/dirty tank)
96244
+ * - detergent ← `hasDetergentReservoir`
96245
+ * - dust-bin ← `canAutoEmpty` (an auto-empty robot has an onboard dust bin)
96246
+ */
96247
+ function decodeVacuumTanks(caps, props) {
96248
+ const tank = (present, status) => present ? {
96249
+ level: null,
96250
+ status
96251
+ } : null;
96252
+ return {
96253
+ cleanWater: tank(Boolean(caps?.canMop), props ? cleanWaterTankStatus(props) : null),
96254
+ dirtyWater: tank(Boolean(caps?.canMopWash), null),
96255
+ detergent: tank(Boolean(caps?.hasDetergentReservoir), props ? detergentTankStatus(props.detergentStatus) : null),
96256
+ dustBin: tank(Boolean(caps?.canAutoEmpty), props ? dustBinTankStatus(props.dustCollection) : null)
96257
+ };
96258
+ }
96259
+ /**
96260
+ * Clamp a raw consumable percentage to `[0, 100]`, or return `null` when the
96261
+ * value is absent / non-finite. The library reports remaining life as a 0..100
96262
+ * integer; we clamp defensively so an out-of-range firmware value never violates
96263
+ * the cap schema.
96264
+ */
96265
+ function clampConsumablePct(value) {
96266
+ if (value === null || !Number.isFinite(value)) return null;
96267
+ return Math.max(0, Math.min(100, value));
96268
+ }
96269
+ /**
96270
+ * Build the `consumables` cap runtime-state slice from a live {@link VacuumDevice}
96271
+ * handle. The returned object satisfies the cap's `runtimeState` schema
96272
+ * (`ConsumablesStatusSchema.extend({ lastFetchedAt: z.number() })`).
96273
+ *
96274
+ * PRESENCE-driven (mirrors HA): the library's `supportedConsumables` enumerates
96275
+ * ONLY the consumables this model reports (a present MIoT life property) — main/
96276
+ * side brush, filter, sensor, tank-filter, mop-pad, silver-ion, detergent,
96277
+ * squeegee, dust-bag — each with its remaining-life % and whether a reset action
96278
+ * exists. No hardcoded per-model list: a consumable the device does not expose
96279
+ * never appears, and a `resettable` item drives the real MIoT reset action.
96280
+ */
96281
+ function buildVacuumConsumables(vacuum) {
96282
+ const items = vacuum.supportedConsumables.map((c) => ({
96283
+ key: c.key,
96284
+ label: c.label,
96285
+ level: clampConsumablePct(c.leftPct),
96286
+ status: null,
96287
+ lastResetAt: null,
96288
+ resettable: c.resettable
96289
+ }));
96290
+ const now = Date.now();
96291
+ return {
96292
+ items: [...items],
96293
+ lastChangedAt: now,
96294
+ lastFetchedAt: now
96295
+ };
96296
+ }
96297
+ //#endregion
96298
+ //#region src/devices/dreame-camera-stream.ts
96299
+ /** The one stream the robot camera offers. Half of the broker id (`<deviceId>/live`). */
96300
+ var CAMERA_STREAM_ID = "live";
96301
+ /** How often an open monitor asks the broker whether anybody is still watching. */
96302
+ var IDLE_CHECK_MS = 6e4;
96303
+ /** `ChargingStatus` as the robot reports it (siid 3 piid 2, VERIFIED r2532a). */
96304
+ var CHARGING_STATUS_CHARGING = 1;
96305
+ function clampLevel(level) {
96306
+ if (level === null || !Number.isFinite(level)) return null;
96307
+ return Math.max(0, Math.min(100, level));
96308
+ }
96309
+ /**
96310
+ * The battery slice the broker reads before a play: `sleeping` decides
96311
+ * whether it asks for a wake, `lastContactAt` decides whether it refuses to
96312
+ * (`deriveBatteryPresence` calls hours of silence "unreachable"). A contact
96313
+ * of 0 would read as "never heard" — the field is omitted instead.
96314
+ */
96315
+ function cameraBatterySlice(facts) {
96316
+ const charging = facts.charging === null ? "unknown" : facts.charging === CHARGING_STATUS_CHARGING ? "dc" : "none";
96317
+ return {
96318
+ percentage: clampLevel(facts.level),
96319
+ charging,
96320
+ sleeping: !facts.streamOpen,
96321
+ lastUpdated: facts.now,
96322
+ ...facts.contactAt !== null && facts.contactAt > 0 ? { lastContactAt: facts.contactAt } : {}
96323
+ };
96324
+ }
96325
+ /** One `pull-rtmp` descriptor while the monitor is open; nothing while it is closed. */
96326
+ function cameraStreamCatalog(session) {
96327
+ if (session === null) return [];
96328
+ return [{
96329
+ camStreamId: CAMERA_STREAM_ID,
96330
+ kind: "pull-rtmp",
96331
+ url: session.url,
96332
+ codec: "h264",
96333
+ label: "Robot camera",
96334
+ deviceFeatures: ["battery-operated"],
96335
+ autoEligible: true
96336
+ }];
96337
+ }
96338
+ /**
96339
+ * Somebody WATCHING is a reason to keep the monitor open: an RTSP client, a
96340
+ * WebRTC session (an `encoded` subscriber), an audio listener. The detection
96341
+ * pipeline is deliberately NOT one — it subscribes decoded frames for as long
96342
+ * as a stream exists, so counting it would keep the robot's relay open, on
96343
+ * its battery and through the cloud, until the battery died. It rides along
96344
+ * while a viewer watches and stops when the monitor closes.
96345
+ */
96346
+ function isBrokerBusy(clients) {
96347
+ return clients.rtsp.length > 0 || clients.encoded.length > 0 || clients.audio.length > 0;
96348
+ }
96349
+ /** Consecutive idle checks; a busy check starts the count over. */
96350
+ function nextIdleCount(previous, busy) {
96351
+ return busy ? 0 : previous + 1;
96352
+ }
96353
+ function shouldCloseForIdle(idleCount) {
96354
+ return idleCount >= 3;
96355
+ }
96356
+ //#endregion
96357
+ //#region src/devices/dreame-navigation-actions.ts
96358
+ /** Forward throttle magnitude for a full-forward nudge (device speed units). */
96359
+ var FORWARD_SPEED = 200;
96360
+ /** Turn magnitudes: left = +45, right = -45, spin/turn-around = +180. */
96361
+ var TURN_LEFT = 45;
96362
+ var TURN_RIGHT = -45;
96363
+ var SPIN = 180;
96364
+ /** The `setFillLight` value that selects auto. */
96365
+ var FILL_LIGHT_AUTO$1 = 101;
96366
+ /**
96367
+ * Map a normalized {@link NavigationMoveCommand} ([-1, 1] per axis) to a discrete
96368
+ * Dreame drive vector. The robot has no proportional joystick, so intensity is
96369
+ * quantized to the RE'd discrete nudges. Priority: forward, then spin
96370
+ * (tilt-down), then turn (pan). An all-zero / empty command is a stop.
96371
+ */
96372
+ function driveVector(cmd) {
96373
+ const pan = cmd.pan ?? 0;
96374
+ const tilt = cmd.tilt ?? 0;
96375
+ if (tilt > 0) return {
96376
+ spdv: FORWARD_SPEED,
96377
+ spdw: 0
96378
+ };
96379
+ if (tilt < 0) return {
96380
+ spdv: 0,
96381
+ spdw: SPIN
96382
+ };
96383
+ if (pan < 0) return {
96384
+ spdv: 0,
96385
+ spdw: TURN_LEFT
96386
+ };
96387
+ if (pan > 0) return {
96388
+ spdv: 0,
96389
+ spdw: TURN_RIGHT
96390
+ };
96391
+ return {
96392
+ spdv: 0,
96393
+ spdw: 0
96394
+ };
96395
+ }
96396
+ function moveCall(cmd) {
96397
+ const { spdv, spdw } = driveVector(cmd);
96398
+ return {
96399
+ method: "drive",
96400
+ spdv,
96401
+ spdw
96402
+ };
96403
+ }
96404
+ /** The explicit halt: a zero drive vector. */
96405
+ function stopCall() {
96406
+ return {
96407
+ method: "drive",
96408
+ spdv: 0,
96409
+ spdw: 0
96410
+ };
96411
+ }
96412
+ /** Fill light on (full) / off (back to auto) — the control that works anytime. */
96413
+ function lightOnCall(on) {
96414
+ return {
96415
+ method: "setFillLightAuto",
96416
+ full: on
96417
+ };
96418
+ }
96419
+ /** Fill-light mode: auto, or manual at `level` (40..100). Camera-service; needs a stream. */
96420
+ function lightModeCall(mode, level) {
96421
+ return {
96422
+ method: "setFillLight",
96423
+ value: mode === "auto" ? FILL_LIGHT_AUTO$1 : level ?? 100
96424
+ };
96425
+ }
96426
+ function lightLevelCall(level) {
96427
+ return lightModeCall("manual", level);
96428
+ }
96429
+ function playSoundCall(soundId) {
96430
+ return {
96431
+ method: "playSound",
96432
+ soundId
96433
+ };
96434
+ }
96435
+ function goToPointCall(point) {
96436
+ return {
96437
+ method: "goToPoint",
96438
+ x: point.x,
96439
+ y: point.y
96440
+ };
96441
+ }
96442
+ /** A discrete {@link NavigationActionId} → the controller method that performs it. */
96443
+ function actionCall(actionId) {
96444
+ switch (actionId) {
96445
+ case "goHome": return { method: "returnToDock" };
96446
+ case "locate": return { method: "locate" };
96447
+ case "spotClean": return { method: "spotClean" };
96448
+ case "findPet": return { method: "findPet" };
96449
+ case "personFollow": return { method: "startPersonFollow" };
96450
+ case "stop": return { method: "stopWork" };
96451
+ case "startClean": return {
96452
+ method: "runVacuumAction",
96453
+ action: "startClean"
96454
+ };
96455
+ case "pauseClean": return {
96456
+ method: "runVacuumAction",
96457
+ action: "pauseClean"
96458
+ };
96459
+ case "dockWash": return {
96460
+ method: "runVacuumAction",
96461
+ action: "dockWash"
96462
+ };
96463
+ case "autoEmpty": return {
96464
+ method: "runVacuumAction",
96465
+ action: "autoEmpty"
96466
+ };
96467
+ case "flashOn": return lightOnCall(true);
96468
+ case "flashOff": return lightOnCall(false);
96469
+ }
96470
+ }
96471
+ /** Perform one call on a controller. Exhaustive — a new method is a compile error here. */
96472
+ async function runControllerCall(controller, call) {
96473
+ switch (call.method) {
96474
+ case "drive":
96475
+ await controller.drive(call.spdv, call.spdw);
96476
+ return;
96477
+ case "stopWork":
96478
+ await controller.stopWork();
96479
+ return;
96480
+ case "startPersonFollow":
96481
+ await controller.startPersonFollow();
96482
+ return;
96483
+ case "spotClean":
96484
+ await controller.spotClean();
96485
+ return;
96486
+ case "findPet":
96487
+ await controller.findPet();
96488
+ return;
96489
+ case "playSound":
96490
+ await controller.playSound(call.soundId);
96491
+ return;
96492
+ case "runVacuumAction":
96493
+ await controller.runVacuumAction(call.action);
96494
+ return;
96495
+ case "goToPoint":
96496
+ await controller.goToPoint([[call.x, call.y]], [[call.x, call.y]]);
96497
+ return;
96498
+ case "setFillLight":
96499
+ await controller.setFillLight(call.value);
96500
+ return;
96501
+ case "setFillLightAuto":
96502
+ await controller.setFillLightAuto(call.full);
96503
+ return;
96504
+ case "returnToDock":
96505
+ await controller.returnToDock();
96506
+ return;
96507
+ case "locate":
96508
+ await controller.locate();
96509
+ return;
96510
+ }
96511
+ }
96512
+ //#endregion
96513
+ //#region src/devices/dreame-navigation-config.ts
96514
+ /**
96515
+ * DATA-DRIVEN feature flags for the Dreame navigation surface. Keeping these as
96516
+ * data (rather than branches in the device class) is what makes enabling /
96517
+ * disabling a control later a CONFIG change, not a code change — a future step
96518
+ * merges per-device / per-addon overrides on top of these defaults before the
96519
+ * cap reports them.
96520
+ *
96521
+ * `goToPoint` ships OFF: the send-to-map-coordinate path needs map-coordinate
96522
+ * plumbing (resolving a tap on the live map to robot coordinates) that is not
96523
+ * wired yet. Every other primitive defaults ON.
96524
+ */
96525
+ var DREAME_NAVIGATION_DEFAULT_FEATURES = {
96526
+ move: true,
96527
+ stop: true,
96528
+ goToPoint: false,
96529
+ runAction: true,
96530
+ playSound: true,
96531
+ light: true,
96532
+ lightMode: true
96533
+ };
96534
+ /**
96535
+ * Dictionary-entry ids disabled by default for Dreame (data-driven per-entry
96536
+ * flags). Empty today — every catalog action / sound is enabled. A future
96537
+ * config override drops ids in here (or removes them) without touching code.
96538
+ */
96539
+ var DREAME_NAVIGATION_DISABLED_ENTRY_IDS = /* @__PURE__ */ new Set();
96540
+ /**
96541
+ * The action dictionary as THIS provider reports it: the shared catalog with the
96542
+ * per-entry `enabled` flag resolved from {@link DREAME_NAVIGATION_DISABLED_ENTRY_IDS}.
96543
+ * Pure — the device method just returns this (a later step layers device config
96544
+ * on top).
96545
+ */
96546
+ function dreameNavigationCatalog(disabledEntryIds = DREAME_NAVIGATION_DISABLED_ENTRY_IDS) {
96547
+ return NAVIGATION_ACTION_CATALOG.map((entry) => ({
96548
+ ...entry,
96549
+ enabled: entry.enabled && !disabledEntryIds.has(entry.id)
96550
+ }));
96551
+ }
96552
+ /** The feature flags in force for ONE device: defaults, then its overrides. */
96553
+ function resolveNavigationFeatures(overrides) {
96554
+ return {
96555
+ ...DREAME_NAVIGATION_DEFAULT_FEATURES,
96556
+ ...overrides?.features ?? {}
96557
+ };
96558
+ }
96559
+ /** The disabled entry ids in force for ONE device: the addon set plus its own. */
96560
+ function resolveDisabledEntryIds(overrides) {
96561
+ return new Set([...DREAME_NAVIGATION_DISABLED_ENTRY_IDS, ...overrides?.disabledEntryIds ?? []]);
96562
+ }
96563
+ /** The camera-service fill-light mode/level property. */
96564
+ var FILL_LIGHT_SET_SIID = 10001;
96565
+ /** The wire value that means "auto" on `fillinLightSet`. */
96566
+ var FILL_LIGHT_AUTO = 101;
96567
+ function asRecord(raw) {
96568
+ if (typeof raw === "string") try {
96569
+ const parsed = JSON.parse(raw);
96570
+ return parsed !== null && typeof parsed === "object" ? parsed : null;
96571
+ } catch {
96572
+ return null;
96573
+ }
96574
+ return raw !== null && typeof raw === "object" ? raw : null;
96575
+ }
96576
+ /** `4/50` → `{ flash }`, only when the packed key is the fill light. */
96577
+ function decodeFillLight(raw) {
96578
+ const record = asRecord(raw);
96579
+ if (!record || record["k"] !== "FillinLight") return {};
96580
+ const v = record["v"];
96581
+ if (v === 1 || v === "1" || v === true) return { flash: true };
96582
+ if (v === 0 || v === "0" || v === false) return { flash: false };
96583
+ return {};
96584
+ }
96585
+ /** `10001/9` → `{ lightMode, lightLevel? }`: 101 is auto, 40..100 a manual level. */
96586
+ function decodeFillLightSet(raw) {
96587
+ const n = typeof raw === "string" ? Number(raw) : typeof raw === "number" ? raw : NaN;
96588
+ if (!Number.isFinite(n)) return {};
96589
+ if (n === FILL_LIGHT_AUTO) return { lightMode: "auto" };
96590
+ if (n >= 40 && n <= 100) return {
96591
+ lightMode: "manual",
96592
+ lightLevel: n
96593
+ };
96594
+ return {};
96595
+ }
96596
+ /** `true` while the robot's own state says it is following; `null` state = not known. */
96597
+ function decodeFollowing(state) {
96598
+ if (state === null || state === void 0 || !Number.isFinite(state)) return null;
96599
+ return state === 26;
96600
+ }
96601
+ /** Everything the robot currently says about its light, as one patch. */
96602
+ function decodeNavigationReadback(read) {
96603
+ return {
96604
+ ...decodeFillLight(read(4, 50)),
96605
+ ...decodeFillLightSet(read(FILL_LIGHT_SET_SIID, 9))
96606
+ };
96607
+ }
96608
+ //#endregion
96609
+ //#region src/devices/dreame-camera-device.ts
96610
+ var CAP_NAME$12 = "navigation";
96611
+ /**
96612
+ * The slice as WRITTEN: the cap's runtime-state shape (status + `lastFetchedAt`).
96613
+ * `setCapState` validates against it, so a status-only object throws at
96614
+ * activation and registers nothing — which is how the first deploy shipped
96615
+ * a camera with no navigation and no PTZ.
96616
+ */
96617
+ var NAV_COLD_START = {
96618
+ mode: "unknown",
96619
+ following: false,
96620
+ flash: false,
96621
+ lightMode: "auto",
96622
+ lightLevel: 100,
96623
+ lastChangedAt: 0,
96624
+ lastFetchedAt: 0
96625
+ };
96626
+ /** The next slice: the previous one, the patch, and both clocks stamped `now`. */
96627
+ function navigationSlice(prev, patch, now) {
96628
+ return {
96629
+ ...prev,
96630
+ ...patch,
96631
+ lastChangedAt: now,
96632
+ lastFetchedAt: now
96633
+ };
96634
+ }
96635
+ /**
96636
+ * Dreame CAMERA + NAVIGATION device — a SEPARATE camstack device from the
96637
+ * `DreameVacuumDevice`, spawned only for camera-capable vacuums (those whose
96638
+ * `vacuumCapabilities.hasCamera` is true, i.e. a LinkVisual video surface). It
96639
+ * owns the `navigation` cap (drive joystick + discrete robot actions + sounds +
96640
+ * fill light); the vacuum device keeps `vacuum-control` / `consumables` / maps.
96641
+ *
96642
+ * PREBUFFER OFF: this is a BATTERY robot whose camera is expensive to cold-start,
96643
+ * so the device advertises {@link DeviceFeature.BatteryOperated} — the SAME flag
96644
+ * the Reolink battery-camera path uses. The stream-broker's
96645
+ * `getEffectivePreBufferSec` battery rule then sets `preBufferSec = 0`, so the
96646
+ * stream opens on demand and is never continuously prebuffered.
96647
+ *
96648
+ * The whole navigation surface goes through nodedreame's typed
96649
+ * `DreameCameraController` (1.12+, built lazily from the vacuum handle with the
96650
+ * pairing PIN when the camera is coded), mapped by `dreame-navigation-actions.ts`.
96651
+ * The controller is never `open()`ed here — the stream is the broker's; drive is
96652
+ * valid only while a stream is open, which is when the panel is used.
96653
+ *
96654
+ * STATUS: `mode` is mapped from the shared vacuum state; `flash` / `lightMode`
96655
+ * / `lightLevel` are decoded from the robot's own properties on every push
96656
+ * (`dreame-navigation-status.ts`: `4/50` and `10001/9`) and, until the first
96657
+ * read-back lands, from the commands this device issued. `following` has no
96658
+ * confirmed read-back yet and stays command-driven.
96659
+ *
96660
+ * `navigation` is its own capability, deliberately NOT projected as `ptz`: a
96661
+ * gimbal and a chassis are different physical models, and the operator asked
96662
+ * for two separate things (admin UI: two tabs; viewer: one panel that shows the
96663
+ * navigation controls when the device has the cap).
96664
+ */
96665
+ var DreameCameraDevice = class extends DreameChildDevice {
96666
+ features = [DeviceFeature.BatteryOperated];
96667
+ constructor(ctx) {
96668
+ super(ctx);
96669
+ }
96670
+ registerControlCap() {
96671
+ const provider = this.buildNavigationProvider();
96672
+ this.ctx.registerNativeCap(navigationCapability, provider);
96673
+ this.runtimeState.setCapState(CAP_NAME$12, NAV_COLD_START);
96674
+ this.registerBatteryCap();
96675
+ this.registerStreamCatalogCap();
96676
+ }
96677
+ /** The relay the current `open()` handed out; null while the monitor is closed. */
96678
+ session = null;
96679
+ /** A single in-flight `open()` — two plays must not cold-start the robot twice. */
96680
+ opening = null;
96681
+ idleTimer = null;
96682
+ idleCount = 0;
96683
+ /** Last time the robot said anything (a state change) — the wake refusal's evidence. */
96684
+ lastContactAt = null;
96685
+ registerBatteryCap() {
96686
+ this.ctx.registerNativeCap(batteryCapability, { wakeForStream: async ({ timeoutMs }) => this.wakeForStream(timeoutMs) });
96687
+ this.publishBattery();
96688
+ this.runtimeState.subscribeCap(batteryCapability.name, (slice) => {
96689
+ if (!slice) return;
96690
+ this.ctx.eventBus.emit(createEvent(EventCategory.BatteryOnStatusChanged, {
96691
+ type: "device",
96692
+ id: this.stableId
96693
+ }, {
96694
+ deviceId: this.id,
96695
+ status: slice
96696
+ }));
96697
+ });
96698
+ }
96699
+ registerStreamCatalogCap() {
96700
+ this.ctx.registerNativeCap(streamCatalogCapability, { getCatalog: async ({ deviceId }) => deviceId === this.id ? cameraStreamCatalog(this.session) : [] });
96701
+ this.publishCatalog();
96702
+ }
96703
+ publishBattery() {
96704
+ const handle = this.resolveHandle();
96705
+ const vacuum = handle instanceof VacuumDevice ? handle : null;
96706
+ this.setCapSlice(batteryCapability, cameraBatterySlice({
96707
+ level: vacuum?.battery ?? null,
96708
+ charging: vacuum?.charging ?? null,
96709
+ streamOpen: this.session !== null,
96710
+ now: Date.now(),
96711
+ contactAt: this.lastContactAt
96712
+ }));
96713
+ }
96714
+ publishCatalog() {
96715
+ this.runtimeState.setCapState(streamCatalogCapability.name, {
96716
+ descriptors: [...cameraStreamCatalog(this.session)],
96717
+ lastFetchedAt: Date.now()
96718
+ });
96719
+ }
96720
+ /**
96721
+ * `battery.wakeForStream`: open the monitor inside the caller's bound. An
96722
+ * open that outlives the bound keeps running — the next play joins it.
96723
+ */
96724
+ async wakeForStream(timeoutMs) {
96725
+ const startedAt = Date.now();
96726
+ if (this.session !== null) return {
96727
+ awoke: true,
96728
+ durationMs: 0
96729
+ };
96730
+ const opened = this.openStream().then(() => "open", () => "failed");
96731
+ const bound = new Promise((resolve) => {
96732
+ setTimeout(() => resolve("timeout"), timeoutMs).unref?.();
96733
+ });
96734
+ const outcome = await Promise.race([opened, bound]);
96735
+ if (outcome === "timeout") this.ctx.logger.info("robot camera: the wake outlived its bound — the open keeps running", {
96736
+ tags: { deviceId: this.id },
96737
+ meta: { timeoutMs }
96738
+ });
96739
+ return {
96740
+ awoke: outcome === "open",
96741
+ durationMs: Date.now() - startedAt
96742
+ };
96743
+ }
96744
+ openStream() {
96745
+ if (this.opening !== null) return this.opening;
96746
+ const run = (async () => {
96747
+ const startedAt = Date.now();
96748
+ try {
96749
+ const handle = await (await this.requireController()).open();
96750
+ this.session = { url: handle.rtmpUrl };
96751
+ this.idleCount = 0;
96752
+ this.publishBattery();
96753
+ this.publishCatalog();
96754
+ this.startIdleWatch();
96755
+ this.ctx.logger.info("robot camera: monitor open, relay published to the catalog", {
96756
+ tags: { deviceId: this.id },
96757
+ meta: { openMs: Date.now() - startedAt }
96758
+ });
96759
+ } catch (err) {
96760
+ this.ctx.logger.warn("robot camera: monitor could not be opened", {
96761
+ tags: { deviceId: this.id },
96762
+ meta: {
96763
+ openMs: Date.now() - startedAt,
96764
+ error: errMsg(err)
96765
+ }
96766
+ });
96767
+ throw err;
96768
+ }
96769
+ })();
96770
+ this.opening = run.finally(() => {
96771
+ this.opening = null;
96772
+ });
96773
+ return this.opening;
96774
+ }
96775
+ async closeStream(reason) {
96776
+ this.stopIdleWatch();
96777
+ if (this.session === null) return;
96778
+ this.session = null;
96779
+ this.publishBattery();
96780
+ this.publishCatalog();
96781
+ this.ctx.logger.info("robot camera: monitor closed", {
96782
+ tags: { deviceId: this.id },
96783
+ meta: { reason }
96784
+ });
96785
+ const pending = this.controller;
96786
+ if (pending === null) return;
96787
+ await pending.then((controller) => controller.close()).catch((err) => {
96788
+ this.ctx.logger.debug("robot camera: close reported an error", {
96789
+ tags: { deviceId: this.id },
96790
+ meta: {
96791
+ reason,
96792
+ error: errMsg(err)
96793
+ }
96794
+ });
96795
+ });
96796
+ }
96797
+ startIdleWatch() {
96798
+ this.stopIdleWatch();
96799
+ this.idleTimer = setInterval(() => {
96800
+ this.checkIdle();
96801
+ }, IDLE_CHECK_MS);
96802
+ this.idleTimer.unref?.();
96803
+ }
96804
+ stopIdleWatch() {
96805
+ if (this.idleTimer !== null) clearInterval(this.idleTimer);
96806
+ this.idleTimer = null;
96807
+ this.idleCount = 0;
96808
+ }
96809
+ /** Ask the broker whether anybody is still watching; no broker at all counts as idle. */
96810
+ async checkIdle() {
96811
+ if (this.session === null) return;
96812
+ const clients = await this.ctx.api.streamBroker.listClients.query({ brokerId: `${String(this.id)}/${CAMERA_STREAM_ID}` }).catch(() => null);
96813
+ const busy = clients !== null && isBrokerBusy(clients);
96814
+ this.idleCount = nextIdleCount(this.idleCount, busy);
96815
+ if (shouldCloseForIdle(this.idleCount)) await this.closeStream("idle");
96816
+ }
96817
+ buildNavigationProvider() {
96818
+ return {
96819
+ getStatus: async () => this.runtimeState.getCapState(CAP_NAME$12) ?? NAV_COLD_START,
96820
+ listActions: async () => dreameNavigationCatalog(resolveDisabledEntryIds(this.overrides())),
96821
+ getFeatures: async () => this.buildFeatures(),
96822
+ move: async ({ pan, tilt, speed }) => {
96823
+ await this.dispatch(moveCall({
96824
+ pan,
96825
+ tilt,
96826
+ speed
96827
+ }));
96828
+ },
96829
+ stop: async () => {
96830
+ await this.dispatch(stopCall());
96831
+ },
96832
+ goToPoint: async ({ x, y }) => {
96833
+ await this.dispatch(goToPointCall({
96834
+ x,
96835
+ y
96836
+ }));
96837
+ },
96838
+ runAction: async ({ actionId }) => {
96839
+ await this.dispatch(actionCall(actionId));
96840
+ this.applyOptimisticAction(actionId);
96841
+ },
96842
+ playSound: async ({ soundId }) => {
96843
+ await this.dispatch(playSoundCall(soundId));
96844
+ },
96845
+ setLightOn: async ({ on }) => {
96846
+ await this.dispatch(lightOnCall(on));
96847
+ this.patchStatus({ flash: on });
96848
+ },
96849
+ setLightMode: async ({ mode, level }) => {
96850
+ await this.dispatch(lightModeCall(mode, level));
96851
+ this.patchStatus(mode === "manual" && level !== void 0 ? {
96852
+ lightMode: mode,
96853
+ lightLevel: level
96854
+ } : { lightMode: mode });
96855
+ },
96856
+ setLightLevel: async ({ level }) => {
96857
+ await this.dispatch(lightLevelCall(level));
96858
+ this.patchStatus({
96859
+ lightMode: "manual",
96860
+ lightLevel: level
96861
+ });
96862
+ }
96863
+ };
96864
+ }
96865
+ recomputeSlice() {
96866
+ const handle = this.resolveHandle();
96867
+ if (handle === null) return;
96868
+ this.lastContactAt = Date.now();
96869
+ this.publishBattery();
96870
+ const prev = this.runtimeState.getCapState(CAP_NAME$12) ?? NAV_COLD_START;
96871
+ const readback = handle instanceof VacuumDevice ? decodeNavigationReadback((siid, piid) => handle.getProperty(siid, piid)?.value) : {};
96872
+ const following = handle instanceof VacuumDevice ? decodeFollowing(handle.status) : null;
96873
+ this.runtimeState.setCapState(CAP_NAME$12, navigationSlice(prev, {
96874
+ ...readback,
96875
+ ...following !== null ? { following } : {},
96876
+ mode: following === true ? "following" : this.deriveMode(handle)
96877
+ }, Date.now()));
96878
+ }
96879
+ controller = null;
96880
+ controllerHandle = null;
96881
+ /**
96882
+ * The typed controller, built once per live handle (the build resolves the
96883
+ * cloud video profile, so it is a promise shared by every caller); rebuilt
96884
+ * if the handle changes. Never `open()`ed here.
96885
+ */
96886
+ requireController() {
96887
+ const handle = this.requireHandle();
96888
+ if (!(handle instanceof VacuumDevice)) throw new Error("DreameCameraDevice: the robot handle is not a vacuum — no camera controller");
96889
+ if (this.controller === null || this.controllerHandle !== handle) {
96890
+ const accessCode = this.config.get("navigationAccessCode");
96891
+ this.controllerHandle = handle;
96892
+ this.controller = handle.createCameraController({
96893
+ ...accessCode !== void 0 && accessCode !== "" ? { accessCode } : {},
96894
+ onKeepAliveError: (err) => {
96895
+ this.ctx.logger.warn("robot camera: relay keep-alive failed — closing the monitor", {
96896
+ tags: { deviceId: this.id },
96897
+ meta: { error: errMsg(err) }
96898
+ });
96899
+ this.closeStream("keep-alive");
96900
+ }
96901
+ }).catch((err) => {
96902
+ this.controller = null;
96903
+ this.controllerHandle = null;
96904
+ throw err;
96905
+ });
96906
+ }
96907
+ return this.controller;
96908
+ }
96909
+ /** Perform a translated navigation call on the typed controller. */
96910
+ async dispatch(call) {
96911
+ await runControllerCall(await this.requireController(), call);
96912
+ }
96913
+ async removeDevice() {
96914
+ await super.removeDevice();
96915
+ await this.closeStream("removed");
96916
+ const pending = this.controller;
96917
+ this.controller = null;
96918
+ this.controllerHandle = null;
96919
+ if (pending !== null) await pending.then((controller) => controller.close()).catch(() => void 0);
96920
+ }
96921
+ /** Per-device general-primitive feature flags: the defaults, then this device's overrides. */
96922
+ buildFeatures() {
96923
+ return resolveNavigationFeatures(this.overrides());
96924
+ }
96925
+ /** This device's navigation overrides, straight from its persisted config. */
96926
+ overrides() {
96927
+ const features = this.config.get("navigationFeatures");
96928
+ const disabledEntryIds = this.config.get("navigationDisabledEntryIds");
96929
+ return {
96930
+ ...features !== void 0 ? { features } : {},
96931
+ ...disabledEntryIds !== void 0 ? { disabledEntryIds } : {}
96932
+ };
96933
+ }
96934
+ /** Map the shared vacuum lifecycle state to a coarse navigation mode. */
96935
+ deriveMode(handle) {
96936
+ if (!(handle instanceof VacuumDevice)) return "unknown";
96937
+ switch (mapMiotStateToVacuumState(handle.status)) {
96938
+ case "cleaning": return "cleaning";
96939
+ case "paused": return "paused";
96940
+ case "returning": return "returning";
96941
+ case "idle":
96942
+ case "docked":
96943
+ case "drying": return "idle";
96944
+ default: return "unknown";
96945
+ }
96946
+ }
96947
+ /**
96948
+ * Optimistically reflect a just-issued action in the status slice so the UI
96949
+ * updates immediately (there is no confirmed read-back yet).
96950
+ */
96951
+ applyOptimisticAction(actionId) {
96952
+ switch (actionId) {
96953
+ case "flashOn":
96954
+ this.patchStatus({ flash: true });
96955
+ return;
96956
+ case "flashOff":
96957
+ this.patchStatus({ flash: false });
96958
+ return;
96959
+ case "personFollow":
96960
+ this.patchStatus({
96961
+ following: true,
96962
+ mode: "following"
96963
+ });
96964
+ return;
96965
+ case "stop":
96966
+ this.patchStatus({ following: false });
96967
+ return;
96968
+ default: return;
96969
+ }
96970
+ }
96971
+ /**
96972
+ * Merge a patch into the status slice. Written THROUGH `runtimeState` — never a
96973
+ * parallel private cache (CLAUDE.md).
96974
+ */
96975
+ patchStatus(patch) {
96976
+ const prev = this.runtimeState.getCapState(CAP_NAME$12) ?? NAV_COLD_START;
96977
+ this.runtimeState.setCapState(CAP_NAME$12, navigationSlice(prev, patch, Date.now()));
96978
+ }
96979
+ };
96980
+ //#endregion
94326
96981
  //#region src/devices/dreame-enum-sensor-device.ts
94327
96982
  var CAP_NAME$11 = "enum-sensor";
94328
96983
  /** One persisted enum option: the MIoT int value + its display label. */
@@ -94547,12 +97202,12 @@ function mapMowerStatusToActivity(status) {
94547
97202
  }
94548
97203
  }
94549
97204
  /** Clamp a raw battery reading (0..100 from the library) to the slice field. */
94550
- function clampBatteryLevel$1(value) {
97205
+ function clampBatteryLevel(value) {
94551
97206
  if (value === null || !Number.isFinite(value)) return null;
94552
97207
  return Math.max(0, Math.min(100, value));
94553
97208
  }
94554
97209
  /** Clamp a 0..100 percentage (mowing progress), null-safe. */
94555
- function clampPercent$1(value) {
97210
+ function clampPercent(value) {
94556
97211
  if (value === null || !Number.isFinite(value)) return null;
94557
97212
  return Math.max(0, Math.min(100, value));
94558
97213
  }
@@ -94726,11 +97381,11 @@ var DreameMowerDevice = class extends DreameChildDevice {
94726
97381
  if (mower === null) return;
94727
97382
  const status = mower.status;
94728
97383
  const code = mower.faultRaw;
94729
- const livePct = clampPercent$1(mower.mowingProgressPct);
97384
+ const livePct = clampPercent(mower.mowingProgressPct);
94730
97385
  const prevPct = this.runtimeState.getCapState(CAP_NAME$9)?.progressPercent;
94731
97386
  const slice = {
94732
97387
  activity: mapMowerStatusToActivity(status),
94733
- batteryLevel: clampBatteryLevel$1(mower.battery),
97388
+ batteryLevel: clampBatteryLevel(mower.battery),
94734
97389
  progressPercent: livePct ?? prevPct ?? null,
94735
97390
  currentCode: code,
94736
97391
  currentCodeLabel: code !== null ? MowerFault[code] ?? null : null,
@@ -95373,204 +98028,21 @@ var DreameSwitchDevice = class extends DreameChildDevice {
95373
98028
  });
95374
98029
  }
95375
98030
  };
95376
- //#endregion
95377
- //#region src/dreame-vacuum-decode.ts
95378
- /**
95379
- * Pure decode helpers for the Dreame vacuum. Translate the library's MIoT enum
95380
- * values into the camstack `vacuum-control` slice shape. Unit-tested in
95381
- * isolation (no facade, no network) — mirrors `homematic-decode.ts`.
95382
- */
95383
- /**
95384
- * Map a Dreame {@link MiotState} (siid 2 piid 1) onto the camstack canonical
95385
- * `VacuumState` lifecycle (`idle` / `cleaning` / `paused` / `returning` /
95386
- * `docked` / `error`). A `null` state (not yet observed) settles on `idle`.
95387
- *
95388
- * The MIoT state space is large (~50 values); we collapse it onto the six
95389
- * canonical states by family. Anything unrecognised falls through to `idle`
95390
- * (the safe neutral) so a firmware-added code never throws.
95391
- */
95392
- function mapMiotStateToVacuumState(state) {
95393
- if (state === null) return "idle";
95394
- switch (state) {
95395
- case MiotState.Charging:
95396
- case MiotState.ChargingComplete:
95397
- case MiotState.IntelligentCharging:
95398
- case MiotState.CleanWashboardBase:
95399
- case MiotState.AutoRepairBase:
95400
- case MiotState.WaterSupplyDrainTest:
95401
- case MiotState.CleanMopRefillWater:
95402
- case MiotState.AutoEmptying:
95403
- case MiotState.Emptying:
95404
- case MiotState.Draining:
95405
- case MiotState.AutoWaterDraining:
95406
- case MiotState.Sanitizing: return "docked";
95407
- case MiotState.MopDrying:
95408
- case MiotState.DustBagDrying:
95409
- case MiotState.SanitizingWithDry: return "drying";
95410
- case MiotState.ReturningToCharge:
95411
- case MiotState.ReturningToWash:
95412
- case MiotState.ReturnInstallMop:
95413
- case MiotState.ReturnRemoveMop:
95414
- case MiotState.ReturnToEmpty:
95415
- case MiotState.ReturningToDrain: return "returning";
95416
- case MiotState.Paused:
95417
- case MiotState.PausedAlt:
95418
- case MiotState.PausedCleaning:
95419
- case MiotState.DustBagDryingPaused:
95420
- case MiotState.CameraMonitoringPaused:
95421
- case MiotState.FindingPetPaused:
95422
- case MiotState.InitialDeepCleanPaused:
95423
- case MiotState.ChangingMopPaused:
95424
- case MiotState.FloorMaintainingPaused: return "paused";
95425
- case MiotState.Standby:
95426
- case MiotState.WaitingForTask: return "idle";
95427
- default: return "cleaning";
95428
- }
95429
- }
95430
- /**
95431
- * The camstack `fanSpeed` token for a Dreame {@link SuctionLevel} — the enum
95432
- * member NAME, lower-cased (`Quiet` → `'quiet'`). Returns `null` for an
95433
- * unobserved / unknown level.
95434
- */
95435
- function suctionLevelToken(level) {
95436
- if (level === null) return null;
95437
- const name = SuctionLevel[level];
95438
- return typeof name === "string" ? name.toLowerCase() : null;
95439
- }
95440
- /**
95441
- * Resolve a camstack `fanSpeed` token back to the Dreame {@link SuctionLevel}
95442
- * enum value, validated against the model's `supported` levels. Returns null
95443
- * when the token doesn't match a supported level (the caller rejects the set).
95444
- */
95445
- function tokenToSuctionLevel(token, supported) {
95446
- const wanted = token.trim().toLowerCase();
95447
- for (const level of supported) if (suctionLevelToken(level) === wanted) return level;
95448
- return null;
95449
- }
95450
- /** The list of accepted `fanSpeed` tokens for a model's supported suction set. */
95451
- function supportedFanSpeedTokens(supported) {
95452
- const tokens = [];
95453
- for (const level of supported) {
95454
- const token = suctionLevelToken(level);
95455
- if (token !== null) tokens.push(token);
95456
- }
95457
- return tokens;
95458
- }
95459
- /**
95460
- * Clamp a raw battery reading (already a 0..100 percentage from the library)
95461
- * to the `vacuum-control` slice's `batteryLevel` field. Returns null for a
95462
- * null/NaN/out-of-band reading so the slice stays valid (the schema rejects
95463
- * a number outside 0..100).
95464
- */
95465
- function clampBatteryLevel(value) {
95466
- if (value === null || !Number.isFinite(value)) return null;
95467
- return Math.max(0, Math.min(100, value));
95468
- }
95469
- /** Clamp a 0..100 percentage (progress, coverage, …), null-safe. */
95470
- function clampPercent(value) {
95471
- if (value === null || !Number.isFinite(value)) return null;
95472
- return Math.max(0, Math.min(100, value));
95473
- }
95474
- /**
95475
- * Clean-water tank `status`: prefer the richer clean-water-tank-status (27/1),
95476
- * fall back to the coarser water-tank presence (4/6). `null` when neither maps
95477
- * to a known good/low reading (NOT_INSTALLED / CHECKING / unknown). Pure.
95478
- */
95479
- function cleanWaterTankStatus(props) {
95480
- if (props.cleanWaterTankStatus !== null) {
95481
- if (props.cleanWaterTankStatus === 0) return "ok";
95482
- if (props.cleanWaterTankStatus === 2) return "low";
95483
- return null;
95484
- }
95485
- if (props.waterTank === 1 || props.waterTank === 10) return "ok";
95486
- return null;
95487
- }
95488
- /** Detergent tank `status` from detergent-status (27/4). Pure. */
95489
- function detergentTankStatus(value) {
95490
- if (value === 0) return "ok";
95491
- if (value === 2) return "low";
95492
- return null;
95493
- }
95494
- /** Dust-bin `status` from dust-collection (15/3); OVER_USE reads as `full`. Pure. */
95495
- function dustBinTankStatus(value) {
95496
- if (value === 1) return "ok";
95497
- if (value === 2) return "full";
95498
- return null;
95499
- }
95500
- /**
95501
- * The four `vacuum-control` tank fields (clean-water / dirty-water / detergent /
95502
- * dust-bin) decoded from the model's {@link VacuumCapabilities}, with the
95503
- * discrete `status` populated from the live MIoT enum props when supplied. Pure.
95504
- *
95505
- * The nodedreame library exposes NO tank FILL-LEVEL telemetry — the cloud shadow
95506
- * (verified against a real `dreame.vacuum.r2538z`) carries only the water-VOLUME
95507
- * *setting* (a mop-flow level, NOT a tank fill). We therefore never fabricate a
95508
- * `level`; `level` is always `null`. The cap contract is honoured on PRESENCE,
95509
- * with `status` derived from the tank-status enums:
95510
- *
95511
- * - a field is `null` when the hardware HAS NO such tank (capability flags), and
95512
- * - `{ level: null, status }` when the tank EXISTS — `status` from the enum
95513
- * prop ('ok' / 'low' / 'full'), or `null` when the device reports none.
95514
- *
95515
- * Presence is derived from the capability record:
95516
- * - clean-water ← `canMop` (a mopping robot carries a clean-water reservoir)
95517
- * - dirty-water ← `canMopWash` (a self-wash dock has a recovery/dirty tank)
95518
- * - detergent ← `hasDetergentReservoir`
95519
- * - dust-bin ← `canAutoEmpty` (an auto-empty robot has an onboard dust bin)
95520
- */
95521
- function decodeVacuumTanks(caps, props) {
95522
- const tank = (present, status) => present ? {
95523
- level: null,
95524
- status
95525
- } : null;
98031
+ /** `null` = nothing learned (keep the previous slice). */
98032
+ function networkLinkFromBars(bars, now) {
98033
+ if (bars === null || !Number.isFinite(bars)) return null;
95526
98034
  return {
95527
- cleanWater: tank(Boolean(caps?.canMop), props ? cleanWaterTankStatus(props) : null),
95528
- dirtyWater: tank(Boolean(caps?.canMopWash), null),
95529
- detergent: tank(Boolean(caps?.hasDetergentReservoir), props ? detergentTankStatus(props.detergentStatus) : null),
95530
- dustBin: tank(Boolean(caps?.canAutoEmpty), props ? dustBinTankStatus(props.dustCollection) : null)
95531
- };
95532
- }
95533
- /**
95534
- * Clamp a raw consumable percentage to `[0, 100]`, or return `null` when the
95535
- * value is absent / non-finite. The library reports remaining life as a 0..100
95536
- * integer; we clamp defensively so an out-of-range firmware value never violates
95537
- * the cap schema.
95538
- */
95539
- function clampConsumablePct(value) {
95540
- if (value === null || !Number.isFinite(value)) return null;
95541
- return Math.max(0, Math.min(100, value));
95542
- }
95543
- /**
95544
- * Build the `consumables` cap runtime-state slice from a live {@link VacuumDevice}
95545
- * handle. The returned object satisfies the cap's `runtimeState` schema
95546
- * (`ConsumablesStatusSchema.extend({ lastFetchedAt: z.number() })`).
95547
- *
95548
- * PRESENCE-driven (mirrors HA): the library's `supportedConsumables` enumerates
95549
- * ONLY the consumables this model reports (a present MIoT life property) — main/
95550
- * side brush, filter, sensor, tank-filter, mop-pad, silver-ion, detergent,
95551
- * squeegee, dust-bag — each with its remaining-life % and whether a reset action
95552
- * exists. No hardcoded per-model list: a consumable the device does not expose
95553
- * never appears, and a `resettable` item drives the real MIoT reset action.
95554
- */
95555
- function buildVacuumConsumables(vacuum) {
95556
- const items = vacuum.supportedConsumables.map((c) => ({
95557
- key: c.key,
95558
- label: c.label,
95559
- level: clampConsumablePct(c.leftPct),
95560
- status: null,
95561
- lastResetAt: null,
95562
- resettable: c.resettable
95563
- }));
95564
- const now = Date.now();
95565
- return {
95566
- items: [...items],
95567
- lastChangedAt: now,
95568
- lastFetchedAt: now
98035
+ type: "wifi",
98036
+ signalPercent: bars === 0 ? null : signalPercentFromBars(bars, 4),
98037
+ lastUpdated: now
95569
98038
  };
95570
98039
  }
95571
98040
  //#endregion
95572
98041
  //#region src/devices/dreame-vacuum-device.ts
95573
98042
  var CAP_NAME$1 = "vacuum-control";
98043
+ var NETWORK_LINK_CAP_NAME = "network-link";
98044
+ /** The Wi-Fi signal map is a cloud round trip; the bars at the dock barely move. */
98045
+ var NETWORK_LINK_REFRESH_MS = 30 * 6e4;
95574
98046
  var CONSUMABLES_CAP_NAME = "consumables";
95575
98047
  var CONTROL_COLD_START = {
95576
98048
  state: "idle",
@@ -95626,6 +98098,8 @@ var DreameVacuumDevice = class extends DreameChildDevice {
95626
98098
  }
95627
98099
  async onActivate() {
95628
98100
  await super.onActivate();
98101
+ this.refreshNetworkLink("activate");
98102
+ this.networkLinkTimer = setInterval(() => void this.refreshNetworkLink("periodic"), NETWORK_LINK_REFRESH_MS);
95629
98103
  const vacuum = this.resolveVacuum();
95630
98104
  if (vacuum !== null) {
95631
98105
  vacuum.refreshFromCache().catch((err) => {
@@ -95649,11 +98123,60 @@ var DreameVacuumDevice = class extends DreameChildDevice {
95649
98123
  }
95650
98124
  }
95651
98125
  /** Narrow the generic handle to the library's typed {@link DreameVacuum}, or null. */
98126
+ networkLinkTimer = null;
98127
+ networkLinkReported = false;
98128
+ networkLinkFailureWarned = false;
98129
+ /** `network-link`: the read path serves the slice; `refreshNetworkLink` writes it. */
98130
+ registerNetworkLink() {
98131
+ this.ctx.registerNativeCap(networkLinkCapability, { getStatus: async () => this.runtimeState.getCapState(NETWORK_LINK_CAP_NAME) ?? NETWORK_LINK_UNKNOWN });
98132
+ if (this.runtimeState.getCapState(NETWORK_LINK_CAP_NAME) === null) this.runtimeState.setCapState(NETWORK_LINK_CAP_NAME, NETWORK_LINK_UNKNOWN);
98133
+ }
98134
+ /**
98135
+ * Read the bars at the robot's position off its Wi-Fi signal map. Bounded,
98136
+ * best-effort; a failure keeps the last slice. The first read per device is
98137
+ * said at INFO with the raw bars, the first failure at WARN.
98138
+ */
98139
+ async refreshNetworkLink(reason) {
98140
+ const vacuum = this.resolveVacuum();
98141
+ if (vacuum === null) return;
98142
+ try {
98143
+ const bars = await vacuum.getCurrentSignal({ maxWaitMs: 2e4 });
98144
+ const next = networkLinkFromBars(bars, Date.now());
98145
+ this.ctx.logger[this.networkLinkReported ? "debug" : "info"]("network link read", {
98146
+ tags: { deviceId: this.id },
98147
+ meta: {
98148
+ reason,
98149
+ rawBars: bars,
98150
+ ...next ?? {}
98151
+ }
98152
+ });
98153
+ this.networkLinkReported = true;
98154
+ if (next !== null) this.runtimeState.setCapState(NETWORK_LINK_CAP_NAME, next);
98155
+ } catch (err) {
98156
+ const level = this.networkLinkFailureWarned ? "debug" : "warn";
98157
+ this.networkLinkFailureWarned = true;
98158
+ this.ctx.logger[level]("network link read failed — keeping the last slice", {
98159
+ tags: { deviceId: this.id },
98160
+ meta: {
98161
+ reason,
98162
+ error: err instanceof Error ? err.message : String(err)
98163
+ }
98164
+ });
98165
+ }
98166
+ }
98167
+ async removeDevice() {
98168
+ if (this.networkLinkTimer !== null) {
98169
+ clearInterval(this.networkLinkTimer);
98170
+ this.networkLinkTimer = null;
98171
+ }
98172
+ await super.removeDevice();
98173
+ }
95652
98174
  resolveVacuum() {
95653
98175
  const handle = this.resolveHandle();
95654
98176
  return handle instanceof VacuumDevice ? handle : null;
95655
98177
  }
95656
98178
  registerControlCap() {
98179
+ this.registerNetworkLink();
95657
98180
  this.ctx.registerNativeCap(vacuumControlCapability, {
95658
98181
  getStatus: async () => this.runtimeState.getCapState(CAP_NAME$1) ?? CONTROL_COLD_START,
95659
98182
  start: async () => {
@@ -95710,14 +98233,14 @@ var DreameVacuumDevice = class extends DreameChildDevice {
95710
98233
  const tanks = decodeVacuumTanks(vacuum.vacuumCapabilities, this.readTankProps());
95711
98234
  const controlSlice = {
95712
98235
  state: mapMiotStateToVacuumState(vacuum.status),
95713
- batteryLevel: clampBatteryLevel(vacuum.battery),
98236
+ batteryLevel: clampBatteryLevel$1(vacuum.battery),
95714
98237
  fanSpeed: suctionLevelToken(vacuum.suction),
95715
98238
  availableFanSpeeds: supportedFanSpeedTokens(vacuum.supportedSuctionLevels),
95716
98239
  cleanWater: tanks.cleanWater,
95717
98240
  dirtyWater: tanks.dirtyWater,
95718
98241
  detergent: tanks.detergent,
95719
98242
  dustBin: tanks.dustBin,
95720
- progressPercent: clampPercent(vacuum.taskProgressPct),
98243
+ progressPercent: clampPercent$1(vacuum.taskProgressPct),
95721
98244
  errorCode: vacuum.errorCode,
95722
98245
  errorLabel: vacuum.errorCode ? `Error ${vacuum.errorCode}` : null,
95723
98246
  lastChangedAt: Date.now()
@@ -96043,6 +98566,15 @@ function dreameMowerMapSelectChildFactory(ctx) {
96043
98566
  return new DreameMowerMapSelectDevice(ctx);
96044
98567
  }
96045
98568
  /**
98569
+ * Factory for the CAMERA + NAVIGATION child (a `DeviceType.Camera` grouped under
98570
+ * the Container), spawned only for camera-capable vacuums. Owns the `navigation`
98571
+ * cap (drive / actions / sounds / fill light). Selected directly by the
98572
+ * Container's `getAccessoryChildren()` for the camera child spec.
98573
+ */
98574
+ function dreameCameraChildFactory(ctx) {
98575
+ return new DreameCameraDevice(ctx);
98576
+ }
98577
+ /**
96046
98578
  * Factory for an AI-obstacle Switch child (a `DeviceType.Switch` grouped under
96047
98579
  * the Container). Like {@link dreameMapChildFactory}, selected directly by the
96048
98580
  * Container's `getAccessoryChildren()` — the child config carries the
@@ -96214,6 +98746,20 @@ var DreameContainerDevice = class extends BaseDevice {
96214
98746
  factory: dreameMapChildFactory
96215
98747
  });
96216
98748
  }
98749
+ if (kind === "vacuum" && this.modelSupportsCamera()) {
98750
+ const cameraMeta = {
98751
+ type: DeviceType.Camera,
98752
+ name: this.name.length > 0 ? `${this.name} Camera` : "Camera",
98753
+ linkDeviceId: this.id,
98754
+ ...this.integrationId !== void 0 ? { integrationId: this.integrationId } : {}
98755
+ };
98756
+ children.push({
98757
+ stableIdSuffix: "camera",
98758
+ meta: cameraMeta,
98759
+ config,
98760
+ factory: dreameCameraChildFactory
98761
+ });
98762
+ }
96217
98763
  if (kind === "vacuum") {
96218
98764
  for (const feature of this.supportedAiFeatures()) {
96219
98765
  const switchMeta = {
@@ -96647,6 +99193,7 @@ var DreameContainerDevice = class extends BaseDevice {
96647
99193
  const caps = handle.vacuumCapabilities;
96648
99194
  return {
96649
99195
  canMap: caps.canMap,
99196
+ hasCamera: caps.hasCamera,
96650
99197
  hasAiObstacleDetection: caps.hasAiObstacleDetection,
96651
99198
  aiFeatureCount: caps.hasAiObstacleDetection ? caps.supportedAiFeatures.length : 0
96652
99199
  };
@@ -96674,6 +99221,12 @@ var DreameContainerDevice = class extends BaseDevice {
96674
99221
  modelSupportsMap() {
96675
99222
  return vacuumSupportsMap(this.resolveVacuumCaps());
96676
99223
  }
99224
+ /** Whether this vacuum model carries an on-board camera — gates the separate
99225
+ * camera + navigation child. Gated on the RESOLVED handle's confirmed
99226
+ * capability (false when the handle is not up, never optimistic). */
99227
+ modelSupportsCamera() {
99228
+ return vacuumSupportsCamera(this.resolveVacuumCaps());
99229
+ }
96677
99230
  /** Whether this mower model supports a map — gated on the RESOLVED handle's
96678
99231
  * confirmed capability (false when the handle is not up, never optimistic). */
96679
99232
  modelSupportsMowerMap() {
@@ -97571,4 +100124,4 @@ var DreameProviderAddon = class extends BaseDeviceProvider {
97571
100124
  }
97572
100125
  };
97573
100126
  //#endregion
97574
- export { DreameProviderAddon, mapMowerStatusToActivity as a, deviceTypeForKind as c, dreameConfigSchema as d, toNodreameOptions as f, tokenToSuctionLevel as i, buildConnectionFormSchema as l, suctionLevelToken as n, classifyDreameModel as o, supportedFanSpeedTokens as r, controlCapForKind as s, mapMiotStateToVacuumState as t, connectionFromSettings as u };
100127
+ export { DreameProviderAddon, tokenToSuctionLevel as a, deviceTypeForKind as c, dreameConfigSchema as d, toNodreameOptions as f, supportedFanSpeedTokens as i, buildConnectionFormSchema as l, mapMiotStateToVacuumState as n, classifyDreameModel as o, suctionLevelToken as r, controlCapForKind as s, mapMowerStatusToActivity as t, connectionFromSettings as u };