@7365admin1/layer-common 4.52.0 → 4.54.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.
@@ -286,8 +286,10 @@ const COMMAND_NEEDS: Readonly<Record<string, string>> = {
286
286
  area: "startProcess",
287
287
  spot: "spotClean",
288
288
  return: "sendToDock",
289
- pause: "pauseProcess",
290
- resume: "pauseProcess",
289
+ // Pause / resume = pausing a robot mid-way; cancel = stopping the job. Split
290
+ // so a brand that can stop but not pause (PadBot) shows Cancel with no Pause.
291
+ pause: "pauseResume",
292
+ resume: "pauseResume",
291
293
  cancel: "pauseProcess",
292
294
  navigate: "navigateToPoint",
293
295
  relocalize: "relocalize",
@@ -383,7 +385,8 @@ export type TRobotCommandKind =
383
385
  | "resume"
384
386
  | "cancel"
385
387
  | "navigate"
386
- | "relocalize";
388
+ | "relocalize"
389
+ | "patrol-resume";
387
390
 
388
391
  /**
389
392
  * The command bar's buttons, in bar order; each needs `COMMAND_NEEDS[kind]`.
@@ -398,6 +401,7 @@ const COMMANDS: ReadonlyArray<{ kind: TRobotCommandKind; label: string }> = [
398
401
  { kind: "cancel", label: "Cancel" },
399
402
  { kind: "navigate", label: "Go to point" },
400
403
  { kind: "relocalize", label: "Set position" },
404
+ { kind: "patrol-resume", label: "Resume patrol" },
401
405
  ];
402
406
 
403
407
  export type TRobotCommand = { kind: TRobotCommandKind; label: string; primary: boolean };
@@ -409,7 +413,9 @@ export type TRobotCommand = { kind: TRobotCommandKind; label: string; primary: b
409
413
  * - role: only a Manager (`canCommand`, i.e. the app's command permission);
410
414
  * - online: only a robot confirmed online (`online === true`);
411
415
  * - context: Pause only while working, Resume only while paused;
412
- * - type: a security robot has no cleaning jobs, so no Clean all / Clean area.
416
+ * - type: a security robot has no cleaning jobs, so no Clean all / Clean area -
417
+ * but a brand with `patrolControl` gets Start patrol (the default route) and
418
+ * Resume patrol (while it is not already patrolling).
413
419
  *
414
420
  * Returns [] whenever the bar should not render at all.
415
421
  */
@@ -422,13 +428,20 @@ export function visibleCommands(input: {
422
428
  } | null | undefined): TRobotCommand[] {
423
429
  if (!input || input.canCommand !== true || input.online !== true) return [];
424
430
  const caps = input.capabilities ?? {};
431
+ const patrol = input.type === "security" && caps.patrolControl === true;
425
432
  return COMMANDS.filter((c) => {
433
+ if (c.kind === "patrol-resume") return patrol && input.mode !== "WORKING" && input.mode !== "IN_TRANSIT";
426
434
  if (caps[COMMAND_NEEDS[c.kind]!] !== true) return false;
427
435
  if (c.kind === "pause" && input.mode !== "WORKING") return false;
428
436
  if (c.kind === "resume" && input.mode !== "PAUSED") return false;
429
- if ((c.kind === "full" || c.kind === "area") && input.type === "security") return false;
437
+ if (c.kind === "full" && input.type === "security") return patrol;
438
+ if (c.kind === "area" && input.type === "security") return false;
430
439
  return true;
431
- }).map((c) => ({ kind: c.kind, label: c.label, primary: c.kind === "full" }));
440
+ }).map((c) => ({
441
+ kind: c.kind,
442
+ label: c.kind === "full" && input.type === "security" ? "Start patrol" : c.label,
443
+ primary: c.kind === "full",
444
+ }));
432
445
  }
433
446
 
434
447
  /**
@@ -596,28 +609,79 @@ export type TLiveRobotState = {
596
609
  /** The brand's own status words ("CLEANING_AUTO"), quoted as "Brand status". */
597
610
  statusName?: string;
