@apocaliss92/nodedreame 1.2.0 → 1.3.1

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.1";
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,461 @@ 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
+ // `deviceid` is listed explicitly: the bare `did` fragment does NOT match
4162
+ // `deviceid` (the substring `did` is absent from `d-e-v-i-c-e-i-d`), yet
4163
+ // base-device sets the real `did` as `deviceId` on raw payloads (FIX 1).
4164
+ "deviceid",
4165
+ "uid",
4166
+ "token",
4167
+ // accessToken, refreshToken, refresh_token, token_type stripped too (safe)
4168
+ "mac",
4169
+ "serial",
4170
+ "email",
4171
+ "account",
4172
+ "password",
4173
+ "passwd",
4174
+ "secret",
4175
+ "authorization",
4176
+ "auth",
4177
+ "credential",
4178
+ "apikey",
4179
+ "api_key",
4180
+ "clientid",
4181
+ "client_id",
4182
+ // location / PII
4183
+ "gps",
4184
+ "coordinate",
4185
+ "latitude",
4186
+ "longitude",
4187
+ "lat",
4188
+ "lon",
4189
+ "lng",
4190
+ "ssid",
4191
+ "wifi",
4192
+ "bssid",
4193
+ "ipaddr",
4194
+ "localip",
4195
+ "binddomain",
4196
+ "host",
4197
+ "address",
4198
+ "room",
4199
+ "area_name",
4200
+ "areaname",
4201
+ "segmentname",
4202
+ "segment_name",
4203
+ // room/zone/map names are user-set PII (FIX 3).
4204
+ "zone_name",
4205
+ "zonename",
4206
+ "map_name",
4207
+ "mapname",
4208
+ // map binary / geometry (location-revealing)
4209
+ "map_info",
4210
+ "mapinfo",
4211
+ "mapblob",
4212
+ // free-text device names that may carry PII. NOTE: the bare fragment `name` is
4213
+ // intentionally NOT listed — it would over-match the catalog's command `name`
4214
+ // field (an enum-derived, non-sensitive label like `START`). ACCEPTED
4215
+ // TRADE-OFF: a bare `{ name: "..." }` in an event argument is NOT scrubbed by
4216
+ // key. This is mitigated in practice by (a) the value-sanitizer below
4217
+ // (sanitizeStringValue, applied to EVERY string scalar — it catches OSS
4218
+ // paths / URLs / tokens regardless of key), and (b) the specific
4219
+ // customName/deviceName/nickName/zoneName/mapName fragments which cover the
4220
+ // realistic PII-bearing name cases.
4221
+ "customname",
4222
+ "devicename",
4223
+ "nickname"
4224
+ ];
4225
+ var EXACT_SENSITIVE_KEYS = /* @__PURE__ */ new Set(["ip"]);
4226
+ function isSensitiveKey(key2) {
4227
+ const lower = key2.toLowerCase();
4228
+ if (EXACT_SENSITIVE_KEYS.has(lower)) {
4229
+ return true;
4230
+ }
4231
+ return SENSITIVE_KEY_FRAGMENTS.some((frag) => lower.includes(frag));
4232
+ }
4233
+ function isPlainObject(v) {
4234
+ return typeof v === "object" && v !== null && !Array.isArray(v);
4235
+ }
4236
+ var RISKY_VALUE_PATTERNS = [
4237
+ /:\/\//,
4238
+ // any URL scheme (https://, mqtts://, …) — signed URLs carry tokens
4239
+ /ali_dreame\//i,
4240
+ // OSS object path embedding uid/did
4241
+ /[A-Za-z0-9_-]{32,}/,
4242
+ // a long opaque token run (base64/hex secrets)
4243
+ /\b(did|uid|token)\s*=/i,
4244
+ // query-param secrets (?did=…&token=…)
4245
+ /access/i
4246
+ // accessKey / access-token / x-access-key style markers
4247
+ ];
4248
+ function sanitizeStringValue(s) {
4249
+ return RISKY_VALUE_PATTERNS.some((re) => re.test(s)) ? REDACTED : s;
4250
+ }
4251
+ function redact(value) {
4252
+ if (Array.isArray(value)) {
4253
+ return value.map((v) => redact(v));
4254
+ }
4255
+ if (isPlainObject(value)) {
4256
+ const out = {};
4257
+ for (const [k, v] of Object.entries(value)) {
4258
+ out[k] = isSensitiveKey(k) ? REDACTED : redact(v);
4259
+ }
4260
+ return out;
4261
+ }
4262
+ if (typeof value === "string") {
4263
+ return sanitizeStringValue(value);
4264
+ }
4265
+ return value;
4266
+ }
4267
+
4268
+ // src/diagnostics/dump-format.ts
4269
+ var import_zod3 = require("zod");
4270
+ var DumpScalarSchema = import_zod3.z.union([import_zod3.z.string(), import_zod3.z.number(), import_zod3.z.boolean()]);
4271
+ var PropertyObservationSchema = import_zod3.z.object({
4272
+ values: import_zod3.z.array(DumpScalarSchema),
4273
+ unmapped: import_zod3.z.array(DumpScalarSchema),
4274
+ enum: import_zod3.z.string().optional(),
4275
+ count: import_zod3.z.number(),
4276
+ firstSeen: import_zod3.z.number(),
4277
+ lastSeen: import_zod3.z.number()
4278
+ });
4279
+ var EventObservationSchema = import_zod3.z.object({
4280
+ at: import_zod3.z.number(),
4281
+ type: import_zod3.z.string(),
4282
+ data: import_zod3.z.unknown().optional()
4283
+ });
4284
+ var RawFrameSchema = import_zod3.z.object({
4285
+ at: import_zod3.z.number(),
4286
+ source: import_zod3.z.string(),
4287
+ payload: import_zod3.z.unknown()
4288
+ });
4289
+ var CommandSchema = import_zod3.z.object({
4290
+ name: import_zod3.z.string(),
4291
+ siid: import_zod3.z.number().optional(),
4292
+ aiid: import_zod3.z.number().optional()
4293
+ });
4294
+ var SensorSchema = import_zod3.z.object({
4295
+ model: import_zod3.z.string(),
4296
+ channel: import_zod3.z.number().optional()
4297
+ });
4298
+ var DeviceDumpSchema = import_zod3.z.object({
4299
+ schemaVersion: import_zod3.z.literal(1),
4300
+ library: import_zod3.z.union([import_zod3.z.literal("nodedreame"), import_zod3.z.literal("nodewitt")]),
4301
+ libraryVersion: import_zod3.z.string(),
4302
+ device: import_zod3.z.object({
4303
+ model: import_zod3.z.string(),
4304
+ firmware: import_zod3.z.string().optional(),
4305
+ region: import_zod3.z.string().optional(),
4306
+ type: import_zod3.z.string().optional()
4307
+ }),
4308
+ observations: import_zod3.z.object({
4309
+ properties: import_zod3.z.record(import_zod3.z.string(), PropertyObservationSchema),
4310
+ events: import_zod3.z.array(EventObservationSchema),
4311
+ rawFrames: import_zod3.z.array(RawFrameSchema).optional()
4312
+ }),
4313
+ catalog: import_zod3.z.object({
4314
+ commands: import_zod3.z.array(CommandSchema).optional(),
4315
+ capabilities: import_zod3.z.record(import_zod3.z.string(), import_zod3.z.unknown()).optional(),
4316
+ sensors: import_zod3.z.array(SensorSchema).optional()
4317
+ }),
4318
+ meta: import_zod3.z.object({
4319
+ startedAt: import_zod3.z.number(),
4320
+ durationMs: import_zod3.z.number(),
4321
+ generatedAt: import_zod3.z.number()
4322
+ })
4323
+ });
4324
+
4325
+ // src/diagnostics/dumper.ts
4326
+ function commandsForFamily(family) {
4327
+ const commands = [];
4328
+ if (family === "vacuum") {
4329
+ for (const [name, ref] of Object.entries(VACUUM_ACTION)) {
4330
+ commands.push({ name, siid: ref.siid, aiid: ref.aiid });
4331
+ }
4332
+ } else if (family === "mower") {
4333
+ for (const [name, ref] of Object.entries(MOWER_ACTION)) {
4334
+ commands.push({ name, siid: ref.siid, aiid: ref.aiid });
4335
+ }
4336
+ }
4337
+ return commands;
4338
+ }
4339
+ function decoderFor(enumName, members) {
4340
+ const lookup = enumLookup(members);
4341
+ return { enumName, isMember: (raw) => lookup(raw) !== null };
4342
+ }
4343
+ function vacuumDecoders() {
4344
+ return {
4345
+ "2.1": decoderFor(
4346
+ "MiotState",
4347
+ Object.values(MiotState).filter((v) => typeof v === "number")
4348
+ ),
4349
+ "3.2": decoderFor(
4350
+ "ChargingStatus",
4351
+ Object.values(ChargingStatus).filter((v) => typeof v === "number")
4352
+ ),
4353
+ "4.4": decoderFor(
4354
+ "SuctionLevel",
4355
+ Object.values(SuctionLevel).filter((v) => typeof v === "number")
4356
+ ),
4357
+ "4.5": decoderFor(
4358
+ "WaterVolume",
4359
+ Object.values(WaterVolume).filter((v) => typeof v === "number")
4360
+ ),
4361
+ "2.6": decoderFor(
4362
+ "CleaningMode",
4363
+ Object.values(CleaningMode).filter((v) => typeof v === "number")
4364
+ ),
4365
+ "4.1": decoderFor(
4366
+ "TaskStatus",
4367
+ Object.values(TaskStatus).filter((v) => typeof v === "number")
4368
+ )
4369
+ };
4370
+ }
4371
+ function mowerDecoders() {
4372
+ return {
4373
+ "2.1": decoderFor(
4374
+ "MowerStatus",
4375
+ Object.values(MowerStatus).filter((v) => typeof v === "number")
4376
+ ),
4377
+ "3.2": decoderFor(
4378
+ "MowerChargingStatus",
4379
+ Object.values(MowerChargingStatus).filter(
4380
+ (v) => typeof v === "number"
4381
+ )
4382
+ ),
4383
+ "5.104": decoderFor(
4384
+ "MowerTaskStatus",
4385
+ Object.values(MowerTaskStatus).filter((v) => typeof v === "number")
4386
+ ),
4387
+ "2.2": decoderFor(
4388
+ "MowerFault",
4389
+ Object.values(MowerFault).filter((v) => typeof v === "number")
4390
+ )
4391
+ };
4392
+ }
4393
+ function toScalar(value) {
4394
+ if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") {
4395
+ return value;
4396
+ }
4397
+ return null;
4398
+ }
4399
+ var PropertyAccumulator = class {
4400
+ #decoders;
4401
+ #entries = /* @__PURE__ */ new Map();
4402
+ constructor(decoders = {}) {
4403
+ this.#decoders = decoders;
4404
+ }
4405
+ record(siid, piid, rawValue, at) {
4406
+ const key2 = `${siid}.${piid}`;
4407
+ const scalar = toScalar(rawValue);
4408
+ let entry = this.#entries.get(key2);
4409
+ if (!entry) {
4410
+ entry = { values: [], unmapped: [], count: 0, firstSeen: at, lastSeen: at };
4411
+ this.#entries.set(key2, entry);
4412
+ }
4413
+ entry.count += 1;
4414
+ entry.lastSeen = at;
4415
+ if (scalar === null) {
4416
+ return;
4417
+ }
4418
+ if (!entry.values.includes(scalar)) {
4419
+ entry.values.push(scalar);
4420
+ }
4421
+ const decoder = this.#decoders[key2];
4422
+ if (decoder && typeof scalar === "number" && !decoder.isMember(scalar)) {
4423
+ if (!entry.unmapped.includes(scalar)) {
4424
+ entry.unmapped.push(scalar);
4425
+ }
4426
+ }
4427
+ }
4428
+ snapshot() {
4429
+ const out = {};
4430
+ for (const [key2, e] of this.#entries) {
4431
+ const decoder = this.#decoders[key2];
4432
+ const base = {
4433
+ values: [...e.values],
4434
+ unmapped: [...e.unmapped],
4435
+ count: e.count,
4436
+ firstSeen: e.firstSeen,
4437
+ lastSeen: e.lastSeen
4438
+ };
4439
+ out[key2] = decoder ? { ...base, enum: decoder.enumName } : base;
4440
+ }
4441
+ return out;
4442
+ }
4443
+ };
4444
+ var DEFAULT_REFRESH_INTERVAL_MS = 3e4;
4445
+ var DEFAULT_MAX_RAW_FRAMES = 500;
4446
+ var Dumper = class {
4447
+ #device;
4448
+ #opts;
4449
+ #acc;
4450
+ #events = [];
4451
+ #rawFrames = [];
4452
+ #catalog;
4453
+ #type;
4454
+ #onProperty;
4455
+ #onEvent;
4456
+ #timer = null;
4457
+ #startedAt = 0;
4458
+ #started = false;
4459
+ constructor(device, catalog, type, decoders, options = {}) {
4460
+ this.#device = device;
4461
+ this.#catalog = catalog;
4462
+ this.#type = type;
4463
+ this.#acc = new PropertyAccumulator(decoders);
4464
+ this.#opts = {
4465
+ captureRawFrames: options.captureRawFrames ?? false,
4466
+ maxRawFrames: options.maxRawFrames ?? DEFAULT_MAX_RAW_FRAMES,
4467
+ refreshIntervalMs: options.refreshIntervalMs ?? DEFAULT_REFRESH_INTERVAL_MS
4468
+ };
4469
+ this.#onProperty = (e) => {
4470
+ this.#acc.record(e.siid, e.piid, e.value, Date.now());
4471
+ this.#pushRaw("mqtt:property", e);
4472
+ };
4473
+ this.#onEvent = (e) => {
4474
+ this.#events.push({
4475
+ at: Date.now(),
4476
+ type: `${e.siid}.${e.eiid}`,
4477
+ data: { arguments: e.arguments }
4478
+ });
4479
+ this.#pushRaw("mqtt:event", e);
4480
+ };
4481
+ }
4482
+ /** Attach to the live stream + arm the periodic shadow refresh. Idempotent. */
4483
+ async start() {
4484
+ if (this.#started) {
4485
+ return;
4486
+ }
4487
+ this.#started = true;
4488
+ this.#startedAt = Date.now();
4489
+ this.#device.on("propertyChanged", this.#onProperty);
4490
+ this.#device.on("event", this.#onEvent);
4491
+ await this.#refresh();
4492
+ if (this.#opts.refreshIntervalMs > 0 && this.#device.refreshFromCache) {
4493
+ this.#timer = setInterval(() => void this.#refresh(), this.#opts.refreshIntervalMs);
4494
+ this.#timer.unref?.();
4495
+ }
4496
+ }
4497
+ /** Detach every listener + clear the timer. Idempotent. */
4498
+ stop() {
4499
+ if (!this.#started) {
4500
+ return Promise.resolve();
4501
+ }
4502
+ this.#started = false;
4503
+ if (this.#timer) {
4504
+ clearInterval(this.#timer);
4505
+ this.#timer = null;
4506
+ }
4507
+ this.#device.off("propertyChanged", this.#onProperty);
4508
+ this.#device.off("event", this.#onEvent);
4509
+ return Promise.resolve();
4510
+ }
4511
+ /** Build the anonymized, schema-valid dump. Works live or after {@link stop}. */
4512
+ export() {
4513
+ const now = Date.now();
4514
+ const dump = {
4515
+ schemaVersion: 1,
4516
+ library: "nodedreame",
4517
+ libraryVersion: LIBRARY_VERSION,
4518
+ device: {
4519
+ model: this.#device.model,
4520
+ type: this.#type
4521
+ },
4522
+ observations: {
4523
+ properties: this.#acc.snapshot(),
4524
+ events: [...this.#events],
4525
+ ...this.#opts.captureRawFrames ? { rawFrames: this.#redactFrames() } : {}
4526
+ },
4527
+ catalog: this.#catalog,
4528
+ meta: {
4529
+ startedAt: this.#startedAt,
4530
+ durationMs: Math.max(0, now - this.#startedAt),
4531
+ generatedAt: now
4532
+ }
4533
+ };
4534
+ const anonymized = redact(dump);
4535
+ return DeviceDumpSchema.parse(anonymized);
4536
+ }
4537
+ /** Deterministic pretty JSON of {@link export}. */
4538
+ exportJson() {
4539
+ return JSON.stringify(this.export(), null, 2);
4540
+ }
4541
+ async #refresh() {
4542
+ if (this.#device.refreshFromCache) {
4543
+ try {
4544
+ await this.#device.refreshFromCache();
4545
+ } catch {
4546
+ }
4547
+ }
4548
+ }
4549
+ #pushRaw(source, payload) {
4550
+ if (!this.#opts.captureRawFrames) {
4551
+ return;
4552
+ }
4553
+ this.#rawFrames.push({ at: Date.now(), source, payload });
4554
+ if (this.#rawFrames.length > this.#opts.maxRawFrames) {
4555
+ this.#rawFrames.shift();
4556
+ }
4557
+ }
4558
+ #redactFrames() {
4559
+ return this.#rawFrames.map((f) => ({ at: f.at, source: f.source, payload: redact(f.payload) }));
4560
+ }
4561
+ };
4562
+ function familyOf(device) {
4563
+ if (device instanceof VacuumDevice) {
4564
+ return "vacuum";
4565
+ }
4566
+ if (device instanceof MowerDevice) {
4567
+ return "mower";
4568
+ }
4569
+ if (!(device instanceof BaseDevice)) {
4570
+ if (device.model.startsWith("dreame.vacuum.")) {
4571
+ return "vacuum";
4572
+ }
4573
+ if (device.model.startsWith("dreame.mower.")) {
4574
+ return "mower";
4575
+ }
4576
+ }
4577
+ return "device";
4578
+ }
4579
+ function decodersFor(device) {
4580
+ switch (familyOf(device)) {
4581
+ case "vacuum":
4582
+ return vacuumDecoders();
4583
+ case "mower":
4584
+ return mowerDecoders();
4585
+ default:
4586
+ return {};
4587
+ }
4588
+ }
4589
+ function typeFor(device) {
4590
+ return familyOf(device);
4591
+ }
4592
+ function catalogFor(device) {
4593
+ return {
4594
+ commands: commandsForFamily(familyOf(device)),
4595
+ capabilities: { tokens: [...device.capabilities.list()] }
4596
+ };
4597
+ }
4598
+ function createDumper(target, options) {
4599
+ return new Dumper(
4600
+ target,
4601
+ catalogFor(target),
4602
+ typeFor(target),
4603
+ decodersFor(target),
4604
+ options ?? {}
4605
+ );
4606
+ }
4607
+ function createClientDumper(client, options) {
4608
+ return client.devices.map((d) => createDumper(d, options));
4609
+ }
4152
4610
  // Annotate the CommonJS export names for ESM import in node:
4153
4611
  0 && (module.exports = {
4154
4612
  BaseDevice,
@@ -4178,6 +4636,8 @@ var Nodreame = class extends TypedEmitter {
4178
4636
  VacuumCapabilityResolver,
4179
4637
  VacuumDevice,
4180
4638
  WaterVolume,
4639
+ createClientDumper,
4640
+ createDumper,
4181
4641
  getMowerCapabilities,
4182
4642
  getVacuumCapabilities,
4183
4643
  renderMowerSvg,