@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 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
- GET /api/members/user/:user/app/admin the Seven365 staff membership
1567
- GET /api/roles/id/:role that membership's role document
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
- owner = member.type === "admin" && role.type === "admin" && role.default === true
1570
- staff = member.type === "admin" && role.type === "admin"
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 }">{{ attentionText(item).message }}</template>
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">Since {{ faultSince }} · notified once</div>
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" :loading="videoBusy" @click="openVideo">Live video</v-btn>
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 variant="outlined" :loading="videoBusy" @click="openVideo">Live video</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
- <div v-if="!schedule.length" class="hero-sched__empty">No schedule yet.</div>
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") patrol.value = await getPatrolRoutes(props.robotId);
309
- else plans.value = (await getRobotPlans(props.robotId))?.items ?? [];
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
- function send(kind: TRobotCommandKind) {
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
- /* ---- security-robot extras: live video (minted per click, never kept), resolve an alarm ---- */
403
- const videoBusy = ref(false);
404
- async function openVideo() {
405
- // Opened before the await so the browser treats it as the click's own window.
406
- const win = typeof window !== "undefined" ? window.open("", "_blank") : null;
407
- videoBusy.value = true;
408
- try {
409
- const { url } = await getLiveVideo(props.robotId);
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
- :title="r.name"
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>
@@ -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.53.0",
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
+ }
@@ -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. A robot that did not answer
619
- * REPLACES its old state with `{ ok: false }`: a stale battery or position
620
- * must never stay on screen as if live. Robots not in this answer keep theirs.
630
+ * Fold one poll's answer into the live map. Robots not in this answer keep theirs.
631
+ *
632
+ * One missed or failed update must not make a robot vanish from the map
633
+ * (owner, 2026-09-26: it disappeared and came back while moving). So:
634
+ * - no answer (`ok: false`): the last reading is kept, marked `stale`, for
635
+ * up to `LIVE_GRACE_MS` after it was read; past that it is REPLACED by
636
+ * `{ ok: false }` ("Reconnecting") - a stale battery or position is never
637
+ * kept as if live;
638
+ * - an answer without a position: the last position is kept (stale) within
639
+ * the same window, unless the robot now says it is offline or that its
640
+ * floor is unknown (`floorKnown: false`);
641
+ * - an answer without floor fields keeps the last floor.
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
- next[item.id] = item.ok === true ? ({ ...item, ok: true } as TLiveRobotState) : { id: item.id, ok: false, monitorable: item.monitorable !== false };
651
+ const p = prev[item.id];
652
+ const recent = (at: number | undefined) => finite(at) && now - at <= LIVE_GRACE_MS;
653
+ if (item.ok !== true) {
654
+ next[item.id] =
655
+ item.monitorable !== false && p?.ok && recent(p.readAt)
656
+ ? { ...p, stale: true }
657
+ : { id: item.id, ok: false, monitorable: item.monitorable !== false };
658
+ continue;
659
+ }
660
+ const s = { ...item, ok: true, readAt: now, stale: false } as TLiveRobotState;
661
+ if (!("floorKnown" in item) && !("floorNo" in item) && p?.ok) {
662
+ s.floorNo = p.floorNo;
663
+ s.floorKnown = p.floorKnown;
664
+ }
665
+ if (hasPos(item)) s.positionAt = now;
666
+ else if (p?.ok && hasPos(p) && recent(p.positionAt) && item.online !== false && item.floorKnown !== false) {
667
+ s.position = p.position ?? null;
668
+ s.planPosition = p.planPosition ?? null;
669
+ s.positionAt = p.positionAt;
670
+ s.stale = true;
671
+ }
672
+ next[item.id] = s;
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
- return [{ id, name: String(r.name ?? ""), mode: live!.mode, type: r.type as string, ...at, rot: `rotate(${headingDeg(live)}deg)` }];
854
+ // A kept (stale) reading is drawn faded with "last seen N s ago" - never as live.
855
+ return [{ id, name: String(r.name ?? ""), mode: live!.mode, type: r.type as string, ...at, rot: `rotate(${headingDeg(live)}deg)`, stale: live!.stale === true, seen: staleText(live) }];
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
- if (!checkpoints?.length) return "";
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.map((c, i) => ({
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.length)
857
- .map((r) => ({ key: r.id, name: r.name, count: r.checkpoints.length, left: `${r.checkpoints[0]!.x}%`, top: `${r.checkpoints[0]!.y}%` }));
919
+ .filter((r) => r.checkpoints.some(placed))
920
+ .map((r) => {
921
+ const first = r.checkpoints.find(placed)!;
922
+ return { key: r.id, name: r.name, count: r.checkpoints.length, left: `${first.x}%`, top: `${first.y}%` };
923
+ });
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 || !p || !finite(p.x) || !finite(p.y)) return "Reconnecting";
1189
+ if (!live?.ok || live.online === false) return "Reconnecting";
1190
+ // Answering, but it has no position right now (e.g. not relocalised) - said, not guessed.
1191
+ if (!p || !finite(p.x) || !finite(p.y)) return "Position not reported right now";
1046
1192
  const deg = finite(p.theta) ? Math.round(((((p.theta * 180) / Math.PI) % 360) + 360) % 360) : 0;
1047
- return `x ${p.x.toFixed(1)} · y ${p.y.toFixed(1)} · ${deg}°`;
1193
+ const seen = staleText(live, now);
1194
+ return `x ${p.x.toFixed(1)} · y ${p.y.toFixed(1)} · ${deg}°${seen ? ` · ${seen}` : ""}`;
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 } : {}),