598
611
  model?: string;
599
- fault?: { code: string; message?: string; since?: number } | null;
612
+ fault?: { code: string; message?: string; since?: number; imageUrl?: string | null; thermalImageUrl?: string | null } | null;
600
613
  capabilities?: Record<string, unknown>;
601
614
  lastSeenAt?: string | null;
615
+ /** Epoch ms of the last answer that was read (set by `mergeLiveStates`). */
616
+ readAt?: number;
617
+ /** Epoch ms when the shown position was reported. */
618
+ positionAt?: number;
619
+ /** true = what is shown is the last reading, kept through a short gap - drawn faded with "last seen". */
620
+ stale?: boolean;
602
621
  };
603
622
 
623
+ /** A gap shorter than this keeps the last reading (faded, "last seen N s ago"); longer reads "Reconnecting". */
624
+ export const LIVE_GRACE_MS = 15_000;
625
+
626
+ const hasPos = (s: Partial<TLiveRobotState> | null | undefined) =>
627
+ (!!s?.position && finite(s.position.x) && finite(s.position.y)) || (!!s?.planPosition && finite(s.planPosition.x) && finite(s.planPosition.y));
628
+
604
629
  /**
605
- * Fold one poll's answer into the live map. A robot that did not answer
606
- * REPLACES its old state with `{ ok: false }`: a stale battery or position
607
- * must never stay on screen as if live. Robots not in this answer keep theirs.
630
+ * Fold one poll's answer into the live map. Robots not in this answer keep theirs.
631
+ *
632
+ * One missed or failed update must not make a robot vanish from the map
633
+ * (owner, 2026-09-26: it disappeared and came back while moving). So:
634
+ * - no answer (`ok: false`): the last reading is kept, marked `stale`, for
635
+ * up to `LIVE_GRACE_MS` after it was read; past that it is REPLACED by
636
+ * `{ ok: false }` ("Reconnecting") - a stale battery or position is never
637
+ * kept as if live;
638
+ * - an answer without a position: the last position is kept (stale) within
639
+ * the same window, unless the robot now says it is offline or that its
640
+ * floor is unknown (`floorKnown: false`);
641
+ * - an answer without floor fields keeps the last floor.
608
642
  */
609
643
  export function mergeLiveStates(
610
644
  prev: Record<string, TLiveRobotState>,
611
645
  items: Array<Partial<TLiveRobotState> & { id?: unknown }> | null | undefined,
646
+ now: number = Date.now(),
612
647
  ): Record<string, TLiveRobotState> {
613
648
  const next = { ...prev };
614
649
  for (const item of items ?? []) {
615
650
  if (!item || typeof item.id !== "string") continue;
616
- next[item.id] = item.ok === true ? ({ ...item, ok: true } as TLiveRobotState) : { id: item.id, ok: false, monitorable: item.monitorable !== false };
651
+ const p = prev[item.id];
652
+ const recent = (at: number | undefined) => finite(at) && now - at <= LIVE_GRACE_MS;
653
+ if (item.ok !== true) {
654
+ next[item.id] =
655
+ item.monitorable !== false && p?.ok && recent(p.readAt)
656
+ ? { ...p, stale: true }
657
+ : { id: item.id, ok: false, monitorable: item.monitorable !== false };
658
+ continue;
659
+ }
660
+ const s = { ...item, ok: true, readAt: now, stale: false } as TLiveRobotState;
661
+ if (!("floorKnown" in item) && !("floorNo" in item) && p?.ok) {
662
+ s.floorNo = p.floorNo;
663
+ s.floorKnown = p.floorKnown;
664
+ }
665
+ if (hasPos(item)) s.positionAt = now;
666
+ else if (p?.ok && hasPos(p) && recent(p.positionAt) && item.online !== false && item.floorKnown !== false) {
667
+ s.position = p.position ?? null;
668
+ s.planPosition = p.planPosition ?? null;
669
+ s.positionAt = p.positionAt;
670
+ s.stale = true;
671
+ }
672
+ next[item.id] = s;
617
673
  }
618
674
  return next;
619
675
  }
