@7365admin1/layer-common 4.53.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 +10 -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 +94 -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 +164 -15
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
# @iservice365/layer-common
|
|
2
2
|
|
|
3
|
+
## 4.54.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- 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.
|
|
8
|
+
|
|
3
9
|
## 4.53.0
|
|
4
10
|
|
|
5
11
|
### Minor Changes
|
|
@@ -1563,11 +1569,11 @@
|
|
|
1563
1569
|
`utils/console-tier.ts` + `composables/useConsoleTier.ts` mirror the server's
|
|
1564
1570
|
own rule from two endpoints the console already calls, unprojected:
|
|
1565
1571
|
|
|
1566
|
-
|
|
1567
|
-
|
|
1572
|
+
GET /api/members/user/:user/app/admin the Seven365 staff membership
|
|
1573
|
+
GET /api/roles/id/:role that membership's role document
|
|
1568
1574
|
|
|
1569
|
-
|
|
1570
|
-
|
|
1575
|
+
owner = member.type === "admin" && role.type === "admin" && role.default === true
|
|
1576
|
+
staff = member.type === "admin" && role.type === "admin"
|
|
1571
1577
|
|
|
1572
1578
|
`role.default` is the marker because it is the only property of a platform
|
|
1573
1579
|
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" />
|
|
@@ -25,9 +25,12 @@
|
|
|
25
25
|
<span class="hero-faultbar__code">{{ fault.code }}</span>
|
|
26
26
|
<div>
|
|
27
27
|
<div class="hero-faultbar__msg">{{ fault.message || fault.code }}</div>
|
|
28
|
-
<div class="hero-faultbar__since">
|
|
28
|
+
<div class="hero-faultbar__since">
|
|
29
|
+
Since {{ faultSince }} · notified once
|
|
30
|
+
<template v-for="ph in faultPhotos(fault)" :key="ph.label"> · <a :href="ph.url" target="_blank" rel="noopener noreferrer">{{ ph.label }}</a></template>
|
|
31
|
+
</div>
|
|
29
32
|
</div>
|
|
30
|
-
<v-btn v-if="caps?.liveVideo === true && online" variant="outlined" size="small"
|
|
33
|
+
<v-btn v-if="caps?.liveVideo === true && online" variant="outlined" size="small" @click="liveOpen = true">Live view</v-btn>
|
|
31
34
|
<v-btn v-if="canResolve" variant="outlined" size="small" :loading="resolving" @click="resolveFault">Mark resolved</v-btn>
|
|
32
35
|
<span class="hero-faultbar__badge">{{ faultBadge(fault) }}</span>
|
|
33
36
|
</div>
|
|
@@ -41,8 +44,9 @@
|
|
|
41
44
|
</div>
|
|
42
45
|
</div>
|
|
43
46
|
<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
|
|
47
|
+
<div v-if="(caps?.liveVideo === true && online && !fault) || canDrive" class="hero-detail__video">
|
|
48
|
+
<v-btn v-if="caps?.liveVideo === true && online && !fault" variant="outlined" @click="liveOpen = true">Live view</v-btn>
|
|
49
|
+
<v-btn v-if="canDrive" variant="outlined" @click="driveOpen = true">Manual control</v-btn>
|
|
46
50
|
</div>
|
|
47
51
|
|
|
48
52
|
<div v-if="commands.length">
|
|
@@ -67,10 +71,26 @@
|
|
|
67
71
|
robotType === "security" ? "days · start–end · floor · checkpoints × rounds" : "days · start–end · jobs per floor · passes"
|
|
68
72
|
}}</span>
|
|
69
73
|
</div>
|
|
70
|
-
<
|
|
74
|
+
<template v-if="!schedule.length && robotType === 'security' && patrol?.scheduleShared === false">
|
|
75
|
+
<div class="hero-sched__empty">Patrol schedules are set in the PadBot app. PadBot doesn't share them with us yet.</div>
|
|
76
|
+
<div v-if="runHint" class="hero-sched__hint">{{ runHint }} - a guess from recent runs, not the schedule.</div>
|
|
77
|
+
<template v-if="recentRuns.length">
|
|
78
|
+
<div class="hero-aside__hint">Recent patrol runs</div>
|
|
79
|
+
<div v-for="r in recentRuns" :key="r.key" class="hero-sched__row">
|
|
80
|
+
<b>{{ r.when }}</b><span>{{ r.route }} · {{ r.result }}</span><span>{{ r.detail }}</span>
|
|
81
|
+
</div>
|
|
82
|
+
</template>
|
|
83
|
+
</template>
|
|
84
|
+
<div v-else-if="!schedule.length" class="hero-sched__empty">No schedule yet.</div>
|
|
71
85
|
<div v-for="p in schedule" :key="p.key" class="hero-sched__row">
|
|
72
86
|
<b>{{ p.days }}</b><span>{{ p.time }}</span><span>{{ p.jobs }}</span>
|
|
73
87
|
</div>
|
|
88
|
+
<template v-if="unplacedRoutes.length">
|
|
89
|
+
<div class="hero-aside__hint">Patrol routes (stops not placed on the plan)</div>
|
|
90
|
+
<div v-for="r in unplacedRoutes" :key="r.id" class="hero-sched__row">
|
|
91
|
+
<b>{{ r.name }}</b><span>Stops: {{ r.text }}</span>
|
|
92
|
+
</div>
|
|
93
|
+
</template>
|
|
74
94
|
</div>
|
|
75
95
|
|
|
76
96
|
<div>
|
|
@@ -82,6 +102,7 @@
|
|
|
82
102
|
:name="record?.name"
|
|
83
103
|
:status-label="statusLabel"
|
|
84
104
|
:point="posLive ? point : null"
|
|
105
|
+
:stale="shown?.stale === true"
|
|
85
106
|
:heading="headingDeg(shown)"
|
|
86
107
|
:trail="trail"
|
|
87
108
|
:readout="readout"
|
|
@@ -131,6 +152,16 @@
|
|
|
131
152
|
</div>
|
|
132
153
|
</v-dialog>
|
|
133
154
|
|
|
155
|
+
<v-dialog v-model="liveOpen" width="860">
|
|
156
|
+
<RobotLiveView v-if="liveOpen" :robot-id="robotId" :name="record?.name" @close="liveOpen = false" />
|
|
157
|
+
</v-dialog>
|
|
158
|
+
<v-dialog v-model="driveOpen" width="520" persistent>
|
|
159
|
+
<RobotManualDrive v-if="driveOpen" :robot-id="robotId" :name="record?.name" @close="driveOpen = false" />
|
|
160
|
+
</v-dialog>
|
|
161
|
+
<v-dialog v-model="pickOpen" width="520">
|
|
162
|
+
<RobotPointPicker v-if="pickOpen && pickKind" :kind="pickKind" :groups="pointGroups" :floor-no="live?.floorNo ?? null" @close="pickOpen = false" @pick="onPick" />
|
|
163
|
+
</v-dialog>
|
|
164
|
+
|
|
134
165
|
<RobotToast v-model="toast" />
|
|
135
166
|
</template>
|
|
136
167
|
|
|
@@ -144,6 +175,10 @@ import RobotLivePlan from "./LivePlan.vue";
|
|
|
144
175
|
import RobotToast from "./Toast.vue";
|
|
145
176
|
import RobotForm from "./Form.vue";
|
|
146
177
|
import RobotPatrolHistory from "./PatrolHistory.vue";
|
|
178
|
+
import RobotLiveView from "./LiveView.vue";
|
|
179
|
+
import RobotManualDrive from "./ManualDrive.vue";
|
|
180
|
+
import RobotPointPicker from "./PointPicker.vue";
|
|
181
|
+
import { faultPhotos } from "../../utils/robot-control";
|
|
147
182
|
import useRobotLive, { siteStatesFeed } from "../../composables/useRobotLive";
|
|
148
183
|
import {
|
|
149
184
|
brandName,
|
|
@@ -157,7 +192,12 @@ import {
|
|
|
157
192
|
mergePose,
|
|
158
193
|
notReportedLine,
|
|
159
194
|
optimisticMode,
|
|
195
|
+
patrolRecordRow,
|
|
196
|
+
patrolRunHint,
|
|
160
197
|
patrolScheduleRows,
|
|
198
|
+
pointCommandBody,
|
|
199
|
+
savedPointGroups,
|
|
200
|
+
unplacedStopsText,
|
|
161
201
|
planPoint,
|
|
162
202
|
positionText,
|
|
163
203
|
robotErrorMessage,
|
|
@@ -173,6 +213,8 @@ import {
|
|
|
173
213
|
type TLiveRobotState,
|
|
174
214
|
type TPatrolRouteView,
|
|
175
215
|
type TRobotCommandKind,
|
|
216
|
+
type TSavedPoint,
|
|
217
|
+
type TSavedPointGroup,
|
|
176
218
|
} from "../../utils/robot-display";
|
|
177
219
|
|
|
178
220
|
const props = withDefaults(
|
|
@@ -207,8 +249,8 @@ const {
|
|
|
207
249
|
deleteRobot,
|
|
208
250
|
getRobotMaps,
|
|
209
251
|
getRobotMapImages,
|
|
210
|
-
getLiveVideo,
|
|
211
252
|
resolveRobotAlerts,
|
|
253
|
+
getPatrolHistory,
|
|
212
254
|
} = useRobots();
|
|
213
255
|
|
|
214
256
|
const record = ref<Record<string, any> | null>(null);
|
|
@@ -282,7 +324,7 @@ watch(
|
|
|
282
324
|
if (p && posLive.value && isMoving(statusMode.value)) trail.value = trailPush(trail.value, p);
|
|
283
325
|
},
|
|
284
326
|
);
|
|
285
|
-
const readout = computed(() => `${positionText(live.value, caps.value)} · ${speedText(prevPose.value, nextPose.value, statusMode.value)}`);
|
|
327
|
+
const readout = computed(() => `${positionText(live.value, caps.value, now.value)} · ${speedText(prevPose.value, nextPose.value, statusMode.value)}`);
|
|
286
328
|
|
|
287
329
|
const spot = ref<Array<{ x: number; y: number }>>([]);
|
|
288
330
|
const spotOn = computed(() => showSpotClean({ canCommand: props.canCommand, capabilities: caps.value, online: online.value, type: robotType.value }));
|
|
@@ -299,14 +341,25 @@ const planHint = computed(() => {
|
|
|
299
341
|
|
|
300
342
|
/* ---- schedule: cleaning plans from the brand, or the patrol contract ---- */
|
|
301
343
|
const plans = ref<Array<Record<string, any>>>([]);
|
|
302
|
-
const patrol = ref<{ supported?: boolean; routes?: TPatrolRouteView[]; schedule?: any[] } | null>(null);
|
|
344
|
+
const patrol = ref<{ supported?: boolean; routes?: TPatrolRouteView[]; schedule?: any[]; scheduleShared?: boolean } | null>(null);
|
|
303
345
|
const schedule = computed(() => (robotType.value === "security" ? patrolScheduleRows(patrol.value) : cleaningScheduleRows(plans.value)));
|
|
346
|
+
const unplacedRoutes = computed(() =>
|
|
347
|
+
(patrol.value?.routes ?? []).map((r) => ({ id: r.id, name: r.name, text: unplacedStopsText(r) })).filter((r) => r.text),
|
|
348
|
+
);
|
|
349
|
+
// The brand keeps its schedules to itself: recent runs (and a labelled guess) instead.
|
|
350
|
+
const runs = ref<any[]>([]);
|
|
351
|
+
const recentRuns = computed(() => runs.value.slice(0, 5).map((r) => patrolRecordRow(r, now.value)));
|
|
352
|
+
const runHint = computed(() => patrolRunHint(runs.value, now.value));
|
|
304
353
|
|
|
305
354
|
async function loadSchedule() {
|
|
306
355
|
if (!record.value || noInterface.value) return;
|
|
307
356
|
try {
|
|
308
|
-
if (robotType.value === "security")
|
|
309
|
-
|
|
357
|
+
if (robotType.value === "security") {
|
|
358
|
+
patrol.value = await getPatrolRoutes(props.robotId);
|
|
359
|
+
if (patrol.value?.scheduleShared === false && caps.value?.patrolHistory === true && !runs.value.length) {
|
|
360
|
+
runs.value = (await getPatrolHistory(props.robotId, { page: 1, limit: 20 }).catch(() => null))?.items ?? [];
|
|
361
|
+
}
|
|
362
|
+
} else plans.value = (await getRobotPlans(props.robotId))?.items ?? [];
|
|
310
363
|
} catch {
|
|
311
364
|
// A schedule the brand cannot serve reads "No schedule yet"; the facts still show.
|
|
312
365
|
}
|
|
@@ -356,7 +409,23 @@ async function run(kind: TRobotCommandKind | "spot", body: Record<string, unknow
|
|
|
356
409
|
busy.value = null;
|
|
357
410
|
}
|
|
358
411
|
}
|
|
359
|
-
|
|
412
|
+
/* Go to point / Set position: the robot's own saved points when its brand lists them (PadBot). */
|
|
413
|
+
const pointGroups = ref<TSavedPointGroup[]>([]);
|
|
414
|
+
const pickOpen = ref(false);
|
|
415
|
+
const pickKind = ref<"navigate" | "relocalize" | null>(null);
|
|
416
|
+
async function send(kind: TRobotCommandKind) {
|
|
417
|
+
if (kind === "navigate" || kind === "relocalize") {
|
|
418
|
+
try {
|
|
419
|
+
pointGroups.value = savedPointGroups((await getRobotMaps(props.robotId))?.items);
|
|
420
|
+
} catch {
|
|
421
|
+
pointGroups.value = [];
|
|
422
|
+
}
|
|
423
|
+
if (pointGroups.value.length) {
|
|
424
|
+
pickKind.value = kind;
|
|
425
|
+
pickOpen.value = true;
|
|
426
|
+
return;
|
|
427
|
+
}
|
|
428
|
+
}
|
|
360
429
|
const body = commandBody(kind, live.value);
|
|
361
430
|
if (!body) {
|
|
362
431
|
toast.value = `${commandLabel(kind)}: the robot's position is not known yet.`;
|
|
@@ -399,26 +468,22 @@ async function sendSpot() {
|
|
|
399
468
|
}
|
|
400
469
|
}
|
|
401
470
|
|
|
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;
|
|
471
|
+
function onPick(p: TSavedPoint) {
|
|
472
|
+
const kind = pickKind.value;
|
|
473
|
+
pickOpen.value = false;
|
|
474
|
+
if (!kind) return;
|
|
475
|
+
const body = pointCommandBody(kind, p, live.value);
|
|
476
|
+
if (!body) {
|
|
477
|
+
toast.value = "Set position: the robot is not on that floor.";
|
|
478
|
+
return;
|
|
420
479
|
}
|
|
480
|
+
run(kind, body);
|
|
421
481
|
}
|
|
482
|
+
|
|
483
|
+
/* ---- security-robot extras: live view (minted per open, never kept), manual control, resolve an alarm ---- */
|
|
484
|
+
const liveOpen = ref(false);
|
|
485
|
+
const driveOpen = ref(false);
|
|
486
|
+
const canDrive = computed(() => props.canCommand && caps.value?.manualDrive === true && online.value);
|
|
422
487
|
const canResolve = computed(
|
|
423
488
|
() => props.canCommand && caps.value?.resolveAlerts === true && faultBadge(fault.value) === "ALARM" && typeof fault.value?.code === "string",
|
|
424
489
|
);
|
|
@@ -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.0",
|
|
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
|
@@ -609,28 +609,79 @@ export type TLiveRobotState = {
|
|
|
609
609
|
/** The brand's own status words ("CLEANING_AUTO"), quoted as "Brand status". */
|
|
610
610
|
statusName?: string;
|
|
611
611
|
model?: string;
|
|
612
|
-
fault?: { code: string; message?: string; since?: number } | null;
|
|
612
|
+
fault?: { code: string; message?: string; since?: number; imageUrl?: string | null; thermalImageUrl?: string | null } | null;
|
|
613
613
|
capabilities?: Record<string, unknown>;
|
|
614
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;
|
|
615
621
|
};
|
|
616
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
|
+
|
|
617
629
|
/**
|
|
618
|
-
* Fold one poll's answer into the live map.
|
|
619
|
-
*
|
|
620
|
-
*
|
|
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.
|
|
621
642
|
*/
|
|
622
643
|
export function mergeLiveStates(
|
|
623
644
|
prev: Record<string, TLiveRobotState>,
|
|
624
645
|
items: Array<Partial<TLiveRobotState> & { id?: unknown }> | null | undefined,
|
|
646
|
+
now: number = Date.now(),
|
|
625
647
|
): Record<string, TLiveRobotState> {
|
|
626
648
|
const next = { ...prev };
|
|
627
649
|
for (const item of items ?? []) {
|
|
628
650
|
if (!item || typeof item.id !== "string") continue;
|
|
629
|
-
|
|
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;
|
|
630
673
|
}
|
|
631
674
|
return next;
|
|
632
675
|
}
|
|
633
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
|
+
|
|
634
685
|
/**
|
|
635
686
|
* What the Status cell says for a row: the live mode when read; "Reconnecting"
|
|
636
687
|
* when the last poll got no answer; "Offline" from the stored record while no
|
|
@@ -731,7 +782,7 @@ export function brandName(brand: unknown, brands?: Array<{ brand?: unknown; disp
|
|
|
731
782
|
|
|
732
783
|
/* ------------------------------------------------------------- mini-map */
|
|
733
784
|
|
|
734
|
-
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 };
|
|
735
786
|
|
|
736
787
|
const finite = (v: unknown): v is number => typeof v === "number" && Number.isFinite(v);
|
|
737
788
|
|
|
@@ -800,7 +851,8 @@ export function mapRobots(
|
|
|
800
851
|
const live = states[id];
|
|
801
852
|
if (!placeable(live) || live!.floorNo !== floor) return [];
|
|
802
853
|
const at = planPoint(live as any)!;
|
|
803
|
-
|
|
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) }];
|
|
804
856
|
});
|
|
805
857
|
}
|
|
806
858
|
|
|
@@ -828,33 +880,47 @@ export type TPatrolRouteView = {
|
|
|
828
880
|
floorNo: number | null;
|
|
829
881
|
checkpoints: Array<{ name: string; x: number; y: number }>;
|
|
830
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[];
|
|
831
885
|
};
|
|
832
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
|
+
|
|
833
895
|
/** The SVG path of a closed route on a `w` x `h` viewBox (checkpoints are 0-100 shares of the plan). */
|
|
834
896
|
export function routePathD(checkpoints: Array<{ x: number; y: number }>, w = 400, h = 300): string {
|
|
835
|
-
|
|
897
|
+
checkpoints = (checkpoints ?? []).filter(placed);
|
|
898
|
+
if (checkpoints.length < 2) return "";
|
|
836
899
|
return checkpoints.map((p, i) => `${i ? "L" : "M"}${((p.x / 100) * w).toFixed(1)} ${((p.y / 100) * h).toFixed(1)}`).join(" ") + " Z";
|
|
837
900
|
}
|
|
838
901
|
|
|
839
902
|
/** Numbered checkpoint markers for every route; `current` = the one the robot is heading for. */
|
|
840
903
|
export function routeMarks(routes: TPatrolRouteView[]) {
|
|
841
904
|
return routes.flatMap((r) =>
|
|
842
|
-
r.checkpoints.
|
|
905
|
+
r.checkpoints.flatMap((c, i) => (!placed(c) ? [] : [{
|
|
843
906
|
key: `${r.id}-${i}`,
|
|
844
907
|
n: i + 1,
|
|
845
908
|
left: `${c.x}%`,
|
|
846
909
|
top: `${c.y}%`,
|
|
847
910
|
label: c.name,
|
|
848
911
|
current: r.currentCheckpoint === i,
|
|
849
|
-
})),
|
|
912
|
+
}])),
|
|
850
913
|
);
|
|
851
914
|
}
|
|
852
915
|
|
|
853
916
|
/** A route-name tag at each route's first checkpoint. */
|
|
854
917
|
export function routeTags(routes: TPatrolRouteView[]) {
|
|
855
918
|
return routes
|
|
856
|
-
.filter((r) => r.checkpoints.
|
|
857
|
-
.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
|
+
});
|
|
858
924
|
}
|
|
859
925
|
|
|
860
926
|
/**
|
|
@@ -995,6 +1061,84 @@ export function commandBody(kind: TRobotCommandKind, live: TLiveRobotState | nul
|
|
|
995
1061
|
return finite(live?.floorNo) ? { kind, floorNo: live!.floorNo, x: p.x, y: p.y, theta: p.theta } : null;
|
|
996
1062
|
}
|
|
997
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
|
+
|
|
998
1142
|
/**
|
|
999
1143
|
* May the plan take spot-clean clicks? A Manager, a cleaning robot, a brand
|
|
1000
1144
|
* that declares `spotClean`, and a robot confirmed online. (A busy robot keeps
|
|
@@ -1039,12 +1183,15 @@ export function speedText(
|
|
|
1039
1183
|
}
|
|
1040
1184
|
|
|
1041
1185
|
/** "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 {
|
|
1186
|
+
export function positionText(live: TLiveRobotState | null | undefined, capabilities: Record<string, unknown> | null | undefined, now: number = Date.now()): string {
|
|
1043
1187
|
if (capabilities?.readLocation !== true) return "Not reported";
|
|
1044
1188
|
const p = live?.position;
|
|
1045
|
-
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";
|
|
1046
1192
|
const deg = finite(p.theta) ? Math.round(((((p.theta * 180) / Math.PI) % 360) + 360) % 360) : 0;
|
|
1047
|
-
|
|
1193
|
+
const seen = staleText(live, now);
|
|
1194
|
+
return `x ${p.x.toFixed(1)} · y ${p.y.toFixed(1)} · ${deg}°${seen ? ` · ${seen}` : ""}`;
|
|
1048
1195
|
}
|
|
1049
1196
|
|
|
1050
1197
|
export type TRobotFact = { label: string; value: string; dim: boolean };
|
|
@@ -1105,6 +1252,8 @@ export function mergePose(
|
|
|
1105
1252
|
const plan = event.planPosition as { x?: unknown; y?: unknown } | null | undefined;
|
|
1106
1253
|
return {
|
|
1107
1254
|
...live,
|
|
1255
|
+
stale: false,
|
|
1256
|
+
positionAt: Date.now(),
|
|
1108
1257
|
position: { x, y, theta: finite(theta) ? theta : live.position?.theta ?? 0 },
|
|
1109
1258
|
...(plan && finite(plan.x) && finite(plan.y) ? { planPosition: { x: plan.x, y: plan.y } } : {}),
|
|
1110
1259
|
...(typeof mode === "string" && mode ? { mode } : {}),
|