@camstack/addon-provider-dreame 0.2.69 → 0.2.71
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/addon.js +2843 -269
- package/dist/addon.mjs +2825 -251
- package/dist/index.mjs +1 -1
- package/package.json +5 -2
package/dist/addon.js
CHANGED
|
@@ -695,7 +695,7 @@ var require_util$9 = /* @__PURE__ */ __commonJSMin(((exports, module) => {
|
|
|
695
695
|
var { kDestroyed, kBodyUsed, kListeners, kBody } = require_symbols$4();
|
|
696
696
|
var { IncomingMessage } = require("node:http");
|
|
697
697
|
var stream$1 = require("node:stream");
|
|
698
|
-
var net$
|
|
698
|
+
var net$7 = require("node:net");
|
|
699
699
|
var { Blob: Blob$3 } = require("node:buffer");
|
|
700
700
|
var nodeUtil$3 = require("node:util");
|
|
701
701
|
var { stringify } = require("node:querystring");
|
|
@@ -800,7 +800,7 @@ var require_util$9 = /* @__PURE__ */ __commonJSMin(((exports, module) => {
|
|
|
800
800
|
if (!host) return null;
|
|
801
801
|
assert$27(typeof host === "string");
|
|
802
802
|
const servername = getHostname(host);
|
|
803
|
-
if (net$
|
|
803
|
+
if (net$7.isIP(servername)) return "";
|
|
804
804
|
return servername;
|
|
805
805
|
}
|
|
806
806
|
function deepClone(obj) {
|
|
@@ -1967,7 +1967,7 @@ var require_timers = /* @__PURE__ */ __commonJSMin(((exports, module) => {
|
|
|
1967
1967
|
//#endregion
|
|
1968
1968
|
//#region ../../node_modules/@apocaliss92/nodedreame/node_modules/undici/lib/core/connect.js
|
|
1969
1969
|
var require_connect$1 = /* @__PURE__ */ __commonJSMin(((exports, module) => {
|
|
1970
|
-
var net$
|
|
1970
|
+
var net$6 = require("node:net");
|
|
1971
1971
|
var assert$25 = require("node:assert");
|
|
1972
1972
|
var util = require_util$9();
|
|
1973
1973
|
var { InvalidArgumentError, ConnectTimeoutError } = require_errors$1();
|
|
@@ -2047,7 +2047,7 @@ var require_connect$1 = /* @__PURE__ */ __commonJSMin(((exports, module) => {
|
|
|
2047
2047
|
} else {
|
|
2048
2048
|
assert$25(!httpSocket, "httpSocket can only be sent on TLS update");
|
|
2049
2049
|
port = port || 80;
|
|
2050
|
-
socket = net$
|
|
2050
|
+
socket = net$6.connect({
|
|
2051
2051
|
highWaterMark: 64 * 1024,
|
|
2052
2052
|
...options,
|
|
2053
2053
|
localAddress,
|
|
@@ -6264,7 +6264,7 @@ var require_redirect_interceptor = /* @__PURE__ */ __commonJSMin(((exports, modu
|
|
|
6264
6264
|
//#region ../../node_modules/@apocaliss92/nodedreame/node_modules/undici/lib/dispatcher/client.js
|
|
6265
6265
|
var require_client$1 = /* @__PURE__ */ __commonJSMin(((exports, module) => {
|
|
6266
6266
|
var assert$17 = require("node:assert");
|
|
6267
|
-
var net$
|
|
6267
|
+
var net$5 = require("node:net");
|
|
6268
6268
|
var http$2 = require("node:http");
|
|
6269
6269
|
var util = require_util$9();
|
|
6270
6270
|
var { channels } = require_diagnostics();
|
|
@@ -6308,7 +6308,7 @@ var require_client$1 = /* @__PURE__ */ __commonJSMin(((exports, module) => {
|
|
|
6308
6308
|
if (connect != null && typeof connect !== "function" && typeof connect !== "object") throw new InvalidArgumentError("connect must be a function or an object");
|
|
6309
6309
|
if (maxRedirections != null && (!Number.isInteger(maxRedirections) || maxRedirections < 0)) throw new InvalidArgumentError("maxRedirections must be a positive number");
|
|
6310
6310
|
if (maxRequestsPerClient != null && (!Number.isInteger(maxRequestsPerClient) || maxRequestsPerClient < 0)) throw new InvalidArgumentError("maxRequestsPerClient must be a positive number");
|
|
6311
|
-
if (localAddress != null && (typeof localAddress !== "string" || net$
|
|
6311
|
+
if (localAddress != null && (typeof localAddress !== "string" || net$5.isIP(localAddress) === 0)) throw new InvalidArgumentError("localAddress must be valid string IP address");
|
|
6312
6312
|
if (maxResponseSize != null && (!Number.isInteger(maxResponseSize) || maxResponseSize < -1)) throw new InvalidArgumentError("maxResponseSize must be a positive number");
|
|
6313
6313
|
if (autoSelectFamilyAttemptTimeout != null && (!Number.isInteger(autoSelectFamilyAttemptTimeout) || autoSelectFamilyAttemptTimeout < -1)) throw new InvalidArgumentError("autoSelectFamilyAttemptTimeout must be a positive number");
|
|
6314
6314
|
if (allowH2 != null && typeof allowH2 !== "boolean") throw new InvalidArgumentError("allowH2 must be a valid boolean value");
|
|
@@ -6449,7 +6449,7 @@ var require_client$1 = /* @__PURE__ */ __commonJSMin(((exports, module) => {
|
|
|
6449
6449
|
const idx = hostname.indexOf("]");
|
|
6450
6450
|
assert$17(idx !== -1);
|
|
6451
6451
|
const ip = hostname.substring(1, idx);
|
|
6452
|
-
assert$17(net$
|
|
6452
|
+
assert$17(net$5.isIP(ip));
|
|
6453
6453
|
hostname = ip;
|
|
6454
6454
|
}
|
|
6455
6455
|
client[kConnecting] = true;
|
|
@@ -36633,7 +36633,7 @@ var require_websocket = /* @__PURE__ */ __commonJSMin(((exports, module) => {
|
|
|
36633
36633
|
var EventEmitter$2 = require("events");
|
|
36634
36634
|
var https = require("https");
|
|
36635
36635
|
var http$1 = require("http");
|
|
36636
|
-
var net$
|
|
36636
|
+
var net$4 = require("net");
|
|
36637
36637
|
var tls = require("tls");
|
|
36638
36638
|
var { randomBytes: randomBytes$1, createHash: createHash$2 } = require("crypto");
|
|
36639
36639
|
var { Duplex: Duplex$2, Readable } = require("stream");
|
|
@@ -37379,7 +37379,7 @@ var require_websocket = /* @__PURE__ */ __commonJSMin(((exports, module) => {
|
|
|
37379
37379
|
*/
|
|
37380
37380
|
function netConnect(options) {
|
|
37381
37381
|
options.path = options.socketPath;
|
|
37382
|
-
return net$
|
|
37382
|
+
return net$4.connect(options);
|
|
37383
37383
|
}
|
|
37384
37384
|
/**
|
|
37385
37385
|
* Create a `tls.TLSSocket` and initiate a connection.
|
|
@@ -37390,7 +37390,7 @@ var require_websocket = /* @__PURE__ */ __commonJSMin(((exports, module) => {
|
|
|
37390
37390
|
*/
|
|
37391
37391
|
function tlsConnect(options) {
|
|
37392
37392
|
options.path = void 0;
|
|
37393
|
-
if (!options.servername && options.servername !== "") options.servername = net$
|
|
37393
|
+
if (!options.servername && options.servername !== "") options.servername = net$4.isIP(options.host) ? "" : options.host;
|
|
37394
37394
|
return tls.connect(options);
|
|
37395
37395
|
}
|
|
37396
37396
|
/**
|
|
@@ -41473,7 +41473,7 @@ var require_helpers = /* @__PURE__ */ __commonJSMin(((exports) => {
|
|
|
41473
41473
|
var constants_1 = require_constants$3();
|
|
41474
41474
|
var stream = require("stream");
|
|
41475
41475
|
var ip_address_1 = require_ip_address();
|
|
41476
|
-
var net$
|
|
41476
|
+
var net$3 = require("net");
|
|
41477
41477
|
/**
|
|
41478
41478
|
* Validates the provided SocksClientOptions
|
|
41479
41479
|
* @param options { SocksClientOptions }
|
|
@@ -41551,10 +41551,10 @@ var require_helpers = /* @__PURE__ */ __commonJSMin(((exports) => {
|
|
|
41551
41551
|
}
|
|
41552
41552
|
exports.int32ToIpv4 = int32ToIpv4;
|
|
41553
41553
|
function ipToBuffer(ip) {
|
|
41554
|
-
if (net$
|
|
41554
|
+
if (net$3.isIPv4(ip)) {
|
|
41555
41555
|
const address = new ip_address_1.Address4(ip);
|
|
41556
41556
|
return Buffer.from(address.toArray());
|
|
41557
|
-
} else if (net$
|
|
41557
|
+
} else if (net$3.isIPv6(ip)) {
|
|
41558
41558
|
const address = new ip_address_1.Address6(ip);
|
|
41559
41559
|
return Buffer.from(address.canonicalForm().split(":").map((segment) => segment.padStart(4, "0")).join(""), "hex");
|
|
41560
41560
|
} else throw new Error("Invalid IP address format");
|
|
@@ -41633,7 +41633,7 @@ var require_socksclient = /* @__PURE__ */ __commonJSMin(((exports) => {
|
|
|
41633
41633
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
41634
41634
|
exports.SocksClientError = exports.SocksClient = void 0;
|
|
41635
41635
|
var events_1 = require("events");
|
|
41636
|
-
var net = require("net");
|
|
41636
|
+
var net$2 = require("net");
|
|
41637
41637
|
var smart_buffer_1 = require_smartbuffer();
|
|
41638
41638
|
var constants_1 = require_constants$3();
|
|
41639
41639
|
var helpers_1 = require_helpers();
|
|
@@ -41745,10 +41745,10 @@ var require_socksclient = /* @__PURE__ */ __commonJSMin(((exports) => {
|
|
|
41745
41745
|
const buff = new smart_buffer_1.SmartBuffer();
|
|
41746
41746
|
buff.writeUInt16BE(0);
|
|
41747
41747
|
buff.writeUInt8(options.frameNumber || 0);
|
|
41748
|
-
if (net.isIPv4(options.remoteHost.host)) {
|
|
41748
|
+
if (net$2.isIPv4(options.remoteHost.host)) {
|
|
41749
41749
|
buff.writeUInt8(constants_1.Socks5HostType.IPv4);
|
|
41750
41750
|
buff.writeUInt32BE((0, helpers_1.ipv4ToInt32)(options.remoteHost.host));
|
|
41751
|
-
} else if (net.isIPv6(options.remoteHost.host)) {
|
|
41751
|
+
} else if (net$2.isIPv6(options.remoteHost.host)) {
|
|
41752
41752
|
buff.writeUInt8(constants_1.Socks5HostType.IPv6);
|
|
41753
41753
|
buff.writeBuffer((0, helpers_1.ipToBuffer)(options.remoteHost.host));
|
|
41754
41754
|
} else {
|
|
@@ -41801,7 +41801,7 @@ var require_socksclient = /* @__PURE__ */ __commonJSMin(((exports) => {
|
|
|
41801
41801
|
const timer = setTimeout(() => this.onEstablishedTimeout(), this.options.timeout || constants_1.DEFAULT_TIMEOUT);
|
|
41802
41802
|
if (timer.unref && typeof timer.unref === "function") timer.unref();
|
|
41803
41803
|
if (existingSocket) this.socket = existingSocket;
|
|
41804
|
-
else this.socket = new net.Socket();
|
|
41804
|
+
else this.socket = new net$2.Socket();
|
|
41805
41805
|
this.socket.once("close", this.onClose);
|
|
41806
41806
|
this.socket.once("error", this.onError);
|
|
41807
41807
|
this.socket.once("connect", this.onConnect);
|
|
@@ -41913,7 +41913,7 @@ var require_socksclient = /* @__PURE__ */ __commonJSMin(((exports) => {
|
|
|
41913
41913
|
buff.writeUInt8(4);
|
|
41914
41914
|
buff.writeUInt8(constants_1.SocksCommand[this.options.command]);
|
|
41915
41915
|
buff.writeUInt16BE(this.options.destination.port);
|
|
41916
|
-
if (net.isIPv4(this.options.destination.host)) {
|
|
41916
|
+
if (net$2.isIPv4(this.options.destination.host)) {
|
|
41917
41917
|
buff.writeBuffer((0, helpers_1.ipToBuffer)(this.options.destination.host));
|
|
41918
41918
|
buff.writeStringNT(userId);
|
|
41919
41919
|
} else {
|
|
@@ -42072,10 +42072,10 @@ var require_socksclient = /* @__PURE__ */ __commonJSMin(((exports) => {
|
|
|
42072
42072
|
buff.writeUInt8(5);
|
|
42073
42073
|
buff.writeUInt8(constants_1.SocksCommand[this.options.command]);
|
|
42074
42074
|
buff.writeUInt8(0);
|
|
42075
|
-
if (net.isIPv4(this.options.destination.host)) {
|
|
42075
|
+
if (net$2.isIPv4(this.options.destination.host)) {
|
|
42076
42076
|
buff.writeUInt8(constants_1.Socks5HostType.IPv4);
|
|
42077
42077
|
buff.writeBuffer((0, helpers_1.ipToBuffer)(this.options.destination.host));
|
|
42078
|
-
} else if (net.isIPv6(this.options.destination.host)) {
|
|
42078
|
+
} else if (net$2.isIPv6(this.options.destination.host)) {
|
|
42079
42079
|
buff.writeUInt8(constants_1.Socks5HostType.IPv6);
|
|
42080
42080
|
buff.writeBuffer((0, helpers_1.ipToBuffer)(this.options.destination.host));
|
|
42081
42081
|
} else {
|
|
@@ -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 (0, crypto$1.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": (0, crypto$1.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 ?? (0, crypto$1.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 ?? (0, crypto$1.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: (0, crypto$1.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 (0, crypto$1.createHash)("md5").update(`${accountId}_${now}`).digest("hex");
|
|
47227
|
+
}
|
|
47228
|
+
function hashAccessCode(code) {
|
|
47229
|
+
return (0, crypto$1.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,8 +51703,14 @@ 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
|
-
//#region ../types/dist/event-category-
|
|
51713
|
+
//#region ../types/dist/event-category-zAv7pMUz.mjs
|
|
50508
51714
|
var EventCategory = /* @__PURE__ */ function(EventCategory) {
|
|
50509
51715
|
EventCategory["SystemBoot"] = "system.boot";
|
|
50510
51716
|
EventCategory["SystemAddonsReady"] = "system.addons-ready";
|
|
@@ -50876,6 +52082,13 @@ var EventCategory = /* @__PURE__ */ function(EventCategory) {
|
|
|
50876
52082
|
*/
|
|
50877
52083
|
EventCategory["BatteryOnStatusChanged"] = "battery.onStatusChanged";
|
|
50878
52084
|
/**
|
|
52085
|
+
* Cap event fired by every device that registers the `network-link`
|
|
52086
|
+
* capability. Mirrors the cap definition's `onStatusChanged`. Carries
|
|
52087
|
+
* `{ deviceId, status: NetworkLinkStatus }` — a link switch or a signal
|
|
52088
|
+
* reading that moved.
|
|
52089
|
+
*/
|
|
52090
|
+
EventCategory["NetworkLinkOnStatusChanged"] = "network-link.onStatusChanged";
|
|
52091
|
+
/**
|
|
50879
52092
|
* Emitted by the battery cap provider WHEN `wakeForStream` enters the
|
|
50880
52093
|
* "wake in progress" window — between the Baichuan wake-up issue and
|
|
50881
52094
|
* the camera's first dialed-back RTP packet. The stream-broker
|
|
@@ -74271,6 +75484,99 @@ onStatusChanged: { data: object({
|
|
|
74271
75484
|
volatileStateFields: ["lastUpdated"]
|
|
74272
75485
|
};
|
|
74273
75486
|
/**
|
|
75487
|
+
* Network-link snapshot. Same shape for every provider (a Reolink wifi
|
|
75488
|
+
* camera, a Home Assistant device with a signal-strength sensor, a Tapo
|
|
75489
|
+
* plug): one slice under `device.runtimeState['network-link']`, one badge,
|
|
75490
|
+
* one Home Assistant projection.
|
|
75491
|
+
*/
|
|
75492
|
+
var NetworkLinkStatusSchema = object({
|
|
75493
|
+
/** The link the device is on. `'unknown'` = not read yet, not "no link". */
|
|
75494
|
+
type: _enum([
|
|
75495
|
+
"wifi",
|
|
75496
|
+
"ethernet",
|
|
75497
|
+
"cellular",
|
|
75498
|
+
"unknown"
|
|
75499
|
+
]),
|
|
75500
|
+
/**
|
|
75501
|
+
* Link quality, 0..100 inclusive, normalised by the provider from whatever
|
|
75502
|
+
* the firmware reports (bars, RSSI, a vendor scale). **`null` means NOT
|
|
75503
|
+
* KNOWN or NOT APPLICABLE** — a wired link has no signal, and a wireless
|
|
75504
|
+
* one whose reading has not landed must not be drawn at 0 %. Consumers
|
|
75505
|
+
* SKIP a null rather than coerce it.
|
|
75506
|
+
*/
|
|
75507
|
+
signalPercent: number().min(0).max(100).nullable(),
|
|
75508
|
+
/** Raw received signal strength in dBm, when the firmware reports one. */
|
|
75509
|
+
rssiDbm: number().optional(),
|
|
75510
|
+
/** Network name of a wireless link, when the firmware reports it. */
|
|
75511
|
+
ssid: string().optional(),
|
|
75512
|
+
/** Ms epoch of the last observation. Lets consumers reason about freshness. */
|
|
75513
|
+
lastUpdated: number()
|
|
75514
|
+
});
|
|
75515
|
+
/** The slice a provider seeds before its first read: nothing is known yet. */
|
|
75516
|
+
var NETWORK_LINK_UNKNOWN = {
|
|
75517
|
+
type: "unknown",
|
|
75518
|
+
signalPercent: null,
|
|
75519
|
+
lastUpdated: 0
|
|
75520
|
+
};
|
|
75521
|
+
/**
|
|
75522
|
+
* Normalise a signal the firmware reports as BARS (0..`maxBars`) to 0..100.
|
|
75523
|
+
* Out-of-range or non-finite input is not a reading: `null`.
|
|
75524
|
+
*/
|
|
75525
|
+
function signalPercentFromBars(bars, maxBars) {
|
|
75526
|
+
if (bars === void 0 || !Number.isFinite(bars) || maxBars <= 0) return null;
|
|
75527
|
+
if (bars < 0 || bars > maxBars) return null;
|
|
75528
|
+
return Math.round(bars / maxBars * 100);
|
|
75529
|
+
}
|
|
75530
|
+
var networkLinkCapability = {
|
|
75531
|
+
name: "network-link",
|
|
75532
|
+
scope: "device",
|
|
75533
|
+
deviceNative: true,
|
|
75534
|
+
mode: "singleton",
|
|
75535
|
+
deviceTypes: [
|
|
75536
|
+
DeviceType.Camera,
|
|
75537
|
+
DeviceType.Sensor,
|
|
75538
|
+
DeviceType.Button,
|
|
75539
|
+
DeviceType.Switch,
|
|
75540
|
+
DeviceType.Light,
|
|
75541
|
+
DeviceType.Lock,
|
|
75542
|
+
DeviceType.Siren
|
|
75543
|
+
],
|
|
75544
|
+
methods: {},
|
|
75545
|
+
events: {
|
|
75546
|
+
/**
|
|
75547
|
+
* Emitted whenever the cached status changes (a link switch, a signal
|
|
75548
|
+
* reading that moved). Mirrored on the parent chain by the
|
|
75549
|
+
* DeviceEventPropagator like `battery.onStatusChanged`.
|
|
75550
|
+
*/
|
|
75551
|
+
onStatusChanged: { data: object({
|
|
75552
|
+
deviceId: number(),
|
|
75553
|
+
status: NetworkLinkStatusSchema
|
|
75554
|
+
}) } },
|
|
75555
|
+
status: {
|
|
75556
|
+
schema: NetworkLinkStatusSchema,
|
|
75557
|
+
kind: "push",
|
|
75558
|
+
empty: NETWORK_LINK_UNKNOWN
|
|
75559
|
+
},
|
|
75560
|
+
/**
|
|
75561
|
+
* Runtime-state slice — every provider stores the same shape under
|
|
75562
|
+
* `device.runtimeState['network-link']`, read once by the badge and the
|
|
75563
|
+
* Home Assistant projector regardless of the driver.
|
|
75564
|
+
*/
|
|
75565
|
+
runtimeState: NetworkLinkStatusSchema,
|
|
75566
|
+
/**
|
|
75567
|
+
* Runtime-state durability: **restored** — a link reading is slow to
|
|
75568
|
+
* change and a sleeping battery camera may not report for hours; the
|
|
75569
|
+
* restored slice is what the badge shows until the next read.
|
|
75570
|
+
*
|
|
75571
|
+
* See `RuntimeStateDurability`. Enforced by
|
|
75572
|
+
* `scripts/check-runtime-state-durability.ts`.
|
|
75573
|
+
*/
|
|
75574
|
+
durability: "restored",
|
|
75575
|
+
/** Clock fields: written, but excluded from the compare that decides
|
|
75576
|
+
* whether persisting is worth a SQLite commit. */
|
|
75577
|
+
volatileStateFields: ["lastUpdated"]
|
|
75578
|
+
};
|
|
75579
|
+
/**
|
|
74274
75580
|
* Generic boolean sensor — last-resort fallback when no domain-
|
|
74275
75581
|
* specific binary cap fits (Home Assistant `binary_sensor` without a
|
|
74276
75582
|
* known `device_class`, or a domain we haven't typed yet). Pure
|
|
@@ -79247,7 +80553,10 @@ var PtzMoveCommandSchema = object({
|
|
|
79247
80553
|
zoom: number().optional(),
|
|
79248
80554
|
speed: number().optional()
|
|
79249
80555
|
});
|
|
79250
|
-
|
|
80556
|
+
/** PTZ status — current position plus the camera's autofocus state.
|
|
80557
|
+
* `getPosition` / `move` still operate on the bare position; the
|
|
80558
|
+
* auto-injected `getStatus` returns this richer block. */
|
|
80559
|
+
var PtzStatusSchema = PtzPositionSchema.extend({ autofocus: boolean() });
|
|
79251
80560
|
/** Per-camera PTZ availability descriptor — drives the UI. */
|
|
79252
80561
|
var PtzOptionsSchema = object({
|
|
79253
80562
|
hasPan: boolean(),
|
|
@@ -79260,26 +80569,65 @@ var PtzOptionsSchema = object({
|
|
|
79260
80569
|
* (boolean `hasX` per the getOptions availability convention). */
|
|
79261
80570
|
hasAutofocus: boolean()
|
|
79262
80571
|
});
|
|
79263
|
-
|
|
79264
|
-
|
|
79265
|
-
|
|
79266
|
-
|
|
79267
|
-
|
|
79268
|
-
|
|
79269
|
-
|
|
79270
|
-
|
|
79271
|
-
|
|
79272
|
-
|
|
79273
|
-
|
|
79274
|
-
|
|
79275
|
-
|
|
79276
|
-
}
|
|
79277
|
-
|
|
79278
|
-
|
|
79279
|
-
|
|
79280
|
-
|
|
79281
|
-
|
|
79282
|
-
|
|
80572
|
+
var ptzCapability = {
|
|
80573
|
+
name: "ptz",
|
|
80574
|
+
scope: "device",
|
|
80575
|
+
deviceNative: true,
|
|
80576
|
+
mode: "singleton",
|
|
80577
|
+
deviceTypes: [DeviceType.Camera],
|
|
80578
|
+
deviceConfig: { ui: {
|
|
80579
|
+
kind: "widget",
|
|
80580
|
+
widgetId: "host/ptz-panel",
|
|
80581
|
+
tab: "ptz",
|
|
80582
|
+
topTab: true,
|
|
80583
|
+
label: "PTZ",
|
|
80584
|
+
order: 0
|
|
80585
|
+
} },
|
|
80586
|
+
methods: {
|
|
80587
|
+
move: method(PtzMoveCommandSchema.extend({ deviceId: number() }), _void(), { kind: "mutation" }),
|
|
80588
|
+
continuousMove: method(PtzMoveCommandSchema.extend({ deviceId: number() }), _void(), { kind: "mutation" }),
|
|
80589
|
+
stop: method(object({ deviceId: number() }), _void(), { kind: "mutation" }),
|
|
80590
|
+
getPresets: method(object({ deviceId: number() }), array(PtzPresetSchema)),
|
|
80591
|
+
goToPreset: method(object({
|
|
80592
|
+
deviceId: number(),
|
|
80593
|
+
presetId: string()
|
|
80594
|
+
}), _void(), { kind: "mutation" }),
|
|
80595
|
+
savePreset: method(object({
|
|
80596
|
+
deviceId: number(),
|
|
80597
|
+
presetId: string(),
|
|
80598
|
+
name: string()
|
|
80599
|
+
}), _void(), {
|
|
80600
|
+
kind: "mutation",
|
|
80601
|
+
auth: "admin"
|
|
80602
|
+
}),
|
|
80603
|
+
deletePreset: method(object({
|
|
80604
|
+
deviceId: number(),
|
|
80605
|
+
presetId: string()
|
|
80606
|
+
}), _void(), {
|
|
80607
|
+
kind: "mutation",
|
|
80608
|
+
auth: "admin"
|
|
80609
|
+
}),
|
|
80610
|
+
getOptions: method(object({ deviceId: number() }), PtzOptionsSchema),
|
|
80611
|
+
goHome: method(object({ deviceId: number() }), _void(), { kind: "mutation" }),
|
|
80612
|
+
/**
|
|
80613
|
+
* Pull the current PTZ position. Redundant with the auto-injected
|
|
80614
|
+
* `getStatus` method (see `status` below); kept for callers that
|
|
80615
|
+
* haven't migrated. Will be folded into `getStatus` in a future
|
|
80616
|
+
* cleanup pass once all consumers use the typed status surface.
|
|
80617
|
+
*/
|
|
80618
|
+
getPosition: method(object({ deviceId: number() }), PtzPositionSchema),
|
|
80619
|
+
/** Toggle the camera's autofocus. Only meaningful when
|
|
80620
|
+
* `getOptions().hasAutofocus` is true. */
|
|
80621
|
+
setAutofocus: method(object({
|
|
80622
|
+
deviceId: number(),
|
|
80623
|
+
enabled: boolean()
|
|
80624
|
+
}), _void(), { kind: "mutation" })
|
|
80625
|
+
},
|
|
80626
|
+
status: {
|
|
80627
|
+
schema: PtzStatusSchema,
|
|
80628
|
+
kind: "command-driven"
|
|
80629
|
+
}
|
|
80630
|
+
};
|
|
79283
80631
|
var PtzAutotrackSettingsSchema = object({
|
|
79284
80632
|
targetType: string().describe("Vendor target string (people/vehicle/pet); empty = camera default"),
|
|
79285
80633
|
stopDelaySeconds: number().int().min(0).max(300),
|
|
@@ -79405,6 +80753,424 @@ var ptzAutotrackCapability = {
|
|
|
79405
80753
|
*/
|
|
79406
80754
|
durability: "session"
|
|
79407
80755
|
};
|
|
80756
|
+
/**
|
|
80757
|
+
* `navigation` — a device-scoped capability that natively expresses the FULL
|
|
80758
|
+
* navigation / action surface of a robot that DRIVES ITSELF and carries an
|
|
80759
|
+
* on-board camera (the Dreame robot-vacuum camera is the first provider).
|
|
80760
|
+
*
|
|
80761
|
+
* Why a NEW cap rather than overloading `ptz`:
|
|
80762
|
+
* - `ptz` moves a *gimbal* on a fixed camera (pan/tilt/zoom of the lens). A
|
|
80763
|
+
* robot vacuum has no gimbal — the whole chassis drives, turns and spins.
|
|
80764
|
+
* The two are different physical models: PTZ is absolute-position + presets,
|
|
80765
|
+
* navigation is momentary drive nudges + discrete robot ACTIONS
|
|
80766
|
+
* (dock / spot-clean / follow-pet / go-to-point / …).
|
|
80767
|
+
* - This cap is the SOURCE OF TRUTH. Two consumers adapt from it rather than
|
|
80768
|
+
* the reverse:
|
|
80769
|
+
* 1. a native CamStack navigation panel (data-driven from `listActions`
|
|
80770
|
+
* / `getOptions`), and
|
|
80771
|
+
* 2. the PTZ surface — a thin adapter mimics `ptz` from `navigation` so a
|
|
80772
|
+
* robot camera shows up in the existing PTZ control path without every
|
|
80773
|
+
* PTZ provider learning about robots. The mapping lives in the adapter,
|
|
80774
|
+
* not here (see the addon design note):
|
|
80775
|
+
* ptz.continuousMove({pan,tilt}) → navigation.move({pan,tilt})
|
|
80776
|
+
* ptz.stop() → navigation.stop()
|
|
80777
|
+
* ptz.goHome() → navigation.runAction('goHome')
|
|
80778
|
+
* ptz.getPresets() → navigation.listActions() (id→preset)
|
|
80779
|
+
* ptz.goToPreset(id) → navigation.runAction(id)
|
|
80780
|
+
*
|
|
80781
|
+
* ## Continuous drive
|
|
80782
|
+
*
|
|
80783
|
+
* `move` is MOMENTARY. Fluid navigation comes from the UI (or the PTZ adapter)
|
|
80784
|
+
* sending `move({pan,tilt})` REPEATEDLY at ~1 Hz while a direction is held, and
|
|
80785
|
+
* one `stop()` on release — exactly like the robot app's remote-drive joystick.
|
|
80786
|
+
* The provider forwards EACH `move` to one drive write; it must NOT debounce or
|
|
80787
|
+
* coalesce them. The UI owns the cadence.
|
|
80788
|
+
*
|
|
80789
|
+
* ## The action dictionary
|
|
80790
|
+
*
|
|
80791
|
+
* The discrete controls (dock / locate / spot-clean / follow / flash / sounds)
|
|
80792
|
+
* are a DATA-DRIVEN dictionary the cap exposes via `listActions()`. Each entry
|
|
80793
|
+
* carries `{ id, kind, label, icon }` (plus `soundId` for sound entries) so the
|
|
80794
|
+
* native panel AND the PTZ mimic render buttons WITHOUT hardcoding a
|
|
80795
|
+
* vendor-specific list. `kind: 'action'` entries are triggered with
|
|
80796
|
+
* `runAction({ actionId })`; `kind: 'sound'` entries with `playSound({ soundId })`
|
|
80797
|
+
* (the entry carries the `soundId` to pass). The general primitives — `move`,
|
|
80798
|
+
* `stop`, `goToPoint` — stay as first-class methods, not dictionary entries.
|
|
80799
|
+
*
|
|
80800
|
+
* NOTE (provider wiring): the first provider (Dreame) drives this with the RAW
|
|
80801
|
+
* `callAction(siid,aiid,in)` / `setProperty({siid,piid,value})` MIoT primitives
|
|
80802
|
+
* that the currently-published `@apocaliss92/nodedreame` already exposes on
|
|
80803
|
+
* every device handle. A future nodedreame publish adds a typed
|
|
80804
|
+
* `DreameCameraController` (drive / playPetSound / spotClean / findPet / …); the
|
|
80805
|
+
* provider can then swap the raw calls for the typed methods with no change to
|
|
80806
|
+
* THIS contract.
|
|
80807
|
+
*/
|
|
80808
|
+
/**
|
|
80809
|
+
* A momentary drive nudge. The robot MOVES (no gimbal) for as long as the caller
|
|
80810
|
+
* keeps sending nudges (~1 Hz); an explicit `stop` (or letting the nudges lapse)
|
|
80811
|
+
* halts it.
|
|
80812
|
+
*
|
|
80813
|
+
* - `pan` — turn: negative = left, positive = right, 0 = straight.
|
|
80814
|
+
* - `tilt` — throttle: positive = forward, negative = spin / turn-around.
|
|
80815
|
+
* - `speed` — optional intensity hint [0, 1]; the provider may scale the drive
|
|
80816
|
+
* vector by it (drivers without proportional drive ignore it).
|
|
80817
|
+
*
|
|
80818
|
+
* `pan` / `tilt` are normalized [-1, 1]. Both optional so a caller can nudge one
|
|
80819
|
+
* axis alone; an all-undefined nudge is a no-op.
|
|
80820
|
+
*/
|
|
80821
|
+
var NavigationMoveCommandSchema = object({
|
|
80822
|
+
pan: number().min(-1).max(1).optional(),
|
|
80823
|
+
tilt: number().min(-1).max(1).optional(),
|
|
80824
|
+
speed: number().min(0).max(1).optional()
|
|
80825
|
+
});
|
|
80826
|
+
/**
|
|
80827
|
+
* The enumerated discrete actions a navigation-capable robot can perform via
|
|
80828
|
+
* `runAction`. This is the CLOSED vocabulary; a given device advertises the
|
|
80829
|
+
* subset it supports through `listActions`. Sounds are NOT here — they go through
|
|
80830
|
+
* `playSound` (see the `sound` dictionary entries).
|
|
80831
|
+
*/
|
|
80832
|
+
var NavigationActionIdSchema = _enum([
|
|
80833
|
+
"goHome",
|
|
80834
|
+
"locate",
|
|
80835
|
+
"spotClean",
|
|
80836
|
+
"findPet",
|
|
80837
|
+
"personFollow",
|
|
80838
|
+
"stop",
|
|
80839
|
+
"startClean",
|
|
80840
|
+
"pauseClean",
|
|
80841
|
+
"dockWash",
|
|
80842
|
+
"autoEmpty",
|
|
80843
|
+
"flashOn",
|
|
80844
|
+
"flashOff"
|
|
80845
|
+
]);
|
|
80846
|
+
/** Whether a dictionary entry is a `runAction` action or a `playSound` sound. */
|
|
80847
|
+
var NavigationEntryKindSchema = _enum(["action", "sound"]);
|
|
80848
|
+
/**
|
|
80849
|
+
* One entry in the navigation action dictionary — the DATA-DRIVEN unit both the
|
|
80850
|
+
* native panel and the PTZ mimic render as a button.
|
|
80851
|
+
*
|
|
80852
|
+
* - `id` — stable id. For `kind:'action'` it is a {@link NavigationActionId}
|
|
80853
|
+
* (pass to `runAction`); for `kind:'sound'` it is a namespaced id
|
|
80854
|
+
* (`sound:meow`) whose `soundId` is passed to `playSound`.
|
|
80855
|
+
* - `icon` — icon HINT (lucide-style name; the UI maps it to its own set).
|
|
80856
|
+
* - `label` — operator-facing English label.
|
|
80857
|
+
* - `soundId` — wire sound id, present only on `kind:'sound'` entries.
|
|
80858
|
+
* - `enabled` — per-device FEATURE FLAG. `listActions` reports it so the UI /
|
|
80859
|
+
* PTZ render ONLY enabled entries. Data-driven: the provider
|
|
80860
|
+
* flips it from config, never by editing code.
|
|
80861
|
+
*/
|
|
80862
|
+
var NavigationActionEntrySchema = object({
|
|
80863
|
+
id: string(),
|
|
80864
|
+
kind: NavigationEntryKindSchema,
|
|
80865
|
+
label: string(),
|
|
80866
|
+
icon: string(),
|
|
80867
|
+
/** Present only on `kind:'sound'` entries — the id to pass to `playSound`. */
|
|
80868
|
+
soundId: number().int().optional(),
|
|
80869
|
+
/** Per-device feature flag — render this entry only when true. */
|
|
80870
|
+
enabled: boolean()
|
|
80871
|
+
});
|
|
80872
|
+
/**
|
|
80873
|
+
* Canonical dictionary of every navigation control (discrete actions + sounds)
|
|
80874
|
+
* with its default label + icon hint. A provider filters this to the subset a
|
|
80875
|
+
* device supports (and may override a label). Exported so the provider AND the
|
|
80876
|
+
* UI share ONE list instead of re-deriving labels / ids independently.
|
|
80877
|
+
*
|
|
80878
|
+
* Icons are lucide-style hints; the viewer maps them to its own icon set. Every
|
|
80879
|
+
* catalog default is `enabled: true`; a provider overrides per-device from
|
|
80880
|
+
* config (data-driven — enabling / disabling one later is a config change, not a
|
|
80881
|
+
* code change).
|
|
80882
|
+
*/
|
|
80883
|
+
var NAVIGATION_ACTION_CATALOG = [
|
|
80884
|
+
{
|
|
80885
|
+
id: "startClean",
|
|
80886
|
+
kind: "action",
|
|
80887
|
+
label: "Start cleaning",
|
|
80888
|
+
icon: "play",
|
|
80889
|
+
enabled: true
|
|
80890
|
+
},
|
|
80891
|
+
{
|
|
80892
|
+
id: "pauseClean",
|
|
80893
|
+
kind: "action",
|
|
80894
|
+
label: "Pause cleaning",
|
|
80895
|
+
icon: "pause",
|
|
80896
|
+
enabled: true
|
|
80897
|
+
},
|
|
80898
|
+
{
|
|
80899
|
+
id: "stop",
|
|
80900
|
+
kind: "action",
|
|
80901
|
+
label: "Stop",
|
|
80902
|
+
icon: "hand",
|
|
80903
|
+
enabled: true
|
|
80904
|
+
},
|
|
80905
|
+
{
|
|
80906
|
+
id: "goHome",
|
|
80907
|
+
kind: "action",
|
|
80908
|
+
label: "Return to dock",
|
|
80909
|
+
icon: "home",
|
|
80910
|
+
enabled: true
|
|
80911
|
+
},
|
|
80912
|
+
{
|
|
80913
|
+
id: "locate",
|
|
80914
|
+
kind: "action",
|
|
80915
|
+
label: "Locate",
|
|
80916
|
+
icon: "bell",
|
|
80917
|
+
enabled: true
|
|
80918
|
+
},
|
|
80919
|
+
{
|
|
80920
|
+
id: "spotClean",
|
|
80921
|
+
kind: "action",
|
|
80922
|
+
label: "Spot clean here",
|
|
80923
|
+
icon: "crosshair",
|
|
80924
|
+
enabled: true
|
|
80925
|
+
},
|
|
80926
|
+
{
|
|
80927
|
+
id: "findPet",
|
|
80928
|
+
kind: "action",
|
|
80929
|
+
label: "Find pet",
|
|
80930
|
+
icon: "paw-print",
|
|
80931
|
+
enabled: true
|
|
80932
|
+
},
|
|
80933
|
+
{
|
|
80934
|
+
id: "personFollow",
|
|
80935
|
+
kind: "action",
|
|
80936
|
+
label: "Follow me",
|
|
80937
|
+
icon: "footprints",
|
|
80938
|
+
enabled: true
|
|
80939
|
+
},
|
|
80940
|
+
{
|
|
80941
|
+
id: "dockWash",
|
|
80942
|
+
kind: "action",
|
|
80943
|
+
label: "Wash mop",
|
|
80944
|
+
icon: "droplets",
|
|
80945
|
+
enabled: true
|
|
80946
|
+
},
|
|
80947
|
+
{
|
|
80948
|
+
id: "autoEmpty",
|
|
80949
|
+
kind: "action",
|
|
80950
|
+
label: "Empty dustbin",
|
|
80951
|
+
icon: "trash-2",
|
|
80952
|
+
enabled: true
|
|
80953
|
+
},
|
|
80954
|
+
{
|
|
80955
|
+
id: "flashOn",
|
|
80956
|
+
kind: "action",
|
|
80957
|
+
label: "Flash on",
|
|
80958
|
+
icon: "flashlight",
|
|
80959
|
+
enabled: true
|
|
80960
|
+
},
|
|
80961
|
+
{
|
|
80962
|
+
id: "flashOff",
|
|
80963
|
+
kind: "action",
|
|
80964
|
+
label: "Flash off",
|
|
80965
|
+
icon: "flashlight-off",
|
|
80966
|
+
enabled: true
|
|
80967
|
+
},
|
|
80968
|
+
{
|
|
80969
|
+
id: "sound:meow",
|
|
80970
|
+
kind: "sound",
|
|
80971
|
+
label: "Meow",
|
|
80972
|
+
icon: "cat",
|
|
80973
|
+
soundId: 684,
|
|
80974
|
+
enabled: true
|
|
80975
|
+
},
|
|
80976
|
+
{
|
|
80977
|
+
id: "sound:bark",
|
|
80978
|
+
kind: "sound",
|
|
80979
|
+
label: "Woof",
|
|
80980
|
+
icon: "dog",
|
|
80981
|
+
soundId: 685,
|
|
80982
|
+
enabled: true
|
|
80983
|
+
},
|
|
80984
|
+
{
|
|
80985
|
+
id: "sound:footsteps",
|
|
80986
|
+
kind: "sound",
|
|
80987
|
+
label: "Footsteps",
|
|
80988
|
+
icon: "footprints",
|
|
80989
|
+
soundId: 686,
|
|
80990
|
+
enabled: true
|
|
80991
|
+
},
|
|
80992
|
+
{
|
|
80993
|
+
id: "sound:purring",
|
|
80994
|
+
kind: "sound",
|
|
80995
|
+
label: "Purring",
|
|
80996
|
+
icon: "cat",
|
|
80997
|
+
soundId: 687,
|
|
80998
|
+
enabled: true
|
|
80999
|
+
},
|
|
81000
|
+
{
|
|
81001
|
+
id: "sound:tickTock",
|
|
81002
|
+
kind: "sound",
|
|
81003
|
+
label: "Tick-tock",
|
|
81004
|
+
icon: "clock",
|
|
81005
|
+
soundId: 688,
|
|
81006
|
+
enabled: true
|
|
81007
|
+
}
|
|
81008
|
+
];
|
|
81009
|
+
/** Coordinates for `goToPoint` — a point on the robot's live map. */
|
|
81010
|
+
var NavigationPointSchema = object({
|
|
81011
|
+
x: number(),
|
|
81012
|
+
y: number()
|
|
81013
|
+
});
|
|
81014
|
+
/**
|
|
81015
|
+
* Per-device FEATURE-FLAG report for the general (non-dictionary) primitives.
|
|
81016
|
+
* The cap reports which are enabled so the UI / PTZ render only the controls
|
|
81017
|
+
* that are turned on for THIS device. Data-driven: the provider derives these
|
|
81018
|
+
* from config + probe, never hardcoded in the UI. The per-DICTIONARY-entry flags
|
|
81019
|
+
* live on {@link NavigationActionEntrySchema.enabled}; these gate the primitives
|
|
81020
|
+
* that are not dictionary entries.
|
|
81021
|
+
*
|
|
81022
|
+
* - `move` / `stop` — the momentary drive joystick.
|
|
81023
|
+
* - `goToPoint` — send-to-map-coordinate. Ships OFF on Dreame until the
|
|
81024
|
+
* map-coordinate plumbing is wired.
|
|
81025
|
+
* - `runAction` — the discrete action buttons (dictionary `kind:'action'`).
|
|
81026
|
+
* - `playSound` — the sound buttons (dictionary `kind:'sound'`).
|
|
81027
|
+
* - `light` — the on/off fill-light toggle (works anytime).
|
|
81028
|
+
* - `lightMode` — the auto/manual selector + manual level slider (a
|
|
81029
|
+
* camera-service control; needs an active stream).
|
|
81030
|
+
*/
|
|
81031
|
+
var NavigationFeaturesSchema = object({
|
|
81032
|
+
move: boolean(),
|
|
81033
|
+
stop: boolean(),
|
|
81034
|
+
goToPoint: boolean(),
|
|
81035
|
+
runAction: boolean(),
|
|
81036
|
+
playSound: boolean(),
|
|
81037
|
+
light: boolean(),
|
|
81038
|
+
lightMode: boolean()
|
|
81039
|
+
});
|
|
81040
|
+
/** Light mode: `auto` lets the camera choose brightness; `manual` uses `level`. */
|
|
81041
|
+
var NavigationLightModeSchema = _enum(["auto", "manual"]);
|
|
81042
|
+
/**
|
|
81043
|
+
* Live navigation state so the UI can reflect what the robot is doing:
|
|
81044
|
+
* - `mode` — coarse activity (idle / cleaning / following / …).
|
|
81045
|
+
* - `following` — person/pet follow is currently armed.
|
|
81046
|
+
* - `flash` — the on-camera fill light is on.
|
|
81047
|
+
* - `lightMode` — auto vs manual fill-light mode.
|
|
81048
|
+
* - `lightLevel` — manual fill-light level (40..100); meaningful when
|
|
81049
|
+
* `lightMode === 'manual'`.
|
|
81050
|
+
*/
|
|
81051
|
+
var NavigationStatusSchema = object({
|
|
81052
|
+
mode: _enum([
|
|
81053
|
+
"idle",
|
|
81054
|
+
"cleaning",
|
|
81055
|
+
"spot",
|
|
81056
|
+
"following",
|
|
81057
|
+
"goto",
|
|
81058
|
+
"returning",
|
|
81059
|
+
"paused",
|
|
81060
|
+
"unknown"
|
|
81061
|
+
]),
|
|
81062
|
+
following: boolean(),
|
|
81063
|
+
flash: boolean(),
|
|
81064
|
+
lightMode: NavigationLightModeSchema,
|
|
81065
|
+
lightLevel: number().min(40).max(100),
|
|
81066
|
+
/** Ms epoch when the slice was last updated. */
|
|
81067
|
+
lastChangedAt: number()
|
|
81068
|
+
});
|
|
81069
|
+
/**
|
|
81070
|
+
* Runtime-state slice owned by this cap (kernel-managed: validated, mirrored,
|
|
81071
|
+
* observable). Adds `lastFetchedAt` on top of the status shape per the
|
|
81072
|
+
* convention.
|
|
81073
|
+
*/
|
|
81074
|
+
var NavigationRuntimeStateSchema = NavigationStatusSchema.extend({ lastFetchedAt: number() });
|
|
81075
|
+
var navigationCapability = {
|
|
81076
|
+
name: "navigation",
|
|
81077
|
+
scope: "device",
|
|
81078
|
+
deviceNative: true,
|
|
81079
|
+
mode: "singleton",
|
|
81080
|
+
deviceTypes: [DeviceType.Camera],
|
|
81081
|
+
deviceConfig: { ui: {
|
|
81082
|
+
kind: "widget",
|
|
81083
|
+
widgetId: "host/navigation-panel",
|
|
81084
|
+
tab: "navigation",
|
|
81085
|
+
topTab: true,
|
|
81086
|
+
label: "Navigation",
|
|
81087
|
+
order: 0
|
|
81088
|
+
} },
|
|
81089
|
+
methods: {
|
|
81090
|
+
/**
|
|
81091
|
+
* Momentary drive nudge (the robot moves). `protected` — mirrors
|
|
81092
|
+
* `ptz.continuousMove` so the Viewer navigation panel (and the PTZ-mimic
|
|
81093
|
+
* path) works for any authenticated user, not admin-only. The UI sends
|
|
81094
|
+
* these at ~1 Hz while a control is held; the provider forwards each one to
|
|
81095
|
+
* a single drive write WITHOUT debouncing.
|
|
81096
|
+
*/
|
|
81097
|
+
move: method(NavigationMoveCommandSchema.extend({ deviceId: number() }), _void(), { kind: "mutation" }),
|
|
81098
|
+
/** Halt all motion immediately (zero drive vector). */
|
|
81099
|
+
stop: method(object({ deviceId: number() }), _void(), { kind: "mutation" }),
|
|
81100
|
+
/** Send the robot to a point on its live map. */
|
|
81101
|
+
goToPoint: method(NavigationPointSchema.extend({ deviceId: number() }), _void(), { kind: "mutation" }),
|
|
81102
|
+
/**
|
|
81103
|
+
* Enumerate the discrete controls THIS device supports (data-driven UI +
|
|
81104
|
+
* PTZ mimic). Camera-probed subset of {@link NAVIGATION_ACTION_CATALOG}.
|
|
81105
|
+
*/
|
|
81106
|
+
listActions: method(object({ deviceId: number() }), array(NavigationActionEntrySchema)),
|
|
81107
|
+
/**
|
|
81108
|
+
* Run one discrete action (a `kind:'action'` dictionary entry). Invalid /
|
|
81109
|
+
* unsupported action ids are rejected by the provider.
|
|
81110
|
+
*/
|
|
81111
|
+
runAction: method(object({
|
|
81112
|
+
deviceId: number(),
|
|
81113
|
+
actionId: NavigationActionIdSchema
|
|
81114
|
+
}), _void(), { kind: "mutation" }),
|
|
81115
|
+
/** Play a sound by its wire id (the `soundId` of a `kind:'sound'` entry). */
|
|
81116
|
+
playSound: method(object({
|
|
81117
|
+
deviceId: number(),
|
|
81118
|
+
soundId: number().int()
|
|
81119
|
+
}), _void(), { kind: "mutation" }),
|
|
81120
|
+
/**
|
|
81121
|
+
* Turn the on-camera fill light on / off (the `OpenFullLight` control —
|
|
81122
|
+
* works anytime, no active stream required).
|
|
81123
|
+
*/
|
|
81124
|
+
setLightOn: method(object({
|
|
81125
|
+
deviceId: number(),
|
|
81126
|
+
on: boolean()
|
|
81127
|
+
}), _void(), { kind: "mutation" }),
|
|
81128
|
+
/**
|
|
81129
|
+
* Set the fill-light mode (auto vs manual). `manual` optionally carries the
|
|
81130
|
+
* initial `level`. The auto/manual + level control is a CAMERA-service
|
|
81131
|
+
* action that generally needs an active camera stream/monitor session — the
|
|
81132
|
+
* UI shows the manual level slider ONLY when `mode === 'manual'`.
|
|
81133
|
+
*/
|
|
81134
|
+
setLightMode: method(object({
|
|
81135
|
+
deviceId: number(),
|
|
81136
|
+
mode: NavigationLightModeSchema,
|
|
81137
|
+
level: number().min(40).max(100).optional()
|
|
81138
|
+
}), _void(), { kind: "mutation" }),
|
|
81139
|
+
/** Set the MANUAL fill-light level (40..100). Implies `manual` mode. */
|
|
81140
|
+
setLightLevel: method(object({
|
|
81141
|
+
deviceId: number(),
|
|
81142
|
+
level: number().min(40).max(100)
|
|
81143
|
+
}), _void(), { kind: "mutation" }),
|
|
81144
|
+
/**
|
|
81145
|
+
* Per-device FEATURE-FLAG report for the general primitives — drives which
|
|
81146
|
+
* controls the UI shows (the per-entry flags for the dictionary come back on
|
|
81147
|
+
* `listActions`).
|
|
81148
|
+
*/
|
|
81149
|
+
getFeatures: method(object({ deviceId: number() }), NavigationFeaturesSchema)
|
|
81150
|
+
},
|
|
81151
|
+
events: { onStatusChanged: { data: object({
|
|
81152
|
+
deviceId: number(),
|
|
81153
|
+
status: NavigationStatusSchema
|
|
81154
|
+
}) } },
|
|
81155
|
+
status: {
|
|
81156
|
+
schema: NavigationStatusSchema,
|
|
81157
|
+
kind: "push"
|
|
81158
|
+
},
|
|
81159
|
+
/**
|
|
81160
|
+
* Runtime-state slice mirrored by the kernel. The navigation panel watches it
|
|
81161
|
+
* for live mode / follow / flash changes.
|
|
81162
|
+
*/
|
|
81163
|
+
runtimeState: NavigationRuntimeStateSchema,
|
|
81164
|
+
/**
|
|
81165
|
+
* Runtime-state durability: **session** — like `vacuum-control`, a restored
|
|
81166
|
+
* `mode: cleaning` / `following: true` is a robot that is not actually doing
|
|
81167
|
+
* that. The live handle re-publishes on connect.
|
|
81168
|
+
*
|
|
81169
|
+
* See `RuntimeStateDurability`. Enforced by
|
|
81170
|
+
* `scripts/check-runtime-state-durability.ts`.
|
|
81171
|
+
*/
|
|
81172
|
+
durability: "session"
|
|
81173
|
+
};
|
|
79408
81174
|
DeviceType.Camera, DeviceType.Sensor, DeviceType.Switch, method(object({ deviceId: number().int().nonnegative() }), object({ success: literal(true) }), {
|
|
79409
81175
|
kind: "mutation",
|
|
79410
81176
|
auth: "admin"
|
|
@@ -82680,6 +84446,8 @@ var DEVICE_LOCAL_STATE_CAPS = {
|
|
|
82680
84446
|
motionTrigger: motionTriggerCapability,
|
|
82681
84447
|
motionZones: motionZonesCapability,
|
|
82682
84448
|
nativeObjectDetection: nativeObjectDetectionCapability,
|
|
84449
|
+
navigation: navigationCapability,
|
|
84450
|
+
networkLink: networkLinkCapability,
|
|
82683
84451
|
notifier: notifierCapability,
|
|
82684
84452
|
numericSensor: numericSensorCapability,
|
|
82685
84453
|
petFeeder: petFeederCapability,
|
|
@@ -86533,6 +88301,66 @@ Object.freeze({
|
|
|
86533
88301
|
addonId: null,
|
|
86534
88302
|
access: "create"
|
|
86535
88303
|
},
|
|
88304
|
+
"navigation.getFeatures": {
|
|
88305
|
+
capName: "navigation",
|
|
88306
|
+
capScope: "device",
|
|
88307
|
+
addonId: null,
|
|
88308
|
+
access: "view"
|
|
88309
|
+
},
|
|
88310
|
+
"navigation.goToPoint": {
|
|
88311
|
+
capName: "navigation",
|
|
88312
|
+
capScope: "device",
|
|
88313
|
+
addonId: null,
|
|
88314
|
+
access: "create"
|
|
88315
|
+
},
|
|
88316
|
+
"navigation.listActions": {
|
|
88317
|
+
capName: "navigation",
|
|
88318
|
+
capScope: "device",
|
|
88319
|
+
addonId: null,
|
|
88320
|
+
access: "view"
|
|
88321
|
+
},
|
|
88322
|
+
"navigation.move": {
|
|
88323
|
+
capName: "navigation",
|
|
88324
|
+
capScope: "device",
|
|
88325
|
+
addonId: null,
|
|
88326
|
+
access: "create"
|
|
88327
|
+
},
|
|
88328
|
+
"navigation.playSound": {
|
|
88329
|
+
capName: "navigation",
|
|
88330
|
+
capScope: "device",
|
|
88331
|
+
addonId: null,
|
|
88332
|
+
access: "create"
|
|
88333
|
+
},
|
|
88334
|
+
"navigation.runAction": {
|
|
88335
|
+
capName: "navigation",
|
|
88336
|
+
capScope: "device",
|
|
88337
|
+
addonId: null,
|
|
88338
|
+
access: "create"
|
|
88339
|
+
},
|
|
88340
|
+
"navigation.setLightLevel": {
|
|
88341
|
+
capName: "navigation",
|
|
88342
|
+
capScope: "device",
|
|
88343
|
+
addonId: null,
|
|
88344
|
+
access: "create"
|
|
88345
|
+
},
|
|
88346
|
+
"navigation.setLightMode": {
|
|
88347
|
+
capName: "navigation",
|
|
88348
|
+
capScope: "device",
|
|
88349
|
+
addonId: null,
|
|
88350
|
+
access: "create"
|
|
88351
|
+
},
|
|
88352
|
+
"navigation.setLightOn": {
|
|
88353
|
+
capName: "navigation",
|
|
88354
|
+
capScope: "device",
|
|
88355
|
+
addonId: null,
|
|
88356
|
+
access: "create"
|
|
88357
|
+
},
|
|
88358
|
+
"navigation.stop": {
|
|
88359
|
+
capName: "navigation",
|
|
88360
|
+
capScope: "device",
|
|
88361
|
+
addonId: null,
|
|
88362
|
+
access: "create"
|
|
88363
|
+
},
|
|
86536
88364
|
"networkAccess.getEndpoint": {
|
|
86537
88365
|
capName: "network-access",
|
|
86538
88366
|
capScope: "system",
|
|
@@ -90628,6 +92456,56 @@ Object.freeze({
|
|
|
90628
92456
|
form: "single",
|
|
90629
92457
|
optional: false
|
|
90630
92458
|
}],
|
|
92459
|
+
"navigation.getFeatures": [{
|
|
92460
|
+
name: "deviceId",
|
|
92461
|
+
form: "single",
|
|
92462
|
+
optional: false
|
|
92463
|
+
}],
|
|
92464
|
+
"navigation.goToPoint": [{
|
|
92465
|
+
name: "deviceId",
|
|
92466
|
+
form: "single",
|
|
92467
|
+
optional: false
|
|
92468
|
+
}],
|
|
92469
|
+
"navigation.listActions": [{
|
|
92470
|
+
name: "deviceId",
|
|
92471
|
+
form: "single",
|
|
92472
|
+
optional: false
|
|
92473
|
+
}],
|
|
92474
|
+
"navigation.move": [{
|
|
92475
|
+
name: "deviceId",
|
|
92476
|
+
form: "single",
|
|
92477
|
+
optional: false
|
|
92478
|
+
}],
|
|
92479
|
+
"navigation.playSound": [{
|
|
92480
|
+
name: "deviceId",
|
|
92481
|
+
form: "single",
|
|
92482
|
+
optional: false
|
|
92483
|
+
}],
|
|
92484
|
+
"navigation.runAction": [{
|
|
92485
|
+
name: "deviceId",
|
|
92486
|
+
form: "single",
|
|
92487
|
+
optional: false
|
|
92488
|
+
}],
|
|
92489
|
+
"navigation.setLightLevel": [{
|
|
92490
|
+
name: "deviceId",
|
|
92491
|
+
form: "single",
|
|
92492
|
+
optional: false
|
|
92493
|
+
}],
|
|
92494
|
+
"navigation.setLightMode": [{
|
|
92495
|
+
name: "deviceId",
|
|
92496
|
+
form: "single",
|
|
92497
|
+
optional: false
|
|
92498
|
+
}],
|
|
92499
|
+
"navigation.setLightOn": [{
|
|
92500
|
+
name: "deviceId",
|
|
92501
|
+
form: "single",
|
|
92502
|
+
optional: false
|
|
92503
|
+
}],
|
|
92504
|
+
"navigation.stop": [{
|
|
92505
|
+
name: "deviceId",
|
|
92506
|
+
form: "single",
|
|
92507
|
+
optional: false
|
|
92508
|
+
}],
|
|
90631
92509
|
"networkQuality.getDeviceStats": [{
|
|
90632
92510
|
name: "deviceId",
|
|
90633
92511
|
form: "single",
|
|
@@ -93810,6 +95688,23 @@ var dreameChildSchema = object({
|
|
|
93810
95688
|
mapShowPath: boolean().optional(),
|
|
93811
95689
|
mapShowRobot: boolean().optional(),
|
|
93812
95690
|
mapShowCharger: boolean().optional(),
|
|
95691
|
+
/**
|
|
95692
|
+
* `navigation` per-device overrides (camera child only): a feature flag set
|
|
95693
|
+
* here wins over the addon defaults, an entry id listed here is switched off.
|
|
95694
|
+
* Data, not code — see `dreame-navigation-config.ts`.
|
|
95695
|
+
*/
|
|
95696
|
+
navigationFeatures: object({
|
|
95697
|
+
move: boolean().optional(),
|
|
95698
|
+
stop: boolean().optional(),
|
|
95699
|
+
goToPoint: boolean().optional(),
|
|
95700
|
+
runAction: boolean().optional(),
|
|
95701
|
+
playSound: boolean().optional(),
|
|
95702
|
+
light: boolean().optional(),
|
|
95703
|
+
lightMode: boolean().optional()
|
|
95704
|
+
}).optional(),
|
|
95705
|
+
navigationDisabledEntryIds: array(string()).optional(),
|
|
95706
|
+
/** Privacy PIN set at pairing — required to wake a coded camera (camera child only). */
|
|
95707
|
+
navigationAccessCode: string().optional(),
|
|
93813
95708
|
mapShowNoGo: boolean().optional(),
|
|
93814
95709
|
mapShowVirtualWalls: boolean().optional(),
|
|
93815
95710
|
mapShowObstacles: boolean().optional(),
|
|
@@ -93902,7 +95797,7 @@ var DreameChildDevice = class extends BaseDevice {
|
|
|
93902
95797
|
};
|
|
93903
95798
|
//#endregion
|
|
93904
95799
|
//#region src/devices/dreame-ai-switch-device.ts
|
|
93905
|
-
var CAP_NAME$
|
|
95800
|
+
var CAP_NAME$15 = "switch";
|
|
93906
95801
|
var COLD$4 = {
|
|
93907
95802
|
on: false,
|
|
93908
95803
|
lastChangedAt: 0
|
|
@@ -93964,26 +95859,26 @@ var DreameAiSwitchDevice = class extends DreameChildDevice {
|
|
|
93964
95859
|
}
|
|
93965
95860
|
registerControlCap() {
|
|
93966
95861
|
this.ctx.registerNativeCap(switchCapability, {
|
|
93967
|
-
getStatus: async () => this.runtimeState.getCapState(CAP_NAME$
|
|
95862
|
+
getStatus: async () => this.runtimeState.getCapState(CAP_NAME$15) ?? COLD$4,
|
|
93968
95863
|
setState: async ({ on }) => {
|
|
93969
95864
|
const vacuum = this.resolveVacuum();
|
|
93970
95865
|
if (vacuum === null) throw new Error(`DreameAiSwitchDevice: vacuum handle unavailable (device=${this.dreameDeviceId}, feature=${this.#feature})`);
|
|
93971
95866
|
await vacuum.setAiFeature(this.#feature, on);
|
|
93972
|
-
this.runtimeState.setCapState(CAP_NAME$
|
|
95867
|
+
this.runtimeState.setCapState(CAP_NAME$15, {
|
|
93973
95868
|
on,
|
|
93974
95869
|
lastChangedAt: Date.now()
|
|
93975
95870
|
});
|
|
93976
95871
|
}
|
|
93977
95872
|
});
|
|
93978
|
-
this.runtimeState.setCapState(CAP_NAME$
|
|
95873
|
+
this.runtimeState.setCapState(CAP_NAME$15, COLD$4);
|
|
93979
95874
|
}
|
|
93980
95875
|
recomputeSlice() {
|
|
93981
95876
|
const vacuum = this.resolveVacuum();
|
|
93982
95877
|
if (vacuum === null) return;
|
|
93983
95878
|
const on = vacuum.aiFeature(this.#feature);
|
|
93984
95879
|
if (on === null) return;
|
|
93985
|
-
if (this.runtimeState.getCapState(CAP_NAME$
|
|
93986
|
-
this.runtimeState.setCapState(CAP_NAME$
|
|
95880
|
+
if (this.runtimeState.getCapState(CAP_NAME$15)?.on === on) return;
|
|
95881
|
+
this.runtimeState.setCapState(CAP_NAME$15, {
|
|
93987
95882
|
on,
|
|
93988
95883
|
lastChangedAt: Date.now()
|
|
93989
95884
|
});
|
|
@@ -94008,6 +95903,14 @@ function vacuumSupportsAi(caps) {
|
|
|
94008
95903
|
return caps !== null && caps.hasAiObstacleDetection && caps.aiFeatureCount > 0;
|
|
94009
95904
|
}
|
|
94010
95905
|
/**
|
|
95906
|
+
* Whether a vacuum carries an on-board camera (LinkVisual video) — gates the
|
|
95907
|
+
* separate camera + navigation child. `false` when the handle has not resolved
|
|
95908
|
+
* (confirmed capability only, never optimistic).
|
|
95909
|
+
*/
|
|
95910
|
+
function vacuumSupportsCamera(caps) {
|
|
95911
|
+
return caps?.hasCamera === true;
|
|
95912
|
+
}
|
|
95913
|
+
/**
|
|
94011
95914
|
* Whether a mower supports a map. `false` when the handle has not resolved.
|
|
94012
95915
|
*/
|
|
94013
95916
|
function mowerSupportsMap(caps) {
|
|
@@ -94041,7 +95944,7 @@ function deriveContainerFeatures(input) {
|
|
|
94041
95944
|
}
|
|
94042
95945
|
//#endregion
|
|
94043
95946
|
//#region src/devices/dreame-auto-switch-device.ts
|
|
94044
|
-
var CAP_NAME$
|
|
95947
|
+
var CAP_NAME$14 = "switch";
|
|
94045
95948
|
var COLD$3 = {
|
|
94046
95949
|
on: false,
|
|
94047
95950
|
lastChangedAt: 0
|
|
@@ -94089,19 +95992,19 @@ var DreameAutoSwitchDevice = class extends DreameChildDevice {
|
|
|
94089
95992
|
}
|
|
94090
95993
|
registerControlCap() {
|
|
94091
95994
|
this.ctx.registerNativeCap(switchCapability, {
|
|
94092
|
-
getStatus: async () => this.runtimeState.getCapState(CAP_NAME$
|
|
95995
|
+
getStatus: async () => this.runtimeState.getCapState(CAP_NAME$14) ?? COLD$3,
|
|
94093
95996
|
setState: async ({ on }) => {
|
|
94094
95997
|
const vacuum = this.#vacuum();
|
|
94095
95998
|
if (vacuum === null) throw new Error(`DreameAutoSwitchDevice: handle unavailable (device=${this.dreameDeviceId}, ${this.#key})`);
|
|
94096
95999
|
await vacuum.setAutoSwitchProperty(this.#key, on ? 1 : 0);
|
|
94097
|
-
this.runtimeState.setCapState(CAP_NAME$
|
|
96000
|
+
this.runtimeState.setCapState(CAP_NAME$14, {
|
|
94098
96001
|
on,
|
|
94099
96002
|
lastChangedAt: Date.now()
|
|
94100
96003
|
});
|
|
94101
96004
|
}
|
|
94102
96005
|
});
|
|
94103
96006
|
const on = this.#read();
|
|
94104
|
-
this.runtimeState.setCapState(CAP_NAME$
|
|
96007
|
+
this.runtimeState.setCapState(CAP_NAME$14, on === null ? COLD$3 : {
|
|
94105
96008
|
on,
|
|
94106
96009
|
lastChangedAt: Date.now()
|
|
94107
96010
|
});
|
|
@@ -94109,8 +96012,8 @@ var DreameAutoSwitchDevice = class extends DreameChildDevice {
|
|
|
94109
96012
|
recomputeSlice() {
|
|
94110
96013
|
const on = this.#read();
|
|
94111
96014
|
if (on === null) return;
|
|
94112
|
-
if (this.runtimeState.getCapState(CAP_NAME$
|
|
94113
|
-
this.runtimeState.setCapState(CAP_NAME$
|
|
96015
|
+
if (this.runtimeState.getCapState(CAP_NAME$14)?.on === on) return;
|
|
96016
|
+
this.runtimeState.setCapState(CAP_NAME$14, {
|
|
94114
96017
|
on,
|
|
94115
96018
|
lastChangedAt: Date.now()
|
|
94116
96019
|
});
|
|
@@ -94118,7 +96021,7 @@ var DreameAutoSwitchDevice = class extends DreameChildDevice {
|
|
|
94118
96021
|
};
|
|
94119
96022
|
//#endregion
|
|
94120
96023
|
//#region src/devices/dreame-auto-select-device.ts
|
|
94121
|
-
var CAP_NAME$
|
|
96024
|
+
var CAP_NAME$13 = "control";
|
|
94122
96025
|
var dreameAutoSelectOptionSchema = object({
|
|
94123
96026
|
value: number().int(),
|
|
94124
96027
|
label: string().min(1)
|
|
@@ -94173,7 +96076,7 @@ var DreameAutoSelectDevice = class extends DreameChildDevice {
|
|
|
94173
96076
|
}
|
|
94174
96077
|
registerControlCap() {
|
|
94175
96078
|
this.ctx.registerNativeCap(controlCapability, {
|
|
94176
|
-
getStatus: async () => this.runtimeState.getCapState(CAP_NAME$
|
|
96079
|
+
getStatus: async () => this.runtimeState.getCapState(CAP_NAME$13) ?? this.#slice(this.#readLabel() ?? ""),
|
|
94177
96080
|
setValue: async ({ control }) => {
|
|
94178
96081
|
if (control.kind !== "select") throw new Error(`DreameAutoSelectDevice: expected a select value (device=${this.dreameDeviceId}, ${this.#key})`);
|
|
94179
96082
|
const option = this.#options.find((o) => o.label === control.value);
|
|
@@ -94181,16 +96084,16 @@ var DreameAutoSelectDevice = class extends DreameChildDevice {
|
|
|
94181
96084
|
const vacuum = this.#vacuum();
|
|
94182
96085
|
if (vacuum === null) throw new Error(`DreameAutoSelectDevice: handle unavailable (device=${this.dreameDeviceId}, ${this.#key})`);
|
|
94183
96086
|
await vacuum.setAutoSwitchProperty(this.#key, option.value);
|
|
94184
|
-
this.runtimeState.setCapState(CAP_NAME$
|
|
96087
|
+
this.runtimeState.setCapState(CAP_NAME$13, this.#slice(option.label));
|
|
94185
96088
|
}
|
|
94186
96089
|
});
|
|
94187
|
-
this.runtimeState.setCapState(CAP_NAME$
|
|
96090
|
+
this.runtimeState.setCapState(CAP_NAME$13, this.#slice(this.#readLabel() ?? ""));
|
|
94188
96091
|
}
|
|
94189
96092
|
recomputeSlice() {
|
|
94190
96093
|
const label = this.#readLabel();
|
|
94191
96094
|
if (label === null) return;
|
|
94192
|
-
if (this.runtimeState.getCapState(CAP_NAME$
|
|
94193
|
-
this.runtimeState.setCapState(CAP_NAME$
|
|
96095
|
+
if (this.runtimeState.getCapState(CAP_NAME$13)?.value === label) return;
|
|
96096
|
+
this.runtimeState.setCapState(CAP_NAME$13, this.#slice(label));
|
|
94194
96097
|
}
|
|
94195
96098
|
};
|
|
94196
96099
|
//#endregion
|
|
@@ -94233,6 +96136,779 @@ var DreameButtonDevice = class extends DreameChildDevice {
|
|
|
94233
96136
|
recomputeSlice() {}
|
|
94234
96137
|
};
|
|
94235
96138
|
//#endregion
|
|
96139
|
+
//#region src/dreame-vacuum-decode.ts
|
|
96140
|
+
/**
|
|
96141
|
+
* Pure decode helpers for the Dreame vacuum. Translate the library's MIoT enum
|
|
96142
|
+
* values into the camstack `vacuum-control` slice shape. Unit-tested in
|
|
96143
|
+
* isolation (no facade, no network) — mirrors `homematic-decode.ts`.
|
|
96144
|
+
*/
|
|
96145
|
+
/**
|
|
96146
|
+
* Map a Dreame {@link MiotState} (siid 2 piid 1) onto the camstack canonical
|
|
96147
|
+
* `VacuumState` lifecycle (`idle` / `cleaning` / `paused` / `returning` /
|
|
96148
|
+
* `docked` / `error`). A `null` state (not yet observed) settles on `idle`.
|
|
96149
|
+
*
|
|
96150
|
+
* The MIoT state space is large (~50 values); we collapse it onto the six
|
|
96151
|
+
* canonical states by family. Anything unrecognised falls through to `idle`
|
|
96152
|
+
* (the safe neutral) so a firmware-added code never throws.
|
|
96153
|
+
*/
|
|
96154
|
+
function mapMiotStateToVacuumState(state) {
|
|
96155
|
+
if (state === null) return "idle";
|
|
96156
|
+
switch (state) {
|
|
96157
|
+
case MiotState.Charging:
|
|
96158
|
+
case MiotState.ChargingComplete:
|
|
96159
|
+
case MiotState.IntelligentCharging:
|
|
96160
|
+
case MiotState.CleanWashboardBase:
|
|
96161
|
+
case MiotState.AutoRepairBase:
|
|
96162
|
+
case MiotState.WaterSupplyDrainTest:
|
|
96163
|
+
case MiotState.CleanMopRefillWater:
|
|
96164
|
+
case MiotState.AutoEmptying:
|
|
96165
|
+
case MiotState.Emptying:
|
|
96166
|
+
case MiotState.Draining:
|
|
96167
|
+
case MiotState.AutoWaterDraining:
|
|
96168
|
+
case MiotState.Sanitizing: return "docked";
|
|
96169
|
+
case MiotState.MopDrying:
|
|
96170
|
+
case MiotState.DustBagDrying:
|
|
96171
|
+
case MiotState.SanitizingWithDry: return "drying";
|
|
96172
|
+
case MiotState.ReturningToCharge:
|
|
96173
|
+
case MiotState.ReturningToWash:
|
|
96174
|
+
case MiotState.ReturnInstallMop:
|
|
96175
|
+
case MiotState.ReturnRemoveMop:
|
|
96176
|
+
case MiotState.ReturnToEmpty:
|
|
96177
|
+
case MiotState.ReturningToDrain: return "returning";
|
|
96178
|
+
case MiotState.Paused:
|
|
96179
|
+
case MiotState.PausedAlt:
|
|
96180
|
+
case MiotState.PausedCleaning:
|
|
96181
|
+
case MiotState.DustBagDryingPaused:
|
|
96182
|
+
case MiotState.CameraMonitoringPaused:
|
|
96183
|
+
case MiotState.FindingPetPaused:
|
|
96184
|
+
case MiotState.InitialDeepCleanPaused:
|
|
96185
|
+
case MiotState.ChangingMopPaused:
|
|
96186
|
+
case MiotState.FloorMaintainingPaused: return "paused";
|
|
96187
|
+
case MiotState.Standby:
|
|
96188
|
+
case MiotState.WaitingForTask: return "idle";
|
|
96189
|
+
default: return "cleaning";
|
|
96190
|
+
}
|
|
96191
|
+
}
|
|
96192
|
+
/**
|
|
96193
|
+
* The camstack `fanSpeed` token for a Dreame {@link SuctionLevel} — the enum
|
|
96194
|
+
* member NAME, lower-cased (`Quiet` → `'quiet'`). Returns `null` for an
|
|
96195
|
+
* unobserved / unknown level.
|
|
96196
|
+
*/
|
|
96197
|
+
function suctionLevelToken(level) {
|
|
96198
|
+
if (level === null) return null;
|
|
96199
|
+
const name = SuctionLevel[level];
|
|
96200
|
+
return typeof name === "string" ? name.toLowerCase() : null;
|
|
96201
|
+
}
|
|
96202
|
+
/**
|
|
96203
|
+
* Resolve a camstack `fanSpeed` token back to the Dreame {@link SuctionLevel}
|
|
96204
|
+
* enum value, validated against the model's `supported` levels. Returns null
|
|
96205
|
+
* when the token doesn't match a supported level (the caller rejects the set).
|
|
96206
|
+
*/
|
|
96207
|
+
function tokenToSuctionLevel(token, supported) {
|
|
96208
|
+
const wanted = token.trim().toLowerCase();
|
|
96209
|
+
for (const level of supported) if (suctionLevelToken(level) === wanted) return level;
|
|
96210
|
+
return null;
|
|
96211
|
+
}
|
|
96212
|
+
/** The list of accepted `fanSpeed` tokens for a model's supported suction set. */
|
|
96213
|
+
function supportedFanSpeedTokens(supported) {
|
|
96214
|
+
const tokens = [];
|
|
96215
|
+
for (const level of supported) {
|
|
96216
|
+
const token = suctionLevelToken(level);
|
|
96217
|
+
if (token !== null) tokens.push(token);
|
|
96218
|
+
}
|
|
96219
|
+
return tokens;
|
|
96220
|
+
}
|
|
96221
|
+
/**
|
|
96222
|
+
* Clamp a raw battery reading (already a 0..100 percentage from the library)
|
|
96223
|
+
* to the `vacuum-control` slice's `batteryLevel` field. Returns null for a
|
|
96224
|
+
* null/NaN/out-of-band reading so the slice stays valid (the schema rejects
|
|
96225
|
+
* a number outside 0..100).
|
|
96226
|
+
*/
|
|
96227
|
+
function clampBatteryLevel$1(value) {
|
|
96228
|
+
if (value === null || !Number.isFinite(value)) return null;
|
|
96229
|
+
return Math.max(0, Math.min(100, value));
|
|
96230
|
+
}
|
|
96231
|
+
/** Clamp a 0..100 percentage (progress, coverage, …), null-safe. */
|
|
96232
|
+
function clampPercent$1(value) {
|
|
96233
|
+
if (value === null || !Number.isFinite(value)) return null;
|
|
96234
|
+
return Math.max(0, Math.min(100, value));
|
|
96235
|
+
}
|
|
96236
|
+
/**
|
|
96237
|
+
* Clean-water tank `status`: prefer the richer clean-water-tank-status (27/1),
|
|
96238
|
+
* fall back to the coarser water-tank presence (4/6). `null` when neither maps
|
|
96239
|
+
* to a known good/low reading (NOT_INSTALLED / CHECKING / unknown). Pure.
|
|
96240
|
+
*/
|
|
96241
|
+
function cleanWaterTankStatus(props) {
|
|
96242
|
+
if (props.cleanWaterTankStatus !== null) {
|
|
96243
|
+
if (props.cleanWaterTankStatus === 0) return "ok";
|
|
96244
|
+
if (props.cleanWaterTankStatus === 2) return "low";
|
|
96245
|
+
return null;
|
|
96246
|
+
}
|
|
96247
|
+
if (props.waterTank === 1 || props.waterTank === 10) return "ok";
|
|
96248
|
+
return null;
|
|
96249
|
+
}
|
|
96250
|
+
/** Detergent tank `status` from detergent-status (27/4). Pure. */
|
|
96251
|
+
function detergentTankStatus(value) {
|
|
96252
|
+
if (value === 0) return "ok";
|
|
96253
|
+
if (value === 2) return "low";
|
|
96254
|
+
return null;
|
|
96255
|
+
}
|
|
96256
|
+
/** Dust-bin `status` from dust-collection (15/3); OVER_USE reads as `full`. Pure. */
|
|
96257
|
+
function dustBinTankStatus(value) {
|
|
96258
|
+
if (value === 1) return "ok";
|
|
96259
|
+
if (value === 2) return "full";
|
|
96260
|
+
return null;
|
|
96261
|
+
}
|
|
96262
|
+
/**
|
|
96263
|
+
* The four `vacuum-control` tank fields (clean-water / dirty-water / detergent /
|
|
96264
|
+
* dust-bin) decoded from the model's {@link VacuumCapabilities}, with the
|
|
96265
|
+
* discrete `status` populated from the live MIoT enum props when supplied. Pure.
|
|
96266
|
+
*
|
|
96267
|
+
* The nodedreame library exposes NO tank FILL-LEVEL telemetry — the cloud shadow
|
|
96268
|
+
* (verified against a real `dreame.vacuum.r2538z`) carries only the water-VOLUME
|
|
96269
|
+
* *setting* (a mop-flow level, NOT a tank fill). We therefore never fabricate a
|
|
96270
|
+
* `level`; `level` is always `null`. The cap contract is honoured on PRESENCE,
|
|
96271
|
+
* with `status` derived from the tank-status enums:
|
|
96272
|
+
*
|
|
96273
|
+
* - a field is `null` when the hardware HAS NO such tank (capability flags), and
|
|
96274
|
+
* - `{ level: null, status }` when the tank EXISTS — `status` from the enum
|
|
96275
|
+
* prop ('ok' / 'low' / 'full'), or `null` when the device reports none.
|
|
96276
|
+
*
|
|
96277
|
+
* Presence is derived from the capability record:
|
|
96278
|
+
* - clean-water ← `canMop` (a mopping robot carries a clean-water reservoir)
|
|
96279
|
+
* - dirty-water ← `canMopWash` (a self-wash dock has a recovery/dirty tank)
|
|
96280
|
+
* - detergent ← `hasDetergentReservoir`
|
|
96281
|
+
* - dust-bin ← `canAutoEmpty` (an auto-empty robot has an onboard dust bin)
|
|
96282
|
+
*/
|
|
96283
|
+
function decodeVacuumTanks(caps, props) {
|
|
96284
|
+
const tank = (present, status) => present ? {
|
|
96285
|
+
level: null,
|
|
96286
|
+
status
|
|
96287
|
+
} : null;
|
|
96288
|
+
return {
|
|
96289
|
+
cleanWater: tank(Boolean(caps?.canMop), props ? cleanWaterTankStatus(props) : null),
|
|
96290
|
+
dirtyWater: tank(Boolean(caps?.canMopWash), null),
|
|
96291
|
+
detergent: tank(Boolean(caps?.hasDetergentReservoir), props ? detergentTankStatus(props.detergentStatus) : null),
|
|
96292
|
+
dustBin: tank(Boolean(caps?.canAutoEmpty), props ? dustBinTankStatus(props.dustCollection) : null)
|
|
96293
|
+
};
|
|
96294
|
+
}
|
|
96295
|
+
/**
|
|
96296
|
+
* Clamp a raw consumable percentage to `[0, 100]`, or return `null` when the
|
|
96297
|
+
* value is absent / non-finite. The library reports remaining life as a 0..100
|
|
96298
|
+
* integer; we clamp defensively so an out-of-range firmware value never violates
|
|
96299
|
+
* the cap schema.
|
|
96300
|
+
*/
|
|
96301
|
+
function clampConsumablePct(value) {
|
|
96302
|
+
if (value === null || !Number.isFinite(value)) return null;
|
|
96303
|
+
return Math.max(0, Math.min(100, value));
|
|
96304
|
+
}
|
|
96305
|
+
/**
|
|
96306
|
+
* Build the `consumables` cap runtime-state slice from a live {@link VacuumDevice}
|
|
96307
|
+
* handle. The returned object satisfies the cap's `runtimeState` schema
|
|
96308
|
+
* (`ConsumablesStatusSchema.extend({ lastFetchedAt: z.number() })`).
|
|
96309
|
+
*
|
|
96310
|
+
* PRESENCE-driven (mirrors HA): the library's `supportedConsumables` enumerates
|
|
96311
|
+
* ONLY the consumables this model reports (a present MIoT life property) — main/
|
|
96312
|
+
* side brush, filter, sensor, tank-filter, mop-pad, silver-ion, detergent,
|
|
96313
|
+
* squeegee, dust-bag — each with its remaining-life % and whether a reset action
|
|
96314
|
+
* exists. No hardcoded per-model list: a consumable the device does not expose
|
|
96315
|
+
* never appears, and a `resettable` item drives the real MIoT reset action.
|
|
96316
|
+
*/
|
|
96317
|
+
function buildVacuumConsumables(vacuum) {
|
|
96318
|
+
const items = vacuum.supportedConsumables.map((c) => ({
|
|
96319
|
+
key: c.key,
|
|
96320
|
+
label: c.label,
|
|
96321
|
+
level: clampConsumablePct(c.leftPct),
|
|
96322
|
+
status: null,
|
|
96323
|
+
lastResetAt: null,
|
|
96324
|
+
resettable: c.resettable
|
|
96325
|
+
}));
|
|
96326
|
+
const now = Date.now();
|
|
96327
|
+
return {
|
|
96328
|
+
items: [...items],
|
|
96329
|
+
lastChangedAt: now,
|
|
96330
|
+
lastFetchedAt: now
|
|
96331
|
+
};
|
|
96332
|
+
}
|
|
96333
|
+
//#endregion
|
|
96334
|
+
//#region src/devices/dreame-navigation-actions.ts
|
|
96335
|
+
/** Forward throttle magnitude for a full-forward nudge (device speed units). */
|
|
96336
|
+
var FORWARD_SPEED = 200;
|
|
96337
|
+
/** Turn magnitudes: left = +45, right = -45, spin/turn-around = +180. */
|
|
96338
|
+
var TURN_LEFT = 45;
|
|
96339
|
+
var TURN_RIGHT = -45;
|
|
96340
|
+
var SPIN = 180;
|
|
96341
|
+
/** The `setFillLight` value that selects auto. */
|
|
96342
|
+
var FILL_LIGHT_AUTO$1 = 101;
|
|
96343
|
+
/**
|
|
96344
|
+
* Map a normalized {@link NavigationMoveCommand} ([-1, 1] per axis) to a discrete
|
|
96345
|
+
* Dreame drive vector. The robot has no proportional joystick, so intensity is
|
|
96346
|
+
* quantized to the RE'd discrete nudges. Priority: forward, then spin
|
|
96347
|
+
* (tilt-down), then turn (pan). An all-zero / empty command is a stop.
|
|
96348
|
+
*/
|
|
96349
|
+
function driveVector(cmd) {
|
|
96350
|
+
const pan = cmd.pan ?? 0;
|
|
96351
|
+
const tilt = cmd.tilt ?? 0;
|
|
96352
|
+
if (tilt > 0) return {
|
|
96353
|
+
spdv: FORWARD_SPEED,
|
|
96354
|
+
spdw: 0
|
|
96355
|
+
};
|
|
96356
|
+
if (tilt < 0) return {
|
|
96357
|
+
spdv: 0,
|
|
96358
|
+
spdw: SPIN
|
|
96359
|
+
};
|
|
96360
|
+
if (pan < 0) return {
|
|
96361
|
+
spdv: 0,
|
|
96362
|
+
spdw: TURN_LEFT
|
|
96363
|
+
};
|
|
96364
|
+
if (pan > 0) return {
|
|
96365
|
+
spdv: 0,
|
|
96366
|
+
spdw: TURN_RIGHT
|
|
96367
|
+
};
|
|
96368
|
+
return {
|
|
96369
|
+
spdv: 0,
|
|
96370
|
+
spdw: 0
|
|
96371
|
+
};
|
|
96372
|
+
}
|
|
96373
|
+
function moveCall(cmd) {
|
|
96374
|
+
const { spdv, spdw } = driveVector(cmd);
|
|
96375
|
+
return {
|
|
96376
|
+
method: "drive",
|
|
96377
|
+
spdv,
|
|
96378
|
+
spdw
|
|
96379
|
+
};
|
|
96380
|
+
}
|
|
96381
|
+
/** The explicit halt: a zero drive vector. */
|
|
96382
|
+
function stopCall() {
|
|
96383
|
+
return {
|
|
96384
|
+
method: "drive",
|
|
96385
|
+
spdv: 0,
|
|
96386
|
+
spdw: 0
|
|
96387
|
+
};
|
|
96388
|
+
}
|
|
96389
|
+
/** Fill light on (full) / off (back to auto) — the control that works anytime. */
|
|
96390
|
+
function lightOnCall(on) {
|
|
96391
|
+
return {
|
|
96392
|
+
method: "setFillLightAuto",
|
|
96393
|
+
full: on
|
|
96394
|
+
};
|
|
96395
|
+
}
|
|
96396
|
+
/** Fill-light mode: auto, or manual at `level` (40..100). Camera-service; needs a stream. */
|
|
96397
|
+
function lightModeCall(mode, level) {
|
|
96398
|
+
return {
|
|
96399
|
+
method: "setFillLight",
|
|
96400
|
+
value: mode === "auto" ? FILL_LIGHT_AUTO$1 : level ?? 100
|
|
96401
|
+
};
|
|
96402
|
+
}
|
|
96403
|
+
function lightLevelCall(level) {
|
|
96404
|
+
return lightModeCall("manual", level);
|
|
96405
|
+
}
|
|
96406
|
+
function playSoundCall(soundId) {
|
|
96407
|
+
return {
|
|
96408
|
+
method: "playSound",
|
|
96409
|
+
soundId
|
|
96410
|
+
};
|
|
96411
|
+
}
|
|
96412
|
+
function goToPointCall(point) {
|
|
96413
|
+
return {
|
|
96414
|
+
method: "goToPoint",
|
|
96415
|
+
x: point.x,
|
|
96416
|
+
y: point.y
|
|
96417
|
+
};
|
|
96418
|
+
}
|
|
96419
|
+
/** A discrete {@link NavigationActionId} → the controller method that performs it. */
|
|
96420
|
+
function actionCall(actionId) {
|
|
96421
|
+
switch (actionId) {
|
|
96422
|
+
case "goHome": return { method: "returnToDock" };
|
|
96423
|
+
case "locate": return { method: "locate" };
|
|
96424
|
+
case "spotClean": return { method: "spotClean" };
|
|
96425
|
+
case "findPet": return { method: "findPet" };
|
|
96426
|
+
case "personFollow": return { method: "startPersonFollow" };
|
|
96427
|
+
case "stop": return { method: "stopWork" };
|
|
96428
|
+
case "startClean": return {
|
|
96429
|
+
method: "runVacuumAction",
|
|
96430
|
+
action: "startClean"
|
|
96431
|
+
};
|
|
96432
|
+
case "pauseClean": return {
|
|
96433
|
+
method: "runVacuumAction",
|
|
96434
|
+
action: "pauseClean"
|
|
96435
|
+
};
|
|
96436
|
+
case "dockWash": return {
|
|
96437
|
+
method: "runVacuumAction",
|
|
96438
|
+
action: "dockWash"
|
|
96439
|
+
};
|
|
96440
|
+
case "autoEmpty": return {
|
|
96441
|
+
method: "runVacuumAction",
|
|
96442
|
+
action: "autoEmpty"
|
|
96443
|
+
};
|
|
96444
|
+
case "flashOn": return lightOnCall(true);
|
|
96445
|
+
case "flashOff": return lightOnCall(false);
|
|
96446
|
+
}
|
|
96447
|
+
}
|
|
96448
|
+
/** Perform one call on a controller. Exhaustive — a new method is a compile error here. */
|
|
96449
|
+
async function runControllerCall(controller, call) {
|
|
96450
|
+
switch (call.method) {
|
|
96451
|
+
case "drive":
|
|
96452
|
+
await controller.drive(call.spdv, call.spdw);
|
|
96453
|
+
return;
|
|
96454
|
+
case "stopWork":
|
|
96455
|
+
await controller.stopWork();
|
|
96456
|
+
return;
|
|
96457
|
+
case "startPersonFollow":
|
|
96458
|
+
await controller.startPersonFollow();
|
|
96459
|
+
return;
|
|
96460
|
+
case "spotClean":
|
|
96461
|
+
await controller.spotClean();
|
|
96462
|
+
return;
|
|
96463
|
+
case "findPet":
|
|
96464
|
+
await controller.findPet();
|
|
96465
|
+
return;
|
|
96466
|
+
case "playSound":
|
|
96467
|
+
await controller.playSound(call.soundId);
|
|
96468
|
+
return;
|
|
96469
|
+
case "runVacuumAction":
|
|
96470
|
+
await controller.runVacuumAction(call.action);
|
|
96471
|
+
return;
|
|
96472
|
+
case "goToPoint":
|
|
96473
|
+
await controller.goToPoint([[call.x, call.y]], [[call.x, call.y]]);
|
|
96474
|
+
return;
|
|
96475
|
+
case "setFillLight":
|
|
96476
|
+
await controller.setFillLight(call.value);
|
|
96477
|
+
return;
|
|
96478
|
+
case "setFillLightAuto":
|
|
96479
|
+
await controller.setFillLightAuto(call.full);
|
|
96480
|
+
return;
|
|
96481
|
+
case "returnToDock":
|
|
96482
|
+
await controller.returnToDock();
|
|
96483
|
+
return;
|
|
96484
|
+
case "locate":
|
|
96485
|
+
await controller.locate();
|
|
96486
|
+
return;
|
|
96487
|
+
}
|
|
96488
|
+
}
|
|
96489
|
+
//#endregion
|
|
96490
|
+
//#region src/devices/dreame-navigation-config.ts
|
|
96491
|
+
/**
|
|
96492
|
+
* DATA-DRIVEN feature flags for the Dreame navigation surface. Keeping these as
|
|
96493
|
+
* data (rather than branches in the device class) is what makes enabling /
|
|
96494
|
+
* disabling a control later a CONFIG change, not a code change — a future step
|
|
96495
|
+
* merges per-device / per-addon overrides on top of these defaults before the
|
|
96496
|
+
* cap reports them.
|
|
96497
|
+
*
|
|
96498
|
+
* `goToPoint` ships OFF: the send-to-map-coordinate path needs map-coordinate
|
|
96499
|
+
* plumbing (resolving a tap on the live map to robot coordinates) that is not
|
|
96500
|
+
* wired yet. Every other primitive defaults ON.
|
|
96501
|
+
*/
|
|
96502
|
+
var DREAME_NAVIGATION_DEFAULT_FEATURES = {
|
|
96503
|
+
move: true,
|
|
96504
|
+
stop: true,
|
|
96505
|
+
goToPoint: false,
|
|
96506
|
+
runAction: true,
|
|
96507
|
+
playSound: true,
|
|
96508
|
+
light: true,
|
|
96509
|
+
lightMode: true
|
|
96510
|
+
};
|
|
96511
|
+
/**
|
|
96512
|
+
* Dictionary-entry ids disabled by default for Dreame (data-driven per-entry
|
|
96513
|
+
* flags). Empty today — every catalog action / sound is enabled. A future
|
|
96514
|
+
* config override drops ids in here (or removes them) without touching code.
|
|
96515
|
+
*/
|
|
96516
|
+
var DREAME_NAVIGATION_DISABLED_ENTRY_IDS = /* @__PURE__ */ new Set();
|
|
96517
|
+
/**
|
|
96518
|
+
* The action dictionary as THIS provider reports it: the shared catalog with the
|
|
96519
|
+
* per-entry `enabled` flag resolved from {@link DREAME_NAVIGATION_DISABLED_ENTRY_IDS}.
|
|
96520
|
+
* Pure — the device method just returns this (a later step layers device config
|
|
96521
|
+
* on top).
|
|
96522
|
+
*/
|
|
96523
|
+
function dreameNavigationCatalog(disabledEntryIds = DREAME_NAVIGATION_DISABLED_ENTRY_IDS) {
|
|
96524
|
+
return NAVIGATION_ACTION_CATALOG.map((entry) => ({
|
|
96525
|
+
...entry,
|
|
96526
|
+
enabled: entry.enabled && !disabledEntryIds.has(entry.id)
|
|
96527
|
+
}));
|
|
96528
|
+
}
|
|
96529
|
+
/** The feature flags in force for ONE device: defaults, then its overrides. */
|
|
96530
|
+
function resolveNavigationFeatures(overrides) {
|
|
96531
|
+
return {
|
|
96532
|
+
...DREAME_NAVIGATION_DEFAULT_FEATURES,
|
|
96533
|
+
...overrides?.features ?? {}
|
|
96534
|
+
};
|
|
96535
|
+
}
|
|
96536
|
+
/** The disabled entry ids in force for ONE device: the addon set plus its own. */
|
|
96537
|
+
function resolveDisabledEntryIds(overrides) {
|
|
96538
|
+
return new Set([...DREAME_NAVIGATION_DISABLED_ENTRY_IDS, ...overrides?.disabledEntryIds ?? []]);
|
|
96539
|
+
}
|
|
96540
|
+
//#endregion
|
|
96541
|
+
//#region src/devices/dreame-navigation-ptz-adapter.ts
|
|
96542
|
+
/** A gimbal descriptor for a robot: the joystick is pan/tilt, there is no lens. */
|
|
96543
|
+
function ptzOptionsFromFeatures(features) {
|
|
96544
|
+
return {
|
|
96545
|
+
hasPan: features.move,
|
|
96546
|
+
hasTilt: features.move,
|
|
96547
|
+
hasZoom: false,
|
|
96548
|
+
supportsPresets: features.runAction || features.playSound,
|
|
96549
|
+
hasAutofocus: false
|
|
96550
|
+
};
|
|
96551
|
+
}
|
|
96552
|
+
/** Every ENABLED dictionary entry is a preset; the label is the preset name. */
|
|
96553
|
+
function presetsFromCatalog(entries, features) {
|
|
96554
|
+
return entries.filter((entry) => entry.enabled).filter((entry) => entry.kind === "action" ? features.runAction : features.playSound).map((entry) => ({
|
|
96555
|
+
id: entry.id,
|
|
96556
|
+
name: entry.label
|
|
96557
|
+
}));
|
|
96558
|
+
}
|
|
96559
|
+
/**
|
|
96560
|
+
* Resolve a preset id back to the navigation call. `null` when the id names
|
|
96561
|
+
* nothing enabled — the caller rejects rather than guessing an action.
|
|
96562
|
+
*/
|
|
96563
|
+
function presetTarget(entries, presetId) {
|
|
96564
|
+
const entry = entries.find((candidate) => candidate.id === presetId && candidate.enabled);
|
|
96565
|
+
if (!entry) return null;
|
|
96566
|
+
if (entry.kind === "sound") return entry.soundId === void 0 ? null : {
|
|
96567
|
+
kind: "sound",
|
|
96568
|
+
soundId: entry.soundId
|
|
96569
|
+
};
|
|
96570
|
+
const parsed = NavigationActionIdSchema.safeParse(entry.id);
|
|
96571
|
+
return parsed.success ? {
|
|
96572
|
+
kind: "action",
|
|
96573
|
+
actionId: parsed.data
|
|
96574
|
+
} : null;
|
|
96575
|
+
}
|
|
96576
|
+
function clampAxis(value) {
|
|
96577
|
+
if (value === void 0 || !Number.isFinite(value)) return void 0;
|
|
96578
|
+
return Math.max(-1, Math.min(1, value));
|
|
96579
|
+
}
|
|
96580
|
+
/**
|
|
96581
|
+
* A PTZ move is a navigation nudge: pan turns, tilt drives, zoom has no
|
|
96582
|
+
* meaning on a chassis and is dropped. Axes are clamped to the cap's [-1, 1].
|
|
96583
|
+
*/
|
|
96584
|
+
function ptzMoveToNavigation(cmd) {
|
|
96585
|
+
const pan = clampAxis(cmd.pan);
|
|
96586
|
+
const tilt = clampAxis(cmd.tilt);
|
|
96587
|
+
const speed = cmd.speed === void 0 || !Number.isFinite(cmd.speed) ? void 0 : Math.max(0, Math.min(1, cmd.speed));
|
|
96588
|
+
return {
|
|
96589
|
+
...pan !== void 0 ? { pan } : {},
|
|
96590
|
+
...tilt !== void 0 ? { tilt } : {},
|
|
96591
|
+
...speed !== void 0 ? { speed } : {}
|
|
96592
|
+
};
|
|
96593
|
+
}
|
|
96594
|
+
/** The camera-service fill-light mode/level property. */
|
|
96595
|
+
var FILL_LIGHT_SET_SIID = 10001;
|
|
96596
|
+
/** The wire value that means "auto" on `fillinLightSet`. */
|
|
96597
|
+
var FILL_LIGHT_AUTO = 101;
|
|
96598
|
+
function asRecord(raw) {
|
|
96599
|
+
if (typeof raw === "string") try {
|
|
96600
|
+
const parsed = JSON.parse(raw);
|
|
96601
|
+
return parsed !== null && typeof parsed === "object" ? parsed : null;
|
|
96602
|
+
} catch {
|
|
96603
|
+
return null;
|
|
96604
|
+
}
|
|
96605
|
+
return raw !== null && typeof raw === "object" ? raw : null;
|
|
96606
|
+
}
|
|
96607
|
+
/** `4/50` → `{ flash }`, only when the packed key is the fill light. */
|
|
96608
|
+
function decodeFillLight(raw) {
|
|
96609
|
+
const record = asRecord(raw);
|
|
96610
|
+
if (!record || record["k"] !== "FillinLight") return {};
|
|
96611
|
+
const v = record["v"];
|
|
96612
|
+
if (v === 1 || v === "1" || v === true) return { flash: true };
|
|
96613
|
+
if (v === 0 || v === "0" || v === false) return { flash: false };
|
|
96614
|
+
return {};
|
|
96615
|
+
}
|
|
96616
|
+
/** `10001/9` → `{ lightMode, lightLevel? }`: 101 is auto, 40..100 a manual level. */
|
|
96617
|
+
function decodeFillLightSet(raw) {
|
|
96618
|
+
const n = typeof raw === "string" ? Number(raw) : typeof raw === "number" ? raw : NaN;
|
|
96619
|
+
if (!Number.isFinite(n)) return {};
|
|
96620
|
+
if (n === FILL_LIGHT_AUTO) return { lightMode: "auto" };
|
|
96621
|
+
if (n >= 40 && n <= 100) return {
|
|
96622
|
+
lightMode: "manual",
|
|
96623
|
+
lightLevel: n
|
|
96624
|
+
};
|
|
96625
|
+
return {};
|
|
96626
|
+
}
|
|
96627
|
+
/** `true` while the robot's own state says it is following; `null` state = not known. */
|
|
96628
|
+
function decodeFollowing(state) {
|
|
96629
|
+
if (state === null || state === void 0 || !Number.isFinite(state)) return null;
|
|
96630
|
+
return state === 26;
|
|
96631
|
+
}
|
|
96632
|
+
/** Everything the robot currently says about its light, as one patch. */
|
|
96633
|
+
function decodeNavigationReadback(read) {
|
|
96634
|
+
return {
|
|
96635
|
+
...decodeFillLight(read(4, 50)),
|
|
96636
|
+
...decodeFillLightSet(read(FILL_LIGHT_SET_SIID, 9))
|
|
96637
|
+
};
|
|
96638
|
+
}
|
|
96639
|
+
//#endregion
|
|
96640
|
+
//#region src/devices/dreame-camera-device.ts
|
|
96641
|
+
var CAP_NAME$12 = "navigation";
|
|
96642
|
+
var NAV_COLD_START = {
|
|
96643
|
+
mode: "unknown",
|
|
96644
|
+
following: false,
|
|
96645
|
+
flash: false,
|
|
96646
|
+
lightMode: "auto",
|
|
96647
|
+
lightLevel: 100,
|
|
96648
|
+
lastChangedAt: 0
|
|
96649
|
+
};
|
|
96650
|
+
/**
|
|
96651
|
+
* Dreame CAMERA + NAVIGATION device — a SEPARATE camstack device from the
|
|
96652
|
+
* `DreameVacuumDevice`, spawned only for camera-capable vacuums (those whose
|
|
96653
|
+
* `vacuumCapabilities.hasCamera` is true, i.e. a LinkVisual video surface). It
|
|
96654
|
+
* owns the `navigation` cap (drive joystick + discrete robot actions + sounds +
|
|
96655
|
+
* fill light); the vacuum device keeps `vacuum-control` / `consumables` / maps.
|
|
96656
|
+
*
|
|
96657
|
+
* PREBUFFER OFF: this is a BATTERY robot whose camera is expensive to cold-start,
|
|
96658
|
+
* so the device advertises {@link DeviceFeature.BatteryOperated} — the SAME flag
|
|
96659
|
+
* the Reolink battery-camera path uses. The stream-broker's
|
|
96660
|
+
* `getEffectivePreBufferSec` battery rule then sets `preBufferSec = 0`, so the
|
|
96661
|
+
* stream opens on demand and is never continuously prebuffered.
|
|
96662
|
+
*
|
|
96663
|
+
* The whole navigation surface goes through nodedreame's typed
|
|
96664
|
+
* `DreameCameraController` (1.12+, built lazily from the vacuum handle with the
|
|
96665
|
+
* pairing PIN when the camera is coded), mapped by `dreame-navigation-actions.ts`.
|
|
96666
|
+
* The controller is never `open()`ed here — the stream is the broker's; drive is
|
|
96667
|
+
* valid only while a stream is open, which is when the panel is used.
|
|
96668
|
+
*
|
|
96669
|
+
* STATUS: `mode` is mapped from the shared vacuum state; `flash` / `lightMode`
|
|
96670
|
+
* / `lightLevel` are decoded from the robot's own properties on every push
|
|
96671
|
+
* (`dreame-navigation-status.ts`: `4/50` and `10001/9`) and, until the first
|
|
96672
|
+
* read-back lands, from the commands this device issued. `following` has no
|
|
96673
|
+
* confirmed read-back yet and stays command-driven.
|
|
96674
|
+
*
|
|
96675
|
+
* PTZ MIMIC: the same device also registers `ptz`, projected from `navigation`
|
|
96676
|
+
* through `dreame-navigation-ptz-adapter.ts` (pure, tested) — the joystick is
|
|
96677
|
+
* pan/tilt, the enabled actions and sounds are the presets, there is no zoom
|
|
96678
|
+
* and no autofocus. `navigation` stays the source of truth; nothing is stored
|
|
96679
|
+
* on the PTZ side.
|
|
96680
|
+
*/
|
|
96681
|
+
var DreameCameraDevice = class extends DreameChildDevice {
|
|
96682
|
+
features = [DeviceFeature.BatteryOperated];
|
|
96683
|
+
constructor(ctx) {
|
|
96684
|
+
super(ctx);
|
|
96685
|
+
}
|
|
96686
|
+
registerControlCap() {
|
|
96687
|
+
const provider = this.buildNavigationProvider();
|
|
96688
|
+
this.ctx.registerNativeCap(navigationCapability, provider);
|
|
96689
|
+
this.runtimeState.setCapState(CAP_NAME$12, NAV_COLD_START);
|
|
96690
|
+
this.ctx.registerNativeCap(ptzCapability, this.buildPtzMimic(provider));
|
|
96691
|
+
}
|
|
96692
|
+
buildNavigationProvider() {
|
|
96693
|
+
return {
|
|
96694
|
+
getStatus: async () => this.runtimeState.getCapState(CAP_NAME$12) ?? NAV_COLD_START,
|
|
96695
|
+
listActions: async () => dreameNavigationCatalog(resolveDisabledEntryIds(this.overrides())),
|
|
96696
|
+
getFeatures: async () => this.buildFeatures(),
|
|
96697
|
+
move: async ({ pan, tilt, speed }) => {
|
|
96698
|
+
await this.dispatch(moveCall({
|
|
96699
|
+
pan,
|
|
96700
|
+
tilt,
|
|
96701
|
+
speed
|
|
96702
|
+
}));
|
|
96703
|
+
},
|
|
96704
|
+
stop: async () => {
|
|
96705
|
+
await this.dispatch(stopCall());
|
|
96706
|
+
},
|
|
96707
|
+
goToPoint: async ({ x, y }) => {
|
|
96708
|
+
await this.dispatch(goToPointCall({
|
|
96709
|
+
x,
|
|
96710
|
+
y
|
|
96711
|
+
}));
|
|
96712
|
+
},
|
|
96713
|
+
runAction: async ({ actionId }) => {
|
|
96714
|
+
await this.dispatch(actionCall(actionId));
|
|
96715
|
+
this.applyOptimisticAction(actionId);
|
|
96716
|
+
},
|
|
96717
|
+
playSound: async ({ soundId }) => {
|
|
96718
|
+
await this.dispatch(playSoundCall(soundId));
|
|
96719
|
+
},
|
|
96720
|
+
setLightOn: async ({ on }) => {
|
|
96721
|
+
await this.dispatch(lightOnCall(on));
|
|
96722
|
+
this.patchStatus({ flash: on });
|
|
96723
|
+
},
|
|
96724
|
+
setLightMode: async ({ mode, level }) => {
|
|
96725
|
+
await this.dispatch(lightModeCall(mode, level));
|
|
96726
|
+
this.patchStatus(mode === "manual" && level !== void 0 ? {
|
|
96727
|
+
lightMode: mode,
|
|
96728
|
+
lightLevel: level
|
|
96729
|
+
} : { lightMode: mode });
|
|
96730
|
+
},
|
|
96731
|
+
setLightLevel: async ({ level }) => {
|
|
96732
|
+
await this.dispatch(lightLevelCall(level));
|
|
96733
|
+
this.patchStatus({
|
|
96734
|
+
lightMode: "manual",
|
|
96735
|
+
lightLevel: level
|
|
96736
|
+
});
|
|
96737
|
+
}
|
|
96738
|
+
};
|
|
96739
|
+
}
|
|
96740
|
+
/**
|
|
96741
|
+
* `ptz` projected from `navigation` (the PTZ-mimic adapter). Every call
|
|
96742
|
+
* forwards to the navigation provider through the pure mapping; a preset
|
|
96743
|
+
* that names nothing enabled is rejected, never guessed.
|
|
96744
|
+
*/
|
|
96745
|
+
buildPtzMimic(nav) {
|
|
96746
|
+
const deviceId = this.id;
|
|
96747
|
+
const noGimbal = (what) => {
|
|
96748
|
+
throw new Error(`DreameCameraDevice: a robot camera has no ${what}`);
|
|
96749
|
+
};
|
|
96750
|
+
return {
|
|
96751
|
+
getStatus: async () => ({
|
|
96752
|
+
pan: 0,
|
|
96753
|
+
tilt: 0,
|
|
96754
|
+
zoom: 0,
|
|
96755
|
+
autofocus: false
|
|
96756
|
+
}),
|
|
96757
|
+
getPosition: async () => ({
|
|
96758
|
+
pan: 0,
|
|
96759
|
+
tilt: 0,
|
|
96760
|
+
zoom: 0
|
|
96761
|
+
}),
|
|
96762
|
+
getOptions: async () => ptzOptionsFromFeatures(await nav.getFeatures({ deviceId })),
|
|
96763
|
+
move: async (cmd) => {
|
|
96764
|
+
await nav.move({
|
|
96765
|
+
deviceId,
|
|
96766
|
+
...ptzMoveToNavigation(cmd)
|
|
96767
|
+
});
|
|
96768
|
+
},
|
|
96769
|
+
continuousMove: async (cmd) => {
|
|
96770
|
+
await nav.move({
|
|
96771
|
+
deviceId,
|
|
96772
|
+
...ptzMoveToNavigation(cmd)
|
|
96773
|
+
});
|
|
96774
|
+
},
|
|
96775
|
+
stop: async () => {
|
|
96776
|
+
await nav.stop({ deviceId });
|
|
96777
|
+
},
|
|
96778
|
+
goHome: async () => {
|
|
96779
|
+
await nav.runAction({
|
|
96780
|
+
deviceId,
|
|
96781
|
+
actionId: "goHome"
|
|
96782
|
+
});
|
|
96783
|
+
},
|
|
96784
|
+
getPresets: async () => presetsFromCatalog(await nav.listActions({ deviceId }), await nav.getFeatures({ deviceId })),
|
|
96785
|
+
goToPreset: async ({ presetId }) => {
|
|
96786
|
+
const target = presetTarget(await nav.listActions({ deviceId }), presetId);
|
|
96787
|
+
if (target === null) throw new Error(`DreameCameraDevice: unknown or disabled navigation preset "${presetId}"`);
|
|
96788
|
+
if (target.kind === "sound") await nav.playSound({
|
|
96789
|
+
deviceId,
|
|
96790
|
+
soundId: target.soundId
|
|
96791
|
+
});
|
|
96792
|
+
else await nav.runAction({
|
|
96793
|
+
deviceId,
|
|
96794
|
+
actionId: target.actionId
|
|
96795
|
+
});
|
|
96796
|
+
},
|
|
96797
|
+
savePreset: async () => noGimbal("saved preset — its presets are its actions"),
|
|
96798
|
+
deletePreset: async () => noGimbal("deletable preset — its presets are its actions"),
|
|
96799
|
+
setAutofocus: async () => noGimbal("autofocus")
|
|
96800
|
+
};
|
|
96801
|
+
}
|
|
96802
|
+
recomputeSlice() {
|
|
96803
|
+
const handle = this.resolveHandle();
|
|
96804
|
+
if (handle === null) return;
|
|
96805
|
+
const prev = this.runtimeState.getCapState(CAP_NAME$12) ?? NAV_COLD_START;
|
|
96806
|
+
const readback = handle instanceof VacuumDevice ? decodeNavigationReadback((siid, piid) => handle.getProperty(siid, piid)?.value) : {};
|
|
96807
|
+
const following = handle instanceof VacuumDevice ? decodeFollowing(handle.status) : null;
|
|
96808
|
+
this.runtimeState.setCapState(CAP_NAME$12, {
|
|
96809
|
+
...prev,
|
|
96810
|
+
...readback,
|
|
96811
|
+
...following !== null ? { following } : {},
|
|
96812
|
+
mode: following === true ? "following" : this.deriveMode(handle),
|
|
96813
|
+
lastChangedAt: Date.now()
|
|
96814
|
+
});
|
|
96815
|
+
}
|
|
96816
|
+
controller = null;
|
|
96817
|
+
controllerHandle = null;
|
|
96818
|
+
/**
|
|
96819
|
+
* The typed controller, built once per live handle (the build resolves the
|
|
96820
|
+
* cloud video profile, so it is a promise shared by every caller); rebuilt
|
|
96821
|
+
* if the handle changes. Never `open()`ed here.
|
|
96822
|
+
*/
|
|
96823
|
+
requireController() {
|
|
96824
|
+
const handle = this.requireHandle();
|
|
96825
|
+
if (!(handle instanceof VacuumDevice)) throw new Error("DreameCameraDevice: the robot handle is not a vacuum — no camera controller");
|
|
96826
|
+
if (this.controller === null || this.controllerHandle !== handle) {
|
|
96827
|
+
const accessCode = this.config.get("navigationAccessCode");
|
|
96828
|
+
this.controllerHandle = handle;
|
|
96829
|
+
this.controller = handle.createCameraController(accessCode !== void 0 && accessCode !== "" ? { accessCode } : {}).catch((err) => {
|
|
96830
|
+
this.controller = null;
|
|
96831
|
+
this.controllerHandle = null;
|
|
96832
|
+
throw err;
|
|
96833
|
+
});
|
|
96834
|
+
}
|
|
96835
|
+
return this.controller;
|
|
96836
|
+
}
|
|
96837
|
+
/** Perform a translated navigation call on the typed controller. */
|
|
96838
|
+
async dispatch(call) {
|
|
96839
|
+
await runControllerCall(await this.requireController(), call);
|
|
96840
|
+
}
|
|
96841
|
+
async removeDevice() {
|
|
96842
|
+
await super.removeDevice();
|
|
96843
|
+
const pending = this.controller;
|
|
96844
|
+
this.controller = null;
|
|
96845
|
+
this.controllerHandle = null;
|
|
96846
|
+
if (pending !== null) await pending.then((controller) => controller.close()).catch(() => void 0);
|
|
96847
|
+
}
|
|
96848
|
+
/** Per-device general-primitive feature flags: the defaults, then this device's overrides. */
|
|
96849
|
+
buildFeatures() {
|
|
96850
|
+
return resolveNavigationFeatures(this.overrides());
|
|
96851
|
+
}
|
|
96852
|
+
/** This device's navigation overrides, straight from its persisted config. */
|
|
96853
|
+
overrides() {
|
|
96854
|
+
const features = this.config.get("navigationFeatures");
|
|
96855
|
+
const disabledEntryIds = this.config.get("navigationDisabledEntryIds");
|
|
96856
|
+
return {
|
|
96857
|
+
...features !== void 0 ? { features } : {},
|
|
96858
|
+
...disabledEntryIds !== void 0 ? { disabledEntryIds } : {}
|
|
96859
|
+
};
|
|
96860
|
+
}
|
|
96861
|
+
/** Map the shared vacuum lifecycle state to a coarse navigation mode. */
|
|
96862
|
+
deriveMode(handle) {
|
|
96863
|
+
if (!(handle instanceof VacuumDevice)) return "unknown";
|
|
96864
|
+
switch (mapMiotStateToVacuumState(handle.status)) {
|
|
96865
|
+
case "cleaning": return "cleaning";
|
|
96866
|
+
case "paused": return "paused";
|
|
96867
|
+
case "returning": return "returning";
|
|
96868
|
+
case "idle":
|
|
96869
|
+
case "docked":
|
|
96870
|
+
case "drying": return "idle";
|
|
96871
|
+
default: return "unknown";
|
|
96872
|
+
}
|
|
96873
|
+
}
|
|
96874
|
+
/**
|
|
96875
|
+
* Optimistically reflect a just-issued action in the status slice so the UI
|
|
96876
|
+
* updates immediately (there is no confirmed read-back yet).
|
|
96877
|
+
*/
|
|
96878
|
+
applyOptimisticAction(actionId) {
|
|
96879
|
+
switch (actionId) {
|
|
96880
|
+
case "flashOn":
|
|
96881
|
+
this.patchStatus({ flash: true });
|
|
96882
|
+
return;
|
|
96883
|
+
case "flashOff":
|
|
96884
|
+
this.patchStatus({ flash: false });
|
|
96885
|
+
return;
|
|
96886
|
+
case "personFollow":
|
|
96887
|
+
this.patchStatus({
|
|
96888
|
+
following: true,
|
|
96889
|
+
mode: "following"
|
|
96890
|
+
});
|
|
96891
|
+
return;
|
|
96892
|
+
case "stop":
|
|
96893
|
+
this.patchStatus({ following: false });
|
|
96894
|
+
return;
|
|
96895
|
+
default: return;
|
|
96896
|
+
}
|
|
96897
|
+
}
|
|
96898
|
+
/**
|
|
96899
|
+
* Merge a patch into the status slice. Written THROUGH `runtimeState` — never a
|
|
96900
|
+
* parallel private cache (CLAUDE.md).
|
|
96901
|
+
*/
|
|
96902
|
+
patchStatus(patch) {
|
|
96903
|
+
const prev = this.runtimeState.getCapState(CAP_NAME$12) ?? NAV_COLD_START;
|
|
96904
|
+
this.runtimeState.setCapState(CAP_NAME$12, {
|
|
96905
|
+
...prev,
|
|
96906
|
+
...patch,
|
|
96907
|
+
lastChangedAt: Date.now()
|
|
96908
|
+
});
|
|
96909
|
+
}
|
|
96910
|
+
};
|
|
96911
|
+
//#endregion
|
|
94236
96912
|
//#region src/devices/dreame-enum-sensor-device.ts
|
|
94237
96913
|
var CAP_NAME$11 = "enum-sensor";
|
|
94238
96914
|
/** One persisted enum option: the MIoT int value + its display label. */
|
|
@@ -94457,12 +97133,12 @@ function mapMowerStatusToActivity(status) {
|
|
|
94457
97133
|
}
|
|
94458
97134
|
}
|
|
94459
97135
|
/** Clamp a raw battery reading (0..100 from the library) to the slice field. */
|
|
94460
|
-
function clampBatteryLevel
|
|
97136
|
+
function clampBatteryLevel(value) {
|
|
94461
97137
|
if (value === null || !Number.isFinite(value)) return null;
|
|
94462
97138
|
return Math.max(0, Math.min(100, value));
|
|
94463
97139
|
}
|
|
94464
97140
|
/** Clamp a 0..100 percentage (mowing progress), null-safe. */
|
|
94465
|
-
function clampPercent
|
|
97141
|
+
function clampPercent(value) {
|
|
94466
97142
|
if (value === null || !Number.isFinite(value)) return null;
|
|
94467
97143
|
return Math.max(0, Math.min(100, value));
|
|
94468
97144
|
}
|
|
@@ -94636,11 +97312,11 @@ var DreameMowerDevice = class extends DreameChildDevice {
|
|
|
94636
97312
|
if (mower === null) return;
|
|
94637
97313
|
const status = mower.status;
|
|
94638
97314
|
const code = mower.faultRaw;
|
|
94639
|
-
const livePct = clampPercent
|
|
97315
|
+
const livePct = clampPercent(mower.mowingProgressPct);
|
|
94640
97316
|
const prevPct = this.runtimeState.getCapState(CAP_NAME$9)?.progressPercent;
|
|
94641
97317
|
const slice = {
|
|
94642
97318
|
activity: mapMowerStatusToActivity(status),
|
|
94643
|
-
batteryLevel: clampBatteryLevel
|
|
97319
|
+
batteryLevel: clampBatteryLevel(mower.battery),
|
|
94644
97320
|
progressPercent: livePct ?? prevPct ?? null,
|
|
94645
97321
|
currentCode: code,
|
|
94646
97322
|
currentCodeLabel: code !== null ? MowerFault[code] ?? null : null,
|
|
@@ -95283,204 +97959,21 @@ var DreameSwitchDevice = class extends DreameChildDevice {
|
|
|
95283
97959
|
});
|
|
95284
97960
|
}
|
|
95285
97961
|
};
|
|
95286
|
-
|
|
95287
|
-
|
|
95288
|
-
|
|
95289
|
-
* Pure decode helpers for the Dreame vacuum. Translate the library's MIoT enum
|
|
95290
|
-
* values into the camstack `vacuum-control` slice shape. Unit-tested in
|
|
95291
|
-
* isolation (no facade, no network) — mirrors `homematic-decode.ts`.
|
|
95292
|
-
*/
|
|
95293
|
-
/**
|
|
95294
|
-
* Map a Dreame {@link MiotState} (siid 2 piid 1) onto the camstack canonical
|
|
95295
|
-
* `VacuumState` lifecycle (`idle` / `cleaning` / `paused` / `returning` /
|
|
95296
|
-
* `docked` / `error`). A `null` state (not yet observed) settles on `idle`.
|
|
95297
|
-
*
|
|
95298
|
-
* The MIoT state space is large (~50 values); we collapse it onto the six
|
|
95299
|
-
* canonical states by family. Anything unrecognised falls through to `idle`
|
|
95300
|
-
* (the safe neutral) so a firmware-added code never throws.
|
|
95301
|
-
*/
|
|
95302
|
-
function mapMiotStateToVacuumState(state) {
|
|
95303
|
-
if (state === null) return "idle";
|
|
95304
|
-
switch (state) {
|
|
95305
|
-
case MiotState.Charging:
|
|
95306
|
-
case MiotState.ChargingComplete:
|
|
95307
|
-
case MiotState.IntelligentCharging:
|
|
95308
|
-
case MiotState.CleanWashboardBase:
|
|
95309
|
-
case MiotState.AutoRepairBase:
|
|
95310
|
-
case MiotState.WaterSupplyDrainTest:
|
|
95311
|
-
case MiotState.CleanMopRefillWater:
|
|
95312
|
-
case MiotState.AutoEmptying:
|
|
95313
|
-
case MiotState.Emptying:
|
|
95314
|
-
case MiotState.Draining:
|
|
95315
|
-
case MiotState.AutoWaterDraining:
|
|
95316
|
-
case MiotState.Sanitizing: return "docked";
|
|
95317
|
-
case MiotState.MopDrying:
|
|
95318
|
-
case MiotState.DustBagDrying:
|
|
95319
|
-
case MiotState.SanitizingWithDry: return "drying";
|
|
95320
|
-
case MiotState.ReturningToCharge:
|
|
95321
|
-
case MiotState.ReturningToWash:
|
|
95322
|
-
case MiotState.ReturnInstallMop:
|
|
95323
|
-
case MiotState.ReturnRemoveMop:
|
|
95324
|
-
case MiotState.ReturnToEmpty:
|
|
95325
|
-
case MiotState.ReturningToDrain: return "returning";
|
|
95326
|
-
case MiotState.Paused:
|
|
95327
|
-
case MiotState.PausedAlt:
|
|
95328
|
-
case MiotState.PausedCleaning:
|
|
95329
|
-
case MiotState.DustBagDryingPaused:
|
|
95330
|
-
case MiotState.CameraMonitoringPaused:
|
|
95331
|
-
case MiotState.FindingPetPaused:
|
|
95332
|
-
case MiotState.InitialDeepCleanPaused:
|
|
95333
|
-
case MiotState.ChangingMopPaused:
|
|
95334
|
-
case MiotState.FloorMaintainingPaused: return "paused";
|
|
95335
|
-
case MiotState.Standby:
|
|
95336
|
-
case MiotState.WaitingForTask: return "idle";
|
|
95337
|
-
default: return "cleaning";
|
|
95338
|
-
}
|
|
95339
|
-
}
|
|
95340
|
-
/**
|
|
95341
|
-
* The camstack `fanSpeed` token for a Dreame {@link SuctionLevel} — the enum
|
|
95342
|
-
* member NAME, lower-cased (`Quiet` → `'quiet'`). Returns `null` for an
|
|
95343
|
-
* unobserved / unknown level.
|
|
95344
|
-
*/
|
|
95345
|
-
function suctionLevelToken(level) {
|
|
95346
|
-
if (level === null) return null;
|
|
95347
|
-
const name = SuctionLevel[level];
|
|
95348
|
-
return typeof name === "string" ? name.toLowerCase() : null;
|
|
95349
|
-
}
|
|
95350
|
-
/**
|
|
95351
|
-
* Resolve a camstack `fanSpeed` token back to the Dreame {@link SuctionLevel}
|
|
95352
|
-
* enum value, validated against the model's `supported` levels. Returns null
|
|
95353
|
-
* when the token doesn't match a supported level (the caller rejects the set).
|
|
95354
|
-
*/
|
|
95355
|
-
function tokenToSuctionLevel(token, supported) {
|
|
95356
|
-
const wanted = token.trim().toLowerCase();
|
|
95357
|
-
for (const level of supported) if (suctionLevelToken(level) === wanted) return level;
|
|
95358
|
-
return null;
|
|
95359
|
-
}
|
|
95360
|
-
/** The list of accepted `fanSpeed` tokens for a model's supported suction set. */
|
|
95361
|
-
function supportedFanSpeedTokens(supported) {
|
|
95362
|
-
const tokens = [];
|
|
95363
|
-
for (const level of supported) {
|
|
95364
|
-
const token = suctionLevelToken(level);
|
|
95365
|
-
if (token !== null) tokens.push(token);
|
|
95366
|
-
}
|
|
95367
|
-
return tokens;
|
|
95368
|
-
}
|
|
95369
|
-
/**
|
|
95370
|
-
* Clamp a raw battery reading (already a 0..100 percentage from the library)
|
|
95371
|
-
* to the `vacuum-control` slice's `batteryLevel` field. Returns null for a
|
|
95372
|
-
* null/NaN/out-of-band reading so the slice stays valid (the schema rejects
|
|
95373
|
-
* a number outside 0..100).
|
|
95374
|
-
*/
|
|
95375
|
-
function clampBatteryLevel(value) {
|
|
95376
|
-
if (value === null || !Number.isFinite(value)) return null;
|
|
95377
|
-
return Math.max(0, Math.min(100, value));
|
|
95378
|
-
}
|
|
95379
|
-
/** Clamp a 0..100 percentage (progress, coverage, …), null-safe. */
|
|
95380
|
-
function clampPercent(value) {
|
|
95381
|
-
if (value === null || !Number.isFinite(value)) return null;
|
|
95382
|
-
return Math.max(0, Math.min(100, value));
|
|
95383
|
-
}
|
|
95384
|
-
/**
|
|
95385
|
-
* Clean-water tank `status`: prefer the richer clean-water-tank-status (27/1),
|
|
95386
|
-
* fall back to the coarser water-tank presence (4/6). `null` when neither maps
|
|
95387
|
-
* to a known good/low reading (NOT_INSTALLED / CHECKING / unknown). Pure.
|
|
95388
|
-
*/
|
|
95389
|
-
function cleanWaterTankStatus(props) {
|
|
95390
|
-
if (props.cleanWaterTankStatus !== null) {
|
|
95391
|
-
if (props.cleanWaterTankStatus === 0) return "ok";
|
|
95392
|
-
if (props.cleanWaterTankStatus === 2) return "low";
|
|
95393
|
-
return null;
|
|
95394
|
-
}
|
|
95395
|
-
if (props.waterTank === 1 || props.waterTank === 10) return "ok";
|
|
95396
|
-
return null;
|
|
95397
|
-
}
|
|
95398
|
-
/** Detergent tank `status` from detergent-status (27/4). Pure. */
|
|
95399
|
-
function detergentTankStatus(value) {
|
|
95400
|
-
if (value === 0) return "ok";
|
|
95401
|
-
if (value === 2) return "low";
|
|
95402
|
-
return null;
|
|
95403
|
-
}
|
|
95404
|
-
/** Dust-bin `status` from dust-collection (15/3); OVER_USE reads as `full`. Pure. */
|
|
95405
|
-
function dustBinTankStatus(value) {
|
|
95406
|
-
if (value === 1) return "ok";
|
|
95407
|
-
if (value === 2) return "full";
|
|
95408
|
-
return null;
|
|
95409
|
-
}
|
|
95410
|
-
/**
|
|
95411
|
-
* The four `vacuum-control` tank fields (clean-water / dirty-water / detergent /
|
|
95412
|
-
* dust-bin) decoded from the model's {@link VacuumCapabilities}, with the
|
|
95413
|
-
* discrete `status` populated from the live MIoT enum props when supplied. Pure.
|
|
95414
|
-
*
|
|
95415
|
-
* The nodedreame library exposes NO tank FILL-LEVEL telemetry — the cloud shadow
|
|
95416
|
-
* (verified against a real `dreame.vacuum.r2538z`) carries only the water-VOLUME
|
|
95417
|
-
* *setting* (a mop-flow level, NOT a tank fill). We therefore never fabricate a
|
|
95418
|
-
* `level`; `level` is always `null`. The cap contract is honoured on PRESENCE,
|
|
95419
|
-
* with `status` derived from the tank-status enums:
|
|
95420
|
-
*
|
|
95421
|
-
* - a field is `null` when the hardware HAS NO such tank (capability flags), and
|
|
95422
|
-
* - `{ level: null, status }` when the tank EXISTS — `status` from the enum
|
|
95423
|
-
* prop ('ok' / 'low' / 'full'), or `null` when the device reports none.
|
|
95424
|
-
*
|
|
95425
|
-
* Presence is derived from the capability record:
|
|
95426
|
-
* - clean-water ← `canMop` (a mopping robot carries a clean-water reservoir)
|
|
95427
|
-
* - dirty-water ← `canMopWash` (a self-wash dock has a recovery/dirty tank)
|
|
95428
|
-
* - detergent ← `hasDetergentReservoir`
|
|
95429
|
-
* - dust-bin ← `canAutoEmpty` (an auto-empty robot has an onboard dust bin)
|
|
95430
|
-
*/
|
|
95431
|
-
function decodeVacuumTanks(caps, props) {
|
|
95432
|
-
const tank = (present, status) => present ? {
|
|
95433
|
-
level: null,
|
|
95434
|
-
status
|
|
95435
|
-
} : null;
|
|
95436
|
-
return {
|
|
95437
|
-
cleanWater: tank(Boolean(caps?.canMop), props ? cleanWaterTankStatus(props) : null),
|
|
95438
|
-
dirtyWater: tank(Boolean(caps?.canMopWash), null),
|
|
95439
|
-
detergent: tank(Boolean(caps?.hasDetergentReservoir), props ? detergentTankStatus(props.detergentStatus) : null),
|
|
95440
|
-
dustBin: tank(Boolean(caps?.canAutoEmpty), props ? dustBinTankStatus(props.dustCollection) : null)
|
|
95441
|
-
};
|
|
95442
|
-
}
|
|
95443
|
-
/**
|
|
95444
|
-
* Clamp a raw consumable percentage to `[0, 100]`, or return `null` when the
|
|
95445
|
-
* value is absent / non-finite. The library reports remaining life as a 0..100
|
|
95446
|
-
* integer; we clamp defensively so an out-of-range firmware value never violates
|
|
95447
|
-
* the cap schema.
|
|
95448
|
-
*/
|
|
95449
|
-
function clampConsumablePct(value) {
|
|
95450
|
-
if (value === null || !Number.isFinite(value)) return null;
|
|
95451
|
-
return Math.max(0, Math.min(100, value));
|
|
95452
|
-
}
|
|
95453
|
-
/**
|
|
95454
|
-
* Build the `consumables` cap runtime-state slice from a live {@link VacuumDevice}
|
|
95455
|
-
* handle. The returned object satisfies the cap's `runtimeState` schema
|
|
95456
|
-
* (`ConsumablesStatusSchema.extend({ lastFetchedAt: z.number() })`).
|
|
95457
|
-
*
|
|
95458
|
-
* PRESENCE-driven (mirrors HA): the library's `supportedConsumables` enumerates
|
|
95459
|
-
* ONLY the consumables this model reports (a present MIoT life property) — main/
|
|
95460
|
-
* side brush, filter, sensor, tank-filter, mop-pad, silver-ion, detergent,
|
|
95461
|
-
* squeegee, dust-bag — each with its remaining-life % and whether a reset action
|
|
95462
|
-
* exists. No hardcoded per-model list: a consumable the device does not expose
|
|
95463
|
-
* never appears, and a `resettable` item drives the real MIoT reset action.
|
|
95464
|
-
*/
|
|
95465
|
-
function buildVacuumConsumables(vacuum) {
|
|
95466
|
-
const items = vacuum.supportedConsumables.map((c) => ({
|
|
95467
|
-
key: c.key,
|
|
95468
|
-
label: c.label,
|
|
95469
|
-
level: clampConsumablePct(c.leftPct),
|
|
95470
|
-
status: null,
|
|
95471
|
-
lastResetAt: null,
|
|
95472
|
-
resettable: c.resettable
|
|
95473
|
-
}));
|
|
95474
|
-
const now = Date.now();
|
|
97962
|
+
/** `null` = nothing learned (keep the previous slice). */
|
|
97963
|
+
function networkLinkFromBars(bars, now) {
|
|
97964
|
+
if (bars === null || !Number.isFinite(bars)) return null;
|
|
95475
97965
|
return {
|
|
95476
|
-
|
|
95477
|
-
|
|
95478
|
-
|
|
97966
|
+
type: "wifi",
|
|
97967
|
+
signalPercent: bars === 0 ? null : signalPercentFromBars(bars, 4),
|
|
97968
|
+
lastUpdated: now
|
|
95479
97969
|
};
|
|
95480
97970
|
}
|
|
95481
97971
|
//#endregion
|
|
95482
97972
|
//#region src/devices/dreame-vacuum-device.ts
|
|
95483
97973
|
var CAP_NAME$1 = "vacuum-control";
|
|
97974
|
+
var NETWORK_LINK_CAP_NAME = "network-link";
|
|
97975
|
+
/** The Wi-Fi signal map is a cloud round trip; the bars at the dock barely move. */
|
|
97976
|
+
var NETWORK_LINK_REFRESH_MS = 30 * 6e4;
|
|
95484
97977
|
var CONSUMABLES_CAP_NAME = "consumables";
|
|
95485
97978
|
var CONTROL_COLD_START = {
|
|
95486
97979
|
state: "idle",
|
|
@@ -95536,6 +98029,8 @@ var DreameVacuumDevice = class extends DreameChildDevice {
|
|
|
95536
98029
|
}
|
|
95537
98030
|
async onActivate() {
|
|
95538
98031
|
await super.onActivate();
|
|
98032
|
+
this.refreshNetworkLink("activate");
|
|
98033
|
+
this.networkLinkTimer = setInterval(() => void this.refreshNetworkLink("periodic"), NETWORK_LINK_REFRESH_MS);
|
|
95539
98034
|
const vacuum = this.resolveVacuum();
|
|
95540
98035
|
if (vacuum !== null) {
|
|
95541
98036
|
vacuum.refreshFromCache().catch((err) => {
|
|
@@ -95559,11 +98054,60 @@ var DreameVacuumDevice = class extends DreameChildDevice {
|
|
|
95559
98054
|
}
|
|
95560
98055
|
}
|
|
95561
98056
|
/** Narrow the generic handle to the library's typed {@link DreameVacuum}, or null. */
|
|
98057
|
+
networkLinkTimer = null;
|
|
98058
|
+
networkLinkReported = false;
|
|
98059
|
+
networkLinkFailureWarned = false;
|
|
98060
|
+
/** `network-link`: the read path serves the slice; `refreshNetworkLink` writes it. */
|
|
98061
|
+
registerNetworkLink() {
|
|
98062
|
+
this.ctx.registerNativeCap(networkLinkCapability, { getStatus: async () => this.runtimeState.getCapState(NETWORK_LINK_CAP_NAME) ?? NETWORK_LINK_UNKNOWN });
|
|
98063
|
+
if (this.runtimeState.getCapState(NETWORK_LINK_CAP_NAME) === null) this.runtimeState.setCapState(NETWORK_LINK_CAP_NAME, NETWORK_LINK_UNKNOWN);
|
|
98064
|
+
}
|
|
98065
|
+
/**
|
|
98066
|
+
* Read the bars at the robot's position off its Wi-Fi signal map. Bounded,
|
|
98067
|
+
* best-effort; a failure keeps the last slice. The first read per device is
|
|
98068
|
+
* said at INFO with the raw bars, the first failure at WARN.
|
|
98069
|
+
*/
|
|
98070
|
+
async refreshNetworkLink(reason) {
|
|
98071
|
+
const vacuum = this.resolveVacuum();
|
|
98072
|
+
if (vacuum === null) return;
|
|
98073
|
+
try {
|
|
98074
|
+
const bars = await vacuum.getCurrentSignal({ maxWaitMs: 2e4 });
|
|
98075
|
+
const next = networkLinkFromBars(bars, Date.now());
|
|
98076
|
+
this.ctx.logger[this.networkLinkReported ? "debug" : "info"]("network link read", {
|
|
98077
|
+
tags: { deviceId: this.id },
|
|
98078
|
+
meta: {
|
|
98079
|
+
reason,
|
|
98080
|
+
rawBars: bars,
|
|
98081
|
+
...next ?? {}
|
|
98082
|
+
}
|
|
98083
|
+
});
|
|
98084
|
+
this.networkLinkReported = true;
|
|
98085
|
+
if (next !== null) this.runtimeState.setCapState(NETWORK_LINK_CAP_NAME, next);
|
|
98086
|
+
} catch (err) {
|
|
98087
|
+
const level = this.networkLinkFailureWarned ? "debug" : "warn";
|
|
98088
|
+
this.networkLinkFailureWarned = true;
|
|
98089
|
+
this.ctx.logger[level]("network link read failed — keeping the last slice", {
|
|
98090
|
+
tags: { deviceId: this.id },
|
|
98091
|
+
meta: {
|
|
98092
|
+
reason,
|
|
98093
|
+
error: err instanceof Error ? err.message : String(err)
|
|
98094
|
+
}
|
|
98095
|
+
});
|
|
98096
|
+
}
|
|
98097
|
+
}
|
|
98098
|
+
async removeDevice() {
|
|
98099
|
+
if (this.networkLinkTimer !== null) {
|
|
98100
|
+
clearInterval(this.networkLinkTimer);
|
|
98101
|
+
this.networkLinkTimer = null;
|
|
98102
|
+
}
|
|
98103
|
+
await super.removeDevice();
|
|
98104
|
+
}
|
|
95562
98105
|
resolveVacuum() {
|
|
95563
98106
|
const handle = this.resolveHandle();
|
|
95564
98107
|
return handle instanceof VacuumDevice ? handle : null;
|
|
95565
98108
|
}
|
|
95566
98109
|
registerControlCap() {
|
|
98110
|
+
this.registerNetworkLink();
|
|
95567
98111
|
this.ctx.registerNativeCap(vacuumControlCapability, {
|
|
95568
98112
|
getStatus: async () => this.runtimeState.getCapState(CAP_NAME$1) ?? CONTROL_COLD_START,
|
|
95569
98113
|
start: async () => {
|
|
@@ -95620,14 +98164,14 @@ var DreameVacuumDevice = class extends DreameChildDevice {
|
|
|
95620
98164
|
const tanks = decodeVacuumTanks(vacuum.vacuumCapabilities, this.readTankProps());
|
|
95621
98165
|
const controlSlice = {
|
|
95622
98166
|
state: mapMiotStateToVacuumState(vacuum.status),
|
|
95623
|
-
batteryLevel: clampBatteryLevel(vacuum.battery),
|
|
98167
|
+
batteryLevel: clampBatteryLevel$1(vacuum.battery),
|
|
95624
98168
|
fanSpeed: suctionLevelToken(vacuum.suction),
|
|
95625
98169
|
availableFanSpeeds: supportedFanSpeedTokens(vacuum.supportedSuctionLevels),
|
|
95626
98170
|
cleanWater: tanks.cleanWater,
|
|
95627
98171
|
dirtyWater: tanks.dirtyWater,
|
|
95628
98172
|
detergent: tanks.detergent,
|
|
95629
98173
|
dustBin: tanks.dustBin,
|
|
95630
|
-
progressPercent: clampPercent(vacuum.taskProgressPct),
|
|
98174
|
+
progressPercent: clampPercent$1(vacuum.taskProgressPct),
|
|
95631
98175
|
errorCode: vacuum.errorCode,
|
|
95632
98176
|
errorLabel: vacuum.errorCode ? `Error ${vacuum.errorCode}` : null,
|
|
95633
98177
|
lastChangedAt: Date.now()
|
|
@@ -95953,6 +98497,15 @@ function dreameMowerMapSelectChildFactory(ctx) {
|
|
|
95953
98497
|
return new DreameMowerMapSelectDevice(ctx);
|
|
95954
98498
|
}
|
|
95955
98499
|
/**
|
|
98500
|
+
* Factory for the CAMERA + NAVIGATION child (a `DeviceType.Camera` grouped under
|
|
98501
|
+
* the Container), spawned only for camera-capable vacuums. Owns the `navigation`
|
|
98502
|
+
* cap (drive / actions / sounds / fill light). Selected directly by the
|
|
98503
|
+
* Container's `getAccessoryChildren()` for the camera child spec.
|
|
98504
|
+
*/
|
|
98505
|
+
function dreameCameraChildFactory(ctx) {
|
|
98506
|
+
return new DreameCameraDevice(ctx);
|
|
98507
|
+
}
|
|
98508
|
+
/**
|
|
95956
98509
|
* Factory for an AI-obstacle Switch child (a `DeviceType.Switch` grouped under
|
|
95957
98510
|
* the Container). Like {@link dreameMapChildFactory}, selected directly by the
|
|
95958
98511
|
* Container's `getAccessoryChildren()` — the child config carries the
|
|
@@ -96124,6 +98677,20 @@ var DreameContainerDevice = class extends BaseDevice {
|
|
|
96124
98677
|
factory: dreameMapChildFactory
|
|
96125
98678
|
});
|
|
96126
98679
|
}
|
|
98680
|
+
if (kind === "vacuum" && this.modelSupportsCamera()) {
|
|
98681
|
+
const cameraMeta = {
|
|
98682
|
+
type: DeviceType.Camera,
|
|
98683
|
+
name: this.name.length > 0 ? `${this.name} Camera` : "Camera",
|
|
98684
|
+
linkDeviceId: this.id,
|
|
98685
|
+
...this.integrationId !== void 0 ? { integrationId: this.integrationId } : {}
|
|
98686
|
+
};
|
|
98687
|
+
children.push({
|
|
98688
|
+
stableIdSuffix: "camera",
|
|
98689
|
+
meta: cameraMeta,
|
|
98690
|
+
config,
|
|
98691
|
+
factory: dreameCameraChildFactory
|
|
98692
|
+
});
|
|
98693
|
+
}
|
|
96127
98694
|
if (kind === "vacuum") {
|
|
96128
98695
|
for (const feature of this.supportedAiFeatures()) {
|
|
96129
98696
|
const switchMeta = {
|
|
@@ -96557,6 +99124,7 @@ var DreameContainerDevice = class extends BaseDevice {
|
|
|
96557
99124
|
const caps = handle.vacuumCapabilities;
|
|
96558
99125
|
return {
|
|
96559
99126
|
canMap: caps.canMap,
|
|
99127
|
+
hasCamera: caps.hasCamera,
|
|
96560
99128
|
hasAiObstacleDetection: caps.hasAiObstacleDetection,
|
|
96561
99129
|
aiFeatureCount: caps.hasAiObstacleDetection ? caps.supportedAiFeatures.length : 0
|
|
96562
99130
|
};
|
|
@@ -96584,6 +99152,12 @@ var DreameContainerDevice = class extends BaseDevice {
|
|
|
96584
99152
|
modelSupportsMap() {
|
|
96585
99153
|
return vacuumSupportsMap(this.resolveVacuumCaps());
|
|
96586
99154
|
}
|
|
99155
|
+
/** Whether this vacuum model carries an on-board camera — gates the separate
|
|
99156
|
+
* camera + navigation child. Gated on the RESOLVED handle's confirmed
|
|
99157
|
+
* capability (false when the handle is not up, never optimistic). */
|
|
99158
|
+
modelSupportsCamera() {
|
|
99159
|
+
return vacuumSupportsCamera(this.resolveVacuumCaps());
|
|
99160
|
+
}
|
|
96587
99161
|
/** Whether this mower model supports a map — gated on the RESOLVED handle's
|
|
96588
99162
|
* confirmed capability (false when the handle is not up, never optimistic). */
|
|
96589
99163
|
modelSupportsMowerMap() {
|