620
676
 
677
+ /** "last seen 8 s ago" for a kept (stale) reading, "" when it is fresh. */
678
+ export function staleText(live: TLiveRobotState | null | undefined, now: number = Date.now()): string {
679
+ if (!live?.stale) return "";
680
+ const at = finite(live.positionAt) ? live.positionAt : live.readAt;
681
+ if (!finite(at)) return "last reading";
682
+ return `last seen ${Math.max(0, Math.round((now - at) / 1000))} s ago`;
683
+ }
684
+
621
685
  /**
622
686
  * What the Status cell says for a row: the live mode when read; "Reconnecting"
623
687
  * when the last poll got no answer; "Offline" from the stored record while no
@@ -718,7 +782,7 @@ export function brandName(brand: unknown, brands?: Array<{ brand?: unknown; disp
718
782
 
719
783
  /* ------------------------------------------------------------- mini-map */
720
784
 
721
- export type TMapRobot = { id: string; name: string; mode?: string; type?: string; left: string; top: string; rot: string };
785
+ export type TMapRobot = { id: string; name: string; mode?: string; type?: string; left: string; top: string; rot: string; stale?: boolean; seen?: string };
722
786
 
723
787
  const finite = (v: unknown): v is number => typeof v === "number" && Number.isFinite(v);
724
788
 
@@ -787,7 +851,8 @@ export function mapRobots(
787
851
  const live = states[id];
788
852
  if (!placeable(live) || live!.floorNo !== floor) return [];
789
853
  const at = planPoint(live as any)!;
790
- return [{ id, name: String(r.name ?? ""), mode: live!.mode, type: r.type as string, ...at, rot: `rotate(${headingDeg(live)}deg)` }];
854
+ // A kept (stale) reading is drawn faded with "last seen N s ago" - never as live.
855
+ return [{ id, name: String(r.name ?? ""), mode: live!.mode, type: r.type as string, ...at, rot: `rotate(${headingDeg(live)}deg)`, stale: live!.stale === true, seen: staleText(live) }];
791
856
  });
792
857
  }
793
858
 
@@ -815,33 +880,47 @@ export type TPatrolRouteView = {
815
880
  floorNo: number | null;
816
881
  checkpoints: Array<{ name: string; x: number; y: number }>;
817
882
  currentCheckpoint: number | null;
883
+ /** Every stop's name in order, placed on the plan or not (PadBot); a route with none placed is listed, never drawn. */
884
+ stops?: string[];
818
885
  };
819
886
 
887
+ const placed = (c: { x?: unknown; y?: unknown }) => finite(c?.x) && finite(c?.y);
888
+
889
+ /** "Gate → Lobby → Car park" for a route whose stops cannot be placed on the plan; "" when it can be drawn. */
890
+ export function unplacedStopsText(route: Pick<TPatrolRouteView, "checkpoints" | "stops"> | null | undefined): string {
891
+ if (!route || (route.checkpoints ?? []).filter(placed).length >= 2 || !route.stops?.length) return "";
892
+ return route.stops.join(" → ");
893
+ }
894
+
820
895
  /** The SVG path of a closed route on a `w` x `h` viewBox (checkpoints are 0-100 shares of the plan). */
821
896
  export function routePathD(checkpoints: Array<{ x: number; y: number }>, w = 400, h = 300): string {
822
- if (!checkpoints?.length) return "";
897
+ checkpoints = (checkpoints ?? []).filter(placed);
898
+ if (checkpoints.length < 2) return "";
823
899
  return checkpoints.map((p, i) => `${i ? "L" : "M"}${((p.x / 100) * w).toFixed(1)} ${((p.y / 100) * h).toFixed(1)}`).join(" ") + " Z";
824
900
  }
825
901
 
826
902
  /** Numbered checkpoint markers for every route; `current` = the one the robot is heading for. */
