@camstack/addon-provider-dreame 0.2.70 → 0.2.72
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/addon.js +2805 -252
- package/dist/addon.mjs +2787 -234
- package/dist/index.mjs +1 -1
- package/package.json +5 -2
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((
|
|
48793
|
-
Math.round(
|
|
48794
|
-
Math.round(
|
|
48795
|
-
Math.round(
|
|
48796
|
-
Math.round(
|
|
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
|
|
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:
|
|
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:
|
|
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:
|
|
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((
|
|
51314
|
+
return this.#sendTask(buildZonePayload(zoneIds.map((z7) => Math.trunc(z7))));
|
|
50115
51315
|
}
|
|
50116
51316
|
/** Edge / contour mowing (2:50 o:101). Contour ids are two-int pairs [[1,0]]. */
|
|
50117
51317
|
async startMowingEdges(contourIds) {
|
|
@@ -50503,6 +51703,12 @@ objectType({
|
|
|
50503
51703
|
generatedAt: numberType()
|
|
50504
51704
|
})
|
|
50505
51705
|
});
|
|
51706
|
+
Buffer.from([
|
|
51707
|
+
0,
|
|
51708
|
+
0,
|
|
51709
|
+
0,
|
|
51710
|
+
1
|
|
51711
|
+
]);
|
|
50506
51712
|
//#endregion
|
|
50507
51713
|
//#region ../types/dist/event-category-zAv7pMUz.mjs
|
|
50508
51714
|
var EventCategory = /* @__PURE__ */ function(EventCategory) {
|
|
@@ -55911,6 +57117,12 @@ Object.fromEntries([
|
|
|
55911
57117
|
icon: "move",
|
|
55912
57118
|
order: 40
|
|
55913
57119
|
},
|
|
57120
|
+
{
|
|
57121
|
+
id: "navigation",
|
|
57122
|
+
label: "Navigation",
|
|
57123
|
+
icon: "compass",
|
|
57124
|
+
order: 41
|
|
57125
|
+
},
|
|
55914
57126
|
{
|
|
55915
57127
|
id: "consumables",
|
|
55916
57128
|
label: "Consumables",
|
|
@@ -74306,6 +75518,21 @@ var NetworkLinkStatusSchema = object({
|
|
|
74306
75518
|
/** Ms epoch of the last observation. Lets consumers reason about freshness. */
|
|
74307
75519
|
lastUpdated: number()
|
|
74308
75520
|
});
|
|
75521
|
+
/** The slice a provider seeds before its first read: nothing is known yet. */
|
|
75522
|
+
var NETWORK_LINK_UNKNOWN = {
|
|
75523
|
+
type: "unknown",
|
|
75524
|
+
signalPercent: null,
|
|
75525
|
+
lastUpdated: 0
|
|
75526
|
+
};
|
|
75527
|
+
/**
|
|
75528
|
+
* Normalise a signal the firmware reports as BARS (0..`maxBars`) to 0..100.
|
|
75529
|
+
* Out-of-range or non-finite input is not a reading: `null`.
|
|
75530
|
+
*/
|
|
75531
|
+
function signalPercentFromBars(bars, maxBars) {
|
|
75532
|
+
if (bars === void 0 || !Number.isFinite(bars) || maxBars <= 0) return null;
|
|
75533
|
+
if (bars < 0 || bars > maxBars) return null;
|
|
75534
|
+
return Math.round(bars / maxBars * 100);
|
|
75535
|
+
}
|
|
74309
75536
|
var networkLinkCapability = {
|
|
74310
75537
|
name: "network-link",
|
|
74311
75538
|
scope: "device",
|
|
@@ -74334,11 +75561,7 @@ onStatusChanged: { data: object({
|
|
|
74334
75561
|
status: {
|
|
74335
75562
|
schema: NetworkLinkStatusSchema,
|
|
74336
75563
|
kind: "push",
|
|
74337
|
-
empty:
|
|
74338
|
-
type: "unknown",
|
|
74339
|
-
signalPercent: null,
|
|
74340
|
-
lastUpdated: 0
|
|
74341
|
-
}
|
|
75564
|
+
empty: NETWORK_LINK_UNKNOWN
|
|
74342
75565
|
},
|
|
74343
75566
|
/**
|
|
74344
75567
|
* Runtime-state slice — every provider stores the same shape under
|
|
@@ -79494,6 +80717,424 @@ var ptzAutotrackCapability = {
|
|
|
79494
80717
|
*/
|
|
79495
80718
|
durability: "session"
|
|
79496
80719
|
};
|
|
80720
|
+
/**
|
|
80721
|
+
* `navigation` — a device-scoped capability that natively expresses the FULL
|
|
80722
|
+
* navigation / action surface of a robot that DRIVES ITSELF and carries an
|
|
80723
|
+
* on-board camera (the Dreame robot-vacuum camera is the first provider).
|
|
80724
|
+
*
|
|
80725
|
+
* Why a NEW cap rather than overloading `ptz`:
|
|
80726
|
+
* - `ptz` moves a *gimbal* on a fixed camera (pan/tilt/zoom of the lens). A
|
|
80727
|
+
* robot vacuum has no gimbal — the whole chassis drives, turns and spins.
|
|
80728
|
+
* The two are different physical models: PTZ is absolute-position + presets,
|
|
80729
|
+
* navigation is momentary drive nudges + discrete robot ACTIONS
|
|
80730
|
+
* (dock / spot-clean / follow-pet / go-to-point / …).
|
|
80731
|
+
* - This cap is the SOURCE OF TRUTH. Two consumers adapt from it rather than
|
|
80732
|
+
* the reverse:
|
|
80733
|
+
* 1. a native CamStack navigation panel (data-driven from `listActions`
|
|
80734
|
+
* / `getOptions`), and
|
|
80735
|
+
* 2. the PTZ surface — a thin adapter mimics `ptz` from `navigation` so a
|
|
80736
|
+
* robot camera shows up in the existing PTZ control path without every
|
|
80737
|
+
* PTZ provider learning about robots. The mapping lives in the adapter,
|
|
80738
|
+
* not here (see the addon design note):
|
|
80739
|
+
* ptz.continuousMove({pan,tilt}) → navigation.move({pan,tilt})
|
|
80740
|
+
* ptz.stop() → navigation.stop()
|
|
80741
|
+
* ptz.goHome() → navigation.runAction('goHome')
|
|
80742
|
+
* ptz.getPresets() → navigation.listActions() (id→preset)
|
|
80743
|
+
* ptz.goToPreset(id) → navigation.runAction(id)
|
|
80744
|
+
*
|
|
80745
|
+
* ## Continuous drive
|
|
80746
|
+
*
|
|
80747
|
+
* `move` is MOMENTARY. Fluid navigation comes from the UI (or the PTZ adapter)
|
|
80748
|
+
* sending `move({pan,tilt})` REPEATEDLY at ~1 Hz while a direction is held, and
|
|
80749
|
+
* one `stop()` on release — exactly like the robot app's remote-drive joystick.
|
|
80750
|
+
* The provider forwards EACH `move` to one drive write; it must NOT debounce or
|
|
80751
|
+
* coalesce them. The UI owns the cadence.
|
|
80752
|
+
*
|
|
80753
|
+
* ## The action dictionary
|
|
80754
|
+
*
|
|
80755
|
+
* The discrete controls (dock / locate / spot-clean / follow / flash / sounds)
|
|
80756
|
+
* are a DATA-DRIVEN dictionary the cap exposes via `listActions()`. Each entry
|
|
80757
|
+
* carries `{ id, kind, label, icon }` (plus `soundId` for sound entries) so the
|
|
80758
|
+
* native panel AND the PTZ mimic render buttons WITHOUT hardcoding a
|
|
80759
|
+
* vendor-specific list. `kind: 'action'` entries are triggered with
|
|
80760
|
+
* `runAction({ actionId })`; `kind: 'sound'` entries with `playSound({ soundId })`
|
|
80761
|
+
* (the entry carries the `soundId` to pass). The general primitives — `move`,
|
|
80762
|
+
* `stop`, `goToPoint` — stay as first-class methods, not dictionary entries.
|
|
80763
|
+
*
|
|
80764
|
+
* NOTE (provider wiring): the first provider (Dreame) drives this with the RAW
|
|
80765
|
+
* `callAction(siid,aiid,in)` / `setProperty({siid,piid,value})` MIoT primitives
|
|
80766
|
+
* that the currently-published `@apocaliss92/nodedreame` already exposes on
|
|
80767
|
+
* every device handle. A future nodedreame publish adds a typed
|
|
80768
|
+
* `DreameCameraController` (drive / playPetSound / spotClean / findPet / …); the
|
|
80769
|
+
* provider can then swap the raw calls for the typed methods with no change to
|
|
80770
|
+
* THIS contract.
|
|
80771
|
+
*/
|
|
80772
|
+
/**
|
|
80773
|
+
* A momentary drive nudge. The robot MOVES (no gimbal) for as long as the caller
|
|
80774
|
+
* keeps sending nudges (~1 Hz); an explicit `stop` (or letting the nudges lapse)
|
|
80775
|
+
* halts it.
|
|
80776
|
+
*
|
|
80777
|
+
* - `pan` — turn: negative = left, positive = right, 0 = straight.
|
|
80778
|
+
* - `tilt` — throttle: positive = forward, negative = spin / turn-around.
|
|
80779
|
+
* - `speed` — optional intensity hint [0, 1]; the provider may scale the drive
|
|
80780
|
+
* vector by it (drivers without proportional drive ignore it).
|
|
80781
|
+
*
|
|
80782
|
+
* `pan` / `tilt` are normalized [-1, 1]. Both optional so a caller can nudge one
|
|
80783
|
+
* axis alone; an all-undefined nudge is a no-op.
|
|
80784
|
+
*/
|
|
80785
|
+
var NavigationMoveCommandSchema = object({
|
|
80786
|
+
pan: number().min(-1).max(1).optional(),
|
|
80787
|
+
tilt: number().min(-1).max(1).optional(),
|
|
80788
|
+
speed: number().min(0).max(1).optional()
|
|
80789
|
+
});
|
|
80790
|
+
/**
|
|
80791
|
+
* The enumerated discrete actions a navigation-capable robot can perform via
|
|
80792
|
+
* `runAction`. This is the CLOSED vocabulary; a given device advertises the
|
|
80793
|
+
* subset it supports through `listActions`. Sounds are NOT here — they go through
|
|
80794
|
+
* `playSound` (see the `sound` dictionary entries).
|
|
80795
|
+
*/
|
|
80796
|
+
var NavigationActionIdSchema = _enum([
|
|
80797
|
+
"goHome",
|
|
80798
|
+
"locate",
|
|
80799
|
+
"spotClean",
|
|
80800
|
+
"findPet",
|
|
80801
|
+
"personFollow",
|
|
80802
|
+
"stop",
|
|
80803
|
+
"startClean",
|
|
80804
|
+
"pauseClean",
|
|
80805
|
+
"dockWash",
|
|
80806
|
+
"autoEmpty",
|
|
80807
|
+
"flashOn",
|
|
80808
|
+
"flashOff"
|
|
80809
|
+
]);
|
|
80810
|
+
/** Whether a dictionary entry is a `runAction` action or a `playSound` sound. */
|
|
80811
|
+
var NavigationEntryKindSchema = _enum(["action", "sound"]);
|
|
80812
|
+
/**
|
|
80813
|
+
* One entry in the navigation action dictionary — the DATA-DRIVEN unit both the
|
|
80814
|
+
* native panel and the PTZ mimic render as a button.
|
|
80815
|
+
*
|
|
80816
|
+
* - `id` — stable id. For `kind:'action'` it is a {@link NavigationActionId}
|
|
80817
|
+
* (pass to `runAction`); for `kind:'sound'` it is a namespaced id
|
|
80818
|
+
* (`sound:meow`) whose `soundId` is passed to `playSound`.
|
|
80819
|
+
* - `icon` — icon HINT (lucide-style name; the UI maps it to its own set).
|
|
80820
|
+
* - `label` — operator-facing English label.
|
|
80821
|
+
* - `soundId` — wire sound id, present only on `kind:'sound'` entries.
|
|
80822
|
+
* - `enabled` — per-device FEATURE FLAG. `listActions` reports it so the UI /
|
|
80823
|
+
* PTZ render ONLY enabled entries. Data-driven: the provider
|
|
80824
|
+
* flips it from config, never by editing code.
|
|
80825
|
+
*/
|
|
80826
|
+
var NavigationActionEntrySchema = object({
|
|
80827
|
+
id: string(),
|
|
80828
|
+
kind: NavigationEntryKindSchema,
|
|
80829
|
+
label: string(),
|
|
80830
|
+
icon: string(),
|
|
80831
|
+
/** Present only on `kind:'sound'` entries — the id to pass to `playSound`. */
|
|
80832
|
+
soundId: number().int().optional(),
|
|
80833
|
+
/** Per-device feature flag — render this entry only when true. */
|
|
80834
|
+
enabled: boolean()
|
|
80835
|
+
});
|
|
80836
|
+
/**
|
|
80837
|
+
* Canonical dictionary of every navigation control (discrete actions + sounds)
|
|
80838
|
+
* with its default label + icon hint. A provider filters this to the subset a
|
|
80839
|
+
* device supports (and may override a label). Exported so the provider AND the
|
|
80840
|
+
* UI share ONE list instead of re-deriving labels / ids independently.
|
|
80841
|
+
*
|
|
80842
|
+
* Icons are lucide-style hints; the viewer maps them to its own icon set. Every
|
|
80843
|
+
* catalog default is `enabled: true`; a provider overrides per-device from
|
|
80844
|
+
* config (data-driven — enabling / disabling one later is a config change, not a
|
|
80845
|
+
* code change).
|
|
80846
|
+
*/
|
|
80847
|
+
var NAVIGATION_ACTION_CATALOG = [
|
|
80848
|
+
{
|
|
80849
|
+
id: "startClean",
|
|
80850
|
+
kind: "action",
|
|
80851
|
+
label: "Start cleaning",
|
|
80852
|
+
icon: "play",
|
|
80853
|
+
enabled: true
|
|
80854
|
+
},
|
|
80855
|
+
{
|
|
80856
|
+
id: "pauseClean",
|
|
80857
|
+
kind: "action",
|
|
80858
|
+
label: "Pause cleaning",
|
|
80859
|
+
icon: "pause",
|
|
80860
|
+
enabled: true
|
|
80861
|
+
},
|
|
80862
|
+
{
|
|
80863
|
+
id: "stop",
|
|
80864
|
+
kind: "action",
|
|
80865
|
+
label: "Stop",
|
|
80866
|
+
icon: "hand",
|
|
80867
|
+
enabled: true
|
|
80868
|
+
},
|
|
80869
|
+
{
|
|
80870
|
+
id: "goHome",
|
|
80871
|
+
kind: "action",
|
|
80872
|
+
label: "Return to dock",
|
|
80873
|
+
icon: "home",
|
|
80874
|
+
enabled: true
|
|
80875
|
+
},
|
|
80876
|
+
{
|
|
80877
|
+
id: "locate",
|
|
80878
|
+
kind: "action",
|
|
80879
|
+
label: "Locate",
|
|
80880
|
+
icon: "bell",
|
|
80881
|
+
enabled: true
|
|
80882
|
+
},
|
|
80883
|
+
{
|
|
80884
|
+
id: "spotClean",
|
|
80885
|
+
kind: "action",
|
|
80886
|
+
label: "Spot clean here",
|
|
80887
|
+
icon: "crosshair",
|
|
80888
|
+
enabled: true
|
|
80889
|
+
},
|
|
80890
|
+
{
|
|
80891
|
+
id: "findPet",
|
|
80892
|
+
kind: "action",
|
|
80893
|
+
label: "Find pet",
|
|
80894
|
+
icon: "paw-print",
|
|
80895
|
+
enabled: true
|
|
80896
|
+
},
|
|
80897
|
+
{
|
|
80898
|
+
id: "personFollow",
|
|
80899
|
+
kind: "action",
|
|
80900
|
+
label: "Follow me",
|
|
80901
|
+
icon: "footprints",
|
|
80902
|
+
enabled: true
|
|
80903
|
+
},
|
|
80904
|
+
{
|
|
80905
|
+
id: "dockWash",
|
|
80906
|
+
kind: "action",
|
|
80907
|
+
label: "Wash mop",
|
|
80908
|
+
icon: "droplets",
|
|
80909
|
+
enabled: true
|
|
80910
|
+
},
|
|
80911
|
+
{
|
|
80912
|
+
id: "autoEmpty",
|
|
80913
|
+
kind: "action",
|
|
80914
|
+
label: "Empty dustbin",
|
|
80915
|
+
icon: "trash-2",
|
|
80916
|
+
enabled: true
|
|
80917
|
+
},
|
|
80918
|
+
{
|
|
80919
|
+
id: "flashOn",
|
|
80920
|
+
kind: "action",
|
|
80921
|
+
label: "Flash on",
|
|
80922
|
+
icon: "flashlight",
|
|
80923
|
+
enabled: true
|
|
80924
|
+
},
|
|
80925
|
+
{
|
|
80926
|
+
id: "flashOff",
|
|
80927
|
+
kind: "action",
|
|
80928
|
+
label: "Flash off",
|
|
80929
|
+
icon: "flashlight-off",
|
|
80930
|
+
enabled: true
|
|
80931
|
+
},
|
|
80932
|
+
{
|
|
80933
|
+
id: "sound:meow",
|
|
80934
|
+
kind: "sound",
|
|
80935
|
+
label: "Meow",
|
|
80936
|
+
icon: "cat",
|
|
80937
|
+
soundId: 684,
|
|
80938
|
+
enabled: true
|
|
80939
|
+
},
|
|
80940
|
+
{
|
|
80941
|
+
id: "sound:bark",
|
|
80942
|
+
kind: "sound",
|
|
80943
|
+
label: "Woof",
|
|
80944
|
+
icon: "dog",
|
|
80945
|
+
soundId: 685,
|
|
80946
|
+
enabled: true
|
|
80947
|
+
},
|
|
80948
|
+
{
|
|
80949
|
+
id: "sound:footsteps",
|
|
80950
|
+
kind: "sound",
|
|
80951
|
+
label: "Footsteps",
|
|
80952
|
+
icon: "footprints",
|
|
80953
|
+
soundId: 686,
|
|
80954
|
+
enabled: true
|
|
80955
|
+
},
|
|
80956
|
+
{
|
|
80957
|
+
id: "sound:purring",
|
|
80958
|
+
kind: "sound",
|
|
80959
|
+
label: "Purring",
|
|
80960
|
+
icon: "cat",
|
|
80961
|
+
soundId: 687,
|
|
80962
|
+
enabled: true
|
|
80963
|
+
},
|
|
80964
|
+
{
|
|
80965
|
+
id: "sound:tickTock",
|
|
80966
|
+
kind: "sound",
|
|
80967
|
+
label: "Tick-tock",
|
|
80968
|
+
icon: "clock",
|
|
80969
|
+
soundId: 688,
|
|
80970
|
+
enabled: true
|
|
80971
|
+
}
|
|
80972
|
+
];
|
|
80973
|
+
/** Coordinates for `goToPoint` — a point on the robot's live map. */
|
|
80974
|
+
var NavigationPointSchema = object({
|
|
80975
|
+
x: number(),
|
|
80976
|
+
y: number()
|
|
80977
|
+
});
|
|
80978
|
+
/**
|
|
80979
|
+
* Per-device FEATURE-FLAG report for the general (non-dictionary) primitives.
|
|
80980
|
+
* The cap reports which are enabled so the UI / PTZ render only the controls
|
|
80981
|
+
* that are turned on for THIS device. Data-driven: the provider derives these
|
|
80982
|
+
* from config + probe, never hardcoded in the UI. The per-DICTIONARY-entry flags
|
|
80983
|
+
* live on {@link NavigationActionEntrySchema.enabled}; these gate the primitives
|
|
80984
|
+
* that are not dictionary entries.
|
|
80985
|
+
*
|
|
80986
|
+
* - `move` / `stop` — the momentary drive joystick.
|
|
80987
|
+
* - `goToPoint` — send-to-map-coordinate. Ships OFF on Dreame until the
|
|
80988
|
+
* map-coordinate plumbing is wired.
|
|
80989
|
+
* - `runAction` — the discrete action buttons (dictionary `kind:'action'`).
|
|
80990
|
+
* - `playSound` — the sound buttons (dictionary `kind:'sound'`).
|
|
80991
|
+
* - `light` — the on/off fill-light toggle (works anytime).
|
|
80992
|
+
* - `lightMode` — the auto/manual selector + manual level slider (a
|
|
80993
|
+
* camera-service control; needs an active stream).
|
|
80994
|
+
*/
|
|
80995
|
+
var NavigationFeaturesSchema = object({
|
|
80996
|
+
move: boolean(),
|
|
80997
|
+
stop: boolean(),
|
|
80998
|
+
goToPoint: boolean(),
|
|
80999
|
+
runAction: boolean(),
|
|
81000
|
+
playSound: boolean(),
|
|
81001
|
+
light: boolean(),
|
|
81002
|
+
lightMode: boolean()
|
|
81003
|
+
});
|
|
81004
|
+
/** Light mode: `auto` lets the camera choose brightness; `manual` uses `level`. */
|
|
81005
|
+
var NavigationLightModeSchema = _enum(["auto", "manual"]);
|
|
81006
|
+
/**
|
|
81007
|
+
* Live navigation state so the UI can reflect what the robot is doing:
|
|
81008
|
+
* - `mode` — coarse activity (idle / cleaning / following / …).
|
|
81009
|
+
* - `following` — person/pet follow is currently armed.
|
|
81010
|
+
* - `flash` — the on-camera fill light is on.
|
|
81011
|
+
* - `lightMode` — auto vs manual fill-light mode.
|
|
81012
|
+
* - `lightLevel` — manual fill-light level (40..100); meaningful when
|
|
81013
|
+
* `lightMode === 'manual'`.
|
|
81014
|
+
*/
|
|
81015
|
+
var NavigationStatusSchema = object({
|
|
81016
|
+
mode: _enum([
|
|
81017
|
+
"idle",
|
|
81018
|
+
"cleaning",
|
|
81019
|
+
"spot",
|
|
81020
|
+
"following",
|
|
81021
|
+
"goto",
|
|
81022
|
+
"returning",
|
|
81023
|
+
"paused",
|
|
81024
|
+
"unknown"
|
|
81025
|
+
]),
|
|
81026
|
+
following: boolean(),
|
|
81027
|
+
flash: boolean(),
|
|
81028
|
+
lightMode: NavigationLightModeSchema,
|
|
81029
|
+
lightLevel: number().min(40).max(100),
|
|
81030
|
+
/** Ms epoch when the slice was last updated. */
|
|
81031
|
+
lastChangedAt: number()
|
|
81032
|
+
});
|
|
81033
|
+
/**
|
|
81034
|
+
* Runtime-state slice owned by this cap (kernel-managed: validated, mirrored,
|
|
81035
|
+
* observable). Adds `lastFetchedAt` on top of the status shape per the
|
|
81036
|
+
* convention.
|
|
81037
|
+
*/
|
|
81038
|
+
var NavigationRuntimeStateSchema = NavigationStatusSchema.extend({ lastFetchedAt: number() });
|
|
81039
|
+
var navigationCapability = {
|
|
81040
|
+
name: "navigation",
|
|
81041
|
+
scope: "device",
|
|
81042
|
+
deviceNative: true,
|
|
81043
|
+
mode: "singleton",
|
|
81044
|
+
deviceTypes: [DeviceType.Camera],
|
|
81045
|
+
deviceConfig: { ui: {
|
|
81046
|
+
kind: "widget",
|
|
81047
|
+
widgetId: "host/navigation-panel",
|
|
81048
|
+
tab: "navigation",
|
|
81049
|
+
topTab: true,
|
|
81050
|
+
label: "Navigation",
|
|
81051
|
+
order: 0
|
|
81052
|
+
} },
|
|
81053
|
+
methods: {
|
|
81054
|
+
/**
|
|
81055
|
+
* Momentary drive nudge (the robot moves). `protected` — mirrors
|
|
81056
|
+
* `ptz.continuousMove` so the Viewer navigation panel (and the PTZ-mimic
|
|
81057
|
+
* path) works for any authenticated user, not admin-only. The UI sends
|
|
81058
|
+
* these at ~1 Hz while a control is held; the provider forwards each one to
|
|
81059
|
+
* a single drive write WITHOUT debouncing.
|
|
81060
|
+
*/
|
|
81061
|
+
move: method(NavigationMoveCommandSchema.extend({ deviceId: number() }), _void(), { kind: "mutation" }),
|
|
81062
|
+
/** Halt all motion immediately (zero drive vector). */
|
|
81063
|
+
stop: method(object({ deviceId: number() }), _void(), { kind: "mutation" }),
|
|
81064
|
+
/** Send the robot to a point on its live map. */
|
|
81065
|
+
goToPoint: method(NavigationPointSchema.extend({ deviceId: number() }), _void(), { kind: "mutation" }),
|
|
81066
|
+
/**
|
|
81067
|
+
* Enumerate the discrete controls THIS device supports (data-driven UI +
|
|
81068
|
+
* PTZ mimic). Camera-probed subset of {@link NAVIGATION_ACTION_CATALOG}.
|
|
81069
|
+
*/
|
|
81070
|
+
listActions: method(object({ deviceId: number() }), array(NavigationActionEntrySchema)),
|
|
81071
|
+
/**
|
|
81072
|
+
* Run one discrete action (a `kind:'action'` dictionary entry). Invalid /
|
|
81073
|
+
* unsupported action ids are rejected by the provider.
|
|
81074
|
+
*/
|
|
81075
|
+
runAction: method(object({
|
|
81076
|
+
deviceId: number(),
|
|
81077
|
+
actionId: NavigationActionIdSchema
|
|
81078
|
+
}), _void(), { kind: "mutation" }),
|
|
81079
|
+
/** Play a sound by its wire id (the `soundId` of a `kind:'sound'` entry). */
|
|
81080
|
+
playSound: method(object({
|
|
81081
|
+
deviceId: number(),
|
|
81082
|
+
soundId: number().int()
|
|
81083
|
+
}), _void(), { kind: "mutation" }),
|
|
81084
|
+
/**
|
|
81085
|
+
* Turn the on-camera fill light on / off (the `OpenFullLight` control —
|
|
81086
|
+
* works anytime, no active stream required).
|
|
81087
|
+
*/
|
|
81088
|
+
setLightOn: method(object({
|
|
81089
|
+
deviceId: number(),
|
|
81090
|
+
on: boolean()
|
|
81091
|
+
}), _void(), { kind: "mutation" }),
|
|
81092
|
+
/**
|
|
81093
|
+
* Set the fill-light mode (auto vs manual). `manual` optionally carries the
|
|
81094
|
+
* initial `level`. The auto/manual + level control is a CAMERA-service
|
|
81095
|
+
* action that generally needs an active camera stream/monitor session — the
|
|
81096
|
+
* UI shows the manual level slider ONLY when `mode === 'manual'`.
|
|
81097
|
+
*/
|
|
81098
|
+
setLightMode: method(object({
|
|
81099
|
+
deviceId: number(),
|
|
81100
|
+
mode: NavigationLightModeSchema,
|
|
81101
|
+
level: number().min(40).max(100).optional()
|
|
81102
|
+
}), _void(), { kind: "mutation" }),
|
|
81103
|
+
/** Set the MANUAL fill-light level (40..100). Implies `manual` mode. */
|
|
81104
|
+
setLightLevel: method(object({
|
|
81105
|
+
deviceId: number(),
|
|
81106
|
+
level: number().min(40).max(100)
|
|
81107
|
+
}), _void(), { kind: "mutation" }),
|
|
81108
|
+
/**
|
|
81109
|
+
* Per-device FEATURE-FLAG report for the general primitives — drives which
|
|
81110
|
+
* controls the UI shows (the per-entry flags for the dictionary come back on
|
|
81111
|
+
* `listActions`).
|
|
81112
|
+
*/
|
|
81113
|
+
getFeatures: method(object({ deviceId: number() }), NavigationFeaturesSchema)
|
|
81114
|
+
},
|
|
81115
|
+
events: { onStatusChanged: { data: object({
|
|
81116
|
+
deviceId: number(),
|
|
81117
|
+
status: NavigationStatusSchema
|
|
81118
|
+
}) } },
|
|
81119
|
+
status: {
|
|
81120
|
+
schema: NavigationStatusSchema,
|
|
81121
|
+
kind: "push"
|
|
81122
|
+
},
|
|
81123
|
+
/**
|
|
81124
|
+
* Runtime-state slice mirrored by the kernel. The navigation panel watches it
|
|
81125
|
+
* for live mode / follow / flash changes.
|
|
81126
|
+
*/
|
|
81127
|
+
runtimeState: NavigationRuntimeStateSchema,
|
|
81128
|
+
/**
|
|
81129
|
+
* Runtime-state durability: **session** — like `vacuum-control`, a restored
|
|
81130
|
+
* `mode: cleaning` / `following: true` is a robot that is not actually doing
|
|
81131
|
+
* that. The live handle re-publishes on connect.
|
|
81132
|
+
*
|
|
81133
|
+
* See `RuntimeStateDurability`. Enforced by
|
|
81134
|
+
* `scripts/check-runtime-state-durability.ts`.
|
|
81135
|
+
*/
|
|
81136
|
+
durability: "session"
|
|
81137
|
+
};
|
|
79497
81138
|
DeviceType.Camera, DeviceType.Sensor, DeviceType.Switch, method(object({ deviceId: number().int().nonnegative() }), object({ success: literal(true) }), {
|
|
79498
81139
|
kind: "mutation",
|
|
79499
81140
|
auth: "admin"
|
|
@@ -82769,6 +84410,7 @@ var DEVICE_LOCAL_STATE_CAPS = {
|
|
|
82769
84410
|
motionTrigger: motionTriggerCapability,
|
|
82770
84411
|
motionZones: motionZonesCapability,
|
|
82771
84412
|
nativeObjectDetection: nativeObjectDetectionCapability,
|
|
84413
|
+
navigation: navigationCapability,
|
|
82772
84414
|
networkLink: networkLinkCapability,
|
|
82773
84415
|
notifier: notifierCapability,
|
|
82774
84416
|
numericSensor: numericSensorCapability,
|
|
@@ -86623,6 +88265,66 @@ Object.freeze({
|
|
|
86623
88265
|
addonId: null,
|
|
86624
88266
|
access: "create"
|
|
86625
88267
|
},
|
|
88268
|
+
"navigation.getFeatures": {
|
|
88269
|
+
capName: "navigation",
|
|
88270
|
+
capScope: "device",
|
|
88271
|
+
addonId: null,
|
|
88272
|
+
access: "view"
|
|
88273
|
+
},
|
|
88274
|
+
"navigation.goToPoint": {
|
|
88275
|
+
capName: "navigation",
|
|
88276
|
+
capScope: "device",
|
|
88277
|
+
addonId: null,
|
|
88278
|
+
access: "create"
|
|
88279
|
+
},
|
|
88280
|
+
"navigation.listActions": {
|
|
88281
|
+
capName: "navigation",
|
|
88282
|
+
capScope: "device",
|
|
88283
|
+
addonId: null,
|
|
88284
|
+
access: "view"
|
|
88285
|
+
},
|
|
88286
|
+
"navigation.move": {
|
|
88287
|
+
capName: "navigation",
|
|
88288
|
+
capScope: "device",
|
|
88289
|
+
addonId: null,
|
|
88290
|
+
access: "create"
|
|
88291
|
+
},
|
|
88292
|
+
"navigation.playSound": {
|
|
88293
|
+
capName: "navigation",
|
|
88294
|
+
capScope: "device",
|
|
88295
|
+
addonId: null,
|
|
88296
|
+
access: "create"
|
|
88297
|
+
},
|
|
88298
|
+
"navigation.runAction": {
|
|
88299
|
+
capName: "navigation",
|
|
88300
|
+
capScope: "device",
|
|
88301
|
+
addonId: null,
|
|
88302
|
+
access: "create"
|
|
88303
|
+
},
|
|
88304
|
+
"navigation.setLightLevel": {
|
|
88305
|
+
capName: "navigation",
|
|
88306
|
+
capScope: "device",
|
|
88307
|
+
addonId: null,
|
|
88308
|
+
access: "create"
|
|
88309
|
+
},
|
|
88310
|
+
"navigation.setLightMode": {
|
|
88311
|
+
capName: "navigation",
|
|
88312
|
+
capScope: "device",
|
|
88313
|
+
addonId: null,
|
|
88314
|
+
access: "create"
|
|
88315
|
+
},
|
|
88316
|
+
"navigation.setLightOn": {
|
|
88317
|
+
capName: "navigation",
|
|
88318
|
+
capScope: "device",
|
|
88319
|
+
addonId: null,
|
|
88320
|
+
access: "create"
|
|
88321
|
+
},
|
|
88322
|
+
"navigation.stop": {
|
|
88323
|
+
capName: "navigation",
|
|
88324
|
+
capScope: "device",
|
|
88325
|
+
addonId: null,
|
|
88326
|
+
access: "create"
|
|
88327
|
+
},
|
|
86626
88328
|
"networkAccess.getEndpoint": {
|
|
86627
88329
|
capName: "network-access",
|
|
86628
88330
|
capScope: "system",
|
|
@@ -90718,6 +92420,56 @@ Object.freeze({
|
|
|
90718
92420
|
form: "single",
|
|
90719
92421
|
optional: false
|
|
90720
92422
|
}],
|
|
92423
|
+
"navigation.getFeatures": [{
|
|
92424
|
+
name: "deviceId",
|
|
92425
|
+
form: "single",
|
|
92426
|
+
optional: false
|
|
92427
|
+
}],
|
|
92428
|
+
"navigation.goToPoint": [{
|
|
92429
|
+
name: "deviceId",
|
|
92430
|
+
form: "single",
|
|
92431
|
+
optional: false
|
|
92432
|
+
}],
|
|
92433
|
+
"navigation.listActions": [{
|
|
92434
|
+
name: "deviceId",
|
|
92435
|
+
form: "single",
|
|
92436
|
+
optional: false
|
|
92437
|
+
}],
|
|
92438
|
+
"navigation.move": [{
|
|
92439
|
+
name: "deviceId",
|
|
92440
|
+
form: "single",
|
|
92441
|
+
optional: false
|
|
92442
|
+
}],
|
|
92443
|
+
"navigation.playSound": [{
|
|
92444
|
+
name: "deviceId",
|
|
92445
|
+
form: "single",
|
|
92446
|
+
optional: false
|
|
92447
|
+
}],
|
|
92448
|
+
"navigation.runAction": [{
|
|
92449
|
+
name: "deviceId",
|
|
92450
|
+
form: "single",
|
|
92451
|
+
optional: false
|
|
92452
|
+
}],
|
|
92453
|
+
"navigation.setLightLevel": [{
|
|
92454
|
+
name: "deviceId",
|
|
92455
|
+
form: "single",
|
|
92456
|
+
optional: false
|
|
92457
|
+
}],
|
|
92458
|
+
"navigation.setLightMode": [{
|
|
92459
|
+
name: "deviceId",
|
|
92460
|
+
form: "single",
|
|
92461
|
+
optional: false
|
|
92462
|
+
}],
|
|
92463
|
+
"navigation.setLightOn": [{
|
|
92464
|
+
name: "deviceId",
|
|
92465
|
+
form: "single",
|
|
92466
|
+
optional: false
|
|
92467
|
+
}],
|
|
92468
|
+
"navigation.stop": [{
|
|
92469
|
+
name: "deviceId",
|
|
92470
|
+
form: "single",
|
|
92471
|
+
optional: false
|
|
92472
|
+
}],
|
|
90721
92473
|
"networkQuality.getDeviceStats": [{
|
|
90722
92474
|
name: "deviceId",
|
|
90723
92475
|
form: "single",
|
|
@@ -93900,6 +95652,23 @@ var dreameChildSchema = object({
|
|
|
93900
95652
|
mapShowPath: boolean().optional(),
|
|
93901
95653
|
mapShowRobot: boolean().optional(),
|
|
93902
95654
|
mapShowCharger: boolean().optional(),
|
|
95655
|
+
/**
|
|
95656
|
+
* `navigation` per-device overrides (camera child only): a feature flag set
|
|
95657
|
+
* here wins over the addon defaults, an entry id listed here is switched off.
|
|
95658
|
+
* Data, not code — see `dreame-navigation-config.ts`.
|
|
95659
|
+
*/
|
|
95660
|
+
navigationFeatures: object({
|
|
95661
|
+
move: boolean().optional(),
|
|
95662
|
+
stop: boolean().optional(),
|
|
95663
|
+
goToPoint: boolean().optional(),
|
|
95664
|
+
runAction: boolean().optional(),
|
|
95665
|
+
playSound: boolean().optional(),
|
|
95666
|
+
light: boolean().optional(),
|
|
95667
|
+
lightMode: boolean().optional()
|
|
95668
|
+
}).optional(),
|
|
95669
|
+
navigationDisabledEntryIds: array(string()).optional(),
|
|
95670
|
+
/** Privacy PIN set at pairing — required to wake a coded camera (camera child only). */
|
|
95671
|
+
navigationAccessCode: string().optional(),
|
|
93903
95672
|
mapShowNoGo: boolean().optional(),
|
|
93904
95673
|
mapShowVirtualWalls: boolean().optional(),
|
|
93905
95674
|
mapShowObstacles: boolean().optional(),
|
|
@@ -93992,7 +95761,7 @@ var DreameChildDevice = class extends BaseDevice {
|
|
|
93992
95761
|
};
|
|
93993
95762
|
//#endregion
|
|
93994
95763
|
//#region src/devices/dreame-ai-switch-device.ts
|
|
93995
|
-
var CAP_NAME$
|
|
95764
|
+
var CAP_NAME$15 = "switch";
|
|
93996
95765
|
var COLD$4 = {
|
|
93997
95766
|
on: false,
|
|
93998
95767
|
lastChangedAt: 0
|
|
@@ -94054,26 +95823,26 @@ var DreameAiSwitchDevice = class extends DreameChildDevice {
|
|
|
94054
95823
|
}
|
|
94055
95824
|
registerControlCap() {
|
|
94056
95825
|
this.ctx.registerNativeCap(switchCapability, {
|
|
94057
|
-
getStatus: async () => this.runtimeState.getCapState(CAP_NAME$
|
|
95826
|
+
getStatus: async () => this.runtimeState.getCapState(CAP_NAME$15) ?? COLD$4,
|
|
94058
95827
|
setState: async ({ on }) => {
|
|
94059
95828
|
const vacuum = this.resolveVacuum();
|
|
94060
95829
|
if (vacuum === null) throw new Error(`DreameAiSwitchDevice: vacuum handle unavailable (device=${this.dreameDeviceId}, feature=${this.#feature})`);
|
|
94061
95830
|
await vacuum.setAiFeature(this.#feature, on);
|
|
94062
|
-
this.runtimeState.setCapState(CAP_NAME$
|
|
95831
|
+
this.runtimeState.setCapState(CAP_NAME$15, {
|
|
94063
95832
|
on,
|
|
94064
95833
|
lastChangedAt: Date.now()
|
|
94065
95834
|
});
|
|
94066
95835
|
}
|
|
94067
95836
|
});
|
|
94068
|
-
this.runtimeState.setCapState(CAP_NAME$
|
|
95837
|
+
this.runtimeState.setCapState(CAP_NAME$15, COLD$4);
|
|
94069
95838
|
}
|
|
94070
95839
|
recomputeSlice() {
|
|
94071
95840
|
const vacuum = this.resolveVacuum();
|
|
94072
95841
|
if (vacuum === null) return;
|
|
94073
95842
|
const on = vacuum.aiFeature(this.#feature);
|
|
94074
95843
|
if (on === null) return;
|
|
94075
|
-
if (this.runtimeState.getCapState(CAP_NAME$
|
|
94076
|
-
this.runtimeState.setCapState(CAP_NAME$
|
|
95844
|
+
if (this.runtimeState.getCapState(CAP_NAME$15)?.on === on) return;
|
|
95845
|
+
this.runtimeState.setCapState(CAP_NAME$15, {
|
|
94077
95846
|
on,
|
|
94078
95847
|
lastChangedAt: Date.now()
|
|
94079
95848
|
});
|
|
@@ -94098,6 +95867,14 @@ function vacuumSupportsAi(caps) {
|
|
|
94098
95867
|
return caps !== null && caps.hasAiObstacleDetection && caps.aiFeatureCount > 0;
|
|
94099
95868
|
}
|
|
94100
95869
|
/**
|
|
95870
|
+
* Whether a vacuum carries an on-board camera (LinkVisual video) — gates the
|
|
95871
|
+
* separate camera + navigation child. `false` when the handle has not resolved
|
|
95872
|
+
* (confirmed capability only, never optimistic).
|
|
95873
|
+
*/
|
|
95874
|
+
function vacuumSupportsCamera(caps) {
|
|
95875
|
+
return caps?.hasCamera === true;
|
|
95876
|
+
}
|
|
95877
|
+
/**
|
|
94101
95878
|
* Whether a mower supports a map. `false` when the handle has not resolved.
|
|
94102
95879
|
*/
|
|
94103
95880
|
function mowerSupportsMap(caps) {
|
|
@@ -94131,7 +95908,7 @@ function deriveContainerFeatures(input) {
|
|
|
94131
95908
|
}
|
|
94132
95909
|
//#endregion
|
|
94133
95910
|
//#region src/devices/dreame-auto-switch-device.ts
|
|
94134
|
-
var CAP_NAME$
|
|
95911
|
+
var CAP_NAME$14 = "switch";
|
|
94135
95912
|
var COLD$3 = {
|
|
94136
95913
|
on: false,
|
|
94137
95914
|
lastChangedAt: 0
|
|
@@ -94179,19 +95956,19 @@ var DreameAutoSwitchDevice = class extends DreameChildDevice {
|
|
|
94179
95956
|
}
|
|
94180
95957
|
registerControlCap() {
|
|
94181
95958
|
this.ctx.registerNativeCap(switchCapability, {
|
|
94182
|
-
getStatus: async () => this.runtimeState.getCapState(CAP_NAME$
|
|
95959
|
+
getStatus: async () => this.runtimeState.getCapState(CAP_NAME$14) ?? COLD$3,
|
|
94183
95960
|
setState: async ({ on }) => {
|
|
94184
95961
|
const vacuum = this.#vacuum();
|
|
94185
95962
|
if (vacuum === null) throw new Error(`DreameAutoSwitchDevice: handle unavailable (device=${this.dreameDeviceId}, ${this.#key})`);
|
|
94186
95963
|
await vacuum.setAutoSwitchProperty(this.#key, on ? 1 : 0);
|
|
94187
|
-
this.runtimeState.setCapState(CAP_NAME$
|
|
95964
|
+
this.runtimeState.setCapState(CAP_NAME$14, {
|
|
94188
95965
|
on,
|
|
94189
95966
|
lastChangedAt: Date.now()
|
|
94190
95967
|
});
|
|
94191
95968
|
}
|
|
94192
95969
|
});
|
|
94193
95970
|
const on = this.#read();
|
|
94194
|
-
this.runtimeState.setCapState(CAP_NAME$
|
|
95971
|
+
this.runtimeState.setCapState(CAP_NAME$14, on === null ? COLD$3 : {
|
|
94195
95972
|
on,
|
|
94196
95973
|
lastChangedAt: Date.now()
|
|
94197
95974
|
});
|
|
@@ -94199,8 +95976,8 @@ var DreameAutoSwitchDevice = class extends DreameChildDevice {
|
|
|
94199
95976
|
recomputeSlice() {
|
|
94200
95977
|
const on = this.#read();
|
|
94201
95978
|
if (on === null) return;
|
|
94202
|
-
if (this.runtimeState.getCapState(CAP_NAME$
|
|
94203
|
-
this.runtimeState.setCapState(CAP_NAME$
|
|
95979
|
+
if (this.runtimeState.getCapState(CAP_NAME$14)?.on === on) return;
|
|
95980
|
+
this.runtimeState.setCapState(CAP_NAME$14, {
|
|
94204
95981
|
on,
|
|
94205
95982
|
lastChangedAt: Date.now()
|
|
94206
95983
|
});
|
|
@@ -94208,7 +95985,7 @@ var DreameAutoSwitchDevice = class extends DreameChildDevice {
|
|
|
94208
95985
|
};
|
|
94209
95986
|
//#endregion
|
|
94210
95987
|
//#region src/devices/dreame-auto-select-device.ts
|
|
94211
|
-
var CAP_NAME$
|
|
95988
|
+
var CAP_NAME$13 = "control";
|
|
94212
95989
|
var dreameAutoSelectOptionSchema = object({
|
|
94213
95990
|
value: number().int(),
|
|
94214
95991
|
label: string().min(1)
|
|
@@ -94263,7 +96040,7 @@ var DreameAutoSelectDevice = class extends DreameChildDevice {
|
|
|
94263
96040
|
}
|
|
94264
96041
|
registerControlCap() {
|
|
94265
96042
|
this.ctx.registerNativeCap(controlCapability, {
|
|
94266
|
-
getStatus: async () => this.runtimeState.getCapState(CAP_NAME$
|
|
96043
|
+
getStatus: async () => this.runtimeState.getCapState(CAP_NAME$13) ?? this.#slice(this.#readLabel() ?? ""),
|
|
94267
96044
|
setValue: async ({ control }) => {
|
|
94268
96045
|
if (control.kind !== "select") throw new Error(`DreameAutoSelectDevice: expected a select value (device=${this.dreameDeviceId}, ${this.#key})`);
|
|
94269
96046
|
const option = this.#options.find((o) => o.label === control.value);
|
|
@@ -94271,16 +96048,16 @@ var DreameAutoSelectDevice = class extends DreameChildDevice {
|
|
|
94271
96048
|
const vacuum = this.#vacuum();
|
|
94272
96049
|
if (vacuum === null) throw new Error(`DreameAutoSelectDevice: handle unavailable (device=${this.dreameDeviceId}, ${this.#key})`);
|
|
94273
96050
|
await vacuum.setAutoSwitchProperty(this.#key, option.value);
|
|
94274
|
-
this.runtimeState.setCapState(CAP_NAME$
|
|
96051
|
+
this.runtimeState.setCapState(CAP_NAME$13, this.#slice(option.label));
|
|
94275
96052
|
}
|
|
94276
96053
|
});
|
|
94277
|
-
this.runtimeState.setCapState(CAP_NAME$
|
|
96054
|
+
this.runtimeState.setCapState(CAP_NAME$13, this.#slice(this.#readLabel() ?? ""));
|
|
94278
96055
|
}
|
|
94279
96056
|
recomputeSlice() {
|
|
94280
96057
|
const label = this.#readLabel();
|
|
94281
96058
|
if (label === null) return;
|
|
94282
|
-
if (this.runtimeState.getCapState(CAP_NAME$
|
|
94283
|
-
this.runtimeState.setCapState(CAP_NAME$
|
|
96059
|
+
if (this.runtimeState.getCapState(CAP_NAME$13)?.value === label) return;
|
|
96060
|
+
this.runtimeState.setCapState(CAP_NAME$13, this.#slice(label));
|
|
94284
96061
|
}
|
|
94285
96062
|
};
|
|
94286
96063
|
//#endregion
|
|
@@ -94323,6 +96100,884 @@ var DreameButtonDevice = class extends DreameChildDevice {
|
|
|
94323
96100
|
recomputeSlice() {}
|
|
94324
96101
|
};
|
|
94325
96102
|
//#endregion
|
|
96103
|
+
//#region src/dreame-vacuum-decode.ts
|
|
96104
|
+
/**
|
|
96105
|
+
* Pure decode helpers for the Dreame vacuum. Translate the library's MIoT enum
|
|
96106
|
+
* values into the camstack `vacuum-control` slice shape. Unit-tested in
|
|
96107
|
+
* isolation (no facade, no network) — mirrors `homematic-decode.ts`.
|
|
96108
|
+
*/
|
|
96109
|
+
/**
|
|
96110
|
+
* Map a Dreame {@link MiotState} (siid 2 piid 1) onto the camstack canonical
|
|
96111
|
+
* `VacuumState` lifecycle (`idle` / `cleaning` / `paused` / `returning` /
|
|
96112
|
+
* `docked` / `error`). A `null` state (not yet observed) settles on `idle`.
|
|
96113
|
+
*
|
|
96114
|
+
* The MIoT state space is large (~50 values); we collapse it onto the six
|
|
96115
|
+
* canonical states by family. Anything unrecognised falls through to `idle`
|
|
96116
|
+
* (the safe neutral) so a firmware-added code never throws.
|
|
96117
|
+
*/
|
|
96118
|
+
function mapMiotStateToVacuumState(state) {
|
|
96119
|
+
if (state === null) return "idle";
|
|
96120
|
+
switch (state) {
|
|
96121
|
+
case MiotState.Charging:
|
|
96122
|
+
case MiotState.ChargingComplete:
|
|
96123
|
+
case MiotState.IntelligentCharging:
|
|
96124
|
+
case MiotState.CleanWashboardBase:
|
|
96125
|
+
case MiotState.AutoRepairBase:
|
|
96126
|
+
case MiotState.WaterSupplyDrainTest:
|
|
96127
|
+
case MiotState.CleanMopRefillWater:
|
|
96128
|
+
case MiotState.AutoEmptying:
|
|
96129
|
+
case MiotState.Emptying:
|
|
96130
|
+
case MiotState.Draining:
|
|
96131
|
+
case MiotState.AutoWaterDraining:
|
|
96132
|
+
case MiotState.Sanitizing: return "docked";
|
|
96133
|
+
case MiotState.MopDrying:
|
|
96134
|
+
case MiotState.DustBagDrying:
|
|
96135
|
+
case MiotState.SanitizingWithDry: return "drying";
|
|
96136
|
+
case MiotState.ReturningToCharge:
|
|
96137
|
+
case MiotState.ReturningToWash:
|
|
96138
|
+
case MiotState.ReturnInstallMop:
|
|
96139
|
+
case MiotState.ReturnRemoveMop:
|
|
96140
|
+
case MiotState.ReturnToEmpty:
|
|
96141
|
+
case MiotState.ReturningToDrain: return "returning";
|
|
96142
|
+
case MiotState.Paused:
|
|
96143
|
+
case MiotState.PausedAlt:
|
|
96144
|
+
case MiotState.PausedCleaning:
|
|
96145
|
+
case MiotState.DustBagDryingPaused:
|
|
96146
|
+
case MiotState.CameraMonitoringPaused:
|
|
96147
|
+
case MiotState.FindingPetPaused:
|
|
96148
|
+
case MiotState.InitialDeepCleanPaused:
|
|
96149
|
+
case MiotState.ChangingMopPaused:
|
|
96150
|
+
case MiotState.FloorMaintainingPaused: return "paused";
|
|
96151
|
+
case MiotState.Standby:
|
|
96152
|
+
case MiotState.WaitingForTask: return "idle";
|
|
96153
|
+
default: return "cleaning";
|
|
96154
|
+
}
|
|
96155
|
+
}
|
|
96156
|
+
/**
|
|
96157
|
+
* The camstack `fanSpeed` token for a Dreame {@link SuctionLevel} — the enum
|
|
96158
|
+
* member NAME, lower-cased (`Quiet` → `'quiet'`). Returns `null` for an
|
|
96159
|
+
* unobserved / unknown level.
|
|
96160
|
+
*/
|
|
96161
|
+
function suctionLevelToken(level) {
|
|
96162
|
+
if (level === null) return null;
|
|
96163
|
+
const name = SuctionLevel[level];
|
|
96164
|
+
return typeof name === "string" ? name.toLowerCase() : null;
|
|
96165
|
+
}
|
|
96166
|
+
/**
|
|
96167
|
+
* Resolve a camstack `fanSpeed` token back to the Dreame {@link SuctionLevel}
|
|
96168
|
+
* enum value, validated against the model's `supported` levels. Returns null
|
|
96169
|
+
* when the token doesn't match a supported level (the caller rejects the set).
|
|
96170
|
+
*/
|
|
96171
|
+
function tokenToSuctionLevel(token, supported) {
|
|
96172
|
+
const wanted = token.trim().toLowerCase();
|
|
96173
|
+
for (const level of supported) if (suctionLevelToken(level) === wanted) return level;
|
|
96174
|
+
return null;
|
|
96175
|
+
}
|
|
96176
|
+
/** The list of accepted `fanSpeed` tokens for a model's supported suction set. */
|
|
96177
|
+
function supportedFanSpeedTokens(supported) {
|
|
96178
|
+
const tokens = [];
|
|
96179
|
+
for (const level of supported) {
|
|
96180
|
+
const token = suctionLevelToken(level);
|
|
96181
|
+
if (token !== null) tokens.push(token);
|
|
96182
|
+
}
|
|
96183
|
+
return tokens;
|
|
96184
|
+
}
|
|
96185
|
+
/**
|
|
96186
|
+
* Clamp a raw battery reading (already a 0..100 percentage from the library)
|
|
96187
|
+
* to the `vacuum-control` slice's `batteryLevel` field. Returns null for a
|
|
96188
|
+
* null/NaN/out-of-band reading so the slice stays valid (the schema rejects
|
|
96189
|
+
* a number outside 0..100).
|
|
96190
|
+
*/
|
|
96191
|
+
function clampBatteryLevel$1(value) {
|
|
96192
|
+
if (value === null || !Number.isFinite(value)) return null;
|
|
96193
|
+
return Math.max(0, Math.min(100, value));
|
|
96194
|
+
}
|
|
96195
|
+
/** Clamp a 0..100 percentage (progress, coverage, …), null-safe. */
|
|
96196
|
+
function clampPercent$1(value) {
|
|
96197
|
+
if (value === null || !Number.isFinite(value)) return null;
|
|
96198
|
+
return Math.max(0, Math.min(100, value));
|
|
96199
|
+
}
|
|
96200
|
+
/**
|
|
96201
|
+
* Clean-water tank `status`: prefer the richer clean-water-tank-status (27/1),
|
|
96202
|
+
* fall back to the coarser water-tank presence (4/6). `null` when neither maps
|
|
96203
|
+
* to a known good/low reading (NOT_INSTALLED / CHECKING / unknown). Pure.
|
|
96204
|
+
*/
|
|
96205
|
+
function cleanWaterTankStatus(props) {
|
|
96206
|
+
if (props.cleanWaterTankStatus !== null) {
|
|
96207
|
+
if (props.cleanWaterTankStatus === 0) return "ok";
|
|
96208
|
+
if (props.cleanWaterTankStatus === 2) return "low";
|
|
96209
|
+
return null;
|
|
96210
|
+
}
|
|
96211
|
+
if (props.waterTank === 1 || props.waterTank === 10) return "ok";
|
|
96212
|
+
return null;
|
|
96213
|
+
}
|
|
96214
|
+
/** Detergent tank `status` from detergent-status (27/4). Pure. */
|
|
96215
|
+
function detergentTankStatus(value) {
|
|
96216
|
+
if (value === 0) return "ok";
|
|
96217
|
+
if (value === 2) return "low";
|
|
96218
|
+
return null;
|
|
96219
|
+
}
|
|
96220
|
+
/** Dust-bin `status` from dust-collection (15/3); OVER_USE reads as `full`. Pure. */
|
|
96221
|
+
function dustBinTankStatus(value) {
|
|
96222
|
+
if (value === 1) return "ok";
|
|
96223
|
+
if (value === 2) return "full";
|
|
96224
|
+
return null;
|
|
96225
|
+
}
|
|
96226
|
+
/**
|
|
96227
|
+
* The four `vacuum-control` tank fields (clean-water / dirty-water / detergent /
|
|
96228
|
+
* dust-bin) decoded from the model's {@link VacuumCapabilities}, with the
|
|
96229
|
+
* discrete `status` populated from the live MIoT enum props when supplied. Pure.
|
|
96230
|
+
*
|
|
96231
|
+
* The nodedreame library exposes NO tank FILL-LEVEL telemetry — the cloud shadow
|
|
96232
|
+
* (verified against a real `dreame.vacuum.r2538z`) carries only the water-VOLUME
|
|
96233
|
+
* *setting* (a mop-flow level, NOT a tank fill). We therefore never fabricate a
|
|
96234
|
+
* `level`; `level` is always `null`. The cap contract is honoured on PRESENCE,
|
|
96235
|
+
* with `status` derived from the tank-status enums:
|
|
96236
|
+
*
|
|
96237
|
+
* - a field is `null` when the hardware HAS NO such tank (capability flags), and
|
|
96238
|
+
* - `{ level: null, status }` when the tank EXISTS — `status` from the enum
|
|
96239
|
+
* prop ('ok' / 'low' / 'full'), or `null` when the device reports none.
|
|
96240
|
+
*
|
|
96241
|
+
* Presence is derived from the capability record:
|
|
96242
|
+
* - clean-water ← `canMop` (a mopping robot carries a clean-water reservoir)
|
|
96243
|
+
* - dirty-water ← `canMopWash` (a self-wash dock has a recovery/dirty tank)
|
|
96244
|
+
* - detergent ← `hasDetergentReservoir`
|
|
96245
|
+
* - dust-bin ← `canAutoEmpty` (an auto-empty robot has an onboard dust bin)
|
|
96246
|
+
*/
|
|
96247
|
+
function decodeVacuumTanks(caps, props) {
|
|
96248
|
+
const tank = (present, status) => present ? {
|
|
96249
|
+
level: null,
|
|
96250
|
+
status
|
|
96251
|
+
} : null;
|
|
96252
|
+
return {
|
|
96253
|
+
cleanWater: tank(Boolean(caps?.canMop), props ? cleanWaterTankStatus(props) : null),
|
|
96254
|
+
dirtyWater: tank(Boolean(caps?.canMopWash), null),
|
|
96255
|
+
detergent: tank(Boolean(caps?.hasDetergentReservoir), props ? detergentTankStatus(props.detergentStatus) : null),
|
|
96256
|
+
dustBin: tank(Boolean(caps?.canAutoEmpty), props ? dustBinTankStatus(props.dustCollection) : null)
|
|
96257
|
+
};
|
|
96258
|
+
}
|
|
96259
|
+
/**
|
|
96260
|
+
* Clamp a raw consumable percentage to `[0, 100]`, or return `null` when the
|
|
96261
|
+
* value is absent / non-finite. The library reports remaining life as a 0..100
|
|
96262
|
+
* integer; we clamp defensively so an out-of-range firmware value never violates
|
|
96263
|
+
* the cap schema.
|
|
96264
|
+
*/
|
|
96265
|
+
function clampConsumablePct(value) {
|
|
96266
|
+
if (value === null || !Number.isFinite(value)) return null;
|
|
96267
|
+
return Math.max(0, Math.min(100, value));
|
|
96268
|
+
}
|
|
96269
|
+
/**
|
|
96270
|
+
* Build the `consumables` cap runtime-state slice from a live {@link VacuumDevice}
|
|
96271
|
+
* handle. The returned object satisfies the cap's `runtimeState` schema
|
|
96272
|
+
* (`ConsumablesStatusSchema.extend({ lastFetchedAt: z.number() })`).
|
|
96273
|
+
*
|
|
96274
|
+
* PRESENCE-driven (mirrors HA): the library's `supportedConsumables` enumerates
|
|
96275
|
+
* ONLY the consumables this model reports (a present MIoT life property) — main/
|
|
96276
|
+
* side brush, filter, sensor, tank-filter, mop-pad, silver-ion, detergent,
|
|
96277
|
+
* squeegee, dust-bag — each with its remaining-life % and whether a reset action
|
|
96278
|
+
* exists. No hardcoded per-model list: a consumable the device does not expose
|
|
96279
|
+
* never appears, and a `resettable` item drives the real MIoT reset action.
|
|
96280
|
+
*/
|
|
96281
|
+
function buildVacuumConsumables(vacuum) {
|
|
96282
|
+
const items = vacuum.supportedConsumables.map((c) => ({
|
|
96283
|
+
key: c.key,
|
|
96284
|
+
label: c.label,
|
|
96285
|
+
level: clampConsumablePct(c.leftPct),
|
|
96286
|
+
status: null,
|
|
96287
|
+
lastResetAt: null,
|
|
96288
|
+
resettable: c.resettable
|
|
96289
|
+
}));
|
|
96290
|
+
const now = Date.now();
|
|
96291
|
+
return {
|
|
96292
|
+
items: [...items],
|
|
96293
|
+
lastChangedAt: now,
|
|
96294
|
+
lastFetchedAt: now
|
|
96295
|
+
};
|
|
96296
|
+
}
|
|
96297
|
+
//#endregion
|
|
96298
|
+
//#region src/devices/dreame-camera-stream.ts
|
|
96299
|
+
/** The one stream the robot camera offers. Half of the broker id (`<deviceId>/live`). */
|
|
96300
|
+
var CAMERA_STREAM_ID = "live";
|
|
96301
|
+
/** How often an open monitor asks the broker whether anybody is still watching. */
|
|
96302
|
+
var IDLE_CHECK_MS = 6e4;
|
|
96303
|
+
/** `ChargingStatus` as the robot reports it (siid 3 piid 2, VERIFIED r2532a). */
|
|
96304
|
+
var CHARGING_STATUS_CHARGING = 1;
|
|
96305
|
+
function clampLevel(level) {
|
|
96306
|
+
if (level === null || !Number.isFinite(level)) return null;
|
|
96307
|
+
return Math.max(0, Math.min(100, level));
|
|
96308
|
+
}
|
|
96309
|
+
/**
|
|
96310
|
+
* The battery slice the broker reads before a play: `sleeping` decides
|
|
96311
|
+
* whether it asks for a wake, `lastContactAt` decides whether it refuses to
|
|
96312
|
+
* (`deriveBatteryPresence` calls hours of silence "unreachable"). A contact
|
|
96313
|
+
* of 0 would read as "never heard" — the field is omitted instead.
|
|
96314
|
+
*/
|
|
96315
|
+
function cameraBatterySlice(facts) {
|
|
96316
|
+
const charging = facts.charging === null ? "unknown" : facts.charging === CHARGING_STATUS_CHARGING ? "dc" : "none";
|
|
96317
|
+
return {
|
|
96318
|
+
percentage: clampLevel(facts.level),
|
|
96319
|
+
charging,
|
|
96320
|
+
sleeping: !facts.streamOpen,
|
|
96321
|
+
lastUpdated: facts.now,
|
|
96322
|
+
...facts.contactAt !== null && facts.contactAt > 0 ? { lastContactAt: facts.contactAt } : {}
|
|
96323
|
+
};
|
|
96324
|
+
}
|
|
96325
|
+
/** One `pull-rtmp` descriptor while the monitor is open; nothing while it is closed. */
|
|
96326
|
+
function cameraStreamCatalog(session) {
|
|
96327
|
+
if (session === null) return [];
|
|
96328
|
+
return [{
|
|
96329
|
+
camStreamId: CAMERA_STREAM_ID,
|
|
96330
|
+
kind: "pull-rtmp",
|
|
96331
|
+
url: session.url,
|
|
96332
|
+
codec: "h264",
|
|
96333
|
+
label: "Robot camera",
|
|
96334
|
+
deviceFeatures: ["battery-operated"],
|
|
96335
|
+
autoEligible: true
|
|
96336
|
+
}];
|
|
96337
|
+
}
|
|
96338
|
+
/**
|
|
96339
|
+
* Somebody WATCHING is a reason to keep the monitor open: an RTSP client, a
|
|
96340
|
+
* WebRTC session (an `encoded` subscriber), an audio listener. The detection
|
|
96341
|
+
* pipeline is deliberately NOT one — it subscribes decoded frames for as long
|
|
96342
|
+
* as a stream exists, so counting it would keep the robot's relay open, on
|
|
96343
|
+
* its battery and through the cloud, until the battery died. It rides along
|
|
96344
|
+
* while a viewer watches and stops when the monitor closes.
|
|
96345
|
+
*/
|
|
96346
|
+
function isBrokerBusy(clients) {
|
|
96347
|
+
return clients.rtsp.length > 0 || clients.encoded.length > 0 || clients.audio.length > 0;
|
|
96348
|
+
}
|
|
96349
|
+
/** Consecutive idle checks; a busy check starts the count over. */
|
|
96350
|
+
function nextIdleCount(previous, busy) {
|
|
96351
|
+
return busy ? 0 : previous + 1;
|
|
96352
|
+
}
|
|
96353
|
+
function shouldCloseForIdle(idleCount) {
|
|
96354
|
+
return idleCount >= 3;
|
|
96355
|
+
}
|
|
96356
|
+
//#endregion
|
|
96357
|
+
//#region src/devices/dreame-navigation-actions.ts
|
|
96358
|
+
/** Forward throttle magnitude for a full-forward nudge (device speed units). */
|
|
96359
|
+
var FORWARD_SPEED = 200;
|
|
96360
|
+
/** Turn magnitudes: left = +45, right = -45, spin/turn-around = +180. */
|
|
96361
|
+
var TURN_LEFT = 45;
|
|
96362
|
+
var TURN_RIGHT = -45;
|
|
96363
|
+
var SPIN = 180;
|
|
96364
|
+
/** The `setFillLight` value that selects auto. */
|
|
96365
|
+
var FILL_LIGHT_AUTO$1 = 101;
|
|
96366
|
+
/**
|
|
96367
|
+
* Map a normalized {@link NavigationMoveCommand} ([-1, 1] per axis) to a discrete
|
|
96368
|
+
* Dreame drive vector. The robot has no proportional joystick, so intensity is
|
|
96369
|
+
* quantized to the RE'd discrete nudges. Priority: forward, then spin
|
|
96370
|
+
* (tilt-down), then turn (pan). An all-zero / empty command is a stop.
|
|
96371
|
+
*/
|
|
96372
|
+
function driveVector(cmd) {
|
|
96373
|
+
const pan = cmd.pan ?? 0;
|
|
96374
|
+
const tilt = cmd.tilt ?? 0;
|
|
96375
|
+
if (tilt > 0) return {
|
|
96376
|
+
spdv: FORWARD_SPEED,
|
|
96377
|
+
spdw: 0
|
|
96378
|
+
};
|
|
96379
|
+
if (tilt < 0) return {
|
|
96380
|
+
spdv: 0,
|
|
96381
|
+
spdw: SPIN
|
|
96382
|
+
};
|
|
96383
|
+
if (pan < 0) return {
|
|
96384
|
+
spdv: 0,
|
|
96385
|
+
spdw: TURN_LEFT
|
|
96386
|
+
};
|
|
96387
|
+
if (pan > 0) return {
|
|
96388
|
+
spdv: 0,
|
|
96389
|
+
spdw: TURN_RIGHT
|
|
96390
|
+
};
|
|
96391
|
+
return {
|
|
96392
|
+
spdv: 0,
|
|
96393
|
+
spdw: 0
|
|
96394
|
+
};
|
|
96395
|
+
}
|
|
96396
|
+
function moveCall(cmd) {
|
|
96397
|
+
const { spdv, spdw } = driveVector(cmd);
|
|
96398
|
+
return {
|
|
96399
|
+
method: "drive",
|
|
96400
|
+
spdv,
|
|
96401
|
+
spdw
|
|
96402
|
+
};
|
|
96403
|
+
}
|
|
96404
|
+
/** The explicit halt: a zero drive vector. */
|
|
96405
|
+
function stopCall() {
|
|
96406
|
+
return {
|
|
96407
|
+
method: "drive",
|
|
96408
|
+
spdv: 0,
|
|
96409
|
+
spdw: 0
|
|
96410
|
+
};
|
|
96411
|
+
}
|
|
96412
|
+
/** Fill light on (full) / off (back to auto) — the control that works anytime. */
|
|
96413
|
+
function lightOnCall(on) {
|
|
96414
|
+
return {
|
|
96415
|
+
method: "setFillLightAuto",
|
|
96416
|
+
full: on
|
|
96417
|
+
};
|
|
96418
|
+
}
|
|
96419
|
+
/** Fill-light mode: auto, or manual at `level` (40..100). Camera-service; needs a stream. */
|
|
96420
|
+
function lightModeCall(mode, level) {
|
|
96421
|
+
return {
|
|
96422
|
+
method: "setFillLight",
|
|
96423
|
+
value: mode === "auto" ? FILL_LIGHT_AUTO$1 : level ?? 100
|
|
96424
|
+
};
|
|
96425
|
+
}
|
|
96426
|
+
function lightLevelCall(level) {
|
|
96427
|
+
return lightModeCall("manual", level);
|
|
96428
|
+
}
|
|
96429
|
+
function playSoundCall(soundId) {
|
|
96430
|
+
return {
|
|
96431
|
+
method: "playSound",
|
|
96432
|
+
soundId
|
|
96433
|
+
};
|
|
96434
|
+
}
|
|
96435
|
+
function goToPointCall(point) {
|
|
96436
|
+
return {
|
|
96437
|
+
method: "goToPoint",
|
|
96438
|
+
x: point.x,
|
|
96439
|
+
y: point.y
|
|
96440
|
+
};
|
|
96441
|
+
}
|
|
96442
|
+
/** A discrete {@link NavigationActionId} → the controller method that performs it. */
|
|
96443
|
+
function actionCall(actionId) {
|
|
96444
|
+
switch (actionId) {
|
|
96445
|
+
case "goHome": return { method: "returnToDock" };
|
|
96446
|
+
case "locate": return { method: "locate" };
|
|
96447
|
+
case "spotClean": return { method: "spotClean" };
|
|
96448
|
+
case "findPet": return { method: "findPet" };
|
|
96449
|
+
case "personFollow": return { method: "startPersonFollow" };
|
|
96450
|
+
case "stop": return { method: "stopWork" };
|
|
96451
|
+
case "startClean": return {
|
|
96452
|
+
method: "runVacuumAction",
|
|
96453
|
+
action: "startClean"
|
|
96454
|
+
};
|
|
96455
|
+
case "pauseClean": return {
|
|
96456
|
+
method: "runVacuumAction",
|
|
96457
|
+
action: "pauseClean"
|
|
96458
|
+
};
|
|
96459
|
+
case "dockWash": return {
|
|
96460
|
+
method: "runVacuumAction",
|
|
96461
|
+
action: "dockWash"
|
|
96462
|
+
};
|
|
96463
|
+
case "autoEmpty": return {
|
|
96464
|
+
method: "runVacuumAction",
|
|
96465
|
+
action: "autoEmpty"
|
|
96466
|
+
};
|
|
96467
|
+
case "flashOn": return lightOnCall(true);
|
|
96468
|
+
case "flashOff": return lightOnCall(false);
|
|
96469
|
+
}
|
|
96470
|
+
}
|
|
96471
|
+
/** Perform one call on a controller. Exhaustive — a new method is a compile error here. */
|
|
96472
|
+
async function runControllerCall(controller, call) {
|
|
96473
|
+
switch (call.method) {
|
|
96474
|
+
case "drive":
|
|
96475
|
+
await controller.drive(call.spdv, call.spdw);
|
|
96476
|
+
return;
|
|
96477
|
+
case "stopWork":
|
|
96478
|
+
await controller.stopWork();
|
|
96479
|
+
return;
|
|
96480
|
+
case "startPersonFollow":
|
|
96481
|
+
await controller.startPersonFollow();
|
|
96482
|
+
return;
|
|
96483
|
+
case "spotClean":
|
|
96484
|
+
await controller.spotClean();
|
|
96485
|
+
return;
|
|
96486
|
+
case "findPet":
|
|
96487
|
+
await controller.findPet();
|
|
96488
|
+
return;
|
|
96489
|
+
case "playSound":
|
|
96490
|
+
await controller.playSound(call.soundId);
|
|
96491
|
+
return;
|
|
96492
|
+
case "runVacuumAction":
|
|
96493
|
+
await controller.runVacuumAction(call.action);
|
|
96494
|
+
return;
|
|
96495
|
+
case "goToPoint":
|
|
96496
|
+
await controller.goToPoint([[call.x, call.y]], [[call.x, call.y]]);
|
|
96497
|
+
return;
|
|
96498
|
+
case "setFillLight":
|
|
96499
|
+
await controller.setFillLight(call.value);
|
|
96500
|
+
return;
|
|
96501
|
+
case "setFillLightAuto":
|
|
96502
|
+
await controller.setFillLightAuto(call.full);
|
|
96503
|
+
return;
|
|
96504
|
+
case "returnToDock":
|
|
96505
|
+
await controller.returnToDock();
|
|
96506
|
+
return;
|
|
96507
|
+
case "locate":
|
|
96508
|
+
await controller.locate();
|
|
96509
|
+
return;
|
|
96510
|
+
}
|
|
96511
|
+
}
|
|
96512
|
+
//#endregion
|
|
96513
|
+
//#region src/devices/dreame-navigation-config.ts
|
|
96514
|
+
/**
|
|
96515
|
+
* DATA-DRIVEN feature flags for the Dreame navigation surface. Keeping these as
|
|
96516
|
+
* data (rather than branches in the device class) is what makes enabling /
|
|
96517
|
+
* disabling a control later a CONFIG change, not a code change — a future step
|
|
96518
|
+
* merges per-device / per-addon overrides on top of these defaults before the
|
|
96519
|
+
* cap reports them.
|
|
96520
|
+
*
|
|
96521
|
+
* `goToPoint` ships OFF: the send-to-map-coordinate path needs map-coordinate
|
|
96522
|
+
* plumbing (resolving a tap on the live map to robot coordinates) that is not
|
|
96523
|
+
* wired yet. Every other primitive defaults ON.
|
|
96524
|
+
*/
|
|
96525
|
+
var DREAME_NAVIGATION_DEFAULT_FEATURES = {
|
|
96526
|
+
move: true,
|
|
96527
|
+
stop: true,
|
|
96528
|
+
goToPoint: false,
|
|
96529
|
+
runAction: true,
|
|
96530
|
+
playSound: true,
|
|
96531
|
+
light: true,
|
|
96532
|
+
lightMode: true
|
|
96533
|
+
};
|
|
96534
|
+
/**
|
|
96535
|
+
* Dictionary-entry ids disabled by default for Dreame (data-driven per-entry
|
|
96536
|
+
* flags). Empty today — every catalog action / sound is enabled. A future
|
|
96537
|
+
* config override drops ids in here (or removes them) without touching code.
|
|
96538
|
+
*/
|
|
96539
|
+
var DREAME_NAVIGATION_DISABLED_ENTRY_IDS = /* @__PURE__ */ new Set();
|
|
96540
|
+
/**
|
|
96541
|
+
* The action dictionary as THIS provider reports it: the shared catalog with the
|
|
96542
|
+
* per-entry `enabled` flag resolved from {@link DREAME_NAVIGATION_DISABLED_ENTRY_IDS}.
|
|
96543
|
+
* Pure — the device method just returns this (a later step layers device config
|
|
96544
|
+
* on top).
|
|
96545
|
+
*/
|
|
96546
|
+
function dreameNavigationCatalog(disabledEntryIds = DREAME_NAVIGATION_DISABLED_ENTRY_IDS) {
|
|
96547
|
+
return NAVIGATION_ACTION_CATALOG.map((entry) => ({
|
|
96548
|
+
...entry,
|
|
96549
|
+
enabled: entry.enabled && !disabledEntryIds.has(entry.id)
|
|
96550
|
+
}));
|
|
96551
|
+
}
|
|
96552
|
+
/** The feature flags in force for ONE device: defaults, then its overrides. */
|
|
96553
|
+
function resolveNavigationFeatures(overrides) {
|
|
96554
|
+
return {
|
|
96555
|
+
...DREAME_NAVIGATION_DEFAULT_FEATURES,
|
|
96556
|
+
...overrides?.features ?? {}
|
|
96557
|
+
};
|
|
96558
|
+
}
|
|
96559
|
+
/** The disabled entry ids in force for ONE device: the addon set plus its own. */
|
|
96560
|
+
function resolveDisabledEntryIds(overrides) {
|
|
96561
|
+
return new Set([...DREAME_NAVIGATION_DISABLED_ENTRY_IDS, ...overrides?.disabledEntryIds ?? []]);
|
|
96562
|
+
}
|
|
96563
|
+
/** The camera-service fill-light mode/level property. */
|
|
96564
|
+
var FILL_LIGHT_SET_SIID = 10001;
|
|
96565
|
+
/** The wire value that means "auto" on `fillinLightSet`. */
|
|
96566
|
+
var FILL_LIGHT_AUTO = 101;
|
|
96567
|
+
function asRecord(raw) {
|
|
96568
|
+
if (typeof raw === "string") try {
|
|
96569
|
+
const parsed = JSON.parse(raw);
|
|
96570
|
+
return parsed !== null && typeof parsed === "object" ? parsed : null;
|
|
96571
|
+
} catch {
|
|
96572
|
+
return null;
|
|
96573
|
+
}
|
|
96574
|
+
return raw !== null && typeof raw === "object" ? raw : null;
|
|
96575
|
+
}
|
|
96576
|
+
/** `4/50` → `{ flash }`, only when the packed key is the fill light. */
|
|
96577
|
+
function decodeFillLight(raw) {
|
|
96578
|
+
const record = asRecord(raw);
|
|
96579
|
+
if (!record || record["k"] !== "FillinLight") return {};
|
|
96580
|
+
const v = record["v"];
|
|
96581
|
+
if (v === 1 || v === "1" || v === true) return { flash: true };
|
|
96582
|
+
if (v === 0 || v === "0" || v === false) return { flash: false };
|
|
96583
|
+
return {};
|
|
96584
|
+
}
|
|
96585
|
+
/** `10001/9` → `{ lightMode, lightLevel? }`: 101 is auto, 40..100 a manual level. */
|
|
96586
|
+
function decodeFillLightSet(raw) {
|
|
96587
|
+
const n = typeof raw === "string" ? Number(raw) : typeof raw === "number" ? raw : NaN;
|
|
96588
|
+
if (!Number.isFinite(n)) return {};
|
|
96589
|
+
if (n === FILL_LIGHT_AUTO) return { lightMode: "auto" };
|
|
96590
|
+
if (n >= 40 && n <= 100) return {
|
|
96591
|
+
lightMode: "manual",
|
|
96592
|
+
lightLevel: n
|
|
96593
|
+
};
|
|
96594
|
+
return {};
|
|
96595
|
+
}
|
|
96596
|
+
/** `true` while the robot's own state says it is following; `null` state = not known. */
|
|
96597
|
+
function decodeFollowing(state) {
|
|
96598
|
+
if (state === null || state === void 0 || !Number.isFinite(state)) return null;
|
|
96599
|
+
return state === 26;
|
|
96600
|
+
}
|
|
96601
|
+
/** Everything the robot currently says about its light, as one patch. */
|
|
96602
|
+
function decodeNavigationReadback(read) {
|
|
96603
|
+
return {
|
|
96604
|
+
...decodeFillLight(read(4, 50)),
|
|
96605
|
+
...decodeFillLightSet(read(FILL_LIGHT_SET_SIID, 9))
|
|
96606
|
+
};
|
|
96607
|
+
}
|
|
96608
|
+
//#endregion
|
|
96609
|
+
//#region src/devices/dreame-camera-device.ts
|
|
96610
|
+
var CAP_NAME$12 = "navigation";
|
|
96611
|
+
/**
|
|
96612
|
+
* The slice as WRITTEN: the cap's runtime-state shape (status + `lastFetchedAt`).
|
|
96613
|
+
* `setCapState` validates against it, so a status-only object throws at
|
|
96614
|
+
* activation and registers nothing — which is how the first deploy shipped
|
|
96615
|
+
* a camera with no navigation and no PTZ.
|
|
96616
|
+
*/
|
|
96617
|
+
var NAV_COLD_START = {
|
|
96618
|
+
mode: "unknown",
|
|
96619
|
+
following: false,
|
|
96620
|
+
flash: false,
|
|
96621
|
+
lightMode: "auto",
|
|
96622
|
+
lightLevel: 100,
|
|
96623
|
+
lastChangedAt: 0,
|
|
96624
|
+
lastFetchedAt: 0
|
|
96625
|
+
};
|
|
96626
|
+
/** The next slice: the previous one, the patch, and both clocks stamped `now`. */
|
|
96627
|
+
function navigationSlice(prev, patch, now) {
|
|
96628
|
+
return {
|
|
96629
|
+
...prev,
|
|
96630
|
+
...patch,
|
|
96631
|
+
lastChangedAt: now,
|
|
96632
|
+
lastFetchedAt: now
|
|
96633
|
+
};
|
|
96634
|
+
}
|
|
96635
|
+
/**
|
|
96636
|
+
* Dreame CAMERA + NAVIGATION device — a SEPARATE camstack device from the
|
|
96637
|
+
* `DreameVacuumDevice`, spawned only for camera-capable vacuums (those whose
|
|
96638
|
+
* `vacuumCapabilities.hasCamera` is true, i.e. a LinkVisual video surface). It
|
|
96639
|
+
* owns the `navigation` cap (drive joystick + discrete robot actions + sounds +
|
|
96640
|
+
* fill light); the vacuum device keeps `vacuum-control` / `consumables` / maps.
|
|
96641
|
+
*
|
|
96642
|
+
* PREBUFFER OFF: this is a BATTERY robot whose camera is expensive to cold-start,
|
|
96643
|
+
* so the device advertises {@link DeviceFeature.BatteryOperated} — the SAME flag
|
|
96644
|
+
* the Reolink battery-camera path uses. The stream-broker's
|
|
96645
|
+
* `getEffectivePreBufferSec` battery rule then sets `preBufferSec = 0`, so the
|
|
96646
|
+
* stream opens on demand and is never continuously prebuffered.
|
|
96647
|
+
*
|
|
96648
|
+
* The whole navigation surface goes through nodedreame's typed
|
|
96649
|
+
* `DreameCameraController` (1.12+, built lazily from the vacuum handle with the
|
|
96650
|
+
* pairing PIN when the camera is coded), mapped by `dreame-navigation-actions.ts`.
|
|
96651
|
+
* The controller is never `open()`ed here — the stream is the broker's; drive is
|
|
96652
|
+
* valid only while a stream is open, which is when the panel is used.
|
|
96653
|
+
*
|
|
96654
|
+
* STATUS: `mode` is mapped from the shared vacuum state; `flash` / `lightMode`
|
|
96655
|
+
* / `lightLevel` are decoded from the robot's own properties on every push
|
|
96656
|
+
* (`dreame-navigation-status.ts`: `4/50` and `10001/9`) and, until the first
|
|
96657
|
+
* read-back lands, from the commands this device issued. `following` has no
|
|
96658
|
+
* confirmed read-back yet and stays command-driven.
|
|
96659
|
+
*
|
|
96660
|
+
* `navigation` is its own capability, deliberately NOT projected as `ptz`: a
|
|
96661
|
+
* gimbal and a chassis are different physical models, and the operator asked
|
|
96662
|
+
* for two separate things (admin UI: two tabs; viewer: one panel that shows the
|
|
96663
|
+
* navigation controls when the device has the cap).
|
|
96664
|
+
*/
|
|
96665
|
+
var DreameCameraDevice = class extends DreameChildDevice {
|
|
96666
|
+
features = [DeviceFeature.BatteryOperated];
|
|
96667
|
+
constructor(ctx) {
|
|
96668
|
+
super(ctx);
|
|
96669
|
+
}
|
|
96670
|
+
registerControlCap() {
|
|
96671
|
+
const provider = this.buildNavigationProvider();
|
|
96672
|
+
this.ctx.registerNativeCap(navigationCapability, provider);
|
|
96673
|
+
this.runtimeState.setCapState(CAP_NAME$12, NAV_COLD_START);
|
|
96674
|
+
this.registerBatteryCap();
|
|
96675
|
+
this.registerStreamCatalogCap();
|
|
96676
|
+
}
|
|
96677
|
+
/** The relay the current `open()` handed out; null while the monitor is closed. */
|
|
96678
|
+
session = null;
|
|
96679
|
+
/** A single in-flight `open()` — two plays must not cold-start the robot twice. */
|
|
96680
|
+
opening = null;
|
|
96681
|
+
idleTimer = null;
|
|
96682
|
+
idleCount = 0;
|
|
96683
|
+
/** Last time the robot said anything (a state change) — the wake refusal's evidence. */
|
|
96684
|
+
lastContactAt = null;
|
|
96685
|
+
registerBatteryCap() {
|
|
96686
|
+
this.ctx.registerNativeCap(batteryCapability, { wakeForStream: async ({ timeoutMs }) => this.wakeForStream(timeoutMs) });
|
|
96687
|
+
this.publishBattery();
|
|
96688
|
+
this.runtimeState.subscribeCap(batteryCapability.name, (slice) => {
|
|
96689
|
+
if (!slice) return;
|
|
96690
|
+
this.ctx.eventBus.emit(createEvent(EventCategory.BatteryOnStatusChanged, {
|
|
96691
|
+
type: "device",
|
|
96692
|
+
id: this.stableId
|
|
96693
|
+
}, {
|
|
96694
|
+
deviceId: this.id,
|
|
96695
|
+
status: slice
|
|
96696
|
+
}));
|
|
96697
|
+
});
|
|
96698
|
+
}
|
|
96699
|
+
registerStreamCatalogCap() {
|
|
96700
|
+
this.ctx.registerNativeCap(streamCatalogCapability, { getCatalog: async ({ deviceId }) => deviceId === this.id ? cameraStreamCatalog(this.session) : [] });
|
|
96701
|
+
this.publishCatalog();
|
|
96702
|
+
}
|
|
96703
|
+
publishBattery() {
|
|
96704
|
+
const handle = this.resolveHandle();
|
|
96705
|
+
const vacuum = handle instanceof VacuumDevice ? handle : null;
|
|
96706
|
+
this.setCapSlice(batteryCapability, cameraBatterySlice({
|
|
96707
|
+
level: vacuum?.battery ?? null,
|
|
96708
|
+
charging: vacuum?.charging ?? null,
|
|
96709
|
+
streamOpen: this.session !== null,
|
|
96710
|
+
now: Date.now(),
|
|
96711
|
+
contactAt: this.lastContactAt
|
|
96712
|
+
}));
|
|
96713
|
+
}
|
|
96714
|
+
publishCatalog() {
|
|
96715
|
+
this.runtimeState.setCapState(streamCatalogCapability.name, {
|
|
96716
|
+
descriptors: [...cameraStreamCatalog(this.session)],
|
|
96717
|
+
lastFetchedAt: Date.now()
|
|
96718
|
+
});
|
|
96719
|
+
}
|
|
96720
|
+
/**
|
|
96721
|
+
* `battery.wakeForStream`: open the monitor inside the caller's bound. An
|
|
96722
|
+
* open that outlives the bound keeps running — the next play joins it.
|
|
96723
|
+
*/
|
|
96724
|
+
async wakeForStream(timeoutMs) {
|
|
96725
|
+
const startedAt = Date.now();
|
|
96726
|
+
if (this.session !== null) return {
|
|
96727
|
+
awoke: true,
|
|
96728
|
+
durationMs: 0
|
|
96729
|
+
};
|
|
96730
|
+
const opened = this.openStream().then(() => "open", () => "failed");
|
|
96731
|
+
const bound = new Promise((resolve) => {
|
|
96732
|
+
setTimeout(() => resolve("timeout"), timeoutMs).unref?.();
|
|
96733
|
+
});
|
|
96734
|
+
const outcome = await Promise.race([opened, bound]);
|
|
96735
|
+
if (outcome === "timeout") this.ctx.logger.info("robot camera: the wake outlived its bound — the open keeps running", {
|
|
96736
|
+
tags: { deviceId: this.id },
|
|
96737
|
+
meta: { timeoutMs }
|
|
96738
|
+
});
|
|
96739
|
+
return {
|
|
96740
|
+
awoke: outcome === "open",
|
|
96741
|
+
durationMs: Date.now() - startedAt
|
|
96742
|
+
};
|
|
96743
|
+
}
|
|
96744
|
+
openStream() {
|
|
96745
|
+
if (this.opening !== null) return this.opening;
|
|
96746
|
+
const run = (async () => {
|
|
96747
|
+
const startedAt = Date.now();
|
|
96748
|
+
try {
|
|
96749
|
+
const handle = await (await this.requireController()).open();
|
|
96750
|
+
this.session = { url: handle.rtmpUrl };
|
|
96751
|
+
this.idleCount = 0;
|
|
96752
|
+
this.publishBattery();
|
|
96753
|
+
this.publishCatalog();
|
|
96754
|
+
this.startIdleWatch();
|
|
96755
|
+
this.ctx.logger.info("robot camera: monitor open, relay published to the catalog", {
|
|
96756
|
+
tags: { deviceId: this.id },
|
|
96757
|
+
meta: { openMs: Date.now() - startedAt }
|
|
96758
|
+
});
|
|
96759
|
+
} catch (err) {
|
|
96760
|
+
this.ctx.logger.warn("robot camera: monitor could not be opened", {
|
|
96761
|
+
tags: { deviceId: this.id },
|
|
96762
|
+
meta: {
|
|
96763
|
+
openMs: Date.now() - startedAt,
|
|
96764
|
+
error: errMsg(err)
|
|
96765
|
+
}
|
|
96766
|
+
});
|
|
96767
|
+
throw err;
|
|
96768
|
+
}
|
|
96769
|
+
})();
|
|
96770
|
+
this.opening = run.finally(() => {
|
|
96771
|
+
this.opening = null;
|
|
96772
|
+
});
|
|
96773
|
+
return this.opening;
|
|
96774
|
+
}
|
|
96775
|
+
async closeStream(reason) {
|
|
96776
|
+
this.stopIdleWatch();
|
|
96777
|
+
if (this.session === null) return;
|
|
96778
|
+
this.session = null;
|
|
96779
|
+
this.publishBattery();
|
|
96780
|
+
this.publishCatalog();
|
|
96781
|
+
this.ctx.logger.info("robot camera: monitor closed", {
|
|
96782
|
+
tags: { deviceId: this.id },
|
|
96783
|
+
meta: { reason }
|
|
96784
|
+
});
|
|
96785
|
+
const pending = this.controller;
|
|
96786
|
+
if (pending === null) return;
|
|
96787
|
+
await pending.then((controller) => controller.close()).catch((err) => {
|
|
96788
|
+
this.ctx.logger.debug("robot camera: close reported an error", {
|
|
96789
|
+
tags: { deviceId: this.id },
|
|
96790
|
+
meta: {
|
|
96791
|
+
reason,
|
|
96792
|
+
error: errMsg(err)
|
|
96793
|
+
}
|
|
96794
|
+
});
|
|
96795
|
+
});
|
|
96796
|
+
}
|
|
96797
|
+
startIdleWatch() {
|
|
96798
|
+
this.stopIdleWatch();
|
|
96799
|
+
this.idleTimer = setInterval(() => {
|
|
96800
|
+
this.checkIdle();
|
|
96801
|
+
}, IDLE_CHECK_MS);
|
|
96802
|
+
this.idleTimer.unref?.();
|
|
96803
|
+
}
|
|
96804
|
+
stopIdleWatch() {
|
|
96805
|
+
if (this.idleTimer !== null) clearInterval(this.idleTimer);
|
|
96806
|
+
this.idleTimer = null;
|
|
96807
|
+
this.idleCount = 0;
|
|
96808
|
+
}
|
|
96809
|
+
/** Ask the broker whether anybody is still watching; no broker at all counts as idle. */
|
|
96810
|
+
async checkIdle() {
|
|
96811
|
+
if (this.session === null) return;
|
|
96812
|
+
const clients = await this.ctx.api.streamBroker.listClients.query({ brokerId: `${String(this.id)}/${CAMERA_STREAM_ID}` }).catch(() => null);
|
|
96813
|
+
const busy = clients !== null && isBrokerBusy(clients);
|
|
96814
|
+
this.idleCount = nextIdleCount(this.idleCount, busy);
|
|
96815
|
+
if (shouldCloseForIdle(this.idleCount)) await this.closeStream("idle");
|
|
96816
|
+
}
|
|
96817
|
+
buildNavigationProvider() {
|
|
96818
|
+
return {
|
|
96819
|
+
getStatus: async () => this.runtimeState.getCapState(CAP_NAME$12) ?? NAV_COLD_START,
|
|
96820
|
+
listActions: async () => dreameNavigationCatalog(resolveDisabledEntryIds(this.overrides())),
|
|
96821
|
+
getFeatures: async () => this.buildFeatures(),
|
|
96822
|
+
move: async ({ pan, tilt, speed }) => {
|
|
96823
|
+
await this.dispatch(moveCall({
|
|
96824
|
+
pan,
|
|
96825
|
+
tilt,
|
|
96826
|
+
speed
|
|
96827
|
+
}));
|
|
96828
|
+
},
|
|
96829
|
+
stop: async () => {
|
|
96830
|
+
await this.dispatch(stopCall());
|
|
96831
|
+
},
|
|
96832
|
+
goToPoint: async ({ x, y }) => {
|
|
96833
|
+
await this.dispatch(goToPointCall({
|
|
96834
|
+
x,
|
|
96835
|
+
y
|
|
96836
|
+
}));
|
|
96837
|
+
},
|
|
96838
|
+
runAction: async ({ actionId }) => {
|
|
96839
|
+
await this.dispatch(actionCall(actionId));
|
|
96840
|
+
this.applyOptimisticAction(actionId);
|
|
96841
|
+
},
|
|
96842
|
+
playSound: async ({ soundId }) => {
|
|
96843
|
+
await this.dispatch(playSoundCall(soundId));
|
|
96844
|
+
},
|
|
96845
|
+
setLightOn: async ({ on }) => {
|
|
96846
|
+
await this.dispatch(lightOnCall(on));
|
|
96847
|
+
this.patchStatus({ flash: on });
|
|
96848
|
+
},
|
|
96849
|
+
setLightMode: async ({ mode, level }) => {
|
|
96850
|
+
await this.dispatch(lightModeCall(mode, level));
|
|
96851
|
+
this.patchStatus(mode === "manual" && level !== void 0 ? {
|
|
96852
|
+
lightMode: mode,
|
|
96853
|
+
lightLevel: level
|
|
96854
|
+
} : { lightMode: mode });
|
|
96855
|
+
},
|
|
96856
|
+
setLightLevel: async ({ level }) => {
|
|
96857
|
+
await this.dispatch(lightLevelCall(level));
|
|
96858
|
+
this.patchStatus({
|
|
96859
|
+
lightMode: "manual",
|
|
96860
|
+
lightLevel: level
|
|
96861
|
+
});
|
|
96862
|
+
}
|
|
96863
|
+
};
|
|
96864
|
+
}
|
|
96865
|
+
recomputeSlice() {
|
|
96866
|
+
const handle = this.resolveHandle();
|
|
96867
|
+
if (handle === null) return;
|
|
96868
|
+
this.lastContactAt = Date.now();
|
|
96869
|
+
this.publishBattery();
|
|
96870
|
+
const prev = this.runtimeState.getCapState(CAP_NAME$12) ?? NAV_COLD_START;
|
|
96871
|
+
const readback = handle instanceof VacuumDevice ? decodeNavigationReadback((siid, piid) => handle.getProperty(siid, piid)?.value) : {};
|
|
96872
|
+
const following = handle instanceof VacuumDevice ? decodeFollowing(handle.status) : null;
|
|
96873
|
+
this.runtimeState.setCapState(CAP_NAME$12, navigationSlice(prev, {
|
|
96874
|
+
...readback,
|
|
96875
|
+
...following !== null ? { following } : {},
|
|
96876
|
+
mode: following === true ? "following" : this.deriveMode(handle)
|
|
96877
|
+
}, Date.now()));
|
|
96878
|
+
}
|
|
96879
|
+
controller = null;
|
|
96880
|
+
controllerHandle = null;
|
|
96881
|
+
/**
|
|
96882
|
+
* The typed controller, built once per live handle (the build resolves the
|
|
96883
|
+
* cloud video profile, so it is a promise shared by every caller); rebuilt
|
|
96884
|
+
* if the handle changes. Never `open()`ed here.
|
|
96885
|
+
*/
|
|
96886
|
+
requireController() {
|
|
96887
|
+
const handle = this.requireHandle();
|
|
96888
|
+
if (!(handle instanceof VacuumDevice)) throw new Error("DreameCameraDevice: the robot handle is not a vacuum — no camera controller");
|
|
96889
|
+
if (this.controller === null || this.controllerHandle !== handle) {
|
|
96890
|
+
const accessCode = this.config.get("navigationAccessCode");
|
|
96891
|
+
this.controllerHandle = handle;
|
|
96892
|
+
this.controller = handle.createCameraController({
|
|
96893
|
+
...accessCode !== void 0 && accessCode !== "" ? { accessCode } : {},
|
|
96894
|
+
onKeepAliveError: (err) => {
|
|
96895
|
+
this.ctx.logger.warn("robot camera: relay keep-alive failed — closing the monitor", {
|
|
96896
|
+
tags: { deviceId: this.id },
|
|
96897
|
+
meta: { error: errMsg(err) }
|
|
96898
|
+
});
|
|
96899
|
+
this.closeStream("keep-alive");
|
|
96900
|
+
}
|
|
96901
|
+
}).catch((err) => {
|
|
96902
|
+
this.controller = null;
|
|
96903
|
+
this.controllerHandle = null;
|
|
96904
|
+
throw err;
|
|
96905
|
+
});
|
|
96906
|
+
}
|
|
96907
|
+
return this.controller;
|
|
96908
|
+
}
|
|
96909
|
+
/** Perform a translated navigation call on the typed controller. */
|
|
96910
|
+
async dispatch(call) {
|
|
96911
|
+
await runControllerCall(await this.requireController(), call);
|
|
96912
|
+
}
|
|
96913
|
+
async removeDevice() {
|
|
96914
|
+
await super.removeDevice();
|
|
96915
|
+
await this.closeStream("removed");
|
|
96916
|
+
const pending = this.controller;
|
|
96917
|
+
this.controller = null;
|
|
96918
|
+
this.controllerHandle = null;
|
|
96919
|
+
if (pending !== null) await pending.then((controller) => controller.close()).catch(() => void 0);
|
|
96920
|
+
}
|
|
96921
|
+
/** Per-device general-primitive feature flags: the defaults, then this device's overrides. */
|
|
96922
|
+
buildFeatures() {
|
|
96923
|
+
return resolveNavigationFeatures(this.overrides());
|
|
96924
|
+
}
|
|
96925
|
+
/** This device's navigation overrides, straight from its persisted config. */
|
|
96926
|
+
overrides() {
|
|
96927
|
+
const features = this.config.get("navigationFeatures");
|
|
96928
|
+
const disabledEntryIds = this.config.get("navigationDisabledEntryIds");
|
|
96929
|
+
return {
|
|
96930
|
+
...features !== void 0 ? { features } : {},
|
|
96931
|
+
...disabledEntryIds !== void 0 ? { disabledEntryIds } : {}
|
|
96932
|
+
};
|
|
96933
|
+
}
|
|
96934
|
+
/** Map the shared vacuum lifecycle state to a coarse navigation mode. */
|
|
96935
|
+
deriveMode(handle) {
|
|
96936
|
+
if (!(handle instanceof VacuumDevice)) return "unknown";
|
|
96937
|
+
switch (mapMiotStateToVacuumState(handle.status)) {
|
|
96938
|
+
case "cleaning": return "cleaning";
|
|
96939
|
+
case "paused": return "paused";
|
|
96940
|
+
case "returning": return "returning";
|
|
96941
|
+
case "idle":
|
|
96942
|
+
case "docked":
|
|
96943
|
+
case "drying": return "idle";
|
|
96944
|
+
default: return "unknown";
|
|
96945
|
+
}
|
|
96946
|
+
}
|
|
96947
|
+
/**
|
|
96948
|
+
* Optimistically reflect a just-issued action in the status slice so the UI
|
|
96949
|
+
* updates immediately (there is no confirmed read-back yet).
|
|
96950
|
+
*/
|
|
96951
|
+
applyOptimisticAction(actionId) {
|
|
96952
|
+
switch (actionId) {
|
|
96953
|
+
case "flashOn":
|
|
96954
|
+
this.patchStatus({ flash: true });
|
|
96955
|
+
return;
|
|
96956
|
+
case "flashOff":
|
|
96957
|
+
this.patchStatus({ flash: false });
|
|
96958
|
+
return;
|
|
96959
|
+
case "personFollow":
|
|
96960
|
+
this.patchStatus({
|
|
96961
|
+
following: true,
|
|
96962
|
+
mode: "following"
|
|
96963
|
+
});
|
|
96964
|
+
return;
|
|
96965
|
+
case "stop":
|
|
96966
|
+
this.patchStatus({ following: false });
|
|
96967
|
+
return;
|
|
96968
|
+
default: return;
|
|
96969
|
+
}
|
|
96970
|
+
}
|
|
96971
|
+
/**
|
|
96972
|
+
* Merge a patch into the status slice. Written THROUGH `runtimeState` — never a
|
|
96973
|
+
* parallel private cache (CLAUDE.md).
|
|
96974
|
+
*/
|
|
96975
|
+
patchStatus(patch) {
|
|
96976
|
+
const prev = this.runtimeState.getCapState(CAP_NAME$12) ?? NAV_COLD_START;
|
|
96977
|
+
this.runtimeState.setCapState(CAP_NAME$12, navigationSlice(prev, patch, Date.now()));
|
|
96978
|
+
}
|
|
96979
|
+
};
|
|
96980
|
+
//#endregion
|
|
94326
96981
|
//#region src/devices/dreame-enum-sensor-device.ts
|
|
94327
96982
|
var CAP_NAME$11 = "enum-sensor";
|
|
94328
96983
|
/** One persisted enum option: the MIoT int value + its display label. */
|
|
@@ -94547,12 +97202,12 @@ function mapMowerStatusToActivity(status) {
|
|
|
94547
97202
|
}
|
|
94548
97203
|
}
|
|
94549
97204
|
/** Clamp a raw battery reading (0..100 from the library) to the slice field. */
|
|
94550
|
-
function clampBatteryLevel
|
|
97205
|
+
function clampBatteryLevel(value) {
|
|
94551
97206
|
if (value === null || !Number.isFinite(value)) return null;
|
|
94552
97207
|
return Math.max(0, Math.min(100, value));
|
|
94553
97208
|
}
|
|
94554
97209
|
/** Clamp a 0..100 percentage (mowing progress), null-safe. */
|
|
94555
|
-
function clampPercent
|
|
97210
|
+
function clampPercent(value) {
|
|
94556
97211
|
if (value === null || !Number.isFinite(value)) return null;
|
|
94557
97212
|
return Math.max(0, Math.min(100, value));
|
|
94558
97213
|
}
|
|
@@ -94726,11 +97381,11 @@ var DreameMowerDevice = class extends DreameChildDevice {
|
|
|
94726
97381
|
if (mower === null) return;
|
|
94727
97382
|
const status = mower.status;
|
|
94728
97383
|
const code = mower.faultRaw;
|
|
94729
|
-
const livePct = clampPercent
|
|
97384
|
+
const livePct = clampPercent(mower.mowingProgressPct);
|
|
94730
97385
|
const prevPct = this.runtimeState.getCapState(CAP_NAME$9)?.progressPercent;
|
|
94731
97386
|
const slice = {
|
|
94732
97387
|
activity: mapMowerStatusToActivity(status),
|
|
94733
|
-
batteryLevel: clampBatteryLevel
|
|
97388
|
+
batteryLevel: clampBatteryLevel(mower.battery),
|
|
94734
97389
|
progressPercent: livePct ?? prevPct ?? null,
|
|
94735
97390
|
currentCode: code,
|
|
94736
97391
|
currentCodeLabel: code !== null ? MowerFault[code] ?? null : null,
|
|
@@ -95373,204 +98028,21 @@ var DreameSwitchDevice = class extends DreameChildDevice {
|
|
|
95373
98028
|
});
|
|
95374
98029
|
}
|
|
95375
98030
|
};
|
|
95376
|
-
|
|
95377
|
-
|
|
95378
|
-
|
|
95379
|
-
* Pure decode helpers for the Dreame vacuum. Translate the library's MIoT enum
|
|
95380
|
-
* values into the camstack `vacuum-control` slice shape. Unit-tested in
|
|
95381
|
-
* isolation (no facade, no network) — mirrors `homematic-decode.ts`.
|
|
95382
|
-
*/
|
|
95383
|
-
/**
|
|
95384
|
-
* Map a Dreame {@link MiotState} (siid 2 piid 1) onto the camstack canonical
|
|
95385
|
-
* `VacuumState` lifecycle (`idle` / `cleaning` / `paused` / `returning` /
|
|
95386
|
-
* `docked` / `error`). A `null` state (not yet observed) settles on `idle`.
|
|
95387
|
-
*
|
|
95388
|
-
* The MIoT state space is large (~50 values); we collapse it onto the six
|
|
95389
|
-
* canonical states by family. Anything unrecognised falls through to `idle`
|
|
95390
|
-
* (the safe neutral) so a firmware-added code never throws.
|
|
95391
|
-
*/
|
|
95392
|
-
function mapMiotStateToVacuumState(state) {
|
|
95393
|
-
if (state === null) return "idle";
|
|
95394
|
-
switch (state) {
|
|
95395
|
-
case MiotState.Charging:
|
|
95396
|
-
case MiotState.ChargingComplete:
|
|
95397
|
-
case MiotState.IntelligentCharging:
|
|
95398
|
-
case MiotState.CleanWashboardBase:
|
|
95399
|
-
case MiotState.AutoRepairBase:
|
|
95400
|
-
case MiotState.WaterSupplyDrainTest:
|
|
95401
|
-
case MiotState.CleanMopRefillWater:
|
|
95402
|
-
case MiotState.AutoEmptying:
|
|
95403
|
-
case MiotState.Emptying:
|
|
95404
|
-
case MiotState.Draining:
|
|
95405
|
-
case MiotState.AutoWaterDraining:
|
|
95406
|
-
case MiotState.Sanitizing: return "docked";
|
|
95407
|
-
case MiotState.MopDrying:
|
|
95408
|
-
case MiotState.DustBagDrying:
|
|
95409
|
-
case MiotState.SanitizingWithDry: return "drying";
|
|
95410
|
-
case MiotState.ReturningToCharge:
|
|
95411
|
-
case MiotState.ReturningToWash:
|
|
95412
|
-
case MiotState.ReturnInstallMop:
|
|
95413
|
-
case MiotState.ReturnRemoveMop:
|
|
95414
|
-
case MiotState.ReturnToEmpty:
|
|
95415
|
-
case MiotState.ReturningToDrain: return "returning";
|
|
95416
|
-
case MiotState.Paused:
|
|
95417
|
-
case MiotState.PausedAlt:
|
|
95418
|
-
case MiotState.PausedCleaning:
|
|
95419
|
-
case MiotState.DustBagDryingPaused:
|
|
95420
|
-
case MiotState.CameraMonitoringPaused:
|
|
95421
|
-
case MiotState.FindingPetPaused:
|
|
95422
|
-
case MiotState.InitialDeepCleanPaused:
|
|
95423
|
-
case MiotState.ChangingMopPaused:
|
|
95424
|
-
case MiotState.FloorMaintainingPaused: return "paused";
|
|
95425
|
-
case MiotState.Standby:
|
|
95426
|
-
case MiotState.WaitingForTask: return "idle";
|
|
95427
|
-
default: return "cleaning";
|
|
95428
|
-
}
|
|
95429
|
-
}
|
|
95430
|
-
/**
|
|
95431
|
-
* The camstack `fanSpeed` token for a Dreame {@link SuctionLevel} — the enum
|
|
95432
|
-
* member NAME, lower-cased (`Quiet` → `'quiet'`). Returns `null` for an
|
|
95433
|
-
* unobserved / unknown level.
|
|
95434
|
-
*/
|
|
95435
|
-
function suctionLevelToken(level) {
|
|
95436
|
-
if (level === null) return null;
|
|
95437
|
-
const name = SuctionLevel[level];
|
|
95438
|
-
return typeof name === "string" ? name.toLowerCase() : null;
|
|
95439
|
-
}
|
|
95440
|
-
/**
|
|
95441
|
-
* Resolve a camstack `fanSpeed` token back to the Dreame {@link SuctionLevel}
|
|
95442
|
-
* enum value, validated against the model's `supported` levels. Returns null
|
|
95443
|
-
* when the token doesn't match a supported level (the caller rejects the set).
|
|
95444
|
-
*/
|
|
95445
|
-
function tokenToSuctionLevel(token, supported) {
|
|
95446
|
-
const wanted = token.trim().toLowerCase();
|
|
95447
|
-
for (const level of supported) if (suctionLevelToken(level) === wanted) return level;
|
|
95448
|
-
return null;
|
|
95449
|
-
}
|
|
95450
|
-
/** The list of accepted `fanSpeed` tokens for a model's supported suction set. */
|
|
95451
|
-
function supportedFanSpeedTokens(supported) {
|
|
95452
|
-
const tokens = [];
|
|
95453
|
-
for (const level of supported) {
|
|
95454
|
-
const token = suctionLevelToken(level);
|
|
95455
|
-
if (token !== null) tokens.push(token);
|
|
95456
|
-
}
|
|
95457
|
-
return tokens;
|
|
95458
|
-
}
|
|
95459
|
-
/**
|
|
95460
|
-
* Clamp a raw battery reading (already a 0..100 percentage from the library)
|
|
95461
|
-
* to the `vacuum-control` slice's `batteryLevel` field. Returns null for a
|
|
95462
|
-
* null/NaN/out-of-band reading so the slice stays valid (the schema rejects
|
|
95463
|
-
* a number outside 0..100).
|
|
95464
|
-
*/
|
|
95465
|
-
function clampBatteryLevel(value) {
|
|
95466
|
-
if (value === null || !Number.isFinite(value)) return null;
|
|
95467
|
-
return Math.max(0, Math.min(100, value));
|
|
95468
|
-
}
|
|
95469
|
-
/** Clamp a 0..100 percentage (progress, coverage, …), null-safe. */
|
|
95470
|
-
function clampPercent(value) {
|
|
95471
|
-
if (value === null || !Number.isFinite(value)) return null;
|
|
95472
|
-
return Math.max(0, Math.min(100, value));
|
|
95473
|
-
}
|
|
95474
|
-
/**
|
|
95475
|
-
* Clean-water tank `status`: prefer the richer clean-water-tank-status (27/1),
|
|
95476
|
-
* fall back to the coarser water-tank presence (4/6). `null` when neither maps
|
|
95477
|
-
* to a known good/low reading (NOT_INSTALLED / CHECKING / unknown). Pure.
|
|
95478
|
-
*/
|
|
95479
|
-
function cleanWaterTankStatus(props) {
|
|
95480
|
-
if (props.cleanWaterTankStatus !== null) {
|
|
95481
|
-
if (props.cleanWaterTankStatus === 0) return "ok";
|
|
95482
|
-
if (props.cleanWaterTankStatus === 2) return "low";
|
|
95483
|
-
return null;
|
|
95484
|
-
}
|
|
95485
|
-
if (props.waterTank === 1 || props.waterTank === 10) return "ok";
|
|
95486
|
-
return null;
|
|
95487
|
-
}
|
|
95488
|
-
/** Detergent tank `status` from detergent-status (27/4). Pure. */
|
|
95489
|
-
function detergentTankStatus(value) {
|
|
95490
|
-
if (value === 0) return "ok";
|
|
95491
|
-
if (value === 2) return "low";
|
|
95492
|
-
return null;
|
|
95493
|
-
}
|
|
95494
|
-
/** Dust-bin `status` from dust-collection (15/3); OVER_USE reads as `full`. Pure. */
|
|
95495
|
-
function dustBinTankStatus(value) {
|
|
95496
|
-
if (value === 1) return "ok";
|
|
95497
|
-
if (value === 2) return "full";
|
|
95498
|
-
return null;
|
|
95499
|
-
}
|
|
95500
|
-
/**
|
|
95501
|
-
* The four `vacuum-control` tank fields (clean-water / dirty-water / detergent /
|
|
95502
|
-
* dust-bin) decoded from the model's {@link VacuumCapabilities}, with the
|
|
95503
|
-
* discrete `status` populated from the live MIoT enum props when supplied. Pure.
|
|
95504
|
-
*
|
|
95505
|
-
* The nodedreame library exposes NO tank FILL-LEVEL telemetry — the cloud shadow
|
|
95506
|
-
* (verified against a real `dreame.vacuum.r2538z`) carries only the water-VOLUME
|
|
95507
|
-
* *setting* (a mop-flow level, NOT a tank fill). We therefore never fabricate a
|
|
95508
|
-
* `level`; `level` is always `null`. The cap contract is honoured on PRESENCE,
|
|
95509
|
-
* with `status` derived from the tank-status enums:
|
|
95510
|
-
*
|
|
95511
|
-
* - a field is `null` when the hardware HAS NO such tank (capability flags), and
|
|
95512
|
-
* - `{ level: null, status }` when the tank EXISTS — `status` from the enum
|
|
95513
|
-
* prop ('ok' / 'low' / 'full'), or `null` when the device reports none.
|
|
95514
|
-
*
|
|
95515
|
-
* Presence is derived from the capability record:
|
|
95516
|
-
* - clean-water ← `canMop` (a mopping robot carries a clean-water reservoir)
|
|
95517
|
-
* - dirty-water ← `canMopWash` (a self-wash dock has a recovery/dirty tank)
|
|
95518
|
-
* - detergent ← `hasDetergentReservoir`
|
|
95519
|
-
* - dust-bin ← `canAutoEmpty` (an auto-empty robot has an onboard dust bin)
|
|
95520
|
-
*/
|
|
95521
|
-
function decodeVacuumTanks(caps, props) {
|
|
95522
|
-
const tank = (present, status) => present ? {
|
|
95523
|
-
level: null,
|
|
95524
|
-
status
|
|
95525
|
-
} : null;
|
|
98031
|
+
/** `null` = nothing learned (keep the previous slice). */
|
|
98032
|
+
function networkLinkFromBars(bars, now) {
|
|
98033
|
+
if (bars === null || !Number.isFinite(bars)) return null;
|
|
95526
98034
|
return {
|
|
95527
|
-
|
|
95528
|
-
|
|
95529
|
-
|
|
95530
|
-
dustBin: tank(Boolean(caps?.canAutoEmpty), props ? dustBinTankStatus(props.dustCollection) : null)
|
|
95531
|
-
};
|
|
95532
|
-
}
|
|
95533
|
-
/**
|
|
95534
|
-
* Clamp a raw consumable percentage to `[0, 100]`, or return `null` when the
|
|
95535
|
-
* value is absent / non-finite. The library reports remaining life as a 0..100
|
|
95536
|
-
* integer; we clamp defensively so an out-of-range firmware value never violates
|
|
95537
|
-
* the cap schema.
|
|
95538
|
-
*/
|
|
95539
|
-
function clampConsumablePct(value) {
|
|
95540
|
-
if (value === null || !Number.isFinite(value)) return null;
|
|
95541
|
-
return Math.max(0, Math.min(100, value));
|
|
95542
|
-
}
|
|
95543
|
-
/**
|
|
95544
|
-
* Build the `consumables` cap runtime-state slice from a live {@link VacuumDevice}
|
|
95545
|
-
* handle. The returned object satisfies the cap's `runtimeState` schema
|
|
95546
|
-
* (`ConsumablesStatusSchema.extend({ lastFetchedAt: z.number() })`).
|
|
95547
|
-
*
|
|
95548
|
-
* PRESENCE-driven (mirrors HA): the library's `supportedConsumables` enumerates
|
|
95549
|
-
* ONLY the consumables this model reports (a present MIoT life property) — main/
|
|
95550
|
-
* side brush, filter, sensor, tank-filter, mop-pad, silver-ion, detergent,
|
|
95551
|
-
* squeegee, dust-bag — each with its remaining-life % and whether a reset action
|
|
95552
|
-
* exists. No hardcoded per-model list: a consumable the device does not expose
|
|
95553
|
-
* never appears, and a `resettable` item drives the real MIoT reset action.
|
|
95554
|
-
*/
|
|
95555
|
-
function buildVacuumConsumables(vacuum) {
|
|
95556
|
-
const items = vacuum.supportedConsumables.map((c) => ({
|
|
95557
|
-
key: c.key,
|
|
95558
|
-
label: c.label,
|
|
95559
|
-
level: clampConsumablePct(c.leftPct),
|
|
95560
|
-
status: null,
|
|
95561
|
-
lastResetAt: null,
|
|
95562
|
-
resettable: c.resettable
|
|
95563
|
-
}));
|
|
95564
|
-
const now = Date.now();
|
|
95565
|
-
return {
|
|
95566
|
-
items: [...items],
|
|
95567
|
-
lastChangedAt: now,
|
|
95568
|
-
lastFetchedAt: now
|
|
98035
|
+
type: "wifi",
|
|
98036
|
+
signalPercent: bars === 0 ? null : signalPercentFromBars(bars, 4),
|
|
98037
|
+
lastUpdated: now
|
|
95569
98038
|
};
|
|
95570
98039
|
}
|
|
95571
98040
|
//#endregion
|
|
95572
98041
|
//#region src/devices/dreame-vacuum-device.ts
|
|
95573
98042
|
var CAP_NAME$1 = "vacuum-control";
|
|
98043
|
+
var NETWORK_LINK_CAP_NAME = "network-link";
|
|
98044
|
+
/** The Wi-Fi signal map is a cloud round trip; the bars at the dock barely move. */
|
|
98045
|
+
var NETWORK_LINK_REFRESH_MS = 30 * 6e4;
|
|
95574
98046
|
var CONSUMABLES_CAP_NAME = "consumables";
|
|
95575
98047
|
var CONTROL_COLD_START = {
|
|
95576
98048
|
state: "idle",
|
|
@@ -95626,6 +98098,8 @@ var DreameVacuumDevice = class extends DreameChildDevice {
|
|
|
95626
98098
|
}
|
|
95627
98099
|
async onActivate() {
|
|
95628
98100
|
await super.onActivate();
|
|
98101
|
+
this.refreshNetworkLink("activate");
|
|
98102
|
+
this.networkLinkTimer = setInterval(() => void this.refreshNetworkLink("periodic"), NETWORK_LINK_REFRESH_MS);
|
|
95629
98103
|
const vacuum = this.resolveVacuum();
|
|
95630
98104
|
if (vacuum !== null) {
|
|
95631
98105
|
vacuum.refreshFromCache().catch((err) => {
|
|
@@ -95649,11 +98123,60 @@ var DreameVacuumDevice = class extends DreameChildDevice {
|
|
|
95649
98123
|
}
|
|
95650
98124
|
}
|
|
95651
98125
|
/** Narrow the generic handle to the library's typed {@link DreameVacuum}, or null. */
|
|
98126
|
+
networkLinkTimer = null;
|
|
98127
|
+
networkLinkReported = false;
|
|
98128
|
+
networkLinkFailureWarned = false;
|
|
98129
|
+
/** `network-link`: the read path serves the slice; `refreshNetworkLink` writes it. */
|
|
98130
|
+
registerNetworkLink() {
|
|
98131
|
+
this.ctx.registerNativeCap(networkLinkCapability, { getStatus: async () => this.runtimeState.getCapState(NETWORK_LINK_CAP_NAME) ?? NETWORK_LINK_UNKNOWN });
|
|
98132
|
+
if (this.runtimeState.getCapState(NETWORK_LINK_CAP_NAME) === null) this.runtimeState.setCapState(NETWORK_LINK_CAP_NAME, NETWORK_LINK_UNKNOWN);
|
|
98133
|
+
}
|
|
98134
|
+
/**
|
|
98135
|
+
* Read the bars at the robot's position off its Wi-Fi signal map. Bounded,
|
|
98136
|
+
* best-effort; a failure keeps the last slice. The first read per device is
|
|
98137
|
+
* said at INFO with the raw bars, the first failure at WARN.
|
|
98138
|
+
*/
|
|
98139
|
+
async refreshNetworkLink(reason) {
|
|
98140
|
+
const vacuum = this.resolveVacuum();
|
|
98141
|
+
if (vacuum === null) return;
|
|
98142
|
+
try {
|
|
98143
|
+
const bars = await vacuum.getCurrentSignal({ maxWaitMs: 2e4 });
|
|
98144
|
+
const next = networkLinkFromBars(bars, Date.now());
|
|
98145
|
+
this.ctx.logger[this.networkLinkReported ? "debug" : "info"]("network link read", {
|
|
98146
|
+
tags: { deviceId: this.id },
|
|
98147
|
+
meta: {
|
|
98148
|
+
reason,
|
|
98149
|
+
rawBars: bars,
|
|
98150
|
+
...next ?? {}
|
|
98151
|
+
}
|
|
98152
|
+
});
|
|
98153
|
+
this.networkLinkReported = true;
|
|
98154
|
+
if (next !== null) this.runtimeState.setCapState(NETWORK_LINK_CAP_NAME, next);
|
|
98155
|
+
} catch (err) {
|
|
98156
|
+
const level = this.networkLinkFailureWarned ? "debug" : "warn";
|
|
98157
|
+
this.networkLinkFailureWarned = true;
|
|
98158
|
+
this.ctx.logger[level]("network link read failed — keeping the last slice", {
|
|
98159
|
+
tags: { deviceId: this.id },
|
|
98160
|
+
meta: {
|
|
98161
|
+
reason,
|
|
98162
|
+
error: err instanceof Error ? err.message : String(err)
|
|
98163
|
+
}
|
|
98164
|
+
});
|
|
98165
|
+
}
|
|
98166
|
+
}
|
|
98167
|
+
async removeDevice() {
|
|
98168
|
+
if (this.networkLinkTimer !== null) {
|
|
98169
|
+
clearInterval(this.networkLinkTimer);
|
|
98170
|
+
this.networkLinkTimer = null;
|
|
98171
|
+
}
|
|
98172
|
+
await super.removeDevice();
|
|
98173
|
+
}
|
|
95652
98174
|
resolveVacuum() {
|
|
95653
98175
|
const handle = this.resolveHandle();
|
|
95654
98176
|
return handle instanceof VacuumDevice ? handle : null;
|
|
95655
98177
|
}
|
|
95656
98178
|
registerControlCap() {
|
|
98179
|
+
this.registerNetworkLink();
|
|
95657
98180
|
this.ctx.registerNativeCap(vacuumControlCapability, {
|
|
95658
98181
|
getStatus: async () => this.runtimeState.getCapState(CAP_NAME$1) ?? CONTROL_COLD_START,
|
|
95659
98182
|
start: async () => {
|
|
@@ -95710,14 +98233,14 @@ var DreameVacuumDevice = class extends DreameChildDevice {
|
|
|
95710
98233
|
const tanks = decodeVacuumTanks(vacuum.vacuumCapabilities, this.readTankProps());
|
|
95711
98234
|
const controlSlice = {
|
|
95712
98235
|
state: mapMiotStateToVacuumState(vacuum.status),
|
|
95713
|
-
batteryLevel: clampBatteryLevel(vacuum.battery),
|
|
98236
|
+
batteryLevel: clampBatteryLevel$1(vacuum.battery),
|
|
95714
98237
|
fanSpeed: suctionLevelToken(vacuum.suction),
|
|
95715
98238
|
availableFanSpeeds: supportedFanSpeedTokens(vacuum.supportedSuctionLevels),
|
|
95716
98239
|
cleanWater: tanks.cleanWater,
|
|
95717
98240
|
dirtyWater: tanks.dirtyWater,
|
|
95718
98241
|
detergent: tanks.detergent,
|
|
95719
98242
|
dustBin: tanks.dustBin,
|
|
95720
|
-
progressPercent: clampPercent(vacuum.taskProgressPct),
|
|
98243
|
+
progressPercent: clampPercent$1(vacuum.taskProgressPct),
|
|
95721
98244
|
errorCode: vacuum.errorCode,
|
|
95722
98245
|
errorLabel: vacuum.errorCode ? `Error ${vacuum.errorCode}` : null,
|
|
95723
98246
|
lastChangedAt: Date.now()
|
|
@@ -96043,6 +98566,15 @@ function dreameMowerMapSelectChildFactory(ctx) {
|
|
|
96043
98566
|
return new DreameMowerMapSelectDevice(ctx);
|
|
96044
98567
|
}
|
|
96045
98568
|
/**
|
|
98569
|
+
* Factory for the CAMERA + NAVIGATION child (a `DeviceType.Camera` grouped under
|
|
98570
|
+
* the Container), spawned only for camera-capable vacuums. Owns the `navigation`
|
|
98571
|
+
* cap (drive / actions / sounds / fill light). Selected directly by the
|
|
98572
|
+
* Container's `getAccessoryChildren()` for the camera child spec.
|
|
98573
|
+
*/
|
|
98574
|
+
function dreameCameraChildFactory(ctx) {
|
|
98575
|
+
return new DreameCameraDevice(ctx);
|
|
98576
|
+
}
|
|
98577
|
+
/**
|
|
96046
98578
|
* Factory for an AI-obstacle Switch child (a `DeviceType.Switch` grouped under
|
|
96047
98579
|
* the Container). Like {@link dreameMapChildFactory}, selected directly by the
|
|
96048
98580
|
* Container's `getAccessoryChildren()` — the child config carries the
|
|
@@ -96214,6 +98746,20 @@ var DreameContainerDevice = class extends BaseDevice {
|
|
|
96214
98746
|
factory: dreameMapChildFactory
|
|
96215
98747
|
});
|
|
96216
98748
|
}
|
|
98749
|
+
if (kind === "vacuum" && this.modelSupportsCamera()) {
|
|
98750
|
+
const cameraMeta = {
|
|
98751
|
+
type: DeviceType.Camera,
|
|
98752
|
+
name: this.name.length > 0 ? `${this.name} Camera` : "Camera",
|
|
98753
|
+
linkDeviceId: this.id,
|
|
98754
|
+
...this.integrationId !== void 0 ? { integrationId: this.integrationId } : {}
|
|
98755
|
+
};
|
|
98756
|
+
children.push({
|
|
98757
|
+
stableIdSuffix: "camera",
|
|
98758
|
+
meta: cameraMeta,
|
|
98759
|
+
config,
|
|
98760
|
+
factory: dreameCameraChildFactory
|
|
98761
|
+
});
|
|
98762
|
+
}
|
|
96217
98763
|
if (kind === "vacuum") {
|
|
96218
98764
|
for (const feature of this.supportedAiFeatures()) {
|
|
96219
98765
|
const switchMeta = {
|
|
@@ -96647,6 +99193,7 @@ var DreameContainerDevice = class extends BaseDevice {
|
|
|
96647
99193
|
const caps = handle.vacuumCapabilities;
|
|
96648
99194
|
return {
|
|
96649
99195
|
canMap: caps.canMap,
|
|
99196
|
+
hasCamera: caps.hasCamera,
|
|
96650
99197
|
hasAiObstacleDetection: caps.hasAiObstacleDetection,
|
|
96651
99198
|
aiFeatureCount: caps.hasAiObstacleDetection ? caps.supportedAiFeatures.length : 0
|
|
96652
99199
|
};
|
|
@@ -96674,6 +99221,12 @@ var DreameContainerDevice = class extends BaseDevice {
|
|
|
96674
99221
|
modelSupportsMap() {
|
|
96675
99222
|
return vacuumSupportsMap(this.resolveVacuumCaps());
|
|
96676
99223
|
}
|
|
99224
|
+
/** Whether this vacuum model carries an on-board camera — gates the separate
|
|
99225
|
+
* camera + navigation child. Gated on the RESOLVED handle's confirmed
|
|
99226
|
+
* capability (false when the handle is not up, never optimistic). */
|
|
99227
|
+
modelSupportsCamera() {
|
|
99228
|
+
return vacuumSupportsCamera(this.resolveVacuumCaps());
|
|
99229
|
+
}
|
|
96677
99230
|
/** Whether this mower model supports a map — gated on the RESOLVED handle's
|
|
96678
99231
|
* confirmed capability (false when the handle is not up, never optimistic). */
|
|
96679
99232
|
modelSupportsMowerMap() {
|
|
@@ -97571,4 +100124,4 @@ var DreameProviderAddon = class extends BaseDeviceProvider {
|
|
|
97571
100124
|
}
|
|
97572
100125
|
};
|
|
97573
100126
|
//#endregion
|
|
97574
|
-
export { DreameProviderAddon,
|
|
100127
|
+
export { DreameProviderAddon, tokenToSuctionLevel as a, deviceTypeForKind as c, dreameConfigSchema as d, toNodreameOptions as f, supportedFanSpeedTokens as i, buildConnectionFormSchema as l, mapMiotStateToVacuumState as n, classifyDreameModel as o, suctionLevelToken as r, controlCapForKind as s, mapMowerStatusToActivity as t, connectionFromSettings as u };
|