@camstack/addon-provider-dreame 0.2.70 → 0.2.71

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) {
@@ -74306,6 +75512,21 @@ var NetworkLinkStatusSchema = object({
74306
75512
  /** Ms epoch of the last observation. Lets consumers reason about freshness. */
74307
75513
  lastUpdated: number()
74308
75514
  });
75515
+ /** The slice a provider seeds before its first read: nothing is known yet. */
75516
+ var NETWORK_LINK_UNKNOWN = {
75517
+ type: "unknown",
75518
+ signalPercent: null,
75519
+ lastUpdated: 0
75520
+ };
75521
+ /**
75522
+ * Normalise a signal the firmware reports as BARS (0..`maxBars`) to 0..100.
75523
+ * Out-of-range or non-finite input is not a reading: `null`.
75524
+ */
75525
+ function signalPercentFromBars(bars, maxBars) {
75526
+ if (bars === void 0 || !Number.isFinite(bars) || maxBars <= 0) return null;
75527
+ if (bars < 0 || bars > maxBars) return null;
75528
+ return Math.round(bars / maxBars * 100);
75529
+ }
74309
75530
  var networkLinkCapability = {
74310
75531
  name: "network-link",
74311
75532
  scope: "device",
@@ -74334,11 +75555,7 @@ onStatusChanged: { data: object({
74334
75555
  status: {
74335
75556
  schema: NetworkLinkStatusSchema,
74336
75557
  kind: "push",
74337
- empty: {
74338
- type: "unknown",
74339
- signalPercent: null,
74340
- lastUpdated: 0
74341
- }
75558
+ empty: NETWORK_LINK_UNKNOWN
74342
75559
  },
74343
75560
  /**
74344
75561
  * Runtime-state slice — every provider stores the same shape under
@@ -79336,7 +80553,10 @@ var PtzMoveCommandSchema = object({
79336
80553
  zoom: number().optional(),
79337
80554
  speed: number().optional()
79338
80555
  });
79339
- PtzPositionSchema.extend({ autofocus: boolean() });
80556
+ /** PTZ status — current position plus the camera's autofocus state.
80557
+ * `getPosition` / `move` still operate on the bare position; the
80558
+ * auto-injected `getStatus` returns this richer block. */
80559
+ var PtzStatusSchema = PtzPositionSchema.extend({ autofocus: boolean() });
79340
80560
  /** Per-camera PTZ availability descriptor — drives the UI. */
79341
80561
  var PtzOptionsSchema = object({
79342
80562
  hasPan: boolean(),
@@ -79349,26 +80569,65 @@ var PtzOptionsSchema = object({
79349
80569
  * (boolean `hasX` per the getOptions availability convention). */
79350
80570
  hasAutofocus: boolean()
79351
80571
  });
79352
- DeviceType.Camera, method(PtzMoveCommandSchema.extend({ deviceId: number() }), _void(), { kind: "mutation" }), method(PtzMoveCommandSchema.extend({ deviceId: number() }), _void(), { kind: "mutation" }), method(object({ deviceId: number() }), _void(), { kind: "mutation" }), method(object({ deviceId: number() }), array(PtzPresetSchema)), method(object({
79353
- deviceId: number(),
79354
- presetId: string()
79355
- }), _void(), { kind: "mutation" }), method(object({
79356
- deviceId: number(),
79357
- presetId: string(),
79358
- name: string()
79359
- }), _void(), {
79360
- kind: "mutation",
79361
- auth: "admin"
79362
- }), method(object({
79363
- deviceId: number(),
79364
- presetId: string()
79365
- }), _void(), {
79366
- kind: "mutation",
79367
- auth: "admin"
79368
- }), method(object({ deviceId: number() }), PtzOptionsSchema), method(object({ deviceId: number() }), _void(), { kind: "mutation" }), method(object({ deviceId: number() }), PtzPositionSchema), method(object({
79369
- deviceId: number(),
79370
- enabled: boolean()
79371
- }), _void(), { kind: "mutation" });
80572
+ var ptzCapability = {
80573
+ name: "ptz",
80574
+ scope: "device",
80575
+ deviceNative: true,
80576
+ mode: "singleton",
80577
+ deviceTypes: [DeviceType.Camera],
80578
+ deviceConfig: { ui: {
80579
+ kind: "widget",
80580
+ widgetId: "host/ptz-panel",
80581
+ tab: "ptz",
80582
+ topTab: true,
80583
+ label: "PTZ",
80584
+ order: 0
80585
+ } },
80586
+ methods: {
80587
+ move: method(PtzMoveCommandSchema.extend({ deviceId: number() }), _void(), { kind: "mutation" }),
80588
+ continuousMove: method(PtzMoveCommandSchema.extend({ deviceId: number() }), _void(), { kind: "mutation" }),
80589
+ stop: method(object({ deviceId: number() }), _void(), { kind: "mutation" }),
80590
+ getPresets: method(object({ deviceId: number() }), array(PtzPresetSchema)),
80591
+ goToPreset: method(object({
80592
+ deviceId: number(),
80593
+ presetId: string()
80594
+ }), _void(), { kind: "mutation" }),
80595
+ savePreset: method(object({
80596
+ deviceId: number(),
80597
+ presetId: string(),
80598
+ name: string()
80599
+ }), _void(), {
80600
+ kind: "mutation",
80601
+ auth: "admin"
80602
+ }),
80603
+ deletePreset: method(object({
80604
+ deviceId: number(),
80605
+ presetId: string()
80606
+ }), _void(), {
80607
+ kind: "mutation",
80608
+ auth: "admin"
80609
+ }),
80610
+ getOptions: method(object({ deviceId: number() }), PtzOptionsSchema),
80611
+ goHome: method(object({ deviceId: number() }), _void(), { kind: "mutation" }),
80612
+ /**
80613
+ * Pull the current PTZ position. Redundant with the auto-injected
80614
+ * `getStatus` method (see `status` below); kept for callers that
80615
+ * haven't migrated. Will be folded into `getStatus` in a future
80616
+ * cleanup pass once all consumers use the typed status surface.
80617
+ */
80618
+ getPosition: method(object({ deviceId: number() }), PtzPositionSchema),
80619
+ /** Toggle the camera's autofocus. Only meaningful when
80620
+ * `getOptions().hasAutofocus` is true. */
80621
+ setAutofocus: method(object({
80622
+ deviceId: number(),
80623
+ enabled: boolean()
80624
+ }), _void(), { kind: "mutation" })
80625
+ },
80626
+ status: {
80627
+ schema: PtzStatusSchema,
80628
+ kind: "command-driven"
80629
+ }
80630
+ };
79372
80631
  var PtzAutotrackSettingsSchema = object({
79373
80632
  targetType: string().describe("Vendor target string (people/vehicle/pet); empty = camera default"),
79374
80633
  stopDelaySeconds: number().int().min(0).max(300),
@@ -79494,6 +80753,424 @@ var ptzAutotrackCapability = {
79494
80753
  */
79495
80754
  durability: "session"
79496
80755
  };
80756
+ /**
80757
+ * `navigation` — a device-scoped capability that natively expresses the FULL
80758
+ * navigation / action surface of a robot that DRIVES ITSELF and carries an
80759
+ * on-board camera (the Dreame robot-vacuum camera is the first provider).
80760
+ *
80761
+ * Why a NEW cap rather than overloading `ptz`:
80762
+ * - `ptz` moves a *gimbal* on a fixed camera (pan/tilt/zoom of the lens). A
80763
+ * robot vacuum has no gimbal — the whole chassis drives, turns and spins.
80764
+ * The two are different physical models: PTZ is absolute-position + presets,
80765
+ * navigation is momentary drive nudges + discrete robot ACTIONS
80766
+ * (dock / spot-clean / follow-pet / go-to-point / …).
80767
+ * - This cap is the SOURCE OF TRUTH. Two consumers adapt from it rather than
80768
+ * the reverse:
80769
+ * 1. a native CamStack navigation panel (data-driven from `listActions`
80770
+ * / `getOptions`), and
80771
+ * 2. the PTZ surface — a thin adapter mimics `ptz` from `navigation` so a
80772
+ * robot camera shows up in the existing PTZ control path without every
80773
+ * PTZ provider learning about robots. The mapping lives in the adapter,
80774
+ * not here (see the addon design note):
80775
+ * ptz.continuousMove({pan,tilt}) → navigation.move({pan,tilt})
80776
+ * ptz.stop() → navigation.stop()
80777
+ * ptz.goHome() → navigation.runAction('goHome')
80778
+ * ptz.getPresets() → navigation.listActions() (id→preset)
80779
+ * ptz.goToPreset(id) → navigation.runAction(id)
80780
+ *
80781
+ * ## Continuous drive
80782
+ *
80783
+ * `move` is MOMENTARY. Fluid navigation comes from the UI (or the PTZ adapter)
80784
+ * sending `move({pan,tilt})` REPEATEDLY at ~1 Hz while a direction is held, and
80785
+ * one `stop()` on release — exactly like the robot app's remote-drive joystick.
80786
+ * The provider forwards EACH `move` to one drive write; it must NOT debounce or
80787
+ * coalesce them. The UI owns the cadence.
80788
+ *
80789
+ * ## The action dictionary
80790
+ *
80791
+ * The discrete controls (dock / locate / spot-clean / follow / flash / sounds)
80792
+ * are a DATA-DRIVEN dictionary the cap exposes via `listActions()`. Each entry
80793
+ * carries `{ id, kind, label, icon }` (plus `soundId` for sound entries) so the
80794
+ * native panel AND the PTZ mimic render buttons WITHOUT hardcoding a
80795
+ * vendor-specific list. `kind: 'action'` entries are triggered with
80796
+ * `runAction({ actionId })`; `kind: 'sound'` entries with `playSound({ soundId })`
80797
+ * (the entry carries the `soundId` to pass). The general primitives — `move`,
80798
+ * `stop`, `goToPoint` — stay as first-class methods, not dictionary entries.
80799
+ *
80800
+ * NOTE (provider wiring): the first provider (Dreame) drives this with the RAW
80801
+ * `callAction(siid,aiid,in)` / `setProperty({siid,piid,value})` MIoT primitives
80802
+ * that the currently-published `@apocaliss92/nodedreame` already exposes on
80803
+ * every device handle. A future nodedreame publish adds a typed
80804
+ * `DreameCameraController` (drive / playPetSound / spotClean / findPet / …); the
80805
+ * provider can then swap the raw calls for the typed methods with no change to
80806
+ * THIS contract.
80807
+ */
80808
+ /**
80809
+ * A momentary drive nudge. The robot MOVES (no gimbal) for as long as the caller
80810
+ * keeps sending nudges (~1 Hz); an explicit `stop` (or letting the nudges lapse)
80811
+ * halts it.
80812
+ *
80813
+ * - `pan` — turn: negative = left, positive = right, 0 = straight.
80814
+ * - `tilt` — throttle: positive = forward, negative = spin / turn-around.
80815
+ * - `speed` — optional intensity hint [0, 1]; the provider may scale the drive
80816
+ * vector by it (drivers without proportional drive ignore it).
80817
+ *
80818
+ * `pan` / `tilt` are normalized [-1, 1]. Both optional so a caller can nudge one
80819
+ * axis alone; an all-undefined nudge is a no-op.
80820
+ */
80821
+ var NavigationMoveCommandSchema = object({
80822
+ pan: number().min(-1).max(1).optional(),
80823
+ tilt: number().min(-1).max(1).optional(),
80824
+ speed: number().min(0).max(1).optional()
80825
+ });
80826
+ /**
80827
+ * The enumerated discrete actions a navigation-capable robot can perform via
80828
+ * `runAction`. This is the CLOSED vocabulary; a given device advertises the
80829
+ * subset it supports through `listActions`. Sounds are NOT here — they go through
80830
+ * `playSound` (see the `sound` dictionary entries).
80831
+ */
80832
+ var NavigationActionIdSchema = _enum([
80833
+ "goHome",
80834
+ "locate",
80835
+ "spotClean",
80836
+ "findPet",
80837
+ "personFollow",
80838
+ "stop",
80839
+ "startClean",
80840
+ "pauseClean",
80841
+ "dockWash",
80842
+ "autoEmpty",
80843
+ "flashOn",
80844
+ "flashOff"
80845
+ ]);
80846
+ /** Whether a dictionary entry is a `runAction` action or a `playSound` sound. */
80847
+ var NavigationEntryKindSchema = _enum(["action", "sound"]);
80848
+ /**
80849
+ * One entry in the navigation action dictionary — the DATA-DRIVEN unit both the
80850
+ * native panel and the PTZ mimic render as a button.
80851
+ *
80852
+ * - `id` — stable id. For `kind:'action'` it is a {@link NavigationActionId}
80853
+ * (pass to `runAction`); for `kind:'sound'` it is a namespaced id
80854
+ * (`sound:meow`) whose `soundId` is passed to `playSound`.
80855
+ * - `icon` — icon HINT (lucide-style name; the UI maps it to its own set).
80856
+ * - `label` — operator-facing English label.
80857
+ * - `soundId` — wire sound id, present only on `kind:'sound'` entries.
80858
+ * - `enabled` — per-device FEATURE FLAG. `listActions` reports it so the UI /
80859
+ * PTZ render ONLY enabled entries. Data-driven: the provider
80860
+ * flips it from config, never by editing code.
80861
+ */
80862
+ var NavigationActionEntrySchema = object({
80863
+ id: string(),
80864
+ kind: NavigationEntryKindSchema,
80865
+ label: string(),
80866
+ icon: string(),
80867
+ /** Present only on `kind:'sound'` entries — the id to pass to `playSound`. */
80868
+ soundId: number().int().optional(),
80869
+ /** Per-device feature flag — render this entry only when true. */
80870
+ enabled: boolean()
80871
+ });
80872
+ /**
80873
+ * Canonical dictionary of every navigation control (discrete actions + sounds)
80874
+ * with its default label + icon hint. A provider filters this to the subset a
80875
+ * device supports (and may override a label). Exported so the provider AND the
80876
+ * UI share ONE list instead of re-deriving labels / ids independently.
80877
+ *
80878
+ * Icons are lucide-style hints; the viewer maps them to its own icon set. Every
80879
+ * catalog default is `enabled: true`; a provider overrides per-device from
80880
+ * config (data-driven — enabling / disabling one later is a config change, not a
80881
+ * code change).
80882
+ */
80883
+ var NAVIGATION_ACTION_CATALOG = [
80884
+ {
80885
+ id: "startClean",
80886
+ kind: "action",
80887
+ label: "Start cleaning",
80888
+ icon: "play",
80889
+ enabled: true
80890
+ },
80891
+ {
80892
+ id: "pauseClean",
80893
+ kind: "action",
80894
+ label: "Pause cleaning",
80895
+ icon: "pause",
80896
+ enabled: true
80897
+ },
80898
+ {
80899
+ id: "stop",
80900
+ kind: "action",
80901
+ label: "Stop",
80902
+ icon: "hand",
80903
+ enabled: true
80904
+ },
80905
+ {
80906
+ id: "goHome",
80907
+ kind: "action",
80908
+ label: "Return to dock",
80909
+ icon: "home",
80910
+ enabled: true
80911
+ },
80912
+ {
80913
+ id: "locate",
80914
+ kind: "action",
80915
+ label: "Locate",
80916
+ icon: "bell",
80917
+ enabled: true
80918
+ },
80919
+ {
80920
+ id: "spotClean",
80921
+ kind: "action",
80922
+ label: "Spot clean here",
80923
+ icon: "crosshair",
80924
+ enabled: true
80925
+ },
80926
+ {
80927
+ id: "findPet",
80928
+ kind: "action",
80929
+ label: "Find pet",
80930
+ icon: "paw-print",
80931
+ enabled: true
80932
+ },
80933
+ {
80934
+ id: "personFollow",
80935
+ kind: "action",
80936
+ label: "Follow me",
80937
+ icon: "footprints",
80938
+ enabled: true
80939
+ },
80940
+ {
80941
+ id: "dockWash",
80942
+ kind: "action",
80943
+ label: "Wash mop",
80944
+ icon: "droplets",
80945
+ enabled: true
80946
+ },
80947
+ {
80948
+ id: "autoEmpty",
80949
+ kind: "action",
80950
+ label: "Empty dustbin",
80951
+ icon: "trash-2",
80952
+ enabled: true
80953
+ },
80954
+ {
80955
+ id: "flashOn",
80956
+ kind: "action",
80957
+ label: "Flash on",
80958
+ icon: "flashlight",
80959
+ enabled: true
80960
+ },
80961
+ {
80962
+ id: "flashOff",
80963
+ kind: "action",
80964
+ label: "Flash off",
80965
+ icon: "flashlight-off",
80966
+ enabled: true
80967
+ },
80968
+ {
80969
+ id: "sound:meow",
80970
+ kind: "sound",
80971
+ label: "Meow",
80972
+ icon: "cat",
80973
+ soundId: 684,
80974
+ enabled: true
80975
+ },
80976
+ {
80977
+ id: "sound:bark",
80978
+ kind: "sound",
80979
+ label: "Woof",
80980
+ icon: "dog",
80981
+ soundId: 685,
80982
+ enabled: true
80983
+ },
80984
+ {
80985
+ id: "sound:footsteps",
80986
+ kind: "sound",
80987
+ label: "Footsteps",
80988
+ icon: "footprints",
80989
+ soundId: 686,
80990
+ enabled: true
80991
+ },
80992
+ {
80993
+ id: "sound:purring",
80994
+ kind: "sound",
80995
+ label: "Purring",
80996
+ icon: "cat",
80997
+ soundId: 687,
80998
+ enabled: true
80999
+ },
81000
+ {
81001
+ id: "sound:tickTock",
81002
+ kind: "sound",
81003
+ label: "Tick-tock",
81004
+ icon: "clock",
81005
+ soundId: 688,
81006
+ enabled: true
81007
+ }
81008
+ ];
81009
+ /** Coordinates for `goToPoint` — a point on the robot's live map. */
81010
+ var NavigationPointSchema = object({
81011
+ x: number(),
81012
+ y: number()
81013
+ });
81014
+ /**
81015
+ * Per-device FEATURE-FLAG report for the general (non-dictionary) primitives.
81016
+ * The cap reports which are enabled so the UI / PTZ render only the controls
81017
+ * that are turned on for THIS device. Data-driven: the provider derives these
81018
+ * from config + probe, never hardcoded in the UI. The per-DICTIONARY-entry flags
81019
+ * live on {@link NavigationActionEntrySchema.enabled}; these gate the primitives
81020
+ * that are not dictionary entries.
81021
+ *
81022
+ * - `move` / `stop` — the momentary drive joystick.
81023
+ * - `goToPoint` — send-to-map-coordinate. Ships OFF on Dreame until the
81024
+ * map-coordinate plumbing is wired.
81025
+ * - `runAction` — the discrete action buttons (dictionary `kind:'action'`).
81026
+ * - `playSound` — the sound buttons (dictionary `kind:'sound'`).
81027
+ * - `light` — the on/off fill-light toggle (works anytime).
81028
+ * - `lightMode` — the auto/manual selector + manual level slider (a
81029
+ * camera-service control; needs an active stream).
81030
+ */
81031
+ var NavigationFeaturesSchema = object({
81032
+ move: boolean(),
81033
+ stop: boolean(),
81034
+ goToPoint: boolean(),
81035
+ runAction: boolean(),
81036
+ playSound: boolean(),
81037
+ light: boolean(),
81038
+ lightMode: boolean()
81039
+ });
81040
+ /** Light mode: `auto` lets the camera choose brightness; `manual` uses `level`. */
81041
+ var NavigationLightModeSchema = _enum(["auto", "manual"]);
81042
+ /**
81043
+ * Live navigation state so the UI can reflect what the robot is doing:
81044
+ * - `mode` — coarse activity (idle / cleaning / following / …).
81045
+ * - `following` — person/pet follow is currently armed.
81046
+ * - `flash` — the on-camera fill light is on.
81047
+ * - `lightMode` — auto vs manual fill-light mode.
81048
+ * - `lightLevel` — manual fill-light level (40..100); meaningful when
81049
+ * `lightMode === 'manual'`.
81050
+ */
81051
+ var NavigationStatusSchema = object({
81052
+ mode: _enum([
81053
+ "idle",
81054
+ "cleaning",
81055
+ "spot",
81056
+ "following",
81057
+ "goto",
81058
+ "returning",
81059
+ "paused",
81060
+ "unknown"
81061
+ ]),
81062
+ following: boolean(),
81063
+ flash: boolean(),
81064
+ lightMode: NavigationLightModeSchema,
81065
+ lightLevel: number().min(40).max(100),
81066
+ /** Ms epoch when the slice was last updated. */
81067
+ lastChangedAt: number()
81068
+ });
81069
+ /**
81070
+ * Runtime-state slice owned by this cap (kernel-managed: validated, mirrored,
81071
+ * observable). Adds `lastFetchedAt` on top of the status shape per the
81072
+ * convention.
81073
+ */
81074
+ var NavigationRuntimeStateSchema = NavigationStatusSchema.extend({ lastFetchedAt: number() });
81075
+ var navigationCapability = {
81076
+ name: "navigation",
81077
+ scope: "device",
81078
+ deviceNative: true,
81079
+ mode: "singleton",
81080
+ deviceTypes: [DeviceType.Camera],
81081
+ deviceConfig: { ui: {
81082
+ kind: "widget",
81083
+ widgetId: "host/navigation-panel",
81084
+ tab: "navigation",
81085
+ topTab: true,
81086
+ label: "Navigation",
81087
+ order: 0
81088
+ } },
81089
+ methods: {
81090
+ /**
81091
+ * Momentary drive nudge (the robot moves). `protected` — mirrors
81092
+ * `ptz.continuousMove` so the Viewer navigation panel (and the PTZ-mimic
81093
+ * path) works for any authenticated user, not admin-only. The UI sends
81094
+ * these at ~1 Hz while a control is held; the provider forwards each one to
81095
+ * a single drive write WITHOUT debouncing.
81096
+ */
81097
+ move: method(NavigationMoveCommandSchema.extend({ deviceId: number() }), _void(), { kind: "mutation" }),
81098
+ /** Halt all motion immediately (zero drive vector). */
81099
+ stop: method(object({ deviceId: number() }), _void(), { kind: "mutation" }),
81100
+ /** Send the robot to a point on its live map. */
81101
+ goToPoint: method(NavigationPointSchema.extend({ deviceId: number() }), _void(), { kind: "mutation" }),
81102
+ /**
81103
+ * Enumerate the discrete controls THIS device supports (data-driven UI +
81104
+ * PTZ mimic). Camera-probed subset of {@link NAVIGATION_ACTION_CATALOG}.
81105
+ */
81106
+ listActions: method(object({ deviceId: number() }), array(NavigationActionEntrySchema)),
81107
+ /**
81108
+ * Run one discrete action (a `kind:'action'` dictionary entry). Invalid /
81109
+ * unsupported action ids are rejected by the provider.
81110
+ */
81111
+ runAction: method(object({
81112
+ deviceId: number(),
81113
+ actionId: NavigationActionIdSchema
81114
+ }), _void(), { kind: "mutation" }),
81115
+ /** Play a sound by its wire id (the `soundId` of a `kind:'sound'` entry). */
81116
+ playSound: method(object({
81117
+ deviceId: number(),
81118
+ soundId: number().int()
81119
+ }), _void(), { kind: "mutation" }),
81120
+ /**
81121
+ * Turn the on-camera fill light on / off (the `OpenFullLight` control —
81122
+ * works anytime, no active stream required).
81123
+ */
81124
+ setLightOn: method(object({
81125
+ deviceId: number(),
81126
+ on: boolean()
81127
+ }), _void(), { kind: "mutation" }),
81128
+ /**
81129
+ * Set the fill-light mode (auto vs manual). `manual` optionally carries the
81130
+ * initial `level`. The auto/manual + level control is a CAMERA-service
81131
+ * action that generally needs an active camera stream/monitor session — the
81132
+ * UI shows the manual level slider ONLY when `mode === 'manual'`.
81133
+ */
81134
+ setLightMode: method(object({
81135
+ deviceId: number(),
81136
+ mode: NavigationLightModeSchema,
81137
+ level: number().min(40).max(100).optional()
81138
+ }), _void(), { kind: "mutation" }),
81139
+ /** Set the MANUAL fill-light level (40..100). Implies `manual` mode. */
81140
+ setLightLevel: method(object({
81141
+ deviceId: number(),
81142
+ level: number().min(40).max(100)
81143
+ }), _void(), { kind: "mutation" }),
81144
+ /**
81145
+ * Per-device FEATURE-FLAG report for the general primitives — drives which
81146
+ * controls the UI shows (the per-entry flags for the dictionary come back on
81147
+ * `listActions`).
81148
+ */
81149
+ getFeatures: method(object({ deviceId: number() }), NavigationFeaturesSchema)
81150
+ },
81151
+ events: { onStatusChanged: { data: object({
81152
+ deviceId: number(),
81153
+ status: NavigationStatusSchema
81154
+ }) } },
81155
+ status: {
81156
+ schema: NavigationStatusSchema,
81157
+ kind: "push"
81158
+ },
81159
+ /**
81160
+ * Runtime-state slice mirrored by the kernel. The navigation panel watches it
81161
+ * for live mode / follow / flash changes.
81162
+ */
81163
+ runtimeState: NavigationRuntimeStateSchema,
81164
+ /**
81165
+ * Runtime-state durability: **session** — like `vacuum-control`, a restored
81166
+ * `mode: cleaning` / `following: true` is a robot that is not actually doing
81167
+ * that. The live handle re-publishes on connect.
81168
+ *
81169
+ * See `RuntimeStateDurability`. Enforced by
81170
+ * `scripts/check-runtime-state-durability.ts`.
81171
+ */
81172
+ durability: "session"
81173
+ };
79497
81174
  DeviceType.Camera, DeviceType.Sensor, DeviceType.Switch, method(object({ deviceId: number().int().nonnegative() }), object({ success: literal(true) }), {
79498
81175
  kind: "mutation",
79499
81176
  auth: "admin"
@@ -82769,6 +84446,7 @@ var DEVICE_LOCAL_STATE_CAPS = {
82769
84446
  motionTrigger: motionTriggerCapability,
82770
84447
  motionZones: motionZonesCapability,
82771
84448
  nativeObjectDetection: nativeObjectDetectionCapability,
84449
+ navigation: navigationCapability,
82772
84450
  networkLink: networkLinkCapability,
82773
84451
  notifier: notifierCapability,
82774
84452
  numericSensor: numericSensorCapability,
@@ -86623,6 +88301,66 @@ Object.freeze({
86623
88301
  addonId: null,
86624
88302
  access: "create"
86625
88303
  },
88304
+ "navigation.getFeatures": {
88305
+ capName: "navigation",
88306
+ capScope: "device",
88307
+ addonId: null,
88308
+ access: "view"
88309
+ },
88310
+ "navigation.goToPoint": {
88311
+ capName: "navigation",
88312
+ capScope: "device",
88313
+ addonId: null,
88314
+ access: "create"
88315
+ },
88316
+ "navigation.listActions": {
88317
+ capName: "navigation",
88318
+ capScope: "device",
88319
+ addonId: null,
88320
+ access: "view"
88321
+ },
88322
+ "navigation.move": {
88323
+ capName: "navigation",
88324
+ capScope: "device",
88325
+ addonId: null,
88326
+ access: "create"
88327
+ },
88328
+ "navigation.playSound": {
88329
+ capName: "navigation",
88330
+ capScope: "device",
88331
+ addonId: null,
88332
+ access: "create"
88333
+ },
88334
+ "navigation.runAction": {
88335
+ capName: "navigation",
88336
+ capScope: "device",
88337
+ addonId: null,
88338
+ access: "create"
88339
+ },
88340
+ "navigation.setLightLevel": {
88341
+ capName: "navigation",
88342
+ capScope: "device",
88343
+ addonId: null,
88344
+ access: "create"
88345
+ },
88346
+ "navigation.setLightMode": {
88347
+ capName: "navigation",
88348
+ capScope: "device",
88349
+ addonId: null,
88350
+ access: "create"
88351
+ },
88352
+ "navigation.setLightOn": {
88353
+ capName: "navigation",
88354
+ capScope: "device",
88355
+ addonId: null,
88356
+ access: "create"
88357
+ },
88358
+ "navigation.stop": {
88359
+ capName: "navigation",
88360
+ capScope: "device",
88361
+ addonId: null,
88362
+ access: "create"
88363
+ },
86626
88364
  "networkAccess.getEndpoint": {
86627
88365
  capName: "network-access",
86628
88366
  capScope: "system",
@@ -90718,6 +92456,56 @@ Object.freeze({
90718
92456
  form: "single",
90719
92457
  optional: false
90720
92458
  }],
92459
+ "navigation.getFeatures": [{
92460
+ name: "deviceId",
92461
+ form: "single",
92462
+ optional: false
92463
+ }],
92464
+ "navigation.goToPoint": [{
92465
+ name: "deviceId",
92466
+ form: "single",
92467
+ optional: false
92468
+ }],
92469
+ "navigation.listActions": [{
92470
+ name: "deviceId",
92471
+ form: "single",
92472
+ optional: false
92473
+ }],
92474
+ "navigation.move": [{
92475
+ name: "deviceId",
92476
+ form: "single",
92477
+ optional: false
92478
+ }],
92479
+ "navigation.playSound": [{
92480
+ name: "deviceId",
92481
+ form: "single",
92482
+ optional: false
92483
+ }],
92484
+ "navigation.runAction": [{
92485
+ name: "deviceId",
92486
+ form: "single",
92487
+ optional: false
92488
+ }],
92489
+ "navigation.setLightLevel": [{
92490
+ name: "deviceId",
92491
+ form: "single",
92492
+ optional: false
92493
+ }],
92494
+ "navigation.setLightMode": [{
92495
+ name: "deviceId",
92496
+ form: "single",
92497
+ optional: false
92498
+ }],
92499
+ "navigation.setLightOn": [{
92500
+ name: "deviceId",
92501
+ form: "single",
92502
+ optional: false
92503
+ }],
92504
+ "navigation.stop": [{
92505
+ name: "deviceId",
92506
+ form: "single",
92507
+ optional: false
92508
+ }],
90721
92509
  "networkQuality.getDeviceStats": [{
90722
92510
  name: "deviceId",
90723
92511
  form: "single",
@@ -93900,6 +95688,23 @@ var dreameChildSchema = object({
93900
95688
  mapShowPath: boolean().optional(),
93901
95689
  mapShowRobot: boolean().optional(),
93902
95690
  mapShowCharger: boolean().optional(),
95691
+ /**
95692
+ * `navigation` per-device overrides (camera child only): a feature flag set
95693
+ * here wins over the addon defaults, an entry id listed here is switched off.
95694
+ * Data, not code — see `dreame-navigation-config.ts`.
95695
+ */
95696
+ navigationFeatures: object({
95697
+ move: boolean().optional(),
95698
+ stop: boolean().optional(),
95699
+ goToPoint: boolean().optional(),
95700
+ runAction: boolean().optional(),
95701
+ playSound: boolean().optional(),
95702
+ light: boolean().optional(),
95703
+ lightMode: boolean().optional()
95704
+ }).optional(),
95705
+ navigationDisabledEntryIds: array(string()).optional(),
95706
+ /** Privacy PIN set at pairing — required to wake a coded camera (camera child only). */
95707
+ navigationAccessCode: string().optional(),
93903
95708
  mapShowNoGo: boolean().optional(),
93904
95709
  mapShowVirtualWalls: boolean().optional(),
93905
95710
  mapShowObstacles: boolean().optional(),
@@ -93992,7 +95797,7 @@ var DreameChildDevice = class extends BaseDevice {
93992
95797
  };
93993
95798
  //#endregion
93994
95799
  //#region src/devices/dreame-ai-switch-device.ts
93995
- var CAP_NAME$14 = "switch";
95800
+ var CAP_NAME$15 = "switch";
93996
95801
  var COLD$4 = {
93997
95802
  on: false,
93998
95803
  lastChangedAt: 0
@@ -94054,26 +95859,26 @@ var DreameAiSwitchDevice = class extends DreameChildDevice {
94054
95859
  }
94055
95860
  registerControlCap() {
94056
95861
  this.ctx.registerNativeCap(switchCapability, {
94057
- getStatus: async () => this.runtimeState.getCapState(CAP_NAME$14) ?? COLD$4,
95862
+ getStatus: async () => this.runtimeState.getCapState(CAP_NAME$15) ?? COLD$4,
94058
95863
  setState: async ({ on }) => {
94059
95864
  const vacuum = this.resolveVacuum();
94060
95865
  if (vacuum === null) throw new Error(`DreameAiSwitchDevice: vacuum handle unavailable (device=${this.dreameDeviceId}, feature=${this.#feature})`);
94061
95866
  await vacuum.setAiFeature(this.#feature, on);
94062
- this.runtimeState.setCapState(CAP_NAME$14, {
95867
+ this.runtimeState.setCapState(CAP_NAME$15, {
94063
95868
  on,
94064
95869
  lastChangedAt: Date.now()
94065
95870
  });
94066
95871
  }
94067
95872
  });
94068
- this.runtimeState.setCapState(CAP_NAME$14, COLD$4);
95873
+ this.runtimeState.setCapState(CAP_NAME$15, COLD$4);
94069
95874
  }
94070
95875
  recomputeSlice() {
94071
95876
  const vacuum = this.resolveVacuum();
94072
95877
  if (vacuum === null) return;
94073
95878
  const on = vacuum.aiFeature(this.#feature);
94074
95879
  if (on === null) return;
94075
- if (this.runtimeState.getCapState(CAP_NAME$14)?.on === on) return;
94076
- this.runtimeState.setCapState(CAP_NAME$14, {
95880
+ if (this.runtimeState.getCapState(CAP_NAME$15)?.on === on) return;
95881
+ this.runtimeState.setCapState(CAP_NAME$15, {
94077
95882
  on,
94078
95883
  lastChangedAt: Date.now()
94079
95884
  });
@@ -94098,6 +95903,14 @@ function vacuumSupportsAi(caps) {
94098
95903
  return caps !== null && caps.hasAiObstacleDetection && caps.aiFeatureCount > 0;
94099
95904
  }
94100
95905
  /**
95906
+ * Whether a vacuum carries an on-board camera (LinkVisual video) — gates the
95907
+ * separate camera + navigation child. `false` when the handle has not resolved
95908
+ * (confirmed capability only, never optimistic).
95909
+ */
95910
+ function vacuumSupportsCamera(caps) {
95911
+ return caps?.hasCamera === true;
95912
+ }
95913
+ /**
94101
95914
  * Whether a mower supports a map. `false` when the handle has not resolved.
94102
95915
  */
94103
95916
  function mowerSupportsMap(caps) {
@@ -94131,7 +95944,7 @@ function deriveContainerFeatures(input) {
94131
95944
  }
94132
95945
  //#endregion
94133
95946
  //#region src/devices/dreame-auto-switch-device.ts
94134
- var CAP_NAME$13 = "switch";
95947
+ var CAP_NAME$14 = "switch";
94135
95948
  var COLD$3 = {
94136
95949
  on: false,
94137
95950
  lastChangedAt: 0
@@ -94179,19 +95992,19 @@ var DreameAutoSwitchDevice = class extends DreameChildDevice {
94179
95992
  }
94180
95993
  registerControlCap() {
94181
95994
  this.ctx.registerNativeCap(switchCapability, {
94182
- getStatus: async () => this.runtimeState.getCapState(CAP_NAME$13) ?? COLD$3,
95995
+ getStatus: async () => this.runtimeState.getCapState(CAP_NAME$14) ?? COLD$3,
94183
95996
  setState: async ({ on }) => {
94184
95997
  const vacuum = this.#vacuum();
94185
95998
  if (vacuum === null) throw new Error(`DreameAutoSwitchDevice: handle unavailable (device=${this.dreameDeviceId}, ${this.#key})`);
94186
95999
  await vacuum.setAutoSwitchProperty(this.#key, on ? 1 : 0);
94187
- this.runtimeState.setCapState(CAP_NAME$13, {
96000
+ this.runtimeState.setCapState(CAP_NAME$14, {
94188
96001
  on,
94189
96002
  lastChangedAt: Date.now()
94190
96003
  });
94191
96004
  }
94192
96005
  });
94193
96006
  const on = this.#read();
94194
- this.runtimeState.setCapState(CAP_NAME$13, on === null ? COLD$3 : {
96007
+ this.runtimeState.setCapState(CAP_NAME$14, on === null ? COLD$3 : {
94195
96008
  on,
94196
96009
  lastChangedAt: Date.now()
94197
96010
  });
@@ -94199,8 +96012,8 @@ var DreameAutoSwitchDevice = class extends DreameChildDevice {
94199
96012
  recomputeSlice() {
94200
96013
  const on = this.#read();
94201
96014
  if (on === null) return;
94202
- if (this.runtimeState.getCapState(CAP_NAME$13)?.on === on) return;
94203
- this.runtimeState.setCapState(CAP_NAME$13, {
96015
+ if (this.runtimeState.getCapState(CAP_NAME$14)?.on === on) return;
96016
+ this.runtimeState.setCapState(CAP_NAME$14, {
94204
96017
  on,
94205
96018
  lastChangedAt: Date.now()
94206
96019
  });
@@ -94208,7 +96021,7 @@ var DreameAutoSwitchDevice = class extends DreameChildDevice {
94208
96021
  };
94209
96022
  //#endregion
94210
96023
  //#region src/devices/dreame-auto-select-device.ts
94211
- var CAP_NAME$12 = "control";
96024
+ var CAP_NAME$13 = "control";
94212
96025
  var dreameAutoSelectOptionSchema = object({
94213
96026
  value: number().int(),
94214
96027
  label: string().min(1)
@@ -94263,7 +96076,7 @@ var DreameAutoSelectDevice = class extends DreameChildDevice {
94263
96076
  }
94264
96077
  registerControlCap() {
94265
96078
  this.ctx.registerNativeCap(controlCapability, {
94266
- getStatus: async () => this.runtimeState.getCapState(CAP_NAME$12) ?? this.#slice(this.#readLabel() ?? ""),
96079
+ getStatus: async () => this.runtimeState.getCapState(CAP_NAME$13) ?? this.#slice(this.#readLabel() ?? ""),
94267
96080
  setValue: async ({ control }) => {
94268
96081
  if (control.kind !== "select") throw new Error(`DreameAutoSelectDevice: expected a select value (device=${this.dreameDeviceId}, ${this.#key})`);
94269
96082
  const option = this.#options.find((o) => o.label === control.value);
@@ -94271,16 +96084,16 @@ var DreameAutoSelectDevice = class extends DreameChildDevice {
94271
96084
  const vacuum = this.#vacuum();
94272
96085
  if (vacuum === null) throw new Error(`DreameAutoSelectDevice: handle unavailable (device=${this.dreameDeviceId}, ${this.#key})`);
94273
96086
  await vacuum.setAutoSwitchProperty(this.#key, option.value);
94274
- this.runtimeState.setCapState(CAP_NAME$12, this.#slice(option.label));
96087
+ this.runtimeState.setCapState(CAP_NAME$13, this.#slice(option.label));
94275
96088
  }
94276
96089
  });
94277
- this.runtimeState.setCapState(CAP_NAME$12, this.#slice(this.#readLabel() ?? ""));
96090
+ this.runtimeState.setCapState(CAP_NAME$13, this.#slice(this.#readLabel() ?? ""));
94278
96091
  }
94279
96092
  recomputeSlice() {
94280
96093
  const label = this.#readLabel();
94281
96094
  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));
96095
+ if (this.runtimeState.getCapState(CAP_NAME$13)?.value === label) return;
96096
+ this.runtimeState.setCapState(CAP_NAME$13, this.#slice(label));
94284
96097
  }
94285
96098
  };
94286
96099
  //#endregion
@@ -94323,6 +96136,779 @@ var DreameButtonDevice = class extends DreameChildDevice {
94323
96136
  recomputeSlice() {}
94324
96137
  };
94325
96138
  //#endregion
96139
+ //#region src/dreame-vacuum-decode.ts
96140
+ /**
96141
+ * Pure decode helpers for the Dreame vacuum. Translate the library's MIoT enum
96142
+ * values into the camstack `vacuum-control` slice shape. Unit-tested in
96143
+ * isolation (no facade, no network) — mirrors `homematic-decode.ts`.
96144
+ */
96145
+ /**
96146
+ * Map a Dreame {@link MiotState} (siid 2 piid 1) onto the camstack canonical
96147
+ * `VacuumState` lifecycle (`idle` / `cleaning` / `paused` / `returning` /
96148
+ * `docked` / `error`). A `null` state (not yet observed) settles on `idle`.
96149
+ *
96150
+ * The MIoT state space is large (~50 values); we collapse it onto the six
96151
+ * canonical states by family. Anything unrecognised falls through to `idle`
96152
+ * (the safe neutral) so a firmware-added code never throws.
96153
+ */
96154
+ function mapMiotStateToVacuumState(state) {
96155
+ if (state === null) return "idle";
96156
+ switch (state) {
96157
+ case MiotState.Charging:
96158
+ case MiotState.ChargingComplete:
96159
+ case MiotState.IntelligentCharging:
96160
+ case MiotState.CleanWashboardBase:
96161
+ case MiotState.AutoRepairBase:
96162
+ case MiotState.WaterSupplyDrainTest:
96163
+ case MiotState.CleanMopRefillWater:
96164
+ case MiotState.AutoEmptying:
96165
+ case MiotState.Emptying:
96166
+ case MiotState.Draining:
96167
+ case MiotState.AutoWaterDraining:
96168
+ case MiotState.Sanitizing: return "docked";
96169
+ case MiotState.MopDrying:
96170
+ case MiotState.DustBagDrying:
96171
+ case MiotState.SanitizingWithDry: return "drying";
96172
+ case MiotState.ReturningToCharge:
96173
+ case MiotState.ReturningToWash:
96174
+ case MiotState.ReturnInstallMop:
96175
+ case MiotState.ReturnRemoveMop:
96176
+ case MiotState.ReturnToEmpty:
96177
+ case MiotState.ReturningToDrain: return "returning";
96178
+ case MiotState.Paused:
96179
+ case MiotState.PausedAlt:
96180
+ case MiotState.PausedCleaning:
96181
+ case MiotState.DustBagDryingPaused:
96182
+ case MiotState.CameraMonitoringPaused:
96183
+ case MiotState.FindingPetPaused:
96184
+ case MiotState.InitialDeepCleanPaused:
96185
+ case MiotState.ChangingMopPaused:
96186
+ case MiotState.FloorMaintainingPaused: return "paused";
96187
+ case MiotState.Standby:
96188
+ case MiotState.WaitingForTask: return "idle";
96189
+ default: return "cleaning";
96190
+ }
96191
+ }
96192
+ /**
96193
+ * The camstack `fanSpeed` token for a Dreame {@link SuctionLevel} — the enum
96194
+ * member NAME, lower-cased (`Quiet` → `'quiet'`). Returns `null` for an
96195
+ * unobserved / unknown level.
96196
+ */
96197
+ function suctionLevelToken(level) {
96198
+ if (level === null) return null;
96199
+ const name = SuctionLevel[level];
96200
+ return typeof name === "string" ? name.toLowerCase() : null;
96201
+ }
96202
+ /**
96203
+ * Resolve a camstack `fanSpeed` token back to the Dreame {@link SuctionLevel}
96204
+ * enum value, validated against the model's `supported` levels. Returns null
96205
+ * when the token doesn't match a supported level (the caller rejects the set).
96206
+ */
96207
+ function tokenToSuctionLevel(token, supported) {
96208
+ const wanted = token.trim().toLowerCase();
96209
+ for (const level of supported) if (suctionLevelToken(level) === wanted) return level;
96210
+ return null;
96211
+ }
96212
+ /** The list of accepted `fanSpeed` tokens for a model's supported suction set. */
96213
+ function supportedFanSpeedTokens(supported) {
96214
+ const tokens = [];
96215
+ for (const level of supported) {
96216
+ const token = suctionLevelToken(level);
96217
+ if (token !== null) tokens.push(token);
96218
+ }
96219
+ return tokens;
96220
+ }
96221
+ /**
96222
+ * Clamp a raw battery reading (already a 0..100 percentage from the library)
96223
+ * to the `vacuum-control` slice's `batteryLevel` field. Returns null for a
96224
+ * null/NaN/out-of-band reading so the slice stays valid (the schema rejects
96225
+ * a number outside 0..100).
96226
+ */
96227
+ function clampBatteryLevel$1(value) {
96228
+ if (value === null || !Number.isFinite(value)) return null;
96229
+ return Math.max(0, Math.min(100, value));
96230
+ }
96231
+ /** Clamp a 0..100 percentage (progress, coverage, …), null-safe. */
96232
+ function clampPercent$1(value) {
96233
+ if (value === null || !Number.isFinite(value)) return null;
96234
+ return Math.max(0, Math.min(100, value));
96235
+ }
96236
+ /**
96237
+ * Clean-water tank `status`: prefer the richer clean-water-tank-status (27/1),
96238
+ * fall back to the coarser water-tank presence (4/6). `null` when neither maps
96239
+ * to a known good/low reading (NOT_INSTALLED / CHECKING / unknown). Pure.
96240
+ */
96241
+ function cleanWaterTankStatus(props) {
96242
+ if (props.cleanWaterTankStatus !== null) {
96243
+ if (props.cleanWaterTankStatus === 0) return "ok";
96244
+ if (props.cleanWaterTankStatus === 2) return "low";
96245
+ return null;
96246
+ }
96247
+ if (props.waterTank === 1 || props.waterTank === 10) return "ok";
96248
+ return null;
96249
+ }
96250
+ /** Detergent tank `status` from detergent-status (27/4). Pure. */
96251
+ function detergentTankStatus(value) {
96252
+ if (value === 0) return "ok";
96253
+ if (value === 2) return "low";
96254
+ return null;
96255
+ }
96256
+ /** Dust-bin `status` from dust-collection (15/3); OVER_USE reads as `full`. Pure. */
96257
+ function dustBinTankStatus(value) {
96258
+ if (value === 1) return "ok";
96259
+ if (value === 2) return "full";
96260
+ return null;
96261
+ }
96262
+ /**
96263
+ * The four `vacuum-control` tank fields (clean-water / dirty-water / detergent /
96264
+ * dust-bin) decoded from the model's {@link VacuumCapabilities}, with the
96265
+ * discrete `status` populated from the live MIoT enum props when supplied. Pure.
96266
+ *
96267
+ * The nodedreame library exposes NO tank FILL-LEVEL telemetry — the cloud shadow
96268
+ * (verified against a real `dreame.vacuum.r2538z`) carries only the water-VOLUME
96269
+ * *setting* (a mop-flow level, NOT a tank fill). We therefore never fabricate a
96270
+ * `level`; `level` is always `null`. The cap contract is honoured on PRESENCE,
96271
+ * with `status` derived from the tank-status enums:
96272
+ *
96273
+ * - a field is `null` when the hardware HAS NO such tank (capability flags), and
96274
+ * - `{ level: null, status }` when the tank EXISTS — `status` from the enum
96275
+ * prop ('ok' / 'low' / 'full'), or `null` when the device reports none.
96276
+ *
96277
+ * Presence is derived from the capability record:
96278
+ * - clean-water ← `canMop` (a mopping robot carries a clean-water reservoir)
96279
+ * - dirty-water ← `canMopWash` (a self-wash dock has a recovery/dirty tank)
96280
+ * - detergent ← `hasDetergentReservoir`
96281
+ * - dust-bin ← `canAutoEmpty` (an auto-empty robot has an onboard dust bin)
96282
+ */
96283
+ function decodeVacuumTanks(caps, props) {
96284
+ const tank = (present, status) => present ? {
96285
+ level: null,
96286
+ status
96287
+ } : null;
96288
+ return {
96289
+ cleanWater: tank(Boolean(caps?.canMop), props ? cleanWaterTankStatus(props) : null),
96290
+ dirtyWater: tank(Boolean(caps?.canMopWash), null),
96291
+ detergent: tank(Boolean(caps?.hasDetergentReservoir), props ? detergentTankStatus(props.detergentStatus) : null),
96292
+ dustBin: tank(Boolean(caps?.canAutoEmpty), props ? dustBinTankStatus(props.dustCollection) : null)
96293
+ };
96294
+ }
96295
+ /**
96296
+ * Clamp a raw consumable percentage to `[0, 100]`, or return `null` when the
96297
+ * value is absent / non-finite. The library reports remaining life as a 0..100
96298
+ * integer; we clamp defensively so an out-of-range firmware value never violates
96299
+ * the cap schema.
96300
+ */
96301
+ function clampConsumablePct(value) {
96302
+ if (value === null || !Number.isFinite(value)) return null;
96303
+ return Math.max(0, Math.min(100, value));
96304
+ }
96305
+ /**
96306
+ * Build the `consumables` cap runtime-state slice from a live {@link VacuumDevice}
96307
+ * handle. The returned object satisfies the cap's `runtimeState` schema
96308
+ * (`ConsumablesStatusSchema.extend({ lastFetchedAt: z.number() })`).
96309
+ *
96310
+ * PRESENCE-driven (mirrors HA): the library's `supportedConsumables` enumerates
96311
+ * ONLY the consumables this model reports (a present MIoT life property) — main/
96312
+ * side brush, filter, sensor, tank-filter, mop-pad, silver-ion, detergent,
96313
+ * squeegee, dust-bag — each with its remaining-life % and whether a reset action
96314
+ * exists. No hardcoded per-model list: a consumable the device does not expose
96315
+ * never appears, and a `resettable` item drives the real MIoT reset action.
96316
+ */
96317
+ function buildVacuumConsumables(vacuum) {
96318
+ const items = vacuum.supportedConsumables.map((c) => ({
96319
+ key: c.key,
96320
+ label: c.label,
96321
+ level: clampConsumablePct(c.leftPct),
96322
+ status: null,
96323
+ lastResetAt: null,
96324
+ resettable: c.resettable
96325
+ }));
96326
+ const now = Date.now();
96327
+ return {
96328
+ items: [...items],
96329
+ lastChangedAt: now,
96330
+ lastFetchedAt: now
96331
+ };
96332
+ }
96333
+ //#endregion
96334
+ //#region src/devices/dreame-navigation-actions.ts
96335
+ /** Forward throttle magnitude for a full-forward nudge (device speed units). */
96336
+ var FORWARD_SPEED = 200;
96337
+ /** Turn magnitudes: left = +45, right = -45, spin/turn-around = +180. */
96338
+ var TURN_LEFT = 45;
96339
+ var TURN_RIGHT = -45;
96340
+ var SPIN = 180;
96341
+ /** The `setFillLight` value that selects auto. */
96342
+ var FILL_LIGHT_AUTO$1 = 101;
96343
+ /**
96344
+ * Map a normalized {@link NavigationMoveCommand} ([-1, 1] per axis) to a discrete
96345
+ * Dreame drive vector. The robot has no proportional joystick, so intensity is
96346
+ * quantized to the RE'd discrete nudges. Priority: forward, then spin
96347
+ * (tilt-down), then turn (pan). An all-zero / empty command is a stop.
96348
+ */
96349
+ function driveVector(cmd) {
96350
+ const pan = cmd.pan ?? 0;
96351
+ const tilt = cmd.tilt ?? 0;
96352
+ if (tilt > 0) return {
96353
+ spdv: FORWARD_SPEED,
96354
+ spdw: 0
96355
+ };
96356
+ if (tilt < 0) return {
96357
+ spdv: 0,
96358
+ spdw: SPIN
96359
+ };
96360
+ if (pan < 0) return {
96361
+ spdv: 0,
96362
+ spdw: TURN_LEFT
96363
+ };
96364
+ if (pan > 0) return {
96365
+ spdv: 0,
96366
+ spdw: TURN_RIGHT
96367
+ };
96368
+ return {
96369
+ spdv: 0,
96370
+ spdw: 0
96371
+ };
96372
+ }
96373
+ function moveCall(cmd) {
96374
+ const { spdv, spdw } = driveVector(cmd);
96375
+ return {
96376
+ method: "drive",
96377
+ spdv,
96378
+ spdw
96379
+ };
96380
+ }
96381
+ /** The explicit halt: a zero drive vector. */
96382
+ function stopCall() {
96383
+ return {
96384
+ method: "drive",
96385
+ spdv: 0,
96386
+ spdw: 0
96387
+ };
96388
+ }
96389
+ /** Fill light on (full) / off (back to auto) — the control that works anytime. */
96390
+ function lightOnCall(on) {
96391
+ return {
96392
+ method: "setFillLightAuto",
96393
+ full: on
96394
+ };
96395
+ }
96396
+ /** Fill-light mode: auto, or manual at `level` (40..100). Camera-service; needs a stream. */
96397
+ function lightModeCall(mode, level) {
96398
+ return {
96399
+ method: "setFillLight",
96400
+ value: mode === "auto" ? FILL_LIGHT_AUTO$1 : level ?? 100
96401
+ };
96402
+ }
96403
+ function lightLevelCall(level) {
96404
+ return lightModeCall("manual", level);
96405
+ }
96406
+ function playSoundCall(soundId) {
96407
+ return {
96408
+ method: "playSound",
96409
+ soundId
96410
+ };
96411
+ }
96412
+ function goToPointCall(point) {
96413
+ return {
96414
+ method: "goToPoint",
96415
+ x: point.x,
96416
+ y: point.y
96417
+ };
96418
+ }
96419
+ /** A discrete {@link NavigationActionId} → the controller method that performs it. */
96420
+ function actionCall(actionId) {
96421
+ switch (actionId) {
96422
+ case "goHome": return { method: "returnToDock" };
96423
+ case "locate": return { method: "locate" };
96424
+ case "spotClean": return { method: "spotClean" };
96425
+ case "findPet": return { method: "findPet" };
96426
+ case "personFollow": return { method: "startPersonFollow" };
96427
+ case "stop": return { method: "stopWork" };
96428
+ case "startClean": return {
96429
+ method: "runVacuumAction",
96430
+ action: "startClean"
96431
+ };
96432
+ case "pauseClean": return {
96433
+ method: "runVacuumAction",
96434
+ action: "pauseClean"
96435
+ };
96436
+ case "dockWash": return {
96437
+ method: "runVacuumAction",
96438
+ action: "dockWash"
96439
+ };
96440
+ case "autoEmpty": return {
96441
+ method: "runVacuumAction",
96442
+ action: "autoEmpty"
96443
+ };
96444
+ case "flashOn": return lightOnCall(true);
96445
+ case "flashOff": return lightOnCall(false);
96446
+ }
96447
+ }
96448
+ /** Perform one call on a controller. Exhaustive — a new method is a compile error here. */
96449
+ async function runControllerCall(controller, call) {
96450
+ switch (call.method) {
96451
+ case "drive":
96452
+ await controller.drive(call.spdv, call.spdw);
96453
+ return;
96454
+ case "stopWork":
96455
+ await controller.stopWork();
96456
+ return;
96457
+ case "startPersonFollow":
96458
+ await controller.startPersonFollow();
96459
+ return;
96460
+ case "spotClean":
96461
+ await controller.spotClean();
96462
+ return;
96463
+ case "findPet":
96464
+ await controller.findPet();
96465
+ return;
96466
+ case "playSound":
96467
+ await controller.playSound(call.soundId);
96468
+ return;
96469
+ case "runVacuumAction":
96470
+ await controller.runVacuumAction(call.action);
96471
+ return;
96472
+ case "goToPoint":
96473
+ await controller.goToPoint([[call.x, call.y]], [[call.x, call.y]]);
96474
+ return;
96475
+ case "setFillLight":
96476
+ await controller.setFillLight(call.value);
96477
+ return;
96478
+ case "setFillLightAuto":
96479
+ await controller.setFillLightAuto(call.full);
96480
+ return;
96481
+ case "returnToDock":
96482
+ await controller.returnToDock();
96483
+ return;
96484
+ case "locate":
96485
+ await controller.locate();
96486
+ return;
96487
+ }
96488
+ }
96489
+ //#endregion
96490
+ //#region src/devices/dreame-navigation-config.ts
96491
+ /**
96492
+ * DATA-DRIVEN feature flags for the Dreame navigation surface. Keeping these as
96493
+ * data (rather than branches in the device class) is what makes enabling /
96494
+ * disabling a control later a CONFIG change, not a code change — a future step
96495
+ * merges per-device / per-addon overrides on top of these defaults before the
96496
+ * cap reports them.
96497
+ *
96498
+ * `goToPoint` ships OFF: the send-to-map-coordinate path needs map-coordinate
96499
+ * plumbing (resolving a tap on the live map to robot coordinates) that is not
96500
+ * wired yet. Every other primitive defaults ON.
96501
+ */
96502
+ var DREAME_NAVIGATION_DEFAULT_FEATURES = {
96503
+ move: true,
96504
+ stop: true,
96505
+ goToPoint: false,
96506
+ runAction: true,
96507
+ playSound: true,
96508
+ light: true,
96509
+ lightMode: true
96510
+ };
96511
+ /**
96512
+ * Dictionary-entry ids disabled by default for Dreame (data-driven per-entry
96513
+ * flags). Empty today — every catalog action / sound is enabled. A future
96514
+ * config override drops ids in here (or removes them) without touching code.
96515
+ */
96516
+ var DREAME_NAVIGATION_DISABLED_ENTRY_IDS = /* @__PURE__ */ new Set();
96517
+ /**
96518
+ * The action dictionary as THIS provider reports it: the shared catalog with the
96519
+ * per-entry `enabled` flag resolved from {@link DREAME_NAVIGATION_DISABLED_ENTRY_IDS}.
96520
+ * Pure — the device method just returns this (a later step layers device config
96521
+ * on top).
96522
+ */
96523
+ function dreameNavigationCatalog(disabledEntryIds = DREAME_NAVIGATION_DISABLED_ENTRY_IDS) {
96524
+ return NAVIGATION_ACTION_CATALOG.map((entry) => ({
96525
+ ...entry,
96526
+ enabled: entry.enabled && !disabledEntryIds.has(entry.id)
96527
+ }));
96528
+ }
96529
+ /** The feature flags in force for ONE device: defaults, then its overrides. */
96530
+ function resolveNavigationFeatures(overrides) {
96531
+ return {
96532
+ ...DREAME_NAVIGATION_DEFAULT_FEATURES,
96533
+ ...overrides?.features ?? {}
96534
+ };
96535
+ }
96536
+ /** The disabled entry ids in force for ONE device: the addon set plus its own. */
96537
+ function resolveDisabledEntryIds(overrides) {
96538
+ return new Set([...DREAME_NAVIGATION_DISABLED_ENTRY_IDS, ...overrides?.disabledEntryIds ?? []]);
96539
+ }
96540
+ //#endregion
96541
+ //#region src/devices/dreame-navigation-ptz-adapter.ts
96542
+ /** A gimbal descriptor for a robot: the joystick is pan/tilt, there is no lens. */
96543
+ function ptzOptionsFromFeatures(features) {
96544
+ return {
96545
+ hasPan: features.move,
96546
+ hasTilt: features.move,
96547
+ hasZoom: false,
96548
+ supportsPresets: features.runAction || features.playSound,
96549
+ hasAutofocus: false
96550
+ };
96551
+ }
96552
+ /** Every ENABLED dictionary entry is a preset; the label is the preset name. */
96553
+ function presetsFromCatalog(entries, features) {
96554
+ return entries.filter((entry) => entry.enabled).filter((entry) => entry.kind === "action" ? features.runAction : features.playSound).map((entry) => ({
96555
+ id: entry.id,
96556
+ name: entry.label
96557
+ }));
96558
+ }
96559
+ /**
96560
+ * Resolve a preset id back to the navigation call. `null` when the id names
96561
+ * nothing enabled — the caller rejects rather than guessing an action.
96562
+ */
96563
+ function presetTarget(entries, presetId) {
96564
+ const entry = entries.find((candidate) => candidate.id === presetId && candidate.enabled);
96565
+ if (!entry) return null;
96566
+ if (entry.kind === "sound") return entry.soundId === void 0 ? null : {
96567
+ kind: "sound",
96568
+ soundId: entry.soundId
96569
+ };
96570
+ const parsed = NavigationActionIdSchema.safeParse(entry.id);
96571
+ return parsed.success ? {
96572
+ kind: "action",
96573
+ actionId: parsed.data
96574
+ } : null;
96575
+ }
96576
+ function clampAxis(value) {
96577
+ if (value === void 0 || !Number.isFinite(value)) return void 0;
96578
+ return Math.max(-1, Math.min(1, value));
96579
+ }
96580
+ /**
96581
+ * A PTZ move is a navigation nudge: pan turns, tilt drives, zoom has no
96582
+ * meaning on a chassis and is dropped. Axes are clamped to the cap's [-1, 1].
96583
+ */
96584
+ function ptzMoveToNavigation(cmd) {
96585
+ const pan = clampAxis(cmd.pan);
96586
+ const tilt = clampAxis(cmd.tilt);
96587
+ const speed = cmd.speed === void 0 || !Number.isFinite(cmd.speed) ? void 0 : Math.max(0, Math.min(1, cmd.speed));
96588
+ return {
96589
+ ...pan !== void 0 ? { pan } : {},
96590
+ ...tilt !== void 0 ? { tilt } : {},
96591
+ ...speed !== void 0 ? { speed } : {}
96592
+ };
96593
+ }
96594
+ /** The camera-service fill-light mode/level property. */
96595
+ var FILL_LIGHT_SET_SIID = 10001;
96596
+ /** The wire value that means "auto" on `fillinLightSet`. */
96597
+ var FILL_LIGHT_AUTO = 101;
96598
+ function asRecord(raw) {
96599
+ if (typeof raw === "string") try {
96600
+ const parsed = JSON.parse(raw);
96601
+ return parsed !== null && typeof parsed === "object" ? parsed : null;
96602
+ } catch {
96603
+ return null;
96604
+ }
96605
+ return raw !== null && typeof raw === "object" ? raw : null;
96606
+ }
96607
+ /** `4/50` → `{ flash }`, only when the packed key is the fill light. */
96608
+ function decodeFillLight(raw) {
96609
+ const record = asRecord(raw);
96610
+ if (!record || record["k"] !== "FillinLight") return {};
96611
+ const v = record["v"];
96612
+ if (v === 1 || v === "1" || v === true) return { flash: true };
96613
+ if (v === 0 || v === "0" || v === false) return { flash: false };
96614
+ return {};
96615
+ }
96616
+ /** `10001/9` → `{ lightMode, lightLevel? }`: 101 is auto, 40..100 a manual level. */
96617
+ function decodeFillLightSet(raw) {
96618
+ const n = typeof raw === "string" ? Number(raw) : typeof raw === "number" ? raw : NaN;
96619
+ if (!Number.isFinite(n)) return {};
96620
+ if (n === FILL_LIGHT_AUTO) return { lightMode: "auto" };
96621
+ if (n >= 40 && n <= 100) return {
96622
+ lightMode: "manual",
96623
+ lightLevel: n
96624
+ };
96625
+ return {};
96626
+ }
96627
+ /** `true` while the robot's own state says it is following; `null` state = not known. */
96628
+ function decodeFollowing(state) {
96629
+ if (state === null || state === void 0 || !Number.isFinite(state)) return null;
96630
+ return state === 26;
96631
+ }
96632
+ /** Everything the robot currently says about its light, as one patch. */
96633
+ function decodeNavigationReadback(read) {
96634
+ return {
96635
+ ...decodeFillLight(read(4, 50)),
96636
+ ...decodeFillLightSet(read(FILL_LIGHT_SET_SIID, 9))
96637
+ };
96638
+ }
96639
+ //#endregion
96640
+ //#region src/devices/dreame-camera-device.ts
96641
+ var CAP_NAME$12 = "navigation";
96642
+ var NAV_COLD_START = {
96643
+ mode: "unknown",
96644
+ following: false,
96645
+ flash: false,
96646
+ lightMode: "auto",
96647
+ lightLevel: 100,
96648
+ lastChangedAt: 0
96649
+ };
96650
+ /**
96651
+ * Dreame CAMERA + NAVIGATION device — a SEPARATE camstack device from the
96652
+ * `DreameVacuumDevice`, spawned only for camera-capable vacuums (those whose
96653
+ * `vacuumCapabilities.hasCamera` is true, i.e. a LinkVisual video surface). It
96654
+ * owns the `navigation` cap (drive joystick + discrete robot actions + sounds +
96655
+ * fill light); the vacuum device keeps `vacuum-control` / `consumables` / maps.
96656
+ *
96657
+ * PREBUFFER OFF: this is a BATTERY robot whose camera is expensive to cold-start,
96658
+ * so the device advertises {@link DeviceFeature.BatteryOperated} — the SAME flag
96659
+ * the Reolink battery-camera path uses. The stream-broker's
96660
+ * `getEffectivePreBufferSec` battery rule then sets `preBufferSec = 0`, so the
96661
+ * stream opens on demand and is never continuously prebuffered.
96662
+ *
96663
+ * The whole navigation surface goes through nodedreame's typed
96664
+ * `DreameCameraController` (1.12+, built lazily from the vacuum handle with the
96665
+ * pairing PIN when the camera is coded), mapped by `dreame-navigation-actions.ts`.
96666
+ * The controller is never `open()`ed here — the stream is the broker's; drive is
96667
+ * valid only while a stream is open, which is when the panel is used.
96668
+ *
96669
+ * STATUS: `mode` is mapped from the shared vacuum state; `flash` / `lightMode`
96670
+ * / `lightLevel` are decoded from the robot's own properties on every push
96671
+ * (`dreame-navigation-status.ts`: `4/50` and `10001/9`) and, until the first
96672
+ * read-back lands, from the commands this device issued. `following` has no
96673
+ * confirmed read-back yet and stays command-driven.
96674
+ *
96675
+ * PTZ MIMIC: the same device also registers `ptz`, projected from `navigation`
96676
+ * through `dreame-navigation-ptz-adapter.ts` (pure, tested) — the joystick is
96677
+ * pan/tilt, the enabled actions and sounds are the presets, there is no zoom
96678
+ * and no autofocus. `navigation` stays the source of truth; nothing is stored
96679
+ * on the PTZ side.
96680
+ */
96681
+ var DreameCameraDevice = class extends DreameChildDevice {
96682
+ features = [DeviceFeature.BatteryOperated];
96683
+ constructor(ctx) {
96684
+ super(ctx);
96685
+ }
96686
+ registerControlCap() {
96687
+ const provider = this.buildNavigationProvider();
96688
+ this.ctx.registerNativeCap(navigationCapability, provider);
96689
+ this.runtimeState.setCapState(CAP_NAME$12, NAV_COLD_START);
96690
+ this.ctx.registerNativeCap(ptzCapability, this.buildPtzMimic(provider));
96691
+ }
96692
+ buildNavigationProvider() {
96693
+ return {
96694
+ getStatus: async () => this.runtimeState.getCapState(CAP_NAME$12) ?? NAV_COLD_START,
96695
+ listActions: async () => dreameNavigationCatalog(resolveDisabledEntryIds(this.overrides())),
96696
+ getFeatures: async () => this.buildFeatures(),
96697
+ move: async ({ pan, tilt, speed }) => {
96698
+ await this.dispatch(moveCall({
96699
+ pan,
96700
+ tilt,
96701
+ speed
96702
+ }));
96703
+ },
96704
+ stop: async () => {
96705
+ await this.dispatch(stopCall());
96706
+ },
96707
+ goToPoint: async ({ x, y }) => {
96708
+ await this.dispatch(goToPointCall({
96709
+ x,
96710
+ y
96711
+ }));
96712
+ },
96713
+ runAction: async ({ actionId }) => {
96714
+ await this.dispatch(actionCall(actionId));
96715
+ this.applyOptimisticAction(actionId);
96716
+ },
96717
+ playSound: async ({ soundId }) => {
96718
+ await this.dispatch(playSoundCall(soundId));
96719
+ },
96720
+ setLightOn: async ({ on }) => {
96721
+ await this.dispatch(lightOnCall(on));
96722
+ this.patchStatus({ flash: on });
96723
+ },
96724
+ setLightMode: async ({ mode, level }) => {
96725
+ await this.dispatch(lightModeCall(mode, level));
96726
+ this.patchStatus(mode === "manual" && level !== void 0 ? {
96727
+ lightMode: mode,
96728
+ lightLevel: level
96729
+ } : { lightMode: mode });
96730
+ },
96731
+ setLightLevel: async ({ level }) => {
96732
+ await this.dispatch(lightLevelCall(level));
96733
+ this.patchStatus({
96734
+ lightMode: "manual",
96735
+ lightLevel: level
96736
+ });
96737
+ }
96738
+ };
96739
+ }
96740
+ /**
96741
+ * `ptz` projected from `navigation` (the PTZ-mimic adapter). Every call
96742
+ * forwards to the navigation provider through the pure mapping; a preset
96743
+ * that names nothing enabled is rejected, never guessed.
96744
+ */
96745
+ buildPtzMimic(nav) {
96746
+ const deviceId = this.id;
96747
+ const noGimbal = (what) => {
96748
+ throw new Error(`DreameCameraDevice: a robot camera has no ${what}`);
96749
+ };
96750
+ return {
96751
+ getStatus: async () => ({
96752
+ pan: 0,
96753
+ tilt: 0,
96754
+ zoom: 0,
96755
+ autofocus: false
96756
+ }),
96757
+ getPosition: async () => ({
96758
+ pan: 0,
96759
+ tilt: 0,
96760
+ zoom: 0
96761
+ }),
96762
+ getOptions: async () => ptzOptionsFromFeatures(await nav.getFeatures({ deviceId })),
96763
+ move: async (cmd) => {
96764
+ await nav.move({
96765
+ deviceId,
96766
+ ...ptzMoveToNavigation(cmd)
96767
+ });
96768
+ },
96769
+ continuousMove: async (cmd) => {
96770
+ await nav.move({
96771
+ deviceId,
96772
+ ...ptzMoveToNavigation(cmd)
96773
+ });
96774
+ },
96775
+ stop: async () => {
96776
+ await nav.stop({ deviceId });
96777
+ },
96778
+ goHome: async () => {
96779
+ await nav.runAction({
96780
+ deviceId,
96781
+ actionId: "goHome"
96782
+ });
96783
+ },
96784
+ getPresets: async () => presetsFromCatalog(await nav.listActions({ deviceId }), await nav.getFeatures({ deviceId })),
96785
+ goToPreset: async ({ presetId }) => {
96786
+ const target = presetTarget(await nav.listActions({ deviceId }), presetId);
96787
+ if (target === null) throw new Error(`DreameCameraDevice: unknown or disabled navigation preset "${presetId}"`);
96788
+ if (target.kind === "sound") await nav.playSound({
96789
+ deviceId,
96790
+ soundId: target.soundId
96791
+ });
96792
+ else await nav.runAction({
96793
+ deviceId,
96794
+ actionId: target.actionId
96795
+ });
96796
+ },
96797
+ savePreset: async () => noGimbal("saved preset — its presets are its actions"),
96798
+ deletePreset: async () => noGimbal("deletable preset — its presets are its actions"),
96799
+ setAutofocus: async () => noGimbal("autofocus")
96800
+ };
96801
+ }
96802
+ recomputeSlice() {
96803
+ const handle = this.resolveHandle();
96804
+ if (handle === null) return;
96805
+ const prev = this.runtimeState.getCapState(CAP_NAME$12) ?? NAV_COLD_START;
96806
+ const readback = handle instanceof VacuumDevice ? decodeNavigationReadback((siid, piid) => handle.getProperty(siid, piid)?.value) : {};
96807
+ const following = handle instanceof VacuumDevice ? decodeFollowing(handle.status) : null;
96808
+ this.runtimeState.setCapState(CAP_NAME$12, {
96809
+ ...prev,
96810
+ ...readback,
96811
+ ...following !== null ? { following } : {},
96812
+ mode: following === true ? "following" : this.deriveMode(handle),
96813
+ lastChangedAt: Date.now()
96814
+ });
96815
+ }
96816
+ controller = null;
96817
+ controllerHandle = null;
96818
+ /**
96819
+ * The typed controller, built once per live handle (the build resolves the
96820
+ * cloud video profile, so it is a promise shared by every caller); rebuilt
96821
+ * if the handle changes. Never `open()`ed here.
96822
+ */
96823
+ requireController() {
96824
+ const handle = this.requireHandle();
96825
+ if (!(handle instanceof VacuumDevice)) throw new Error("DreameCameraDevice: the robot handle is not a vacuum — no camera controller");
96826
+ if (this.controller === null || this.controllerHandle !== handle) {
96827
+ const accessCode = this.config.get("navigationAccessCode");
96828
+ this.controllerHandle = handle;
96829
+ this.controller = handle.createCameraController(accessCode !== void 0 && accessCode !== "" ? { accessCode } : {}).catch((err) => {
96830
+ this.controller = null;
96831
+ this.controllerHandle = null;
96832
+ throw err;
96833
+ });
96834
+ }
96835
+ return this.controller;
96836
+ }
96837
+ /** Perform a translated navigation call on the typed controller. */
96838
+ async dispatch(call) {
96839
+ await runControllerCall(await this.requireController(), call);
96840
+ }
96841
+ async removeDevice() {
96842
+ await super.removeDevice();
96843
+ const pending = this.controller;
96844
+ this.controller = null;
96845
+ this.controllerHandle = null;
96846
+ if (pending !== null) await pending.then((controller) => controller.close()).catch(() => void 0);
96847
+ }
96848
+ /** Per-device general-primitive feature flags: the defaults, then this device's overrides. */
96849
+ buildFeatures() {
96850
+ return resolveNavigationFeatures(this.overrides());
96851
+ }
96852
+ /** This device's navigation overrides, straight from its persisted config. */
96853
+ overrides() {
96854
+ const features = this.config.get("navigationFeatures");
96855
+ const disabledEntryIds = this.config.get("navigationDisabledEntryIds");
96856
+ return {
96857
+ ...features !== void 0 ? { features } : {},
96858
+ ...disabledEntryIds !== void 0 ? { disabledEntryIds } : {}
96859
+ };
96860
+ }
96861
+ /** Map the shared vacuum lifecycle state to a coarse navigation mode. */
96862
+ deriveMode(handle) {
96863
+ if (!(handle instanceof VacuumDevice)) return "unknown";
96864
+ switch (mapMiotStateToVacuumState(handle.status)) {
96865
+ case "cleaning": return "cleaning";
96866
+ case "paused": return "paused";
96867
+ case "returning": return "returning";
96868
+ case "idle":
96869
+ case "docked":
96870
+ case "drying": return "idle";
96871
+ default: return "unknown";
96872
+ }
96873
+ }
96874
+ /**
96875
+ * Optimistically reflect a just-issued action in the status slice so the UI
96876
+ * updates immediately (there is no confirmed read-back yet).
96877
+ */
96878
+ applyOptimisticAction(actionId) {
96879
+ switch (actionId) {
96880
+ case "flashOn":
96881
+ this.patchStatus({ flash: true });
96882
+ return;
96883
+ case "flashOff":
96884
+ this.patchStatus({ flash: false });
96885
+ return;
96886
+ case "personFollow":
96887
+ this.patchStatus({
96888
+ following: true,
96889
+ mode: "following"
96890
+ });
96891
+ return;
96892
+ case "stop":
96893
+ this.patchStatus({ following: false });
96894
+ return;
96895
+ default: return;
96896
+ }
96897
+ }
96898
+ /**
96899
+ * Merge a patch into the status slice. Written THROUGH `runtimeState` — never a
96900
+ * parallel private cache (CLAUDE.md).
96901
+ */
96902
+ patchStatus(patch) {
96903
+ const prev = this.runtimeState.getCapState(CAP_NAME$12) ?? NAV_COLD_START;
96904
+ this.runtimeState.setCapState(CAP_NAME$12, {
96905
+ ...prev,
96906
+ ...patch,
96907
+ lastChangedAt: Date.now()
96908
+ });
96909
+ }
96910
+ };
96911
+ //#endregion
94326
96912
  //#region src/devices/dreame-enum-sensor-device.ts
94327
96913
  var CAP_NAME$11 = "enum-sensor";
94328
96914
  /** One persisted enum option: the MIoT int value + its display label. */
@@ -94547,12 +97133,12 @@ function mapMowerStatusToActivity(status) {
94547
97133
  }
94548
97134
  }
94549
97135
  /** Clamp a raw battery reading (0..100 from the library) to the slice field. */
94550
- function clampBatteryLevel$1(value) {
97136
+ function clampBatteryLevel(value) {
94551
97137
  if (value === null || !Number.isFinite(value)) return null;
94552
97138
  return Math.max(0, Math.min(100, value));
94553
97139
  }
94554
97140
  /** Clamp a 0..100 percentage (mowing progress), null-safe. */
94555
- function clampPercent$1(value) {
97141
+ function clampPercent(value) {
94556
97142
  if (value === null || !Number.isFinite(value)) return null;
94557
97143
  return Math.max(0, Math.min(100, value));
94558
97144
  }
@@ -94726,11 +97312,11 @@ var DreameMowerDevice = class extends DreameChildDevice {
94726
97312
  if (mower === null) return;
94727
97313
  const status = mower.status;
94728
97314
  const code = mower.faultRaw;
94729
- const livePct = clampPercent$1(mower.mowingProgressPct);
97315
+ const livePct = clampPercent(mower.mowingProgressPct);
94730
97316
  const prevPct = this.runtimeState.getCapState(CAP_NAME$9)?.progressPercent;
94731
97317
  const slice = {
94732
97318
  activity: mapMowerStatusToActivity(status),
94733
- batteryLevel: clampBatteryLevel$1(mower.battery),
97319
+ batteryLevel: clampBatteryLevel(mower.battery),
94734
97320
  progressPercent: livePct ?? prevPct ?? null,
94735
97321
  currentCode: code,
94736
97322
  currentCodeLabel: code !== null ? MowerFault[code] ?? null : null,
@@ -95373,204 +97959,21 @@ var DreameSwitchDevice = class extends DreameChildDevice {
95373
97959
  });
95374
97960
  }
95375
97961
  };
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;
97962
+ /** `null` = nothing learned (keep the previous slice). */
97963
+ function networkLinkFromBars(bars, now) {
97964
+ if (bars === null || !Number.isFinite(bars)) return null;
95526
97965
  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
97966
+ type: "wifi",
97967
+ signalPercent: bars === 0 ? null : signalPercentFromBars(bars, 4),
97968
+ lastUpdated: now
95569
97969
  };
95570
97970
  }
95571
97971
  //#endregion
95572
97972
  //#region src/devices/dreame-vacuum-device.ts
95573
97973
  var CAP_NAME$1 = "vacuum-control";
97974
+ var NETWORK_LINK_CAP_NAME = "network-link";
97975
+ /** The Wi-Fi signal map is a cloud round trip; the bars at the dock barely move. */
97976
+ var NETWORK_LINK_REFRESH_MS = 30 * 6e4;
95574
97977
  var CONSUMABLES_CAP_NAME = "consumables";
95575
97978
  var CONTROL_COLD_START = {
95576
97979
  state: "idle",
@@ -95626,6 +98029,8 @@ var DreameVacuumDevice = class extends DreameChildDevice {
95626
98029
  }
95627
98030
  async onActivate() {
95628
98031
  await super.onActivate();
98032
+ this.refreshNetworkLink("activate");
98033
+ this.networkLinkTimer = setInterval(() => void this.refreshNetworkLink("periodic"), NETWORK_LINK_REFRESH_MS);
95629
98034
  const vacuum = this.resolveVacuum();
95630
98035
  if (vacuum !== null) {
95631
98036
  vacuum.refreshFromCache().catch((err) => {
@@ -95649,11 +98054,60 @@ var DreameVacuumDevice = class extends DreameChildDevice {
95649
98054
  }
95650
98055
  }
95651
98056
  /** Narrow the generic handle to the library's typed {@link DreameVacuum}, or null. */
98057
+ networkLinkTimer = null;
98058
+ networkLinkReported = false;
98059
+ networkLinkFailureWarned = false;
98060
+ /** `network-link`: the read path serves the slice; `refreshNetworkLink` writes it. */
98061
+ registerNetworkLink() {
98062
+ this.ctx.registerNativeCap(networkLinkCapability, { getStatus: async () => this.runtimeState.getCapState(NETWORK_LINK_CAP_NAME) ?? NETWORK_LINK_UNKNOWN });
98063
+ if (this.runtimeState.getCapState(NETWORK_LINK_CAP_NAME) === null) this.runtimeState.setCapState(NETWORK_LINK_CAP_NAME, NETWORK_LINK_UNKNOWN);
98064
+ }
98065
+ /**
98066
+ * Read the bars at the robot's position off its Wi-Fi signal map. Bounded,
98067
+ * best-effort; a failure keeps the last slice. The first read per device is
98068
+ * said at INFO with the raw bars, the first failure at WARN.
98069
+ */
98070
+ async refreshNetworkLink(reason) {
98071
+ const vacuum = this.resolveVacuum();
98072
+ if (vacuum === null) return;
98073
+ try {
98074
+ const bars = await vacuum.getCurrentSignal({ maxWaitMs: 2e4 });
98075
+ const next = networkLinkFromBars(bars, Date.now());
98076
+ this.ctx.logger[this.networkLinkReported ? "debug" : "info"]("network link read", {
98077
+ tags: { deviceId: this.id },
98078
+ meta: {
98079
+ reason,
98080
+ rawBars: bars,
98081
+ ...next ?? {}
98082
+ }
98083
+ });
98084
+ this.networkLinkReported = true;
98085
+ if (next !== null) this.runtimeState.setCapState(NETWORK_LINK_CAP_NAME, next);
98086
+ } catch (err) {
98087
+ const level = this.networkLinkFailureWarned ? "debug" : "warn";
98088
+ this.networkLinkFailureWarned = true;
98089
+ this.ctx.logger[level]("network link read failed — keeping the last slice", {
98090
+ tags: { deviceId: this.id },
98091
+ meta: {
98092
+ reason,
98093
+ error: err instanceof Error ? err.message : String(err)
98094
+ }
98095
+ });
98096
+ }
98097
+ }
98098
+ async removeDevice() {
98099
+ if (this.networkLinkTimer !== null) {
98100
+ clearInterval(this.networkLinkTimer);
98101
+ this.networkLinkTimer = null;
98102
+ }
98103
+ await super.removeDevice();
98104
+ }
95652
98105
  resolveVacuum() {
95653
98106
  const handle = this.resolveHandle();
95654
98107
  return handle instanceof VacuumDevice ? handle : null;
95655
98108
  }
95656
98109
  registerControlCap() {
98110
+ this.registerNetworkLink();
95657
98111
  this.ctx.registerNativeCap(vacuumControlCapability, {
95658
98112
  getStatus: async () => this.runtimeState.getCapState(CAP_NAME$1) ?? CONTROL_COLD_START,
95659
98113
  start: async () => {
@@ -95710,14 +98164,14 @@ var DreameVacuumDevice = class extends DreameChildDevice {
95710
98164
  const tanks = decodeVacuumTanks(vacuum.vacuumCapabilities, this.readTankProps());
95711
98165
  const controlSlice = {
95712
98166
  state: mapMiotStateToVacuumState(vacuum.status),
95713
- batteryLevel: clampBatteryLevel(vacuum.battery),
98167
+ batteryLevel: clampBatteryLevel$1(vacuum.battery),
95714
98168
  fanSpeed: suctionLevelToken(vacuum.suction),
95715
98169
  availableFanSpeeds: supportedFanSpeedTokens(vacuum.supportedSuctionLevels),
95716
98170
  cleanWater: tanks.cleanWater,
95717
98171
  dirtyWater: tanks.dirtyWater,
95718
98172
  detergent: tanks.detergent,
95719
98173
  dustBin: tanks.dustBin,
95720
- progressPercent: clampPercent(vacuum.taskProgressPct),
98174
+ progressPercent: clampPercent$1(vacuum.taskProgressPct),
95721
98175
  errorCode: vacuum.errorCode,
95722
98176
  errorLabel: vacuum.errorCode ? `Error ${vacuum.errorCode}` : null,
95723
98177
  lastChangedAt: Date.now()
@@ -96043,6 +98497,15 @@ function dreameMowerMapSelectChildFactory(ctx) {
96043
98497
  return new DreameMowerMapSelectDevice(ctx);
96044
98498
  }
96045
98499
  /**
98500
+ * Factory for the CAMERA + NAVIGATION child (a `DeviceType.Camera` grouped under
98501
+ * the Container), spawned only for camera-capable vacuums. Owns the `navigation`
98502
+ * cap (drive / actions / sounds / fill light). Selected directly by the
98503
+ * Container's `getAccessoryChildren()` for the camera child spec.
98504
+ */
98505
+ function dreameCameraChildFactory(ctx) {
98506
+ return new DreameCameraDevice(ctx);
98507
+ }
98508
+ /**
96046
98509
  * Factory for an AI-obstacle Switch child (a `DeviceType.Switch` grouped under
96047
98510
  * the Container). Like {@link dreameMapChildFactory}, selected directly by the
96048
98511
  * Container's `getAccessoryChildren()` — the child config carries the
@@ -96214,6 +98677,20 @@ var DreameContainerDevice = class extends BaseDevice {
96214
98677
  factory: dreameMapChildFactory
96215
98678
  });
96216
98679
  }
98680
+ if (kind === "vacuum" && this.modelSupportsCamera()) {
98681
+ const cameraMeta = {
98682
+ type: DeviceType.Camera,
98683
+ name: this.name.length > 0 ? `${this.name} Camera` : "Camera",
98684
+ linkDeviceId: this.id,
98685
+ ...this.integrationId !== void 0 ? { integrationId: this.integrationId } : {}
98686
+ };
98687
+ children.push({
98688
+ stableIdSuffix: "camera",
98689
+ meta: cameraMeta,
98690
+ config,
98691
+ factory: dreameCameraChildFactory
98692
+ });
98693
+ }
96217
98694
  if (kind === "vacuum") {
96218
98695
  for (const feature of this.supportedAiFeatures()) {
96219
98696
  const switchMeta = {
@@ -96647,6 +99124,7 @@ var DreameContainerDevice = class extends BaseDevice {
96647
99124
  const caps = handle.vacuumCapabilities;
96648
99125
  return {
96649
99126
  canMap: caps.canMap,
99127
+ hasCamera: caps.hasCamera,
96650
99128
  hasAiObstacleDetection: caps.hasAiObstacleDetection,
96651
99129
  aiFeatureCount: caps.hasAiObstacleDetection ? caps.supportedAiFeatures.length : 0
96652
99130
  };
@@ -96674,6 +99152,12 @@ var DreameContainerDevice = class extends BaseDevice {
96674
99152
  modelSupportsMap() {
96675
99153
  return vacuumSupportsMap(this.resolveVacuumCaps());
96676
99154
  }
99155
+ /** Whether this vacuum model carries an on-board camera — gates the separate
99156
+ * camera + navigation child. Gated on the RESOLVED handle's confirmed
99157
+ * capability (false when the handle is not up, never optimistic). */
99158
+ modelSupportsCamera() {
99159
+ return vacuumSupportsCamera(this.resolveVacuumCaps());
99160
+ }
96677
99161
  /** Whether this mower model supports a map — gated on the RESOLVED handle's
96678
99162
  * confirmed capability (false when the handle is not up, never optimistic). */
96679
99163
  modelSupportsMowerMap() {
@@ -97571,4 +100055,4 @@ var DreameProviderAddon = class extends BaseDeviceProvider {
97571
100055
  }
97572
100056
  };
97573
100057
  //#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 };
100058
+ 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 };