827
903
  export function routeMarks(routes: TPatrolRouteView[]) {
828
904
  return routes.flatMap((r) =>
829
- r.checkpoints.map((c, i) => ({
905
+ r.checkpoints.flatMap((c, i) => (!placed(c) ? [] : [{
830
906
  key: `${r.id}-${i}`,
831
907
  n: i + 1,
832
908
  left: `${c.x}%`,
833
909
  top: `${c.y}%`,
834
910
  label: c.name,
835
911
  current: r.currentCheckpoint === i,
836
- })),
912
+ }])),
837
913
  );
838
914
  }
839
915
 
840
916
  /** A route-name tag at each route's first checkpoint. */
841
917
  export function routeTags(routes: TPatrolRouteView[]) {
842
918
  return routes
843
- .filter((r) => r.checkpoints.length)
844
- .map((r) => ({ key: r.id, name: r.name, count: r.checkpoints.length, left: `${r.checkpoints[0]!.x}%`, top: `${r.checkpoints[0]!.y}%` }));
919
+ .filter((r) => r.checkpoints.some(placed))
920
+ .map((r) => {
921
+ const first = r.checkpoints.find(placed)!;
922
+ return { key: r.id, name: r.name, count: r.checkpoints.length, left: `${first.x}%`, top: `${first.y}%` };
923
+ });
845
924
  }
846
925
 
