@7365admin1/layer-common 4.52.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,17 @@
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
+
9
+ ## 4.53.0
10
+
11
+ ### Minor Changes
12
+
13
+ - 74aabc6: PadBot security robots on the HERO robot screens: Add robot labels the fields "AccessKey ID" / "AccessKey Secret" and picks the robot from the account; robot detail offers Start patrol / Resume patrol (brands with `patrolControl`), Live video (minted per click), Mark resolved on an alarm, an ALARM badge for security alerts, and a Patrol history panel. Pause / Resume now need the brand's `pauseResume`, Cancel its `pauseProcess` (no change for existing brands).
14
+
3
15
  ## 4.52.0
4
16
 
5
17
  ### Minor Changes
@@ -1557,11 +1569,11 @@
1557
1569
  `utils/console-tier.ts` + `composables/useConsoleTier.ts` mirror the server's
1558
1570
  own rule from two endpoints the console already calls, unprojected:
1559
1571
 
1560
- GET /api/members/user/:user/app/admin the Seven365 staff membership
1561
- 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
1562
1574
 
1563
- owner = member.type === "admin" && role.type === "admin" && role.default === true
1564
- 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"
1565
1577
 
1566
1578
  `role.default` is the marker because it is the only property of a platform
1567
1579
  staff role no API caller can set - `role.controller.ts` validates create and
@@ -757,7 +757,7 @@
757
757
  .hero .v-btn.hero-ghost { color: var(--hero-accent); padding-inline: 4px; }
758
758
  .hero .v-btn.hero-ghost:hover { background: color-mix(in srgb, var(--hero-accent) 10%, transparent); }
759
759
 
760
- .hero-faultbar { display: grid; grid-template-columns: auto 1fr auto; gap: 14px; align-items: center; padding: 12px 14px; border: 2px solid var(--hero-accent); }
760
+ .hero-faultbar { display: grid; grid-template-columns: auto 1fr; grid-auto-flow: column; grid-auto-columns: auto; gap: 14px; align-items: center; padding: 12px 14px; border: 2px solid var(--hero-accent); }
761
761
  .hero-faultbar__code { font-family: var(--hero-font); font-weight: 800; color: var(--hero-accent); font-size: 18px; }
762
762
  .hero-faultbar__msg { font-weight: 600; }
763
763
  .hero-faultbar__since { opacity: 0.65; font-size: 12px; }
@@ -1234,3 +1234,26 @@ div.hero-editor__wp.hero-editor__sel, div.hero-editor__fid.hero-editor__sel { ou
1234
1234
  }
1235
1235
  .hero-cell__shimmer { display: none; }
1236
1236
  }
1237
+
1238
+ /* PadBot security extras: live video button row under the facts. */
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,14 @@
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
- <span class="hero-faultbar__badge">FAULT</span>
33
+ <v-btn v-if="caps?.liveVideo === true && online" variant="outlined" size="small" @click="liveOpen = true">Live view</v-btn>
34
+ <v-btn v-if="canResolve" variant="outlined" size="small" :loading="resolving" @click="resolveFault">Mark resolved</v-btn>
35
+ <span class="hero-faultbar__badge">{{ faultBadge(fault) }}</span>
31
36
  </div>
32
37
 
33
38
  <RobotNoMonitoring v-if="noInterface" :brand-name="brand" :model="live?.model || undefined" />
@@ -39,6 +44,10 @@
39
44
  </div>
40
45
  </div>
41
46
  <div v-if="notReported" class="hero-notreported">{{ notReported }}</div>
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>
50
+ </div>
42
51
 
43
52
  <div v-if="commands.length">
44
53
  <div class="hero-section__title">Command bar</div>
@@ -62,10 +71,26 @@
62
71
  robotType === "security" ? "days · start–end · floor · checkpoints × rounds" : "days · start–end · jobs per floor · passes"
63
72
  }}</span>
64
73
  </div>
65
- <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>
66
85
  <div v-for="p in schedule" :key="p.key" class="hero-sched__row">
67
86
  <b>{{ p.days }}</b><span>{{ p.time }}</span><span>{{ p.jobs }}</span>
68
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>
69
94
  </div>
70
95
 
71
96
  <div>
@@ -77,6 +102,7 @@
77
102
  :name="record?.name"
78
103
  :status-label="statusLabel"
79
104
  :point="posLive ? point : null"
105
+ :stale="shown?.stale === true"
80
106
  :heading="headingDeg(shown)"
81
107
  :trail="trail"
82
108
  :readout="readout"
@@ -97,6 +123,8 @@
97
123
  </div>
98
124
  </div>
99
125
 
126
+ <RobotPatrolHistory v-if="robotType === 'security' && caps?.patrolHistory === true" :robot-id="robotId" />
127
+
100
128
  <v-dialog v-model="editOpen" width="520" persistent>
101
129
  <RobotForm
102
130
  v-if="editOpen && record"
@@ -124,6 +152,16 @@
124
152
  </div>
125
153
  </v-dialog>
126
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
+
127
165
  <RobotToast v-model="toast" />
128
166
  </template>
129
167
 
@@ -136,6 +174,11 @@ import RobotViewOnlyNotice from "./ViewOnlyNotice.vue";
136
174
  import RobotLivePlan from "./LivePlan.vue";
137
175
  import RobotToast from "./Toast.vue";
138
176
  import RobotForm from "./Form.vue";
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";
139
182
  import useRobotLive, { siteStatesFeed } from "../../composables/useRobotLive";
