@apocaliss92/nodedreame 1.2.0 → 1.3.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/README.md CHANGED
@@ -288,6 +288,54 @@ parser and SVG renderer from
288
288
  [antondaubert/dreame-mower](https://github.com/antondaubert/dreame-mower). Both
289
289
  are MIT — see `LICENSE`.
290
290
 
291
+ ## Diagnostic dump (read-only)
292
+
293
+ `nodedreame` can record what a device exposes while it operates and export an
294
+ **anonymized** JSON you can attach to a GitHub issue to help map undocumented
295
+ codes (e.g. mower `taskStatus` 2/3/10/13). The dumper is strictly **read-only** —
296
+ it never sends a command and never wakes the robot to act. It only subscribes to
297
+ the device's event stream (`on`/`off`), reads the cache (`getProperty`), and
298
+ pulls the cloud shadow (`refreshFromCache`).
299
+
300
+ ```ts
301
+ import { Nodreame, createDumper } from '@apocaliss92/nodedreame';
302
+
303
+ const client = new Nodreame({ username, password, region: 'eu' });
304
+ await client.login();
305
+ const [device] = await client.discoverDevices();
306
+
307
+ const dumper = createDumper(device);
308
+ await dumper.start(); // hooks the live stream + periodic cloud-shadow read
309
+ // ...operate the device normally for a few minutes...
310
+ await dumper.stop();
311
+
312
+ console.log(dumper.exportJson()); // pretty, anonymized — safe to share
313
+ await client.close();
314
+ ```
315
+
316
+ What it captures: per-property distinct value-sets with an `unmapped` flag
317
+ (values that match no known enum — the highest-value signal), MIoT events, and a
318
+ static command/capability catalog. What it strips: device/account ids, tokens,
319
+ MAC, serial, Wi-Fi/IP, GPS, room names, and any custom device name (all →
320
+ `[redacted]`). `firmware`/`region` are omitted (not surfaced by the device
321
+ handle yet).
322
+
323
+ Dump a whole account at once with `createClientDumper(client)`, which returns one
324
+ dumper per discovered device:
325
+
326
+ ```ts
327
+ import { createClientDumper } from '@apocaliss92/nodedreame';
328
+
329
+ const dumpers = createClientDumper(client);
330
+ await Promise.all(dumpers.map((d) => d.start()));
331
+ // ...observe...
332
+ await Promise.all(dumpers.map((d) => d.stop()));
333
+ const dumps = dumpers.map((d) => d.exportJson());
334
+ ```
335
+
336
+ Share the exported JSON in a library issue — maintainers diff it against the enum
337
+ tables to label new codes and fold them into the library.
338
+
291
339
  ## Install
292
340
 
293
341
  ```bash
package/dist/index.cjs CHANGED
@@ -57,6 +57,8 @@ __export(index_exports, {
57
57
  VacuumCapabilityResolver: () => VacuumCapabilityResolver,
58
58
  VacuumDevice: () => VacuumDevice,
59
59
  WaterVolume: () => WaterVolume,
60
+ createClientDumper: () => createClientDumper,
61
+ createDumper: () => createDumper,
60
62
  getMowerCapabilities: () => getMowerCapabilities,
61
63
  getVacuumCapabilities: () => getVacuumCapabilities,
62
64
  renderMowerSvg: () => renderMowerSvg,
@@ -67,6 +69,7 @@ module.exports = __toCommonJS(index_exports);
67
69
 
68
70
  // src/support/version.ts
69
71
  var LIBRARY_NAME = "nodedreame";
72
+ var LIBRARY_VERSION = "1.3.0";
70
73
 
71
74
  // src/transport/errors.ts
72
75
  var DreameError = class extends Error {
@@ -2836,11 +2839,11 @@ var VacuumDevice = class _VacuumDevice extends BaseDevice {
2836
2839
  }
2837
2840
  this.#requireCap(this.#caps.canCleanPerRoom, "cleanZones", "per-room cleaning");
2838
2841
  const { repeats, fan, water } = this.#resolveCleanOpts(opts);
2839
- const areas = zones.map((z3) => [
2840
- Math.round(z3.x0),
2841
- Math.round(z3.y0),
2842
- Math.round(z3.x1),
2843
- Math.round(z3.y1),
2842
+ const areas = zones.map((z4) => [
2843
+ Math.round(z4.x0),
2844
+ Math.round(z4.y0),
2845
+ Math.round(z4.x1),
2846
+ Math.round(z4.y1),
2844
2847
  repeats,
2845
2848
  fan,
2846
2849
  water
@@ -3925,7 +3928,7 @@ var MowerDevice = class _MowerDevice extends BaseDevice {
3925
3928
  if (zoneIds.length === 0) {
3926
3929
  throw new RangeError("startMowingZones: zoneIds must not be empty");
3927
3930
  }
3928
- return this.#sendTask(buildZonePayload(zoneIds.map((z3) => Math.trunc(z3))));
3931
+ return this.#sendTask(buildZonePayload(zoneIds.map((z4) => Math.trunc(z4))));
3929
3932
  }
3930
3933
  /** Edge / contour mowing (2:50 o:101). Contour ids are two-int pairs [[1,0]]. */
3931
3934
  async startMowingEdges(contourIds) {
@@ -4149,6 +4152,429 @@ var Nodreame = class extends TypedEmitter {
4149
4152
  await Promise.all(this.#devices.map((d) => d.applySession(session)));
4150
4153
  }
4151
4154
  };
4155
+
4156
+ // src/diagnostics/redact.ts
4157
+ var REDACTED = "[redacted]";
4158
+ var SENSITIVE_KEY_FRAGMENTS = [
4159
+ // identity / secrets
4160
+ "did",
4161
+ "uid",
4162
+ "token",
4163
+ // accessToken, refreshToken, refresh_token, token_type stripped too (safe)
4164
+ "mac",
4165
+ "serial",
4166
+ "email",
4167
+ "account",
4168
+ "password",
4169
+ "passwd",
4170
+ "secret",
4171
+ "authorization",
4172
+ "auth",
4173
+ "credential",
4174
+ "apikey",
4175
+ "api_key",
4176
+ "clientid",
4177
+ "client_id",
4178
+ // location / PII
4179
+ "gps",
4180
+ "coordinate",
4181
+ "latitude",
4182
+ "longitude",
4183
+ "lat",
4184
+ "lon",
4185
+ "lng",
4186
+ "ssid",
4187
+ "wifi",
4188
+ "bssid",
4189
+ "ipaddr",
4190
+ "localip",
4191
+ "binddomain",
4192
+ "host",
4193
+ "address",
4194
+ "room",
4195
+ "area_name",
4196
+ "areaname",
4197
+ "segmentname",
4198
+ "segment_name",
4199
+ // map binary / geometry (location-revealing)
4200
+ "map_info",
4201
+ "mapinfo",
4202
+ "mapblob",
4203
+ // free-text device names that may carry PII. NOTE: the bare fragment `name` is
4204
+ // intentionally NOT listed — it would over-match the catalog's command `name`
4205
+ // field (an enum-derived, non-sensitive label). The specific custom-name
4206
+ // fields below cover every PII-bearing case.
4207
+ "customname",
4208
+ "devicename",
4209
+ "nickname"
4210
+ ];
4211
+ var EXACT_SENSITIVE_KEYS = /* @__PURE__ */ new Set(["ip"]);
4212
+ function isSensitiveKey(key2) {
4213
+ const lower = key2.toLowerCase();
4214
+ if (EXACT_SENSITIVE_KEYS.has(lower)) {
4215
+ return true;
4216
+ }
4217
+ return SENSITIVE_KEY_FRAGMENTS.some((frag) => lower.includes(frag));
4218
+ }
4219
+ function isPlainObject(v) {
4220
+ return typeof v === "object" && v !== null && !Array.isArray(v);
4221
+ }
4222
+ function redact(value) {
4223
+ if (Array.isArray(value)) {
4224
+ return value.map((v) => redact(v));
4225
+ }
4226
+ if (isPlainObject(value)) {
4227
+ const out = {};
4228
+ for (const [k, v] of Object.entries(value)) {
4229
+ out[k] = isSensitiveKey(k) ? REDACTED : redact(v);
4230
+ }
4231
+ return out;
4232
+ }
4233
+ return value;
4234
+ }
4235
+
4236
+ // src/diagnostics/dump-format.ts
4237
+ var import_zod3 = require("zod");
4238
+ var DumpScalarSchema = import_zod3.z.union([import_zod3.z.string(), import_zod3.z.number(), import_zod3.z.boolean()]);
4239
+ var PropertyObservationSchema = import_zod3.z.object({
4240
+ values: import_zod3.z.array(DumpScalarSchema),
4241
+ unmapped: import_zod3.z.array(DumpScalarSchema),
4242
+ enum: import_zod3.z.string().optional(),
4243
+ count: import_zod3.z.number(),
4244
+ firstSeen: import_zod3.z.number(),
4245
+ lastSeen: import_zod3.z.number()
4246
+ });
4247
+ var EventObservationSchema = import_zod3.z.object({
4248
+ at: import_zod3.z.number(),
4249
+ type: import_zod3.z.string(),
4250
+ data: import_zod3.z.unknown().optional()
4251
+ });
4252
+ var RawFrameSchema = import_zod3.z.object({
4253
+ at: import_zod3.z.number(),
4254
+ source: import_zod3.z.string(),
4255
+ payload: import_zod3.z.unknown()
4256
+ });
4257
+ var CommandSchema = import_zod3.z.object({
4258
+ name: import_zod3.z.string(),
4259
+ siid: import_zod3.z.number().optional(),
4260
+ aiid: import_zod3.z.number().optional()
4261
+ });
4262
+ var SensorSchema = import_zod3.z.object({
4263
+ model: import_zod3.z.string(),
4264
+ channel: import_zod3.z.number().optional()
4265
+ });
4266
+ var DeviceDumpSchema = import_zod3.z.object({
4267
+ schemaVersion: import_zod3.z.literal(1),
4268
+ library: import_zod3.z.union([import_zod3.z.literal("nodedreame"), import_zod3.z.literal("nodewitt")]),
4269
+ libraryVersion: import_zod3.z.string(),
4270
+ device: import_zod3.z.object({
4271
+ model: import_zod3.z.string(),
4272
+ firmware: import_zod3.z.string().optional(),
4273
+ region: import_zod3.z.string().optional(),
4274
+ type: import_zod3.z.string().optional()
4275
+ }),
4276
+ observations: import_zod3.z.object({
4277
+ properties: import_zod3.z.record(import_zod3.z.string(), PropertyObservationSchema),
4278
+ events: import_zod3.z.array(EventObservationSchema),
4279
+ rawFrames: import_zod3.z.array(RawFrameSchema).optional()
4280
+ }),
4281
+ catalog: import_zod3.z.object({
4282
+ commands: import_zod3.z.array(CommandSchema).optional(),
4283
+ capabilities: import_zod3.z.record(import_zod3.z.string(), import_zod3.z.unknown()).optional(),
4284
+ sensors: import_zod3.z.array(SensorSchema).optional()
4285
+ }),
4286
+ meta: import_zod3.z.object({
4287
+ startedAt: import_zod3.z.number(),
4288
+ durationMs: import_zod3.z.number(),
4289
+ generatedAt: import_zod3.z.number()
4290
+ })
4291
+ });
4292
+
4293
+ // src/diagnostics/dumper.ts
4294
+ function commandsForFamily(family) {
4295
+ const commands = [];
4296
+ if (family === "vacuum") {
4297
+ for (const [name, ref] of Object.entries(VACUUM_ACTION)) {
4298
+ commands.push({ name, siid: ref.siid, aiid: ref.aiid });
4299
+ }
4300
+ } else if (family === "mower") {
4301
+ for (const [name, ref] of Object.entries(MOWER_ACTION)) {
4302
+ commands.push({ name, siid: ref.siid, aiid: ref.aiid });
4303
+ }
4304
+ }
4305
+ return commands;
4306
+ }
4307
+ function decoderFor(enumName, members) {
4308
+ const lookup = enumLookup(members);
4309
+ return { enumName, isMember: (raw) => lookup(raw) !== null };
4310
+ }
4311
+ function vacuumDecoders() {
4312
+ return {
4313
+ "2.1": decoderFor(
4314
+ "MiotState",
4315
+ Object.values(MiotState).filter((v) => typeof v === "number")
4316
+ ),
4317
+ "3.2": decoderFor(
4318
+ "ChargingStatus",
4319
+ Object.values(ChargingStatus).filter((v) => typeof v === "number")
4320
+ ),
4321
+ "4.4": decoderFor(
4322
+ "SuctionLevel",
4323
+ Object.values(SuctionLevel).filter((v) => typeof v === "number")
4324
+ ),
4325
+ "4.5": decoderFor(
4326
+ "WaterVolume",
4327
+ Object.values(WaterVolume).filter((v) => typeof v === "number")
4328
+ ),
4329
+ "2.6": decoderFor(
4330
+ "CleaningMode",
4331
+ Object.values(CleaningMode).filter((v) => typeof v === "number")
4332
+ ),
4333
+ "4.1": decoderFor(
4334
+ "TaskStatus",
4335
+ Object.values(TaskStatus).filter((v) => typeof v === "number")
4336
+ )
4337
+ };
4338
+ }
4339
+ function mowerDecoders() {
4340
+ return {
4341
+ "2.1": decoderFor(
4342
+ "MowerStatus",
4343
+ Object.values(MowerStatus).filter((v) => typeof v === "number")
4344
+ ),
4345
+ "3.2": decoderFor(
4346
+ "MowerChargingStatus",
4347
+ Object.values(MowerChargingStatus).filter(
4348
+ (v) => typeof v === "number"
4349
+ )
4350
+ ),
4351
+ "5.104": decoderFor(
4352
+ "MowerTaskStatus",
4353
+ Object.values(MowerTaskStatus).filter((v) => typeof v === "number")
4354
+ ),
4355
+ "2.2": decoderFor(
4356
+ "MowerFault",
4357
+ Object.values(MowerFault).filter((v) => typeof v === "number")
4358
+ )
4359
+ };
4360
+ }
4361
+ function toScalar(value) {
4362
+ if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") {
4363
+ return value;
4364
+ }
4365
+ return null;
4366
+ }
4367
+ var PropertyAccumulator = class {
4368
+ #decoders;
4369
+ #entries = /* @__PURE__ */ new Map();
4370
+ constructor(decoders = {}) {
4371
+ this.#decoders = decoders;
4372
+ }
4373
+ record(siid, piid, rawValue, at) {
4374
+ const key2 = `${siid}.${piid}`;
4375
+ const scalar = toScalar(rawValue);
4376
+ let entry = this.#entries.get(key2);
4377
+ if (!entry) {
4378
+ entry = { values: [], unmapped: [], count: 0, firstSeen: at, lastSeen: at };
4379
+ this.#entries.set(key2, entry);
4380
+ }
4381
+ entry.count += 1;
4382
+ entry.lastSeen = at;
4383
+ if (scalar === null) {
4384
+ return;
4385
+ }
4386
+ if (!entry.values.includes(scalar)) {
4387
+ entry.values.push(scalar);
4388
+ }
4389
+ const decoder = this.#decoders[key2];
4390
+ if (decoder && typeof scalar === "number" && !decoder.isMember(scalar)) {
4391
+ if (!entry.unmapped.includes(scalar)) {
4392
+ entry.unmapped.push(scalar);
4393
+ }
4394
+ }
4395
+ }
4396
+ snapshot() {
4397
+ const out = {};
4398
+ for (const [key2, e] of this.#entries) {
4399
+ const decoder = this.#decoders[key2];
4400
+ const base = {
4401
+ values: [...e.values],
4402
+ unmapped: [...e.unmapped],
4403
+ count: e.count,
4404
+ firstSeen: e.firstSeen,
4405
+ lastSeen: e.lastSeen
4406
+ };
4407
+ out[key2] = decoder ? { ...base, enum: decoder.enumName } : base;
4408
+ }
4409
+ return out;
4410
+ }
4411
+ };
4412
+ var DEFAULT_REFRESH_INTERVAL_MS = 3e4;
4413
+ var DEFAULT_MAX_RAW_FRAMES = 500;
4414
+ var Dumper = class {
4415
+ #device;
4416
+ #opts;
4417
+ #acc;
4418
+ #events = [];
4419
+ #rawFrames = [];
4420
+ #catalog;
4421
+ #type;
4422
+ #onProperty;
4423
+ #onEvent;
4424
+ #timer = null;
4425
+ #startedAt = 0;
4426
+ #started = false;
4427
+ constructor(device, catalog, type, decoders, options = {}) {
4428
+ this.#device = device;
4429
+ this.#catalog = catalog;
4430
+ this.#type = type;
4431
+ this.#acc = new PropertyAccumulator(decoders);
4432
+ this.#opts = {
4433
+ captureRawFrames: options.captureRawFrames ?? false,
4434
+ maxRawFrames: options.maxRawFrames ?? DEFAULT_MAX_RAW_FRAMES,
4435
+ refreshIntervalMs: options.refreshIntervalMs ?? DEFAULT_REFRESH_INTERVAL_MS
4436
+ };
4437
+ this.#onProperty = (e) => {
4438
+ this.#acc.record(e.siid, e.piid, e.value, Date.now());
4439
+ this.#pushRaw("mqtt:property", e);
4440
+ };
4441
+ this.#onEvent = (e) => {
4442
+ this.#events.push({
4443
+ at: Date.now(),
4444
+ type: `${e.siid}.${e.eiid}`,
4445
+ data: { arguments: e.arguments }
4446
+ });
4447
+ this.#pushRaw("mqtt:event", e);
4448
+ };
4449
+ }
4450
+ /** Attach to the live stream + arm the periodic shadow refresh. Idempotent. */
4451
+ async start() {
4452
+ if (this.#started) {
4453
+ return;
4454
+ }
4455
+ this.#started = true;
4456
+ this.#startedAt = Date.now();
4457
+ this.#device.on("propertyChanged", this.#onProperty);
4458
+ this.#device.on("event", this.#onEvent);
4459
+ await this.#refresh();
4460
+ if (this.#opts.refreshIntervalMs > 0 && this.#device.refreshFromCache) {
4461
+ this.#timer = setInterval(() => void this.#refresh(), this.#opts.refreshIntervalMs);
4462
+ this.#timer.unref?.();
4463
+ }
4464
+ }
4465
+ /** Detach every listener + clear the timer. Idempotent. */
4466
+ stop() {
4467
+ if (!this.#started) {
4468
+ return Promise.resolve();
4469
+ }
4470
+ this.#started = false;
4471
+ if (this.#timer) {
4472
+ clearInterval(this.#timer);
4473
+ this.#timer = null;
4474
+ }
4475
+ this.#device.off("propertyChanged", this.#onProperty);
4476
+ this.#device.off("event", this.#onEvent);
4477
+ return Promise.resolve();
4478
+ }
4479
+ /** Build the anonymized, schema-valid dump. Works live or after {@link stop}. */
4480
+ export() {
4481
+ const now = Date.now();
4482
+ const dump = {
4483
+ schemaVersion: 1,
4484
+ library: "nodedreame",
4485
+ libraryVersion: LIBRARY_VERSION,
4486
+ device: {
4487
+ model: this.#device.model,
4488
+ type: this.#type
4489
+ },
4490
+ observations: {
4491
+ properties: this.#acc.snapshot(),
4492
+ events: [...this.#events],
4493
+ ...this.#opts.captureRawFrames ? { rawFrames: this.#redactFrames() } : {}
4494
+ },
4495
+ catalog: this.#catalog,
4496
+ meta: {
4497
+ startedAt: this.#startedAt,
4498
+ durationMs: Math.max(0, now - this.#startedAt),
4499
+ generatedAt: now
4500
+ }
4501
+ };
4502
+ const anonymized = redact(dump);
4503
+ return DeviceDumpSchema.parse(anonymized);
4504
+ }
4505
+ /** Deterministic pretty JSON of {@link export}. */
4506
+ exportJson() {
4507
+ return JSON.stringify(this.export(), null, 2);
4508
+ }
4509
+ async #refresh() {
4510
+ if (this.#device.refreshFromCache) {
4511
+ try {
4512
+ await this.#device.refreshFromCache();
4513
+ } catch {
4514
+ }
4515
+ }
4516
+ }
4517
+ #pushRaw(source, payload) {
4518
+ if (!this.#opts.captureRawFrames) {
4519
+ return;
4520
+ }
4521
+ this.#rawFrames.push({ at: Date.now(), source, payload });
4522
+ if (this.#rawFrames.length > this.#opts.maxRawFrames) {
4523
+ this.#rawFrames.shift();
4524
+ }
4525
+ }
4526
+ #redactFrames() {
4527
+ return this.#rawFrames.map((f) => ({ at: f.at, source: f.source, payload: redact(f.payload) }));
4528
+ }
4529
+ };
4530
+ function familyOf(device) {
4531
+ if (device instanceof VacuumDevice) {
4532
+ return "vacuum";
4533
+ }
4534
+ if (device instanceof MowerDevice) {
4535
+ return "mower";
4536
+ }
4537
+ if (!(device instanceof BaseDevice)) {
4538
+ if (device.model.startsWith("dreame.vacuum.")) {
4539
+ return "vacuum";
4540
+ }
4541
+ if (device.model.startsWith("dreame.mower.")) {
4542
+ return "mower";
4543
+ }
4544
+ }
4545
+ return "device";
4546
+ }
4547
+ function decodersFor(device) {
4548
+ switch (familyOf(device)) {
4549
+ case "vacuum":
4550
+ return vacuumDecoders();
4551
+ case "mower":
4552
+ return mowerDecoders();
4553
+ default:
4554
+ return {};
4555
+ }
4556
+ }
4557
+ function typeFor(device) {
4558
+ return familyOf(device);
4559
+ }
4560
+ function catalogFor(device) {
4561
+ return {
4562
+ commands: commandsForFamily(familyOf(device)),
4563
+ capabilities: { tokens: [...device.capabilities.list()] }
4564
+ };
4565
+ }
4566
+ function createDumper(target, options) {
4567
+ return new Dumper(
4568
+ target,
4569
+ catalogFor(target),
4570
+ typeFor(target),
4571
+ decodersFor(target),
4572
+ options ?? {}
4573
+ );
4574
+ }
4575
+ function createClientDumper(client, options) {
4576
+ return client.devices.map((d) => createDumper(d, options));
4577
+ }
4152
4578
  // Annotate the CommonJS export names for ESM import in node:
4153
4579
  0 && (module.exports = {
4154
4580
  BaseDevice,
@@ -4178,6 +4604,8 @@ var Nodreame = class extends TypedEmitter {
4178
4604
  VacuumCapabilityResolver,
4179
4605
  VacuumDevice,
4180
4606
  WaterVolume,
4607
+ createClientDumper,
4608
+ createDumper,
4181
4609
  getMowerCapabilities,
4182
4610
  getVacuumCapabilities,
4183
4611
  renderMowerSvg,