847
926
  /**
@@ -947,6 +1026,7 @@ export function optimisticMode(kind: unknown, mode: unknown): string {
947
1026
  case "area":
948
1027
  case "spot":
949
1028
  case "resume":
1029
+ case "patrol-resume":
950
1030
  return "WORKING";
951
1031
  case "return":
952
1032
  return "CHARGING_CYCLE";
@@ -981,6 +1061,84 @@ export function commandBody(kind: TRobotCommandKind, live: TLiveRobotState | nul
981
1061
  return finite(live?.floorNo) ? { kind, floorNo: live!.floorNo, x: p.x, y: p.y, theta: p.theta } : null;
982
1062
  }
983
1063
 
1064
+ /* ------------------------------------------ saved points (Go to point / Set position) */
1065
+
1066
+ /** §5.1 target point types, in words. */
1067
+ export const POINT_TYPE_TEXT: Readonly<Record<number, string>> = { 0: "Point", 3: "Lift", 4: "Loading", 6: "Access door", 10: "Charging" };
1068
+
1069
+ export type TSavedPoint = { mapId: string; targetPointId: string; name: string; type: string; floorNo: number | null; x: number; y: number; yaw: number };
1070
+ export type TSavedPointGroup = { mapId: string; title: string; floorNo: number | null; points: TSavedPoint[] };
1071
+
1072
+ /**
1073
+ * The robot's own saved points, per map ("Lobby map · L3"), from `/robots/:id/maps`
1074
+ * items that carry `targetPoints` (PadBot). [] for brands without them - they
1075
+ * keep the current-pose behaviour of `commandBody`.
1076
+ */
1077
+ export function savedPointGroups(maps: Array<Record<string, any>> | null | undefined): TSavedPointGroup[] {
1078
+ return (Array.isArray(maps) ? maps : []).flatMap((m) => {
1079
+ const floorNo = Number.isInteger(m?.floor) ? (m.floor as number) : null;
1080
+ const points = (Array.isArray(m?.targetPoints) ? m.targetPoints : [])
1081
+ .filter((t: any) => typeof t?.targetPointId === "string" && t.targetPointId && finite(t.worldX) && finite(t.worldY))
1082
+ .map((t: any) => ({
1083
+ mapId: String(m.mapId ?? t.mapId ?? ""),
1084
+ targetPointId: t.targetPointId,
1085
+ name: String(t.name || "Unnamed point"),
1086
+ type: POINT_TYPE_TEXT[t.targetPointType] ?? "Point",
1087
+ floorNo,
1088
+ x: t.worldX,
1089
+ y: t.worldY,
1090
+ yaw: finite(t.yaw) ? t.yaw : 0,
1091
+ }));
1092
+ if (!points.length || !m?.mapId) return [];
1093
+ const name = String(m.mapDisplayName || m.mapName || "Map");
1094
+ return [{ mapId: String(m.mapId), title: floorNo === null ? name : `${name} · ${levelText(floorNo)}`, floorNo, points }];
1095
+ });
1096
+ }
1097
+
1098
+ /**
1099
+ * The command for a picked saved point. Go to point names the point
1100
+ * (`go-to-point`); Set position tells the robot it stands there now - only on
1101
+ * the floor the robot reports (null otherwise).
1102
+ */
1103
+ export function pointCommandBody(kind: "navigate" | "relocalize", point: TSavedPoint, live: TLiveRobotState | null | undefined): Record<string, unknown> | null {
1104
+ if (kind === "navigate") return { kind: "go-to-point", mapId: point.mapId, targetPointId: point.targetPointId };
1105
+ if (point.floorNo === null || !finite(live?.floorNo) || live!.floorNo !== point.floorNo) return null;
1106
+ return { kind: "relocalize", floorNo: point.floorNo, x: point.x, y: point.y, theta: (point.yaw * Math.PI) / 180 };
1107
+ }
1108
+
1109
+ /* ------------------------------------------ recent patrol runs (no schedule API) */
1110
+
1111
+ /**
1112
+ * A HINT, never a schedule: when the same route started on at least 3 of the
1113
+ * last 7 days inside one 60-minute window, "Looks like "Night round" runs
1114
+ * daily around 20:00 (based on the last 7 days)". Local clock. "" otherwise.
1115
+ */
1116
+ export function patrolRunHint(records: Array<{ routeName?: unknown; startedAt?: unknown }> | null | undefined, now: number = Date.now()): string {
1117
+ const since = now - 7 * 86_400_000;
1118
+ const byRoute = new Map<string, Array<{ day: string; min: number }>>();
1119
+ for (const r of records ?? []) {
1120
+ const t = typeof r?.startedAt === "string" ? Date.parse(r.startedAt) : NaN;
1121
+ const name = typeof r?.routeName === "string" ? r.routeName.trim() : "";
1122
+ if (!name || !finite(t) || t < since || t > now) continue;
1123
+ const d = new Date(t);
1124
+ byRoute.set(name, [...(byRoute.get(name) ?? []), { day: d.toDateString(), min: d.getHours() * 60 + d.getMinutes() }]);
1125
+ }
1126
+ let best: { name: string; days: number; min: number } | null = null;
1127
+ for (const [name, runs] of byRoute) {
1128
+ for (const start of runs) {
1129
+ const inWin = runs.filter((x) => x.min >= start.min && x.min < start.min + 60);
1130
+ const days = new Set(inWin.map((x) => x.day)).size;
1131
+ if (days >= 3 && (!best || days > best.days)) {
1132
+ best = { name, days, min: Math.round(inWin.reduce((s, x) => s + x.min, 0) / inWin.length / 5) * 5 };
1133
+ }
1134
+ }
1135
+ }
1136
+ if (!best) return "";
1137
+ const hhmm = `${String(Math.floor(best.min / 60) % 24).padStart(2, "0")}:${String(best.min % 60).padStart(2, "0")}`;
1138
+ const how = best.days >= 6 ? "daily" : `on ${best.days} of the last 7 days`;
1139
+ return `Looks like "${best.name}" runs ${how} around ${hhmm} (based on the last 7 days)`;
1140
+ }
1141
+
984
1142
  /**
985
1143
  * May the plan take spot-clean clicks? A Manager, a cleaning robot, a brand
986
1144
  * that declares `spotClean`, and a robot confirmed online. (A busy robot keeps
@@ -1025,12 +1183,15 @@ export function speedText(
1025
1183
  }
1026
1184
 
1027
1185
  /** "x 17.9 · y 34.1 · 53°" (metres, the robot's map frame) - or "Reconnecting", or "Not reported". */
