@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 ADDED
@@ -0,0 +1,24 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2022 Tasshack (dreame-vacuum)
4
+ Copyright (c) 2025 Anton Daubert (dreame-mower)
5
+ Copyright (c) 2026 Martin Ellis (node-dreame)
6
+ Copyright (c) 2026 apocaliss92 (nodedreame Node.js port)
7
+
8
+ Permission is hereby granted, free of charge, to any person obtaining a copy
9
+ of this software and associated documentation files (the "Software"), to deal
10
+ in the Software without restriction, including without limitation the rights
11
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
12
+ copies of the Software, and to permit persons to whom the Software is
13
+ furnished to do so, subject to the following conditions:
14
+
15
+ The above copyright notice and this permission notice shall be included in all
16
+ copies or substantial portions of the Software.
17
+
18
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
19
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
20
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
21
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
22
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
23
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
24
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,301 @@
1
+ # nodedreame
2
+
3
+ Node.js/TypeScript client for Dreame robot vacuums and mowers via the Dreamehome cloud.
4
+
5
+ > Work in progress. Unified, event-driven library — not a UI.
6
+
7
+ ## Status
8
+
9
+ Phase 4 complete: on top of the Phase 2 generic handle, `discoverDevices()` now
10
+ returns a typed `VacuumDevice` for `dreame.vacuum.*` models and a typed
11
+ `MowerDevice` for `dreame.mower.*` models, each with decoded state getters and
12
+ capability-gated commands. Live map / pose-coverage track decoding (Phase 5) is
13
+ not implemented yet.
14
+
15
+ Under the hood:
16
+
17
+ - OAuth password-grant login with proactive token refresh (the shared session
18
+ is refreshed ~100s before expiry, and every device's MQTT push is
19
+ re-authenticated with the new token transparently).
20
+ - Per-device MQTT push with **durable reconnect** — it reconnects with backoff
21
+ on an unexpected drop and rebuilds the connection with a fresh token on
22
+ refresh. A `get_properties` poll fallback runs only while the push is down and
23
+ stops once it reconnects.
24
+ - All cloud and MQTT responses are validated with `zod` at the boundary, so a
25
+ malformed response fails fast with a clear error instead of propagating
26
+ `undefined`.
27
+
28
+ ## Usage
29
+
30
+ ```ts
31
+ import { Nodreame } from '@apocaliss92/nodedreame';
32
+
33
+ const client = new Nodreame({
34
+ username: process.env.DREAME_USERNAME!,
35
+ password: process.env.DREAME_PASSWORD!,
36
+ region: 'eu',
37
+ });
38
+
39
+ await client.login();
40
+ const devices = await client.discoverDevices();
41
+
42
+ for (const device of devices) {
43
+ console.log(device.deviceId, device.model, device.name);
44
+
45
+ // Live-read a couple of MIoT properties (siid.piid are model-specific).
46
+ await device.refreshProperties([{ siid: 2, piid: 1 }]);
47
+ console.log('state:', device.getProperty(2, 1)?.value);
48
+
49
+ // React to pushed updates.
50
+ device.on('stateChanged', (e) => console.log('changed', e.changes));
51
+ }
52
+
53
+ // The shared session auto-refreshes ~100s before expiry; every device's MQTT
54
+ // push is re-authenticated with the new token transparently.
55
+
56
+ await client.close(); // closes all pushes, clears all timers
57
+ ```
58
+
59
+ > The generic MIoT primitives stay available on every handle:
60
+ > `refreshProperties` / `getProperty` (cache) / `setProperty` / `callAction`.
61
+ > Mower (`startMowing`/schedules) handles and live maps arrive in later phases.
62
+
63
+ ## Vacuums
64
+
65
+ For `dreame.vacuum.*` models, `discoverDevices()` returns a `VacuumDevice` (a
66
+ subclass of the generic handle), which adds typed, decoded state getters and
67
+ capability-gated commands on top of the raw MIoT primitives.
68
+
69
+ ```ts
70
+ import { Nodreame, VacuumDevice, SuctionLevel } from '@apocaliss92/nodedreame';
71
+
72
+ const client = new Nodreame({
73
+ username: process.env.DREAME_USERNAME!,
74
+ password: process.env.DREAME_PASSWORD!,
75
+ region: 'eu',
76
+ });
77
+
78
+ await client.login();
79
+ const devices = await client.discoverDevices();
80
+
81
+ const vacuum = devices.find((d): d is VacuumDevice => d instanceof VacuumDevice);
82
+ if (vacuum) {
83
+ // Seed the cache with the vacuum's known properties (one live read).
84
+ await vacuum.refreshProperties([...VacuumDevice.DEFAULT_PROPS]);
85
+
86
+ // Typed, decoded state. Each getter returns null until the matching
87
+ // property has landed (via the seed read above or a pushed update).
88
+ console.log('status:', vacuum.status); // MiotState | null
89
+ console.log('battery:', vacuum.battery); // number | null (%)
90
+ console.log('suction:', vacuum.suction); // SuctionLevel | null
91
+ console.log('water:', vacuum.water); // WaterVolume | null
92
+ console.log('docked:', vacuum.isDocked);
93
+ console.log('faults:', vacuum.faults); // number[]
94
+
95
+ // Capability-gated commands (all async).
96
+ await vacuum.setSuction(SuctionLevel.Max);
97
+ await vacuum.startCleaning(); // begins a clean (NOT the lifecycle start())
98
+ // await vacuum.pause();
99
+ // await vacuum.stop();
100
+ // await vacuum.dock(); // return to dock / charge
101
+ // await vacuum.locate(); // make the robot beep
102
+ // await vacuum.cleanSegments([1, 2]); // per-room, gated by canCleanPerRoom
103
+ }
104
+
105
+ await client.close();
106
+ ```
107
+
108
+ > **`startCleaning()`, not `start()`.** `start()` is the inherited lifecycle
109
+ > method that opens the MQTT push; the cleaning command is `startCleaning()`.
110
+ > All command methods are `async`.
111
+
112
+ ### Honest caveats
113
+
114
+ - **State getters return `null` until data lands.** Call
115
+ `refreshProperties([...VacuumDevice.DEFAULT_PROPS])` (or wait for a pushed
116
+ update) before reading; an unseeded getter is `null`, and an out-of-range raw
117
+ value also decodes to `null` (the raw integer is still available via the
118
+ `*Raw` getters, e.g. `vacuum.suctionRaw`).
119
+ - **`r2538z` capabilities are assumed, not verified.** The user's
120
+ `dreame.vacuum.r2538z` capability record is mirrored from its `r2532a` (X50)
121
+ sibling, so `vacuum.vacuumCapabilities.verified === false`. Treat its feature
122
+ flags as a best-effort hypothesis until confirmed on-device.
123
+ - **Clean-mode writes are safe by construction.** `setCleaningMode()` writes the
124
+ plain `CLEAN_MODE_SETTING` property (siid 2 piid 6); the raw `0x1400`-masked
125
+ bitfield (siid 4 piid 23) is read-only here and never written directly.
126
+ - **Some action mappings are assumed.** `pause`/`stop`/`locate`/`clearWarning`
127
+ are wire-verified on the r2532a sibling; `startCleaning`/`dock` and the
128
+ targeted-clean payloads (`cleanSegments`/`cleanZones`/`cleanSpot`) are ported
129
+ from Tasshack and not yet live-verified across all models.
130
+ - **No map-derived state yet.** Per-room/current-segment data and live maps come
131
+ from the map layer (Phase 5); they are not exposed in Phase 3.
132
+
133
+ ## Mowers
134
+
135
+ For `dreame.mower.*` models, `discoverDevices()` returns a `MowerDevice` (a
136
+ subclass of the generic handle) with typed, decoded state getters and
137
+ capability-gated commands, mirroring the vacuum surface.
138
+
139
+ ```ts
140
+ import { Nodreame, MowerDevice } from '@apocaliss92/nodedreame';
141
+
142
+ const client = new Nodreame({
143
+ username: process.env.DREAME_USERNAME!,
144
+ password: process.env.DREAME_PASSWORD!,
145
+ region: 'eu',
146
+ });
147
+
148
+ await client.login();
149
+ const devices = await client.discoverDevices();
150
+
151
+ const mower = devices.find((d): d is MowerDevice => d instanceof MowerDevice);
152
+ if (mower) {
153
+ // Seed the cache with the mower's known properties (one live read).
154
+ await mower.refreshProperties([...MowerDevice.DEFAULT_PROPS]);
155
+
156
+ // Typed, decoded state. Each getter returns null until the matching
157
+ // property has landed (via the seed read above or a pushed update).
158
+ console.log('status:', mower.status); // MowerStatus | null
159
+ console.log('battery:', mower.battery); // number | null (%)
160
+ console.log('charging:', mower.charging); // MowerChargingStatus | null
161
+ console.log('docked:', mower.isDocked); // boolean
162
+ console.log('mowing:', mower.isMowing); // boolean
163
+ console.log('task:', mower.task); // MowerTaskDescriptor | null (2:50)
164
+ console.log('coverage target %:', mower.coverageTargetPct); // number | null
165
+ console.log('control action:', mower.controlAction); // MowerControlAction | null
166
+
167
+ // Capability-gated commands (all async).
168
+ await mower.startMowing(); // begins mowing (NOT the lifecycle start())
169
+ // await mower.pause();
170
+ // await mower.stop();
171
+ // await mower.dock(); // return to dock / charge
172
+ // await mower.resume(); // resume after a pause (continueControl opcode)
173
+ // Targeted starts (gated by the model's capability flags):
174
+ // await mower.startMowingAllArea(mapId); // whole map
175
+ // await mower.startMowingZones([1, 3]); // selected zones
176
+ // await mower.startMowingEdges([[1, 0]]); // edge / contour pairs
177
+ // await mower.startMowingSpots([5]); // spot areas
178
+ }
179
+
180
+ await client.close();
181
+ ```
182
+
183
+ > **`startMowing()`, not `start()`.** `start()` is the inherited lifecycle
184
+ > method that opens the MQTT push; the mowing command is `startMowing()`. All
185
+ > command methods are `async`.
186
+
187
+ ### Honest caveats
188
+
189
+ - **State getters return `null` until data lands.** Call
190
+ `refreshProperties([...MowerDevice.DEFAULT_PROPS])` (or wait for a pushed
191
+ update) before reading; an unseeded getter is `null`, and an out-of-range raw
192
+ value also decodes to `null` (the raw integer is still available via the
193
+ `*Raw` getters, e.g. `mower.statusRaw`, `mower.chargingRaw`,
194
+ `mower.taskStatusRaw`).
195
+ - **`dreame.mower.p2255` (Dreame A1) capabilities are assumed, not verified.**
196
+ The donor integration has no per-model mower capability matrix, so the
197
+ targeted-mowing flags are a conservative hypothesis from its command surface;
198
+ `mower.mowerCapabilities.verified === false` until confirmed on-device.
199
+ Unsupported targeted starts throw `DreameError`.
200
+ - **Progress is a coverage scalar, not a map track.** `coverageTargetPct`
201
+ surfaces the scheduling-task descriptor's coverage target (`d.o`, 2:50); the
202
+ byte-accurate pose/coverage track geometry and live maps come from the map
203
+ layer (Phase 5) and are not decoded here.
204
+
205
+ The error classes and core domain types are also exported so consumers can catch
206
+ and type cloud failures:
207
+
208
+ ```ts
209
+ import {
210
+ DreameError,
211
+ DreameAuthError,
212
+ DreameApiError,
213
+ DreameDeviceOfflineError,
214
+ DreameTransportError,
215
+ } from '@apocaliss92/nodedreame';
216
+ import type { DreameSession, DreameDevice, MiotProp } from '@apocaliss92/nodedreame';
217
+ ```
218
+
219
+ ## Maps
220
+
221
+ Both device families decode their on-device map and render it to an image. The
222
+ binary/JSON decoders, the OSS signed-blob fetcher and every intermediate step
223
+ stay private — you obtain maps through the device handles and (optionally) the
224
+ two renderers.
225
+
226
+ ### Vacuum map → PNG
227
+
228
+ `VacuumDevice.getMap()` resolves a saved/live map blob, decrypts and inflates
229
+ the binary envelope, parses the 27-byte header and the `fsm:1` pixel grid, and
230
+ returns a structured `VacuumMap` — segments/rooms, the cleaning path, AI
231
+ obstacles, virtual walls, no-go / no-mop zones, sneak zones, per-room walls and
232
+ the cleaned-area overlay. `renderVacuumPng(map)` rasterises it to a PNG
233
+ `Buffer` via `pngjs`.
234
+
235
+ ```ts
236
+ import { renderVacuumPng } from '@apocaliss92/nodedreame';
237
+ import type { VacuumMap } from '@apocaliss92/nodedreame';
238
+ import { writeFile } from 'node:fs/promises';
239
+
240
+ // `filename` is the OSS object name advertised on the map PATH push (siid 6,
241
+ // piid 3); resolve it from a `mapInfo` push before calling getMap().
242
+ const map: VacuumMap = await vacuum.getMap({ filename });
243
+ const png = renderVacuumPng(map); // Buffer (optional { scale } upscale)
244
+ await writeFile('map.png', png);
245
+
246
+ // The most-recently-decoded map is cached, and the active room id is derived:
247
+ vacuum.lastMap; // VacuumMap | null
248
+ vacuum.currentSegmentId; // number | null (id of the active segment)
249
+ ```
250
+
251
+ > **Live map data requires an awake robot.** A sleeping vacuum returns no fresh
252
+ > blob. `getMap()` decodes a single frame; continuous live-frame **P-frame
253
+ > streaming** (merging delta frames as the robot moves) is a documented
254
+ > follow-up — the `applyVacuumPFrame` merge primitive ships and is unit-tested,
255
+ > so that work is additive, not a rewrite.
256
+
257
+ ### Mower map → SVG
258
+
259
+ `MowerDevice.getMap()` reassembles the batched `MAP.*` / `M_PATH.*` JSON chunks
260
+ and parses them into a `MowerMap` — zones, spot areas, forbidden areas,
261
+ navigation paths, contours, mow-path tracks and the map boundary.
262
+ `mower.mapSvg()` (or the free `renderMowerSvg(map)`) renders a deterministic SVG
263
+ string of the geometry.
264
+
265
+ ```ts
266
+ import { renderMowerSvg } from '@apocaliss92/nodedreame';
267
+ import type { MowerMap } from '@apocaliss92/nodedreame';
268
+ import { writeFile } from 'node:fs/promises';
269
+
270
+ const map: MowerMap = await mower.getMap();
271
+ const svg = await mower.mapSvg(); // or renderMowerSvg(map)
272
+ await writeFile('map.svg', svg);
273
+ ```
274
+
275
+ > **Same awake-robot caveat.** A sleeping mower returns no fresh batch.
276
+ > Additionally, the concrete **live batch-fetch cloud endpoint is a documented
277
+ > follow-up**: its path is obfuscated in the donor integration and not yet
278
+ > recovered, so the default fetcher throws (`getMap()` then rejects). The map
279
+ > **parser and SVG renderer are fully shipped and unit-tested** against batch
280
+ > fixtures, and `MowerDevice` accepts an injected batch fetcher seam, so a
281
+ > caller that already knows the path can drive a live map today.
282
+
283
+ ### Attribution
284
+
285
+ The vacuum map decoder is ported from
286
+ [malard/node-dreame](https://github.com/malard/node-dreame); the mower map
287
+ parser and SVG renderer from
288
+ [antondaubert/dreame-mower](https://github.com/antondaubert/dreame-mower). Both
289
+ are MIT — see `LICENSE`.
290
+
291
+ ## Install
292
+
293
+ ```bash
294
+ npm install @apocaliss92/nodedreame
295
+ ```
296
+
297
+ ## License
298
+
299
+ MIT. Ports prior work from [Tasshack/dreame-vacuum](https://github.com/Tasshack/dreame-vacuum),
300
+ [antondaubert/dreame-mower](https://github.com/antondaubert/dreame-mower), and
301
+ [malard/node-dreame](https://github.com/malard/node-dreame); see `LICENSE`.