@7365admin1/layer-common 4.59.0 → 4.59.2
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
CHANGED
|
@@ -1,5 +1,17 @@
|
|
|
1
1
|
# @iservice365/layer-common
|
|
2
2
|
|
|
3
|
+
## 4.59.2
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- e3bedf4: Robots: Start patrol (security) picks the route from the routes on the robot's current map and the confirm names the route, its stops and the map; after "accepted" the detail watches the robot for 30 s and says "<robot> started <route>." or "<robot> didn't start - <reason>" (kept on screen); Resume patrol shows only when a patrol is paused or unfinished; Cancel only while a job is running; Start patrol hidden while patrolling.
|
|
8
|
+
|
|
9
|
+
## 4.59.1
|
|
10
|
+
|
|
11
|
+
### Patch Changes
|
|
12
|
+
|
|
13
|
+
- 95e047b: Robot detail: a robot's own map image follows the layout's height cap (280 px beside the camera, 360 px full width) instead of a fixed 440 px, and opening a robot scrolls its header to the top so the camera, map and commands are on screen.
|
|
14
|
+
|
|
3
15
|
## 4.59.0
|
|
4
16
|
|
|
5
17
|
### Minor Changes
|
|
@@ -1636,11 +1648,11 @@
|
|
|
1636
1648
|
`utils/console-tier.ts` + `composables/useConsoleTier.ts` mirror the server's
|
|
1637
1649
|
own rule from two endpoints the console already calls, unprojected:
|
|
1638
1650
|
|
|
1639
|
-
|
|
1640
|
-
|
|
1651
|
+
GET /api/members/user/:user/app/admin the Seven365 staff membership
|
|
1652
|
+
GET /api/roles/id/:role that membership's role document
|
|
1641
1653
|
|
|
1642
|
-
|
|
1643
|
-
|
|
1654
|
+
owner = member.type === "admin" && role.type === "admin" && role.default === true
|
|
1655
|
+
staff = member.type === "admin" && role.type === "admin"
|
|
1644
1656
|
|
|
1645
1657
|
`role.default` is the marker because it is the only property of a platform
|
|
1646
1658
|
staff role no API caller can set - `role.controller.ts` validates create and
|
|
@@ -809,8 +809,10 @@
|
|
|
809
809
|
.hero-detail__main { display: grid; grid-template-columns: minmax(0, 11fr) minmax(0, 9fr); gap: 20px; align-items: start; }
|
|
810
810
|
.hero-detail__main--single { grid-template-columns: minmax(0, 1fr); }
|
|
811
811
|
.hero-detail__cam, .hero-detail__map { display: flex; flex-direction: column; gap: 10px; min-width: 0; }
|
|
812
|
-
.hero-
|
|
813
|
-
.hero-detail__main--
|
|
812
|
+
.hero-detail__top { scroll-margin-top: 72px; }
|
|
813
|
+
.hero-detail__main { --hero-plan-max: 280px; }
|
|
814
|
+
.hero-detail__main--single { --hero-plan-max: 360px; }
|
|
815
|
+
.hero-detail__main .hero-plan--detail { max-height: var(--hero-plan-max); }
|
|
814
816
|
.hero-detail__main .hero-livepanel .hero-dialog__actions:empty { display: none; }
|
|
815
817
|
.hero-detail__commands { border-top: 2px solid var(--hero-divider); padding-top: 10px; }
|
|
816
818
|
.hero-detail__alertcode { color: var(--hero-accent); }
|
|
@@ -30,6 +30,8 @@ const props = defineProps<{
|
|
|
30
30
|
online?: boolean;
|
|
31
31
|
mode?: unknown;
|
|
32
32
|
type?: string;
|
|
33
|
+
/** A patrol that can be carried on (Resume patrol shows only then). */
|
|
34
|
+
resumable?: boolean;
|
|
33
35
|
/** The command in flight, if any. */
|
|
34
36
|
busy?: TRobotCommandKind | null;
|
|
35
37
|
}>();
|
|
@@ -42,6 +44,7 @@ const commands = computed(() =>
|
|
|
42
44
|
online: props.online,
|
|
43
45
|
mode: props.mode,
|
|
44
46
|
type: props.type,
|
|
47
|
+
resumable: props.resumable,
|
|
45
48
|
}),
|
|
46
49
|
);
|
|
47
50
|
</script>
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
runs -> this robot's alerts -> "What this robot can do" folded at the bottom.
|
|
9
9
|
No camera: the map takes the full width. Narrow: camera, map, commands, patrol.
|
|
10
10
|
-->
|
|
11
|
-
<div>
|
|
11
|
+
<div ref="topEl" class="hero-detail__top">
|
|
12
12
|
<button type="button" class="hero-back hero-back--sm" @click="emit('back')">‹ {{ backLabel || `${record?.site?.name || "Site"} fleet` }}</button>
|
|
13
13
|
<div class="hero-detail__title hero-detail__title--main">
|
|
14
14
|
<h2>{{ record?.name ?? "Robot" }}</h2>
|
|
@@ -121,9 +121,13 @@
|
|
|
121
121
|
:online="live?.ok === true && live.online === true"
|
|
122
122
|
:mode="statusMode"
|
|
123
123
|
:type="robotType"
|
|
124
|
+
:resumable="resumable"
|
|
124
125
|
:busy="busy"
|
|
125
126
|
@command="send"
|
|
126
127
|
/>
|
|
128
|
+
<div v-if="commandResult" class="hero-notice" :class="{ 'hero-notice--error': commandResult.warn }" role="status" aria-live="polite">
|
|
129
|
+
{{ commandResult.text }}
|
|
130
|
+
</div>
|
|
127
131
|
</div>
|
|
128
132
|
<RobotViewOnlyNotice v-if="!canCommand" />
|
|
129
133
|
</section>
|
|
@@ -182,6 +186,18 @@
|
|
|
182
186
|
<v-dialog v-model="confirmOpen" width="440">
|
|
183
187
|
<div v-if="confirming" class="hero-dialog" role="alertdialog" aria-labelledby="hero-confirm-title">
|
|
184
188
|
<div id="hero-confirm-title" class="hero-dialog__title">{{ confirming.text.title }}</div>
|
|
189
|
+
<v-select
|
|
190
|
+
v-if="confirming.routes?.length"
|
|
191
|
+
v-model="confirming.routeId"
|
|
192
|
+
:items="confirming.routes"
|
|
193
|
+
item-title="name"
|
|
194
|
+
item-value="id"
|
|
195
|
+
label="Patrol route"
|
|
196
|
+
density="compact"
|
|
197
|
+
hide-details
|
|
198
|
+
class="mb-2"
|
|
199
|
+
@update:model-value="pickRoute"
|
|
200
|
+
/>
|
|
185
201
|
<div class="hero-dialog__body">{{ confirming.text.body }}</div>
|
|
186
202
|
<div class="hero-dialog__actions">
|
|
187
203
|
<v-btn variant="outlined" @click="confirmOpen = false">Don't send</v-btn>
|
|
@@ -258,7 +274,11 @@ import {
|
|
|
258
274
|
capabilityLabel,
|
|
259
275
|
cleaningScheduleRows,
|
|
260
276
|
commandBody,
|
|
277
|
+
COMMAND_WATCH_MS,
|
|
261
278
|
commandConfirm,
|
|
279
|
+
commandOutcome,
|
|
280
|
+
patrolResumable,
|
|
281
|
+
patrolStartRoutes,
|
|
262
282
|
commandLabel,
|
|
263
283
|
faultBadge,
|
|
264
284
|
headingDeg,
|
|
@@ -332,6 +352,7 @@ const {
|
|
|
332
352
|
} = useRobots();
|
|
333
353
|
|
|
334
354
|
const record = ref<Record<string, any> | null>(null);
|
|
355
|
+
const topEl = ref<HTMLElement | null>(null);
|
|
335
356
|
const brands = ref<Array<Record<string, any>>>([]);
|
|
336
357
|
const error = ref("");
|
|
337
358
|
const toast = ref("");
|
|
@@ -406,7 +427,7 @@ const makerApp = computed(() => (live.value?.ok ? live.value.profile?.makerApp :
|
|
|
406
427
|
|
|
407
428
|
const online = computed(() => live.value?.ok === true && live.value.online === true);
|
|
408
429
|
const commands = computed(() =>
|
|
409
|
-
visibleCommands({ capabilities: caps.value, canCommand: props.canCommand, online: online.value, mode: statusMode.value, type: robotType.value }),
|
|
430
|
+
visibleCommands({ capabilities: caps.value, canCommand: props.canCommand, online: online.value, mode: statusMode.value, type: robotType.value, resumable: resumable.value }),
|
|
410
431
|
);
|
|
411
432
|
|
|
412
433
|
/* ---- the plan ---- */
|
|
@@ -469,6 +490,7 @@ const unplacedRoutes = computed(() =>
|
|
|
469
490
|
const runs = ref<any[]>([]);
|
|
470
491
|
const runsLoaded = ref(false);
|
|
471
492
|
const detected = computed(() => detectPatrolSchedule(runs.value, now.value));
|
|
493
|
+
const resumable = computed(() => patrolResumable(runs.value));
|
|
472
494
|
/** Newest first, 100 a page, until a page reaches 30 days back. ponytail: capped at 5 pages (500 runs); raise if a robot patrols more than ~16x a day. */
|
|
473
495
|
async function loadRuns() {
|
|
474
496
|
const since = Date.now() - 30 * 86_400_000;
|
|
@@ -558,22 +580,54 @@ watch([reportsPosition, online], ([r, o], old) => {
|
|
|
558
580
|
|
|
559
581
|
/* ---- commands ---- */
|
|
560
582
|
const busy = ref<TRobotCommandKind | "spot" | null>(null);
|
|
561
|
-
|
|
583
|
+
/** The last command's real outcome, kept on screen (a toast vanishes in 2.6 s). */
|
|
584
|
+
const commandResult = ref<{ text: string; warn: boolean } | null>(null);
|
|
585
|
+
const watching = ref<{ kind: string; at: number; what?: string } | null>(null);
|
|
586
|
+
watch([lastAt, now], () => {
|
|
587
|
+
const w = watching.value;
|
|
588
|
+
const at = lastAt.value?.getTime() ?? 0;
|
|
589
|
+
// Only a reading taken after the command was accepted counts (never the optimistic status).
|
|
590
|
+
if (!w || at <= w.at + 300) return;
|
|
591
|
+
const out = commandOutcome(w.kind, polled.value?.ok ? polled.value : null, Date.now() - w.at, { name: record.value?.name, what: w.what });
|
|
592
|
+
if (!out) return;
|
|
593
|
+
watching.value = null;
|
|
594
|
+
commandResult.value = { text: out.text, warn: !out.ok };
|
|
595
|
+
if (!out.ok) optimistic.value = null;
|
|
596
|
+
});
|
|
597
|
+
watch(
|
|
598
|
+
() => props.robotId,
|
|
599
|
+
() => {
|
|
600
|
+
commandResult.value = null;
|
|
601
|
+
watching.value = null;
|
|
602
|
+
},
|
|
603
|
+
);
|
|
604
|
+
async function run(kind: TRobotCommandKind | "spot", body: Record<string, unknown>, what?: string) {
|
|
562
605
|
const name = record.value?.name ?? "the robot";
|
|
563
606
|
const before = statusMode.value;
|
|
564
607
|
busy.value = kind;
|
|
565
608
|
optimistic.value = { mode: optimisticMode(kind, before), after: Number.POSITIVE_INFINITY };
|
|
566
|
-
const label = kind === "full" && robotType.value === "security" ? "Start patrol" : commandLabel(kind);
|
|
609
|
+
const label = (kind === "full" || kind === "area") && robotType.value === "security" ? "Start patrol" : commandLabel(kind);
|
|
567
610
|
toast.value = `${label} → ${name} · sending…`;
|
|
611
|
+
commandResult.value = { text: `${label} → ${name} · sending…`, warn: false };
|
|
612
|
+
watching.value = null;
|
|
568
613
|
try {
|
|
569
614
|
await sendRobotCommand(props.robotId, body as any);
|
|
570
615
|
// A poll already in flight may still carry the old status; wait past it.
|
|
571
616
|
optimistic.value = optimistic.value ? { ...optimistic.value, after: Date.now() + 300 } : null;
|
|
572
|
-
toast.value = `${label} → ${name} · accepted
|
|
617
|
+
toast.value = `${label} → ${name} · accepted.`;
|
|
618
|
+
// Resume patrol follows the last run; re-read it once the robot has had time to act.
|
|
619
|
+
if (robotType.value === "security" && caps.value?.patrolHistory === true) setTimeout(loadRuns, 5000);
|
|
620
|
+
// "Accepted" is not "done": watch real readings for up to 30 s (commandOutcome).
|
|
621
|
+
const outcomeKind = body.kind === "go-to-point" ? "go-to-point" : kind;
|
|
622
|
+
if (commandOutcome(outcomeKind, { mode: "" }, COMMAND_WATCH_MS) !== null) {
|
|
623
|
+
watching.value = { kind: String(outcomeKind), at: Date.now(), what };
|
|
624
|
+
commandResult.value = { text: `${label} → ${name} · accepted. Waiting for the robot to move…`, warn: false };
|
|
625
|
+
} else commandResult.value = { text: `${label} → ${name} · accepted.`, warn: false };
|
|
573
626
|
return true;
|
|
574
627
|
} catch (e) {
|
|
575
628
|
optimistic.value = null;
|
|
576
629
|
toast.value = robotErrorMessage(e);
|
|
630
|
+
commandResult.value = { text: `${label} → ${name} · not sent: ${robotErrorMessage(e)}`, warn: true };
|
|
577
631
|
return false;
|
|
578
632
|
} finally {
|
|
579
633
|
busy.value = null;
|
|
@@ -597,6 +651,20 @@ async function send(kind: TRobotCommandKind) {
|
|
|
597
651
|
return;
|
|
598
652
|
}
|
|
599
653
|
}
|
|
654
|
+
// Start patrol (security): pick the route - the robot's own map's routes, first one preselected.
|
|
655
|
+
if (kind === "full" && robotType.value === "security") {
|
|
656
|
+
const routes = patrolStartRoutes(patrol.value?.routes, live.value?.mapId ?? null);
|
|
657
|
+
if (!routes.length && (patrol.value?.routes?.length ?? 0) > 0) {
|
|
658
|
+
commandResult.value = { text: `None of the robot's patrol routes are on ${currentMapLabel.value || "the map it is on"}. Move it to a route's map first.`, warn: true };
|
|
659
|
+
return;
|
|
660
|
+
}
|
|
661
|
+
if (routes.length) {
|
|
662
|
+
confirming.value = { kind: "area", body: {}, text: { title: "", body: "", action: "" }, routes, routeId: routes[0]!.id };
|
|
663
|
+
pickRoute(routes[0]!.id);
|
|
664
|
+
confirmOpen.value = true;
|
|
665
|
+
return;
|
|
666
|
+
}
|
|
667
|
+
}
|
|
600
668
|
const body = commandBody(kind, live.value);
|
|
601
669
|
if (!body) {
|
|
602
670
|
toast.value = `${commandLabel(kind)}: the robot's position is not known yet.`;
|
|
@@ -607,11 +675,31 @@ async function send(kind: TRobotCommandKind) {
|
|
|
607
675
|
confirmOpen.value = true;
|
|
608
676
|
}
|
|
609
677
|
const confirmOpen = ref(false);
|
|
610
|
-
const confirming = ref<{
|
|
678
|
+
const confirming = ref<{
|
|
679
|
+
kind: TRobotCommandKind;
|
|
680
|
+
body: Record<string, unknown>;
|
|
681
|
+
text: { title: string; body: string; action: string };
|
|
682
|
+
routes?: TPatrolRouteView[];
|
|
683
|
+
routeId?: string;
|
|
684
|
+
what?: string;
|
|
685
|
+
} | null>(null);
|
|
686
|
+
/** The route picked in Start patrol's confirm: its exact words, and the body naming it. */
|
|
687
|
+
function pickRoute(id: unknown) {
|
|
688
|
+
const c = confirming.value;
|
|
689
|
+
const route = c?.routes?.find((r) => r.id === id);
|
|
690
|
+
if (!c || !route) return;
|
|
691
|
+
const stops = route.stops?.length ? route.stops : route.checkpoints.map((p) => p.name);
|
|
692
|
+
const map = currentMapLabel.value || "";
|
|
693
|
+
const name = route.name.replace(/ \(default\)$/, "");
|
|
694
|
+
c.routeId = route.id;
|
|
695
|
+
c.body = { kind: "area", areas: [route.id] };
|
|
696
|
+
c.what = /^patrol/i.test(name) ? name : `patrolling ${name}`;
|
|
697
|
+
c.text = commandConfirm("area", { type: "security", name: record.value?.name, route: { name, stops }, map });
|
|
698
|
+
}
|
|
611
699
|
function confirmSend() {
|
|
612
700
|
const c = confirming.value;
|
|
613
701
|
confirmOpen.value = false;
|
|
614
|
-
if (c) run(c.kind, c.body);
|
|
702
|
+
if (c) run(c.kind, c.body, c.what);
|
|
615
703
|
}
|
|
616
704
|
|
|
617
705
|
/** The robot's plan image size for its floor, for turning plan clicks into pixels. */
|
|
@@ -657,7 +745,7 @@ function onPick(p: TSavedPoint) {
|
|
|
657
745
|
toast.value = "Set position: the robot must be on this map first.";
|
|
658
746
|
return;
|
|
659
747
|
}
|
|
660
|
-
run(kind, body);
|
|
748
|
+
run(kind, body, kind === "navigate" ? `going to ${p.name}` : undefined);
|
|
661
749
|
}
|
|
662
750
|
|
|
663
751
|
/* ---- security-robot extras: live view (minted per open, never kept), manual control, resolve an alarm ---- */
|
|
@@ -745,6 +833,8 @@ watch(
|
|
|
745
833
|
runsLoaded.value = false;
|
|
746
834
|
siteAlerts.value = [];
|
|
747
835
|
await loadRecord();
|
|
836
|
+
// Open with the robot's header at the top, so camera + map are on screen (the Super Admin page has pickers above).
|
|
837
|
+
topEl.value?.scrollIntoView?.({ block: "start" });
|
|
748
838
|
loadSchedule();
|
|
749
839
|
loadMaps();
|
|
750
840
|
loadAlerts();
|
|
@@ -35,11 +35,11 @@ const props = defineProps<{
|
|
|
35
35
|
/** That image's width / height, so the plan keeps its shape. */
|
|
36
36
|
aspect?: number | null;
|
|
37
37
|
}>();
|
|
38
|
-
/** The image's own shape; the detail plan's 440 px
|
|
38
|
+
/** The image's own shape; the detail plan's height cap (--hero-plan-max, 440 px) narrows the width instead of squashing it. */
|
|
39
39
|
const frameStyle = computed(() => {
|
|
40
40
|
const a = props.image && props.aspect && Number.isFinite(props.aspect) && props.aspect > 0 ? props.aspect : null;
|
|
41
41
|
if (!a) return undefined;
|
|
42
|
-
return { aspectRatio: String(a), ...(props.detail ? { width: `min(100%,
|
|
42
|
+
return { aspectRatio: String(a), ...(props.detail ? { width: `min(100%, calc(var(--hero-plan-max, 440px) * ${a.toFixed(4)}))`, maxHeight: "none", marginInline: "auto" } : {}) };
|
|
43
43
|
});
|
|
44
44
|
const emit = defineEmits<{ pick: [point: { x: number; y: number }] }>();
|
|
45
45
|
const root = ref<HTMLElement | null>(null);
|
package/package.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"name": "@7365admin1/layer-common",
|
|
3
3
|
"license": "MIT",
|
|
4
4
|
"type": "module",
|
|
5
|
-
"version": "4.59.
|
|
5
|
+
"version": "4.59.2",
|
|
6
6
|
"author": "7365admin1",
|
|
7
7
|
"main": "./nuxt.config.ts",
|
|
8
8
|
"//files": "What a consumer extending this layer actually loads. Without this npm ships the whole working tree - the changesets, the CI workflows, the render harness in tools/ and any scratch directory that happened to exist at publish time. Nuxt resolves a layer by directory, so every runtime directory below has to stay listed; adding a new top-level runtime directory means adding it here too.",
|
package/utils/robot-display.ts
CHANGED
|
@@ -404,6 +404,9 @@ const COMMANDS: ReadonlyArray<{ kind: TRobotCommandKind; label: string }> = [
|
|
|
404
404
|
{ kind: "patrol-resume", label: "Resume patrol" },
|
|
405
405
|
];
|
|
406
406
|
|
|
407
|
+
/** A job is in hand (something for Cancel to stop). */
|
|
408
|
+
const ACTIVE: ReadonlySet<string> = new Set(["WORKING", "IN_TRANSIT", "PAUSED", "MANUAL"]);
|
|
409
|
+
|
|
407
410
|
export type TRobotCommand = { kind: TRobotCommandKind; label: string; primary: boolean };
|
|
408
411
|
|
|
409
412
|
/**
|
|
@@ -412,10 +415,12 @@ export type TRobotCommand = { kind: TRobotCommandKind; label: string; primary: b
|
|
|
412
415
|
* - brand: only commands its declared capabilities support;
|
|
413
416
|
* - role: only a Manager (`canCommand`, i.e. the app's command permission);
|
|
414
417
|
* - online: only a robot confirmed online (`online === true`);
|
|
415
|
-
* - context: Pause only while working, Resume only while paused
|
|
418
|
+
* - context: Pause only while working, Resume only while paused, Cancel only
|
|
419
|
+
* while there is a job to stop (working, moving or paused);
|
|
416
420
|
* - type: a security robot has no cleaning jobs, so no Clean all / Clean area -
|
|
417
|
-
* but a brand with `patrolControl` gets Start patrol (
|
|
418
|
-
* Resume patrol
|
|
421
|
+
* but a brand with `patrolControl` gets Start patrol (a route picker) and
|
|
422
|
+
* Resume patrol - only while a patrol is paused or its last run did not
|
|
423
|
+
* finish (`resumable`), never "just in case".
|
|
419
424
|
*
|
|
420
425
|
* Returns [] whenever the bar should not render at all.
|
|
421
426
|
*/
|
|
@@ -425,16 +430,19 @@ export function visibleCommands(input: {
|
|
|
425
430
|
online?: unknown;
|
|
426
431
|
mode?: unknown;
|
|
427
432
|
type?: unknown;
|
|
433
|
+
/** A patrol that can be carried on: the last run did not finish (in progress / partial / failed). */
|
|
434
|
+
resumable?: unknown;
|
|
428
435
|
} | null | undefined): TRobotCommand[] {
|
|
429
436
|
if (!input || input.canCommand !== true || input.online !== true) return [];
|
|
430
437
|
const caps = input.capabilities ?? {};
|
|
431
438
|
const patrol = input.type === "security" && caps.patrolControl === true;
|
|
432
439
|
return COMMANDS.filter((c) => {
|
|
433
|
-
if (c.kind === "patrol-resume") return patrol && input.mode !== "WORKING" && input.mode !== "IN_TRANSIT";
|
|
440
|
+
if (c.kind === "patrol-resume") return patrol && (input.mode === "PAUSED" || (input.resumable === true && input.mode !== "WORKING" && input.mode !== "IN_TRANSIT"));
|
|
434
441
|
if (caps[COMMAND_NEEDS[c.kind]!] !== true) return false;
|
|
435
442
|
if (c.kind === "pause" && input.mode !== "WORKING") return false;
|
|
436
443
|
if (c.kind === "resume" && input.mode !== "PAUSED") return false;
|
|
437
|
-
if (c.kind === "
|
|
444
|
+
if (c.kind === "cancel" && !ACTIVE.has(String(input.mode))) return false;
|
|
445
|
+
if (c.kind === "full" && input.type === "security") return patrol && !ACTIVE.has(String(input.mode)); // one patrol at a time
|
|
438
446
|
if (c.kind === "area" && input.type === "security") return false;
|
|
439
447
|
return true;
|
|
440
448
|
}).map((c) => ({
|
|
@@ -1645,14 +1653,21 @@ export function scheduleElsewhereText(type: unknown, brand: unknown, profile?: T
|
|
|
1645
1653
|
*/
|
|
1646
1654
|
export function commandConfirm(
|
|
1647
1655
|
kind: unknown,
|
|
1648
|
-
input: { type?: unknown; name?: unknown; point?: unknown } = {},
|
|
1656
|
+
input: { type?: unknown; name?: unknown; point?: unknown; route?: { name?: unknown; stops?: unknown } | null; map?: unknown } = {},
|
|
1649
1657
|
): { title: string; body: string; action: string } {
|
|
1650
1658
|
const who = (typeof input.name === "string" && input.name) || "The robot";
|
|
1651
1659
|
const at = typeof input.point === "string" && input.point ? input.point : "";
|
|
1652
|
-
const
|
|
1660
|
+
const patrol = input.type === "security" && (kind === "full" || kind === "area");
|
|
1661
|
+
const label = patrol ? "Start patrol" : commandLabel(kind);
|
|
1662
|
+
const route = input.route && typeof input.route.name === "string" ? input.route : null;
|
|
1663
|
+
const stops = Array.isArray(route?.stops) ? (route!.stops as unknown[]).filter((s) => typeof s === "string" && s) : [];
|
|
1664
|
+
const onMap = typeof input.map === "string" && input.map ? ` on ${input.map}` : "";
|
|
1665
|
+
const walk = route
|
|
1666
|
+
? `${who} walks "${route.name}" once${stops.length ? ` (${stops.join(" → ")})` : ""}${onMap}, then stops.`
|
|
1667
|
+
: `${who} starts walking its default patrol route once.`;
|
|
1653
1668
|
const body: Record<string, string> = {
|
|
1654
|
-
full: input.type === "security" ?
|
|
1655
|
-
area: `${who} starts cleaning the chosen areas.`,
|
|
1669
|
+
full: input.type === "security" ? walk : `${who} starts cleaning the whole floor it is on.`,
|
|
1670
|
+
area: input.type === "security" ? walk : `${who} starts cleaning the chosen areas.`,
|
|
1656
1671
|
return: `${who} stops what it is doing and drives back to its charger.`,
|
|
1657
1672
|
pause: `${who} stops where it is and waits. Press Resume to carry on.`,
|
|
1658
1673
|
resume: `${who} carries on with the job it paused.`,
|
|
@@ -1666,6 +1681,72 @@ export function commandConfirm(
|
|
|
1666
1681
|
return { title: `${label}?`, body: body[String(kind)] ?? `${who}: ${label}.`, action: label };
|
|
1667
1682
|
}
|
|
1668
1683
|
|
|
1684
|
+
/**
|
|
1685
|
+
* Start patrol's route choices: the routes on the map the robot is on (a route
|
|
1686
|
+
* on another map is refused by the server - the robot cannot walk it from
|
|
1687
|
+
* here). Unknown current map, or routes that say no map: all of them. The
|
|
1688
|
+
* default pick is the first (the server lists the walked / current-map /
|
|
1689
|
+
* default route first).
|
|
1690
|
+
*/
|
|
1691
|
+
export function patrolStartRoutes(routes: TPatrolRouteView[] | null | undefined, currentMapId: unknown): TPatrolRouteView[] {
|
|
1692
|
+
const all = (Array.isArray(routes) ? routes : []).filter((r) => r && typeof r.id === "string" && r.id);
|
|
1693
|
+
if (typeof currentMapId !== "string" || !currentMapId) return all;
|
|
1694
|
+
return all.filter((r) => !r.mapId || r.mapId === currentMapId);
|
|
1695
|
+
}
|
|
1696
|
+
|
|
1697
|
+
/** Last patrol run not finished, so Resume patrol has something to carry on (PadBot §6.7 completionState 0/1/3). */
|
|
1698
|
+
export function patrolResumable(runs: Array<{ result?: unknown }> | null | undefined): boolean {
|
|
1699
|
+
const r = Array.isArray(runs) ? runs[0]?.result : undefined;
|
|
1700
|
+
return r === "in-progress" || r === "partial" || r === "failed";
|
|
1701
|
+
}
|
|
1702
|
+
|
|
1703
|
+
/** How long after "accepted" we wait for the robot to show it acted. */
|
|
1704
|
+
export const COMMAND_WATCH_MS = 30_000;
|
|
1705
|
+
|
|
1706
|
+
const GOING = ["WORKING", "IN_TRANSIT", "PAUSED"];
|
|
1707
|
+
const STARTED: Readonly<Record<string, ReadonlySet<string>>> = {
|
|
1708
|
+
full: new Set(GOING),
|
|
1709
|
+
area: new Set(GOING),
|
|
1710
|
+
"patrol-resume": new Set(GOING),
|
|
1711
|
+
"go-to-point": new Set(GOING),
|
|
1712
|
+
navigate: new Set(GOING),
|
|
1713
|
+
return: new Set([...GOING, "CHARGING_CYCLE", "CHARGED"]),
|
|
1714
|
+
};
|
|
1715
|
+
const RESTING = /^(Standing by|Charging|Charged|Working on a task)$/;
|
|
1716
|
+
|
|
1717
|
+
/**
|
|
1718
|
+
* What an accepted command actually did, from a reading taken AFTER it was
|
|
1719
|
+
* sent (never the optimistic status). The robot cloud's "accepted" is not
|
|
1720
|
+
* "done": PadBot answers code 0 and the robot may still not move.
|
|
1721
|
+
* - started: the robot shows it acted -> `${who} started ${what}.`
|
|
1722
|
+
* - still resting after COMMAND_WATCH_MS -> `${who} didn't start — <reason>`
|
|
1723
|
+
* (the robot's own words when it gave any, e.g. "Could not reach P2 (...)").
|
|
1724
|
+
* - otherwise: keep watching (null).
|
|
1725
|
+
* Commands without a visible outcome (cancel, set position, pause) -> null.
|
|
1726
|
+
*/
|
|
1727
|
+
export function commandOutcome(
|
|
1728
|
+
kind: unknown,
|
|
1729
|
+
reading: { online?: unknown; mode?: unknown; statusName?: unknown; fault?: { message?: unknown } | null } | null | undefined,
|
|
1730
|
+
elapsedMs: number,
|
|
1731
|
+
input: { name?: unknown; what?: unknown } = {},
|
|
1732
|
+
): { ok: boolean; text: string } | null {
|
|
1733
|
+
const started = STARTED[String(kind)];
|
|
1734
|
+
if (!started || !reading) return null;
|
|
1735
|
+
const who = (typeof input.name === "string" && input.name) || "The robot";
|
|
1736
|
+
const what = typeof input.what === "string" && input.what ? input.what : kind === "return" ? "going back to its charger" : "moving";
|
|
1737
|
+
if (typeof reading.mode === "string" && started.has(reading.mode)) return { ok: true, text: `${who} started ${what}.` };
|
|
1738
|
+
if (elapsedMs < COMMAND_WATCH_MS) return null;
|
|
1739
|
+
const said = typeof reading.statusName === "string" ? reading.statusName.split(" · ")[0]!.trim() : "";
|
|
1740
|
+
const fault = typeof reading.fault?.message === "string" ? reading.fault.message : "";
|
|
1741
|
+
const reason = reading.online === false ? "it stopped reporting" : fault || (said && !RESTING.test(said) ? said : "");
|
|
1742
|
+
return {
|
|
1743
|
+
ok: false,
|
|
1744
|
+
text: reason
|
|
1745
|
+
? `${who} didn't start — ${reason}.`
|
|
1746
|
+
: `${who} didn't start — the robot cloud accepted the command, but the robot is still ${said ? said.toLowerCase() : "idle"}. Check the robot or its maker's app.`,
|
|
1747
|
+
};
|
|
1748
|
+
}
|
|
1749
|
+
|
|
1669
1750
|
/** The Brand column: "PadBot S2", "Demo S1 (simulated)", or just "Ounitech" when no model is reported. */
|
|
1670
1751
|
export function brandModelText(
|
|
1671
1752
|
brand: unknown,
|