@7365admin1/layer-common 4.53.0 → 4.54.1

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