@7365admin1/layer-common 4.53.0 → 4.54.1
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 +20 -0
- package/components/Robot/AlertsTable.vue +10 -1
- package/components/Robot/BuildingMap.vue +2 -0
- package/components/Robot/Detail.vue +95 -29
- package/components/Robot/Fleet.vue +13 -0
- 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/PointPicker.vue +33 -0
- package/composables/useRobots.ts +43 -2
- package/package.json +1 -1
- package/utils/robot-control.ts +159 -0
- package/utils/robot-display.ts +172 -15
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,17 @@
|
|
|
1
1
|
# @iservice365/layer-common
|
|
2
2
|
|
|
3
|
+
## 4.54.1
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- 401e0a2: Robot detail: when the robot cannot be read, show the API's plain reason (e.g. "Ounitech rejected the robot list (sign-in) ...") under the title instead of only "Reconnecting". `mergeLiveStates` carries the optional `reason` from `/robots/states`.
|
|
8
|
+
|
|
9
|
+
## 4.54.0
|
|
10
|
+
|
|
11
|
+
### Minor Changes
|
|
12
|
+
|
|
13
|
+
- 6bfc7d1: Robots (PadBot S2 and all brands): a robot no longer vanishes from the map on one missed or failed reading - the last position is kept for up to 15 s, drawn faded with "last seen N s ago", then "Reconnecting"; the floor becomes unknown only when the robot says so; an online robot without a position reads "Position not reported right now". Patrol routes whose stops cannot be placed on the plan are listed ("Stops: Gate → Lobby"), never drawn at a guessed spot; when the brand keeps schedules to itself (`scheduleShared: false`) the panel says so and shows recent patrol runs plus a clearly labelled guess ("Looks like ... runs daily around 20:00"). Live view opens in the page (HD / Smooth, re-minted before expiry, never stored; iframe / native video / "Open in new tab" / RTMP explanation), also from the fleet row menu. Alert photos ("Photo", "Thermal photo") on the fault bar and Alerts rows. Go to point / Set position pick from the robot's saved points when its maps list them (`go-to-point`; Set position only on the robot's floor, with a confirmation line). Manual control (only when the server reports `manualDrive` and the user can update robots): hold-to-move with a big STOP, head controls, heartbeat-guarded session that ends on release, tab hide, close or connection loss.
|
|
14
|
+
|
|
3
15
|
## 4.53.0
|
|
4
16
|
|
|
5
17
|
### Minor Changes
|
|
@@ -1563,11 +1575,11 @@
|
|
|
1563
1575
|
`utils/console-tier.ts` + `composables/useConsoleTier.ts` mirror the server's
|
|
1564
1576
|
own rule from two endpoints the console already calls, unprojected:
|
|
1565
1577
|
|
|
1566
|
-
|
|
1567
|
-
|
|
1578
|
+
GET /api/members/user/:user/app/admin the Seven365 staff membership
|
|
1579
|
+
GET /api/roles/id/:role that membership's role document
|
|
1568
1580
|
|
|
1569
|
-
|
|
1570
|
-
|
|
1581
|
+
owner = member.type === "admin" && role.type === "admin" && role.default === true
|
|
1582
|
+
staff = member.type === "admin" && role.type === "admin"
|
|
1571
1583
|
|
|
1572
1584
|
`role.default` is the marker because it is the only property of a platform
|
|
1573
1585
|
staff role no API caller can set - `role.controller.ts` validates create and
|
|
@@ -1237,3 +1237,23 @@ div.hero-editor__wp.hero-editor__sel, div.hero-editor__fid.hero-editor__sel { ou
|
|
|
1237
1237
|
|
|
1238
1238
|
/* PadBot security extras: live video button row under the facts. */
|
|
1239
1239
|
.hero-detail__video { margin: 10px 0 4px; }
|
|
1240
|
+
|
|
1241
|
+
/* A kept (stale) reading: faded, no ping - never drawn as live (owner 2026-09-26). */
|
|
1242
|
+
.hero-bot--stale, .hero-me--stale { opacity: 0.45; }
|
|
1243
|
+
.hero-bot--stale .hero-bot__ping, .hero-bot--stale .hero-bmap__ping, .hero-me--stale .hero-me__ping, .hero-me--stale .hero-me__radar { display: none; }
|
|
1244
|
+
.hero-sched__hint { font-size: 12px; font-style: italic; opacity: 0.75; margin: 4px 0; }
|
|
1245
|
+
|
|
1246
|
+
/* Live view / manual control / point picker dialogs */
|
|
1247
|
+
.hero-live__head { display: flex; align-items: center; gap: 8px; }
|
|
1248
|
+
.hero-live__quality button, .hero-drive__head button, .hero-pick__point { border: 1px solid var(--hero-line, currentColor); background: none; color: inherit; font: inherit; padding: 4px 10px; cursor: pointer; }
|
|
1249
|
+
.hero-live__quality button[aria-pressed="true"], .hero-pick__point[aria-pressed="true"] { background: var(--hero-accent); color: var(--hero-bg); }
|
|
1250
|
+
.hero-live__stage { min-height: 240px; display: flex; align-items: center; justify-content: center; background: #000; color: #fff; }
|
|
1251
|
+
.hero-live__video { width: 100%; aspect-ratio: 16 / 9; border: 0; background: #000; }
|
|
1252
|
+
.hero-live__copy { margin-top: 8px; }
|
|
1253
|
+
.hero-live__open { align-self: center; }
|
|
1254
|
+
.hero-drive__stop { width: 100%; padding: 18px; font-size: 24px; font-weight: 800; letter-spacing: 0.08em; color: #fff; background: #c62828; border: 0; cursor: pointer; }
|
|
1255
|
+
.hero-drive__pad { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; }
|
|
1256
|
+
.hero-drive__pad button { padding: 16px 8px; border: 1px solid var(--hero-line, currentColor); background: var(--hero-surface); color: inherit; font: inherit; cursor: pointer; touch-action: none; user-select: none; }
|
|
1257
|
+
.hero-drive__pad button:active { background: var(--hero-accent); color: var(--hero-bg); }
|
|
1258
|
+
.hero-drive__head { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; }
|
|
1259
|
+
.hero-pick__group { display: flex; flex-wrap: wrap; gap: 6px; align-items: center; margin-bottom: 6px; }
|
|
@@ -21,7 +21,10 @@
|
|
|
21
21
|
@click:row="onRow"
|
|
22
22
|
>
|
|
23
23
|
<template #item.code="{ item }"><span class="hero-alert__code">{{ attentionText(item).code }}</span></template>
|
|
24
|
-
<template #item.message="{ item }">
|
|
24
|
+
<template #item.message="{ item }">
|
|
25
|
+
{{ attentionText(item).message }}
|
|
26
|
+
<template v-for="ph in photosOf(item)" :key="ph.label"> · <a :href="ph.url" target="_blank" rel="noopener noreferrer" @click.stop>{{ ph.label }}</a></template>
|
|
27
|
+
</template>
|
|
25
28
|
<template #item.robot="{ item }"><span class="hero-table__name">{{ item.robot?.name }}</span></template>
|
|
26
29
|
<template #item.site="{ item }"><span class="hero-dim">{{ item.client?.name }} · {{ item.site?.name }}</span></template>
|
|
27
30
|
<template #item.floor="{ item }">
|
|
@@ -36,6 +39,7 @@
|
|
|
36
39
|
import RobotFloorLabel from "./FloorLabel.vue";
|
|
37
40
|
import RobotTable from "./Table.vue";
|
|
38
41
|
import { attentionText, sinceText } from "../../utils/robot-display";
|
|
42
|
+
import { faultPhotos } from "../../utils/robot-control";
|
|
39
43
|
|
|
40
44
|
const props = withDefaults(
|
|
41
45
|
defineProps<{
|
|
@@ -57,6 +61,11 @@ function floorOf(item: any) {
|
|
|
57
61
|
const s = props.states[String(item?.robot?._id ?? "")];
|
|
58
62
|
return s?.ok ? { floorNo: s.floorNo, floorKnown: s.floorKnown === true } : { floorNo: null, floorKnown: false };
|
|
59
63
|
}
|
|
64
|
+
/** The robot's photos for this alert, when its live fault is this one and carries them. */
|
|
65
|
+
function photosOf(item: any) {
|
|
66
|
+
const f = props.states[String(item?.robot?._id ?? "")]?.fault;
|
|
67
|
+
return f && f.code === item?.code ? faultPhotos(f) : [];
|
|
68
|
+
}
|
|
60
69
|
function onRow(_e: unknown, { item }: { item: any }) {
|
|
61
70
|
emit("open", item);
|
|
62
71
|
}
|
|
@@ -163,6 +163,8 @@
|
|
|
163
163
|
v-for="r in f.robots"
|
|
164
164
|
:key="r.id"
|
|
165
165
|
class="hero-bmap__anchor hero-bmap__bot"
|
|
166
|
+
:class="{ 'hero-bot--stale': r.stale }"
|
|
167
|
+
:title="r.stale ? `${r.name} · ${r.seen}` : undefined"
|
|
166
168
|
:style="{ left: r.left, top: r.top, transition: `left ${tickMs}ms linear, top ${tickMs}ms linear`, '--hero-bot': toneColor(r) }"
|
|
167
169
|
>
|
|
168
170
|
<span class="hero-bmap__ping" aria-hidden="true" />
|
|
@@ -20,14 +20,18 @@
|
|
|
20
20
|
</div>
|
|
21
21
|
|
|
22
22
|
<div v-if="error" class="hero-notice" role="alert">{{ error }}</div>
|
|
23
|
+
<div v-else-if="live && !live.ok && live.reason" class="hero-notice" role="status">Can't read this robot right now: {{ live.reason }}</div>
|
|
23
24
|
|
|
24
25
|
<div v-if="fault" class="hero-faultbar" role="alert">
|
|
25
26
|
<span class="hero-faultbar__code">{{ fault.code }}</span>
|
|
26
27
|
<div>
|
|
27
28
|
<div class="hero-faultbar__msg">{{ fault.message || fault.code }}</div>
|
|
28
|
-
<div class="hero-faultbar__since">
|
|
29
|
+
<div class="hero-faultbar__since">
|
|
30
|
+
Since {{ faultSince }} · notified once
|
|
31
|
+
<template v-for="ph in faultPhotos(fault)" :key="ph.label"> · <a :href="ph.url" target="_blank" rel="noopener noreferrer">{{ ph.label }}</a></template>
|
|
32
|
+
</div>
|
|
29
33
|
</div>
|
|
30
|
-
<v-btn v-if="caps?.liveVideo === true && online" variant="outlined" size="small"
|
|
34
|
+
<v-btn v-if="caps?.liveVideo === true && online" variant="outlined" size="small" @click="liveOpen = true">Live view</v-btn>
|
|
31
35
|
<v-btn v-if="canResolve" variant="outlined" size="small" :loading="resolving" @click="resolveFault">Mark resolved</v-btn>
|
|
32
36
|
<span class="hero-faultbar__badge">{{ faultBadge(fault) }}</span>
|
|
33
37
|
</div>
|
|
@@ -41,8 +45,9 @@
|
|
|
41
45
|
</div>
|
|
42
46
|
</div>
|
|
43
47
|
<div v-if="notReported" class="hero-notreported">{{ notReported }}</div>
|
|
44
|
-
<div v-if="caps?.liveVideo === true && online && !fault" class="hero-detail__video">
|
|
45
|
-
<v-btn
|
|
48
|
+
<div v-if="(caps?.liveVideo === true && online && !fault) || canDrive" class="hero-detail__video">
|
|
49
|
+
<v-btn v-if="caps?.liveVideo === true && online && !fault" variant="outlined" @click="liveOpen = true">Live view</v-btn>
|
|
50
|
+
<v-btn v-if="canDrive" variant="outlined" @click="driveOpen = true">Manual control</v-btn>
|
|
46
51
|
</div>
|
|
47
52
|
|
|
48
53
|
<div v-if="commands.length">
|
|
@@ -67,10 +72,26 @@
|
|
|
67
72
|
robotType === "security" ? "days · start–end · floor · checkpoints × rounds" : "days · start–end · jobs per floor · passes"
|
|
68
73
|
}}</span>
|
|
69
74
|
</div>
|
|
70
|
-
<
|
|
75
|
+
<template v-if="!schedule.length && robotType === 'security' && patrol?.scheduleShared === false">
|
|
76
|
+
<div class="hero-sched__empty">Patrol schedules are set in the PadBot app. PadBot doesn't share them with us yet.</div>
|
|
77
|
+
<div v-if="runHint" class="hero-sched__hint">{{ runHint }} - a guess from recent runs, not the schedule.</div>
|
|
78
|
+
<template v-if="recentRuns.length">
|
|
79
|
+
<div class="hero-aside__hint">Recent patrol runs</div>
|
|
80
|
+
<div v-for="r in recentRuns" :key="r.key" class="hero-sched__row">
|
|
81
|
+
<b>{{ r.when }}</b><span>{{ r.route }} · {{ r.result }}</span><span>{{ r.detail }}</span>
|
|
82
|
+
</div>
|
|
83
|
+
</template>
|
|
84
|
+
</template>
|
|
85
|
+
<div v-else-if="!schedule.length" class="hero-sched__empty">No schedule yet.</div>
|
|
71
86
|
<div v-for="p in schedule" :key="p.key" class="hero-sched__row">
|
|
72
87
|
<b>{{ p.days }}</b><span>{{ p.time }}</span><span>{{ p.jobs }}</span>
|
|
73
88
|
</div>
|
|
89
|
+
<template v-if="unplacedRoutes.length">
|
|
90
|
+
<div class="hero-aside__hint">Patrol routes (stops not placed on the plan)</div>
|
|
91
|
+
<div v-for="r in unplacedRoutes" :key="r.id" class="hero-sched__row">
|
|
92
|
+
<b>{{ r.name }}</b><span>Stops: {{ r.text }}</span>
|
|
93
|
+
</div>
|
|
94
|
+
</template>
|
|
74
95
|
</div>
|
|
75
96
|
|
|
76
97
|
<div>
|
|
@@ -82,6 +103,7 @@
|
|
|
82
103
|
:name="record?.name"
|
|
83
104
|
:status-label="statusLabel"
|
|
84
105
|
:point="posLive ? point : null"
|
|
106
|
+
:stale="shown?.stale === true"
|
|
85
107
|
:heading="headingDeg(shown)"
|
|
86
108
|
:trail="trail"
|
|
87
109
|
:readout="readout"
|
|
@@ -131,6 +153,16 @@
|
|
|
131
153
|
</div>
|
|
132
154
|
</v-dialog>
|
|
133
155
|
|
|
156
|
+
<v-dialog v-model="liveOpen" width="860">
|
|
157
|
+
<RobotLiveView v-if="liveOpen" :robot-id="robotId" :name="record?.name" @close="liveOpen = false" />
|
|
158
|
+
</v-dialog>
|
|
159
|
+
<v-dialog v-model="driveOpen" width="520" persistent>
|
|
160
|
+
<RobotManualDrive v-if="driveOpen" :robot-id="robotId" :name="record?.name" @close="driveOpen = false" />
|
|
161
|
+
</v-dialog>
|
|
162
|
+
<v-dialog v-model="pickOpen" width="520">
|
|
163
|
+
<RobotPointPicker v-if="pickOpen && pickKind" :kind="pickKind" :groups="pointGroups" :floor-no="live?.floorNo ?? null" @close="pickOpen = false" @pick="onPick" />
|
|
164
|
+
</v-dialog>
|
|
165
|
+
|
|
134
166
|
<RobotToast v-model="toast" />
|
|
135
167
|
</template>
|
|
136
168
|
|
|
@@ -144,6 +176,10 @@ import RobotLivePlan from "./LivePlan.vue";
|
|
|
144
176
|
import RobotToast from "./Toast.vue";
|
|
145
177
|
import RobotForm from "./Form.vue";
|
|
146
178
|
import RobotPatrolHistory from "./PatrolHistory.vue";
|
|
179
|
+
import RobotLiveView from "./LiveView.vue";
|
|
180
|
+
import RobotManualDrive from "./ManualDrive.vue";
|
|
181
|
+
import RobotPointPicker from "./PointPicker.vue";
|
|
182
|
+
import { faultPhotos } from "../../utils/robot-control";
|
|
147
183
|
import useRobotLive, { siteStatesFeed } from "../../composables/useRobotLive";
|
|
148
184
|
import {
|
|
149
185
|
brandName,
|
|
@@ -157,7 +193,12 @@ import {
|
|
|
157
193
|
mergePose,
|
|
158
194
|
notReportedLine,
|
|
159
195
|
optimisticMode,
|
|
196
|
+
patrolRecordRow,
|
|
197
|
+
patrolRunHint,
|
|
160
198
|
patrolScheduleRows,
|
|
199
|
+
pointCommandBody,
|
|
200
|
+
savedPointGroups,
|
|
201
|
+
unplacedStopsText,
|
|
161
202
|
planPoint,
|
|
162
203
|
positionText,
|
|
163
204
|
robotErrorMessage,
|
|
@@ -173,6 +214,8 @@ import {
|
|
|
173
214
|
type TLiveRobotState,
|
|
174
215
|
type TPatrolRouteView,
|
|
175
216
|
type TRobotCommandKind,
|
|
217
|
+
type TSavedPoint,
|
|
218
|
+
type TSavedPointGroup,
|
|
176
219
|
} from "../../utils/robot-display";
|
|
177
220
|
|
|
178
221
|
const props = withDefaults(
|
|
@@ -207,8 +250,8 @@ const {
|
|
|
207
250
|
deleteRobot,
|
|
208
251
|
getRobotMaps,
|
|
209
252
|
getRobotMapImages,
|
|
210
|
-
getLiveVideo,
|
|
211
253
|
resolveRobotAlerts,
|
|
254
|
+
getPatrolHistory,
|
|
212
255
|
} = useRobots();
|
|
213
256
|
|
|
214
257
|
const record = ref<Record<string, any> | null>(null);
|
|
@@ -282,7 +325,7 @@ watch(
|
|
|
282
325
|
if (p && posLive.value && isMoving(statusMode.value)) trail.value = trailPush(trail.value, p);
|
|
283
326
|
},
|
|
284
327
|
);
|
|
285
|
-
const readout = computed(() => `${positionText(live.value, caps.value)} · ${speedText(prevPose.value, nextPose.value, statusMode.value)}`);
|
|
328
|
+
const readout = computed(() => `${positionText(live.value, caps.value, now.value)} · ${speedText(prevPose.value, nextPose.value, statusMode.value)}`);
|
|
286
329
|
|
|
287
330
|
const spot = ref<Array<{ x: number; y: number }>>([]);
|
|
288
331
|
const spotOn = computed(() => showSpotClean({ canCommand: props.canCommand, capabilities: caps.value, online: online.value, type: robotType.value }));
|
|
@@ -299,14 +342,25 @@ const planHint = computed(() => {
|
|
|
299
342
|
|
|
300
343
|
/* ---- schedule: cleaning plans from the brand, or the patrol contract ---- */
|
|
301
344
|
const plans = ref<Array<Record<string, any>>>([]);
|
|
302
|
-
const patrol = ref<{ supported?: boolean; routes?: TPatrolRouteView[]; schedule?: any[] } | null>(null);
|
|
345
|
+
const patrol = ref<{ supported?: boolean; routes?: TPatrolRouteView[]; schedule?: any[]; scheduleShared?: boolean } | null>(null);
|
|
303
346
|
const schedule = computed(() => (robotType.value === "security" ? patrolScheduleRows(patrol.value) : cleaningScheduleRows(plans.value)));
|
|
347
|
+
const unplacedRoutes = computed(() =>
|
|
348
|
+
(patrol.value?.routes ?? []).map((r) => ({ id: r.id, name: r.name, text: unplacedStopsText(r) })).filter((r) => r.text),
|
|
349
|
+
);
|
|
350
|
+
// The brand keeps its schedules to itself: recent runs (and a labelled guess) instead.
|
|
351
|
+
const runs = ref<any[]>([]);
|
|
352
|
+
const recentRuns = computed(() => runs.value.slice(0, 5).map((r) => patrolRecordRow(r, now.value)));
|
|
353
|
+
const runHint = computed(() => patrolRunHint(runs.value, now.value));
|
|
304
354
|
|
|
305
355
|
async function loadSchedule() {
|
|
306
356
|
if (!record.value || noInterface.value) return;
|
|
307
357
|
try {
|
|
308
|
-
if (robotType.value === "security")
|
|
309
|
-
|
|
358
|
+
if (robotType.value === "security") {
|
|
359
|
+
patrol.value = await getPatrolRoutes(props.robotId);
|
|
360
|
+
if (patrol.value?.scheduleShared === false && caps.value?.patrolHistory === true && !runs.value.length) {
|
|
361
|
+
runs.value = (await getPatrolHistory(props.robotId, { page: 1, limit: 20 }).catch(() => null))?.items ?? [];
|
|
362
|
+
}
|
|
363
|
+
} else plans.value = (await getRobotPlans(props.robotId))?.items ?? [];
|
|
310
364
|
} catch {
|
|
311
365
|
// A schedule the brand cannot serve reads "No schedule yet"; the facts still show.
|
|
312
366
|
}
|
|
@@ -356,7 +410,23 @@ async function run(kind: TRobotCommandKind | "spot", body: Record<string, unknow
|
|
|
356
410
|
busy.value = null;
|
|
357
411
|
}
|
|
358
412
|
}
|
|
359
|
-
|
|
413
|
+
/* Go to point / Set position: the robot's own saved points when its brand lists them (PadBot). */
|
|
414
|
+
const pointGroups = ref<TSavedPointGroup[]>([]);
|
|
415
|
+
const pickOpen = ref(false);
|
|
416
|
+
const pickKind = ref<"navigate" | "relocalize" | null>(null);
|
|
417
|
+
async function send(kind: TRobotCommandKind) {
|
|
418
|
+
if (kind === "navigate" || kind === "relocalize") {
|
|
419
|
+
try {
|
|
420
|
+
pointGroups.value = savedPointGroups((await getRobotMaps(props.robotId))?.items);
|
|
421
|
+
} catch {
|
|
422
|
+
pointGroups.value = [];
|
|
423
|
+
}
|
|
424
|
+
if (pointGroups.value.length) {
|
|
425
|
+
pickKind.value = kind;
|
|
426
|
+
pickOpen.value = true;
|
|
427
|
+
return;
|
|
428
|
+
}
|
|
429
|
+
}
|
|
360
430
|
const body = commandBody(kind, live.value);
|
|
361
431
|
if (!body) {
|
|
362
432
|
toast.value = `${commandLabel(kind)}: the robot's position is not known yet.`;
|
|
@@ -399,26 +469,22 @@ async function sendSpot() {
|
|
|
399
469
|
}
|
|
400
470
|
}
|
|
401
471
|
|
|
402
|
-
|
|
403
|
-
const
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
const
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
if (!/^https?:\/\//i.test(url)) throw new Error("The robot's cloud sent a video link this page cannot open.");
|
|
411
|
-
if (win) {
|
|
412
|
-
win.opener = null;
|
|
413
|
-
win.location.href = url;
|
|
414
|
-
} else toast.value = "Allow pop-ups to watch the robot's live video.";
|
|
415
|
-
} catch (e) {
|
|
416
|
-
win?.close();
|
|
417
|
-
toast.value = robotErrorMessage(e, "Could not open the live video.");
|
|
418
|
-
} finally {
|
|
419
|
-
videoBusy.value = false;
|
|
472
|
+
function onPick(p: TSavedPoint) {
|
|
473
|
+
const kind = pickKind.value;
|
|
474
|
+
pickOpen.value = false;
|
|
475
|
+
if (!kind) return;
|
|
476
|
+
const body = pointCommandBody(kind, p, live.value);
|
|
477
|
+
if (!body) {
|
|
478
|
+
toast.value = "Set position: the robot is not on that floor.";
|
|
479
|
+
return;
|
|
420
480
|
}
|
|
481
|
+
run(kind, body);
|
|
421
482
|
}
|
|
483
|
+
|
|
484
|
+
/* ---- security-robot extras: live view (minted per open, never kept), manual control, resolve an alarm ---- */
|
|
485
|
+
const liveOpen = ref(false);
|
|
486
|
+
const driveOpen = ref(false);
|
|
487
|
+
const canDrive = computed(() => props.canCommand && caps.value?.manualDrive === true && online.value);
|
|
422
488
|
const canResolve = computed(
|
|
423
489
|
() => props.canCommand && caps.value?.resolveAlerts === true && faultBadge(fault.value) === "ALARM" && typeof fault.value?.code === "string",
|
|
424
490
|
);
|
|
@@ -172,6 +172,12 @@
|
|
|
172
172
|
</template>
|
|
173
173
|
<v-list density="compact">
|
|
174
174
|
<v-list-item title="Schedule" prepend-icon="mdi-calendar-clock" @click="openPlans(item)" />
|
|
175
|
+
<v-list-item
|
|
176
|
+
v-if="states[item._id]?.capabilities?.liveVideo === true && states[item._id]?.online === true"
|
|
177
|
+
title="Live view"
|
|
178
|
+
prepend-icon="mdi-cctv"
|
|
179
|
+
@click="liveRobot = item"
|
|
180
|
+
/>
|
|
175
181
|
<!--
|
|
176
182
|
Only for a brand that can take a click on the plan. A brand
|
|
177
183
|
without it gets no control, not one the server refuses.
|
|
@@ -349,9 +355,14 @@
|
|
|
349
355
|
</v-row>
|
|
350
356
|
</template>
|
|
351
357
|
</ConfirmDialog>
|
|
358
|
+
|
|
359
|
+
<v-dialog :model-value="!!liveRobot" width="860" @update:model-value="(v) => !v && (liveRobot = null)">
|
|
360
|
+
<RobotLiveView v-if="liveRobot" :robot-id="String(liveRobot._id)" :name="liveRobot.name" @close="liveRobot = null" />
|
|
361
|
+
</v-dialog>
|
|
352
362
|
</template>
|
|
353
363
|
|
|
354
364
|
<script lang="ts" setup>
|
|
365
|
+
import RobotLiveView from "./LiveView.vue";
|
|
355
366
|
/**
|
|
356
367
|
* The fleet screen, shared by every application that shows robots.
|
|
357
368
|
*
|
|
@@ -700,6 +711,8 @@ function onCreated() {
|
|
|
700
711
|
|
|
701
712
|
onMounted(load);
|
|
702
713
|
onBeforeUnmount(stopWatching);
|
|
714
|
+
/** The row whose Live view is open (security robots with a camera URL). */
|
|
715
|
+
const liveRobot = ref<any>(null);
|
|
703
716
|
</script>
|
|
704
717
|
|
|
705
718
|
<style scoped>
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
<span class="hero-cross hero-cross--h" :style="{ top: point.top, transition: `top ${tickMs}ms linear` }" aria-hidden="true" />
|
|
16
16
|
<span class="hero-cross hero-cross--v" :style="{ left: point.left, transition: `left ${tickMs}ms linear` }" aria-hidden="true" />
|
|
17
17
|
<div class="hero-readout">{{ readout }}</div>
|
|
18
|
-
<div class="hero-me" :style="{ left: point.left, top: point.top, transition: `left ${tickMs}ms linear, top ${tickMs}ms linear` }">
|
|
18
|
+
<div class="hero-me" :class="{ 'hero-me--stale': stale }" :style="{ left: point.left, top: point.top, transition: `left ${tickMs}ms linear, top ${tickMs}ms linear` }">
|
|
19
19
|
<div class="hero-me__tag">{{ name }} <span>{{ statusLabel }}</span></div>
|
|
20
20
|
<div class="hero-me__radar" aria-hidden="true" />
|
|
21
21
|
<div class="hero-me__ping" aria-hidden="true" />
|
|
@@ -52,8 +52,10 @@ withDefaults(
|
|
|
52
52
|
spot?: Array<{ x: number; y: number }>;
|
|
53
53
|
pick?: boolean;
|
|
54
54
|
tickMs?: number;
|
|
55
|
+
/** The last reading kept through a short gap: drawn faded, never as live. */
|
|
56
|
+
stale?: boolean;
|
|
55
57
|
}>(),
|
|
56
|
-
{ name: "", statusLabel: "", point: null, heading: 0, trail: () => [], readout: "", lost: false, route: null, routeMoving: false, spot: () => [], pick: false, tickMs: 1200 },
|
|
58
|
+
{ stale: false, name: "", statusLabel: "", point: null, heading: 0, trail: () => [], readout: "", lost: false, route: null, routeMoving: false, spot: () => [], pick: false, tickMs: 1200 },
|
|
57
59
|
);
|
|
58
60
|
const emit = defineEmits<{ pick: [point: { x: number; y: number }] }>();
|
|
59
61
|
</script>
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<!--
|
|
3
|
+
The robot's current camera stream, in the page (security robots, liveVideo).
|
|
4
|
+
The URL is minted per open and re-minted shortly before it expires; it is
|
|
5
|
+
never stored. What the browser cannot play in place opens in a new tab.
|
|
6
|
+
-->
|
|
7
|
+
<div class="hero-dialog hero-live" role="dialog" aria-labelledby="hero-live-title">
|
|
8
|
+
<div class="hero-live__head">
|
|
9
|
+
<div id="hero-live-title" class="hero-dialog__title">Live view · {{ name }}</div>
|
|
10
|
+
<span class="hero-head__spacer" />
|
|
11
|
+
<div class="hero-live__quality" role="group" aria-label="Quality">
|
|
12
|
+
<button type="button" :aria-pressed="quality === 'smooth'" @click="quality = 'smooth'">Smooth</button>
|
|
13
|
+
<button type="button" :aria-pressed="quality === 'hd'" @click="quality = 'hd'">HD</button>
|
|
14
|
+
</div>
|
|
15
|
+
</div>
|
|
16
|
+
|
|
17
|
+
<div class="hero-live__stage">
|
|
18
|
+
<div v-if="loading && !url" class="hero-muted">Connecting to the robot's camera…</div>
|
|
19
|
+
<div v-else-if="error" class="hero-notice" role="alert">{{ error }}</div>
|
|
20
|
+
<template v-else-if="url">
|
|
21
|
+
<video v-if="plays" :key="url" class="hero-live__video" :src="url" controls autoplay muted playsinline @error="error = CAMERA_DOWN" />
|
|
22
|
+
<iframe
|
|
23
|
+
v-else-if="kind === 'page'"
|
|
24
|
+
:key="url"
|
|
25
|
+
class="hero-live__video"
|
|
26
|
+
:src="url"
|
|
27
|
+
title="Robot camera"
|
|
28
|
+
sandbox="allow-scripts allow-same-origin"
|
|
29
|
+
allow="autoplay; fullscreen"
|
|
30
|
+
allowfullscreen
|
|
31
|
+
referrerpolicy="no-referrer"
|
|
32
|
+
/>
|
|
33
|
+
<div v-else-if="kind === 'rtmp'" class="hero-dialog__body">
|
|
34
|
+
This stream uses RTMP, which browsers can't play. Open it in a player app such as VLC.
|
|
35
|
+
<div class="hero-live__copy">
|
|
36
|
+
<v-btn variant="outlined" size="small" @click="copy">Copy stream link</v-btn>
|
|
37
|
+
</div>
|
|
38
|
+
</div>
|
|
39
|
+
<div v-else class="hero-dialog__body">This stream can't play inside the page in this browser. Open it in a new tab.</div>
|
|
40
|
+
</template>
|
|
41
|
+
</div>
|
|
42
|
+
|
|
43
|
+
<div class="hero-dialog__actions">
|
|
44
|
+
<a v-if="url && kind !== 'rtmp'" class="hero-live__open" :href="url" target="_blank" rel="noopener noreferrer">Open in new tab</a>
|
|
45
|
+
<span class="hero-head__spacer" />
|
|
46
|
+
<v-btn variant="outlined" @click="emit('close')">Close</v-btn>
|
|
47
|
+
</div>
|
|
48
|
+
</div>
|
|
49
|
+
</template>
|
|
50
|
+
|
|
51
|
+
<script setup lang="ts">
|
|
52
|
+
import { computed, onBeforeUnmount, ref, watch } from "vue";
|
|
53
|
+
import { liveVideoKind, remintDelayMs } from "../../utils/robot-control";
|
|
54
|
+
|
|
55
|
+
const props = defineProps<{ robotId: string; name?: string }>();
|
|
56
|
+
const emit = defineEmits<{ close: [] }>();
|
|
57
|
+
const { getLiveVideo } = useRobots();
|
|
58
|
+
|
|
59
|
+
const CAMERA_DOWN = "The robot's camera isn't available right now.";
|
|
60
|
+
const quality = ref<"hd" | "smooth">("smooth");
|
|
61
|
+
const url = ref("");
|
|
62
|
+
const error = ref("");
|
|
63
|
+
const loading = ref(false);
|
|
64
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
65
|
+
let alive = true;
|
|
66
|
+
|
|
67
|
+
const kind = computed(() => liveVideoKind(url.value));
|
|
68
|
+
const plays = computed(
|
|
69
|
+
() =>
|
|
70
|
+
kind.value === "video" ||
|
|
71
|
+
(kind.value === "hls" && typeof document !== "undefined" && !!document.createElement("video").canPlayType("application/vnd.apple.mpegurl")),
|
|
72
|
+
);
|
|
73
|
+
|
|
74
|
+
async function mint() {
|
|
75
|
+
clearTimeout(timer);
|
|
76
|
+
loading.value = true;
|
|
77
|
+
error.value = "";
|
|
78
|
+
try {
|
|
79
|
+
const res = await getLiveVideo(props.robotId, quality.value);
|
|
80
|
+
if (!alive) return;
|
|
81
|
+
if (liveVideoKind(res?.url) === "bad") throw new Error(CAMERA_DOWN);
|
|
82
|
+
url.value = res.url;
|
|
83
|
+
const wait = remintDelayMs(res?.expiresAt);
|
|
84
|
+
if (wait !== null) timer = setTimeout(mint, wait);
|
|
85
|
+
} catch {
|
|
86
|
+
if (alive) {
|
|
87
|
+
url.value = "";
|
|
88
|
+
error.value = CAMERA_DOWN;
|
|
89
|
+
}
|
|
90
|
+
} finally {
|
|
91
|
+
loading.value = false;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
async function copy() {
|
|
95
|
+
try {
|
|
96
|
+
await navigator.clipboard.writeText(url.value);
|
|
97
|
+
} catch {
|
|
98
|
+
/* the link is still shown by the browser's own copy */
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
watch(quality, mint, { immediate: true });
|
|
102
|
+
onBeforeUnmount(() => {
|
|
103
|
+
alive = false;
|
|
104
|
+
clearTimeout(timer);
|
|
105
|
+
url.value = "";
|
|
106
|
+
});
|
|
107
|
+
</script>
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<!--
|
|
3
|
+
Manual control (Manager only, PADBOT_MANUAL_DRIVE on the server). Hold a
|
|
4
|
+
button to move; letting go stops. The server forwards motion only while
|
|
5
|
+
this screen's heartbeat keeps arriving and stops the robot the moment it
|
|
6
|
+
does not - closing, hiding the tab or losing the connection all stop it.
|
|
7
|
+
-->
|
|
8
|
+
<div class="hero-dialog hero-drive" role="dialog" aria-labelledby="hero-drive-title" tabindex="-1" @keydown="onKey" @keyup="onKeyUp">
|
|
9
|
+
<div id="hero-drive-title" class="hero-dialog__title">Manual control · {{ name }}</div>
|
|
10
|
+
<div v-if="starting" class="hero-muted">Starting manual control…</div>
|
|
11
|
+
<div v-else-if="ended" class="hero-notice" role="alert">{{ ended }}</div>
|
|
12
|
+
<template v-else>
|
|
13
|
+
<div class="hero-dialog__body">Hold a button to move. Let go and the robot stops. {{ speedLimitText(limits) }}.</div>
|
|
14
|
+
<button type="button" class="hero-drive__stop" @click="client?.stop()">STOP</button>
|
|
15
|
+
<div class="hero-drive__pad" aria-label="Drive">
|
|
16
|
+
<span />
|
|
17
|
+
<button type="button" v-bind="hold('forward')">▲ Forward</button>
|
|
18
|
+
<span />
|
|
19
|
+
<button type="button" v-bind="hold('left')">◀ Turn left</button>
|
|
20
|
+
<span />
|
|
21
|
+
<button type="button" v-bind="hold('right')">Turn right ▶</button>
|
|
22
|
+
<span />
|
|
23
|
+
<button type="button" v-bind="hold('back')">▼ Back</button>
|
|
24
|
+
<span />
|
|
25
|
+
</div>
|
|
26
|
+
<div class="hero-drive__head" aria-label="Head">
|
|
27
|
+
<span class="hero-muted">Head</span>
|
|
28
|
+
<button type="button" @click="client?.look('left')">Look left</button>
|
|
29
|
+
<button type="button" @click="client?.look('right')">Look right</button>
|
|
30
|
+
<button type="button" @click="client?.look('up')">Look up</button>
|
|
31
|
+
<button type="button" @click="client?.look('down')">Look down</button>
|
|
32
|
+
<button type="button" @click="client?.look('centre')">Centre</button>
|
|
33
|
+
</div>
|
|
34
|
+
</template>
|
|
35
|
+
<div class="hero-dialog__actions">
|
|
36
|
+
<v-btn variant="outlined" @click="close">Close</v-btn>
|
|
37
|
+
</div>
|
|
38
|
+
</div>
|
|
39
|
+
</template>
|
|
40
|
+
|
|
41
|
+
<script setup lang="ts">
|
|
42
|
+
import { onBeforeUnmount, onMounted, ref } from "vue";
|
|
43
|
+
import { createDriveClient, speedLimitText, type TDriveDirection, type TDriveLimits } from "../../utils/robot-control";
|
|
44
|
+
import { robotErrorMessage } from "../../utils/robot-display";
|
|
45
|
+
|
|
46
|
+
const props = defineProps<{ robotId: string; name?: string }>();
|
|
47
|
+
const emit = defineEmits<{ close: [] }>();
|
|
48
|
+
const { startDrive, driveSignal } = useRobots();
|
|
49
|
+
|
|
50
|
+
const starting = ref(true);
|
|
51
|
+
const ended = ref("");
|
|
52
|
+
const limits = ref<TDriveLimits | null>(null);
|
|
53
|
+
let client: ReturnType<typeof createDriveClient> | null = null;
|
|
54
|
+
|
|
55
|
+
const release = () => client?.release();
|
|
56
|
+
function hold(dir: TDriveDirection) {
|
|
57
|
+
return {
|
|
58
|
+
onPointerdown: (e: PointerEvent) => {
|
|
59
|
+
(e.target as HTMLElement).setPointerCapture?.(e.pointerId);
|
|
60
|
+
client?.press(dir);
|
|
61
|
+
},
|
|
62
|
+
onPointerup: release,
|
|
63
|
+
onPointerleave: release,
|
|
64
|
+
onPointercancel: release,
|
|
65
|
+
onBlur: release,
|
|
66
|
+
onContextmenu: (e: Event) => {
|
|
67
|
+
e.preventDefault();
|
|
68
|
+
release();
|
|
69
|
+
},
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
const KEYS: Record<string, TDriveDirection> = { ArrowUp: "forward", ArrowDown: "back", ArrowLeft: "left", ArrowRight: "right" };
|
|
73
|
+
function onKey(e: KeyboardEvent) {
|
|
74
|
+
if (e.key === " ") {
|
|
75
|
+
e.preventDefault();
|
|
76
|
+
client?.stop();
|
|
77
|
+
} else if (KEYS[e.key] && !e.repeat) {
|
|
78
|
+
e.preventDefault();
|
|
79
|
+
client?.press(KEYS[e.key]!);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
function onKeyUp(e: KeyboardEvent) {
|
|
83
|
+
if (KEYS[e.key]) release();
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const finish = () => client?.end();
|
|
87
|
+
const onHidden = () => {
|
|
88
|
+
if (document.visibilityState === "hidden") finish();
|
|
89
|
+
};
|
|
90
|
+
function close() {
|
|
91
|
+
finish();
|
|
92
|
+
emit("close");
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
onMounted(async () => {
|
|
96
|
+
document.addEventListener("visibilitychange", onHidden);
|
|
97
|
+
window.addEventListener("pagehide", finish);
|
|
98
|
+
window.addEventListener("beforeunload", finish);
|
|
99
|
+
window.addEventListener("blur", release);
|
|
100
|
+
try {
|
|
101
|
+
const res = await startDrive(props.robotId);
|
|
102
|
+
const l = res?.limits ?? {};
|
|
103
|
+
limits.value = {
|
|
104
|
+
maxLinear: Number(l.maxLinear) || 0.3,
|
|
105
|
+
maxAngular: Number(l.maxAngular) || 30,
|
|
106
|
+
heartbeatMs: Math.min(100, Number(l.heartbeatMs) || 100),
|
|
107
|
+
headPan: Array.isArray(l.headPan) ? l.headPan : [-90, 90],
|
|
108
|
+
headTilt: Array.isArray(l.headTilt) ? l.headTilt : [-15, 30],
|
|
109
|
+
};
|
|
110
|
+
client = createDriveClient({
|
|
111
|
+
send: (body, opts) => driveSignal(props.robotId, res.session, body, opts),
|
|
112
|
+
limits: limits.value,
|
|
113
|
+
onEnd: (reason) => (ended.value = reason || "Manual control ended."),
|
|
114
|
+
});
|
|
115
|
+
} catch (e) {
|
|
116
|
+
ended.value = robotErrorMessage(e, "Manual control could not start.");
|
|
117
|
+
} finally {
|
|
118
|
+
starting.value = false;
|
|
119
|
+
}
|
|
120
|
+
});
|
|
121
|
+
onBeforeUnmount(() => {
|
|
122
|
+
finish();
|
|
123
|
+
document.removeEventListener("visibilitychange", onHidden);
|
|
124
|
+
window.removeEventListener("pagehide", finish);
|
|
125
|
+
window.removeEventListener("beforeunload", finish);
|
|
126
|
+
window.removeEventListener("blur", release);
|
|
127
|
+
});
|
|
128
|
+
</script>
|
|
@@ -33,7 +33,8 @@
|
|
|
33
33
|
:key="r.id"
|
|
34
34
|
type="button"
|
|
35
35
|
class="hero-bot"
|
|
36
|
-
:
|
|
36
|
+
:class="{ 'hero-bot--stale': r.stale }"
|
|
37
|
+
:title="r.stale ? `${r.name} · ${r.seen}` : r.name"
|
|
37
38
|
:style="{ left: r.left, top: r.top, transition: `left ${tickMs}ms linear, top ${tickMs}ms linear`, '--hero-bot': toneColor(r) }"
|
|
38
39
|
@click="emit('open', r.id)"
|
|
39
40
|
>
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<!-- Go to point / Set position: the robot's own saved points, per map. -->
|
|
3
|
+
<div class="hero-dialog" role="dialog" aria-labelledby="hero-pick-title">
|
|
4
|
+
<div id="hero-pick-title" class="hero-dialog__title">{{ kind === "navigate" ? "Go to point" : "Set position" }}</div>
|
|
5
|
+
<div v-if="kind === 'relocalize'" class="hero-dialog__body">Only points on the floor the robot reports are listed.</div>
|
|
6
|
+
<div v-if="!groups.length" class="hero-muted">No saved points{{ kind === "relocalize" ? " on the robot's current floor" : "" }}.</div>
|
|
7
|
+
<div v-for="g in groups" :key="g.mapId" class="hero-pick__group">
|
|
8
|
+
<div class="hero-aside__title">{{ g.title }}</div>
|
|
9
|
+
<button v-for="p in g.points" :key="p.targetPointId" type="button" class="hero-pick__point" :aria-pressed="chosen === p" @click="chosen = p">
|
|
10
|
+
{{ p.name }} <span class="hero-dim">{{ p.type }}</span>
|
|
11
|
+
</button>
|
|
12
|
+
</div>
|
|
13
|
+
<div v-if="chosen && kind === 'relocalize'" class="hero-notice">
|
|
14
|
+
Only do this when the robot is standing at {{ chosen.name }} right now - it tells the robot where it is.
|
|
15
|
+
</div>
|
|
16
|
+
<div class="hero-dialog__actions">
|
|
17
|
+
<v-btn variant="outlined" @click="emit('close')">Cancel</v-btn>
|
|
18
|
+
<v-btn color="primary" variant="flat" :disabled="!chosen" @click="chosen && emit('pick', chosen)">
|
|
19
|
+
{{ kind === "navigate" ? `Send to ${chosen?.name ?? "point"}` : `Robot is at ${chosen?.name ?? "point"}` }}
|
|
20
|
+
</v-btn>
|
|
21
|
+
</div>
|
|
22
|
+
</div>
|
|
23
|
+
</template>
|
|
24
|
+
|
|
25
|
+
<script setup lang="ts">
|
|
26
|
+
import { computed, ref } from "vue";
|
|
27
|
+
import type { TSavedPoint, TSavedPointGroup } from "../../utils/robot-display";
|
|
28
|
+
|
|
29
|
+
const props = defineProps<{ kind: "navigate" | "relocalize"; groups: TSavedPointGroup[]; floorNo?: number | null }>();
|
|
30
|
+
const emit = defineEmits<{ close: []; pick: [point: TSavedPoint] }>();
|
|
31
|
+
const chosen = ref<TSavedPoint | null>(null);
|
|
32
|
+
const groups = computed(() => (props.kind === "relocalize" ? props.groups.filter((g) => g.floorNo !== null && g.floorNo === props.floorNo) : props.groups));
|
|
33
|
+
</script>
|
package/composables/useRobots.ts
CHANGED
|
@@ -286,14 +286,53 @@ export default function useRobots() {
|
|
|
286
286
|
/* ---------------------------------------- security-robot extras (brand capability-gated) */
|
|
287
287
|
|
|
288
288
|
/** `liveVideo`: the robot's camera URL, minted now (valid until `expiresAt`). Open it; never store it. */
|
|
289
|
-
function getLiveVideo(id: string) {
|
|
289
|
+
function getLiveVideo(id: string, quality: "hd" | "smooth" = "smooth") {
|
|
290
290
|
return useNuxtApp().$api<{ url: string; expiresAt: string | null }>(`/api/robots/${id}/live-video`, {
|
|
291
291
|
method: "POST",
|
|
292
292
|
query: { appSlug },
|
|
293
|
-
body: {},
|
|
293
|
+
body: { quality },
|
|
294
294
|
});
|
|
295
295
|
}
|
|
296
296
|
|
|
297
|
+
/** Manual control (Manager only): open a drive session. 400 carries the plain reason it cannot start. */
|
|
298
|
+
function startDrive(id: string) {
|
|
299
|
+
return useNuxtApp().$api<{ session: string; limits: Record<string, any> }>(`/api/robots/${id}/drive`, {
|
|
300
|
+
method: "POST",
|
|
301
|
+
query: { appSlug },
|
|
302
|
+
body: { action: "start" },
|
|
303
|
+
});
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* One signal on an open drive session (beat / move / stop / head / end).
|
|
308
|
+
* Plain `fetch` so the page-closing "end" can go out with `keepalive`;
|
|
309
|
+
* never throws - answers `{ ok, status, message }`.
|
|
310
|
+
*/
|
|
311
|
+
async function driveSignal(id: string, session: string, body: Record<string, unknown>, opts: { keepalive?: boolean } = {}) {
|
|
312
|
+
const base = useRuntimeConfig().public?.apiBase ?? "";
|
|
313
|
+
const query = appSlug ? `?appSlug=${encodeURIComponent(appSlug)}` : "";
|
|
314
|
+
try {
|
|
315
|
+
const res = await fetch(`${base}/api/robots/${id}/drive/${encodeURIComponent(session)}${query}`, {
|
|
316
|
+
method: "POST",
|
|
317
|
+
headers: { "Content-Type": "application/json", Accept: "application/json" },
|
|
318
|
+
credentials: "include",
|
|
319
|
+
keepalive: opts.keepalive === true,
|
|
320
|
+
body: JSON.stringify(body),
|
|
321
|
+
});
|
|
322
|
+
let message = "";
|
|
323
|
+
if (!res.ok) {
|
|
324
|
+
try {
|
|
325
|
+
message = String((await res.json())?.message ?? "");
|
|
326
|
+
} catch {
|
|
327
|
+
/* no body */
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
return { ok: res.ok, status: res.status, message };
|
|
331
|
+
} catch {
|
|
332
|
+
return { ok: false, status: 0, message: "" };
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
|
|
297
336
|
/** `resolveAlerts`: mark the robot's open alerts (of one code, or all) resolved in the brand's system. */
|
|
298
337
|
function resolveRobotAlerts(id: string, code?: string) {
|
|
299
338
|
return useNuxtApp().$api<{ resolved: number }>(`/api/robots/${id}/alerts/resolve`, {
|
|
@@ -556,6 +595,8 @@ export default function useRobots() {
|
|
|
556
595
|
getRobotPlans,
|
|
557
596
|
sendRobotCommand,
|
|
558
597
|
getLiveVideo,
|
|
598
|
+
startDrive,
|
|
599
|
+
driveSignal,
|
|
559
600
|
resolveRobotAlerts,
|
|
560
601
|
getPatrolHistory,
|
|
561
602
|
getPatrolVisits,
|
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.
|
|
5
|
+
"version": "4.54.1",
|
|
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.",
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Web-only robot controls: the live-view player's URL handling and the
|
|
3
|
+
* manual-drive client. Not part of the mobile-synced `robot-display.ts`
|
|
4
|
+
* (mobile has no live view or manual control - parity exception, like map
|
|
5
|
+
* editing, decision 81).
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/* ============================================================ live view */
|
|
9
|
+
|
|
10
|
+
export type TVideoKind = "hls" | "video" | "flv" | "rtmp" | "page" | "bad";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* What kind of stream URL the robot's cloud handed us.
|
|
14
|
+
* ponytail: no hls.js / mpegts.js yet - the real PadBot URL type is unknown;
|
|
15
|
+
* HLS plays natively where the browser can (Safari), FLV and HLS elsewhere
|
|
16
|
+
* get "Open in new tab". Add the player library once a real URL is seen.
|
|
17
|
+
*/
|
|
18
|
+
export function liveVideoKind(url: unknown): TVideoKind {
|
|
19
|
+
if (typeof url !== "string") return "bad";
|
|
20
|
+
if (/^rtmps?:\/\//i.test(url)) return "rtmp";
|
|
21
|
+
if (!/^https?:\/\//i.test(url)) return "bad";
|
|
22
|
+
const path = url.split(/[?#]/)[0]!.toLowerCase();
|
|
23
|
+
if (path.endsWith(".m3u8")) return "hls";
|
|
24
|
+
if (path.endsWith(".flv")) return "flv";
|
|
25
|
+
if (/\.(mp4|webm)$/.test(path)) return "video";
|
|
26
|
+
return "page";
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** When to ask for a fresh URL: 30 s before it expires (never sooner than 5 s); null when it does not expire. */
|
|
30
|
+
export function remintDelayMs(expiresAt: unknown, now: number = Date.now()): number | null {
|
|
31
|
+
const t = typeof expiresAt === "string" ? Date.parse(expiresAt) : NaN;
|
|
32
|
+
if (!Number.isFinite(t)) return null;
|
|
33
|
+
return Math.max(5_000, t - now - 30_000);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/* ======================================================== manual drive */
|
|
37
|
+
|
|
38
|
+
export type TDriveLimits = {
|
|
39
|
+
maxLinear: number;
|
|
40
|
+
maxAngular: number;
|
|
41
|
+
heartbeatMs: number;
|
|
42
|
+
commandMaxMs?: number;
|
|
43
|
+
headPan: [number, number];
|
|
44
|
+
headTilt: [number, number];
|
|
45
|
+
};
|
|
46
|
+
export type TDriveDirection = "forward" | "back" | "left" | "right";
|
|
47
|
+
export type TDriveSend = (body: Record<string, unknown>, opts?: { keepalive?: boolean }) => Promise<{ ok: boolean; status: number; message: string }>;
|
|
48
|
+
|
|
49
|
+
/** Chassis vector for a held direction, at the server's cap (left turn = positive angular speed). */
|
|
50
|
+
export function driveVector(dir: TDriveDirection, limits: Pick<TDriveLimits, "maxLinear" | "maxAngular">) {
|
|
51
|
+
if (dir === "forward") return { lv: limits.maxLinear, av: 0 };
|
|
52
|
+
if (dir === "back") return { lv: -limits.maxLinear, av: 0 };
|
|
53
|
+
return { lv: 0, av: dir === "left" ? limits.maxAngular : -limits.maxAngular };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** "Speed limited to 0.3 m/s". */
|
|
57
|
+
export const speedLimitText = (limits: Pick<TDriveLimits, "maxLinear"> | null | undefined) =>
|
|
58
|
+
limits && Number.isFinite(limits.maxLinear) ? `Speed limited to ${limits.maxLinear} m/s` : "";
|
|
59
|
+
|
|
60
|
+
const MOVE_REPEAT_MS = 500;
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* The screen side of a drive session: a heartbeat every `heartbeatMs` for as
|
|
64
|
+
* long as the dialog is open, a move on press and every 500 ms while held, a
|
|
65
|
+
* stop on release. Any refused signal ends the session (the server has
|
|
66
|
+
* already stopped the robot) and `onEnd` gets the server's words.
|
|
67
|
+
*/
|
|
68
|
+
export function createDriveClient(o: {
|
|
69
|
+
send: TDriveSend;
|
|
70
|
+
limits: TDriveLimits;
|
|
71
|
+
onEnd: (reason: string) => void;
|
|
72
|
+
setInterval?: typeof setInterval;
|
|
73
|
+
clearInterval?: typeof clearInterval;
|
|
74
|
+
}) {
|
|
75
|
+
const si = o.setInterval ?? setInterval;
|
|
76
|
+
const ci = o.clearInterval ?? clearInterval;
|
|
77
|
+
let seq = 0;
|
|
78
|
+
let ended = false;
|
|
79
|
+
let held: TDriveDirection | null = null;
|
|
80
|
+
let moveTimer: ReturnType<typeof setInterval> | null = null;
|
|
81
|
+
const head = { pan: 0, tilt: 0 };
|
|
82
|
+
|
|
83
|
+
const post = (body: Record<string, unknown>, opts?: { keepalive?: boolean }) => {
|
|
84
|
+
if (ended) return;
|
|
85
|
+
const signal = { seq: ++seq, ...body };
|
|
86
|
+
void o.send(signal, opts).then((r) => {
|
|
87
|
+
if (!r.ok && body.action !== "end") end(r.message || (r.status ? "Manual control ended." : "Manual control ended: connection lost."), false);
|
|
88
|
+
});
|
|
89
|
+
};
|
|
90
|
+
const beatTimer = si(() => post({ action: "beat" }), o.limits.heartbeatMs);
|
|
91
|
+
post({ action: "beat" });
|
|
92
|
+
|
|
93
|
+
function clearMove() {
|
|
94
|
+
if (moveTimer) ci(moveTimer);
|
|
95
|
+
moveTimer = null;
|
|
96
|
+
}
|
|
97
|
+
function end(reason = "", tellServer = true) {
|
|
98
|
+
if (ended) return;
|
|
99
|
+
clearMove();
|
|
100
|
+
ci(beatTimer);
|
|
101
|
+
if (tellServer) post({ action: "end" }, { keepalive: true });
|
|
102
|
+
ended = true;
|
|
103
|
+
held = null;
|
|
104
|
+
o.onEnd(reason);
|
|
105
|
+
}
|
|
106
|
+
return {
|
|
107
|
+
press(dir: TDriveDirection) {
|
|
108
|
+
if (ended) return;
|
|
109
|
+
held = dir;
|
|
110
|
+
clearMove();
|
|
111
|
+
const send = () => held && post({ action: "move", ...driveVector(held, o.limits) });
|
|
112
|
+
send();
|
|
113
|
+
moveTimer = si(send, MOVE_REPEAT_MS);
|
|
114
|
+
},
|
|
115
|
+
release() {
|
|
116
|
+
if (ended || !held) return;
|
|
117
|
+
held = null;
|
|
118
|
+
clearMove();
|
|
119
|
+
post({ action: "stop" });
|
|
120
|
+
},
|
|
121
|
+
/** The big STOP: always sends, held or not. */
|
|
122
|
+
stop() {
|
|
123
|
+
held = null;
|
|
124
|
+
clearMove();
|
|
125
|
+
post({ action: "stop" });
|
|
126
|
+
},
|
|
127
|
+
look(dir: "left" | "right" | "up" | "down" | "centre", step = 15) {
|
|
128
|
+
if (dir === "centre") {
|
|
129
|
+
head.pan = head.tilt = 0;
|
|
130
|
+
return post({ action: "head", axis: "reset" });
|
|
131
|
+
}
|
|
132
|
+
const clamp = (v: number, [lo, hi]: [number, number]) => Math.max(lo, Math.min(hi, v));
|
|
133
|
+
if (dir === "left" || dir === "right") {
|
|
134
|
+
head.pan = clamp(head.pan + (dir === "left" ? step : -step), o.limits.headPan);
|
|
135
|
+
return post({ action: "head", axis: "pan", angle: head.pan });
|
|
136
|
+
}
|
|
137
|
+
head.tilt = clamp(head.tilt + (dir === "up" ? step : -step), o.limits.headTilt);
|
|
138
|
+
post({ action: "head", axis: "tilt", angle: head.tilt });
|
|
139
|
+
},
|
|
140
|
+
end: (reason = "") => end(reason, true),
|
|
141
|
+
get ended() {
|
|
142
|
+
return ended;
|
|
143
|
+
},
|
|
144
|
+
get held() {
|
|
145
|
+
return held;
|
|
146
|
+
},
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/* ======================================================== alert photos */
|
|
151
|
+
|
|
152
|
+
/** "Photo" / "Thermal photo" links for a fault that carries the robot's pictures (http/https only). */
|
|
153
|
+
export function faultPhotos(fault: { imageUrl?: unknown; thermalImageUrl?: unknown } | null | undefined): Array<{ label: string; url: string }> {
|
|
154
|
+
const ok = (v: unknown): v is string => typeof v === "string" && /^https?:\/\//i.test(v);
|
|
155
|
+
return [
|
|
156
|
+
...(ok(fault?.imageUrl) ? [{ label: "Photo", url: fault!.imageUrl as string }] : []),
|
|
157
|
+
...(ok(fault?.thermalImageUrl) ? [{ label: "Thermal photo", url: fault!.thermalImageUrl as string }] : []),
|
|
158
|
+
];
|
|
159
|
+
}
|
package/utils/robot-display.ts
CHANGED
|
@@ -598,6 +598,8 @@ export type TLiveRobotState = {
|
|
|
598
598
|
ok: boolean;
|
|
599
599
|
/** false = its brand has no interface: nothing to show, ever. */
|
|
600
600
|
monitorable?: boolean;
|
|
601
|
+
/** When `ok` is false: why, in words (e.g. "Ounitech rejected the robot list (sign-in) (code X: ...)"). */
|
|
602
|
+
reason?: string;
|
|
601
603
|
online?: boolean;
|
|
602
604
|
mode?: string;
|
|
603
605
|
batteryPercent?: number | null;
|
|
@@ -609,28 +611,85 @@ export type TLiveRobotState = {
|
|
|
609
611
|
/** The brand's own status words ("CLEANING_AUTO"), quoted as "Brand status". */
|
|
610
612
|
statusName?: string;
|
|
611
613
|
model?: string;
|
|
612
|
-
fault?: { code: string; message?: string; since?: number } | null;
|
|
614
|
+
fault?: { code: string; message?: string; since?: number; imageUrl?: string | null; thermalImageUrl?: string | null } | null;
|
|
613
615
|
capabilities?: Record<string, unknown>;
|
|
614
616
|
lastSeenAt?: string | null;
|
|
617
|
+
/** Epoch ms of the last answer that was read (set by `mergeLiveStates`). */
|
|
618
|
+
readAt?: number;
|
|
619
|
+
/** Epoch ms when the shown position was reported. */
|
|
620
|
+
positionAt?: number;
|
|
621
|
+
/** true = what is shown is the last reading, kept through a short gap - drawn faded with "last seen". */
|
|
622
|
+
stale?: boolean;
|
|
615
623
|
};
|
|
616
624
|
|
|
625
|
+
/** A gap shorter than this keeps the last reading (faded, "last seen N s ago"); longer reads "Reconnecting". */
|
|
626
|
+
export const LIVE_GRACE_MS = 15_000;
|
|
627
|
+
|
|
628
|
+
const hasPos = (s: Partial<TLiveRobotState> | null | undefined) =>
|
|
629
|
+
(!!s?.position && finite(s.position.x) && finite(s.position.y)) || (!!s?.planPosition && finite(s.planPosition.x) && finite(s.planPosition.y));
|
|
630
|
+
|
|
617
631
|
/**
|
|
618
|
-
* Fold one poll's answer into the live map.
|
|
619
|
-
*
|
|
620
|
-
*
|
|
632
|
+
* Fold one poll's answer into the live map. Robots not in this answer keep theirs.
|
|
633
|
+
*
|
|
634
|
+
* One missed or failed update must not make a robot vanish from the map
|
|
635
|
+
* (owner, 2026-09-26: it disappeared and came back while moving). So:
|
|
636
|
+
* - no answer (`ok: false`): the last reading is kept, marked `stale`, for
|
|
637
|
+
* up to `LIVE_GRACE_MS` after it was read; past that it is REPLACED by
|
|
638
|
+
* `{ ok: false }` ("Reconnecting") - a stale battery or position is never
|
|
639
|
+
* kept as if live;
|
|
640
|
+
* - an answer without a position: the last position is kept (stale) within
|
|
641
|
+
* the same window, unless the robot now says it is offline or that its
|
|
642
|
+
* floor is unknown (`floorKnown: false`);
|
|
643
|
+
* - an answer without floor fields keeps the last floor.
|
|
621
644
|
*/
|
|
622
645
|
export function mergeLiveStates(
|
|
623
646
|
prev: Record<string, TLiveRobotState>,
|
|
624
647
|
items: Array<Partial<TLiveRobotState> & { id?: unknown }> | null | undefined,
|
|
648
|
+
now: number = Date.now(),
|
|
625
649
|
): Record<string, TLiveRobotState> {
|
|
626
650
|
const next = { ...prev };
|
|
627
651
|
for (const item of items ?? []) {
|
|
628
652
|
if (!item || typeof item.id !== "string") continue;
|
|
629
|
-
|
|
653
|
+
const p = prev[item.id];
|
|
654
|
+
const recent = (at: number | undefined) => finite(at) && now - at <= LIVE_GRACE_MS;
|
|
655
|
+
if (item.ok !== true) {
|
|
656
|
+
next[item.id] =
|
|
657
|
+
item.monitorable !== false && p?.ok && recent(p.readAt)
|
|
658
|
+
? { ...p, stale: true }
|
|
659
|
+
: {
|
|
660
|
+
id: item.id,
|
|
661
|
+
ok: false,
|
|
662
|
+
monitorable: item.monitorable !== false,
|
|
663
|
+
// The API's plain reason, so the detail screen says WHY instead of only "Reconnecting".
|
|
664
|
+
...(typeof item.reason === "string" && item.reason ? { reason: item.reason.slice(0, 300) } : {}),
|
|
665
|
+
};
|
|
666
|
+
continue;
|
|
667
|
+
}
|
|
668
|
+
const s = { ...item, ok: true, readAt: now, stale: false } as TLiveRobotState;
|
|
669
|
+
if (!("floorKnown" in item) && !("floorNo" in item) && p?.ok) {
|
|
670
|
+
s.floorNo = p.floorNo;
|
|
671
|
+
s.floorKnown = p.floorKnown;
|
|
672
|
+
}
|
|
673
|
+
if (hasPos(item)) s.positionAt = now;
|
|
674
|
+
else if (p?.ok && hasPos(p) && recent(p.positionAt) && item.online !== false && item.floorKnown !== false) {
|
|
675
|
+
s.position = p.position ?? null;
|
|
676
|
+
s.planPosition = p.planPosition ?? null;
|
|
677
|
+
s.positionAt = p.positionAt;
|
|
678
|
+
s.stale = true;
|
|
679
|
+
}
|
|
680
|
+
next[item.id] = s;
|
|
630
681
|
}
|
|
631
682
|
return next;
|
|
632
683
|
}
|
|
633
684
|
|
|
685
|
+
/** "last seen 8 s ago" for a kept (stale) reading, "" when it is fresh. */
|
|
686
|
+
export function staleText(live: TLiveRobotState | null | undefined, now: number = Date.now()): string {
|
|
687
|
+
if (!live?.stale) return "";
|
|
688
|
+
const at = finite(live.positionAt) ? live.positionAt : live.readAt;
|
|
689
|
+
if (!finite(at)) return "last reading";
|
|
690
|
+
return `last seen ${Math.max(0, Math.round((now - at) / 1000))} s ago`;
|
|
691
|
+
}
|
|
692
|
+
|
|
634
693
|
/**
|
|
635
694
|
* What the Status cell says for a row: the live mode when read; "Reconnecting"
|
|
636
695
|
* when the last poll got no answer; "Offline" from the stored record while no
|
|
@@ -731,7 +790,7 @@ export function brandName(brand: unknown, brands?: Array<{ brand?: unknown; disp
|
|
|
731
790
|
|
|
732
791
|
/* ------------------------------------------------------------- mini-map */
|
|
733
792
|
|
|
734
|
-
export type TMapRobot = { id: string; name: string; mode?: string; type?: string; left: string; top: string; rot: string };
|
|
793
|
+
export type TMapRobot = { id: string; name: string; mode?: string; type?: string; left: string; top: string; rot: string; stale?: boolean; seen?: string };
|
|
735
794
|
|
|
736
795
|
const finite = (v: unknown): v is number => typeof v === "number" && Number.isFinite(v);
|
|
737
796
|
|
|
@@ -800,7 +859,8 @@ export function mapRobots(
|
|
|
800
859
|
const live = states[id];
|
|
801
860
|
if (!placeable(live) || live!.floorNo !== floor) return [];
|
|
802
861
|
const at = planPoint(live as any)!;
|
|
803
|
-
|
|
862
|
+
// A kept (stale) reading is drawn faded with "last seen N s ago" - never as live.
|
|
863
|
+
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) }];
|
|
804
864
|
});
|
|
805
865
|
}
|
|
806
866
|
|
|
@@ -828,33 +888,47 @@ export type TPatrolRouteView = {
|
|
|
828
888
|
floorNo: number | null;
|
|
829
889
|
checkpoints: Array<{ name: string; x: number; y: number }>;
|
|
830
890
|
currentCheckpoint: number | null;
|
|
891
|
+
/** Every stop's name in order, placed on the plan or not (PadBot); a route with none placed is listed, never drawn. */
|
|
892
|
+
stops?: string[];
|
|
831
893
|
};
|
|
832
894
|
|
|
895
|
+
const placed = (c: { x?: unknown; y?: unknown }) => finite(c?.x) && finite(c?.y);
|
|
896
|
+
|
|
897
|
+
/** "Gate → Lobby → Car park" for a route whose stops cannot be placed on the plan; "" when it can be drawn. */
|
|
898
|
+
export function unplacedStopsText(route: Pick<TPatrolRouteView, "checkpoints" | "stops"> | null | undefined): string {
|
|
899
|
+
if (!route || (route.checkpoints ?? []).filter(placed).length >= 2 || !route.stops?.length) return "";
|
|
900
|
+
return route.stops.join(" → ");
|
|
901
|
+
}
|
|
902
|
+
|
|
833
903
|
/** The SVG path of a closed route on a `w` x `h` viewBox (checkpoints are 0-100 shares of the plan). */
|
|
834
904
|
export function routePathD(checkpoints: Array<{ x: number; y: number }>, w = 400, h = 300): string {
|
|
835
|
-
|
|
905
|
+
checkpoints = (checkpoints ?? []).filter(placed);
|
|
906
|
+
if (checkpoints.length < 2) return "";
|
|
836
907
|
return checkpoints.map((p, i) => `${i ? "L" : "M"}${((p.x / 100) * w).toFixed(1)} ${((p.y / 100) * h).toFixed(1)}`).join(" ") + " Z";
|
|
837
908
|
}
|
|
838
909
|
|
|
839
910
|
/** Numbered checkpoint markers for every route; `current` = the one the robot is heading for. */
|
|
840
911
|
export function routeMarks(routes: TPatrolRouteView[]) {
|
|
841
912
|
return routes.flatMap((r) =>
|
|
842
|
-
r.checkpoints.
|
|
913
|
+
r.checkpoints.flatMap((c, i) => (!placed(c) ? [] : [{
|
|
843
914
|
key: `${r.id}-${i}`,
|
|
844
915
|
n: i + 1,
|
|
845
916
|
left: `${c.x}%`,
|
|
846
917
|
top: `${c.y}%`,
|
|
847
918
|
label: c.name,
|
|
848
919
|
current: r.currentCheckpoint === i,
|
|
849
|
-
})),
|
|
920
|
+
}])),
|
|
850
921
|
);
|
|
851
922
|
}
|
|
852
923
|
|
|
853
924
|
/** A route-name tag at each route's first checkpoint. */
|
|
854
925
|
export function routeTags(routes: TPatrolRouteView[]) {
|
|
855
926
|
return routes
|
|
856
|
-
.filter((r) => r.checkpoints.
|
|
857
|
-
.map((r) =>
|
|
927
|
+
.filter((r) => r.checkpoints.some(placed))
|
|
928
|
+
.map((r) => {
|
|
929
|
+
const first = r.checkpoints.find(placed)!;
|
|
930
|
+
return { key: r.id, name: r.name, count: r.checkpoints.length, left: `${first.x}%`, top: `${first.y}%` };
|
|
931
|
+
});
|
|
858
932
|
}
|
|
859
933
|
|
|
860
934
|
/**
|
|
@@ -995,6 +1069,84 @@ export function commandBody(kind: TRobotCommandKind, live: TLiveRobotState | nul
|
|
|
995
1069
|
return finite(live?.floorNo) ? { kind, floorNo: live!.floorNo, x: p.x, y: p.y, theta: p.theta } : null;
|
|
996
1070
|
}
|
|
997
1071
|
|
|
1072
|
+
/* ------------------------------------------ saved points (Go to point / Set position) */
|
|
1073
|
+
|
|
1074
|
+
/** §5.1 target point types, in words. */
|
|
1075
|
+
export const POINT_TYPE_TEXT: Readonly<Record<number, string>> = { 0: "Point", 3: "Lift", 4: "Loading", 6: "Access door", 10: "Charging" };
|
|
1076
|
+
|
|
1077
|
+
export type TSavedPoint = { mapId: string; targetPointId: string; name: string; type: string; floorNo: number | null; x: number; y: number; yaw: number };
|
|
1078
|
+
export type TSavedPointGroup = { mapId: string; title: string; floorNo: number | null; points: TSavedPoint[] };
|
|
1079
|
+
|
|
1080
|
+
/**
|
|
1081
|
+
* The robot's own saved points, per map ("Lobby map · L3"), from `/robots/:id/maps`
|
|
1082
|
+
* items that carry `targetPoints` (PadBot). [] for brands without them - they
|
|
1083
|
+
* keep the current-pose behaviour of `commandBody`.
|
|
1084
|
+
*/
|
|
1085
|
+
export function savedPointGroups(maps: Array<Record<string, any>> | null | undefined): TSavedPointGroup[] {
|
|
1086
|
+
return (Array.isArray(maps) ? maps : []).flatMap((m) => {
|
|
1087
|
+
const floorNo = Number.isInteger(m?.floor) ? (m.floor as number) : null;
|
|
1088
|
+
const points = (Array.isArray(m?.targetPoints) ? m.targetPoints : [])
|
|
1089
|
+
.filter((t: any) => typeof t?.targetPointId === "string" && t.targetPointId && finite(t.worldX) && finite(t.worldY))
|
|
1090
|
+
.map((t: any) => ({
|
|
1091
|
+
mapId: String(m.mapId ?? t.mapId ?? ""),
|
|
1092
|
+
targetPointId: t.targetPointId,
|
|
1093
|
+
name: String(t.name || "Unnamed point"),
|
|
1094
|
+
type: POINT_TYPE_TEXT[t.targetPointType] ?? "Point",
|
|
1095
|
+
floorNo,
|
|
1096
|
+
x: t.worldX,
|
|
1097
|
+
y: t.worldY,
|
|
1098
|
+
yaw: finite(t.yaw) ? t.yaw : 0,
|
|
1099
|
+
}));
|
|
1100
|
+
if (!points.length || !m?.mapId) return [];
|
|
1101
|
+
const name = String(m.mapDisplayName || m.mapName || "Map");
|
|
1102
|
+
return [{ mapId: String(m.mapId), title: floorNo === null ? name : `${name} · ${levelText(floorNo)}`, floorNo, points }];
|
|
1103
|
+
});
|
|
1104
|
+
}
|
|
1105
|
+
|
|
1106
|
+
/**
|
|
1107
|
+
* The command for a picked saved point. Go to point names the point
|
|
1108
|
+
* (`go-to-point`); Set position tells the robot it stands there now - only on
|
|
1109
|
+
* the floor the robot reports (null otherwise).
|
|
1110
|
+
*/
|
|
1111
|
+
export function pointCommandBody(kind: "navigate" | "relocalize", point: TSavedPoint, live: TLiveRobotState | null | undefined): Record<string, unknown> | null {
|
|
1112
|
+
if (kind === "navigate") return { kind: "go-to-point", mapId: point.mapId, targetPointId: point.targetPointId };
|
|
1113
|
+
if (point.floorNo === null || !finite(live?.floorNo) || live!.floorNo !== point.floorNo) return null;
|
|
1114
|
+
return { kind: "relocalize", floorNo: point.floorNo, x: point.x, y: point.y, theta: (point.yaw * Math.PI) / 180 };
|
|
1115
|
+
}
|
|
1116
|
+
|
|
1117
|
+
/* ------------------------------------------ recent patrol runs (no schedule API) */
|
|
1118
|
+
|
|
1119
|
+
/**
|
|
1120
|
+
* A HINT, never a schedule: when the same route started on at least 3 of the
|
|
1121
|
+
* last 7 days inside one 60-minute window, "Looks like "Night round" runs
|
|
1122
|
+
* daily around 20:00 (based on the last 7 days)". Local clock. "" otherwise.
|
|
1123
|
+
*/
|
|
1124
|
+
export function patrolRunHint(records: Array<{ routeName?: unknown; startedAt?: unknown }> | null | undefined, now: number = Date.now()): string {
|
|
1125
|
+
const since = now - 7 * 86_400_000;
|
|
1126
|
+
const byRoute = new Map<string, Array<{ day: string; min: number }>>();
|
|
1127
|
+
for (const r of records ?? []) {
|
|
1128
|
+
const t = typeof r?.startedAt === "string" ? Date.parse(r.startedAt) : NaN;
|
|
1129
|
+
const name = typeof r?.routeName === "string" ? r.routeName.trim() : "";
|
|
1130
|
+
if (!name || !finite(t) || t < since || t > now) continue;
|
|
1131
|
+
const d = new Date(t);
|
|
1132
|
+
byRoute.set(name, [...(byRoute.get(name) ?? []), { day: d.toDateString(), min: d.getHours() * 60 + d.getMinutes() }]);
|
|
1133
|
+
}
|
|
1134
|
+
let best: { name: string; days: number; min: number } | null = null;
|
|
1135
|
+
for (const [name, runs] of byRoute) {
|
|
1136
|
+
for (const start of runs) {
|
|
1137
|
+
const inWin = runs.filter((x) => x.min >= start.min && x.min < start.min + 60);
|
|
1138
|
+
const days = new Set(inWin.map((x) => x.day)).size;
|
|
1139
|
+
if (days >= 3 && (!best || days > best.days)) {
|
|
1140
|
+
best = { name, days, min: Math.round(inWin.reduce((s, x) => s + x.min, 0) / inWin.length / 5) * 5 };
|
|
1141
|
+
}
|
|
1142
|
+
}
|
|
1143
|
+
}
|
|
1144
|
+
if (!best) return "";
|
|
1145
|
+
const hhmm = `${String(Math.floor(best.min / 60) % 24).padStart(2, "0")}:${String(best.min % 60).padStart(2, "0")}`;
|
|
1146
|
+
const how = best.days >= 6 ? "daily" : `on ${best.days} of the last 7 days`;
|
|
1147
|
+
return `Looks like "${best.name}" runs ${how} around ${hhmm} (based on the last 7 days)`;
|
|
1148
|
+
}
|
|
1149
|
+
|
|
998
1150
|
/**
|
|
999
1151
|
* May the plan take spot-clean clicks? A Manager, a cleaning robot, a brand
|
|
1000
1152
|
* that declares `spotClean`, and a robot confirmed online. (A busy robot keeps
|
|
@@ -1039,12 +1191,15 @@ export function speedText(
|
|
|
1039
1191
|
}
|
|
1040
1192
|
|
|
1041
1193
|
/** "x 17.9 · y 34.1 · 53°" (metres, the robot's map frame) - or "Reconnecting", or "Not reported". */
|
|
1042
|
-
export function positionText(live: TLiveRobotState | null | undefined, capabilities: Record<string, unknown> | null | undefined): string {
|
|
1194
|
+
export function positionText(live: TLiveRobotState | null | undefined, capabilities: Record<string, unknown> | null | undefined, now: number = Date.now()): string {
|
|
1043
1195
|
if (capabilities?.readLocation !== true) return "Not reported";
|
|
1044
1196
|
const p = live?.position;
|
|
1045
|
-
if (!live?.ok || live.online === false
|
|
1197
|
+
if (!live?.ok || live.online === false) return "Reconnecting";
|
|
1198
|
+
// Answering, but it has no position right now (e.g. not relocalised) - said, not guessed.
|
|
1199
|
+
if (!p || !finite(p.x) || !finite(p.y)) return "Position not reported right now";
|
|
1046
1200
|
const deg = finite(p.theta) ? Math.round(((((p.theta * 180) / Math.PI) % 360) + 360) % 360) : 0;
|
|
1047
|
-
|
|
1201
|
+
const seen = staleText(live, now);
|
|
1202
|
+
return `x ${p.x.toFixed(1)} · y ${p.y.toFixed(1)} · ${deg}°${seen ? ` · ${seen}` : ""}`;
|
|
1048
1203
|
}
|
|
1049
1204
|
|
|
1050
1205
|
export type TRobotFact = { label: string; value: string; dim: boolean };
|
|
@@ -1105,6 +1260,8 @@ export function mergePose(
|
|
|
1105
1260
|
const plan = event.planPosition as { x?: unknown; y?: unknown } | null | undefined;
|
|
1106
1261
|
return {
|
|
1107
1262
|
...live,
|
|
1263
|
+
stale: false,
|
|
1264
|
+
positionAt: Date.now(),
|
|
1108
1265
|
position: { x, y, theta: finite(theta) ? theta : live.position?.theta ?? 0 },
|
|
1109
1266
|
...(plan && finite(plan.x) && finite(plan.y) ? { planPosition: { x: plan.x, y: plan.y } } : {}),
|
|
1110
1267
|
...(typeof mode === "string" && mode ? { mode } : {}),
|