@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/CHANGELOG.md +12 -0
- package/dist/index.d.ts +182 -4
- package/dist/index.js +571 -118
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +559 -118
- package/dist/index.mjs.map +1 -1
- package/package.json +1 -1
- package/test/e2e/robot-ounitech-company-creds.e2e.test.mjs +3 -1
- package/test/e2e/robot-vendor-errors.e2e.test.mjs +2 -2
- package/test/ounitech-api-2025-10.test.mjs +224 -10
- package/test/robot-vendor-errors.test.mjs +3 -0
package/package.json
CHANGED
|
@@ -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
|
-
|
|
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
|
|
148
|
-
assert.equal(ok.body.steps.length,
|
|
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 {
|
|
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:
|
|
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:
|
|
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:
|
|
189
|
+
return ok({ total: 4, taskList: TASKS });
|
|
151
190
|
case ROBOT_CLOUD_PATHS.alertList:
|
|
152
|
-
return ok({ total:
|
|
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
|
-
|
|
177
|
-
assert.
|
|
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:
|
|
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
|
]);
|