@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.
- package/CHANGELOG.md +16 -4
- package/assets/css/robot-hero.css +24 -1
- package/components/Robot/AlertsTable.vue +10 -1
- package/components/Robot/BuildingMap.vue +2 -0
- package/components/Robot/Detail.vue +131 -10
- package/components/Robot/Fleet.vue +13 -0
- package/components/Robot/Form.vue +2 -2
- package/components/Robot/LivePlan.vue +4 -2
- package/components/Robot/LiveView.vue +107 -0
- package/components/Robot/ManualDrive.vue +128 -0
- package/components/Robot/MiniMap.vue +2 -1
- package/components/Robot/PatrolHistory.vue +100 -0
- package/components/Robot/PointPicker.vue +33 -0
- package/composables/useRobots.ts +82 -1
- package/package.json +1 -1
- package/utils/robot-control.ts +159 -0
- package/utils/robot-credential-form.ts +2 -0
- package/utils/robot-display.ts +206 -21
package/utils/robot-display.ts
CHANGED
|
@@ -286,8 +286,10 @@ const COMMAND_NEEDS: Readonly<Record<string, string>> = {
|
|
|
286
286
|
area: "startProcess",
|
|
287
287
|
spot: "spotClean",
|
|
288
288
|
return: "sendToDock",
|
|
289
|
-
|
|
290
|
-
|
|
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 (
|
|
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) => ({
|
|
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.
|
|
606
|
-
*
|
|
607
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
844
|
-
.map((r) =>
|
|
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
|
|
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
|
-
|
|
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,
|