@7365admin1/core 3.131.0 → 3.132.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.0",
4
+ "version": "3.132.0",
5
5
  "author": "7365admin1",
6
6
  "main": "dist/index.js",
7
7
  "module": "dist/index.mjs",
@@ -6,7 +6,7 @@ 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 } from "./.build/services/ounitech.adapter.mjs";
9
+ import { createOunitechAdapter, ounitechRobotFromHomeRow, ounitechFault, ounitechJobWords, OUNITECH_FRESH_MS } from "./.build/services/ounitech.adapter.mjs";
10
10
 
11
11
  /* --------------------------------------------------------------------------
12
12
  * Ounitech "Robot Cloud API Document (2025.10.22)", received 2026-09-28.
@@ -53,8 +53,45 @@ const ROWS = [
53
53
  robotId: 427, robotSn: "ERROR1", modelCode: "Q3W", sceneId: 438, floorNo: 2,
54
54
  robotState: { x: 0, y: 0, theta: 0, statusFlag: 7, properties: [prop("battery-percent", 55)] },
55
55
  },
56
+ // Prod 2026-09-30: the list said offline while the live state (below) answered Idle.
57
+ {
58
+ robotId: 430, robotSn: "LISTOFFLINE", modelCode: "Q3", sceneId: 438, floorNo: 3,
59
+ robotState: { x: 0, y: 0, theta: 0, statusFlag: 99, properties: [prop("battery-percent", 0)] },
60
+ },
56
61
  ];
57
62
 
63
+ /**
64
+ * The shape of get-robot-state-v2.0's reply for robot 424 (Q3, 4G) on prod,
65
+ * 2026-09-30 ~02:04Z, while our UI showed it offline. Readings as the cloud
66
+ * sends them: every value a string, labels Chinese. No credential in it.
67
+ */
68
+ const RECORDED_UPDATE_ON = Date.parse("2026-09-30T02:04:08Z");
69
+ const s = (key, value, type = "string", unit = "") => ({ key, name: "", type, value: String(value), unit });
70
+ const recordedState = (robotId, updateOn = RECORDED_UPDATE_ON) => ({
71
+ robotId,
72
+ updateOn,
73
+ x: 0.4213, y: -1.0875, theta: 1.5708,
74
+ statusFlag: 1,
75
+ statusName: "待机",
76
+ subStatusFlag: 0,
77
+ subStatusName: "",
78
+ rtFaultList: [],
79
+ properties: [
80
+ s("battery-percent", 100, "int", "%"),
81
+ s("left-battery-percent", 100, "int", "%"),
82
+ s("right-battery-percent", 100, "int", "%"),
83
+ s("clean-water", 62, "int", "%"),
84
+ s("dirty-water", 8, "int", "%"),
85
+ s("job", "已结束"),
86
+ s("status", "待机"),
87
+ s("top-cover", 1, "int"),
88
+ s("clean-water-tank", 1, "int"),
89
+ s("waste-water-tank", 1, "int"),
90
+ s("collision-left", 0, "int"),
91
+ s("collision-right", 0, "int"),
92
+ ],
93
+ });
94
+
58
95
  let server;
59
96
  let base;
60
97
  const seen = [];