1028
- export function positionText(live: TLiveRobotState | null | undefined, capabilities: Record<string, unknown> | null | undefined): string {
1186
+ export function positionText(live: TLiveRobotState | null | undefined, capabilities: Record<string, unknown> | null | undefined, now: number = Date.now()): string {
1029
1187
  if (capabilities?.readLocation !== true) return "Not reported";
1030
1188
  const p = live?.position;
1031
- if (!live?.ok || live.online === false || !p || !finite(p.x) || !finite(p.y)) return "Reconnecting";
1189
+ if (!live?.ok || live.online === false) return "Reconnecting";
1190
+ // Answering, but it has no position right now (e.g. not relocalised) - said, not guessed.
1191
+ if (!p || !finite(p.x) || !finite(p.y)) return "Position not reported right now";
1032
1192
  const deg = finite(p.theta) ? Math.round(((((p.theta * 180) / Math.PI) % 360) + 360) % 360) : 0;
1033
- return `x ${p.x.toFixed(1)} · y ${p.y.toFixed(1)} · ${deg}°`;
1193
+ const seen = staleText(live, now);
1194
+ return `x ${p.x.toFixed(1)} · y ${p.y.toFixed(1)} · ${deg}°${seen ? ` · ${seen}` : ""}`;
1034
1195
  }
1035
1196
 
1036
1197
  export type TRobotFact = { label: string; value: string; dim: boolean };
@@ -1091,6 +1252,8 @@ export function mergePose(
1091
1252
  const plan = event.planPosition as { x?: unknown; y?: unknown } | null | undefined;
1092
1253
  return {
1093
1254
  ...live,
1255
+ stale: false,
1256
+ positionAt: Date.now(),
1094
1257
  position: { x, y, theta: finite(theta) ? theta : live.position?.theta ?? 0 },
1095
1258
  ...(plan && finite(plan.x) && finite(plan.y) ? { planPosition: { x: plan.x, y: plan.y } } : {}),
1096
1259
  ...(typeof mode === "string" && mode ? { mode } : {}),
@@ -1184,6 +1347,28 @@ export function activityRow(entry: Record<string, any> | null | undefined, now:
1184
1347
  * type; option shows capability"): only brands that make that class (a brand
1185
1348
  * declaring no `types` makes cleaning robots), as "Ounitech · Basic control".
1186
1349
  */
1350
+ /** "Open alarm" / "Machine fault" badge for the fault bar: a security robot's alarm is something it SAW. */
1351
+ export function faultBadge(fault: { code?: unknown; alarm?: unknown } | null | undefined): "ALARM" | "FAULT" {
1352
+ return fault?.alarm === true || (typeof fault?.code === "string" && fault.code.startsWith("ALARM-")) ? "ALARM" : "FAULT";
1353
+ }
1354
+
1355
+ /** One past patrol as a row: "Completed · 1 min 41 s · 19.9 m · 1 alert". */
1356
+ export function patrolRecordRow(r: Record<string, any> | null | undefined, now: number = Date.now()) {
1357
+ const RESULT: Record<string, string> = { completed: "Completed", partial: "Partly completed", failed: "Failed", "in-progress": "In progress" };
1358
+ const secs = typeof r?.durationS === "number" && Number.isFinite(r.durationS) ? Math.max(0, Math.round(r.durationS)) : null;
1359
+ const dur = secs === null ? "—" : secs >= 60 ? `${Math.floor(secs / 60)} min ${secs % 60} s` : `${secs} s`;
1360
+ const dist = typeof r?.distanceM === "number" && Number.isFinite(r.distanceM) ? `${Math.round(r.distanceM * 10) / 10} m` : "—";
1361
+ const alerts = Number(r?.alarmCount) || 0;
1362
+ return {
1363
+ key: String(r?.id ?? ""),
1364
+ when: activityWhen(r?.startedAt, now),
1365
+ route: String(r?.routeName || "Patrol"),
1366
+ result: RESULT[String(r?.result)] ?? "Unknown",
1367
+ tone: (r?.result === "completed" ? "success" : r?.result === "in-progress" ? "info" : "warning") as THeroTone,
1368
+ detail: `${dur} · ${dist} · ${alerts === 1 ? "1 alert" : `${alerts} alerts`}`,
1369
+ };
1370
+ }
1371
+
1187
1372
  export function brandOptions(
1188
1373
  brands: Array<Record<string, any>> | null | undefined,
1189
1374
  type: unknown,