@7365admin1/core 3.131.1 → 3.133.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/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@7365admin1/core",
3
3
  "license": "MIT",
4
- "version": "3.131.1",
4
+ "version": "3.133.0",
5
5
  "author": "7365admin1",
6
6
  "main": "dist/index.js",
7
7
  "module": "dist/index.mjs",
@@ -114,7 +114,9 @@ describe("Ounitech: base URL, server-verified sign, UTC timestamp, one credentia
114
114
  ]);
115
115
  const text = JSON.stringify(res.body);
116
116
  for (const s of [APPID, SECRET, "+6500000000", "Vendor Owner"]) assert.equal(text.includes(s), false, s);
117
- assert.deepEqual(lastCall(), { path: "/portal/home/get-robot-list", signOk: true, utcNow: true, leakedSecret: false });
117
+ // The list call (a map read may follow it: robots are placed on their own map).
118
+ assert.deepEqual(mock.calls.findLast((c) => c.path === "/portal/home/get-robot-list"), { path: "/portal/home/get-robot-list", signOk: true, utcNow: true, leakedSecret: false });
119
+ assert.ok(mock.calls.every((c) => !c.leakedSecret));
118
120
  });
119
121
 
120
122
  it("the first robot is added with typed credentials and a picked serial; stored encrypted", async () => {
@@ -144,8 +144,8 @@ describe("robot vendor failures are handled answers; diagnose names the step", {
144
144
  mode = "ok";
145
145
  const ok = await h.api(`/robots/${id.robot}/diagnose`, { sid: staffSid });
146
146
  assert.equal(ok.body.failedStep, null, JSON.stringify(ok.body));
147
- // credentials, sign-in, get robot, maps, then the 8 read-only 2025.10 calls (all answered).
148
- assert.equal(ok.body.steps.length, 12, JSON.stringify(ok.body.steps.map((s) => s.step)));
147
+ // credentials, sign-in, get robot, maps, then the 11 read-only 2025.10 calls (all answered).
148
+ assert.equal(ok.body.steps.length, 15, JSON.stringify(ok.body.steps.map((s) => s.step)));
149
149
  assert.ok(ok.body.steps.every((s) => s.ok), JSON.stringify(ok.body.steps.filter((s) => !s.ok)));
150
150
  assert.match(ok.body.steps[2].detail, /model Q3, battery 88%, floor 1/);
151
151
  assert.ok(ok.body.clock?.vendorDate, "the vendor's clock is reported");
@@ -6,7 +6,22 @@ import { after, before, test } from "node:test";
6
6
 
7
7
  import { ROBOT_CLOUD_PATHS, OUNITECH_TIMESTAMP_SKEW_S } from "./.build/utils/robot-cloud.util.mjs";
8
8
  import { RobotCloudError, useRobotCloudService } from "./.build/services/robot-cloud.service.mjs";
9
- import { createOunitechAdapter, ounitechRobotFromHomeRow, ounitechFault, ounitechJobWords, OUNITECH_FRESH_MS } from "./.build/services/ounitech.adapter.mjs";
9
+ import {
10
+ createOunitechAdapter,
11
+ ounitechRobotFromHomeRow,
12
+ ounitechFault,
13
+ ounitechJobWords,
14
+ OUNITECH_FRESH_MS,
15
+ ounitechMapShare,
16
+ ounitechAlertRecord,
17
+ ounitechReadings,
18
+ ounitechTaskRecord,
19
+ ounitechConsumable,
20
+ ounitechPowerPlan,
21
+ ounitechTime,
22
+ pngSize,
23
+ clearOunitechCaches,
24
+ } from "./.build/services/ounitech.adapter.mjs";
10
25
 
11
26
  /* --------------------------------------------------------------------------
12
27
  * Ounitech "Robot Cloud API Document (2025.10.22)", received 2026-09-28.
@@ -92,6 +107,20 @@ const recordedState = (robotId, updateOn = RECORDED_UPDATE_ON) => ({
92
107
  ],
93
108
  });
94
109
 
110
+ /** get-task-list rows in the document's shape (2025.10.22, robot 318 / 160). */
111
+ const TASKS = [
112
+ { taskId: 161745, robotId: 318, sceneId: 332, mapId: 1, timeFrom: "2025-10-20 14:22:09", timeTo: "2025-10-20 14:22:33", typeFlag: 24, subTypeFlag: 201, statusFlag: "0", cleanedArea: 0.8225, cleanedMileage: 1.2946, accArea: 0.8225, countArea: 20.57, accMile: 1.2946, accTime: 14, accPercent: 0, zoneno: 1, source: "app,38786,2025-10-20 14:22:05.808", statusName: "Finished", typeName: "准备清洁", subTypeName: "吸尘", sceneMap: null, wallList: [] },
113
+ { taskId: 148257, robotId: 160, sceneId: 174, mapId: 2700, timeFrom: "2025-09-03 08:15:02", timeTo: "2025-09-03 08:31:00", durationSeconds: 946, typeFlag: 17, statusFlag: "0", cleanedArea: 65.86, cleanedMileage: 234.3402, accArea: 65.86, countArea: 67.43, accTime: 946, accPercent: 100, zoneno: 2, source: "plan,3059,2025-09-03 08:15:00.794", statusName: "Finished", floorNo: 1 },
114
+ { taskId: 161739, robotId: 318, mapId: 1, timeFrom: "2025-10-20 14:09:13", timeTo: "2025-10-20 14:09:19", typeFlag: 17, statusFlag: "0", cleanedArea: 0, accTime: 0, accPercent: 0, source: "btn,1,2025-10-20 14:09:10.481", statusName: "Finished", typeName: "脱离基站中", subTypeName: "洗地" },
115
+ { taskId: 161741, robotId: 318, mapId: 1, timeFrom: "2025-10-20 14:12:23", timeTo: "2025-10-20 14:13:49", statusName: "Finished" },
116
+ ];
117
+ /** get-alert-list rows as robot 424 sent them on prod (2026-09-30) and the document's resolved form. */
118
+ const ALERTS = [
119
+ { deviceFlag: 1, deviceName: "Q3标准版-F8FCA9A1B1C4C", sceneId: 438, errMsg: "734 - (提示)IMU零点偏移过大( 5)", createTime: "2026-09-30 08:32:04" },
120
+ { deviceFlag: 1, errMsg: "540 - (提示)地检-悬崖打开( 5)", createTime: "2026-09-30 07:02:30" },
121
+ { deviceFlag: 1, errCode: "621 - 右轮驱动器重置( 4)(Resolved)", errMsg: "621 - 右轮驱动器重置( 4)(Resolved)", createTime: "2025-10-20 14:23:45" },
122
+ ];
123
+
95
124
  let server;
96
125
  let base;
97
126
  const seen = [];
@@ -107,6 +136,16 @@ before(async () => {
107
136
  let raw = "";
108
137
  req.on("data", (c) => (raw += c));
109
138
  req.on("end", () => {
139
+ // The signed map image (COS in production): a PNG whose header says 200 x 100 px.
140
+ if (req.method === "GET" && req.url.startsWith("/img/")) {
141
+ const png = Buffer.alloc(33);
142
+ Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0, 0, 0, 13]).copy(png, 0);
143
+ png.write("IHDR", 12, "latin1");
144
+ png.writeUInt32BE(200, 16);
145
+ png.writeUInt32BE(100, 20);
146
+ res.writeHead(206, { "content-type": "image/png" });
147
+ return res.end(png);
148
+ }
110
149
  if (!/application\/json/.test(req.headers["content-type"] ?? "")) return text(res, 400, "Content-Type must be application/json");
111
150
  let body;
112
151
  try {
@@ -138,18 +177,24 @@ before(async () => {
138
177
  case ROBOT_CLOUD_PATHS.floorMaps:
139
178
  return ok({
140
179
  mapList: [
141
- { mapId: 1, floorNo: 1, mapName: "L1", latestFlag: true, backgroundFileKey: "/k1.png", partitionFileKey: "/k1_zone.png", originX: -1, originY: -2, resolution: 0.05, totalArea: 10, baseX: 0, baseY: 0, baseTheta: 0, wallList: [{ wallId: 1, pointList: [[0, 0], [1, 1]] }], areaList: [{ areaId: 9, areaName: "Lobby", rgba: "FF0B0B83", x: 10, y: 20, pathType: 0 }] },
180
+ { mapId: 1, floorNo: 3, mapName: "L3", latestFlag: true, backgroundFileKey: "/k1.png", partitionFileKey: "/k1_zone.png", originX: -1, originY: -5, resolution: 0.05, totalArea: 10, baseX: 0, baseY: 0, baseTheta: 0, wallList: [{ wallId: 1, pointList: [[0, 0], [1, 1]] }], areaList: [{ areaId: 9, areaName: "Lobby", rgba: "FF0B0B83", x: 10, y: 20, pathType: 0 }] },
142
181
  { mapId: 2, floorNo: 1, mapName: "L1 old", latestFlag: false, backgroundFileKey: "/k0.png" },
143
182
  ],
144
183
  });
145
184
  case ROBOT_CLOUD_PATHS.filePreview:
146
- return ok({ fileList: body.fileKeys.map((k) => ({ fileKey: k, fileUrl: `https://cos.example${k}?sign=x` })) });
185
+ return ok({ fileList: body.fileKeys.map((k) => ({ fileKey: k, fileUrl: `${base}/img${k}?sign=x` })) });
147
186
  case ROBOT_CLOUD_PATHS.plansV2:
148
187
  return ok({ total: 1, planList: [{ planId: 2274, robotId: body.robotId, dateList: [1, 2, 3], startTime: "08:30", endTime: "12:30", enableFlag: false, cleanList: [{ floorNo: 3, rangeFlag: 1, cleanTimes: 1, modeFlag: 1, subModeFlag: 0, levelFlag: 1 }] }] });
149
188
  case ROBOT_CLOUD_PATHS.taskList:
150
- return ok({ total: 4, taskList: [{ taskId: 1 }, { taskId: 2 }, { taskId: 3 }, { taskId: 4 }] });
189
+ return ok({ total: 4, taskList: TASKS });
151
190
  case ROBOT_CLOUD_PATHS.alertList:
152
- return ok({ total: 1, alertList: [{ deviceId: body.deviceId, errCode: "620 - offline(Resolved)" }] });
191
+ return ok({ total: 3, alertList: ALERTS.map((a) => ({ deviceId: body.deviceId, ...a })) });
192
+ case ROBOT_CLOUD_PATHS.workHours:
193
+ return ok({ unitlist: [{ unitid: 51, modelcode: "Q3W", unitcode: "left_side_brush_plastic", unitname: "左边刷毛", orderno: 1, workminutes: 42330, workdays: 30 }] });
194
+ case ROBOT_CLOUD_PATHS.haltPlans:
195
+ return ok({ total: 1, planList: [{ planId: 4, robotId: body.robotId, dateList: [1, 2, 3, 4, 5], actionTime: "20:00", actionFlag: 0, enableFlag: true, remark: "1-5 晚8点关机" }] });
196
+ case ROBOT_CLOUD_PATHS.stationState:
197
+ return ok({ state: { stationId: 4560, updateOn: Date.now(), statusFlag: "0", statusName: "Offline", properties: [s("clean-water", "3.300", "double", "升"), s("charge-err", "false", "bool")] } });
153
198
  case ROBOT_CLOUD_PATHS.liftList:
154
199
  return ok({ liftlist: [{ liftid: 45, liftno: 3701, liftname: "Lift A", floorno: 13, onlineflag: 1, enableflag: true, simno: "868327073829804" }] });
155
200
  case ROBOT_CLOUD_PATHS.mapList:
@@ -162,6 +207,8 @@ before(async () => {
162
207
  server.listen(0, "127.0.0.1");
163
208
  await once(server, "listening");
164
209
  base = `http://127.0.0.1:${server.address().port}`;
210
+ // Maps are cached per AppID + scene; warm them so the call-order tests below see only their own reads.
211
+ await createOunitechAdapter({ appid: APPID, secret: SECRET }, { baseUrl: base, timeoutMs: 2000 }).getMaps("F8FCA9A1B1C4C");
165
212
  });
166
213
  after(() => server.close());
167
214
 
@@ -173,8 +220,9 @@ test("the robot list is the chapter-6 list, signed for its own path, credentials
173
220
  assert.deepEqual(robots.map((r) => r.robotSn), ["F8FCA9A1B1C4C", "OFFLINE1", "WORKING1", "ERROR1", "LISTOFFLINE"]);
174
221
  assert.equal(seen[0].path, "/portal/home/get-robot-list");
175
222
  // Live state only for the rows the list can't vouch for (99, 7) - never for a known-good status.
176
- assert.deepEqual(seen.slice(1).map((x) => x.body.robotId).sort(), [425, 427, 430]);
177
- assert.ok(seen.slice(1).every((x) => x.path === ROBOT_CLOUD_PATHS.robotState));
223
+ const live = seen.filter((x) => x.path === ROBOT_CLOUD_PATHS.robotState);
224
+ assert.deepEqual(live.map((x) => x.body.robotId).sort(), [425, 427, 430]);
225
+ assert.equal(seen.length, 1 + live.length, "maps come from the cache - no extra vendor reads per poll");
178
226
  assert.deepEqual(Object.keys(seen[0].body).sort(), ["appid", "sign", "timestamp"]);
179
227
  assert.equal(JSON.stringify(seen).includes(SECRET), false);
180
228
  });
@@ -219,21 +267,22 @@ test("status 7 costs one extra read, get-robot-state-v2.0 by robotId, to name th
219
267
  });
220
268
 
221
269
  test("maps: get-floor-maps by the robot's sceneId, latest only, walls left out", async () => {
270
+ clearOunitechCaches();
222
271
  seen.length = 0;
223
272
  const maps = await adapter().getMaps("F8FCA9A1B1C4C");
224
- assert.deepEqual(seen.map((s) => s.path), [ROBOT_CLOUD_PATHS.homeRobotList, ROBOT_CLOUD_PATHS.floorMaps]);
273
+ assert.deepEqual(seen.map((s) => s.path), [ROBOT_CLOUD_PATHS.homeRobotList, ROBOT_CLOUD_PATHS.floorMaps, ROBOT_CLOUD_PATHS.filePreview]);
225
274
  assert.equal(seen[1].body.sceneId, 438);
226
275
  assert.equal(seen[1].body.enableFlag, 1);
227
276
  assert.equal(maps.length, 1);
228
277
  assert.equal(maps[0].backgroundFileKey, "/k1.png");
229
- assert.deepEqual(maps[0].areaList, [{ areaName: "Lobby", rgba: "FF0B0B83", x: 10, y: 20 }]);
278
+ assert.deepEqual(maps[0].areaList, [{ areaId: 9, areaName: "Lobby", rgba: "FF0B0B83", x: 10, y: 20 }]);
230
279
  assert.equal("wallList" in maps[0], false);
231
280
  });
232
281
 
233
282
  test("map images: upload/preview by file key, fileUrl read as url", async () => {
234
283
  seen.length = 0;
235
284
  const files = await adapter().getMapImages("F8FCA9A1B1C4C", ["/k1.png"]);
236
- assert.deepEqual(files, [{ fileKey: "/k1.png", url: "https://cos.example/k1.png?sign=x" }]);
285
+ assert.deepEqual(files, [{ fileKey: "/k1.png", url: `${base}/img/k1.png?sign=x` }]);
237
286
  assert.deepEqual(seen[0].body.fileKeys, ["/k1.png"]);
238
287
  });
239
288
 
@@ -339,3 +388,168 @@ test("live state mapping, recorded reply (pure)", () => {
339
388
  const faulty = ounitechRobotFromHomeRow({ robotSn: "E" }, { statusFlag: 1, rtFaultList: [{ errCode: "302", errMsg: "Clean water tank empty" }] }, now);
340
389
  assert.deepEqual(faulty.fault, { code: "302", message: "Clean water tank empty" });
341
390
  });
391
+
392
+ test("cleaning readings: water levels, tanks and covers, job progress only while cleaning (pure)", async () => {
393
+ const { ounitechCleaning } = await import("./.build/services/ounitech.adapter.mjs");
394
+ const now = RECORDED_UPDATE_ON + 20_000;
395
+ const row = { robotId: 424, robotSn: "F8FCA9A1B1C4C", modelCode: "Q3", robotState: { statusFlag: 99 } };
396
+ // The recorded prod reply: idle, clean 62 %, dirty 8 %, every tank/cover fitted.
397
+ assert.deepEqual(ounitechRobotFromHomeRow(row, recordedState(424), now).cleaning, {
398
+ cleanWater: { level: "medium", percent: 62 },
399
+ dirtyWater: { level: "low", percent: 8 },
400
+ parts: [
401
+ { part: "top cover", ok: true },
402
+ { part: "clean-water tank", ok: true },
403
+ { part: "dirty-water tank", ok: true },
404
+ ],
405
+ job: null,
406
+ });
407
+ const st = (props) => ({ properties: props.map(([k, v]) => s(k, v)) });
408
+ // Cleaning: this job's % and area; low clean water, full dirty water, cover open.
409
+ const c = ounitechCleaning(st([["clean-water", 12], ["dirty-water", 91], ["top-cover", "false"], ["dust-box", "true"], ["acc-percent", 40], ["acc-area", "120.46"], ["count-area", "300"]]), true);
410
+ assert.deepEqual(c, {
411
+ cleanWater: { level: "low", percent: 12 },
412
+ dirtyWater: { level: "high", percent: 91 },
413
+ parts: [{ part: "top cover", ok: false }, { part: "dust box", ok: true }],
414
+ job: { percent: 40, areaDone: 120.5, areaPlanned: 300 },
415
+ });
416
+ // Not cleaning: no job line even with acc-* readings.
417
+ assert.equal(ounitechCleaning(st([["acc-percent", 40]]), false), null);
418
+ // No % sent: the robot's own level word.
419
+ assert.deepEqual(ounitechCleaning(st([["clean-water-level", "中"], ["dirty-water-level", "高"]]), false).dirtyWater, { level: "high", percent: null });
420
+ // Nothing reported: null, never guessed.
421
+ assert.equal(ounitechCleaning({ properties: [] }, true), null);
422
+ // Job % falls back to the list's taskState.
423
+ assert.deepEqual(ounitechCleaning({ properties: [] }, true, 55.4).job, { percent: 55, areaDone: null, areaPlanned: null });
424
+ // Offline: nothing.
425
+ assert.equal(ounitechRobotFromHomeRow(row, { ...recordedState(424), statusFlag: 99 }, now).cleaning, undefined);
426
+ });
427
+
428
+ test("cleaning readings reach the fleet state (waterTanks brands only)", async () => {
429
+ const { fleetStateItem } = await import("./.build/services/robot-fleet.service.mjs");
430
+ const cleaning = { cleanWater: { level: "low", percent: 10 }, dirtyWater: null, parts: [], job: null };
431
+ assert.deepEqual(fleetStateItem("x", { online: true, cleaning }).cleaning, cleaning);
432
+ assert.equal(fleetStateItem("x", { online: true }).cleaning, null);
433
+ const r = await adapter().getRobot("LISTOFFLINE");
434
+ assert.equal(r.cleaning.cleanWater.percent, 62);
435
+ });
436
+
437
+ /* ---------------------------------------------- real map, robot pin, records */
438
+
439
+ test("maps carry the real image: mapId, signed imageUrl + partition, PNG size, areas and dock placed", async () => {
440
+ const [m] = await adapter().getMaps("F8FCA9A1B1C4C");
441
+ assert.equal(m.mapId, "1");
442
+ assert.equal(m.floor, 3);
443
+ assert.equal(m.mapDisplayName, "L3");
444
+ assert.equal(m.imageUrl, `${base}/img/k1.png?sign=x`);
445
+ assert.equal(m.partitionImageUrl, `${base}/img/k1_zone.png?sign=x`);
446
+ assert.equal(m.width, 200);
447
+ assert.equal(m.height, 100);
448
+ assert.deepEqual(m.areas, [{ name: "Lobby", at: { x: 5, y: 20 } }]);
449
+ // base (0, 0): x = (0 + 1) / 0.05 = 20 px of 200; y up = (0 + 5) / 0.05 = 100 px of 100 -> top edge.
450
+ assert.deepEqual(m.dock, { x: 10, y: 0 });
451
+ assert.equal(m.positionApproximate, true);
452
+ });
453
+
454
+ test("the robot is placed on its own map: mapId + planPosition from x/y, origin and resolution", async () => {
455
+ const r = await adapter().getRobot("F8FCA9A1B1C4C");
456
+ assert.equal(r.mapId, "1");
457
+ // x (1.5 + 1) / 0.05 = 50 px -> 25 %; y up (-2.25 + 5) / 0.05 = 55 px -> 45 % from the top.
458
+ assert.deepEqual(r.planPosition, { x: 25, y: 45 });
459
+ const off = await adapter().getRobot("OFFLINE1");
460
+ assert.equal(off.planPosition, undefined, "offline: never placed");
461
+ const otherFloor = await adapter().getRobot("WORKING1");
462
+ assert.equal(otherFloor.mapId, undefined, "no map for floor 2: not placed");
463
+ });
464
+
465
+ test("prod robot 424 numbers (pure): 317 x 255 px map, origin -10.79 / -3.828, 0.05 m/px", () => {
466
+ const frame = { width: 317, height: 255, resolution: 0.05, originX: -10.79, originY: -3.828 };
467
+ assert.deepEqual(ounitechMapShare(frame, 0.14812549284101845, 0.008918337419457247), { x: 69.01, y: 69.91 });
468
+ assert.equal(ounitechMapShare(frame, 99, 0), null, "off the image: not drawn");
469
+ assert.equal(ounitechMapShare({ ...frame, width: null }, 0, 0), null);
470
+ const header = new Uint8Array(24);
471
+ header.set([0x89, 0x50, 0x4e, 0x47], 0);
472
+ header.set([73, 72, 68, 82], 12);
473
+ new DataView(header.buffer).setUint32(16, 317);
474
+ new DataView(header.buffer).setUint32(20, 255);
475
+ assert.deepEqual(pngSize(header), { width: 317, height: 255 });
476
+ assert.equal(pngSize(new Uint8Array(24)), null, "not a PNG");
477
+ });
478
+
479
+ test("records: jobs, alert history, consumables, power schedule, lifts, readings, base station - read-only and cached", async () => {
480
+ clearOunitechCaches();
481
+ seen.length = 0;
482
+ const a = adapter();
483
+ const r = await a.getRecords("F8FCA9A1B1C4C");
484
+ assert.equal(r.supported, true);
485
+ assert.equal(seen.some((x) => COMMAND_PATHS.has(x.path)), false, "never a command");
486
+ const task = seen.find((x) => x.path === ROBOT_CLOUD_PATHS.taskList).body;
487
+ assert.equal(task.robotId, 424);
488
+ assert.match(task.timeFrom, /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/);
489
+ assert.equal(r.tasks.error, undefined);
490
+ assert.equal(r.tasks.items.length, 4);
491
+ assert.equal(r.tasks.items[0].id, "161745", "newest first");
492
+ assert.deepEqual(
493
+ { ...r.tasks.items[0] },
494
+ { id: "161745", start: "2025-10-20T06:22:09.000Z", end: "2025-10-20T06:22:33.000Z", durationSec: 14, areaM2: 0.8, plannedM2: 20.6, percent: 0, distanceM: 1, result: "Finished", job: "Getting ready", mode: "Vacuum", source: "Ounitech app", map: "L3", floorNo: 3 },
495
+ );
496
+ assert.deepEqual(r.alerts.items.map((x) => [x.code, x.level, x.message, x.resolved]), [
497
+ ["734", "Notice", "IMU zero offset too large", false],
498
+ ["540", "Notice", "Floor check: cliff sensor switched on", false],
499
+ ["621", null, "Right wheel driver reset", true],
500
+ ]);
501
+ assert.deepEqual(r.consumables.items, [{ part: "Left side brush", hours: 705.5, days: 30 }]);
502
+ assert.deepEqual(r.power.items, [{ days: [1, 2, 3, 4, 5], time: "20:00", action: "off", enabled: true }]);
503
+ assert.deepEqual(r.lifts.items, [{ name: "Lift A", floorNo: 13, online: true, enabled: true }]);
504
+ assert.ok(r.readings.items.some((x) => x.label === "Left battery" && x.value === "100%"));
505
+ assert.equal(r.station.items[0].status, "Offline");
506
+ assert.ok(r.station.items[0].readings.some((x) => x.label === "Station clean water" && x.value === "3.300 L"));
507
+ assert.equal(JSON.stringify(r).includes("868327073829804"), false, "lift SIM number left out");
508
+ const calls = seen.length;
509
+ await a.getRecords("F8FCA9A1B1C4C");
510
+ assert.equal(seen.length, calls, "second read within 2 min comes from the cache (the vendor rate-limits)");
511
+ });
512
+
513
+ test("records: a robot not on the account says so", async () => {
514
+ const r = await adapter().getRecords("NOPE");
515
+ assert.equal(r.supported, true);
516
+ assert.match(r.error, /not on this Ounitech account/);
517
+ });
518
+
519
+ test("alert, reading, task and schedule mapping (pure, recorded shapes)", () => {
520
+ const a = ounitechAlertRecord({ errMsg: "785 - (提示)定位准备完成( 5)", createTime: "2026-09-30 07:02:57" });
521
+ assert.deepEqual(a, { code: "785", level: "Notice", message: "Localisation ready", vendorText: "定位准备完成", resolved: false, at: "2026-09-29T23:02:57.000Z" });
522
+ assert.equal(ounitechAlertRecord({ errMsg: "999 - (警告)未知( 3)" }).message, "未知", "unknown code keeps the vendor's words");
523
+ assert.equal(ounitechAlertRecord({ errMsg: "999 - (警告)未知( 3)" }).level, "Warning");
524
+
525
+ // Robot 424's live readings on prod (2026-09-30); IP and SIM left out.
526
+ const rows = ounitechReadings([
527
+ s("left-battery-percent", "100"), s("right-battery-percent", "0"), s("bat-vol-left", "29.4"), s("clean-water-level", "低"),
528
+ s("top-cover", "true"), s("dust-box", "false"), s("collision-left", "false"), s("acc-time", "600"), s("job", "已结束"),
529
+ s("status", "待机"), s("RouterSignalStrength", "-79 dBm"), s("RobotIP", "10.0.0.8"), s("RouterIccid", "8986000000"), s("NewKey", "7"), s("McuSW", "-"),
530
+ ]);
531
+ const by = Object.fromEntries(rows.map((x) => [x.key, x]));
532
+ assert.equal(by["left-battery-percent"].value, "100%");
533
+ assert.equal(by["bat-vol-left"].value, "29.4 V");
534
+ assert.equal(by["clean-water-level"].value, "Low");
535
+ assert.equal(by["top-cover"].value, "Yes");
536
+ assert.equal(by["top-cover"].attention, false);
537
+ assert.equal(by["dust-box"].attention, true, "dust box not fitted needs a person");
538
+ assert.equal(by["acc-time"].value, "10 min");
539
+ assert.equal(by.job.value, "job finished");
540
+ assert.equal(by.status.value, "Idle");
541
+ assert.equal(by.RouterSignalStrength.group, "Connection");
542
+ assert.equal(by.NewKey.group, "Other readings", "an undocumented reading still shows");
543
+ assert.equal("RobotIP" in by || "RouterIccid" in by, false, "network identifiers stay in Diagnose");
544
+ assert.equal("McuSW" in by, false, "'-' = not reported");
545
+
546
+ const t = ounitechTaskRecord({ taskId: 1, mapId: 5, source: "plan,1,x", subTypeName: "尘推", timeFrom: "2026-09-30 08:00:00" }, [], 480);
547
+ assert.equal(t.source, "Schedule");
548
+ assert.equal(t.mode, "Dust mop");
549
+ assert.equal(t.map, null);
550
+ assert.equal(t.start, "2026-09-30T00:00:00.000Z");
551
+ assert.equal(ounitechTime("garbage"), null);
552
+ assert.deepEqual(ounitechConsumable({ unitcode: "new_part_x", workminutes: 90 }), { part: "new part x", hours: 1.5, days: null });
553
+ assert.equal(ounitechPowerPlan({ dateList: [6, 7, 9], actionTime: "07:30", actionFlag: 1 }).action, "on");
554
+ assert.deepEqual(ounitechPowerPlan({ dateList: [6, 7, 9] }).days, [6, 7]);
555
+ });
@@ -229,6 +229,9 @@ test("diagnose: every step passes on a healthy account, with the vendor's clock"
229
229
  ["cleaning schedule (get-plans-v2312)", true],
230
230
  ["cleaning records, last 7 days (get-task-list)", true],
231
231
  ["alerts, last 7 days (get-alert-list)", true],
232
+ ["consumable hours (get-work-hours)", true],
233
+ ["power schedule (halt-plan/get)", true],
234
+ ["base station (get-station-detail-state)", true],
232
235
  ["lifts (get-lift-list)", true],
233
236
  ["current job (robot list taskState)", true],
234
237
  ]);