140
183
  import {
141
184
  brandName,
@@ -143,12 +186,18 @@ import {
143
186
  cleaningScheduleRows,
144
187
  commandBody,
145
188
  commandLabel,
189
+ faultBadge,
146
190
  headingDeg,
147
191
  isMoving,
148
192
  mergePose,
149
193
  notReportedLine,
150
194
  optimisticMode,
195
+ patrolRecordRow,
196
+ patrolRunHint,
151
197
  patrolScheduleRows,
198
+ pointCommandBody,
199
+ savedPointGroups,
200
+ unplacedStopsText,
152
201
  planPoint,
153
202
  positionText,
154
203
  robotErrorMessage,
@@ -164,6 +213,8 @@ import {
164
213
  type TLiveRobotState,
165
214
  type TPatrolRouteView,
166
215
  type TRobotCommandKind,
216
+ type TSavedPoint,
217
+ type TSavedPointGroup,
167
218
  } from "../../utils/robot-display";
168
219
 
169
220
  const props = withDefaults(
@@ -188,8 +239,19 @@ const emit = defineEmits<{
188
239
  status: [status: { connected: boolean }];
189
240
  }>();
190
241
 
191
- const { getRobot, getRobotBrands, getRobotPlans, getPatrolRoutes, sendRobotCommand, trackRobot, deleteRobot, getRobotMaps, getRobotMapImages } =
192
- useRobots();
242
+ const {
243
+ getRobot,
244
+ getRobotBrands,
245
+ getRobotPlans,
246
+ getPatrolRoutes,
247
+ sendRobotCommand,
248
+ trackRobot,
249
+ deleteRobot,
250
+ getRobotMaps,
251
+ getRobotMapImages,
252
+ resolveRobotAlerts,
253
+ getPatrolHistory,
254
+ } = useRobots();
193
255
 
194
256
  const record = ref<Record<string, any> | null>(null);
195
257
  const brands = ref<Array<Record<string, any>>>([]);
@@ -262,7 +324,7 @@ watch(
262
324
  if (p && posLive.value && isMoving(statusMode.value)) trail.value = trailPush(trail.value, p);
263
325
  },
264
326
  );
265
- 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)}`);
266
328
 
267
329
  const spot = ref<Array<{ x: number; y: number }>>([]);
268
330
  const spotOn = computed(() => showSpotClean({ canCommand: props.canCommand, capabilities: caps.value, online: online.value, type: robotType.value }));
@@ -279,14 +341,25 @@ const planHint = computed(() => {
279
341
 
280
342
  /* ---- schedule: cleaning plans from the brand, or the patrol contract ---- */
281
343
  const plans = ref<Array<Record<string, any>>>([]);
282
- 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);
283
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));
284
353
 
285
354
  async function loadSchedule() {
286
355
  if (!record.value || noInterface.value) return;
287
356
  try {
288
- if (robotType.value === "security") patrol.value = await getPatrolRoutes(props.robotId);
289
- 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 ?? [];
290
363
  } catch {
291
364
  // A schedule the brand cannot serve reads "No schedule yet"; the facts still show.
292
365
  }
@@ -336,7 +409,23 @@ async function run(kind: TRobotCommandKind | "spot", body: Record<string, unknow
336
409
  busy.value = null;
337
410
  }
338
411
  }
339
- 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
+ }
340
429
  const body = commandBody(kind, live.value);
341
430
  if (!body) {
342
431
  toast.value = `${commandLabel(kind)}: the robot's position is not known yet.`;
@@ -379,6 +468,38 @@ async function sendSpot() {
379
468
  }
380
469
  }
381
470
 
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;
479
+ }
480
+ run(kind, body);
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);
487
+ const canResolve = computed(
488
+ () => props.canCommand && caps.value?.resolveAlerts === true && faultBadge(fault.value) === "ALARM" && typeof fault.value?.code === "string",
489
+ );
490
+ const resolving = ref(false);
491
+ async function resolveFault() {
492
+ resolving.value = true;
493
+ try {
494
+ const res = await resolveRobotAlerts(props.robotId, String(fault.value?.code ?? ""));
495
+ toast.value = res?.resolved ? `Marked resolved · ${res.resolved} alert${res.resolved === 1 ? "" : "s"}` : "Nothing open to resolve.";
496
+ } catch (e) {
497
+ toast.value = robotErrorMessage(e, "Could not mark the alert resolved.");
498
+ } finally {
499
+ resolving.value = false;
500
+ }
501
+ }
502
+
382
503
  /* ---- edit / remove ---- */
383
504
  function onSaved() {
384
505
  editOpen.value = false;
@@ -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>
@@ -155,8 +155,8 @@ const found = ref<Array<{ value: string; label: string; disabled: boolean }>>([]
155
155
  const finding = ref(false);
156
156
  const labels = computed(() => credentialLabels(brand.value));
157
157
  const typed = computed(() => !!appKey.value.trim() && !!appToken.value.trim());
158
- // Ounitech: one AppID/Secret per company, so the robot is picked from its list.
159
- const discoverable = computed(() => brand.value === "ounitech");
158
+ // Ounitech / PadBot: one credential pair per company account, so the robot is picked from its list.
159
+ const discoverable = computed(() => brand.value === "ounitech" || brand.value === "padbot");
160
160
  const findHint = computed(() =>
161
161
  editing.value || typed.value
162
162
  ? "Press Find robots"
@@ -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>