@apocaliss92/nodedreame 1.0.0
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/LICENSE +24 -0
- package/README.md +301 -0
- package/dist/index.cjs +3827 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +1493 -0
- package/dist/index.d.ts +1493 -0
- package/dist/index.js +3760 -0
- package/dist/index.js.map +1 -0
- package/package.json +69 -0
package/dist/index.cjs
ADDED
|
@@ -0,0 +1,3827 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __create = Object.create;
|
|
3
|
+
var __defProp = Object.defineProperty;
|
|
4
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
5
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
6
|
+
var __getProtoOf = Object.getPrototypeOf;
|
|
7
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
8
|
+
var __export = (target, all) => {
|
|
9
|
+
for (var name in all)
|
|
10
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
11
|
+
};
|
|
12
|
+
var __copyProps = (to, from, except, desc) => {
|
|
13
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
14
|
+
for (let key2 of __getOwnPropNames(from))
|
|
15
|
+
if (!__hasOwnProp.call(to, key2) && key2 !== except)
|
|
16
|
+
__defProp(to, key2, { get: () => from[key2], enumerable: !(desc = __getOwnPropDesc(from, key2)) || desc.enumerable });
|
|
17
|
+
}
|
|
18
|
+
return to;
|
|
19
|
+
};
|
|
20
|
+
var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
|
|
21
|
+
// If the importer is in node compatibility mode or this is not an ESM
|
|
22
|
+
// file that has been converted to a CommonJS file using a Babel-
|
|
23
|
+
// compatible transform (i.e. "__esModule" has not been set), then set
|
|
24
|
+
// "default" to the CommonJS "module.exports" for node compatibility.
|
|
25
|
+
isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
|
|
26
|
+
mod
|
|
27
|
+
));
|
|
28
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
29
|
+
|
|
30
|
+
// src/index.ts
|
|
31
|
+
var index_exports = {};
|
|
32
|
+
__export(index_exports, {
|
|
33
|
+
BaseDevice: () => BaseDevice,
|
|
34
|
+
ChargingStatus: () => ChargingStatus,
|
|
35
|
+
CleaningMode: () => CleaningMode,
|
|
36
|
+
DefaultCapabilityResolver: () => DefaultCapabilityResolver,
|
|
37
|
+
DreameApiError: () => DreameApiError,
|
|
38
|
+
DreameAuthError: () => DreameAuthError,
|
|
39
|
+
DreameDeviceOfflineError: () => DreameDeviceOfflineError,
|
|
40
|
+
DreameError: () => DreameError,
|
|
41
|
+
DreameTransportError: () => DreameTransportError,
|
|
42
|
+
LIBRARY_NAME: () => LIBRARY_NAME,
|
|
43
|
+
MOWER_MODEL_CAPABILITIES: () => MODEL_CAPABILITIES2,
|
|
44
|
+
MiotError: () => MiotError,
|
|
45
|
+
MiotState: () => MiotState,
|
|
46
|
+
MowerCapabilityResolver: () => MowerCapabilityResolver,
|
|
47
|
+
MowerChargingStatus: () => MowerChargingStatus,
|
|
48
|
+
MowerControlAction: () => MowerControlAction,
|
|
49
|
+
MowerDevice: () => MowerDevice,
|
|
50
|
+
MowerStatus: () => MowerStatus,
|
|
51
|
+
MowerTaskStatus: () => MowerTaskStatus,
|
|
52
|
+
Nodreame: () => Nodreame,
|
|
53
|
+
SuctionLevel: () => SuctionLevel,
|
|
54
|
+
TaskStatus: () => TaskStatus,
|
|
55
|
+
VACUUM_MODEL_CAPABILITIES: () => MODEL_CAPABILITIES,
|
|
56
|
+
VacuumCapabilityResolver: () => VacuumCapabilityResolver,
|
|
57
|
+
VacuumDevice: () => VacuumDevice,
|
|
58
|
+
WaterVolume: () => WaterVolume,
|
|
59
|
+
getMowerCapabilities: () => getMowerCapabilities,
|
|
60
|
+
getVacuumCapabilities: () => getVacuumCapabilities,
|
|
61
|
+
renderMowerSvg: () => renderMowerSvg,
|
|
62
|
+
renderVacuumPng: () => renderVacuumPng,
|
|
63
|
+
resolveCapabilities: () => resolveCapabilities
|
|
64
|
+
});
|
|
65
|
+
module.exports = __toCommonJS(index_exports);
|
|
66
|
+
|
|
67
|
+
// src/support/version.ts
|
|
68
|
+
var LIBRARY_NAME = "nodedreame";
|
|
69
|
+
|
|
70
|
+
// src/transport/errors.ts
|
|
71
|
+
var DreameError = class extends Error {
|
|
72
|
+
constructor(message, cause) {
|
|
73
|
+
super(message);
|
|
74
|
+
this.cause = cause;
|
|
75
|
+
this.name = "DreameError";
|
|
76
|
+
}
|
|
77
|
+
cause;
|
|
78
|
+
};
|
|
79
|
+
var DreameAuthError = class extends DreameError {
|
|
80
|
+
constructor(message, status, cause) {
|
|
81
|
+
super(message, cause);
|
|
82
|
+
this.status = status;
|
|
83
|
+
this.name = "DreameAuthError";
|
|
84
|
+
}
|
|
85
|
+
status;
|
|
86
|
+
};
|
|
87
|
+
var DreameApiError = class extends DreameError {
|
|
88
|
+
constructor(message, status, body, cause) {
|
|
89
|
+
super(message, cause);
|
|
90
|
+
this.status = status;
|
|
91
|
+
this.body = body;
|
|
92
|
+
this.name = "DreameApiError";
|
|
93
|
+
}
|
|
94
|
+
status;
|
|
95
|
+
body;
|
|
96
|
+
};
|
|
97
|
+
var DreameDeviceOfflineError = class extends DreameApiError {
|
|
98
|
+
constructor(message, status, body) {
|
|
99
|
+
super(message, status, body);
|
|
100
|
+
this.name = "DreameDeviceOfflineError";
|
|
101
|
+
}
|
|
102
|
+
};
|
|
103
|
+
var DreameTransportError = class extends DreameError {
|
|
104
|
+
constructor(message, cause) {
|
|
105
|
+
super(message, cause);
|
|
106
|
+
this.name = "DreameTransportError";
|
|
107
|
+
}
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
// src/transport/crypto.ts
|
|
111
|
+
var import_node_crypto = require("crypto");
|
|
112
|
+
var PASSWORD_SALT = "RAylYC%fmSKp7%Tq";
|
|
113
|
+
var RLC_AES_KEY = "EETjszu*XI5znHsI";
|
|
114
|
+
function hashPassword(plaintext) {
|
|
115
|
+
return (0, import_node_crypto.createHash)("md5").update(plaintext + PASSWORD_SALT).digest("hex");
|
|
116
|
+
}
|
|
117
|
+
function buildRlcHeader(region, lang, country) {
|
|
118
|
+
const plaintext = `${region}|${lang}|${country}`;
|
|
119
|
+
const cipher = (0, import_node_crypto.createCipheriv)("aes-128-ecb", Buffer.from(RLC_AES_KEY, "utf8"), null);
|
|
120
|
+
const out = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]);
|
|
121
|
+
return out.toString("hex");
|
|
122
|
+
}
|
|
123
|
+
function randomMqttClientId() {
|
|
124
|
+
return "p_" + (0, import_node_crypto.randomBytes)(8).toString("hex");
|
|
125
|
+
}
|
|
126
|
+
function randomRequestId() {
|
|
127
|
+
return Math.floor(Math.random() * 2147483647) + 1;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// src/auth/config.ts
|
|
131
|
+
var REGION_HOSTS = {
|
|
132
|
+
eu: "eu.iot.dreame.tech:13267",
|
|
133
|
+
us: "us.iot.dreame.tech:13267",
|
|
134
|
+
cn: "cn.iot.dreame.tech:13267",
|
|
135
|
+
ru: "ru.iot.dreame.tech:13267",
|
|
136
|
+
sg: "sg.iot.dreame.tech:13267",
|
|
137
|
+
in: "in.iot.dreame.tech:13267",
|
|
138
|
+
de: "eu.iot.dreame.tech:13267",
|
|
139
|
+
tw: "cn.iot.dreame.tech:13267"
|
|
140
|
+
};
|
|
141
|
+
var REGION_DEFAULT_COUNTRY = {
|
|
142
|
+
eu: "GB",
|
|
143
|
+
us: "US",
|
|
144
|
+
cn: "CN",
|
|
145
|
+
ru: "RU",
|
|
146
|
+
sg: "SG",
|
|
147
|
+
in: "IN",
|
|
148
|
+
de: "DE",
|
|
149
|
+
tw: "TW"
|
|
150
|
+
};
|
|
151
|
+
var REGION_DEFAULT_LANG = {
|
|
152
|
+
eu: "en",
|
|
153
|
+
us: "en",
|
|
154
|
+
cn: "zh",
|
|
155
|
+
ru: "ru",
|
|
156
|
+
sg: "en",
|
|
157
|
+
in: "en",
|
|
158
|
+
de: "de",
|
|
159
|
+
tw: "zh"
|
|
160
|
+
};
|
|
161
|
+
var OAUTH_BASIC_AUTH = "Basic ZHJlYW1lX2FwcHYxOkFQXmR2QHpAU1FZVnhOODg=";
|
|
162
|
+
var APP_META = "cv=i_829";
|
|
163
|
+
var APP_USER_AGENT = "Dart/3.2 (dart:io)";
|
|
164
|
+
var TENANT_DREAME = "000000";
|
|
165
|
+
var COMMAND_FROM_FIELD = "XXXXXX";
|
|
166
|
+
var IOT_COM_PREFIX_DREAME = 1e4;
|
|
167
|
+
var CONTENT_TYPE_JSON = "application/json";
|
|
168
|
+
var CONTENT_TYPE_FORM = "application/x-www-form-urlencoded";
|
|
169
|
+
|
|
170
|
+
// src/transport/headers.ts
|
|
171
|
+
function buildHeaders(opts) {
|
|
172
|
+
const auth = opts.accessToken ? `bearer ${opts.accessToken}` : "bearer";
|
|
173
|
+
return {
|
|
174
|
+
"user-agent": APP_USER_AGENT,
|
|
175
|
+
authorization: OAUTH_BASIC_AUTH,
|
|
176
|
+
"content-type": opts.contentType ?? CONTENT_TYPE_FORM,
|
|
177
|
+
"dreame-auth": auth,
|
|
178
|
+
"dreame-meta": APP_META,
|
|
179
|
+
"dreame-rlc": buildRlcHeader(opts.region, opts.lang, opts.country),
|
|
180
|
+
"tenant-id": TENANT_DREAME
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// src/transport/fetch.ts
|
|
185
|
+
var import_undici = require("undici");
|
|
186
|
+
var defaultFetch = import_undici.fetch;
|
|
187
|
+
|
|
188
|
+
// src/transport/http.ts
|
|
189
|
+
var CODE_DEVICE_OFFLINE = 80001;
|
|
190
|
+
var DEFAULT_TIMEOUT_MS = 3e4;
|
|
191
|
+
var RequestContext = class _RequestContext {
|
|
192
|
+
region;
|
|
193
|
+
country;
|
|
194
|
+
lang;
|
|
195
|
+
host;
|
|
196
|
+
fetchImpl;
|
|
197
|
+
constructor(opts) {
|
|
198
|
+
this.region = opts.region;
|
|
199
|
+
this.country = opts.country ?? REGION_DEFAULT_COUNTRY[opts.region];
|
|
200
|
+
this.lang = opts.lang ?? REGION_DEFAULT_LANG[opts.region];
|
|
201
|
+
this.host = opts.host ?? REGION_HOSTS[opts.region];
|
|
202
|
+
this.fetchImpl = opts.fetchImpl ?? defaultFetch;
|
|
203
|
+
}
|
|
204
|
+
static from(input) {
|
|
205
|
+
return new _RequestContext({
|
|
206
|
+
region: input.region,
|
|
207
|
+
...input.country !== void 0 ? { country: input.country } : {},
|
|
208
|
+
...input.lang !== void 0 ? { lang: input.lang } : {},
|
|
209
|
+
...input.host !== void 0 ? { host: input.host } : {},
|
|
210
|
+
...input.fetchImpl !== void 0 ? { fetchImpl: input.fetchImpl } : {}
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
/** `https://<host><path>` — pass a path with a leading slash. */
|
|
214
|
+
url(path) {
|
|
215
|
+
return `https://${this.host}${path}`;
|
|
216
|
+
}
|
|
217
|
+
/** Build the static Dreame headers, optionally with a bearer token + content-type. */
|
|
218
|
+
headers(opts = {}) {
|
|
219
|
+
return buildHeaders({
|
|
220
|
+
region: this.region,
|
|
221
|
+
country: this.country,
|
|
222
|
+
lang: this.lang,
|
|
223
|
+
...opts.accessToken !== void 0 ? { accessToken: opts.accessToken } : {},
|
|
224
|
+
...opts.contentType !== void 0 ? { contentType: opts.contentType } : {}
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
};
|
|
228
|
+
async function httpPostJson(input) {
|
|
229
|
+
const Err = input.errorClass ?? DreameApiError;
|
|
230
|
+
const timeoutMs = input.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
231
|
+
const signal = composeSignals(input.signal, timeoutMs);
|
|
232
|
+
let res;
|
|
233
|
+
try {
|
|
234
|
+
res = await input.ctx.fetchImpl(input.url, {
|
|
235
|
+
method: "POST",
|
|
236
|
+
headers: input.headers,
|
|
237
|
+
body: input.body,
|
|
238
|
+
...signal !== void 0 ? { signal } : {}
|
|
239
|
+
});
|
|
240
|
+
} catch (err) {
|
|
241
|
+
if (isAbortError(err)) {
|
|
242
|
+
throw new DreameTransportError(
|
|
243
|
+
`request to ${input.url} aborted after ${timeoutMs}ms or by caller`,
|
|
244
|
+
err
|
|
245
|
+
);
|
|
246
|
+
}
|
|
247
|
+
throw new DreameTransportError(`network error contacting ${input.url}`, err);
|
|
248
|
+
}
|
|
249
|
+
const text = await res.text();
|
|
250
|
+
let parsed = null;
|
|
251
|
+
if (text) {
|
|
252
|
+
try {
|
|
253
|
+
parsed = JSON.parse(text);
|
|
254
|
+
} catch {
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
if (!res.ok) {
|
|
258
|
+
throw new Err(
|
|
259
|
+
`${input.context} failed: ${res.status} ${text.slice(0, 200)}`,
|
|
260
|
+
res.status,
|
|
261
|
+
parsed
|
|
262
|
+
);
|
|
263
|
+
}
|
|
264
|
+
if (!parsed) {
|
|
265
|
+
throw new Err(`${input.context} response was not JSON (status ${res.status})`, res.status);
|
|
266
|
+
}
|
|
267
|
+
if (!input.skipCodeCheck && parsed.code !== void 0 && parsed.code !== 0) {
|
|
268
|
+
if (parsed.code === CODE_DEVICE_OFFLINE) {
|
|
269
|
+
throw new DreameDeviceOfflineError(
|
|
270
|
+
`device offline: ${parsed.msg ?? "timeout"}`,
|
|
271
|
+
res.status,
|
|
272
|
+
parsed
|
|
273
|
+
);
|
|
274
|
+
}
|
|
275
|
+
throw new Err(
|
|
276
|
+
`${input.context} rejected: code=${parsed.code} msg=${parsed.msg ?? "?"}`,
|
|
277
|
+
res.status,
|
|
278
|
+
parsed
|
|
279
|
+
);
|
|
280
|
+
}
|
|
281
|
+
return parsed;
|
|
282
|
+
}
|
|
283
|
+
async function httpPostJsonBody(input) {
|
|
284
|
+
return httpPostJson({
|
|
285
|
+
ctx: input.ctx,
|
|
286
|
+
url: input.ctx.url(input.path),
|
|
287
|
+
headers: input.ctx.headers({
|
|
288
|
+
...input.accessToken !== void 0 ? { accessToken: input.accessToken } : {},
|
|
289
|
+
contentType: CONTENT_TYPE_JSON
|
|
290
|
+
}),
|
|
291
|
+
body: JSON.stringify(input.body),
|
|
292
|
+
context: input.context,
|
|
293
|
+
...input.timeoutMs !== void 0 ? { timeoutMs: input.timeoutMs } : {},
|
|
294
|
+
...input.signal !== void 0 ? { signal: input.signal } : {}
|
|
295
|
+
});
|
|
296
|
+
}
|
|
297
|
+
function composeSignals(callerSignal, timeoutMs) {
|
|
298
|
+
if (timeoutMs <= 0) {
|
|
299
|
+
return callerSignal;
|
|
300
|
+
}
|
|
301
|
+
const timeoutSignal = AbortSignal.timeout(timeoutMs);
|
|
302
|
+
if (!callerSignal) {
|
|
303
|
+
return timeoutSignal;
|
|
304
|
+
}
|
|
305
|
+
return AbortSignal.any([callerSignal, timeoutSignal]);
|
|
306
|
+
}
|
|
307
|
+
function isAbortError(err) {
|
|
308
|
+
return err instanceof Error && (err.name === "AbortError" || err.name === "TimeoutError");
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
// src/transport/schemas.ts
|
|
312
|
+
var import_zod = require("zod");
|
|
313
|
+
var OAuthTokenResponseSchema = import_zod.z.object({
|
|
314
|
+
access_token: import_zod.z.string().optional(),
|
|
315
|
+
refresh_token: import_zod.z.string().optional(),
|
|
316
|
+
expires_in: import_zod.z.number().optional(),
|
|
317
|
+
token_type: import_zod.z.string().optional(),
|
|
318
|
+
uid: import_zod.z.union([import_zod.z.string(), import_zod.z.number()]).optional(),
|
|
319
|
+
region: import_zod.z.string().optional(),
|
|
320
|
+
country: import_zod.z.string().optional(),
|
|
321
|
+
lang: import_zod.z.string().optional(),
|
|
322
|
+
tenant_id: import_zod.z.string().optional(),
|
|
323
|
+
error: import_zod.z.string().optional(),
|
|
324
|
+
error_description: import_zod.z.string().optional(),
|
|
325
|
+
code: import_zod.z.number().optional(),
|
|
326
|
+
msg: import_zod.z.string().optional()
|
|
327
|
+
}).passthrough();
|
|
328
|
+
var RawDeviceSchema = import_zod.z.object({
|
|
329
|
+
did: import_zod.z.union([import_zod.z.string(), import_zod.z.number()]).optional(),
|
|
330
|
+
model: import_zod.z.string().optional(),
|
|
331
|
+
customName: import_zod.z.string().optional(),
|
|
332
|
+
deviceName: import_zod.z.string().optional(),
|
|
333
|
+
mac: import_zod.z.string().optional(),
|
|
334
|
+
online: import_zod.z.boolean().optional(),
|
|
335
|
+
lwt: import_zod.z.number().optional(),
|
|
336
|
+
bindDomain: import_zod.z.string().optional(),
|
|
337
|
+
master: import_zod.z.boolean().optional()
|
|
338
|
+
}).passthrough();
|
|
339
|
+
var DeviceListResponseSchema = import_zod.z.object({
|
|
340
|
+
code: import_zod.z.number().optional(),
|
|
341
|
+
msg: import_zod.z.string().optional(),
|
|
342
|
+
data: import_zod.z.object({
|
|
343
|
+
page: import_zod.z.object({ records: import_zod.z.array(RawDeviceSchema).optional() }).partial().optional(),
|
|
344
|
+
records: import_zod.z.array(RawDeviceSchema).optional()
|
|
345
|
+
}).passthrough().optional(),
|
|
346
|
+
records: import_zod.z.array(RawDeviceSchema).optional()
|
|
347
|
+
}).passthrough();
|
|
348
|
+
var PropertyResultSchema = import_zod.z.object({
|
|
349
|
+
siid: import_zod.z.number().optional(),
|
|
350
|
+
piid: import_zod.z.number().optional(),
|
|
351
|
+
value: import_zod.z.unknown().optional(),
|
|
352
|
+
code: import_zod.z.number().optional()
|
|
353
|
+
}).passthrough();
|
|
354
|
+
var SendCommandResponseSchema = import_zod.z.object({
|
|
355
|
+
code: import_zod.z.number().optional(),
|
|
356
|
+
msg: import_zod.z.string().optional(),
|
|
357
|
+
data: import_zod.z.object({ result: import_zod.z.unknown().optional() }).passthrough().optional(),
|
|
358
|
+
result: import_zod.z.unknown().optional()
|
|
359
|
+
}).passthrough();
|
|
360
|
+
var RawMqttEventSchema = import_zod.z.object({
|
|
361
|
+
id: import_zod.z.number().optional(),
|
|
362
|
+
did: import_zod.z.union([import_zod.z.string(), import_zod.z.number()]).optional(),
|
|
363
|
+
data: import_zod.z.object({
|
|
364
|
+
id: import_zod.z.number().optional(),
|
|
365
|
+
method: import_zod.z.string().optional(),
|
|
366
|
+
params: import_zod.z.unknown().optional()
|
|
367
|
+
}).passthrough().optional()
|
|
368
|
+
}).passthrough();
|
|
369
|
+
|
|
370
|
+
// src/auth/dreame-account.ts
|
|
371
|
+
function ctxFromInput(input) {
|
|
372
|
+
return RequestContext.from({ ...input, host: input.authHost });
|
|
373
|
+
}
|
|
374
|
+
async function login(input) {
|
|
375
|
+
const ctx = ctxFromInput(input);
|
|
376
|
+
const body = new URLSearchParams({
|
|
377
|
+
grant_type: "password",
|
|
378
|
+
scope: "all",
|
|
379
|
+
platform: "IOS",
|
|
380
|
+
type: "account",
|
|
381
|
+
username: input.email,
|
|
382
|
+
password: hashPassword(input.password),
|
|
383
|
+
country: ctx.country,
|
|
384
|
+
lang: ctx.lang
|
|
385
|
+
});
|
|
386
|
+
return postForToken(ctx, body);
|
|
387
|
+
}
|
|
388
|
+
async function refresh(input) {
|
|
389
|
+
const ctx = ctxFromInput(input);
|
|
390
|
+
const body = new URLSearchParams({
|
|
391
|
+
grant_type: "refresh_token",
|
|
392
|
+
refresh_token: input.refreshToken
|
|
393
|
+
});
|
|
394
|
+
return postForToken(ctx, body);
|
|
395
|
+
}
|
|
396
|
+
async function postForToken(ctx, body) {
|
|
397
|
+
const raw = await httpPostJson({
|
|
398
|
+
ctx,
|
|
399
|
+
url: ctx.url("/dreame-auth/oauth/token"),
|
|
400
|
+
headers: ctx.headers(),
|
|
401
|
+
body,
|
|
402
|
+
context: "auth",
|
|
403
|
+
errorClass: DreameAuthError,
|
|
404
|
+
skipCodeCheck: true
|
|
405
|
+
// OAuth uses HTTP status + top-level error fields, not parsed.code
|
|
406
|
+
});
|
|
407
|
+
const data = OAuthTokenResponseSchema.parse(raw);
|
|
408
|
+
if (data.error || data.error_description) {
|
|
409
|
+
throw new DreameAuthError(
|
|
410
|
+
`auth failed: ${data.error ?? "?"} \u2014 ${data.error_description ?? "no description"}`
|
|
411
|
+
);
|
|
412
|
+
}
|
|
413
|
+
if (!data.access_token) {
|
|
414
|
+
throw new DreameAuthError(
|
|
415
|
+
`auth response missing access_token: uid=${data.uid ?? "?"} error=${data.error ?? "?"} code=${data.code ?? "?"}`
|
|
416
|
+
);
|
|
417
|
+
}
|
|
418
|
+
const expiresIn = typeof data.expires_in === "number" ? data.expires_in : 7200;
|
|
419
|
+
const uid = data.uid !== void 0 ? String(data.uid) : "";
|
|
420
|
+
if (!uid) {
|
|
421
|
+
throw new DreameAuthError("auth response missing uid");
|
|
422
|
+
}
|
|
423
|
+
const session = {
|
|
424
|
+
accessToken: data.access_token,
|
|
425
|
+
uid,
|
|
426
|
+
expiresAt: Date.now() + expiresIn * 1e3,
|
|
427
|
+
region: ctx.region
|
|
428
|
+
};
|
|
429
|
+
if (typeof data.refresh_token === "string") {
|
|
430
|
+
session.refreshToken = data.refresh_token;
|
|
431
|
+
}
|
|
432
|
+
return session;
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
// src/cloud/devices.ts
|
|
436
|
+
async function listDevices(input) {
|
|
437
|
+
const ctx = input.ctx ?? RequestContext.from({ ...input, host: input.apiHost });
|
|
438
|
+
const raw = await httpPostJsonBody({
|
|
439
|
+
ctx,
|
|
440
|
+
path: "/dreame-user-iot/iotuserbind/device/listV2",
|
|
441
|
+
accessToken: input.session.accessToken,
|
|
442
|
+
body: {
|
|
443
|
+
sharedStatus: 1,
|
|
444
|
+
current: 1,
|
|
445
|
+
size: 100,
|
|
446
|
+
lang: ctx.lang,
|
|
447
|
+
timestamp: Date.now()
|
|
448
|
+
},
|
|
449
|
+
context: "device list",
|
|
450
|
+
...input.signal !== void 0 ? { signal: input.signal } : {},
|
|
451
|
+
...input.timeoutMs !== void 0 ? { timeoutMs: input.timeoutMs } : {}
|
|
452
|
+
});
|
|
453
|
+
const parsed = DeviceListResponseSchema.parse(raw);
|
|
454
|
+
const records = parsed.data?.page?.records ?? parsed.data?.records ?? parsed.records ?? [];
|
|
455
|
+
return records.map(toDevice);
|
|
456
|
+
}
|
|
457
|
+
function toDevice(rawIn) {
|
|
458
|
+
const raw = rawIn;
|
|
459
|
+
const did = String(rawIn.did ?? "");
|
|
460
|
+
const model = String(rawIn.model ?? "");
|
|
461
|
+
const name = String(rawIn.customName || rawIn.deviceName || model || did);
|
|
462
|
+
let lwtFlag = 0;
|
|
463
|
+
if (typeof raw["property"] === "string") {
|
|
464
|
+
try {
|
|
465
|
+
const p = JSON.parse(raw["property"]);
|
|
466
|
+
if (p && typeof p.lwt === "number") {
|
|
467
|
+
lwtFlag = p.lwt;
|
|
468
|
+
}
|
|
469
|
+
} catch {
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
const online = rawIn.online === true || rawIn.lwt === 1 || lwtFlag === 1;
|
|
473
|
+
const device = {
|
|
474
|
+
did,
|
|
475
|
+
model,
|
|
476
|
+
name,
|
|
477
|
+
online,
|
|
478
|
+
raw,
|
|
479
|
+
cloudState: parseCloudState(raw)
|
|
480
|
+
};
|
|
481
|
+
if (rawIn.mac) {
|
|
482
|
+
device.mac = String(rawIn.mac);
|
|
483
|
+
}
|
|
484
|
+
if (typeof raw["ver"] === "string") {
|
|
485
|
+
device.firmwareVersion = raw["ver"];
|
|
486
|
+
}
|
|
487
|
+
if (typeof raw["sn"] === "string") {
|
|
488
|
+
device.serialNumber = raw["sn"];
|
|
489
|
+
}
|
|
490
|
+
return device;
|
|
491
|
+
}
|
|
492
|
+
function parseCloudState(raw) {
|
|
493
|
+
const latestStatus = typeof raw["latestStatus"] === "number" ? raw["latestStatus"] : null;
|
|
494
|
+
const battery = typeof raw["battery"] === "number" ? raw["battery"] : null;
|
|
495
|
+
const featureCode2 = typeof raw["featureCode2"] === "number" ? raw["featureCode2"] : null;
|
|
496
|
+
let videoActive = null;
|
|
497
|
+
if (typeof raw["videoStatus"] === "string") {
|
|
498
|
+
try {
|
|
499
|
+
const v = JSON.parse(raw["videoStatus"]);
|
|
500
|
+
if (v?.operType === "end" || v?.status === 0) {
|
|
501
|
+
videoActive = false;
|
|
502
|
+
} else if (v?.operType || v?.status) {
|
|
503
|
+
videoActive = true;
|
|
504
|
+
}
|
|
505
|
+
} catch {
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
return { latestStatus, battery, videoActive, featureCode2 };
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
// src/cloud/commands.ts
|
|
512
|
+
var import_zod2 = require("zod");
|
|
513
|
+
async function sendCommand(input) {
|
|
514
|
+
const ctx = input.ctx ?? RequestContext.from({ ...input, host: input.apiHost });
|
|
515
|
+
const prefix = input.iotComPrefix ?? IOT_COM_PREFIX_DREAME;
|
|
516
|
+
const id = randomRequestId();
|
|
517
|
+
const raw = await httpPostJsonBody({
|
|
518
|
+
ctx,
|
|
519
|
+
path: `/dreame-iot-com-${prefix}/device/sendCommand`,
|
|
520
|
+
accessToken: input.session.accessToken,
|
|
521
|
+
body: {
|
|
522
|
+
did: input.did,
|
|
523
|
+
id,
|
|
524
|
+
data: {
|
|
525
|
+
did: input.did,
|
|
526
|
+
id,
|
|
527
|
+
method: input.method,
|
|
528
|
+
params: input.params,
|
|
529
|
+
from: COMMAND_FROM_FIELD
|
|
530
|
+
}
|
|
531
|
+
},
|
|
532
|
+
context: input.method,
|
|
533
|
+
...input.signal !== void 0 ? { signal: input.signal } : {},
|
|
534
|
+
...input.timeoutMs !== void 0 ? { timeoutMs: input.timeoutMs } : {}
|
|
535
|
+
});
|
|
536
|
+
return SendCommandResponseSchema.parse(raw);
|
|
537
|
+
}
|
|
538
|
+
async function getProperties(base, props, opts = {}) {
|
|
539
|
+
const params = props.map((p) => ({ did: base.did, siid: p.siid, piid: p.piid }));
|
|
540
|
+
const res = await sendCommand({ ...base, ...opts, method: "get_properties", params });
|
|
541
|
+
return extractResultArray(res, "get_properties");
|
|
542
|
+
}
|
|
543
|
+
async function setProperties(base, writes, opts = {}) {
|
|
544
|
+
const params = writes.map((p) => ({
|
|
545
|
+
did: base.did,
|
|
546
|
+
siid: p.siid,
|
|
547
|
+
piid: p.piid,
|
|
548
|
+
value: p.value
|
|
549
|
+
}));
|
|
550
|
+
const res = await sendCommand({ ...base, ...opts, method: "set_properties", params });
|
|
551
|
+
return extractResultArray(res, "set_properties");
|
|
552
|
+
}
|
|
553
|
+
async function callAction(base, action, opts = {}) {
|
|
554
|
+
const params = {
|
|
555
|
+
did: base.did,
|
|
556
|
+
siid: action.siid,
|
|
557
|
+
aiid: action.aiid,
|
|
558
|
+
in: action.in ?? []
|
|
559
|
+
};
|
|
560
|
+
const res = await sendCommand({ ...base, ...opts, method: "action", params });
|
|
561
|
+
return res.data?.result ?? res.result ?? res;
|
|
562
|
+
}
|
|
563
|
+
function extractResultArray(res, context) {
|
|
564
|
+
const raw = Array.isArray(res.data?.result) ? res.data.result : Array.isArray(res.result) ? res.result : null;
|
|
565
|
+
if (raw === null) {
|
|
566
|
+
throw new DreameApiError(
|
|
567
|
+
`${context}: response did not contain a result array \u2014 ${JSON.stringify(res).slice(0, 200)}`,
|
|
568
|
+
200,
|
|
569
|
+
res
|
|
570
|
+
);
|
|
571
|
+
}
|
|
572
|
+
const parsed = import_zod2.z.array(PropertyResultSchema).safeParse(raw);
|
|
573
|
+
if (!parsed.success) {
|
|
574
|
+
throw new DreameApiError(
|
|
575
|
+
`${context}: result array contained a non-object element \u2014 ${JSON.stringify(raw).slice(0, 200)}`,
|
|
576
|
+
200,
|
|
577
|
+
res
|
|
578
|
+
);
|
|
579
|
+
}
|
|
580
|
+
return parsed.data;
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
// src/transport/mqtt-push.ts
|
|
584
|
+
var import_mqtt = __toESM(require("mqtt"), 1);
|
|
585
|
+
|
|
586
|
+
// src/transport/typed-emitter.ts
|
|
587
|
+
var import_node_events = require("events");
|
|
588
|
+
var TypedEmitter = class {
|
|
589
|
+
#emitter = new import_node_events.EventEmitter();
|
|
590
|
+
on(event, listener) {
|
|
591
|
+
this.#emitter.on(event, listener);
|
|
592
|
+
return this;
|
|
593
|
+
}
|
|
594
|
+
once(event, listener) {
|
|
595
|
+
this.#emitter.once(event, listener);
|
|
596
|
+
return this;
|
|
597
|
+
}
|
|
598
|
+
off(event, listener) {
|
|
599
|
+
this.#emitter.off(event, listener);
|
|
600
|
+
return this;
|
|
601
|
+
}
|
|
602
|
+
emit(event, ...args) {
|
|
603
|
+
return this.#emitter.emit(event, ...args);
|
|
604
|
+
}
|
|
605
|
+
removeAllListeners() {
|
|
606
|
+
this.#emitter.removeAllListeners();
|
|
607
|
+
return this;
|
|
608
|
+
}
|
|
609
|
+
};
|
|
610
|
+
|
|
611
|
+
// src/transport/mqtt-push.ts
|
|
612
|
+
function defaultConnect(url, opts) {
|
|
613
|
+
const client = import_mqtt.default.connect(url, opts);
|
|
614
|
+
const typed = client;
|
|
615
|
+
return typed;
|
|
616
|
+
}
|
|
617
|
+
var DreamePush = class extends TypedEmitter {
|
|
618
|
+
#device;
|
|
619
|
+
#session;
|
|
620
|
+
#region;
|
|
621
|
+
#connect;
|
|
622
|
+
#backoffMs;
|
|
623
|
+
#rejectUnauthorized;
|
|
624
|
+
#topic;
|
|
625
|
+
#client = null;
|
|
626
|
+
#closed = false;
|
|
627
|
+
#tearingDown = false;
|
|
628
|
+
#reconnectTimer = null;
|
|
629
|
+
/**
|
|
630
|
+
* Per-instance bound close handler. Stored so we can pass the EXACT same
|
|
631
|
+
* function reference to both `client.on('close', …)` and
|
|
632
|
+
* `client.removeListener('close', …)` during teardown — a throwaway arrow
|
|
633
|
+
* would be a different reference and would NOT be removed.
|
|
634
|
+
*/
|
|
635
|
+
#onClose;
|
|
636
|
+
constructor(input) {
|
|
637
|
+
super();
|
|
638
|
+
this.#device = input.device;
|
|
639
|
+
this.#session = input.session;
|
|
640
|
+
this.#region = input.region;
|
|
641
|
+
this.#connect = input.connect ?? defaultConnect;
|
|
642
|
+
this.#backoffMs = input.reconnectBackoffMs ?? 5e3;
|
|
643
|
+
this.#rejectUnauthorized = input.rejectUnauthorized ?? false;
|
|
644
|
+
this.#topic = buildStatusTopic(this.#device, this.#session.uid, this.#region);
|
|
645
|
+
this.#onClose = () => {
|
|
646
|
+
if (this.#tearingDown) {
|
|
647
|
+
return;
|
|
648
|
+
}
|
|
649
|
+
this.emit("close");
|
|
650
|
+
this.#scheduleReconnect();
|
|
651
|
+
};
|
|
652
|
+
}
|
|
653
|
+
get topic() {
|
|
654
|
+
return this.#topic;
|
|
655
|
+
}
|
|
656
|
+
/** Open the connection and resolve when subscribed. */
|
|
657
|
+
async open() {
|
|
658
|
+
if (this.#closed) {
|
|
659
|
+
throw new DreameTransportError("subscription is closed");
|
|
660
|
+
}
|
|
661
|
+
if (this.#client) {
|
|
662
|
+
return;
|
|
663
|
+
}
|
|
664
|
+
await this.#connectAndSubscribe();
|
|
665
|
+
}
|
|
666
|
+
/**
|
|
667
|
+
* Swap to a freshly-refreshed session: close the current client and reopen
|
|
668
|
+
* with the new access token. Safe to call while connected or disconnected.
|
|
669
|
+
*/
|
|
670
|
+
async refreshSession(session) {
|
|
671
|
+
this.#session = session;
|
|
672
|
+
if (this.#closed) {
|
|
673
|
+
return;
|
|
674
|
+
}
|
|
675
|
+
if (this.#reconnectTimer) {
|
|
676
|
+
clearTimeout(this.#reconnectTimer);
|
|
677
|
+
this.#reconnectTimer = null;
|
|
678
|
+
}
|
|
679
|
+
await this.#teardownClient();
|
|
680
|
+
await this.#connectAndSubscribe();
|
|
681
|
+
}
|
|
682
|
+
/** Tear down permanently. Closed subscriptions cannot be reopened. */
|
|
683
|
+
async close() {
|
|
684
|
+
this.#closed = true;
|
|
685
|
+
if (this.#reconnectTimer) {
|
|
686
|
+
clearTimeout(this.#reconnectTimer);
|
|
687
|
+
this.#reconnectTimer = null;
|
|
688
|
+
}
|
|
689
|
+
await this.#teardownClient();
|
|
690
|
+
}
|
|
691
|
+
async #connectAndSubscribe() {
|
|
692
|
+
const client = this.#connect(brokerUrl(this.#device), {
|
|
693
|
+
username: this.#session.uid,
|
|
694
|
+
password: this.#session.accessToken,
|
|
695
|
+
clientId: randomMqttClientId(),
|
|
696
|
+
protocolVersion: 4,
|
|
697
|
+
reconnectPeriod: 0,
|
|
698
|
+
// we drive reconnect ourselves
|
|
699
|
+
connectTimeout: 15e3,
|
|
700
|
+
rejectUnauthorized: this.#rejectUnauthorized,
|
|
701
|
+
clean: true
|
|
702
|
+
});
|
|
703
|
+
this.#client = client;
|
|
704
|
+
client.on("message", (_topic, payload) => this.#handleMessage(payload));
|
|
705
|
+
client.on("error", (err) => this.emit("error", err));
|
|
706
|
+
client.on("close", this.#onClose);
|
|
707
|
+
await new Promise((resolve, reject) => {
|
|
708
|
+
const onConnect = () => {
|
|
709
|
+
client.removeListener("error", onError);
|
|
710
|
+
client.subscribe(this.#topic, { qos: 0 }, (err) => {
|
|
711
|
+
if (err) {
|
|
712
|
+
reject(new DreameTransportError(`mqtt subscribe failed: ${err.message}`, err));
|
|
713
|
+
return;
|
|
714
|
+
}
|
|
715
|
+
this.emit("connect");
|
|
716
|
+
resolve();
|
|
717
|
+
});
|
|
718
|
+
};
|
|
719
|
+
const onError = (err) => {
|
|
720
|
+
client.removeListener("connect", onConnect);
|
|
721
|
+
reject(new DreameTransportError(`mqtt connect failed: ${err.message}`, err));
|
|
722
|
+
};
|
|
723
|
+
client.once("connect", onConnect);
|
|
724
|
+
client.once("error", onError);
|
|
725
|
+
});
|
|
726
|
+
}
|
|
727
|
+
#scheduleReconnect() {
|
|
728
|
+
if (this.#closed || this.#reconnectTimer) {
|
|
729
|
+
return;
|
|
730
|
+
}
|
|
731
|
+
this.#client = null;
|
|
732
|
+
this.#reconnectTimer = setTimeout(() => {
|
|
733
|
+
this.#reconnectTimer = null;
|
|
734
|
+
if (this.#closed || this.#client) {
|
|
735
|
+
return;
|
|
736
|
+
}
|
|
737
|
+
void this.#connectAndSubscribe().catch((err) => {
|
|
738
|
+
this.emit("error", err instanceof Error ? err : new DreameTransportError(String(err)));
|
|
739
|
+
this.#scheduleReconnect();
|
|
740
|
+
});
|
|
741
|
+
}, this.#backoffMs);
|
|
742
|
+
}
|
|
743
|
+
async #teardownClient() {
|
|
744
|
+
const client = this.#client;
|
|
745
|
+
this.#client = null;
|
|
746
|
+
if (!client) {
|
|
747
|
+
return;
|
|
748
|
+
}
|
|
749
|
+
this.#tearingDown = true;
|
|
750
|
+
client.removeListener("close", this.#onClose);
|
|
751
|
+
await new Promise((resolve) => {
|
|
752
|
+
client.end(false, {}, () => resolve());
|
|
753
|
+
});
|
|
754
|
+
this.#tearingDown = false;
|
|
755
|
+
}
|
|
756
|
+
#handleMessage(payload) {
|
|
757
|
+
let raw;
|
|
758
|
+
try {
|
|
759
|
+
raw = JSON.parse(payload.toString("utf8"));
|
|
760
|
+
} catch (err) {
|
|
761
|
+
this.emit("error", new DreameTransportError("invalid mqtt payload", err));
|
|
762
|
+
return;
|
|
763
|
+
}
|
|
764
|
+
const result = RawMqttEventSchema.safeParse(raw);
|
|
765
|
+
if (!result.success) {
|
|
766
|
+
this.emit("error", new DreameTransportError("unexpected mqtt envelope"));
|
|
767
|
+
return;
|
|
768
|
+
}
|
|
769
|
+
const envelope = result.data;
|
|
770
|
+
const did = String(envelope.did ?? this.#device.did);
|
|
771
|
+
const method = envelope.data?.method;
|
|
772
|
+
const params = envelope.data?.params;
|
|
773
|
+
if (method === "properties_changed" && Array.isArray(params)) {
|
|
774
|
+
const changes = parsePropertyChanges(did, params);
|
|
775
|
+
if (changes.length > 0) {
|
|
776
|
+
this.emit("properties", changes);
|
|
777
|
+
}
|
|
778
|
+
return;
|
|
779
|
+
}
|
|
780
|
+
if (method === "event_occured" && isObject(params)) {
|
|
781
|
+
const ev = parseEventOccured(did, params);
|
|
782
|
+
if (ev) {
|
|
783
|
+
this.emit("event", ev);
|
|
784
|
+
}
|
|
785
|
+
return;
|
|
786
|
+
}
|
|
787
|
+
if (method === "props" && isObject(params)) {
|
|
788
|
+
this.emit("props", { did, params });
|
|
789
|
+
return;
|
|
790
|
+
}
|
|
791
|
+
if (method === "_sync.update_vacuum_mapinfo" && isObject(params)) {
|
|
792
|
+
const mi = parseMapInfo(did, params);
|
|
793
|
+
if (mi) {
|
|
794
|
+
this.emit("mapInfo", mi);
|
|
795
|
+
}
|
|
796
|
+
return;
|
|
797
|
+
}
|
|
798
|
+
}
|
|
799
|
+
};
|
|
800
|
+
function isObject(v) {
|
|
801
|
+
return typeof v === "object" && v !== null && !Array.isArray(v);
|
|
802
|
+
}
|
|
803
|
+
function brokerUrl(device) {
|
|
804
|
+
const bindDomain = device.raw["bindDomain"] ?? "";
|
|
805
|
+
if (!bindDomain) {
|
|
806
|
+
throw new DreameTransportError(`device ${device.did} has no bindDomain \u2014 cannot connect MQTT`);
|
|
807
|
+
}
|
|
808
|
+
return `mqtts://${bindDomain}`;
|
|
809
|
+
}
|
|
810
|
+
function buildStatusTopic(device, uid, region) {
|
|
811
|
+
return `/status/${device.did}/${uid}/${device.model}/${region}/`;
|
|
812
|
+
}
|
|
813
|
+
function parsePropertyChanges(fallbackDid, params) {
|
|
814
|
+
const changes = [];
|
|
815
|
+
for (const p of params) {
|
|
816
|
+
if (!isObject(p)) {
|
|
817
|
+
continue;
|
|
818
|
+
}
|
|
819
|
+
const siid = p["siid"];
|
|
820
|
+
const piid = p["piid"];
|
|
821
|
+
if (typeof siid === "number" && typeof piid === "number") {
|
|
822
|
+
const did = p["did"];
|
|
823
|
+
changes.push({
|
|
824
|
+
did: typeof did === "string" || typeof did === "number" ? String(did) : fallbackDid,
|
|
825
|
+
siid,
|
|
826
|
+
piid,
|
|
827
|
+
value: p["value"]
|
|
828
|
+
});
|
|
829
|
+
}
|
|
830
|
+
}
|
|
831
|
+
return changes;
|
|
832
|
+
}
|
|
833
|
+
function parseEventOccured(fallbackDid, params) {
|
|
834
|
+
const siid = params["siid"];
|
|
835
|
+
const eiid = params["eiid"];
|
|
836
|
+
if (typeof siid !== "number" || typeof eiid !== "number") {
|
|
837
|
+
return null;
|
|
838
|
+
}
|
|
839
|
+
const did = params["did"];
|
|
840
|
+
const args = params["arguments"];
|
|
841
|
+
return {
|
|
842
|
+
did: typeof did === "string" || typeof did === "number" ? String(did) : fallbackDid,
|
|
843
|
+
siid,
|
|
844
|
+
eiid,
|
|
845
|
+
arguments: Array.isArray(args) ? args : []
|
|
846
|
+
};
|
|
847
|
+
}
|
|
848
|
+
function parseMapInfo(fallbackDid, params) {
|
|
849
|
+
const inner = params["map_info"];
|
|
850
|
+
if (typeof inner !== "string") {
|
|
851
|
+
return null;
|
|
852
|
+
}
|
|
853
|
+
let decoded;
|
|
854
|
+
try {
|
|
855
|
+
decoded = JSON.parse(inner);
|
|
856
|
+
} catch {
|
|
857
|
+
return null;
|
|
858
|
+
}
|
|
859
|
+
if (!isObject(decoded)) {
|
|
860
|
+
return null;
|
|
861
|
+
}
|
|
862
|
+
const maps = /* @__PURE__ */ new Map();
|
|
863
|
+
for (const [key2, value] of Object.entries(decoded)) {
|
|
864
|
+
const id = Number(key2);
|
|
865
|
+
if (!Number.isFinite(id) || !Array.isArray(value)) {
|
|
866
|
+
continue;
|
|
867
|
+
}
|
|
868
|
+
maps.set(
|
|
869
|
+
id,
|
|
870
|
+
value.filter((v) => typeof v === "number")
|
|
871
|
+
);
|
|
872
|
+
}
|
|
873
|
+
let activeMapId = null;
|
|
874
|
+
for (const [id, tok] of [...maps].sort((a, b) => a[0] - b[0])) {
|
|
875
|
+
if (tok.length > 1 || tok.length === 1 && tok[0] !== 0) {
|
|
876
|
+
activeMapId = id;
|
|
877
|
+
break;
|
|
878
|
+
}
|
|
879
|
+
}
|
|
880
|
+
const savedMapIds = Object.freeze([...maps.keys()].sort((a, b) => a - b));
|
|
881
|
+
const did = params["did"];
|
|
882
|
+
return {
|
|
883
|
+
did: typeof did === "string" || typeof did === "number" ? String(did) : fallbackDid,
|
|
884
|
+
maps,
|
|
885
|
+
activeMapId,
|
|
886
|
+
savedMapIds
|
|
887
|
+
};
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
// src/device/capability.ts
|
|
891
|
+
var CapabilitySet = class {
|
|
892
|
+
model;
|
|
893
|
+
#tokens;
|
|
894
|
+
constructor(model, tokens) {
|
|
895
|
+
this.model = model;
|
|
896
|
+
this.#tokens = tokens;
|
|
897
|
+
}
|
|
898
|
+
has(token) {
|
|
899
|
+
return this.#tokens.has(token);
|
|
900
|
+
}
|
|
901
|
+
list() {
|
|
902
|
+
return [...this.#tokens];
|
|
903
|
+
}
|
|
904
|
+
};
|
|
905
|
+
var DefaultCapabilityResolver = class {
|
|
906
|
+
resolve(model) {
|
|
907
|
+
return new CapabilitySet(model, /* @__PURE__ */ new Set());
|
|
908
|
+
}
|
|
909
|
+
};
|
|
910
|
+
var sharedDefault = new DefaultCapabilityResolver();
|
|
911
|
+
function resolveCapabilities(model) {
|
|
912
|
+
return sharedDefault.resolve(model);
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
// src/device/base-device.ts
|
|
916
|
+
function defaultBaseDeviceDeps() {
|
|
917
|
+
return {
|
|
918
|
+
createPush: (device, session, region) => new DreamePush({ device, session, region }),
|
|
919
|
+
getProperties: (base, props) => getProperties(base, props),
|
|
920
|
+
setProperties: (base, writes) => setProperties(base, writes),
|
|
921
|
+
callAction: (base, action) => callAction(base, action)
|
|
922
|
+
};
|
|
923
|
+
}
|
|
924
|
+
var DEFAULT_POLL_INTERVAL_MS = 3e4;
|
|
925
|
+
var BaseDevice = class extends TypedEmitter {
|
|
926
|
+
#device;
|
|
927
|
+
#region;
|
|
928
|
+
#sessionRef;
|
|
929
|
+
#deps;
|
|
930
|
+
#fetchInitial;
|
|
931
|
+
#initialProps;
|
|
932
|
+
#pollIntervalMs;
|
|
933
|
+
#capabilities;
|
|
934
|
+
#cache = /* @__PURE__ */ new Map();
|
|
935
|
+
#push = null;
|
|
936
|
+
#mqttHealthy = false;
|
|
937
|
+
#pollTimer = null;
|
|
938
|
+
#closed = false;
|
|
939
|
+
constructor(input) {
|
|
940
|
+
super();
|
|
941
|
+
this.#device = input.device;
|
|
942
|
+
this.#region = input.region;
|
|
943
|
+
this.#sessionRef = input.sessionRef;
|
|
944
|
+
this.#deps = input.deps ?? defaultBaseDeviceDeps();
|
|
945
|
+
this.#fetchInitial = input.fetchInitialValues ?? true;
|
|
946
|
+
this.#initialProps = input.initialProps ?? [];
|
|
947
|
+
this.#pollIntervalMs = input.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS;
|
|
948
|
+
this.#capabilities = input.capabilities ?? new DefaultCapabilityResolver().resolve(input.device.model);
|
|
949
|
+
}
|
|
950
|
+
get deviceId() {
|
|
951
|
+
return this.#device.did;
|
|
952
|
+
}
|
|
953
|
+
get model() {
|
|
954
|
+
return this.#device.model;
|
|
955
|
+
}
|
|
956
|
+
get name() {
|
|
957
|
+
return this.#device.name;
|
|
958
|
+
}
|
|
959
|
+
get capabilities() {
|
|
960
|
+
return this.#capabilities;
|
|
961
|
+
}
|
|
962
|
+
/** Snapshot of all cached property states. */
|
|
963
|
+
get properties() {
|
|
964
|
+
return [...this.#cache.values()];
|
|
965
|
+
}
|
|
966
|
+
/** Read a cached property, or `undefined` if never observed. */
|
|
967
|
+
getProperty(siid, piid) {
|
|
968
|
+
return this.#cache.get(key(siid, piid));
|
|
969
|
+
}
|
|
970
|
+
#base() {
|
|
971
|
+
return { session: this.#sessionRef(), region: this.#region, did: this.#device.did };
|
|
972
|
+
}
|
|
973
|
+
/** The region this handle is bound to. Subclasses use it to build map fetches. */
|
|
974
|
+
get region() {
|
|
975
|
+
return this.#region;
|
|
976
|
+
}
|
|
977
|
+
/** Latest session snapshot. Subclasses use it for out-of-band fetches (maps). */
|
|
978
|
+
currentSession() {
|
|
979
|
+
return this.#sessionRef();
|
|
980
|
+
}
|
|
981
|
+
/** Open the push, wire events, optionally seed the cache. */
|
|
982
|
+
async start() {
|
|
983
|
+
const push = this.#deps.createPush(this.#device, this.#sessionRef(), this.#region);
|
|
984
|
+
this.#push = push;
|
|
985
|
+
push.on("properties", (changes) => this.#onProperties(changes));
|
|
986
|
+
push.on(
|
|
987
|
+
"event",
|
|
988
|
+
(ev) => this.emit("event", {
|
|
989
|
+
deviceId: ev.did || this.#device.did,
|
|
990
|
+
siid: ev.siid,
|
|
991
|
+
eiid: ev.eiid,
|
|
992
|
+
arguments: ev.arguments
|
|
993
|
+
})
|
|
994
|
+
);
|
|
995
|
+
push.on("error", (err) => this.emit("error", err));
|
|
996
|
+
push.on("connect", () => this.#onMqttUp());
|
|
997
|
+
push.on("close", () => this.#onMqttDown());
|
|
998
|
+
await push.open();
|
|
999
|
+
this.#onMqttUp();
|
|
1000
|
+
if (this.#fetchInitial && this.#initialProps.length > 0) {
|
|
1001
|
+
await this.refreshProperties(this.#initialProps);
|
|
1002
|
+
}
|
|
1003
|
+
}
|
|
1004
|
+
/** Live-read properties, update the cache, return the raw results. */
|
|
1005
|
+
async refreshProperties(props) {
|
|
1006
|
+
this.#assertOpen();
|
|
1007
|
+
const results = await this.#deps.getProperties(this.#base(), props);
|
|
1008
|
+
const changes = [];
|
|
1009
|
+
for (const r of results) {
|
|
1010
|
+
if (typeof r.siid === "number" && typeof r.piid === "number") {
|
|
1011
|
+
changes.push({ did: this.#device.did, siid: r.siid, piid: r.piid, value: r.value });
|
|
1012
|
+
}
|
|
1013
|
+
}
|
|
1014
|
+
if (changes.length > 0) {
|
|
1015
|
+
this.#onProperties(changes);
|
|
1016
|
+
}
|
|
1017
|
+
return results;
|
|
1018
|
+
}
|
|
1019
|
+
/** Write a property to the device. */
|
|
1020
|
+
async setProperty(write) {
|
|
1021
|
+
this.#assertOpen();
|
|
1022
|
+
return this.#deps.setProperties(this.#base(), [write]);
|
|
1023
|
+
}
|
|
1024
|
+
/** Invoke a MIoT action on the device. */
|
|
1025
|
+
async callAction(siid, aiid, input = []) {
|
|
1026
|
+
this.#assertOpen();
|
|
1027
|
+
return this.#deps.callAction(this.#base(), { siid, aiid, in: input });
|
|
1028
|
+
}
|
|
1029
|
+
/** Guard against use after {@link close}. */
|
|
1030
|
+
#assertOpen() {
|
|
1031
|
+
if (this.#closed) {
|
|
1032
|
+
throw new DreameTransportError("device handle is closed");
|
|
1033
|
+
}
|
|
1034
|
+
}
|
|
1035
|
+
/** Propagate a refreshed session to the underlying push. */
|
|
1036
|
+
async applySession(session) {
|
|
1037
|
+
if (this.#push) {
|
|
1038
|
+
await this.#push.refreshSession(session);
|
|
1039
|
+
}
|
|
1040
|
+
}
|
|
1041
|
+
/** Tear down: stop polling, close the push. */
|
|
1042
|
+
async close() {
|
|
1043
|
+
this.#closed = true;
|
|
1044
|
+
this.#stopPolling();
|
|
1045
|
+
const push = this.#push;
|
|
1046
|
+
this.#push = null;
|
|
1047
|
+
if (push) {
|
|
1048
|
+
await push.close();
|
|
1049
|
+
}
|
|
1050
|
+
this.removeAllListeners();
|
|
1051
|
+
}
|
|
1052
|
+
#onProperties(changes) {
|
|
1053
|
+
const emitted = [];
|
|
1054
|
+
const now = Date.now();
|
|
1055
|
+
for (const c of changes) {
|
|
1056
|
+
const k = key(c.siid, c.piid);
|
|
1057
|
+
const previous = this.#cache.get(k);
|
|
1058
|
+
this.#cache.set(k, { siid: c.siid, piid: c.piid, value: c.value, updatedAt: now });
|
|
1059
|
+
const ev = {
|
|
1060
|
+
deviceId: this.#device.did,
|
|
1061
|
+
siid: c.siid,
|
|
1062
|
+
piid: c.piid,
|
|
1063
|
+
value: c.value,
|
|
1064
|
+
previousValue: previous ? previous.value : null
|
|
1065
|
+
};
|
|
1066
|
+
emitted.push(ev);
|
|
1067
|
+
this.emit("propertyChanged", ev);
|
|
1068
|
+
}
|
|
1069
|
+
if (emitted.length > 0) {
|
|
1070
|
+
this.emit("stateChanged", { deviceId: this.#device.did, changes: emitted });
|
|
1071
|
+
}
|
|
1072
|
+
}
|
|
1073
|
+
#onMqttUp() {
|
|
1074
|
+
this.#mqttHealthy = true;
|
|
1075
|
+
this.#stopPolling();
|
|
1076
|
+
}
|
|
1077
|
+
#onMqttDown() {
|
|
1078
|
+
this.#mqttHealthy = false;
|
|
1079
|
+
this.#startPolling();
|
|
1080
|
+
}
|
|
1081
|
+
#startPolling() {
|
|
1082
|
+
if (this.#closed || this.#pollTimer || this.#pollIntervalMs <= 0) {
|
|
1083
|
+
return;
|
|
1084
|
+
}
|
|
1085
|
+
if (this.#initialProps.length === 0) {
|
|
1086
|
+
return;
|
|
1087
|
+
}
|
|
1088
|
+
this.#pollTimer = setInterval(() => {
|
|
1089
|
+
if (this.#mqttHealthy) {
|
|
1090
|
+
this.#stopPolling();
|
|
1091
|
+
return;
|
|
1092
|
+
}
|
|
1093
|
+
void this.refreshProperties(this.#initialProps).catch((err) => {
|
|
1094
|
+
this.emit("error", err instanceof Error ? err : new Error(String(err)));
|
|
1095
|
+
});
|
|
1096
|
+
}, this.#pollIntervalMs);
|
|
1097
|
+
}
|
|
1098
|
+
#stopPolling() {
|
|
1099
|
+
if (this.#pollTimer) {
|
|
1100
|
+
clearInterval(this.#pollTimer);
|
|
1101
|
+
this.#pollTimer = null;
|
|
1102
|
+
}
|
|
1103
|
+
}
|
|
1104
|
+
};
|
|
1105
|
+
function key(siid, piid) {
|
|
1106
|
+
return `${siid}.${piid}`;
|
|
1107
|
+
}
|
|
1108
|
+
|
|
1109
|
+
// src/models/vacuum/properties.ts
|
|
1110
|
+
var VACUUM_PROP = {
|
|
1111
|
+
/** VERIFIED r2532a — MiotState enum. */
|
|
1112
|
+
STATE: { siid: 2, piid: 1 },
|
|
1113
|
+
/** VERIFIED r2532a — single-value error/fault code (see MiotError). */
|
|
1114
|
+
ERROR: { siid: 2, piid: 2 },
|
|
1115
|
+
/** VERIFIED r2449a — writable CleaningMode (plain 0..3). SAFE write path. */
|
|
1116
|
+
CLEAN_MODE_SETTING: { siid: 2, piid: 6 },
|
|
1117
|
+
/** VERIFIED r2532a — multi-value comma-separated fault-list mirror. */
|
|
1118
|
+
FAULTS_STR: { siid: 4, piid: 18 },
|
|
1119
|
+
/** VERIFIED r2532a — Dreame "task status" enum (TaskStatus). */
|
|
1120
|
+
TASK_STATUS: { siid: 4, piid: 1 },
|
|
1121
|
+
/** ASSUMED Tasshack types.py:573 — job runtime (minutes). */
|
|
1122
|
+
CLEANING_TIME: { siid: 4, piid: 2 },
|
|
1123
|
+
/** ASSUMED Tasshack types.py:574 — area cleaned (m²). */
|
|
1124
|
+
CLEANED_AREA: { siid: 4, piid: 3 },
|
|
1125
|
+
/** ASSUMED Tasshack types.py:575 — suction enum (SuctionLevel). */
|
|
1126
|
+
SUCTION_LEVEL: { siid: 4, piid: 4 },
|
|
1127
|
+
/** ASSUMED Tasshack types.py:576 — water flow during cleaning (WaterVolume). */
|
|
1128
|
+
WATER_VOLUME: { siid: 4, piid: 5 },
|
|
1129
|
+
/** ASSUMED Tasshack types.py:1479 — relocation state machine (0/1/10/11). */
|
|
1130
|
+
RELOCATION_STATUS: { siid: 4, piid: 20 },
|
|
1131
|
+
/**
|
|
1132
|
+
* VERIFIED r2449a — packed (0x1400 capability mask | CleaningMode low-2-bits).
|
|
1133
|
+
* READ-ONLY here: do NOT write directly (drops mask → bricks next clean).
|
|
1134
|
+
* Use CLEAN_MODE_SETTING (siid 2 piid 6) to change clean mode.
|
|
1135
|
+
*/
|
|
1136
|
+
CLEANING_MODE: { siid: 4, piid: 23 },
|
|
1137
|
+
/** VERIFIED r2532a — Child Lock boolean. */
|
|
1138
|
+
CHILD_LOCK: { siid: 4, piid: 27 },
|
|
1139
|
+
/** VERIFIED r2532a 2026-05-03 — task progress percentage 0..100. */
|
|
1140
|
+
TASK_PROGRESS_PCT: { siid: 4, piid: 63 },
|
|
1141
|
+
/** VERIFIED r2532a — mop-drying progress (minutes ticking during MopDrying). */
|
|
1142
|
+
DRYING_PROGRESS: { siid: 4, piid: 64 }
|
|
1143
|
+
};
|
|
1144
|
+
var VACUUM_ACTION = {
|
|
1145
|
+
/** ASSUMED Tasshack — start cleaning. */
|
|
1146
|
+
START: { siid: 2, aiid: 1 },
|
|
1147
|
+
/** VERIFIED r2532a — pause (code 0 while idle). */
|
|
1148
|
+
PAUSE: { siid: 2, aiid: 2 },
|
|
1149
|
+
/** ASSUMED Tasshack — return to dock / charge. */
|
|
1150
|
+
CHARGE: { siid: 3, aiid: 1 },
|
|
1151
|
+
/** ASSUMED Tasshack — START_CUSTOM (segment/zone/spot; needs in payload). */
|
|
1152
|
+
START_CUSTOM: { siid: 4, aiid: 1 },
|
|
1153
|
+
/** VERIFIED r2532a — stop (code 0 while idle). */
|
|
1154
|
+
STOP: { siid: 4, aiid: 2 },
|
|
1155
|
+
/** VERIFIED r2532a 2026-05-02 — clear warning (code 0). */
|
|
1156
|
+
CLEAR_WARNING: { siid: 4, aiid: 3 },
|
|
1157
|
+
/** ASSUMED Tasshack — start dock mop wash. */
|
|
1158
|
+
START_WASHING: { siid: 4, aiid: 4 },
|
|
1159
|
+
/** VERIFIED r2532a 2026-05-02 — locate (robot beeps, code 0). */
|
|
1160
|
+
LOCATE: { siid: 7, aiid: 1 },
|
|
1161
|
+
/** ASSUMED Tasshack — manual auto-empty trigger. */
|
|
1162
|
+
START_AUTO_EMPTY: { siid: 15, aiid: 1 }
|
|
1163
|
+
};
|
|
1164
|
+
var BATTERY_PROP = {
|
|
1165
|
+
/** VERIFIED r2532a — battery percentage. */
|
|
1166
|
+
LEVEL: { siid: 3, piid: 1 },
|
|
1167
|
+
/** VERIFIED r2532a — charging status (ChargingStatus). */
|
|
1168
|
+
CHARGING_STATUS: { siid: 3, piid: 2 }
|
|
1169
|
+
};
|
|
1170
|
+
var SETTINGS_PROP = {
|
|
1171
|
+
/** ASSUMED Tasshack types.py:642 — voice volume 0-100. */
|
|
1172
|
+
VOLUME: { siid: 7, piid: 1 }
|
|
1173
|
+
};
|
|
1174
|
+
var CONSUMABLE_PROP = {
|
|
1175
|
+
MAIN_BRUSH_LEFT: { siid: 9, piid: 2 },
|
|
1176
|
+
SIDE_BRUSH_LEFT: { siid: 10, piid: 2 },
|
|
1177
|
+
FILTER_LEFT: { siid: 11, piid: 1 }
|
|
1178
|
+
};
|
|
1179
|
+
var CUSTOM_CLEAN_MODE = {
|
|
1180
|
+
SEGMENT: 18,
|
|
1181
|
+
ZONE: 19,
|
|
1182
|
+
SPOT: 20
|
|
1183
|
+
};
|
|
1184
|
+
|
|
1185
|
+
// src/models/vacuum/enums.ts
|
|
1186
|
+
var MiotState = /* @__PURE__ */ ((MiotState2) => {
|
|
1187
|
+
MiotState2[MiotState2["Cleaning"] = 1] = "Cleaning";
|
|
1188
|
+
MiotState2[MiotState2["Standby"] = 2] = "Standby";
|
|
1189
|
+
MiotState2[MiotState2["Paused"] = 3] = "Paused";
|
|
1190
|
+
MiotState2[MiotState2["PausedAlt"] = 4] = "PausedAlt";
|
|
1191
|
+
MiotState2[MiotState2["ReturningToCharge"] = 5] = "ReturningToCharge";
|
|
1192
|
+
MiotState2[MiotState2["Charging"] = 6] = "Charging";
|
|
1193
|
+
MiotState2[MiotState2["Mopping"] = 7] = "Mopping";
|
|
1194
|
+
MiotState2[MiotState2["MopDrying"] = 8] = "MopDrying";
|
|
1195
|
+
MiotState2[MiotState2["MopCleaning"] = 9] = "MopCleaning";
|
|
1196
|
+
MiotState2[MiotState2["ReturningToWash"] = 10] = "ReturningToWash";
|
|
1197
|
+
MiotState2[MiotState2["Mapping"] = 11] = "Mapping";
|
|
1198
|
+
MiotState2[MiotState2["CleaningAlt"] = 12] = "CleaningAlt";
|
|
1199
|
+
MiotState2[MiotState2["ChargingComplete"] = 13] = "ChargingComplete";
|
|
1200
|
+
MiotState2[MiotState2["Updating"] = 14] = "Updating";
|
|
1201
|
+
MiotState2[MiotState2["CallToClean"] = 15] = "CallToClean";
|
|
1202
|
+
MiotState2[MiotState2["AutoRepairBase"] = 16] = "AutoRepairBase";
|
|
1203
|
+
MiotState2[MiotState2["ReturnInstallMop"] = 17] = "ReturnInstallMop";
|
|
1204
|
+
MiotState2[MiotState2["ReturnRemoveMop"] = 18] = "ReturnRemoveMop";
|
|
1205
|
+
MiotState2[MiotState2["WaterSupplyDrainTest"] = 19] = "WaterSupplyDrainTest";
|
|
1206
|
+
MiotState2[MiotState2["CleanMopRefillWater"] = 20] = "CleanMopRefillWater";
|
|
1207
|
+
MiotState2[MiotState2["PausedCleaning"] = 21] = "PausedCleaning";
|
|
1208
|
+
MiotState2[MiotState2["AutoEmptying"] = 22] = "AutoEmptying";
|
|
1209
|
+
MiotState2[MiotState2["RemoteCleaning"] = 23] = "RemoteCleaning";
|
|
1210
|
+
MiotState2[MiotState2["IntelligentCharging"] = 24] = "IntelligentCharging";
|
|
1211
|
+
MiotState2[MiotState2["SecondCleaning"] = 25] = "SecondCleaning";
|
|
1212
|
+
MiotState2[MiotState2["Following"] = 26] = "Following";
|
|
1213
|
+
MiotState2[MiotState2["PartialCleaning"] = 27] = "PartialCleaning";
|
|
1214
|
+
MiotState2[MiotState2["ReturnToEmpty"] = 28] = "ReturnToEmpty";
|
|
1215
|
+
MiotState2[MiotState2["WaitingForTask"] = 29] = "WaitingForTask";
|
|
1216
|
+
MiotState2[MiotState2["CleanWashboardBase"] = 30] = "CleanWashboardBase";
|
|
1217
|
+
MiotState2[MiotState2["AutoWaterDraining"] = 33] = "AutoWaterDraining";
|
|
1218
|
+
MiotState2[MiotState2["ShortcutRunning"] = 97] = "ShortcutRunning";
|
|
1219
|
+
MiotState2[MiotState2["CameraMonitoring"] = 98] = "CameraMonitoring";
|
|
1220
|
+
MiotState2[MiotState2["CameraMonitoringPaused"] = 99] = "CameraMonitoringPaused";
|
|
1221
|
+
MiotState2[MiotState2["InitialDeepClean"] = 101] = "InitialDeepClean";
|
|
1222
|
+
return MiotState2;
|
|
1223
|
+
})(MiotState || {});
|
|
1224
|
+
var ChargingStatus = /* @__PURE__ */ ((ChargingStatus2) => {
|
|
1225
|
+
ChargingStatus2[ChargingStatus2["Charging"] = 1] = "Charging";
|
|
1226
|
+
ChargingStatus2[ChargingStatus2["Discharging"] = 2] = "Discharging";
|
|
1227
|
+
ChargingStatus2[ChargingStatus2["Returning"] = 5] = "Returning";
|
|
1228
|
+
return ChargingStatus2;
|
|
1229
|
+
})(ChargingStatus || {});
|
|
1230
|
+
var SuctionLevel = /* @__PURE__ */ ((SuctionLevel2) => {
|
|
1231
|
+
SuctionLevel2[SuctionLevel2["Quiet"] = 0] = "Quiet";
|
|
1232
|
+
SuctionLevel2[SuctionLevel2["Standard"] = 1] = "Standard";
|
|
1233
|
+
SuctionLevel2[SuctionLevel2["Intense"] = 2] = "Intense";
|
|
1234
|
+
SuctionLevel2[SuctionLevel2["Max"] = 3] = "Max";
|
|
1235
|
+
return SuctionLevel2;
|
|
1236
|
+
})(SuctionLevel || {});
|
|
1237
|
+
var WaterVolume = /* @__PURE__ */ ((WaterVolume2) => {
|
|
1238
|
+
WaterVolume2[WaterVolume2["Low"] = 1] = "Low";
|
|
1239
|
+
WaterVolume2[WaterVolume2["Medium"] = 2] = "Medium";
|
|
1240
|
+
WaterVolume2[WaterVolume2["High"] = 3] = "High";
|
|
1241
|
+
return WaterVolume2;
|
|
1242
|
+
})(WaterVolume || {});
|
|
1243
|
+
var CleaningMode = /* @__PURE__ */ ((CleaningMode2) => {
|
|
1244
|
+
CleaningMode2[CleaningMode2["Sweeping"] = 0] = "Sweeping";
|
|
1245
|
+
CleaningMode2[CleaningMode2["Mopping"] = 1] = "Mopping";
|
|
1246
|
+
CleaningMode2[CleaningMode2["SweepAndMop"] = 2] = "SweepAndMop";
|
|
1247
|
+
CleaningMode2[CleaningMode2["MopAfterSweep"] = 3] = "MopAfterSweep";
|
|
1248
|
+
return CleaningMode2;
|
|
1249
|
+
})(CleaningMode || {});
|
|
1250
|
+
var MiotError = /* @__PURE__ */ ((MiotError2) => {
|
|
1251
|
+
MiotError2[MiotError2["Clear"] = 0] = "Clear";
|
|
1252
|
+
MiotError2[MiotError2["WheelRotationAnomaly"] = 1] = "WheelRotationAnomaly";
|
|
1253
|
+
MiotError2[MiotError2["RobotLifted"] = 18] = "RobotLifted";
|
|
1254
|
+
MiotError2[MiotError2["TaskComplete"] = 68] = "TaskComplete";
|
|
1255
|
+
MiotError2[MiotError2["ManualMopInstallRequired"] = 74] = "ManualMopInstallRequired";
|
|
1256
|
+
MiotError2[MiotError2["WastewaterTankFull"] = 105] = "WastewaterTankFull";
|
|
1257
|
+
MiotError2[MiotError2["CleanWaterTankEmpty"] = 107] = "CleanWaterTankEmpty";
|
|
1258
|
+
MiotError2[MiotError2["WashboardFilterNeedsCleaning"] = 114] = "WashboardFilterNeedsCleaning";
|
|
1259
|
+
MiotError2[MiotError2["MopPadsMissing"] = 120] = "MopPadsMissing";
|
|
1260
|
+
MiotError2[MiotError2["BatteryLow"] = 20] = "BatteryLow";
|
|
1261
|
+
MiotError2[MiotError2["ChargeFault"] = 21] = "ChargeFault";
|
|
1262
|
+
MiotError2[MiotError2["BatteryPercentageAnomaly"] = 22] = "BatteryPercentageAnomaly";
|
|
1263
|
+
MiotError2[MiotError2["ChargeNoElectric"] = 28] = "ChargeNoElectric";
|
|
1264
|
+
MiotError2[MiotError2["BatteryFault"] = 29] = "BatteryFault";
|
|
1265
|
+
MiotError2[MiotError2["LowBatteryTurnOff"] = 75] = "LowBatteryTurnOff";
|
|
1266
|
+
MiotError2[MiotError2["RobotStuck"] = 80] = "RobotStuck";
|
|
1267
|
+
MiotError2[MiotError2["RobotStuckRepeat"] = 81] = "RobotStuckRepeat";
|
|
1268
|
+
MiotError2[MiotError2["RobotStuck2"] = 90] = "RobotStuck2";
|
|
1269
|
+
MiotError2[MiotError2["RobotStuckOnTables"] = 91] = "RobotStuckOnTables";
|
|
1270
|
+
MiotError2[MiotError2["RobotStuckOnPassage"] = 92] = "RobotStuckOnPassage";
|
|
1271
|
+
MiotError2[MiotError2["RobotStuckOnThreshold"] = 93] = "RobotStuckOnThreshold";
|
|
1272
|
+
MiotError2[MiotError2["RobotStuckOnLowLyingArea"] = 94] = "RobotStuckOnLowLyingArea";
|
|
1273
|
+
MiotError2[MiotError2["RobotStuckOnRamp"] = 95] = "RobotStuckOnRamp";
|
|
1274
|
+
MiotError2[MiotError2["RobotStuckOnObstacle"] = 96] = "RobotStuckOnObstacle";
|
|
1275
|
+
MiotError2[MiotError2["RobotStuckOnPet"] = 97] = "RobotStuckOnPet";
|
|
1276
|
+
MiotError2[MiotError2["RobotStuckOnSlipperySurface"] = 98] = "RobotStuckOnSlipperySurface";
|
|
1277
|
+
MiotError2[MiotError2["RobotStuckOnCarpet"] = 99] = "RobotStuckOnCarpet";
|
|
1278
|
+
MiotError2[MiotError2["RobotStuckOnCurtain"] = 200] = "RobotStuckOnCurtain";
|
|
1279
|
+
MiotError2[MiotError2["BinFull"] = 101] = "BinFull";
|
|
1280
|
+
MiotError2[MiotError2["StationDisconnected"] = 117] = "StationDisconnected";
|
|
1281
|
+
MiotError2[MiotError2["DustBagFull"] = 121] = "DustBagFull";
|
|
1282
|
+
return MiotError2;
|
|
1283
|
+
})(MiotError || {});
|
|
1284
|
+
var TaskStatus = /* @__PURE__ */ ((TaskStatus2) => {
|
|
1285
|
+
TaskStatus2[TaskStatus2["InterruptedOrPaused"] = 1] = "InterruptedOrPaused";
|
|
1286
|
+
TaskStatus2[TaskStatus2["Active"] = 2] = "Active";
|
|
1287
|
+
TaskStatus2[TaskStatus2["Transitioning"] = 3] = "Transitioning";
|
|
1288
|
+
TaskStatus2[TaskStatus2["OnDockIdle"] = 6] = "OnDockIdle";
|
|
1289
|
+
TaskStatus2[TaskStatus2["TransientPauseEdge"] = 12] = "TransientPauseEdge";
|
|
1290
|
+
TaskStatus2[TaskStatus2["NeedsIntervention"] = 14] = "NeedsIntervention";
|
|
1291
|
+
return TaskStatus2;
|
|
1292
|
+
})(TaskStatus || {});
|
|
1293
|
+
|
|
1294
|
+
// src/models/_shared/decode.ts
|
|
1295
|
+
function asNum(value) {
|
|
1296
|
+
return typeof value === "number" ? value : null;
|
|
1297
|
+
}
|
|
1298
|
+
function enumLookup(members) {
|
|
1299
|
+
const set = new Set(members);
|
|
1300
|
+
return (n) => n !== null && set.has(n) ? members.find((m) => m === n) ?? null : null;
|
|
1301
|
+
}
|
|
1302
|
+
|
|
1303
|
+
// src/models/vacuum/decode.ts
|
|
1304
|
+
function parseFaultList(value) {
|
|
1305
|
+
if (typeof value !== "string") {
|
|
1306
|
+
if (typeof value === "number" && value !== 0) {
|
|
1307
|
+
return [value];
|
|
1308
|
+
}
|
|
1309
|
+
return [];
|
|
1310
|
+
}
|
|
1311
|
+
const trimmed = value.trim();
|
|
1312
|
+
if (trimmed === "" || trimmed === "0") {
|
|
1313
|
+
return [];
|
|
1314
|
+
}
|
|
1315
|
+
const codes = [];
|
|
1316
|
+
for (const part of trimmed.split(",")) {
|
|
1317
|
+
const n = Number.parseInt(part.trim(), 10);
|
|
1318
|
+
if (Number.isFinite(n) && n !== 0) {
|
|
1319
|
+
codes.push(n);
|
|
1320
|
+
}
|
|
1321
|
+
}
|
|
1322
|
+
return codes;
|
|
1323
|
+
}
|
|
1324
|
+
|
|
1325
|
+
// src/models/vacuum/capabilities.ts
|
|
1326
|
+
var FALLBACK = {
|
|
1327
|
+
verified: false,
|
|
1328
|
+
canMop: false,
|
|
1329
|
+
canAutoInstallMop: false,
|
|
1330
|
+
hasSideBrush: true,
|
|
1331
|
+
hasCamera: false,
|
|
1332
|
+
hasCarpetSensor: false,
|
|
1333
|
+
hasAiObstacleDetection: false,
|
|
1334
|
+
canAutoEmpty: false,
|
|
1335
|
+
canMopWash: false,
|
|
1336
|
+
canMopDry: false,
|
|
1337
|
+
canHeatMopWater: false,
|
|
1338
|
+
hasDetergentReservoir: false,
|
|
1339
|
+
canCleanPerRoom: false,
|
|
1340
|
+
supportsVirtualWalls: false,
|
|
1341
|
+
supportsNoGoZones: false,
|
|
1342
|
+
hasChildLock: false,
|
|
1343
|
+
supportsMultiFloor: false,
|
|
1344
|
+
// Map decoding is model-agnostic (binary frame format is identical across
|
|
1345
|
+
// the lineup); the fallback record leaves it false so an unknown model does
|
|
1346
|
+
// not advertise a feature we have not verified its firmware exposes.
|
|
1347
|
+
canMap: false,
|
|
1348
|
+
supportedSuctionLevels: [
|
|
1349
|
+
0 /* Quiet */,
|
|
1350
|
+
1 /* Standard */,
|
|
1351
|
+
2 /* Intense */,
|
|
1352
|
+
3 /* Max */
|
|
1353
|
+
],
|
|
1354
|
+
supportedWaterVolumes: [1 /* Low */, 2 /* Medium */, 3 /* High */]
|
|
1355
|
+
};
|
|
1356
|
+
var X50_FAMILY = {
|
|
1357
|
+
canMop: true,
|
|
1358
|
+
canAutoInstallMop: true,
|
|
1359
|
+
hasSideBrush: true,
|
|
1360
|
+
hasCamera: true,
|
|
1361
|
+
hasCarpetSensor: true,
|
|
1362
|
+
hasAiObstacleDetection: true,
|
|
1363
|
+
canAutoEmpty: true,
|
|
1364
|
+
canMopWash: true,
|
|
1365
|
+
canMopDry: true,
|
|
1366
|
+
canHeatMopWater: true,
|
|
1367
|
+
hasDetergentReservoir: true,
|
|
1368
|
+
canCleanPerRoom: true,
|
|
1369
|
+
supportsVirtualWalls: true,
|
|
1370
|
+
supportsNoGoZones: true,
|
|
1371
|
+
hasChildLock: true,
|
|
1372
|
+
supportsMultiFloor: true,
|
|
1373
|
+
canMap: true,
|
|
1374
|
+
supportedSuctionLevels: [
|
|
1375
|
+
0 /* Quiet */,
|
|
1376
|
+
1 /* Standard */,
|
|
1377
|
+
2 /* Intense */,
|
|
1378
|
+
3 /* Max */
|
|
1379
|
+
],
|
|
1380
|
+
supportedWaterVolumes: [1 /* Low */, 2 /* Medium */, 3 /* High */]
|
|
1381
|
+
};
|
|
1382
|
+
function deepFreeze(obj) {
|
|
1383
|
+
if (obj && typeof obj === "object" && !Object.isFrozen(obj)) {
|
|
1384
|
+
Object.freeze(obj);
|
|
1385
|
+
for (const v of Object.values(obj)) {
|
|
1386
|
+
deepFreeze(v);
|
|
1387
|
+
}
|
|
1388
|
+
}
|
|
1389
|
+
return obj;
|
|
1390
|
+
}
|
|
1391
|
+
var MODEL_CAPABILITIES = {
|
|
1392
|
+
/** Dreame X40 Ultra Complete — VERIFIED r2449a (2026-05-21). */
|
|
1393
|
+
"dreame.vacuum.r2449a": { model: "dreame.vacuum.r2449a", verified: true, ...X50_FAMILY },
|
|
1394
|
+
/** Dreame X50 Ultra Complete — VERIFIED r2532a (firmware 4.3.9_2199, 2026-05-02). */
|
|
1395
|
+
"dreame.vacuum.r2532a": { model: "dreame.vacuum.r2532a", verified: true, ...X50_FAMILY },
|
|
1396
|
+
/**
|
|
1397
|
+
* Dreame r2538z — the user's device. ASSUMED from the r2532a (X50) sibling:
|
|
1398
|
+
* same dock-side feature set is the working hypothesis. Marked verified:false
|
|
1399
|
+
* until confirmed live (e2e). Flip to true + drop this note once verified.
|
|
1400
|
+
*/
|
|
1401
|
+
"dreame.vacuum.r2538z": { model: "dreame.vacuum.r2538z", verified: false, ...X50_FAMILY }
|
|
1402
|
+
};
|
|
1403
|
+
for (const entry of Object.values(MODEL_CAPABILITIES)) {
|
|
1404
|
+
deepFreeze(entry);
|
|
1405
|
+
}
|
|
1406
|
+
function getVacuumCapabilities(model) {
|
|
1407
|
+
const known = MODEL_CAPABILITIES[model];
|
|
1408
|
+
if (known) {
|
|
1409
|
+
return known;
|
|
1410
|
+
}
|
|
1411
|
+
return deepFreeze({ model, ...FALLBACK });
|
|
1412
|
+
}
|
|
1413
|
+
function tokensFor(c) {
|
|
1414
|
+
const tokens = /* @__PURE__ */ new Set();
|
|
1415
|
+
const add = (flag, token) => {
|
|
1416
|
+
if (flag) {
|
|
1417
|
+
tokens.add(token);
|
|
1418
|
+
}
|
|
1419
|
+
};
|
|
1420
|
+
add(c.canMop, "mop");
|
|
1421
|
+
add(c.canAutoInstallMop, "auto-install-mop");
|
|
1422
|
+
add(c.hasSideBrush, "side-brush");
|
|
1423
|
+
add(c.hasCamera, "camera");
|
|
1424
|
+
add(c.hasCarpetSensor, "carpet-sensor");
|
|
1425
|
+
add(c.hasAiObstacleDetection, "ai-obstacle");
|
|
1426
|
+
add(c.canAutoEmpty, "auto-empty");
|
|
1427
|
+
add(c.canMopWash, "mop-wash");
|
|
1428
|
+
add(c.canMopDry, "mop-dry");
|
|
1429
|
+
add(c.canHeatMopWater, "heat-mop-water");
|
|
1430
|
+
add(c.hasDetergentReservoir, "detergent");
|
|
1431
|
+
add(c.canCleanPerRoom, "clean-per-room");
|
|
1432
|
+
add(c.supportsVirtualWalls, "virtual-walls");
|
|
1433
|
+
add(c.supportsNoGoZones, "no-go-zones");
|
|
1434
|
+
add(c.hasChildLock, "child-lock");
|
|
1435
|
+
add(c.supportsMultiFloor, "multi-floor");
|
|
1436
|
+
return tokens;
|
|
1437
|
+
}
|
|
1438
|
+
var VacuumCapabilitySet = class {
|
|
1439
|
+
model;
|
|
1440
|
+
#tokens;
|
|
1441
|
+
constructor(model, tokens) {
|
|
1442
|
+
this.model = model;
|
|
1443
|
+
this.#tokens = tokens;
|
|
1444
|
+
}
|
|
1445
|
+
has(token) {
|
|
1446
|
+
return this.#tokens.has(token);
|
|
1447
|
+
}
|
|
1448
|
+
list() {
|
|
1449
|
+
return [...this.#tokens];
|
|
1450
|
+
}
|
|
1451
|
+
};
|
|
1452
|
+
var VacuumCapabilityResolver = class {
|
|
1453
|
+
resolve(model) {
|
|
1454
|
+
return new VacuumCapabilitySet(model, tokensFor(getVacuumCapabilities(model)));
|
|
1455
|
+
}
|
|
1456
|
+
};
|
|
1457
|
+
|
|
1458
|
+
// src/models/vacuum/map/envelope.ts
|
|
1459
|
+
var import_node_crypto2 = require("crypto");
|
|
1460
|
+
var zlib = __toESM(require("zlib"), 1);
|
|
1461
|
+
var HEADER_SIZE = 27;
|
|
1462
|
+
var ANGLE_ABSENT = 32767;
|
|
1463
|
+
var FRAME_TYPE = {
|
|
1464
|
+
I: 73,
|
|
1465
|
+
P: 80,
|
|
1466
|
+
W: 87
|
|
1467
|
+
};
|
|
1468
|
+
var MapDecodeError = class extends Error {
|
|
1469
|
+
constructor(message, options) {
|
|
1470
|
+
super(message, options);
|
|
1471
|
+
this.name = "MapDecodeError";
|
|
1472
|
+
}
|
|
1473
|
+
};
|
|
1474
|
+
function unwrapEnvelope(value, opts = {}) {
|
|
1475
|
+
const commaIdx = value.indexOf(",");
|
|
1476
|
+
const b64 = commaIdx >= 0 ? value.slice(0, commaIdx) : value;
|
|
1477
|
+
const embeddedKey = commaIdx >= 0 ? value.slice(commaIdx + 1) : null;
|
|
1478
|
+
const standard = b64.replace(/-/g, "+").replace(/_/g, "/");
|
|
1479
|
+
let bytes;
|
|
1480
|
+
try {
|
|
1481
|
+
bytes = Buffer.from(standard, "base64");
|
|
1482
|
+
} catch (err) {
|
|
1483
|
+
throw new MapDecodeError("envelope: base64 decode failed", { cause: err });
|
|
1484
|
+
}
|
|
1485
|
+
if (bytes.length === 0) {
|
|
1486
|
+
throw new MapDecodeError("envelope: empty payload after base64 decode");
|
|
1487
|
+
}
|
|
1488
|
+
const key2 = embeddedKey ?? opts.key ?? null;
|
|
1489
|
+
if (key2) {
|
|
1490
|
+
if (!opts.iv) {
|
|
1491
|
+
throw new MapDecodeError(
|
|
1492
|
+
"envelope: AES key supplied but no IV \u2014 pass `opts.iv` (16-byte ASCII)"
|
|
1493
|
+
);
|
|
1494
|
+
}
|
|
1495
|
+
bytes = aesCbcDecrypt(bytes, key2, opts.iv);
|
|
1496
|
+
}
|
|
1497
|
+
try {
|
|
1498
|
+
return zlib.inflateSync(bytes);
|
|
1499
|
+
} catch (err) {
|
|
1500
|
+
throw new MapDecodeError("envelope: zlib inflate failed", { cause: err });
|
|
1501
|
+
}
|
|
1502
|
+
}
|
|
1503
|
+
function aesCbcDecrypt(cipher, rawKey, iv) {
|
|
1504
|
+
const keyBytes = Buffer.from(
|
|
1505
|
+
(0, import_node_crypto2.createHash)("sha256").update(rawKey).digest("hex").slice(0, 32),
|
|
1506
|
+
"utf8"
|
|
1507
|
+
);
|
|
1508
|
+
const ivBytes = Buffer.from(iv, "utf8");
|
|
1509
|
+
if (ivBytes.length !== 16) {
|
|
1510
|
+
throw new MapDecodeError(`envelope: AES IV must be 16 ASCII bytes, got ${ivBytes.length}`);
|
|
1511
|
+
}
|
|
1512
|
+
try {
|
|
1513
|
+
const decipher = (0, import_node_crypto2.createDecipheriv)("aes-256-cbc", keyBytes, ivBytes);
|
|
1514
|
+
decipher.setAutoPadding(false);
|
|
1515
|
+
return Buffer.concat([decipher.update(cipher), decipher.final()]);
|
|
1516
|
+
} catch (err) {
|
|
1517
|
+
throw new MapDecodeError("envelope: AES decrypt failed", { cause: err });
|
|
1518
|
+
}
|
|
1519
|
+
}
|
|
1520
|
+
|
|
1521
|
+
// src/models/vacuum/map/header.ts
|
|
1522
|
+
function parseMapHeader(buf) {
|
|
1523
|
+
if (buf.length < HEADER_SIZE) {
|
|
1524
|
+
throw new MapDecodeError(`header: need ${HEADER_SIZE} bytes, got ${buf.length}`);
|
|
1525
|
+
}
|
|
1526
|
+
return {
|
|
1527
|
+
mapId: buf.readInt16LE(0),
|
|
1528
|
+
frameId: buf.readInt16LE(2),
|
|
1529
|
+
frameType: frameTypeFromByte(buf[4]),
|
|
1530
|
+
robotX: buf.readInt16LE(5),
|
|
1531
|
+
robotY: buf.readInt16LE(7),
|
|
1532
|
+
robotA: buf.readInt16LE(9),
|
|
1533
|
+
chargerX: buf.readInt16LE(11),
|
|
1534
|
+
chargerY: buf.readInt16LE(13),
|
|
1535
|
+
chargerA: buf.readInt16LE(15),
|
|
1536
|
+
gridSize: buf.readInt16LE(17),
|
|
1537
|
+
width: buf.readInt16LE(19),
|
|
1538
|
+
height: buf.readInt16LE(21),
|
|
1539
|
+
left: buf.readInt16LE(23),
|
|
1540
|
+
top: buf.readInt16LE(25)
|
|
1541
|
+
};
|
|
1542
|
+
}
|
|
1543
|
+
function frameTypeFromByte(b) {
|
|
1544
|
+
switch (b) {
|
|
1545
|
+
case FRAME_TYPE.I:
|
|
1546
|
+
return "I";
|
|
1547
|
+
case FRAME_TYPE.P:
|
|
1548
|
+
return "P";
|
|
1549
|
+
case FRAME_TYPE.W:
|
|
1550
|
+
return "W";
|
|
1551
|
+
default:
|
|
1552
|
+
throw new MapDecodeError(`header: unknown frame_type byte ${b}`);
|
|
1553
|
+
}
|
|
1554
|
+
}
|
|
1555
|
+
|
|
1556
|
+
// src/models/vacuum/map/tail.ts
|
|
1557
|
+
function sliceTailText(inflated, header) {
|
|
1558
|
+
const start = HEADER_SIZE + header.width * header.height;
|
|
1559
|
+
if (inflated.length < start) {
|
|
1560
|
+
throw new MapDecodeError(
|
|
1561
|
+
`tail: inflated payload shorter than header+pixels (${inflated.length} < ${start})`
|
|
1562
|
+
);
|
|
1563
|
+
}
|
|
1564
|
+
return inflated.subarray(start).toString("utf8");
|
|
1565
|
+
}
|
|
1566
|
+
function asMapTail(value) {
|
|
1567
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
1568
|
+
throw new MapDecodeError("tail: JSON top-level is not an object");
|
|
1569
|
+
}
|
|
1570
|
+
const record = { ...value };
|
|
1571
|
+
return record;
|
|
1572
|
+
}
|
|
1573
|
+
function parseMapJsonTail(text) {
|
|
1574
|
+
if (!text) {
|
|
1575
|
+
return {};
|
|
1576
|
+
}
|
|
1577
|
+
let parsed;
|
|
1578
|
+
try {
|
|
1579
|
+
parsed = JSON.parse(text);
|
|
1580
|
+
} catch (err) {
|
|
1581
|
+
throw new MapDecodeError("tail: JSON parse failed", { cause: err });
|
|
1582
|
+
}
|
|
1583
|
+
return asMapTail(parsed);
|
|
1584
|
+
}
|
|
1585
|
+
function parseFrame(inflated) {
|
|
1586
|
+
const header = parseMapHeader(inflated);
|
|
1587
|
+
const tail = parseMapJsonTail(sliceTailText(inflated, header));
|
|
1588
|
+
return { header, tail };
|
|
1589
|
+
}
|
|
1590
|
+
|
|
1591
|
+
// src/models/vacuum/map/pixel-grid.ts
|
|
1592
|
+
function classifyPixelFsm1(byte) {
|
|
1593
|
+
if (byte === 0) {
|
|
1594
|
+
return { kind: "outside" };
|
|
1595
|
+
}
|
|
1596
|
+
const seg = byte >> 2;
|
|
1597
|
+
const meta = byte & 3;
|
|
1598
|
+
const isCarpet = meta === 3;
|
|
1599
|
+
if (seg === 63) {
|
|
1600
|
+
return isCarpet ? { kind: "wall", carpet: true } : { kind: "wall" };
|
|
1601
|
+
}
|
|
1602
|
+
if (seg === 62) {
|
|
1603
|
+
return isCarpet ? { kind: "floor", carpet: true } : { kind: "floor" };
|
|
1604
|
+
}
|
|
1605
|
+
if (seg === 61) {
|
|
1606
|
+
return { kind: "outside" };
|
|
1607
|
+
}
|
|
1608
|
+
if (seg === 0) {
|
|
1609
|
+
if (meta === 2) {
|
|
1610
|
+
return { kind: "wall" };
|
|
1611
|
+
}
|
|
1612
|
+
if (isCarpet) {
|
|
1613
|
+
return { kind: "outside", carpet: true };
|
|
1614
|
+
}
|
|
1615
|
+
return { kind: "outside" };
|
|
1616
|
+
}
|
|
1617
|
+
return isCarpet ? { kind: "segment", segmentId: seg, carpet: true } : { kind: "segment", segmentId: seg };
|
|
1618
|
+
}
|
|
1619
|
+
function decodePixelGridFsm1(pixels, width, height) {
|
|
1620
|
+
const wallRuns = [];
|
|
1621
|
+
const floorRuns = [];
|
|
1622
|
+
const segmentRuns = /* @__PURE__ */ new Map();
|
|
1623
|
+
const carpetRuns = [];
|
|
1624
|
+
for (let y = 0; y < height; y++) {
|
|
1625
|
+
const rowBase = y * width;
|
|
1626
|
+
let runStart = -1;
|
|
1627
|
+
let runKind = "outside";
|
|
1628
|
+
let runSegmentId = 0;
|
|
1629
|
+
let carpetStart = -1;
|
|
1630
|
+
for (let x = 0; x <= width; x++) {
|
|
1631
|
+
const byte = x < width ? pixels[rowBase + x] : 0;
|
|
1632
|
+
const c = x < width ? classifyPixelFsm1(byte) : { kind: "outside", carpet: void 0 };
|
|
1633
|
+
const sameAsRun = runStart >= 0 && c.kind === runKind && (c.kind !== "segment" || c.segmentId === runSegmentId);
|
|
1634
|
+
if (!sameAsRun) {
|
|
1635
|
+
if (runStart >= 0 && runKind !== "outside") {
|
|
1636
|
+
const length = x - runStart;
|
|
1637
|
+
const run = [runStart, y, length];
|
|
1638
|
+
if (runKind === "wall") {
|
|
1639
|
+
wallRuns.push(run);
|
|
1640
|
+
} else if (runKind === "floor") {
|
|
1641
|
+
floorRuns.push(run);
|
|
1642
|
+
} else if (runKind === "segment") {
|
|
1643
|
+
let bucket = segmentRuns.get(runSegmentId);
|
|
1644
|
+
if (!bucket) {
|
|
1645
|
+
bucket = [];
|
|
1646
|
+
segmentRuns.set(runSegmentId, bucket);
|
|
1647
|
+
}
|
|
1648
|
+
bucket.push(run);
|
|
1649
|
+
}
|
|
1650
|
+
}
|
|
1651
|
+
if (x < width && c.kind !== "outside") {
|
|
1652
|
+
runStart = x;
|
|
1653
|
+
runKind = c.kind;
|
|
1654
|
+
runSegmentId = c.kind === "segment" ? c.segmentId : 0;
|
|
1655
|
+
} else {
|
|
1656
|
+
runStart = -1;
|
|
1657
|
+
}
|
|
1658
|
+
}
|
|
1659
|
+
const isCarpet = c.carpet === true;
|
|
1660
|
+
if (isCarpet && carpetStart < 0) {
|
|
1661
|
+
carpetStart = x;
|
|
1662
|
+
} else if (!isCarpet && carpetStart >= 0) {
|
|
1663
|
+
carpetRuns.push([carpetStart, y, x - carpetStart]);
|
|
1664
|
+
carpetStart = -1;
|
|
1665
|
+
}
|
|
1666
|
+
}
|
|
1667
|
+
}
|
|
1668
|
+
const layers = [];
|
|
1669
|
+
if (wallRuns.length > 0) {
|
|
1670
|
+
layers.push({ type: "wall", runs: wallRuns });
|
|
1671
|
+
}
|
|
1672
|
+
if (floorRuns.length > 0) {
|
|
1673
|
+
layers.push({ type: "floor", runs: floorRuns });
|
|
1674
|
+
}
|
|
1675
|
+
for (const [id, runs] of [...segmentRuns.entries()].sort(([a], [b]) => a - b)) {
|
|
1676
|
+
layers.push({ type: "segment", segmentId: id, runs });
|
|
1677
|
+
}
|
|
1678
|
+
if (carpetRuns.length > 0) {
|
|
1679
|
+
layers.push({ type: "carpet", runs: carpetRuns });
|
|
1680
|
+
}
|
|
1681
|
+
return layers;
|
|
1682
|
+
}
|
|
1683
|
+
function collectSegments(layers, dim, tail) {
|
|
1684
|
+
const activeIds = /* @__PURE__ */ new Set();
|
|
1685
|
+
for (const entry of tail.sa ?? []) {
|
|
1686
|
+
if (Array.isArray(entry) && typeof entry[0] === "number") {
|
|
1687
|
+
activeIds.add(entry[0]);
|
|
1688
|
+
}
|
|
1689
|
+
}
|
|
1690
|
+
const segs = [];
|
|
1691
|
+
for (const layer of layers) {
|
|
1692
|
+
if (layer.type !== "segment" || layer.segmentId === void 0) {
|
|
1693
|
+
continue;
|
|
1694
|
+
}
|
|
1695
|
+
const id = layer.segmentId;
|
|
1696
|
+
let xMinPx = Infinity;
|
|
1697
|
+
let yMinPx = Infinity;
|
|
1698
|
+
let xMaxPx = -Infinity;
|
|
1699
|
+
let yMaxPx = -Infinity;
|
|
1700
|
+
let sumXPx = 0;
|
|
1701
|
+
let sumYPx = 0;
|
|
1702
|
+
let count = 0;
|
|
1703
|
+
for (const [x, y, len] of layer.runs) {
|
|
1704
|
+
if (x < xMinPx) {
|
|
1705
|
+
xMinPx = x;
|
|
1706
|
+
}
|
|
1707
|
+
if (y < yMinPx) {
|
|
1708
|
+
yMinPx = y;
|
|
1709
|
+
}
|
|
1710
|
+
if (x + len - 1 > xMaxPx) {
|
|
1711
|
+
xMaxPx = x + len - 1;
|
|
1712
|
+
}
|
|
1713
|
+
if (y > yMaxPx) {
|
|
1714
|
+
yMaxPx = y;
|
|
1715
|
+
}
|
|
1716
|
+
sumXPx += len * x + len * (len - 1) / 2;
|
|
1717
|
+
sumYPx += len * y;
|
|
1718
|
+
count += len;
|
|
1719
|
+
}
|
|
1720
|
+
if (count === 0) {
|
|
1721
|
+
continue;
|
|
1722
|
+
}
|
|
1723
|
+
const meta = tail.seg_inf?.[String(id)] ?? null;
|
|
1724
|
+
const bbox = {
|
|
1725
|
+
xMin: dim.left + xMinPx * dim.gridSize,
|
|
1726
|
+
yMin: dim.top + yMinPx * dim.gridSize,
|
|
1727
|
+
xMax: dim.left + (xMaxPx + 1) * dim.gridSize,
|
|
1728
|
+
yMax: dim.top + (yMaxPx + 1) * dim.gridSize
|
|
1729
|
+
};
|
|
1730
|
+
const centroid = {
|
|
1731
|
+
x: dim.left + sumXPx / count * dim.gridSize,
|
|
1732
|
+
y: dim.top + sumYPx / count * dim.gridSize
|
|
1733
|
+
};
|
|
1734
|
+
segs.push({
|
|
1735
|
+
id,
|
|
1736
|
+
name: meta?.name ? safeBase64ToUtf8(meta.name) : null,
|
|
1737
|
+
bbox,
|
|
1738
|
+
centroid,
|
|
1739
|
+
neighbours: meta?.nei_id ?? [],
|
|
1740
|
+
floorMaterial: meta?.material ?? null,
|
|
1741
|
+
floorDirection: meta?.direction ?? null,
|
|
1742
|
+
active: activeIds.has(id)
|
|
1743
|
+
});
|
|
1744
|
+
}
|
|
1745
|
+
return segs;
|
|
1746
|
+
}
|
|
1747
|
+
function safeBase64ToUtf8(s) {
|
|
1748
|
+
try {
|
|
1749
|
+
return Buffer.from(s, "base64").toString("utf8");
|
|
1750
|
+
} catch {
|
|
1751
|
+
return null;
|
|
1752
|
+
}
|
|
1753
|
+
}
|
|
1754
|
+
|
|
1755
|
+
// src/models/vacuum/map/path.ts
|
|
1756
|
+
var PATH_TYPE_FROM_OP = {
|
|
1757
|
+
M: "mop",
|
|
1758
|
+
W: "sweep-and-mop",
|
|
1759
|
+
S: "sweep",
|
|
1760
|
+
L: "line"
|
|
1761
|
+
};
|
|
1762
|
+
var PATH_OP_REGEX = /([MWSLl])(-?\d+),(-?\d+)/g;
|
|
1763
|
+
function parsePathTr(tr) {
|
|
1764
|
+
if (!tr) {
|
|
1765
|
+
return [];
|
|
1766
|
+
}
|
|
1767
|
+
const out = [];
|
|
1768
|
+
let current = null;
|
|
1769
|
+
let anchor = null;
|
|
1770
|
+
for (const m of tr.matchAll(PATH_OP_REGEX)) {
|
|
1771
|
+
const opRaw = m[1];
|
|
1772
|
+
const xRaw = Number(m[2]);
|
|
1773
|
+
const yRaw = Number(m[3]);
|
|
1774
|
+
const op = opRaw === "l" ? "L" : opRaw;
|
|
1775
|
+
const type = PATH_TYPE_FROM_OP[op];
|
|
1776
|
+
if (!type) {
|
|
1777
|
+
continue;
|
|
1778
|
+
}
|
|
1779
|
+
if (type === "line" && anchor !== null) {
|
|
1780
|
+
const abs = {
|
|
1781
|
+
x: anchor.x + xRaw,
|
|
1782
|
+
y: anchor.y + yRaw
|
|
1783
|
+
};
|
|
1784
|
+
if (!current || current.type !== type) {
|
|
1785
|
+
current = { type, points: [{ x: anchor.x, y: anchor.y }, abs] };
|
|
1786
|
+
out.push(current);
|
|
1787
|
+
} else {
|
|
1788
|
+
current.points.push(abs);
|
|
1789
|
+
}
|
|
1790
|
+
anchor = abs;
|
|
1791
|
+
} else {
|
|
1792
|
+
const pt = { x: xRaw, y: yRaw };
|
|
1793
|
+
if (!current || current.type !== type) {
|
|
1794
|
+
current = { type, points: [pt] };
|
|
1795
|
+
out.push(current);
|
|
1796
|
+
} else {
|
|
1797
|
+
current.points.push(pt);
|
|
1798
|
+
}
|
|
1799
|
+
if (type !== "line") {
|
|
1800
|
+
anchor = pt;
|
|
1801
|
+
}
|
|
1802
|
+
}
|
|
1803
|
+
}
|
|
1804
|
+
return out;
|
|
1805
|
+
}
|
|
1806
|
+
|
|
1807
|
+
// src/models/vacuum/map/field-utils.ts
|
|
1808
|
+
function parseFloatField(v) {
|
|
1809
|
+
if (typeof v === "number") {
|
|
1810
|
+
return Number.isFinite(v) ? v : null;
|
|
1811
|
+
}
|
|
1812
|
+
if (typeof v === "string") {
|
|
1813
|
+
const n = Number(v);
|
|
1814
|
+
return Number.isFinite(n) ? n : null;
|
|
1815
|
+
}
|
|
1816
|
+
return null;
|
|
1817
|
+
}
|
|
1818
|
+
function parseIntField(v) {
|
|
1819
|
+
const n = parseFloatField(v);
|
|
1820
|
+
return n === null ? null : Math.trunc(n);
|
|
1821
|
+
}
|
|
1822
|
+
|
|
1823
|
+
// src/models/vacuum/map/obstacles.ts
|
|
1824
|
+
function parseObstacles(raw) {
|
|
1825
|
+
const out = [];
|
|
1826
|
+
for (const entry of raw) {
|
|
1827
|
+
if (!Array.isArray(entry) || entry.length < 6) {
|
|
1828
|
+
continue;
|
|
1829
|
+
}
|
|
1830
|
+
const x = parseFloatField(entry[0]);
|
|
1831
|
+
const y = parseFloatField(entry[1]);
|
|
1832
|
+
const type = parseIntField(entry[2]);
|
|
1833
|
+
const confRaw = parseFloatField(entry[3]);
|
|
1834
|
+
if (x === null || y === null || type === null) {
|
|
1835
|
+
continue;
|
|
1836
|
+
}
|
|
1837
|
+
const photoFileName = typeof entry[5] === "string" ? entry[5] : null;
|
|
1838
|
+
const idCandidate = parseIntField(entry[6]);
|
|
1839
|
+
const id = idCandidate ?? Math.round((parseFloatField(entry[4]) ?? 0) * 1e6);
|
|
1840
|
+
out.push({
|
|
1841
|
+
id,
|
|
1842
|
+
x,
|
|
1843
|
+
y,
|
|
1844
|
+
type,
|
|
1845
|
+
confidence: confRaw === null ? 0 : Math.round(confRaw * 100),
|
|
1846
|
+
photoFileName,
|
|
1847
|
+
photoKey: null
|
|
1848
|
+
});
|
|
1849
|
+
}
|
|
1850
|
+
return out;
|
|
1851
|
+
}
|
|
1852
|
+
|
|
1853
|
+
// src/models/vacuum/map/geometry.ts
|
|
1854
|
+
function parseVirtualWalls(vw, vws) {
|
|
1855
|
+
const virtualWalls = [];
|
|
1856
|
+
const restrictedAreas = [];
|
|
1857
|
+
if (vw) {
|
|
1858
|
+
for (const line of vw.line ?? []) {
|
|
1859
|
+
const wall = parseLine(line, "wall");
|
|
1860
|
+
if (wall) {
|
|
1861
|
+
virtualWalls.push(wall);
|
|
1862
|
+
}
|
|
1863
|
+
}
|
|
1864
|
+
for (const rect of vw.rect ?? []) {
|
|
1865
|
+
const area = parseRestrictedArea("noGo", rect);
|
|
1866
|
+
if (area) {
|
|
1867
|
+
restrictedAreas.push(area);
|
|
1868
|
+
}
|
|
1869
|
+
}
|
|
1870
|
+
for (const rect of vw.mop ?? []) {
|
|
1871
|
+
const area = parseRestrictedArea("noMop", rect);
|
|
1872
|
+
if (area) {
|
|
1873
|
+
restrictedAreas.push(area);
|
|
1874
|
+
}
|
|
1875
|
+
}
|
|
1876
|
+
for (const rect of vw.nocpt ?? []) {
|
|
1877
|
+
const area = parseRestrictedArea("noGo", rect);
|
|
1878
|
+
if (area) {
|
|
1879
|
+
restrictedAreas.push(area);
|
|
1880
|
+
}
|
|
1881
|
+
}
|
|
1882
|
+
}
|
|
1883
|
+
if (vws) {
|
|
1884
|
+
const npthrsdPresent = Array.isArray(vws.npthrsd) && vws.npthrsd.length > 0;
|
|
1885
|
+
for (const line of vws.vwsl ?? []) {
|
|
1886
|
+
const wall = parseLine(line, "threshold");
|
|
1887
|
+
if (wall) {
|
|
1888
|
+
if (npthrsdPresent) {
|
|
1889
|
+
wall.passable = true;
|
|
1890
|
+
}
|
|
1891
|
+
virtualWalls.push(wall);
|
|
1892
|
+
}
|
|
1893
|
+
}
|
|
1894
|
+
for (const line of vws.npthrsd ?? []) {
|
|
1895
|
+
const wall = parseLine(line, "threshold");
|
|
1896
|
+
if (wall) {
|
|
1897
|
+
wall.passable = false;
|
|
1898
|
+
virtualWalls.push(wall);
|
|
1899
|
+
}
|
|
1900
|
+
}
|
|
1901
|
+
}
|
|
1902
|
+
return { virtualWalls, restrictedAreas };
|
|
1903
|
+
}
|
|
1904
|
+
function parseLine(raw, kind) {
|
|
1905
|
+
if (!Array.isArray(raw) || raw.length < 4) {
|
|
1906
|
+
return null;
|
|
1907
|
+
}
|
|
1908
|
+
const x0 = parseFloatField(raw[0]);
|
|
1909
|
+
const y0 = parseFloatField(raw[1]);
|
|
1910
|
+
const x1 = parseFloatField(raw[2]);
|
|
1911
|
+
const y1 = parseFloatField(raw[3]);
|
|
1912
|
+
if (x0 === null || y0 === null || x1 === null || y1 === null) {
|
|
1913
|
+
return null;
|
|
1914
|
+
}
|
|
1915
|
+
if (kind === "wall") {
|
|
1916
|
+
return { from: { x: x0, y: y0 }, to: { x: x1, y: y1 } };
|
|
1917
|
+
}
|
|
1918
|
+
return { from: { x: x0, y: y0 }, to: { x: x1, y: y1 }, kind };
|
|
1919
|
+
}
|
|
1920
|
+
function parseRestrictedArea(kind, raw) {
|
|
1921
|
+
if (!Array.isArray(raw) || raw.length < 4) {
|
|
1922
|
+
return null;
|
|
1923
|
+
}
|
|
1924
|
+
const a = parseFloatField(raw[0]);
|
|
1925
|
+
const b = parseFloatField(raw[1]);
|
|
1926
|
+
const c = parseFloatField(raw[2]);
|
|
1927
|
+
const d = parseFloatField(raw[3]);
|
|
1928
|
+
if (a === null || b === null || c === null || d === null) {
|
|
1929
|
+
return null;
|
|
1930
|
+
}
|
|
1931
|
+
const xMin = Math.min(a, c);
|
|
1932
|
+
const xMax = Math.max(a, c);
|
|
1933
|
+
const yMin = Math.min(b, d);
|
|
1934
|
+
const yMax = Math.max(b, d);
|
|
1935
|
+
const area = {
|
|
1936
|
+
kind,
|
|
1937
|
+
bbox: { xMin, yMin, xMax, yMax }
|
|
1938
|
+
};
|
|
1939
|
+
const e = raw.length > 4 ? parseFloatField(raw[4]) : null;
|
|
1940
|
+
if (e !== null) {
|
|
1941
|
+
area.angle = e;
|
|
1942
|
+
}
|
|
1943
|
+
return area;
|
|
1944
|
+
}
|
|
1945
|
+
function parseWallsInfo(raw) {
|
|
1946
|
+
if (!raw || !Array.isArray(raw.storeys) || raw.storeys.length === 0) {
|
|
1947
|
+
return null;
|
|
1948
|
+
}
|
|
1949
|
+
const storeys = [];
|
|
1950
|
+
for (const s of raw.storeys) {
|
|
1951
|
+
if (!s || !Array.isArray(s.rooms)) {
|
|
1952
|
+
continue;
|
|
1953
|
+
}
|
|
1954
|
+
const rooms = [];
|
|
1955
|
+
for (const r of s.rooms) {
|
|
1956
|
+
if (!r || typeof r.room_id !== "number" || !Array.isArray(r.walls)) {
|
|
1957
|
+
continue;
|
|
1958
|
+
}
|
|
1959
|
+
const walls = [];
|
|
1960
|
+
for (const w of r.walls) {
|
|
1961
|
+
if (!w || typeof w.type !== "number" || typeof w.beg_pt_x !== "number" || typeof w.beg_pt_y !== "number" || typeof w.end_pt_x !== "number" || typeof w.end_pt_y !== "number" || typeof w.normal_x !== "number" || typeof w.normal_y !== "number") {
|
|
1962
|
+
continue;
|
|
1963
|
+
}
|
|
1964
|
+
walls.push({
|
|
1965
|
+
type: w.type,
|
|
1966
|
+
from: { x: w.beg_pt_x, y: w.beg_pt_y },
|
|
1967
|
+
to: { x: w.end_pt_x, y: w.end_pt_y },
|
|
1968
|
+
normal: { x: w.normal_x, y: w.normal_y }
|
|
1969
|
+
});
|
|
1970
|
+
}
|
|
1971
|
+
rooms.push({ roomId: r.room_id, walls });
|
|
1972
|
+
}
|
|
1973
|
+
storeys.push({ rooms });
|
|
1974
|
+
}
|
|
1975
|
+
if (storeys.length === 0) {
|
|
1976
|
+
return null;
|
|
1977
|
+
}
|
|
1978
|
+
return {
|
|
1979
|
+
versionFlag: typeof raw.version_flag === "number" ? raw.version_flag : 0,
|
|
1980
|
+
storeys
|
|
1981
|
+
};
|
|
1982
|
+
}
|
|
1983
|
+
function parseTailGeometry(tail) {
|
|
1984
|
+
const { virtualWalls, restrictedAreas } = parseVirtualWalls(tail.vw, tail.vws);
|
|
1985
|
+
return {
|
|
1986
|
+
virtualWalls,
|
|
1987
|
+
restrictedAreas,
|
|
1988
|
+
lowLyingAreas: parseLowLyingAreas(tail.sneak_areas_end, tail.sneak_areas),
|
|
1989
|
+
wallsInfo: parseWallsInfo(tail.walls_info)
|
|
1990
|
+
};
|
|
1991
|
+
}
|
|
1992
|
+
function isGeometryComplete(g) {
|
|
1993
|
+
return g.virtualWalls.length > 0 && g.restrictedAreas.length > 0 && g.lowLyingAreas.length > 0 && g.wallsInfo !== null;
|
|
1994
|
+
}
|
|
1995
|
+
function coalesceGeometry(primary, fallback) {
|
|
1996
|
+
return {
|
|
1997
|
+
virtualWalls: primary.virtualWalls.length > 0 ? primary.virtualWalls : fallback.virtualWalls,
|
|
1998
|
+
restrictedAreas: primary.restrictedAreas.length > 0 ? primary.restrictedAreas : fallback.restrictedAreas,
|
|
1999
|
+
lowLyingAreas: primary.lowLyingAreas.length > 0 ? primary.lowLyingAreas : fallback.lowLyingAreas,
|
|
2000
|
+
wallsInfo: primary.wallsInfo !== null ? primary.wallsInfo : fallback.wallsInfo
|
|
2001
|
+
};
|
|
2002
|
+
}
|
|
2003
|
+
function parseLowLyingAreas(end, live) {
|
|
2004
|
+
const source = end && end.length > 0 ? end : live;
|
|
2005
|
+
if (!source) {
|
|
2006
|
+
return [];
|
|
2007
|
+
}
|
|
2008
|
+
const out = [];
|
|
2009
|
+
for (const entry of source) {
|
|
2010
|
+
if (!entry || !Array.isArray(entry.roi) || entry.roi.length < 4 || entry.roi.length % 2 !== 0) {
|
|
2011
|
+
continue;
|
|
2012
|
+
}
|
|
2013
|
+
const points = [];
|
|
2014
|
+
let badPoint = false;
|
|
2015
|
+
for (let i = 0; i + 1 < entry.roi.length; i += 2) {
|
|
2016
|
+
const x = parseFloatField(entry.roi[i]);
|
|
2017
|
+
const y = parseFloatField(entry.roi[i + 1]);
|
|
2018
|
+
if (x === null || y === null) {
|
|
2019
|
+
badPoint = true;
|
|
2020
|
+
break;
|
|
2021
|
+
}
|
|
2022
|
+
points.push({ x, y });
|
|
2023
|
+
}
|
|
2024
|
+
if (badPoint || points.length === 0) {
|
|
2025
|
+
continue;
|
|
2026
|
+
}
|
|
2027
|
+
const id = typeof entry.id === "number" ? entry.id : -1;
|
|
2028
|
+
const area = { id, points };
|
|
2029
|
+
if (typeof entry.area === "number") {
|
|
2030
|
+
area.area = entry.area;
|
|
2031
|
+
}
|
|
2032
|
+
out.push(area);
|
|
2033
|
+
}
|
|
2034
|
+
return out;
|
|
2035
|
+
}
|
|
2036
|
+
|
|
2037
|
+
// src/models/vacuum/map/cleaned-area.ts
|
|
2038
|
+
function parseCleanedAreaOverlay(decmap) {
|
|
2039
|
+
if (!decmap) {
|
|
2040
|
+
return null;
|
|
2041
|
+
}
|
|
2042
|
+
let inflated;
|
|
2043
|
+
try {
|
|
2044
|
+
inflated = unwrapEnvelope(decmap);
|
|
2045
|
+
} catch {
|
|
2046
|
+
return null;
|
|
2047
|
+
}
|
|
2048
|
+
if (inflated.length < HEADER_SIZE) {
|
|
2049
|
+
return null;
|
|
2050
|
+
}
|
|
2051
|
+
let header;
|
|
2052
|
+
try {
|
|
2053
|
+
header = parseMapHeader(inflated);
|
|
2054
|
+
} catch {
|
|
2055
|
+
return null;
|
|
2056
|
+
}
|
|
2057
|
+
const pixelStart = HEADER_SIZE;
|
|
2058
|
+
const pixelEnd = pixelStart + header.width * header.height;
|
|
2059
|
+
if (inflated.length < pixelEnd || header.width <= 0 || header.height <= 0) {
|
|
2060
|
+
return null;
|
|
2061
|
+
}
|
|
2062
|
+
const pixels = inflated.subarray(pixelStart, pixelEnd);
|
|
2063
|
+
const { cleaned, dirty } = decodeCleanedAreaPixels(pixels, header.width, header.height);
|
|
2064
|
+
const overlay = {
|
|
2065
|
+
dimensions: {
|
|
2066
|
+
left: header.left,
|
|
2067
|
+
top: header.top,
|
|
2068
|
+
width: header.width,
|
|
2069
|
+
height: header.height,
|
|
2070
|
+
gridSize: header.gridSize
|
|
2071
|
+
},
|
|
2072
|
+
cleaned,
|
|
2073
|
+
dirty
|
|
2074
|
+
};
|
|
2075
|
+
if (inflated.length > pixelEnd) {
|
|
2076
|
+
const tailText = inflated.subarray(pixelEnd).toString("utf8");
|
|
2077
|
+
if (tailText) {
|
|
2078
|
+
try {
|
|
2079
|
+
const innerTail = JSON.parse(tailText);
|
|
2080
|
+
if (typeof innerTail === "object" && innerTail !== null && !Array.isArray(innerTail) && "CleanArea" in innerTail) {
|
|
2081
|
+
overlay.cleanedSegments = innerTail.CleanArea;
|
|
2082
|
+
}
|
|
2083
|
+
} catch {
|
|
2084
|
+
}
|
|
2085
|
+
}
|
|
2086
|
+
}
|
|
2087
|
+
return overlay;
|
|
2088
|
+
}
|
|
2089
|
+
function decodeCleanedAreaPixels(pixels, width, height) {
|
|
2090
|
+
const cleaned = [];
|
|
2091
|
+
const dirty = [];
|
|
2092
|
+
for (let y = 0; y < height; y++) {
|
|
2093
|
+
let cleanStart = -1;
|
|
2094
|
+
let dirtyStart = -1;
|
|
2095
|
+
const rowOff = y * width;
|
|
2096
|
+
for (let x = 0; x < width; x++) {
|
|
2097
|
+
const v = pixels[rowOff + x] & 3;
|
|
2098
|
+
if (v === 1) {
|
|
2099
|
+
if (dirtyStart >= 0) {
|
|
2100
|
+
dirty.push([dirtyStart, y, x - dirtyStart]);
|
|
2101
|
+
dirtyStart = -1;
|
|
2102
|
+
}
|
|
2103
|
+
if (cleanStart < 0) {
|
|
2104
|
+
cleanStart = x;
|
|
2105
|
+
}
|
|
2106
|
+
} else if (v === 2) {
|
|
2107
|
+
if (cleanStart >= 0) {
|
|
2108
|
+
cleaned.push([cleanStart, y, x - cleanStart]);
|
|
2109
|
+
cleanStart = -1;
|
|
2110
|
+
}
|
|
2111
|
+
if (dirtyStart < 0) {
|
|
2112
|
+
dirtyStart = x;
|
|
2113
|
+
}
|
|
2114
|
+
} else {
|
|
2115
|
+
if (cleanStart >= 0) {
|
|
2116
|
+
cleaned.push([cleanStart, y, x - cleanStart]);
|
|
2117
|
+
cleanStart = -1;
|
|
2118
|
+
}
|
|
2119
|
+
if (dirtyStart >= 0) {
|
|
2120
|
+
dirty.push([dirtyStart, y, x - dirtyStart]);
|
|
2121
|
+
dirtyStart = -1;
|
|
2122
|
+
}
|
|
2123
|
+
}
|
|
2124
|
+
}
|
|
2125
|
+
if (cleanStart >= 0) {
|
|
2126
|
+
cleaned.push([cleanStart, y, width - cleanStart]);
|
|
2127
|
+
}
|
|
2128
|
+
if (dirtyStart >= 0) {
|
|
2129
|
+
dirty.push([dirtyStart, y, width - dirtyStart]);
|
|
2130
|
+
}
|
|
2131
|
+
}
|
|
2132
|
+
return { cleaned, dirty };
|
|
2133
|
+
}
|
|
2134
|
+
|
|
2135
|
+
// src/models/vacuum/map/decode.ts
|
|
2136
|
+
function decodeVacuumMap(input, opts = {}) {
|
|
2137
|
+
const inflated = typeof input === "string" ? unwrapEnvelope(input, opts) : input;
|
|
2138
|
+
const { header, tail } = parseFrame(inflated);
|
|
2139
|
+
const dimensions = mergeDimensions(header, tail);
|
|
2140
|
+
const robot = pose(header.robotX, header.robotY, header.robotA, tail.nr === true);
|
|
2141
|
+
const dock = pose(header.chargerX, header.chargerY, header.chargerA, tail.nc === true);
|
|
2142
|
+
const docked = tail.oc === true;
|
|
2143
|
+
const pixelStart = HEADER_SIZE;
|
|
2144
|
+
const pixelEnd = pixelStart + header.width * header.height;
|
|
2145
|
+
const pixelGrid = inflated.subarray(pixelStart, pixelEnd);
|
|
2146
|
+
const canDecodePixels = header.frameType === "I" && pixelGrid.length === header.width * header.height;
|
|
2147
|
+
const layers = canDecodePixels ? decodePixelGridFsm1(pixelGrid, header.width, header.height) : [];
|
|
2148
|
+
const segments = canDecodePixels ? collectSegments(layers, dimensions, tail) : [];
|
|
2149
|
+
const paths = parsePathTr(tail.tr ?? "");
|
|
2150
|
+
const obstacles = parseObstacles(tail.ai_obstacle ?? []);
|
|
2151
|
+
let geometry = parseTailGeometry(tail);
|
|
2152
|
+
if (!isGeometryComplete(geometry) && typeof tail.rism === "string" && tail.rism.length > 0) {
|
|
2153
|
+
try {
|
|
2154
|
+
const innerInflated = unwrapEnvelope(tail.rism);
|
|
2155
|
+
const { tail: innerTail } = parseFrame(innerInflated);
|
|
2156
|
+
geometry = coalesceGeometry(geometry, parseTailGeometry(innerTail));
|
|
2157
|
+
} catch {
|
|
2158
|
+
}
|
|
2159
|
+
}
|
|
2160
|
+
const cleanedArea = typeof tail.decmap === "string" ? parseCleanedAreaOverlay(tail.decmap) : null;
|
|
2161
|
+
return {
|
|
2162
|
+
mapId: header.mapId,
|
|
2163
|
+
frameId: header.frameId,
|
|
2164
|
+
frameType: header.frameType,
|
|
2165
|
+
timestamp: tail.timestamp_ms ?? 0,
|
|
2166
|
+
rotation: tail.mra ?? 0,
|
|
2167
|
+
dimensions,
|
|
2168
|
+
robot,
|
|
2169
|
+
dock,
|
|
2170
|
+
docked,
|
|
2171
|
+
layers,
|
|
2172
|
+
segments,
|
|
2173
|
+
paths,
|
|
2174
|
+
obstacles,
|
|
2175
|
+
...geometry,
|
|
2176
|
+
cleanedArea
|
|
2177
|
+
};
|
|
2178
|
+
}
|
|
2179
|
+
function mergeDimensions(header, tail) {
|
|
2180
|
+
const left = tail.origin?.[0] ?? header.left;
|
|
2181
|
+
const top = tail.origin?.[1] ?? header.top;
|
|
2182
|
+
return {
|
|
2183
|
+
left,
|
|
2184
|
+
top,
|
|
2185
|
+
width: header.width,
|
|
2186
|
+
height: header.height,
|
|
2187
|
+
gridSize: header.gridSize
|
|
2188
|
+
};
|
|
2189
|
+
}
|
|
2190
|
+
function pose(x, y, angle, absent) {
|
|
2191
|
+
if (absent || angle === ANGLE_ABSENT) {
|
|
2192
|
+
return null;
|
|
2193
|
+
}
|
|
2194
|
+
return { x, y, angle };
|
|
2195
|
+
}
|
|
2196
|
+
|
|
2197
|
+
// src/models/vacuum/map/render.ts
|
|
2198
|
+
var import_pngjs = require("pngjs");
|
|
2199
|
+
var RGBA = {
|
|
2200
|
+
wall: [60, 60, 60, 255],
|
|
2201
|
+
floor: [210, 222, 235, 255],
|
|
2202
|
+
carpet: [180, 150, 120, 200]
|
|
2203
|
+
};
|
|
2204
|
+
function hsvToRgba(h, s, v) {
|
|
2205
|
+
const c = v * s;
|
|
2206
|
+
const hp = (h % 360 + 360) % 360 / 60;
|
|
2207
|
+
const x = c * (1 - Math.abs(hp % 2 - 1));
|
|
2208
|
+
let r = 0;
|
|
2209
|
+
let g = 0;
|
|
2210
|
+
let b = 0;
|
|
2211
|
+
if (hp < 1) {
|
|
2212
|
+
r = c;
|
|
2213
|
+
g = x;
|
|
2214
|
+
} else if (hp < 2) {
|
|
2215
|
+
r = x;
|
|
2216
|
+
g = c;
|
|
2217
|
+
} else if (hp < 3) {
|
|
2218
|
+
g = c;
|
|
2219
|
+
b = x;
|
|
2220
|
+
} else if (hp < 4) {
|
|
2221
|
+
g = x;
|
|
2222
|
+
b = c;
|
|
2223
|
+
} else if (hp < 5) {
|
|
2224
|
+
r = x;
|
|
2225
|
+
b = c;
|
|
2226
|
+
} else {
|
|
2227
|
+
r = c;
|
|
2228
|
+
b = x;
|
|
2229
|
+
}
|
|
2230
|
+
const m = v - c;
|
|
2231
|
+
return [Math.round((r + m) * 255), Math.round((g + m) * 255), Math.round((b + m) * 255), 255];
|
|
2232
|
+
}
|
|
2233
|
+
function segmentColor(id) {
|
|
2234
|
+
const hue = id * 137.508 % 360;
|
|
2235
|
+
return hsvToRgba(hue, 0.45, 0.95);
|
|
2236
|
+
}
|
|
2237
|
+
function renderVacuumPng(map, opts = {}) {
|
|
2238
|
+
const scale = Math.max(1, Math.trunc(opts.scale ?? 1));
|
|
2239
|
+
const w = map.dimensions.width * scale;
|
|
2240
|
+
const h = map.dimensions.height * scale;
|
|
2241
|
+
const png = new import_pngjs.PNG({ width: Math.max(1, w), height: Math.max(1, h) });
|
|
2242
|
+
png.data.fill(0);
|
|
2243
|
+
for (const layer of map.layers) {
|
|
2244
|
+
paintLayer(png, layer, scale);
|
|
2245
|
+
}
|
|
2246
|
+
return import_pngjs.PNG.sync.write(png);
|
|
2247
|
+
}
|
|
2248
|
+
function paintLayer(png, layer, scale) {
|
|
2249
|
+
const color = layer.type === "segment" ? segmentColor(layer.segmentId ?? 0) : RGBA[layer.type];
|
|
2250
|
+
for (const run of layer.runs) {
|
|
2251
|
+
paintRun(png, run, color, scale, png.width, png.height);
|
|
2252
|
+
}
|
|
2253
|
+
}
|
|
2254
|
+
function paintRun(png, run, color, scale, width, height) {
|
|
2255
|
+
const [xPx, yPx, len] = run;
|
|
2256
|
+
for (let i = 0; i < len; i += 1) {
|
|
2257
|
+
const baseX = (xPx + i) * scale;
|
|
2258
|
+
const baseY = yPx * scale;
|
|
2259
|
+
for (let dy = 0; dy < scale; dy += 1) {
|
|
2260
|
+
const py = baseY + dy;
|
|
2261
|
+
if (py < 0 || py >= height) {
|
|
2262
|
+
continue;
|
|
2263
|
+
}
|
|
2264
|
+
for (let dx = 0; dx < scale; dx += 1) {
|
|
2265
|
+
const px = baseX + dx;
|
|
2266
|
+
if (px < 0 || px >= width) {
|
|
2267
|
+
continue;
|
|
2268
|
+
}
|
|
2269
|
+
const off = (py * width + px) * 4;
|
|
2270
|
+
png.data[off] = color[0];
|
|
2271
|
+
png.data[off + 1] = color[1];
|
|
2272
|
+
png.data[off + 2] = color[2];
|
|
2273
|
+
png.data[off + 3] = color[3];
|
|
2274
|
+
}
|
|
2275
|
+
}
|
|
2276
|
+
}
|
|
2277
|
+
}
|
|
2278
|
+
|
|
2279
|
+
// src/models/vacuum/map/oss-fetch.ts
|
|
2280
|
+
var OSS_DOWNLOAD_PATH = "/dreame-user-iot/iotfile/getDownloadUrl";
|
|
2281
|
+
var DEFAULT_TTL_MS = 30 * 60 * 1e3;
|
|
2282
|
+
var OssFetcher = class {
|
|
2283
|
+
ttlMs;
|
|
2284
|
+
now;
|
|
2285
|
+
fetchImpl;
|
|
2286
|
+
cache = /* @__PURE__ */ new Map();
|
|
2287
|
+
/** In-flight fetchBlob calls, keyed `did:filename`. Lets us coalesce concurrent fetches for the same blob. */
|
|
2288
|
+
inflight = /* @__PURE__ */ new Map();
|
|
2289
|
+
constructor(opts = {}) {
|
|
2290
|
+
this.ttlMs = opts.ttlMs ?? DEFAULT_TTL_MS;
|
|
2291
|
+
this.now = opts.now ?? Date.now;
|
|
2292
|
+
this.fetchImpl = opts.fetchImpl ?? defaultFetch;
|
|
2293
|
+
}
|
|
2294
|
+
/**
|
|
2295
|
+
* Resolve `input.filename` to a signed download URL. Uses the cache
|
|
2296
|
+
* when fresh; on cache hit returns the cached URL with
|
|
2297
|
+
* `current=<unix_ts>` appended (using `&` if the URL already has a
|
|
2298
|
+
* query string — Aliyun signed URLs always do).
|
|
2299
|
+
*/
|
|
2300
|
+
async resolveUrl(input) {
|
|
2301
|
+
const key2 = `${input.did}:${input.filename}`;
|
|
2302
|
+
const now = this.now();
|
|
2303
|
+
const cached = this.cache.get(key2);
|
|
2304
|
+
if (cached && cached.expiresAt > now) {
|
|
2305
|
+
const sep = cached.url.includes("?") ? "&" : "?";
|
|
2306
|
+
return `${cached.url}${sep}current=${Math.floor(now / 1e3)}`;
|
|
2307
|
+
}
|
|
2308
|
+
const ctx = RequestContext.from({ ...input, fetchImpl: this.fetchImpl });
|
|
2309
|
+
const resp = await httpPostJson({
|
|
2310
|
+
ctx,
|
|
2311
|
+
url: ctx.url(OSS_DOWNLOAD_PATH),
|
|
2312
|
+
headers: ctx.headers({
|
|
2313
|
+
accessToken: input.accessToken,
|
|
2314
|
+
contentType: "application/json"
|
|
2315
|
+
}),
|
|
2316
|
+
body: JSON.stringify({
|
|
2317
|
+
did: input.did,
|
|
2318
|
+
model: input.model,
|
|
2319
|
+
filename: input.filename,
|
|
2320
|
+
region: input.region
|
|
2321
|
+
}),
|
|
2322
|
+
context: "oss download url",
|
|
2323
|
+
...input.signal !== void 0 ? { signal: input.signal } : {},
|
|
2324
|
+
...input.timeoutMs !== void 0 ? { timeoutMs: input.timeoutMs } : {}
|
|
2325
|
+
});
|
|
2326
|
+
if (typeof resp.data !== "string" || resp.data.length === 0) {
|
|
2327
|
+
throw new DreameApiError(
|
|
2328
|
+
`oss download url response missing data: ${JSON.stringify(resp).slice(0, 200)}`,
|
|
2329
|
+
200,
|
|
2330
|
+
resp
|
|
2331
|
+
);
|
|
2332
|
+
}
|
|
2333
|
+
this.cache.set(key2, { url: resp.data, expiresAt: now + this.ttlMs });
|
|
2334
|
+
return resp.data;
|
|
2335
|
+
}
|
|
2336
|
+
/**
|
|
2337
|
+
* Resolve, GET the signed URL, and return the raw bytes.
|
|
2338
|
+
*
|
|
2339
|
+
* Concurrent calls for the same `(did, filename)` pair are coalesced
|
|
2340
|
+
* into a single network request — the second caller awaits the first's
|
|
2341
|
+
* Promise instead of issuing a duplicate fetch.
|
|
2342
|
+
*/
|
|
2343
|
+
async fetchBlob(input) {
|
|
2344
|
+
const key2 = `${input.did}:${input.filename}`;
|
|
2345
|
+
const existing = this.inflight.get(key2);
|
|
2346
|
+
if (existing) {
|
|
2347
|
+
return existing;
|
|
2348
|
+
}
|
|
2349
|
+
const promise = this.#doFetchBlob(input).finally(() => {
|
|
2350
|
+
this.inflight.delete(key2);
|
|
2351
|
+
});
|
|
2352
|
+
this.inflight.set(key2, promise);
|
|
2353
|
+
return promise;
|
|
2354
|
+
}
|
|
2355
|
+
async #doFetchBlob(input) {
|
|
2356
|
+
const url = await this.resolveUrl(input);
|
|
2357
|
+
let res;
|
|
2358
|
+
try {
|
|
2359
|
+
res = await this.fetchImpl(url, {
|
|
2360
|
+
method: "GET",
|
|
2361
|
+
...input.signal !== void 0 ? { signal: input.signal } : {}
|
|
2362
|
+
});
|
|
2363
|
+
} catch (err) {
|
|
2364
|
+
throw new DreameTransportError(`oss download network error`, err);
|
|
2365
|
+
}
|
|
2366
|
+
if (!res.ok) {
|
|
2367
|
+
const text = await res.text().catch(() => "");
|
|
2368
|
+
throw new DreameApiError(
|
|
2369
|
+
`oss download failed: ${res.status} ${text.slice(0, 200)}`,
|
|
2370
|
+
res.status
|
|
2371
|
+
);
|
|
2372
|
+
}
|
|
2373
|
+
return Buffer.from(await res.arrayBuffer());
|
|
2374
|
+
}
|
|
2375
|
+
/** Drop all cached URLs. */
|
|
2376
|
+
clearCache() {
|
|
2377
|
+
this.cache.clear();
|
|
2378
|
+
}
|
|
2379
|
+
};
|
|
2380
|
+
|
|
2381
|
+
// src/models/vacuum/vacuum-device.ts
|
|
2382
|
+
var SUCTION = enumLookup([
|
|
2383
|
+
0 /* Quiet */,
|
|
2384
|
+
1 /* Standard */,
|
|
2385
|
+
2 /* Intense */,
|
|
2386
|
+
3 /* Max */
|
|
2387
|
+
]);
|
|
2388
|
+
var WATER = enumLookup([1 /* Low */, 2 /* Medium */, 3 /* High */]);
|
|
2389
|
+
var CLEAN_MODE = enumLookup([
|
|
2390
|
+
0 /* Sweeping */,
|
|
2391
|
+
1 /* Mopping */,
|
|
2392
|
+
2 /* SweepAndMop */,
|
|
2393
|
+
3 /* MopAfterSweep */
|
|
2394
|
+
]);
|
|
2395
|
+
var STATE = enumLookup(
|
|
2396
|
+
Object.values(MiotState).filter((v) => typeof v === "number")
|
|
2397
|
+
);
|
|
2398
|
+
var CHARGING = enumLookup([
|
|
2399
|
+
1 /* Charging */,
|
|
2400
|
+
2 /* Discharging */,
|
|
2401
|
+
5 /* Returning */
|
|
2402
|
+
]);
|
|
2403
|
+
var TASK = enumLookup([
|
|
2404
|
+
1 /* InterruptedOrPaused */,
|
|
2405
|
+
2 /* Active */,
|
|
2406
|
+
3 /* Transitioning */,
|
|
2407
|
+
6 /* OnDockIdle */,
|
|
2408
|
+
12 /* TransientPauseEdge */,
|
|
2409
|
+
14 /* NeedsIntervention */
|
|
2410
|
+
]);
|
|
2411
|
+
var VacuumDevice = class extends BaseDevice {
|
|
2412
|
+
#caps;
|
|
2413
|
+
#lastMap = null;
|
|
2414
|
+
constructor(input) {
|
|
2415
|
+
super({
|
|
2416
|
+
...input,
|
|
2417
|
+
// Inject the vacuum resolver so the inherited generic `capabilities`
|
|
2418
|
+
// getter carries the vacuum token set (no banned cast).
|
|
2419
|
+
capabilities: input.capabilities ?? new VacuumCapabilityResolver().resolve(input.device.model)
|
|
2420
|
+
});
|
|
2421
|
+
this.#caps = getVacuumCapabilities(input.device.model);
|
|
2422
|
+
}
|
|
2423
|
+
/** Rich, vacuum-specific capability record (booleans + supported enums). */
|
|
2424
|
+
get vacuumCapabilities() {
|
|
2425
|
+
return this.#caps;
|
|
2426
|
+
}
|
|
2427
|
+
#num(siid, piid) {
|
|
2428
|
+
return asNum(this.getProperty(siid, piid)?.value);
|
|
2429
|
+
}
|
|
2430
|
+
// -- typed state --------------------------------------------------------
|
|
2431
|
+
get statusRaw() {
|
|
2432
|
+
return this.#num(VACUUM_PROP.STATE.siid, VACUUM_PROP.STATE.piid);
|
|
2433
|
+
}
|
|
2434
|
+
get status() {
|
|
2435
|
+
return STATE(this.statusRaw);
|
|
2436
|
+
}
|
|
2437
|
+
get battery() {
|
|
2438
|
+
return this.#num(BATTERY_PROP.LEVEL.siid, BATTERY_PROP.LEVEL.piid);
|
|
2439
|
+
}
|
|
2440
|
+
get chargingRaw() {
|
|
2441
|
+
return this.#num(BATTERY_PROP.CHARGING_STATUS.siid, BATTERY_PROP.CHARGING_STATUS.piid);
|
|
2442
|
+
}
|
|
2443
|
+
get charging() {
|
|
2444
|
+
return CHARGING(this.chargingRaw);
|
|
2445
|
+
}
|
|
2446
|
+
get isCharging() {
|
|
2447
|
+
return this.charging === 1 /* Charging */;
|
|
2448
|
+
}
|
|
2449
|
+
/** Docked => MiotState Charging or ChargingComplete (on the dock). */
|
|
2450
|
+
get isDocked() {
|
|
2451
|
+
const s = this.status;
|
|
2452
|
+
return s === 6 /* Charging */ || s === 13 /* ChargingComplete */;
|
|
2453
|
+
}
|
|
2454
|
+
get suctionRaw() {
|
|
2455
|
+
return this.#num(VACUUM_PROP.SUCTION_LEVEL.siid, VACUUM_PROP.SUCTION_LEVEL.piid);
|
|
2456
|
+
}
|
|
2457
|
+
get suction() {
|
|
2458
|
+
return SUCTION(this.suctionRaw);
|
|
2459
|
+
}
|
|
2460
|
+
get waterRaw() {
|
|
2461
|
+
return this.#num(VACUUM_PROP.WATER_VOLUME.siid, VACUUM_PROP.WATER_VOLUME.piid);
|
|
2462
|
+
}
|
|
2463
|
+
get water() {
|
|
2464
|
+
return WATER(this.waterRaw);
|
|
2465
|
+
}
|
|
2466
|
+
/** Reads the SAFE CLEAN_MODE_SETTING (siid 2 piid 6), plain 0-3. */
|
|
2467
|
+
get cleaningModeRaw() {
|
|
2468
|
+
return this.#num(VACUUM_PROP.CLEAN_MODE_SETTING.siid, VACUUM_PROP.CLEAN_MODE_SETTING.piid);
|
|
2469
|
+
}
|
|
2470
|
+
get cleaningMode() {
|
|
2471
|
+
return CLEAN_MODE(this.cleaningModeRaw);
|
|
2472
|
+
}
|
|
2473
|
+
get taskStatusRaw() {
|
|
2474
|
+
return this.#num(VACUUM_PROP.TASK_STATUS.siid, VACUUM_PROP.TASK_STATUS.piid);
|
|
2475
|
+
}
|
|
2476
|
+
get taskStatus() {
|
|
2477
|
+
return TASK(this.taskStatusRaw);
|
|
2478
|
+
}
|
|
2479
|
+
get errorCode() {
|
|
2480
|
+
return this.#num(VACUUM_PROP.ERROR.siid, VACUUM_PROP.ERROR.piid);
|
|
2481
|
+
}
|
|
2482
|
+
get faults() {
|
|
2483
|
+
return parseFaultList(
|
|
2484
|
+
this.getProperty(VACUUM_PROP.FAULTS_STR.siid, VACUUM_PROP.FAULTS_STR.piid)?.value
|
|
2485
|
+
);
|
|
2486
|
+
}
|
|
2487
|
+
get taskProgressPct() {
|
|
2488
|
+
return this.#num(VACUUM_PROP.TASK_PROGRESS_PCT.siid, VACUUM_PROP.TASK_PROGRESS_PCT.piid);
|
|
2489
|
+
}
|
|
2490
|
+
get mainBrushLeftPct() {
|
|
2491
|
+
return this.#num(CONSUMABLE_PROP.MAIN_BRUSH_LEFT.siid, CONSUMABLE_PROP.MAIN_BRUSH_LEFT.piid);
|
|
2492
|
+
}
|
|
2493
|
+
get sideBrushLeftPct() {
|
|
2494
|
+
return this.#num(CONSUMABLE_PROP.SIDE_BRUSH_LEFT.siid, CONSUMABLE_PROP.SIDE_BRUSH_LEFT.piid);
|
|
2495
|
+
}
|
|
2496
|
+
get filterLeftPct() {
|
|
2497
|
+
return this.#num(CONSUMABLE_PROP.FILTER_LEFT.siid, CONSUMABLE_PROP.FILTER_LEFT.piid);
|
|
2498
|
+
}
|
|
2499
|
+
get volume() {
|
|
2500
|
+
return this.#num(SETTINGS_PROP.VOLUME.siid, SETTINGS_PROP.VOLUME.piid);
|
|
2501
|
+
}
|
|
2502
|
+
// -- command helpers ----------------------------------------------------
|
|
2503
|
+
#resolveCleanOpts(opts) {
|
|
2504
|
+
const repeats = Math.max(1, Math.trunc(opts.repeats ?? 1));
|
|
2505
|
+
const fan = opts.fan ?? this.suctionRaw ?? 1 /* Standard */;
|
|
2506
|
+
const water = opts.water ?? this.waterRaw ?? 2 /* Medium */;
|
|
2507
|
+
return { repeats, fan, water };
|
|
2508
|
+
}
|
|
2509
|
+
#startCustom(mode, payload) {
|
|
2510
|
+
return this.callAction(VACUUM_ACTION.START_CUSTOM.siid, VACUUM_ACTION.START_CUSTOM.aiid, [
|
|
2511
|
+
{ piid: 1, value: mode },
|
|
2512
|
+
{ piid: 10, value: JSON.stringify(payload) }
|
|
2513
|
+
]);
|
|
2514
|
+
}
|
|
2515
|
+
#requireCap(flag, op, feature) {
|
|
2516
|
+
if (!flag) {
|
|
2517
|
+
throw new DreameError(`${op}: model ${this.model} does not support ${feature}`);
|
|
2518
|
+
}
|
|
2519
|
+
}
|
|
2520
|
+
// -- no-arg commands ----------------------------------------------------
|
|
2521
|
+
/**
|
|
2522
|
+
* Start cleaning (MIoT action siid 2 aiid 1). Named `startCleaning` — NOT
|
|
2523
|
+
* `start` — because `BaseDevice.start()` is the lifecycle method that opens
|
|
2524
|
+
* the MQTT push, and the facade relies on that meaning. Donor `node-dreame`
|
|
2525
|
+
* names this `start()` only because its standalone `Vacuum` class owns no
|
|
2526
|
+
* lifecycle method to collide with.
|
|
2527
|
+
*/
|
|
2528
|
+
startCleaning() {
|
|
2529
|
+
return this.callAction(VACUUM_ACTION.START.siid, VACUUM_ACTION.START.aiid, []);
|
|
2530
|
+
}
|
|
2531
|
+
pause() {
|
|
2532
|
+
return this.callAction(VACUUM_ACTION.PAUSE.siid, VACUUM_ACTION.PAUSE.aiid, []);
|
|
2533
|
+
}
|
|
2534
|
+
stop() {
|
|
2535
|
+
return this.callAction(VACUUM_ACTION.STOP.siid, VACUUM_ACTION.STOP.aiid, []);
|
|
2536
|
+
}
|
|
2537
|
+
/** Return to the dock / charge (MIoT action CHARGE, siid 3 aiid 1). */
|
|
2538
|
+
dock() {
|
|
2539
|
+
return this.callAction(VACUUM_ACTION.CHARGE.siid, VACUUM_ACTION.CHARGE.aiid, []);
|
|
2540
|
+
}
|
|
2541
|
+
locate() {
|
|
2542
|
+
return this.callAction(VACUUM_ACTION.LOCATE.siid, VACUUM_ACTION.LOCATE.aiid, []);
|
|
2543
|
+
}
|
|
2544
|
+
clearWarning() {
|
|
2545
|
+
return this.callAction(VACUUM_ACTION.CLEAR_WARNING.siid, VACUUM_ACTION.CLEAR_WARNING.aiid, []);
|
|
2546
|
+
}
|
|
2547
|
+
async startAutoEmpty() {
|
|
2548
|
+
this.#requireCap(this.#caps.canAutoEmpty, "startAutoEmpty", "auto-empty");
|
|
2549
|
+
return this.callAction(
|
|
2550
|
+
VACUUM_ACTION.START_AUTO_EMPTY.siid,
|
|
2551
|
+
VACUUM_ACTION.START_AUTO_EMPTY.aiid,
|
|
2552
|
+
[]
|
|
2553
|
+
);
|
|
2554
|
+
}
|
|
2555
|
+
// -- settings writes ----------------------------------------------------
|
|
2556
|
+
/** Type-safe suction write. Validates against the model's supported levels. */
|
|
2557
|
+
setSuction(level) {
|
|
2558
|
+
return this.setSuctionRaw(level);
|
|
2559
|
+
}
|
|
2560
|
+
/**
|
|
2561
|
+
* Untyped suction write: validates an arbitrary number against the model's
|
|
2562
|
+
* supported levels and rejects with `RangeError` on an invalid input. This
|
|
2563
|
+
* is the raw-input entry point so callers (and tests) can exercise validation
|
|
2564
|
+
* with no type assertions. `async` so the guard surfaces as a rejection.
|
|
2565
|
+
*/
|
|
2566
|
+
async setSuctionRaw(level) {
|
|
2567
|
+
if (!this.#caps.supportedSuctionLevels.includes(level)) {
|
|
2568
|
+
throw new RangeError(`setSuction: unsupported suction level ${String(level)}`);
|
|
2569
|
+
}
|
|
2570
|
+
return this.setProperty({ ...VACUUM_PROP.SUCTION_LEVEL, value: level });
|
|
2571
|
+
}
|
|
2572
|
+
/** Type-safe water-volume write. Validates against supported volumes. */
|
|
2573
|
+
setWater(volume) {
|
|
2574
|
+
return this.setWaterRaw(volume);
|
|
2575
|
+
}
|
|
2576
|
+
/** Untyped water-volume write: validates an arbitrary number (zero casts). */
|
|
2577
|
+
async setWaterRaw(volume) {
|
|
2578
|
+
if (!this.#caps.supportedWaterVolumes.includes(volume)) {
|
|
2579
|
+
throw new RangeError(`setWater: unsupported water volume ${String(volume)}`);
|
|
2580
|
+
}
|
|
2581
|
+
return this.setProperty({ ...VACUUM_PROP.WATER_VOLUME, value: volume });
|
|
2582
|
+
}
|
|
2583
|
+
/** SAFE clean-mode write — uses CLEAN_MODE_SETTING (siid 2 piid 6), never the 0x1400 bitfield. */
|
|
2584
|
+
setCleaningMode(mode) {
|
|
2585
|
+
return this.setProperty({ ...VACUUM_PROP.CLEAN_MODE_SETTING, value: mode });
|
|
2586
|
+
}
|
|
2587
|
+
// -- targeted cleaning --------------------------------------------------
|
|
2588
|
+
async cleanSegments(ids, opts = {}) {
|
|
2589
|
+
if (ids.length === 0) {
|
|
2590
|
+
throw new RangeError("cleanSegments: ids must not be empty");
|
|
2591
|
+
}
|
|
2592
|
+
this.#requireCap(this.#caps.canCleanPerRoom, "cleanSegments", "per-room cleaning");
|
|
2593
|
+
const { repeats, fan, water } = this.#resolveCleanOpts(opts);
|
|
2594
|
+
const selects = ids.map((id) => [id, repeats, fan, water, 1]);
|
|
2595
|
+
return this.#startCustom(CUSTOM_CLEAN_MODE.SEGMENT, { selects });
|
|
2596
|
+
}
|
|
2597
|
+
async cleanZones(zones, opts = {}) {
|
|
2598
|
+
if (zones.length === 0) {
|
|
2599
|
+
throw new RangeError("cleanZones: zones must not be empty");
|
|
2600
|
+
}
|
|
2601
|
+
this.#requireCap(this.#caps.canCleanPerRoom, "cleanZones", "per-room cleaning");
|
|
2602
|
+
const { repeats, fan, water } = this.#resolveCleanOpts(opts);
|
|
2603
|
+
const areas = zones.map((z3) => [
|
|
2604
|
+
Math.round(z3.x0),
|
|
2605
|
+
Math.round(z3.y0),
|
|
2606
|
+
Math.round(z3.x1),
|
|
2607
|
+
Math.round(z3.y1),
|
|
2608
|
+
repeats,
|
|
2609
|
+
fan,
|
|
2610
|
+
water
|
|
2611
|
+
]);
|
|
2612
|
+
return this.#startCustom(CUSTOM_CLEAN_MODE.ZONE, { areas });
|
|
2613
|
+
}
|
|
2614
|
+
/**
|
|
2615
|
+
* Send the robot to clean a single point (the SPOT custom-clean action,
|
|
2616
|
+
* mode 20). ASSUMED: Tasshack's "go to point" is the same SPOT action — this
|
|
2617
|
+
* is exposed as `cleanSpot` because it maps to the spot/custom-clean action,
|
|
2618
|
+
* not a distinct go-to. No separate `goTo` is exposed.
|
|
2619
|
+
*/
|
|
2620
|
+
async cleanSpot(point, opts = {}) {
|
|
2621
|
+
this.#requireCap(this.#caps.canCleanPerRoom, "cleanSpot", "per-room cleaning");
|
|
2622
|
+
const { repeats, fan, water } = this.#resolveCleanOpts(opts);
|
|
2623
|
+
const points = [[Math.round(point.x), Math.round(point.y), repeats, fan, water]];
|
|
2624
|
+
return this.#startCustom(CUSTOM_CLEAN_MODE.SPOT, { points });
|
|
2625
|
+
}
|
|
2626
|
+
// -- maps ---------------------------------------------------------------
|
|
2627
|
+
/** The most-recently-decoded map, or `null` until {@link getMap} succeeds. */
|
|
2628
|
+
get lastMap() {
|
|
2629
|
+
return this.#lastMap;
|
|
2630
|
+
}
|
|
2631
|
+
/**
|
|
2632
|
+
* The current room/segment id, derived from the most-recently-decoded map's
|
|
2633
|
+
* active-segment set (`sa`). `null` when no map has been fetched or no
|
|
2634
|
+
* segment is currently active. REPLACES the P3 "intentionally not exposed"
|
|
2635
|
+
* placeholder — the value now comes from the decoded map layer (P5).
|
|
2636
|
+
*/
|
|
2637
|
+
get currentSegmentId() {
|
|
2638
|
+
return this.#lastMap?.segments.find((s) => s.active)?.id ?? null;
|
|
2639
|
+
}
|
|
2640
|
+
/**
|
|
2641
|
+
* Fetch the current saved/live-map blob (OSS), decode it, cache it as
|
|
2642
|
+
* {@link lastMap}, emit a `'map'` event, and return the {@link VacuumMap}.
|
|
2643
|
+
*
|
|
2644
|
+
* Capability-gated on `canMap`. The `filename` is the OSS object name the
|
|
2645
|
+
* caller resolves from a `mapInfo`/PATH push. The fetcher is injectable so
|
|
2646
|
+
* tests drive it with a synthetic blob and no live network.
|
|
2647
|
+
*
|
|
2648
|
+
* NOTE: active live-frame P-frame STREAMING (continuous merge orchestration)
|
|
2649
|
+
* is a documented follow-up; `getMap` resolves a single frame here. The
|
|
2650
|
+
* `applyVacuumPFrame` merge primitive ships separately for that work.
|
|
2651
|
+
*/
|
|
2652
|
+
async getMap(input) {
|
|
2653
|
+
this.#requireCap(this.#caps.canMap, "getMap", "map decoding");
|
|
2654
|
+
const session = this.currentSession();
|
|
2655
|
+
const region = this.region;
|
|
2656
|
+
const fetcher = input.fetcher ?? new OssFetcher();
|
|
2657
|
+
const fetchInput = {
|
|
2658
|
+
host: input.host ?? REGION_HOSTS[region],
|
|
2659
|
+
accessToken: session.accessToken,
|
|
2660
|
+
region,
|
|
2661
|
+
did: this.deviceId,
|
|
2662
|
+
model: this.model,
|
|
2663
|
+
filename: input.filename,
|
|
2664
|
+
...input.timeoutMs !== void 0 ? { timeoutMs: input.timeoutMs } : {},
|
|
2665
|
+
...input.signal !== void 0 ? { signal: input.signal } : {}
|
|
2666
|
+
};
|
|
2667
|
+
const blob = await fetcher.fetchBlob(fetchInput);
|
|
2668
|
+
const map = decodeVacuumMap(blob, {
|
|
2669
|
+
...input.key !== void 0 ? { key: input.key } : {},
|
|
2670
|
+
...input.iv !== void 0 ? { iv: input.iv } : {}
|
|
2671
|
+
});
|
|
2672
|
+
this.#lastMap = map;
|
|
2673
|
+
this.emit("map", map);
|
|
2674
|
+
return map;
|
|
2675
|
+
}
|
|
2676
|
+
/** Props worth seeding on start() / polling — exported for the facade. */
|
|
2677
|
+
static DEFAULT_PROPS = [
|
|
2678
|
+
VACUUM_PROP.STATE,
|
|
2679
|
+
VACUUM_PROP.ERROR,
|
|
2680
|
+
VACUUM_PROP.FAULTS_STR,
|
|
2681
|
+
VACUUM_PROP.TASK_STATUS,
|
|
2682
|
+
VACUUM_PROP.SUCTION_LEVEL,
|
|
2683
|
+
VACUUM_PROP.WATER_VOLUME,
|
|
2684
|
+
VACUUM_PROP.CLEAN_MODE_SETTING,
|
|
2685
|
+
VACUUM_PROP.TASK_PROGRESS_PCT,
|
|
2686
|
+
BATTERY_PROP.LEVEL,
|
|
2687
|
+
BATTERY_PROP.CHARGING_STATUS,
|
|
2688
|
+
CONSUMABLE_PROP.MAIN_BRUSH_LEFT,
|
|
2689
|
+
CONSUMABLE_PROP.SIDE_BRUSH_LEFT,
|
|
2690
|
+
CONSUMABLE_PROP.FILTER_LEFT,
|
|
2691
|
+
SETTINGS_PROP.VOLUME
|
|
2692
|
+
];
|
|
2693
|
+
};
|
|
2694
|
+
|
|
2695
|
+
// src/models/mower/properties.ts
|
|
2696
|
+
var MOWER_PROP = {
|
|
2697
|
+
/** VERIFIED-by-donor — pose + coverage telemetry (binary). P4 uses progress % only. */
|
|
2698
|
+
POSE_COVERAGE: { siid: 1, piid: 4 },
|
|
2699
|
+
/** VERIFIED-by-donor — MowerStatus (DeviceStatus). */
|
|
2700
|
+
STATUS: { siid: 2, piid: 1 },
|
|
2701
|
+
/** VERIFIED-by-donor — device code blob (model/region). Not decoded in P4. */
|
|
2702
|
+
DEVICE_CODE: { siid: 2, piid: 2 },
|
|
2703
|
+
/** VERIFIED-by-donor — mission task descriptor object {t,d:{...}}; also the action target. */
|
|
2704
|
+
SCHEDULING_TASK: { siid: 2, piid: 50 },
|
|
2705
|
+
/** VERIFIED-by-donor — mission completion summary (often empty). */
|
|
2706
|
+
SCHEDULING_SUMMARY: { siid: 2, piid: 52 },
|
|
2707
|
+
/** VERIFIED-by-donor — per-zone control status {status:[[zone,code],...]}. */
|
|
2708
|
+
MOWER_CONTROL_STATUS: { siid: 2, piid: 56 },
|
|
2709
|
+
/** VERIFIED-by-donor — battery percentage 0-100. */
|
|
2710
|
+
BATTERY: { siid: 3, piid: 1 },
|
|
2711
|
+
/** VERIFIED-by-donor — MowerChargingStatus. */
|
|
2712
|
+
CHARGING_STATUS: { siid: 3, piid: 2 },
|
|
2713
|
+
/** VERIFIED-by-donor — task status code (MowerTaskStatus; mostly diagnostic). */
|
|
2714
|
+
TASK_STATUS: { siid: 5, piid: 104 }
|
|
2715
|
+
};
|
|
2716
|
+
var MOWER_ACTION = {
|
|
2717
|
+
START_MOWING: { siid: 5, aiid: 1 },
|
|
2718
|
+
STOP: { siid: 5, aiid: 2 },
|
|
2719
|
+
DOCK: { siid: 5, aiid: 3 },
|
|
2720
|
+
PAUSE: { siid: 5, aiid: 4 }
|
|
2721
|
+
};
|
|
2722
|
+
var TASK_OPCODE = {
|
|
2723
|
+
RESUME: { m: "a", p: 0, o: 5 },
|
|
2724
|
+
ALL_AREA: 100,
|
|
2725
|
+
EDGE: 101,
|
|
2726
|
+
ZONE: 102,
|
|
2727
|
+
SPOT: 103
|
|
2728
|
+
};
|
|
2729
|
+
function buildResumePayload() {
|
|
2730
|
+
return { m: "a", p: 0, o: 5 };
|
|
2731
|
+
}
|
|
2732
|
+
function buildAllAreaPayload(mapId) {
|
|
2733
|
+
return { m: "a", p: 0, o: TASK_OPCODE.ALL_AREA, d: { region_id: [mapId], area_id: [] } };
|
|
2734
|
+
}
|
|
2735
|
+
function buildZonePayload(zoneIds) {
|
|
2736
|
+
return { m: "a", p: 0, o: TASK_OPCODE.ZONE, d: { region: [...zoneIds] } };
|
|
2737
|
+
}
|
|
2738
|
+
function buildEdgePayload(contourIds) {
|
|
2739
|
+
return { m: "a", p: 0, o: TASK_OPCODE.EDGE, d: { edge: contourIds.map((c) => [...c]) } };
|
|
2740
|
+
}
|
|
2741
|
+
function buildSpotPayload(spotAreaIds) {
|
|
2742
|
+
return { m: "a", p: 0, o: TASK_OPCODE.SPOT, d: { area: [...spotAreaIds] } };
|
|
2743
|
+
}
|
|
2744
|
+
|
|
2745
|
+
// src/models/mower/enums.ts
|
|
2746
|
+
var MowerStatus = /* @__PURE__ */ ((MowerStatus2) => {
|
|
2747
|
+
MowerStatus2[MowerStatus2["NoStatus"] = 0] = "NoStatus";
|
|
2748
|
+
MowerStatus2[MowerStatus2["Mowing"] = 1] = "Mowing";
|
|
2749
|
+
MowerStatus2[MowerStatus2["Standby"] = 2] = "Standby";
|
|
2750
|
+
MowerStatus2[MowerStatus2["Paused"] = 3] = "Paused";
|
|
2751
|
+
MowerStatus2[MowerStatus2["PausedDueToErrors"] = 4] = "PausedDueToErrors";
|
|
2752
|
+
MowerStatus2[MowerStatus2["ReturningToCharge"] = 5] = "ReturningToCharge";
|
|
2753
|
+
MowerStatus2[MowerStatus2["Charging"] = 6] = "Charging";
|
|
2754
|
+
MowerStatus2[MowerStatus2["Mapping"] = 11] = "Mapping";
|
|
2755
|
+
MowerStatus2[MowerStatus2["ChargingComplete"] = 13] = "ChargingComplete";
|
|
2756
|
+
MowerStatus2[MowerStatus2["Updating"] = 14] = "Updating";
|
|
2757
|
+
return MowerStatus2;
|
|
2758
|
+
})(MowerStatus || {});
|
|
2759
|
+
var MowerChargingStatus = /* @__PURE__ */ ((MowerChargingStatus2) => {
|
|
2760
|
+
MowerChargingStatus2[MowerChargingStatus2["NotDocked"] = 0] = "NotDocked";
|
|
2761
|
+
MowerChargingStatus2[MowerChargingStatus2["Charging"] = 1] = "Charging";
|
|
2762
|
+
MowerChargingStatus2[MowerChargingStatus2["NotCharging"] = 2] = "NotCharging";
|
|
2763
|
+
MowerChargingStatus2[MowerChargingStatus2["ChargingCompleted"] = 3] = "ChargingCompleted";
|
|
2764
|
+
MowerChargingStatus2[MowerChargingStatus2["ReturnToCharge"] = 5] = "ReturnToCharge";
|
|
2765
|
+
MowerChargingStatus2[MowerChargingStatus2["ChargingPausedLowTemperature"] = 16] = "ChargingPausedLowTemperature";
|
|
2766
|
+
return MowerChargingStatus2;
|
|
2767
|
+
})(MowerChargingStatus || {});
|
|
2768
|
+
var MowerControlAction = /* @__PURE__ */ ((MowerControlAction3) => {
|
|
2769
|
+
MowerControlAction3[MowerControlAction3["Queued"] = -1] = "Queued";
|
|
2770
|
+
MowerControlAction3[MowerControlAction3["Continue"] = 0] = "Continue";
|
|
2771
|
+
MowerControlAction3[MowerControlAction3["Completed"] = 2] = "Completed";
|
|
2772
|
+
MowerControlAction3[MowerControlAction3["Pause"] = 4] = "Pause";
|
|
2773
|
+
return MowerControlAction3;
|
|
2774
|
+
})(MowerControlAction || {});
|
|
2775
|
+
var MowerTaskStatus = /* @__PURE__ */ ((MowerTaskStatus2) => {
|
|
2776
|
+
MowerTaskStatus2[MowerTaskStatus2["SpotIncomplete"] = 7] = "SpotIncomplete";
|
|
2777
|
+
return MowerTaskStatus2;
|
|
2778
|
+
})(MowerTaskStatus || {});
|
|
2779
|
+
|
|
2780
|
+
// src/models/mower/decode.ts
|
|
2781
|
+
function isRecord(v) {
|
|
2782
|
+
return typeof v === "object" && v !== null && !Array.isArray(v);
|
|
2783
|
+
}
|
|
2784
|
+
function numArrayOrNull(v) {
|
|
2785
|
+
if (!Array.isArray(v)) {
|
|
2786
|
+
return null;
|
|
2787
|
+
}
|
|
2788
|
+
const out = [];
|
|
2789
|
+
for (const n of v) {
|
|
2790
|
+
if (typeof n !== "number") {
|
|
2791
|
+
return null;
|
|
2792
|
+
}
|
|
2793
|
+
out.push(n);
|
|
2794
|
+
}
|
|
2795
|
+
return out;
|
|
2796
|
+
}
|
|
2797
|
+
function parseTaskDescriptor(value) {
|
|
2798
|
+
if (!isRecord(value)) {
|
|
2799
|
+
return null;
|
|
2800
|
+
}
|
|
2801
|
+
const t = value["t"];
|
|
2802
|
+
const d = value["d"];
|
|
2803
|
+
if (typeof t !== "string" || !isRecord(d)) {
|
|
2804
|
+
return null;
|
|
2805
|
+
}
|
|
2806
|
+
const exe = d["exe"];
|
|
2807
|
+
const o = d["o"];
|
|
2808
|
+
const status = d["status"];
|
|
2809
|
+
if (typeof exe !== "boolean" || typeof o !== "number" || typeof status !== "boolean") {
|
|
2810
|
+
return null;
|
|
2811
|
+
}
|
|
2812
|
+
return {
|
|
2813
|
+
taskType: t === "TASK" ? "TASK" : "UNKNOWN",
|
|
2814
|
+
executionActive: exe,
|
|
2815
|
+
coverageTarget: o,
|
|
2816
|
+
taskActive: status,
|
|
2817
|
+
areaId: numArrayOrNull(d["area_id"]),
|
|
2818
|
+
regionId: numArrayOrNull(d["region_id"]),
|
|
2819
|
+
elapsedTime: typeof d["time"] === "number" ? d["time"] : null
|
|
2820
|
+
};
|
|
2821
|
+
}
|
|
2822
|
+
var controlLookup = enumLookup([
|
|
2823
|
+
-1 /* Queued */,
|
|
2824
|
+
0 /* Continue */,
|
|
2825
|
+
2 /* Completed */,
|
|
2826
|
+
4 /* Pause */
|
|
2827
|
+
]);
|
|
2828
|
+
function controlActionFor(code) {
|
|
2829
|
+
return controlLookup(code);
|
|
2830
|
+
}
|
|
2831
|
+
function parseControlStatus(value) {
|
|
2832
|
+
if (!isRecord(value)) {
|
|
2833
|
+
return null;
|
|
2834
|
+
}
|
|
2835
|
+
const status = value["status"];
|
|
2836
|
+
if (!Array.isArray(status)) {
|
|
2837
|
+
return null;
|
|
2838
|
+
}
|
|
2839
|
+
if (status.length === 0) {
|
|
2840
|
+
return { action: null, statusCode: null, zones: [] };
|
|
2841
|
+
}
|
|
2842
|
+
const zones = [];
|
|
2843
|
+
for (const entry of status) {
|
|
2844
|
+
const pair = numArrayOrNull(entry);
|
|
2845
|
+
if (pair === null || pair.length < 2) {
|
|
2846
|
+
return null;
|
|
2847
|
+
}
|
|
2848
|
+
zones.push(pair);
|
|
2849
|
+
}
|
|
2850
|
+
const primary = zones.find((e) => e[1] === 0) ?? zones[0];
|
|
2851
|
+
const code = primary?.[1] ?? null;
|
|
2852
|
+
return {
|
|
2853
|
+
action: code === null ? null : controlActionFor(code),
|
|
2854
|
+
statusCode: code,
|
|
2855
|
+
zones
|
|
2856
|
+
};
|
|
2857
|
+
}
|
|
2858
|
+
|
|
2859
|
+
// src/models/mower/capabilities.ts
|
|
2860
|
+
var FALLBACK2 = {
|
|
2861
|
+
verified: false,
|
|
2862
|
+
canMowZones: false,
|
|
2863
|
+
canMowEdges: false,
|
|
2864
|
+
canMowSpots: false,
|
|
2865
|
+
canMowAllArea: false,
|
|
2866
|
+
canResume: true,
|
|
2867
|
+
canSchedule: true,
|
|
2868
|
+
// Vector-map parsing is model-agnostic, but an unknown model leaves it false
|
|
2869
|
+
// until its firmware is verified to expose the batch device-data surface.
|
|
2870
|
+
canMap: false
|
|
2871
|
+
};
|
|
2872
|
+
var A1_FAMILY = {
|
|
2873
|
+
canMowZones: true,
|
|
2874
|
+
canMowEdges: true,
|
|
2875
|
+
canMowSpots: true,
|
|
2876
|
+
canMowAllArea: true,
|
|
2877
|
+
canResume: true,
|
|
2878
|
+
canSchedule: true,
|
|
2879
|
+
canMap: true
|
|
2880
|
+
};
|
|
2881
|
+
function deepFreeze2(obj) {
|
|
2882
|
+
if (obj && typeof obj === "object" && !Object.isFrozen(obj)) {
|
|
2883
|
+
Object.freeze(obj);
|
|
2884
|
+
for (const v of Object.values(obj)) {
|
|
2885
|
+
deepFreeze2(v);
|
|
2886
|
+
}
|
|
2887
|
+
}
|
|
2888
|
+
return obj;
|
|
2889
|
+
}
|
|
2890
|
+
var MODEL_CAPABILITIES2 = {
|
|
2891
|
+
/** Dreame A1 — the user's mower. ASSUMED from the donor command surface. */
|
|
2892
|
+
"dreame.mower.p2255": { model: "dreame.mower.p2255", verified: false, ...A1_FAMILY }
|
|
2893
|
+
};
|
|
2894
|
+
for (const entry of Object.values(MODEL_CAPABILITIES2)) {
|
|
2895
|
+
deepFreeze2(entry);
|
|
2896
|
+
}
|
|
2897
|
+
function getMowerCapabilities(model) {
|
|
2898
|
+
const known = MODEL_CAPABILITIES2[model];
|
|
2899
|
+
if (known) {
|
|
2900
|
+
return known;
|
|
2901
|
+
}
|
|
2902
|
+
return deepFreeze2({ model, ...FALLBACK2 });
|
|
2903
|
+
}
|
|
2904
|
+
function tokensFor2(c) {
|
|
2905
|
+
const tokens = /* @__PURE__ */ new Set();
|
|
2906
|
+
const add = (flag, token) => {
|
|
2907
|
+
if (flag) {
|
|
2908
|
+
tokens.add(token);
|
|
2909
|
+
}
|
|
2910
|
+
};
|
|
2911
|
+
add(c.canMowZones, "mow-zones");
|
|
2912
|
+
add(c.canMowEdges, "mow-edges");
|
|
2913
|
+
add(c.canMowSpots, "mow-spots");
|
|
2914
|
+
add(c.canMowAllArea, "mow-all-area");
|
|
2915
|
+
add(c.canResume, "resume");
|
|
2916
|
+
add(c.canSchedule, "schedule");
|
|
2917
|
+
return tokens;
|
|
2918
|
+
}
|
|
2919
|
+
var MowerCapabilitySet = class {
|
|
2920
|
+
model;
|
|
2921
|
+
#tokens;
|
|
2922
|
+
constructor(model, tokens) {
|
|
2923
|
+
this.model = model;
|
|
2924
|
+
this.#tokens = tokens;
|
|
2925
|
+
}
|
|
2926
|
+
has(token) {
|
|
2927
|
+
return this.#tokens.has(token);
|
|
2928
|
+
}
|
|
2929
|
+
list() {
|
|
2930
|
+
return [...this.#tokens];
|
|
2931
|
+
}
|
|
2932
|
+
};
|
|
2933
|
+
var MowerCapabilityResolver = class {
|
|
2934
|
+
resolve(model) {
|
|
2935
|
+
return new MowerCapabilitySet(model, tokensFor2(getMowerCapabilities(model)));
|
|
2936
|
+
}
|
|
2937
|
+
};
|
|
2938
|
+
|
|
2939
|
+
// src/models/mower/map/parser.ts
|
|
2940
|
+
var PATH_SENTINEL_X = 32767;
|
|
2941
|
+
var PATH_SENTINEL_Y = -32768;
|
|
2942
|
+
function isRecord2(v) {
|
|
2943
|
+
return typeof v === "object" && v !== null && !Array.isArray(v);
|
|
2944
|
+
}
|
|
2945
|
+
function asNumber(v, fallback) {
|
|
2946
|
+
return typeof v === "number" && Number.isFinite(v) ? v : fallback;
|
|
2947
|
+
}
|
|
2948
|
+
function asString(v, fallback) {
|
|
2949
|
+
return typeof v === "string" ? v : fallback;
|
|
2950
|
+
}
|
|
2951
|
+
function mapIdFromIndex(mapIndex) {
|
|
2952
|
+
return mapIndex + 1;
|
|
2953
|
+
}
|
|
2954
|
+
function escapeRegex(s) {
|
|
2955
|
+
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
2956
|
+
}
|
|
2957
|
+
function reassembleMapChunks(batchData, prefix) {
|
|
2958
|
+
const pattern = new RegExp(`^${escapeRegex(prefix)}\\.(\\d+)$`);
|
|
2959
|
+
const chunks = [];
|
|
2960
|
+
for (const [key2, value] of Object.entries(batchData)) {
|
|
2961
|
+
const match = pattern.exec(key2);
|
|
2962
|
+
if (match && typeof value === "string") {
|
|
2963
|
+
const idx = Number(match[1]);
|
|
2964
|
+
chunks.push({ idx, value });
|
|
2965
|
+
}
|
|
2966
|
+
}
|
|
2967
|
+
if (chunks.length === 0) {
|
|
2968
|
+
return null;
|
|
2969
|
+
}
|
|
2970
|
+
chunks.sort((a, b) => a.idx - b.idx);
|
|
2971
|
+
return chunks.map((c) => c.value).join("");
|
|
2972
|
+
}
|
|
2973
|
+
function parseSplitPos(info) {
|
|
2974
|
+
if (typeof info === "string" && /^\d+$/.test(info)) {
|
|
2975
|
+
return Number(info);
|
|
2976
|
+
}
|
|
2977
|
+
return 0;
|
|
2978
|
+
}
|
|
2979
|
+
function parsePolygonList(dataMap) {
|
|
2980
|
+
if (!isRecord2(dataMap) || dataMap["dataType"] !== "Map") {
|
|
2981
|
+
return [];
|
|
2982
|
+
}
|
|
2983
|
+
const value = dataMap["value"];
|
|
2984
|
+
return Array.isArray(value) ? value : [];
|
|
2985
|
+
}
|
|
2986
|
+
function extractPathCoords(pathList) {
|
|
2987
|
+
if (!Array.isArray(pathList)) {
|
|
2988
|
+
return [];
|
|
2989
|
+
}
|
|
2990
|
+
const out = [];
|
|
2991
|
+
for (const p of pathList) {
|
|
2992
|
+
if (isRecord2(p) && typeof p["x"] === "number" && typeof p["y"] === "number") {
|
|
2993
|
+
out.push({ x: p["x"], y: p["y"] });
|
|
2994
|
+
}
|
|
2995
|
+
}
|
|
2996
|
+
return out;
|
|
2997
|
+
}
|
|
2998
|
+
function extractContourId(raw) {
|
|
2999
|
+
if (typeof raw === "string") {
|
|
3000
|
+
const parts = raw.split(",").map((part) => part.trim());
|
|
3001
|
+
if (parts.length !== 2) {
|
|
3002
|
+
throw new Error(`Invalid contour id: ${raw}`);
|
|
3003
|
+
}
|
|
3004
|
+
const a = Number(parts[0]);
|
|
3005
|
+
const b = Number(parts[1]);
|
|
3006
|
+
if (!Number.isFinite(a) || !Number.isFinite(b)) {
|
|
3007
|
+
throw new Error(`Invalid contour id: ${raw}`);
|
|
3008
|
+
}
|
|
3009
|
+
return [Math.trunc(a), Math.trunc(b)];
|
|
3010
|
+
}
|
|
3011
|
+
if (Array.isArray(raw) && raw.length === 2) {
|
|
3012
|
+
const pair = raw;
|
|
3013
|
+
const a = pair[0];
|
|
3014
|
+
const b = pair[1];
|
|
3015
|
+
if (typeof a === "number" && typeof b === "number") {
|
|
3016
|
+
return [Math.trunc(a), Math.trunc(b)];
|
|
3017
|
+
}
|
|
3018
|
+
}
|
|
3019
|
+
throw new Error(`Invalid contour id: ${JSON.stringify(raw)}`);
|
|
3020
|
+
}
|
|
3021
|
+
function asEntry(entry) {
|
|
3022
|
+
if (!Array.isArray(entry) || entry.length < 2) {
|
|
3023
|
+
return null;
|
|
3024
|
+
}
|
|
3025
|
+
const tuple = entry;
|
|
3026
|
+
const data = tuple[1];
|
|
3027
|
+
if (!isRecord2(data)) {
|
|
3028
|
+
return null;
|
|
3029
|
+
}
|
|
3030
|
+
return { id: tuple[0], data };
|
|
3031
|
+
}
|
|
3032
|
+
function parseZones(list, includeTimes) {
|
|
3033
|
+
const out = [];
|
|
3034
|
+
for (const raw of list) {
|
|
3035
|
+
const entry = asEntry(raw);
|
|
3036
|
+
if (!entry) {
|
|
3037
|
+
continue;
|
|
3038
|
+
}
|
|
3039
|
+
const { id, data } = entry;
|
|
3040
|
+
out.push({
|
|
3041
|
+
zoneId: asNumber(id, 0),
|
|
3042
|
+
path: extractPathCoords(data["path"]),
|
|
3043
|
+
name: asString(data["name"], ""),
|
|
3044
|
+
zoneType: asNumber(data["type"], 0),
|
|
3045
|
+
shapeType: includeTimes ? asNumber(data["shapeType"], 0) : 0,
|
|
3046
|
+
area: includeTimes ? asNumber(data["area"], 0) : 0,
|
|
3047
|
+
time: includeTimes ? asNumber(data["time"], 0) : 0,
|
|
3048
|
+
etime: includeTimes ? asNumber(data["etime"], 0) : 0
|
|
3049
|
+
});
|
|
3050
|
+
}
|
|
3051
|
+
return out;
|
|
3052
|
+
}
|
|
3053
|
+
function parseSpotAreas(list) {
|
|
3054
|
+
const out = [];
|
|
3055
|
+
for (const raw of list) {
|
|
3056
|
+
const entry = asEntry(raw);
|
|
3057
|
+
if (!entry) {
|
|
3058
|
+
continue;
|
|
3059
|
+
}
|
|
3060
|
+
const { id, data } = entry;
|
|
3061
|
+
out.push({
|
|
3062
|
+
areaId: Math.trunc(asNumber(id, 0)),
|
|
3063
|
+
path: extractPathCoords(data["path"]),
|
|
3064
|
+
name: asString(data["name"], ""),
|
|
3065
|
+
shapeType: asNumber(data["shapeType"], 0),
|
|
3066
|
+
area: asNumber(data["area"], 0)
|
|
3067
|
+
});
|
|
3068
|
+
}
|
|
3069
|
+
return out;
|
|
3070
|
+
}
|
|
3071
|
+
function parsePaths(list) {
|
|
3072
|
+
const out = [];
|
|
3073
|
+
for (const raw of list) {
|
|
3074
|
+
const entry = asEntry(raw);
|
|
3075
|
+
if (!entry) {
|
|
3076
|
+
continue;
|
|
3077
|
+
}
|
|
3078
|
+
const { id, data } = entry;
|
|
3079
|
+
out.push({
|
|
3080
|
+
pathId: asNumber(id, 0),
|
|
3081
|
+
path: extractPathCoords(data["path"]),
|
|
3082
|
+
pathType: asNumber(data["type"], 0)
|
|
3083
|
+
});
|
|
3084
|
+
}
|
|
3085
|
+
return out;
|
|
3086
|
+
}
|
|
3087
|
+
function parseContours(list) {
|
|
3088
|
+
const out = [];
|
|
3089
|
+
for (const raw of list) {
|
|
3090
|
+
const entry = asEntry(raw);
|
|
3091
|
+
if (!entry) {
|
|
3092
|
+
continue;
|
|
3093
|
+
}
|
|
3094
|
+
const { id, data } = entry;
|
|
3095
|
+
out.push({
|
|
3096
|
+
contourId: extractContourId(id),
|
|
3097
|
+
path: extractPathCoords(data["path"]),
|
|
3098
|
+
contourType: asNumber(data["type"], 0),
|
|
3099
|
+
shapeType: asNumber(data["shapeType"], 0)
|
|
3100
|
+
});
|
|
3101
|
+
}
|
|
3102
|
+
return out;
|
|
3103
|
+
}
|
|
3104
|
+
function parseBoundary(raw) {
|
|
3105
|
+
if (!isRecord2(raw)) {
|
|
3106
|
+
return null;
|
|
3107
|
+
}
|
|
3108
|
+
const { x1, y1, x2, y2 } = raw;
|
|
3109
|
+
if (typeof x1 !== "number" || typeof y1 !== "number" || typeof x2 !== "number" || typeof y2 !== "number") {
|
|
3110
|
+
return null;
|
|
3111
|
+
}
|
|
3112
|
+
return { x1, y1, x2, y2 };
|
|
3113
|
+
}
|
|
3114
|
+
function parseMowerMap(mapJsonStr) {
|
|
3115
|
+
const data = JSON.parse(mapJsonStr);
|
|
3116
|
+
if (!isRecord2(data)) {
|
|
3117
|
+
throw new Error("Map JSON is not an object");
|
|
3118
|
+
}
|
|
3119
|
+
const mapIndex = asNumber(data["mapIndex"], 0);
|
|
3120
|
+
return {
|
|
3121
|
+
zones: parseZones(parsePolygonList(data["mowingAreas"]), true),
|
|
3122
|
+
spotAreas: parseSpotAreas(parsePolygonList(data["spotAreas"])),
|
|
3123
|
+
forbiddenAreas: parseZones(parsePolygonList(data["forbiddenAreas"]), false),
|
|
3124
|
+
paths: parsePaths(parsePolygonList(data["paths"])),
|
|
3125
|
+
contours: parseContours(parsePolygonList(data["contours"])),
|
|
3126
|
+
boundary: parseBoundary(data["boundary"]),
|
|
3127
|
+
totalArea: asNumber(data["totalArea"], 0),
|
|
3128
|
+
name: asString(data["name"], ""),
|
|
3129
|
+
mapIndex,
|
|
3130
|
+
mapId: mapIdFromIndex(mapIndex),
|
|
3131
|
+
mowPaths: [],
|
|
3132
|
+
availableMaps: [],
|
|
3133
|
+
currentMapId: null,
|
|
3134
|
+
lastUpdated: Date.now()
|
|
3135
|
+
};
|
|
3136
|
+
}
|
|
3137
|
+
function parseMowPaths(batchData) {
|
|
3138
|
+
const reassembled = reassembleMapChunks(batchData, "M_PATH");
|
|
3139
|
+
if (!reassembled) {
|
|
3140
|
+
return [];
|
|
3141
|
+
}
|
|
3142
|
+
const splitPos = parseSplitPos(batchData["M_PATH.info"]);
|
|
3143
|
+
let raw = reassembled;
|
|
3144
|
+
if (splitPos > 0 && splitPos < raw.length) {
|
|
3145
|
+
raw = raw.slice(splitPos);
|
|
3146
|
+
}
|
|
3147
|
+
const trimmed = raw.trim();
|
|
3148
|
+
if (trimmed.length === 0 || trimmed === "[]") {
|
|
3149
|
+
return [];
|
|
3150
|
+
}
|
|
3151
|
+
const pairPattern = /\[(-?\d+),(-?\d+)\]/g;
|
|
3152
|
+
const rawPairs = [];
|
|
3153
|
+
for (const m of raw.matchAll(pairPattern)) {
|
|
3154
|
+
rawPairs.push([Number(m[1]), Number(m[2])]);
|
|
3155
|
+
}
|
|
3156
|
+
if (rawPairs.length === 0) {
|
|
3157
|
+
return [];
|
|
3158
|
+
}
|
|
3159
|
+
const segments = [];
|
|
3160
|
+
let current = [];
|
|
3161
|
+
for (const [x, y] of rawPairs) {
|
|
3162
|
+
if (x === PATH_SENTINEL_X && y === PATH_SENTINEL_Y) {
|
|
3163
|
+
if (current.length > 0) {
|
|
3164
|
+
segments.push(current);
|
|
3165
|
+
current = [];
|
|
3166
|
+
}
|
|
3167
|
+
} else {
|
|
3168
|
+
current.push({ x: x * 10, y: y * 10 });
|
|
3169
|
+
}
|
|
3170
|
+
}
|
|
3171
|
+
if (current.length > 0) {
|
|
3172
|
+
segments.push(current);
|
|
3173
|
+
}
|
|
3174
|
+
if (segments.length === 0) {
|
|
3175
|
+
return [];
|
|
3176
|
+
}
|
|
3177
|
+
return [{ zoneId: 0, segments }];
|
|
3178
|
+
}
|
|
3179
|
+
function parseMapPart(part) {
|
|
3180
|
+
const trimmed = part.trim();
|
|
3181
|
+
if (trimmed.length === 0) {
|
|
3182
|
+
return [];
|
|
3183
|
+
}
|
|
3184
|
+
let arr;
|
|
3185
|
+
try {
|
|
3186
|
+
arr = JSON.parse(trimmed);
|
|
3187
|
+
} catch {
|
|
3188
|
+
return [];
|
|
3189
|
+
}
|
|
3190
|
+
if (!Array.isArray(arr)) {
|
|
3191
|
+
return [];
|
|
3192
|
+
}
|
|
3193
|
+
const out = [];
|
|
3194
|
+
for (const item of arr) {
|
|
3195
|
+
if (typeof item !== "string") {
|
|
3196
|
+
continue;
|
|
3197
|
+
}
|
|
3198
|
+
try {
|
|
3199
|
+
out.push(parseMowerMap(item));
|
|
3200
|
+
} catch {
|
|
3201
|
+
}
|
|
3202
|
+
}
|
|
3203
|
+
return out;
|
|
3204
|
+
}
|
|
3205
|
+
function parseBatchMapData(batchData) {
|
|
3206
|
+
if (Object.keys(batchData).length === 0) {
|
|
3207
|
+
return null;
|
|
3208
|
+
}
|
|
3209
|
+
const rawMap = reassembleMapChunks(batchData, "MAP");
|
|
3210
|
+
if (!rawMap) {
|
|
3211
|
+
return null;
|
|
3212
|
+
}
|
|
3213
|
+
const splitPos = parseSplitPos(batchData["MAP.info"]);
|
|
3214
|
+
const parts = splitPos > 0 && splitPos < rawMap.length ? [rawMap.slice(0, splitPos), rawMap.slice(splitPos)] : [rawMap];
|
|
3215
|
+
const parsedMaps = [];
|
|
3216
|
+
for (const part of parts) {
|
|
3217
|
+
parsedMaps.push(...parseMapPart(part));
|
|
3218
|
+
}
|
|
3219
|
+
if (parsedMaps.length === 0) {
|
|
3220
|
+
return null;
|
|
3221
|
+
}
|
|
3222
|
+
const primary = parsedMaps.find((m) => m.mapIndex === 0) ?? parsedMaps[0];
|
|
3223
|
+
if (!primary) {
|
|
3224
|
+
return null;
|
|
3225
|
+
}
|
|
3226
|
+
const availableMaps = [...parsedMaps].sort((a, b) => a.mapId - b.mapId).map((m) => ({
|
|
3227
|
+
mapId: m.mapId,
|
|
3228
|
+
mapIndex: m.mapIndex,
|
|
3229
|
+
name: m.name,
|
|
3230
|
+
totalArea: m.totalArea
|
|
3231
|
+
}));
|
|
3232
|
+
const mowPaths = parseMowPaths(batchData);
|
|
3233
|
+
return {
|
|
3234
|
+
...primary,
|
|
3235
|
+
availableMaps,
|
|
3236
|
+
mowPaths,
|
|
3237
|
+
currentMapId: primary.mapId
|
|
3238
|
+
};
|
|
3239
|
+
}
|
|
3240
|
+
|
|
3241
|
+
// src/models/mower/map/render.ts
|
|
3242
|
+
var DEFAULT_WIDTH = 1200;
|
|
3243
|
+
var DEFAULT_HEIGHT = 1200;
|
|
3244
|
+
var DEFAULT_PADDING = 50;
|
|
3245
|
+
var BACKGROUND = "#f5f5f0";
|
|
3246
|
+
var MAP_BOUNDARY = "#006400";
|
|
3247
|
+
var MOWING_PATH = "#ffa500";
|
|
3248
|
+
var NAV_PATH = "#b4b4b4";
|
|
3249
|
+
var OBSTACLE_STROKE = "#ff4d00";
|
|
3250
|
+
var OBSTACLE_FILL = "#ff4d0065";
|
|
3251
|
+
var TEXT_COLOR = "#000000";
|
|
3252
|
+
var ZONE_COLORS = [
|
|
3253
|
+
["#a4d291c8", "#86be73"],
|
|
3254
|
+
["#a0c8dcc8", "#82aac8"],
|
|
3255
|
+
["#f0c8aac8", "#dcaf8c"],
|
|
3256
|
+
["#f0b4b4c8", "#dc9696"],
|
|
3257
|
+
["#e6dca0c8", "#d2c882"],
|
|
3258
|
+
["#beaadcc8", "#aa91c8"],
|
|
3259
|
+
["#aad7d2c8", "#8cc3be"],
|
|
3260
|
+
["#dcbea0c8", "#c8a582"]
|
|
3261
|
+
];
|
|
3262
|
+
var SENTINEL = 2147483647;
|
|
3263
|
+
function calculateBounds(allPoints) {
|
|
3264
|
+
const valid = allPoints.filter((p) => p.x !== SENTINEL && p.y !== SENTINEL);
|
|
3265
|
+
if (valid.length === 0) {
|
|
3266
|
+
return { minX: 0, minY: 0, maxX: 100, maxY: 100 };
|
|
3267
|
+
}
|
|
3268
|
+
let minX = valid[0]?.x ?? 0;
|
|
3269
|
+
let maxX = minX;
|
|
3270
|
+
let minY = valid[0]?.y ?? 0;
|
|
3271
|
+
let maxY = minY;
|
|
3272
|
+
for (const p of valid) {
|
|
3273
|
+
if (p.x < minX) minX = p.x;
|
|
3274
|
+
if (p.x > maxX) maxX = p.x;
|
|
3275
|
+
if (p.y < minY) minY = p.y;
|
|
3276
|
+
if (p.y > maxY) maxY = p.y;
|
|
3277
|
+
}
|
|
3278
|
+
return { minX, minY, maxX, maxY };
|
|
3279
|
+
}
|
|
3280
|
+
function coordToPixel(point, bounds, width, height, padding) {
|
|
3281
|
+
let { maxX, maxY } = bounds;
|
|
3282
|
+
const { minX, minY } = bounds;
|
|
3283
|
+
if (maxX === minX) {
|
|
3284
|
+
maxX = minX + 100;
|
|
3285
|
+
}
|
|
3286
|
+
if (maxY === minY) {
|
|
3287
|
+
maxY = minY + 100;
|
|
3288
|
+
}
|
|
3289
|
+
const coordWidth = maxX - minX;
|
|
3290
|
+
const coordHeight = maxY - minY;
|
|
3291
|
+
const availableWidth = width - 2 * padding;
|
|
3292
|
+
const availableHeight = height - 2 * padding;
|
|
3293
|
+
const scale = Math.min(availableWidth / coordWidth, availableHeight / coordHeight);
|
|
3294
|
+
const renderedWidth = coordWidth * scale;
|
|
3295
|
+
const renderedHeight = coordHeight * scale;
|
|
3296
|
+
const offsetX = padding + (availableWidth - renderedWidth) / 2;
|
|
3297
|
+
const offsetY = padding + (availableHeight - renderedHeight) / 2;
|
|
3298
|
+
const px = Math.trunc(offsetX + (point.x - minX) * scale);
|
|
3299
|
+
const py = Math.trunc(offsetY + (maxY - point.y) * scale);
|
|
3300
|
+
return { px, py };
|
|
3301
|
+
}
|
|
3302
|
+
function svgPolygon(points, bounds, width, height, padding, fill, stroke) {
|
|
3303
|
+
if (points.length < 3) {
|
|
3304
|
+
return "";
|
|
3305
|
+
}
|
|
3306
|
+
const pixels = points.map((p) => coordToPixel(p, bounds, width, height, padding)).map((p) => `${p.px},${p.py}`).join(" ");
|
|
3307
|
+
return `<polygon points="${pixels}" fill="${fill}" stroke="${stroke}"/>`;
|
|
3308
|
+
}
|
|
3309
|
+
function svgPathFromSegments(segments, bounds, width, height, padding, stroke, strokeWidth, dashed) {
|
|
3310
|
+
const parts = [];
|
|
3311
|
+
for (const segment of segments) {
|
|
3312
|
+
if (segment.length < 2) {
|
|
3313
|
+
continue;
|
|
3314
|
+
}
|
|
3315
|
+
const first = segment[0];
|
|
3316
|
+
if (!first) {
|
|
3317
|
+
continue;
|
|
3318
|
+
}
|
|
3319
|
+
let prev = coordToPixel(first, bounds, width, height, padding);
|
|
3320
|
+
parts.push(`M ${prev.px} ${prev.py}`);
|
|
3321
|
+
for (let i = 1; i < segment.length; i += 1) {
|
|
3322
|
+
const point = segment[i];
|
|
3323
|
+
if (!point) {
|
|
3324
|
+
continue;
|
|
3325
|
+
}
|
|
3326
|
+
const pixel = coordToPixel(point, bounds, width, height, padding);
|
|
3327
|
+
if (pixel.px !== prev.px || pixel.py !== prev.py) {
|
|
3328
|
+
parts.push(`L ${pixel.px} ${pixel.py}`);
|
|
3329
|
+
prev = pixel;
|
|
3330
|
+
}
|
|
3331
|
+
}
|
|
3332
|
+
}
|
|
3333
|
+
if (parts.length === 0) {
|
|
3334
|
+
return "";
|
|
3335
|
+
}
|
|
3336
|
+
const dash = dashed ? ' stroke-dasharray="10,5"' : "";
|
|
3337
|
+
return `<path d="${parts.join(" ")}" stroke="${stroke}" stroke-width="${strokeWidth}"${dash} fill="none"/>`;
|
|
3338
|
+
}
|
|
3339
|
+
function renderMowerSvg(map, opts = {}) {
|
|
3340
|
+
const width = opts.width ?? DEFAULT_WIDTH;
|
|
3341
|
+
const height = opts.height ?? DEFAULT_HEIGHT;
|
|
3342
|
+
const padding = opts.padding ?? DEFAULT_PADDING;
|
|
3343
|
+
const lines = [
|
|
3344
|
+
'<?xml version="1.0" encoding="UTF-8"?>',
|
|
3345
|
+
`<svg width="${width}" height="${height}" viewBox="0 0 ${width} ${height}" xmlns="http://www.w3.org/2000/svg">`,
|
|
3346
|
+
`<rect width="100%" height="100%" fill="${BACKGROUND}"/>`
|
|
3347
|
+
];
|
|
3348
|
+
const allPoints = [];
|
|
3349
|
+
for (const zone of map.zones) {
|
|
3350
|
+
allPoints.push(...zone.path);
|
|
3351
|
+
}
|
|
3352
|
+
for (const area of map.forbiddenAreas) {
|
|
3353
|
+
allPoints.push(...area.path);
|
|
3354
|
+
}
|
|
3355
|
+
for (const path of map.paths) {
|
|
3356
|
+
allPoints.push(...path.path);
|
|
3357
|
+
}
|
|
3358
|
+
for (const contour of map.contours) {
|
|
3359
|
+
allPoints.push(...contour.path);
|
|
3360
|
+
}
|
|
3361
|
+
for (const mp of map.mowPaths) {
|
|
3362
|
+
for (const seg of mp.segments) {
|
|
3363
|
+
allPoints.push(...seg);
|
|
3364
|
+
}
|
|
3365
|
+
}
|
|
3366
|
+
if (allPoints.length === 0) {
|
|
3367
|
+
lines.push(
|
|
3368
|
+
`<text x="${Math.trunc(width / 2)}" y="${Math.trunc(height / 2)}" font-family="Arial, sans-serif" font-size="16" fill="${TEXT_COLOR}" text-anchor="middle">No map data available</text>`
|
|
3369
|
+
);
|
|
3370
|
+
lines.push("</svg>");
|
|
3371
|
+
return lines.join("\n");
|
|
3372
|
+
}
|
|
3373
|
+
const bounds = calculateBounds(allPoints);
|
|
3374
|
+
const multiZone = map.zones.length > 1;
|
|
3375
|
+
for (const navPath of map.paths) {
|
|
3376
|
+
const dashed = svgPathFromSegments(
|
|
3377
|
+
[navPath.path],
|
|
3378
|
+
bounds,
|
|
3379
|
+
width,
|
|
3380
|
+
height,
|
|
3381
|
+
padding,
|
|
3382
|
+
NAV_PATH,
|
|
3383
|
+
3,
|
|
3384
|
+
true
|
|
3385
|
+
);
|
|
3386
|
+
if (dashed) {
|
|
3387
|
+
lines.push(dashed);
|
|
3388
|
+
}
|
|
3389
|
+
}
|
|
3390
|
+
map.zones.forEach((zone, i) => {
|
|
3391
|
+
if (!multiZone) {
|
|
3392
|
+
return;
|
|
3393
|
+
}
|
|
3394
|
+
const palette = ZONE_COLORS[i % ZONE_COLORS.length];
|
|
3395
|
+
const [fill, outline] = palette ?? ZONE_COLORS[0] ?? ["#cccccc", "#888888"];
|
|
3396
|
+
const poly = svgPolygon(zone.path, bounds, width, height, padding, fill, outline);
|
|
3397
|
+
if (poly) {
|
|
3398
|
+
lines.push(poly);
|
|
3399
|
+
}
|
|
3400
|
+
});
|
|
3401
|
+
map.zones.forEach((zone, i) => {
|
|
3402
|
+
const outline = multiZone ? ZONE_COLORS[i % ZONE_COLORS.length]?.[1] ?? MAP_BOUNDARY : MAP_BOUNDARY;
|
|
3403
|
+
const boundary = svgPathFromSegments(
|
|
3404
|
+
[zone.path],
|
|
3405
|
+
bounds,
|
|
3406
|
+
width,
|
|
3407
|
+
height,
|
|
3408
|
+
padding,
|
|
3409
|
+
outline,
|
|
3410
|
+
2,
|
|
3411
|
+
false
|
|
3412
|
+
);
|
|
3413
|
+
if (boundary) {
|
|
3414
|
+
lines.push(boundary);
|
|
3415
|
+
}
|
|
3416
|
+
});
|
|
3417
|
+
for (const mp of map.mowPaths) {
|
|
3418
|
+
const track = svgPathFromSegments(
|
|
3419
|
+
mp.segments,
|
|
3420
|
+
bounds,
|
|
3421
|
+
width,
|
|
3422
|
+
height,
|
|
3423
|
+
padding,
|
|
3424
|
+
MOWING_PATH,
|
|
3425
|
+
2,
|
|
3426
|
+
false
|
|
3427
|
+
);
|
|
3428
|
+
if (track) {
|
|
3429
|
+
lines.push(track);
|
|
3430
|
+
}
|
|
3431
|
+
}
|
|
3432
|
+
for (const area of map.forbiddenAreas) {
|
|
3433
|
+
const poly = svgPolygon(
|
|
3434
|
+
area.path,
|
|
3435
|
+
bounds,
|
|
3436
|
+
width,
|
|
3437
|
+
height,
|
|
3438
|
+
padding,
|
|
3439
|
+
OBSTACLE_FILL,
|
|
3440
|
+
OBSTACLE_STROKE
|
|
3441
|
+
);
|
|
3442
|
+
if (poly) {
|
|
3443
|
+
lines.push(poly);
|
|
3444
|
+
}
|
|
3445
|
+
}
|
|
3446
|
+
lines.push("</svg>");
|
|
3447
|
+
return lines.join("\n");
|
|
3448
|
+
}
|
|
3449
|
+
|
|
3450
|
+
// src/models/mower/mower-device.ts
|
|
3451
|
+
var DEFAULT_MAP_PROPS = ["MAP", "M_PATH"];
|
|
3452
|
+
var STATUS = enumLookup(
|
|
3453
|
+
Object.values(MowerStatus).filter((v) => typeof v === "number")
|
|
3454
|
+
);
|
|
3455
|
+
var CHARGING2 = enumLookup(
|
|
3456
|
+
Object.values(MowerChargingStatus).filter((v) => typeof v === "number")
|
|
3457
|
+
);
|
|
3458
|
+
var MowerDevice = class extends BaseDevice {
|
|
3459
|
+
#caps;
|
|
3460
|
+
#fetchBatch;
|
|
3461
|
+
#lastMap = null;
|
|
3462
|
+
constructor(input) {
|
|
3463
|
+
super({
|
|
3464
|
+
...input,
|
|
3465
|
+
// Inject the mower resolver so the inherited generic `capabilities` getter
|
|
3466
|
+
// carries the mower token set (no banned cast).
|
|
3467
|
+
capabilities: input.capabilities ?? new MowerCapabilityResolver().resolve(input.device.model)
|
|
3468
|
+
});
|
|
3469
|
+
this.#caps = getMowerCapabilities(input.device.model);
|
|
3470
|
+
this.#fetchBatch = input.getBatchDeviceDatas ?? null;
|
|
3471
|
+
}
|
|
3472
|
+
/** Rich, mower-specific capability record. */
|
|
3473
|
+
get mowerCapabilities() {
|
|
3474
|
+
return this.#caps;
|
|
3475
|
+
}
|
|
3476
|
+
#num(siid, piid) {
|
|
3477
|
+
return asNum(this.getProperty(siid, piid)?.value);
|
|
3478
|
+
}
|
|
3479
|
+
// -- typed state --------------------------------------------------------
|
|
3480
|
+
get statusRaw() {
|
|
3481
|
+
return this.#num(MOWER_PROP.STATUS.siid, MOWER_PROP.STATUS.piid);
|
|
3482
|
+
}
|
|
3483
|
+
get status() {
|
|
3484
|
+
return STATUS(this.statusRaw);
|
|
3485
|
+
}
|
|
3486
|
+
get battery() {
|
|
3487
|
+
return this.#num(MOWER_PROP.BATTERY.siid, MOWER_PROP.BATTERY.piid);
|
|
3488
|
+
}
|
|
3489
|
+
get chargingRaw() {
|
|
3490
|
+
return this.#num(MOWER_PROP.CHARGING_STATUS.siid, MOWER_PROP.CHARGING_STATUS.piid);
|
|
3491
|
+
}
|
|
3492
|
+
get charging() {
|
|
3493
|
+
return CHARGING2(this.chargingRaw);
|
|
3494
|
+
}
|
|
3495
|
+
/** Docked => on the dock (Charging / ChargingComplete state). */
|
|
3496
|
+
get isDocked() {
|
|
3497
|
+
const s = this.status;
|
|
3498
|
+
return s === 6 /* Charging */ || s === 13 /* ChargingComplete */;
|
|
3499
|
+
}
|
|
3500
|
+
get isMowing() {
|
|
3501
|
+
return this.status === 1 /* Mowing */;
|
|
3502
|
+
}
|
|
3503
|
+
get taskStatusRaw() {
|
|
3504
|
+
return this.#num(MOWER_PROP.TASK_STATUS.siid, MOWER_PROP.TASK_STATUS.piid);
|
|
3505
|
+
}
|
|
3506
|
+
/** Parsed scheduling task descriptor (2:50), or null. */
|
|
3507
|
+
get task() {
|
|
3508
|
+
return parseTaskDescriptor(
|
|
3509
|
+
this.getProperty(MOWER_PROP.SCHEDULING_TASK.siid, MOWER_PROP.SCHEDULING_TASK.piid)?.value
|
|
3510
|
+
);
|
|
3511
|
+
}
|
|
3512
|
+
/**
|
|
3513
|
+
* Mowing coverage target / progress signal from the task descriptor (`d.o`).
|
|
3514
|
+
* This is the P4 progress surface; the byte-accurate pose-track % is P5.
|
|
3515
|
+
*/
|
|
3516
|
+
get coverageTargetPct() {
|
|
3517
|
+
return this.task?.coverageTarget ?? null;
|
|
3518
|
+
}
|
|
3519
|
+
/** Parsed per-zone control status (2:56), or null. */
|
|
3520
|
+
get controlStatus() {
|
|
3521
|
+
return parseControlStatus(
|
|
3522
|
+
this.getProperty(MOWER_PROP.MOWER_CONTROL_STATUS.siid, MOWER_PROP.MOWER_CONTROL_STATUS.piid)?.value
|
|
3523
|
+
);
|
|
3524
|
+
}
|
|
3525
|
+
get controlAction() {
|
|
3526
|
+
return this.controlStatus?.action ?? null;
|
|
3527
|
+
}
|
|
3528
|
+
// -- command helpers ----------------------------------------------------
|
|
3529
|
+
#requireCap(flag, op, feature) {
|
|
3530
|
+
if (!flag) {
|
|
3531
|
+
throw new DreameError(`${op}: model ${this.model} does not support ${feature}`);
|
|
3532
|
+
}
|
|
3533
|
+
}
|
|
3534
|
+
/** Send a scheduling-task action (2:50) carrying a single opcode object. */
|
|
3535
|
+
#sendTask(payload) {
|
|
3536
|
+
return this.callAction(MOWER_PROP.SCHEDULING_TASK.siid, MOWER_PROP.SCHEDULING_TASK.piid, [
|
|
3537
|
+
payload
|
|
3538
|
+
]);
|
|
3539
|
+
}
|
|
3540
|
+
// -- no-arg commands ----------------------------------------------------
|
|
3541
|
+
/**
|
|
3542
|
+
* Start the generic mowing action (siid 5 aiid 1). Named `startMowing` — NOT
|
|
3543
|
+
* `start` — because `BaseDevice.start()` is the MQTT lifecycle method the
|
|
3544
|
+
* facade relies on; overriding it would break handle startup.
|
|
3545
|
+
*/
|
|
3546
|
+
startMowing() {
|
|
3547
|
+
return this.callAction(MOWER_ACTION.START_MOWING.siid, MOWER_ACTION.START_MOWING.aiid, []);
|
|
3548
|
+
}
|
|
3549
|
+
pause() {
|
|
3550
|
+
return this.callAction(MOWER_ACTION.PAUSE.siid, MOWER_ACTION.PAUSE.aiid, []);
|
|
3551
|
+
}
|
|
3552
|
+
stop() {
|
|
3553
|
+
return this.callAction(MOWER_ACTION.STOP.siid, MOWER_ACTION.STOP.aiid, []);
|
|
3554
|
+
}
|
|
3555
|
+
/** Send the mower to its dock (siid 5 aiid 3). */
|
|
3556
|
+
dock() {
|
|
3557
|
+
return this.callAction(MOWER_ACTION.DOCK.siid, MOWER_ACTION.DOCK.aiid, []);
|
|
3558
|
+
}
|
|
3559
|
+
/**
|
|
3560
|
+
* Resume mowing after a pause. Encoded as the continueControl opcode
|
|
3561
|
+
* `{m:'a', p:0, o:5}` sent as the single in-param of the SCHEDULING_TASK
|
|
3562
|
+
* (2:50) action — NOT a siid-5 action. Mirrors the donor TASK_PAYLOAD_RESUME.
|
|
3563
|
+
*/
|
|
3564
|
+
async resume() {
|
|
3565
|
+
this.#requireCap(this.#caps.canResume, "resume", "resume");
|
|
3566
|
+
return this.#sendTask(buildResumePayload());
|
|
3567
|
+
}
|
|
3568
|
+
// -- targeted mowing ----------------------------------------------------
|
|
3569
|
+
/** All-area, map-targeted mowing (2:50 o:100). */
|
|
3570
|
+
async startMowingAllArea(mapId) {
|
|
3571
|
+
this.#requireCap(this.#caps.canMowAllArea, "startMowingAllArea", "all-area mowing");
|
|
3572
|
+
return this.#sendTask(buildAllAreaPayload(Math.trunc(mapId)));
|
|
3573
|
+
}
|
|
3574
|
+
/** Zone-selective mowing (2:50 o:102). */
|
|
3575
|
+
async startMowingZones(zoneIds) {
|
|
3576
|
+
this.#requireCap(this.#caps.canMowZones, "startMowingZones", "zone mowing");
|
|
3577
|
+
if (zoneIds.length === 0) {
|
|
3578
|
+
throw new RangeError("startMowingZones: zoneIds must not be empty");
|
|
3579
|
+
}
|
|
3580
|
+
return this.#sendTask(buildZonePayload(zoneIds.map((z3) => Math.trunc(z3))));
|
|
3581
|
+
}
|
|
3582
|
+
/** Edge / contour mowing (2:50 o:101). Contour ids are two-int pairs [[1,0]]. */
|
|
3583
|
+
async startMowingEdges(contourIds) {
|
|
3584
|
+
this.#requireCap(this.#caps.canMowEdges, "startMowingEdges", "edge mowing");
|
|
3585
|
+
if (contourIds.length === 0) {
|
|
3586
|
+
throw new RangeError("startMowingEdges: contourIds must not be empty");
|
|
3587
|
+
}
|
|
3588
|
+
return this.#sendTask(buildEdgePayload(contourIds));
|
|
3589
|
+
}
|
|
3590
|
+
/** Spot mowing (2:50 o:103). */
|
|
3591
|
+
async startMowingSpots(spotAreaIds) {
|
|
3592
|
+
this.#requireCap(this.#caps.canMowSpots, "startMowingSpots", "spot mowing");
|
|
3593
|
+
if (spotAreaIds.length === 0) {
|
|
3594
|
+
throw new RangeError("startMowingSpots: spotAreaIds must not be empty");
|
|
3595
|
+
}
|
|
3596
|
+
return this.#sendTask(buildSpotPayload(spotAreaIds.map((s) => Math.trunc(s))));
|
|
3597
|
+
}
|
|
3598
|
+
// -- maps ---------------------------------------------------------------
|
|
3599
|
+
/** The most-recently-parsed map, or `null` until {@link getMap} succeeds. */
|
|
3600
|
+
get lastMap() {
|
|
3601
|
+
return this.#lastMap;
|
|
3602
|
+
}
|
|
3603
|
+
/**
|
|
3604
|
+
* Fetch the batched vector-map data, parse it into a {@link MowerMap}, cache
|
|
3605
|
+
* it as {@link lastMap}, and return it. Capability-gated on `canMap`.
|
|
3606
|
+
*
|
|
3607
|
+
* The batch fetcher is injected at construction (the live cloud endpoint path
|
|
3608
|
+
* is not yet recovered — see `cloud/commands.ts`). Rejects with
|
|
3609
|
+
* {@link DreameError} if no fetcher was injected or the batch yields no
|
|
3610
|
+
* parseable map (asleep mower / empty data).
|
|
3611
|
+
*/
|
|
3612
|
+
async getMap(props = DEFAULT_MAP_PROPS) {
|
|
3613
|
+
this.#requireCap(this.#caps.canMap, "getMap", "map parsing");
|
|
3614
|
+
if (!this.#fetchBatch) {
|
|
3615
|
+
throw new DreameError(
|
|
3616
|
+
"getMap: no batch device-data fetcher injected (live endpoint unrecovered)"
|
|
3617
|
+
);
|
|
3618
|
+
}
|
|
3619
|
+
const batch = await this.#fetchBatch(this.deviceId, [...props]);
|
|
3620
|
+
const map = parseBatchMapData(batch);
|
|
3621
|
+
if (!map) {
|
|
3622
|
+
throw new DreameError("getMap: batch device-data contained no parseable map");
|
|
3623
|
+
}
|
|
3624
|
+
this.#lastMap = map;
|
|
3625
|
+
return map;
|
|
3626
|
+
}
|
|
3627
|
+
/**
|
|
3628
|
+
* Render the current map to a deterministic SVG string. Uses {@link lastMap}
|
|
3629
|
+
* when present, otherwise fetches first via {@link getMap}.
|
|
3630
|
+
*/
|
|
3631
|
+
async mapSvg(opts) {
|
|
3632
|
+
const map = this.#lastMap ?? await this.getMap();
|
|
3633
|
+
return renderMowerSvg(map, opts ?? {});
|
|
3634
|
+
}
|
|
3635
|
+
/** Props worth seeding on start() / polling — exported for the facade. */
|
|
3636
|
+
static DEFAULT_PROPS = [
|
|
3637
|
+
MOWER_PROP.STATUS,
|
|
3638
|
+
MOWER_PROP.BATTERY,
|
|
3639
|
+
MOWER_PROP.CHARGING_STATUS,
|
|
3640
|
+
MOWER_PROP.TASK_STATUS,
|
|
3641
|
+
MOWER_PROP.SCHEDULING_TASK,
|
|
3642
|
+
MOWER_PROP.MOWER_CONTROL_STATUS
|
|
3643
|
+
];
|
|
3644
|
+
};
|
|
3645
|
+
|
|
3646
|
+
// src/api/nodreame.ts
|
|
3647
|
+
var DEFAULT_REFRESH_LEEWAY_SECS = 100;
|
|
3648
|
+
function deviceClassFor(model) {
|
|
3649
|
+
if (model.startsWith("dreame.vacuum.")) {
|
|
3650
|
+
return VacuumDevice;
|
|
3651
|
+
}
|
|
3652
|
+
if (model.startsWith("dreame.mower.")) {
|
|
3653
|
+
return MowerDevice;
|
|
3654
|
+
}
|
|
3655
|
+
return BaseDevice;
|
|
3656
|
+
}
|
|
3657
|
+
function defaultDeps(opts) {
|
|
3658
|
+
return {
|
|
3659
|
+
login: (input) => login(input),
|
|
3660
|
+
refresh: (input) => refresh(input),
|
|
3661
|
+
listDevices: (input) => listDevices(input),
|
|
3662
|
+
createDevice: (args) => {
|
|
3663
|
+
const DeviceClass = deviceClassFor(args.device.model);
|
|
3664
|
+
return new DeviceClass({
|
|
3665
|
+
device: args.device,
|
|
3666
|
+
region: args.region,
|
|
3667
|
+
sessionRef: args.sessionRef,
|
|
3668
|
+
...opts.fetchInitialValues !== void 0 ? { fetchInitialValues: opts.fetchInitialValues } : {},
|
|
3669
|
+
...opts.pollIntervalMs !== void 0 ? { pollIntervalMs: opts.pollIntervalMs } : {}
|
|
3670
|
+
});
|
|
3671
|
+
}
|
|
3672
|
+
};
|
|
3673
|
+
}
|
|
3674
|
+
var Nodreame = class extends TypedEmitter {
|
|
3675
|
+
#opts;
|
|
3676
|
+
#deps;
|
|
3677
|
+
#leewayMs;
|
|
3678
|
+
#session = null;
|
|
3679
|
+
#devices = [];
|
|
3680
|
+
#closed = false;
|
|
3681
|
+
constructor(opts, deps) {
|
|
3682
|
+
super();
|
|
3683
|
+
if (!opts.username || !opts.password) {
|
|
3684
|
+
throw new DreameAuthError("username and password are required");
|
|
3685
|
+
}
|
|
3686
|
+
this.#opts = opts;
|
|
3687
|
+
this.#deps = deps ?? defaultDeps(opts);
|
|
3688
|
+
this.#leewayMs = (opts.refreshLeewaySecs ?? DEFAULT_REFRESH_LEEWAY_SECS) * 1e3;
|
|
3689
|
+
}
|
|
3690
|
+
get region() {
|
|
3691
|
+
return this.#opts.region;
|
|
3692
|
+
}
|
|
3693
|
+
get session() {
|
|
3694
|
+
return this.#session;
|
|
3695
|
+
}
|
|
3696
|
+
get devices() {
|
|
3697
|
+
return this.#devices;
|
|
3698
|
+
}
|
|
3699
|
+
/** Authenticate and stash the single shared session. */
|
|
3700
|
+
async login() {
|
|
3701
|
+
this.#session = await this.#deps.login({
|
|
3702
|
+
email: this.#opts.username,
|
|
3703
|
+
password: this.#opts.password,
|
|
3704
|
+
region: this.#opts.region,
|
|
3705
|
+
...this.#opts.country !== void 0 ? { country: this.#opts.country } : {},
|
|
3706
|
+
...this.#opts.lang !== void 0 ? { lang: this.#opts.lang } : {},
|
|
3707
|
+
...this.#opts.fetchImpl !== void 0 ? { fetchImpl: this.#opts.fetchImpl } : {}
|
|
3708
|
+
});
|
|
3709
|
+
return this.#session;
|
|
3710
|
+
}
|
|
3711
|
+
/** Return a valid session, refreshing proactively within the leeway window. */
|
|
3712
|
+
async ensureSession() {
|
|
3713
|
+
const current = this.#session;
|
|
3714
|
+
if (!current) {
|
|
3715
|
+
return this.login();
|
|
3716
|
+
}
|
|
3717
|
+
if (Date.now() < current.expiresAt - this.#leewayMs) {
|
|
3718
|
+
return current;
|
|
3719
|
+
}
|
|
3720
|
+
if (current.refreshToken) {
|
|
3721
|
+
try {
|
|
3722
|
+
const next = await this.#deps.refresh({
|
|
3723
|
+
refreshToken: current.refreshToken,
|
|
3724
|
+
region: this.#opts.region,
|
|
3725
|
+
...this.#opts.country !== void 0 ? { country: this.#opts.country } : {},
|
|
3726
|
+
...this.#opts.lang !== void 0 ? { lang: this.#opts.lang } : {},
|
|
3727
|
+
...this.#opts.fetchImpl !== void 0 ? { fetchImpl: this.#opts.fetchImpl } : {}
|
|
3728
|
+
});
|
|
3729
|
+
await this.#adoptSession(next);
|
|
3730
|
+
return next;
|
|
3731
|
+
} catch {
|
|
3732
|
+
}
|
|
3733
|
+
}
|
|
3734
|
+
const fresh = await this.login();
|
|
3735
|
+
await this.#propagateSession(fresh);
|
|
3736
|
+
return fresh;
|
|
3737
|
+
}
|
|
3738
|
+
/** Discover devices and build a live handle per device. */
|
|
3739
|
+
async discoverDevices() {
|
|
3740
|
+
if (this.#closed) {
|
|
3741
|
+
throw new DreameAuthError("client is closed");
|
|
3742
|
+
}
|
|
3743
|
+
const session = await this.ensureSession();
|
|
3744
|
+
const records = await this.#deps.listDevices({
|
|
3745
|
+
session,
|
|
3746
|
+
region: this.#opts.region,
|
|
3747
|
+
...this.#opts.fetchImpl !== void 0 ? { fetchImpl: this.#opts.fetchImpl } : {}
|
|
3748
|
+
});
|
|
3749
|
+
const handles = records.map(
|
|
3750
|
+
(device) => this.#deps.createDevice({
|
|
3751
|
+
device,
|
|
3752
|
+
region: this.#opts.region,
|
|
3753
|
+
sessionRef: () => this.#requireSession()
|
|
3754
|
+
})
|
|
3755
|
+
);
|
|
3756
|
+
for (const h of handles) {
|
|
3757
|
+
h.on("stateChanged", (e) => this.emit("stateChanged", e));
|
|
3758
|
+
h.on("event", (e) => this.emit("event", e));
|
|
3759
|
+
h.on("error", (err) => this.emit("error", err));
|
|
3760
|
+
await h.start();
|
|
3761
|
+
}
|
|
3762
|
+
const previous = this.#devices;
|
|
3763
|
+
this.#devices = [...handles];
|
|
3764
|
+
await Promise.all(previous.map((d) => d.close()));
|
|
3765
|
+
return this.#devices;
|
|
3766
|
+
}
|
|
3767
|
+
/** Tear everything down: close every device push and clear timers. */
|
|
3768
|
+
async close() {
|
|
3769
|
+
this.#closed = true;
|
|
3770
|
+
const devices = this.#devices;
|
|
3771
|
+
this.#devices = [];
|
|
3772
|
+
await Promise.all(devices.map((d) => d.close()));
|
|
3773
|
+
this.removeAllListeners();
|
|
3774
|
+
}
|
|
3775
|
+
#requireSession() {
|
|
3776
|
+
if (!this.#session) {
|
|
3777
|
+
throw new DreameAuthError("no active session \u2014 call login() first");
|
|
3778
|
+
}
|
|
3779
|
+
return this.#session;
|
|
3780
|
+
}
|
|
3781
|
+
async #adoptSession(session) {
|
|
3782
|
+
this.#session = session;
|
|
3783
|
+
await this.#propagateSession(session);
|
|
3784
|
+
}
|
|
3785
|
+
/** Push the refreshed token to every live device push. */
|
|
3786
|
+
async #propagateSession(session) {
|
|
3787
|
+
if (this.#closed) {
|
|
3788
|
+
return;
|
|
3789
|
+
}
|
|
3790
|
+
await Promise.all(this.#devices.map((d) => d.applySession(session)));
|
|
3791
|
+
}
|
|
3792
|
+
};
|
|
3793
|
+
// Annotate the CommonJS export names for ESM import in node:
|
|
3794
|
+
0 && (module.exports = {
|
|
3795
|
+
BaseDevice,
|
|
3796
|
+
ChargingStatus,
|
|
3797
|
+
CleaningMode,
|
|
3798
|
+
DefaultCapabilityResolver,
|
|
3799
|
+
DreameApiError,
|
|
3800
|
+
DreameAuthError,
|
|
3801
|
+
DreameDeviceOfflineError,
|
|
3802
|
+
DreameError,
|
|
3803
|
+
DreameTransportError,
|
|
3804
|
+
LIBRARY_NAME,
|
|
3805
|
+
MOWER_MODEL_CAPABILITIES,
|
|
3806
|
+
MiotError,
|
|
3807
|
+
MiotState,
|
|
3808
|
+
MowerCapabilityResolver,
|
|
3809
|
+
MowerChargingStatus,
|
|
3810
|
+
MowerControlAction,
|
|
3811
|
+
MowerDevice,
|
|
3812
|
+
MowerStatus,
|
|
3813
|
+
MowerTaskStatus,
|
|
3814
|
+
Nodreame,
|
|
3815
|
+
SuctionLevel,
|
|
3816
|
+
TaskStatus,
|
|
3817
|
+
VACUUM_MODEL_CAPABILITIES,
|
|
3818
|
+
VacuumCapabilityResolver,
|
|
3819
|
+
VacuumDevice,
|
|
3820
|
+
WaterVolume,
|
|
3821
|
+
getMowerCapabilities,
|
|
3822
|
+
getVacuumCapabilities,
|
|
3823
|
+
renderMowerSvg,
|
|
3824
|
+
renderVacuumPng,
|
|
3825
|
+
resolveCapabilities
|
|
3826
|
+
});
|
|
3827
|
+
//# sourceMappingURL=index.cjs.map
|