@@ -95,6 +132,8 @@ before(async () => {
95
132
  case ROBOT_CLOUD_PATHS.robotList:
96
133
  return res.end(JSON.stringify({ robotList: [] })); // chapter 1: owner-bound only
97
134
  case ROBOT_CLOUD_PATHS.robotState:
135
+ if (body.robotId === 430 || body.robotId === 424) return ok({ robotState: recordedState(body.robotId, Date.now() - 8000) });
136
+ if (body.robotId === 425) return ok({ robotState: { robotId: 425, updateOn: Date.now() - 3_600_000, statusFlag: 99, properties: [] } });
98
137
  return ok({ robotState: { robotId: body.robotId, statusFlag: 7, rtFaultList: [{ errCode: "621", errMsg: "Right wheel driver reset" }] } });
99
138
  case ROBOT_CLOUD_PATHS.floorMaps:
100
139
  return ok({
@@ -131,9 +170,11 @@ const adapter = (opts = {}) => createOunitechAdapter({ appid: APPID, secret: SEC
131
170
  test("the robot list is the chapter-6 list, signed for its own path, credentials in the body", async () => {
132
171
  seen.length = 0;
133
172
  const robots = await adapter().listRobots();
134
- assert.deepEqual(robots.map((r) => r.robotSn), ["F8FCA9A1B1C4C", "OFFLINE1", "WORKING1", "ERROR1"]);
135
- assert.equal(seen.length, 1);
173
+ assert.deepEqual(robots.map((r) => r.robotSn), ["F8FCA9A1B1C4C", "OFFLINE1", "WORKING1", "ERROR1", "LISTOFFLINE"]);
136
174
  assert.equal(seen[0].path, "/portal/home/get-robot-list");
175
+ // 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));
137
178
  assert.deepEqual(Object.keys(seen[0].body).sort(), ["appid", "sign", "timestamp"]);
138
179
  assert.equal(JSON.stringify(seen).includes(SECRET), false);
139
180
  });
@@ -219,7 +260,7 @@ test("refusals are the host's own plain words, with the credential hint", async
219
260
 
220
261
  test("within the 300 s window is accepted", async () => {
221
262
  const early = adapter({ now: () => new Date(Date.now() - (OUNITECH_TIMESTAMP_SKEW_S - 30) * 1000) });
222
- assert.equal((await early.listRobots()).length, 4);
263
+ assert.equal((await early.listRobots()).length, 5);
223
264
  });
224
265
 
225
266
  test("diagnose: every read answered, chapter 1 shows why the old list was empty, never a command", async () => {
@@ -240,3 +281,106 @@ test("row mapping holds on its own (pure)", () => {
240
281
  assert.equal(r.floorKnown, false);
241
282
  assert.equal(ounitechRobotFromHomeRow({ robotSn: "Y" }).mode, "UNAVAILABLE");
242
283
  });
284
+
285
+ test("prod 2026-09-30: list says offline, live state answers Idle -> Online / Idle / battery 100 (fleet, detail, Diagnose)", async () => {
286
+ const a = adapter();
287
+ const listed = (await a.listRobots()).find((r) => r.robotSn === "LISTOFFLINE");
288
+ const detail = await a.getRobot("LISTOFFLINE");
289
+ for (const r of [listed, detail]) {
290
+ assert.equal(r.online, true);
291
+ assert.equal(r.mode, "IDLE");
292
+ assert.equal(r.statusName, "Idle");
293
+ assert.equal(r.batteryPercent, 100);
294
+ assert.deepEqual(r.position, { x: 0.4213, y: -1.0875, theta: 1.5708 });
295
+ assert.equal(r.fault, undefined, "empty rtFaultList is no fault");
296
+ }
297
+ const steps = await a.diagnose("LISTOFFLINE");
298
+ const live = steps.find((x) => x.step.startsWith("live state")).data;
299
+ assert.equal(live.online, true);
300
+ assert.equal(live.status, "Idle");
301
+ assert.equal(live.job, "job finished");
302
+ assert.equal(live.battery, 100);
303
+ assert.equal(live.cleanWater, 62);
304
+ assert.equal(live.dirtyWater, 8);
305
+ assert.equal(live.fault, null);
306
+ });
307
+
308
+ test("live state mapping, recorded reply (pure)", () => {
309
+ const row = { robotId: 424, robotSn: "F8FCA9A1B1C4C", modelCode: "Q3", floorNo: 3, robotState: { statusFlag: 99, properties: [prop("battery-percent", 0)] } };
310
+ const now = RECORDED_UPDATE_ON + 20_000;
311
+ const r = ounitechRobotFromHomeRow(row, recordedState(424), now);
312
+ assert.equal(r.online, true);
313
+ assert.equal(r.statusName, "Idle");
314
+ assert.equal(r.batteryPercent, 100);
315
+ assert.equal(ounitechJobWords(recordedState(424)), "job finished");
316
+
317
+ // statusFlag as a numeric string
318
+ assert.equal(ounitechRobotFromHomeRow({ robotSn: "S", robotState: { statusFlag: "1" } }, null, now).mode, "IDLE");
319
+ // no statusFlag: the Chinese status reading supplies it
320
+ const noFlag = { ...recordedState(424), statusFlag: undefined };
321
+ assert.equal(ounitechRobotFromHomeRow({ robotSn: "C" }, noFlag, now).statusName, "Idle");
322
+ const charging = { properties: [s("status", "充电中")] };
323
+ assert.equal(ounitechRobotFromHomeRow({ robotSn: "C" }, charging, now).statusName, "Charging");
324
+ // no status at all: a fresh updateOn is online, a stale one is not
325
+ const bare = (age) => ({ updateOn: now - age, properties: [s("battery-percent", 70, "int", "%")] });
326
+ const fresh = ounitechRobotFromHomeRow({ robotSn: "F" }, bare(60_000), now);
327
+ assert.equal(fresh.online, true);
328
+ assert.equal(fresh.mode, "UNAVAILABLE", "never dispatchable without a known status");
329
+ assert.equal(fresh.batteryPercent, 70);
330
+ const stale = ounitechRobotFromHomeRow({ robotSn: "F" }, bare(OUNITECH_FRESH_MS + 1000), now);
331
+ assert.equal(stale.online, false);
332
+ assert.equal(stale.statusName, "Unknown");
333
+ assert.equal(stale.batteryPercent, null);
334
+ // the live state saying 99 beats a fresh-looking list row
335
+ const off = ounitechRobotFromHomeRow({ robotSn: "O", robotState: { statusFlag: 1 } }, { statusFlag: 99, updateOn: now }, now);
336
+ assert.equal(off.online, false);
337
+ assert.equal(off.statusName, "Offline");
338
+ // a fault listed live is named even without status 7
339
+ const faulty = ounitechRobotFromHomeRow({ robotSn: "E" }, { statusFlag: 1, rtFaultList: [{ errCode: "302", errMsg: "Clean water tank empty" }] }, now);
340
+ assert.deepEqual(faulty.fault, { code: "302", message: "Clean water tank empty" });
341
+ });
342
+
343
+ test("cleaning readings: water levels, tanks and covers, job progress only while cleaning (pure)", async () => {
344
+ const { ounitechCleaning } = await import("./.build/services/ounitech.adapter.mjs");
345
+ const now = RECORDED_UPDATE_ON + 20_000;
346
+ const row = { robotId: 424, robotSn: "F8FCA9A1B1C4C", modelCode: "Q3", robotState: { statusFlag: 99 } };
347
+ // The recorded prod reply: idle, clean 62 %, dirty 8 %, every tank/cover fitted.
348
+ assert.deepEqual(ounitechRobotFromHomeRow(row, recordedState(424), now).cleaning, {
349
+ cleanWater: { level: "medium", percent: 62 },
350
+ dirtyWater: { level: "low", percent: 8 },
351
+ parts: [
352
+ { part: "top cover", ok: true },
353
+ { part: "clean-water tank", ok: true },
354
+ { part: "dirty-water tank", ok: true },
355
+ ],
356
+ job: null,
357
+ });
358
+ const st = (props) => ({ properties: props.map(([k, v]) => s(k, v)) });
359
+ // Cleaning: this job's % and area; low clean water, full dirty water, cover open.
360
+ 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);
361
+ assert.deepEqual(c, {
362
+ cleanWater: { level: "low", percent: 12 },
363
+ dirtyWater: { level: "high", percent: 91 },
364
+ parts: [{ part: "top cover", ok: false }, { part: "dust box", ok: true }],
365
+ job: { percent: 40, areaDone: 120.5, areaPlanned: 300 },
366
+ });
367
+ // Not cleaning: no job line even with acc-* readings.
368
+ assert.equal(ounitechCleaning(st([["acc-percent", 40]]), false), null);
369
+ // No % sent: the robot's own level word.
370
+ assert.deepEqual(ounitechCleaning(st([["clean-water-level", "中"], ["dirty-water-level", "高"]]), false).dirtyWater, { level: "high", percent: null });
371
+ // Nothing reported: null, never guessed.
372
+ assert.equal(ounitechCleaning({ properties: [] }, true), null);
373
+ // Job % falls back to the list's taskState.
374
+ assert.deepEqual(ounitechCleaning({ properties: [] }, true, 55.4).job, { percent: 55, areaDone: null, areaPlanned: null });
375
+ // Offline: nothing.
376
+ assert.equal(ounitechRobotFromHomeRow(row, { ...recordedState(424), statusFlag: 99 }, now).cleaning, undefined);
377
+ });
378
+
379
+ test("cleaning readings reach the fleet state (waterTanks brands only)", async () => {
380
+ const { fleetStateItem } = await import("./.build/services/robot-fleet.service.mjs");
381
+ const cleaning = { cleanWater: { level: "low", percent: 10 }, dirtyWater: null, parts: [], job: null };
382
+ assert.deepEqual(fleetStateItem("x", { online: true, cleaning }).cleaning, cleaning);
383
+ assert.equal(fleetStateItem("x", { online: true }).cleaning, null);
384
+ const r = await adapter().getRobot("LISTOFFLINE");
385
+ assert.equal(r.cleaning.cleanWater.percent, 62);
386
+ });