@7365admin1/layer-common 4.55.0 → 4.57.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.57.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 611dd38: Robots: the live view shows the PadBot (EZVIZ) camera's H.265 picture. HLS now plays through hls.js wherever the browser has Media Source Extensions (native HLS only as the Safari fallback), the player owns `video.src` so a re-render can no longer reset the stream (the cause of the fatal bufferAppendError / black picture), and it recovers by itself: media-error recovery, then a fresh URL with backoff on network errors, expired URLs and stalls. When no picture can be shown it says so in plain words with a Retry button. The robot detail gains an inline Live view panel that plays automatically, with an "Always show live view" switch remembered per browser; it releases the stream while the tab is hidden, the panel is far off-screen or the robot is offline, and Full screen reuses the same stream.
8
+
9
+ ## 4.56.0
10
+
11
+ ### Minor Changes
12
+
13
+ - 2ef1a8d: Robot detail: "What this robot can do" card built from the robot's brand + model manifest; information grouped Status / Location / Patrol|Cleaning / Camera; unsupported facts hidden (never greyed); commands confirm what will happen and report the result; "Return to charger" everywhere; maker-app schedule text from the manifest. Site fleet Brand column names the model; the capability badge's tooltip lists what that robot does.
14
+
3
15
  ## 4.55.0
4
16
 
5
17
  ### Minor Changes
@@ -1581,11 +1593,11 @@
1581
1593
  `utils/console-tier.ts` + `composables/useConsoleTier.ts` mirror the server's
1582
1594
  own rule from two endpoints the console already calls, unprojected:
1583
1595
 
1584
- GET /api/members/user/:user/app/admin the Seven365 staff membership
1585
- GET /api/roles/id/:role that membership's role document
1596
+ GET /api/members/user/:user/app/admin the Seven365 staff membership
1597
+ GET /api/roles/id/:role that membership's role document
1586
1598
 
1587
- owner = member.type === "admin" && role.type === "admin" && role.default === true
1588
- staff = member.type === "admin" && role.type === "admin"
1599
+ owner = member.type === "admin" && role.type === "admin" && role.default === true
1600
+ staff = member.type === "admin" && role.type === "admin"
1589
1601
 
1590
1602
  `role.default` is the marker because it is the only property of a platform
1591
1603
  staff role no API caller can set - `role.controller.ts` validates create and
@@ -781,6 +781,14 @@
781
781
  .hero-fact__value { font-weight: 600; font-variant-numeric: tabular-nums; margin-top: 3px; }
782
782
  .hero-fact__value--dim { opacity: 0.5; }
783
783
  .hero-notreported { font-size: 12px; opacity: 0.7; }
784
+ .hero-facts--tight { margin-bottom: 12px; }
785
+
786
+ /* "What this robot can do": one short row per kind of ability, only what THIS brand + model offers. */
787
+ .hero-can { border: 1px solid var(--hero-divider); border-left: 3px solid var(--hero-accent); padding: 10px 14px; display: grid; gap: 6px; }
788
+ .hero-can__title { font-size: 10px; letter-spacing: 0.12em; text-transform: uppercase; font-weight: 600; opacity: 0.75; }
789
+ .hero-can__row { display: grid; grid-template-columns: 160px 1fr; gap: 12px; font-size: 13px; line-height: 1.45; }
790
+ .hero-can__label { font-weight: 700; }
791
+ @media (max-width: 560px) { .hero-can__row { grid-template-columns: 1fr; gap: 0; } }
784
792
 
785
793
  .hero-section__title {
786
794
  font-size: 10px;
@@ -1265,3 +1273,7 @@ div.hero-editor__wp.hero-editor__sel, div.hero-editor__fid.hero-editor__sel { ou
1265
1273
  .hero-drive__head { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; }
1266
1274
  .hero-pick__group { display: flex; flex-wrap: wrap; gap: 6px; align-items: center; margin-bottom: 6px; }
1267
1275
  .hero-pick__point:disabled { opacity: 0.45; cursor: not-allowed; }
1276
+ .hero-livepanel { display: flex; flex-direction: column; gap: 8px; padding: 12px; background: var(--hero-surface); border: 1px solid color-mix(in srgb, var(--hero-text) 12%, transparent); }
1277
+ .hero-livepanel .hero-live__head { flex-wrap: wrap; }
1278
+ .hero-livepanel .hero-dialog__actions { margin-top: 0; }
1279
+ .hero-live__stage .hero-notice { color: var(--hero-text); font-size: 13px; max-width: 90%; }
@@ -31,27 +31,24 @@
31
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
32
  </div>
33
33
  </div>
34
- <v-btn v-if="caps?.liveVideo === true && online" variant="outlined" size="small" @click="liveOpen = true">Live view</v-btn>
34
+ <v-btn v-if="caps?.liveVideo === true && online" variant="outlined" size="small" @click="livePanel?.show()">Live view</v-btn>
35
35
  <v-btn v-if="canResolve" variant="outlined" size="small" :loading="resolving" @click="resolveFault">Mark resolved</v-btn>
36
36
  <span class="hero-faultbar__badge">{{ faultBadge(fault) }}</span>
37
37
  </div>
38
38
 
39
39
  <RobotNoMonitoring v-if="noInterface" :brand-name="brand" :model="live?.model || undefined" />
40
40
 
41
- <div class="hero-facts" role="list" aria-label="Robot facts">
42
- <div v-for="f in facts" :key="f.label" class="hero-fact" role="listitem">
43
- <div class="hero-fact__label">{{ f.label }}</div>
44
- <div class="hero-fact__value" :class="{ 'hero-fact__value--dim': f.dim }">{{ f.value }}</div>
41
+ <!-- What THIS robot (brand + model) can do, in our words; nothing it can't. -->
42
+ <section v-if="abilities.length" class="hero-can" aria-label="What this robot can do">
43
+ <div class="hero-can__title">What this robot can do</div>
44
+ <div v-for="r in abilities" :key="r.label" class="hero-can__row">
45
+ <span class="hero-can__label">{{ r.label }}</span>
46
+ <span>{{ r.value }}</span>
45
47
  </div>
46
- </div>
47
- <div v-if="notReported" class="hero-notreported">{{ notReported }}</div>
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>
51
- </div>
48
+ </section>
52
49
 
53
50
  <div v-if="commands.length">
54
- <div class="hero-section__title">Command bar</div>
51
+ <div class="hero-section__title">Commands</div>
55
52
  <RobotCommandBar
56
53
  :capabilities="caps"
57
54
  :can-command="canCommand"
@@ -64,8 +61,70 @@
64
61
  </div>
65
62
  <RobotViewOnlyNotice v-if="!canCommand && !noInterface" />
66
63
 
67
- <div class="hero-detail__grid">
64
+ <div>
65
+ <div class="hero-section__title">Status</div>
66
+ <div class="hero-facts" role="list" aria-label="Robot status">
67
+ <div v-for="f in statusFacts" :key="f.label" class="hero-fact" role="listitem">
68
+ <div class="hero-fact__label">{{ f.label }}</div>
69
+ <div class="hero-fact__value" :class="{ 'hero-fact__value--dim': f.dim }">{{ f.value }}</div>
70
+ </div>
71
+ </div>
72
+ </div>
73
+
74
+ <div v-if="!noInterface" class="hero-detail__grid">
75
+ <div>
76
+ <div class="hero-section__title">Location</div>
77
+ <div v-if="locationFacts.length" class="hero-facts hero-facts--tight" role="list" aria-label="Robot location">
78
+ <div v-for="f in locationFacts" :key="f.label" class="hero-fact" role="listitem">
79
+ <div class="hero-fact__label">{{ f.label }}</div>
80
+ <div class="hero-fact__value" :class="{ 'hero-fact__value--dim': f.dim }">{{ f.value }}</div>
81
+ </div>
82
+ </div>
83
+ <template v-if="showPlan">
84
+ <div class="hero-aside__head">
85
+ <span class="hero-aside__title">{{ planTitle }}</span>
86
+ <span class="hero-aside__hint" style="flex-shrink: 0">{{ planHint }}</span>
87
+ </div>
88
+ <div v-if="mapViews.length > 1 || (mapViews.length === 1 && !mapViews[0]!.current)" class="hero-mapsel">
89
+ <select v-model="selectedMapId" class="hero-input hero-select hero-select--sm" aria-label="Robot map">
90
+ <option v-for="m in mapViews" :key="m.mapId" :value="m.mapId">{{ m.label }}{{ m.current ? " · Robot is here" : "" }}</option>
91
+ </select>
92
+ <span v-if="selectedMap?.current" class="hero-mapsel__here">Robot is here</span>
93
+ <span v-else-if="selectedMap" class="hero-mapsel__hint">{{ hereText }}</span>
94
+ <span v-if="selectedMap?.routeNames.length" class="hero-mapsel__hint">Routes: {{ selectedMap.routeNames.join(", ") }}</span>
95
+ </div>
96
+ <RobotLivePlan
97
+ :name="record?.name"
98
+ :status-label="statusLabel"
99
+ :point="posLive && robotOnShownMap ? point : null"
100
+ :stale="shown?.stale === true"
101
+ :heading="headingDeg(shown)"
102
+ :trail="trail"
103
+ :readout="readout"
104
+ :lost="posLost"
105
+ :route="route"
106
+ :routes="selectedMap ? mapRoutes : null"
107
+ :image="selectedMap?.imageUrl ?? null"
108
+ :aspect="selectedMap?.width && selectedMap?.height ? selectedMap.width / selectedMap.height : null"
109
+ :points="selectedMap?.points ?? []"
110
+ :route-moving="statusMode === 'WORKING' && robotOnShownMap"
111
+ :spot="spot"
112
+ :pick="spotOn"
113
+ :tick-ms="tickMs"
114
+ @pick="(p) => spot.push(p)"
115
+ />
116
+ <div v-if="spotOn" class="hero-spotbar">
117
+ <v-btn color="primary" variant="flat" :disabled="!spot.length || busy === 'spot'" :loading="busy === 'spot'" @click="sendSpot">
118
+ Send spot clean · {{ spot.length }} points
119
+ </v-btn>
120
+ <v-btn variant="outlined" @click="spot = []">Clear</v-btn>
121
+ </div>
122
+ </template>
123
+ <div v-else-if="caps" class="hero-sched__empty">{{ brand }} doesn't share where this robot is with us yet.</div>
124
+ </div>
125
+
68
126
  <div>
127
+ <div class="hero-section__title">{{ robotType === "security" ? "Patrol" : "Cleaning" }}</div>
69
128
  <div class="hero-aside__head">
70
129
  <span class="hero-aside__title">{{ robotType === "security" ? "Patrol schedule" : "Cleaning schedule" }}</span>
71
130
  <span class="hero-aside__hint">{{
@@ -73,14 +132,9 @@
73
132
  }}</span>
74
133
  </div>
75
134
  <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>
135
+ <div class="hero-sched__empty">{{ scheduleElsewhere }}</div>
136
+ <!-- The runs themselves are in Patrol history below (said once). -->
77
137
  <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
138
  </template>
85
139
  <div v-else-if="!schedule.length" class="hero-sched__empty">No schedule yet.</div>
86
140
  <div v-for="p in schedule" :key="p.key" class="hero-sched__row">
@@ -93,50 +147,28 @@
93
147
  </div>
94
148
  </template>
95
149
  </div>
150
+ </div>
96
151
 
97
- <div>
98
- <div class="hero-aside__head">
99
- <span class="hero-aside__title">{{ planTitle }}</span>
100
- <span class="hero-aside__hint" style="flex-shrink: 0">{{ planHint }}</span>
101
- </div>
102
- <div v-if="mapViews.length > 1 || (mapViews.length === 1 && !mapViews[0]!.current)" class="hero-mapsel">
103
- <select v-model="selectedMapId" class="hero-input hero-select hero-select--sm" aria-label="Robot map">
104
- <option v-for="m in mapViews" :key="m.mapId" :value="m.mapId">{{ m.label }}{{ m.current ? " · Robot is here" : "" }}</option>
105
- </select>
106
- <span v-if="selectedMap?.current" class="hero-mapsel__here">Robot is here</span>
107
- <span v-else-if="selectedMap" class="hero-mapsel__hint">{{ hereText }}</span>
108
- <span v-if="selectedMap?.routeNames.length" class="hero-mapsel__hint">Routes: {{ selectedMap.routeNames.join(", ") }}</span>
109
- </div>
110
- <RobotLivePlan
111
- :name="record?.name"
112
- :status-label="statusLabel"
113
- :point="posLive && robotOnShownMap ? point : null"
114
- :stale="shown?.stale === true"
115
- :heading="headingDeg(shown)"
116
- :trail="trail"
117
- :readout="readout"
118
- :lost="posLost"
119
- :route="route"
120
- :routes="selectedMap ? mapRoutes : null"
121
- :image="selectedMap?.imageUrl ?? null"
122
- :aspect="selectedMap?.width && selectedMap?.height ? selectedMap.width / selectedMap.height : null"
123
- :points="selectedMap?.points ?? []"
124
- :route-moving="statusMode === 'WORKING' && robotOnShownMap"
125
- :spot="spot"
126
- :pick="spotOn"
127
- :tick-ms="tickMs"
128
- @pick="(p) => spot.push(p)"
129
- />
130
- <div v-if="spotOn" class="hero-spotbar">
131
- <v-btn color="primary" variant="flat" :disabled="!spot.length || busy === 'spot'" :loading="busy === 'spot'" @click="sendSpot">
132
- Send spot clean · {{ spot.length }} points
133
- </v-btn>
134
- <v-btn variant="outlined" @click="spot = []">Clear</v-btn>
135
- </div>
152
+ <RobotPatrolHistory v-if="robotType === 'security' && caps?.patrolHistory === true" :robot-id="robotId" />
153
+
154
+ <div v-if="caps?.liveVideo === true || canDrive">
155
+ <div class="hero-section__title">Camera</div>
156
+ <RobotLiveView v-if="caps?.liveVideo === true" ref="livePanel" panel :robot-id="robotId" :name="record?.name" :offline="!online" />
157
+ <div v-if="canDrive" class="hero-detail__video">
158
+ <v-btn variant="outlined" @click="driveOpen = true">Manual control</v-btn>
136
159
  </div>
137
160
  </div>
138
161
 
139
- <RobotPatrolHistory v-if="robotType === 'security' && caps?.patrolHistory === true" :robot-id="robotId" />
162
+ <v-dialog v-model="confirmOpen" width="440">
163
+ <div v-if="confirming" class="hero-dialog" role="alertdialog" aria-labelledby="hero-confirm-title">
164
+ <div id="hero-confirm-title" class="hero-dialog__title">{{ confirming.text.title }}</div>
165
+ <div class="hero-dialog__body">{{ confirming.text.body }}</div>
166
+ <div class="hero-dialog__actions">
167
+ <v-btn variant="outlined" @click="confirmOpen = false">Don't send</v-btn>
168
+ <v-btn color="primary" variant="flat" @click="confirmSend">{{ confirming.text.action }}</v-btn>
169
+ </div>
170
+ </div>
171
+ </v-dialog>
140
172
 
141
173
  <v-dialog v-model="editOpen" width="520" persistent>
142
174
  <RobotForm
@@ -165,9 +197,6 @@
165
197
  </div>
166
198
  </v-dialog>
167
199
 
168
- <v-dialog v-model="liveOpen" width="860">
169
- <RobotLiveView v-if="liveOpen" :robot-id="robotId" :name="record?.name" @close="liveOpen = false" />
170
- </v-dialog>
171
200
  <v-dialog v-model="driveOpen" width="520" persistent>
172
201
  <RobotManualDrive v-if="driveOpen" :robot-id="robotId" :name="record?.name" @close="driveOpen = false" />
173
202
  </v-dialog>
@@ -208,14 +237,13 @@ import {
208
237
  capabilityLabel,
209
238
  cleaningScheduleRows,
210
239
  commandBody,
240
+ commandConfirm,
211
241
  commandLabel,
212
242
  faultBadge,
213
243
  headingDeg,
214
244
  isMoving,
215
245
  mergePose,
216
- notReportedLine,
217
246
  optimisticMode,
218
- patrolRecordRow,
219
247
  patrolRunHint,
220
248
  patrolScheduleRows,
221
249
  pointCommandBody,
@@ -223,9 +251,11 @@ import {
223
251
  unplacedStopsText,
224
252
  planPoint,
225
253
  positionText,
254
+ robotAbilities,
226
255
  robotErrorMessage,
227
256
  robotFacts,
228
257
  robotStatus,
258
+ scheduleElsewhereText,
229
259
  showSpotClean,
230
260
  sinceText,
231
261
  speedText,
@@ -327,8 +357,16 @@ const statusLabel = computed(() => robotStatus(statusMode.value, robotType.value
327
357
  const fault = computed(() => (live.value?.ok ? live.value.fault ?? null : record.value?.fault ?? null));
328
358
  const faultSince = computed(() => sinceText(fault.value?.since, now.value));
329
359
 
330
- const facts = computed(() => robotFacts({ record: record.value, live: live.value as any, now: now.value }));
331
- const notReported = computed(() => (caps.value ? notReportedLine(brand.value, caps.value, robotType.value) : null));
360
+ // Only what THIS robot reports (hide, never grey); what its maker keeps back is said once, in the card.
361
+ const facts = computed(() => robotFacts({ record: record.value, live: live.value as any, now: now.value, hideUnreported: true }));
362
+ const LOCATION = new Set(["Floor", "Live position"]);
363
+ const statusFacts = computed(() => facts.value.filter((f) => !LOCATION.has(f.label)));
364
+ // The plan's readout already shows the live position; the cell only when there is no plan.
365
+ const locationFacts = computed(() => facts.value.filter((f) => LOCATION.has(f.label) && !(f.label === "Live position" && showPlan.value)));
366
+ const abilities = computed(() =>
367
+ robotAbilities({ capabilities: caps.value, profile: live.value?.ok ? live.value.profile : null, type: robotType.value, brand: brand.value }),
368
+ );
369
+ const scheduleElsewhere = computed(() => scheduleElsewhereText(robotType.value, brand.value, live.value?.ok ? live.value.profile : null));
332
370
 
333
371
  const online = computed(() => live.value?.ok === true && live.value.online === true);
334
372
  const commands = computed(() =>
@@ -352,8 +390,15 @@ const readout = computed(() => `${positionText(live.value, caps.value, now.value
352
390
  const spot = ref<Array<{ x: number; y: number }>>([]);
353
391
  const spotOn = computed(() => showSpotClean({ canCommand: props.canCommand, capabilities: caps.value, online: online.value, type: robotType.value }));
354
392
 
355
- const route = computed<TPatrolRouteView | null>(() => (selectedMap.value ? mapRoutes.value[0] ?? null : patrol.value?.routes?.[0] ?? null));
356
- const floorText = computed(() => facts.value[2]?.value ?? "");
393
+ // The route drawn on the plan: the shown map's first route, else the first with placed checkpoints (the rest are listed by name).
394
+ const route = computed<TPatrolRouteView | null>(() =>
395
+ selectedMap.value ? mapRoutes.value[0] ?? null : patrol.value?.routes?.find((r) => r.checkpoints?.length) ?? null,
396
+ );
397
+ const floorText = computed(() => locationFacts.value.find((f) => f.label === "Floor")?.value ?? "");
398
+ // The plan only for a robot that reports where it is, has maps to show, takes spot clicks, or walks a route we can draw.
399
+ const showPlan = computed(
400
+ () => !caps.value || reportsPosition.value || spotOn.value || mapViews.value.length > 0 || !!route.value?.checkpoints?.length,
401
+ );
357
402
  const planTitle = computed(() => {
358
403
  if (spotOn.value) return "Spot clean";
359
404
  if (selectedMap.value && !selectedMap.value.current) return `Map · ${selectedMap.value.label}`;
@@ -364,7 +409,7 @@ const planHint = computed(() => {
364
409
  if (noInterface.value) return "No position feed for this model";
365
410
  if (selectedMap.value) return `${selectedMap.value.points.length} saved points · ${mapRoutes.value.length} routes`;
366
411
  if (route.value) return `${route.value.checkpoints.length} checkpoints · ${floorText.value}`;
367
- return floorText.value;
412
+ return ""; // the Floor cell above already says it
368
413
  });
369
414
 
370
415
  /* ---- schedule: cleaning plans from the brand, or the patrol contract ---- */
@@ -376,7 +421,6 @@ const unplacedRoutes = computed(() =>
376
421
  );
377
422
  // The brand keeps its schedules to itself: recent runs (and a labelled guess) instead.
378
423
  const runs = ref<any[]>([]);
379
- const recentRuns = computed(() => runs.value.slice(0, 5).map((r) => patrolRecordRow(r, now.value)));
380
424
  const runHint = computed(() => patrolRunHint(runs.value, now.value));
381
425
 
382
426
  async function loadSchedule() {
@@ -454,11 +498,13 @@ async function run(kind: TRobotCommandKind | "spot", body: Record<string, unknow
454
498
  const before = statusMode.value;
455
499
  busy.value = kind;
456
500
  optimistic.value = { mode: optimisticMode(kind, before), after: Number.POSITIVE_INFINITY };
457
- toast.value = `${commandLabel(kind)} → ${name}`;
501
+ const label = kind === "full" && robotType.value === "security" ? "Start patrol" : commandLabel(kind);
502
+ toast.value = `${label} → ${name} · sending…`;
458
503
  try {
459
504
  await sendRobotCommand(props.robotId, body as any);
460
505
  // A poll already in flight may still carry the old status; wait past it.
461
506
  optimistic.value = optimistic.value ? { ...optimistic.value, after: Date.now() + 300 } : null;
507
+ toast.value = `${label} → ${name} · accepted. Its status updates here.`;
462
508
  return true;
463
509
  } catch (e) {
464
510
  optimistic.value = null;
@@ -491,7 +537,16 @@ async function send(kind: TRobotCommandKind) {
491
537
  toast.value = `${commandLabel(kind)}: the robot's position is not known yet.`;
492
538
  return;
493
539
  }
494
- run(kind, body);
540
+ // Say what will happen before it is sent (the point picker is its own confirm).
541
+ confirming.value = { kind, body, text: commandConfirm(kind, { type: robotType.value, name: record.value?.name }) };
542
+ confirmOpen.value = true;
543
+ }
544
+ const confirmOpen = ref(false);
545
+ const confirming = ref<{ kind: TRobotCommandKind; body: Record<string, unknown>; text: { title: string; body: string; action: string } } | null>(null);
546
+ function confirmSend() {
547
+ const c = confirming.value;
548
+ confirmOpen.value = false;
549
+ if (c) run(c.kind, c.body);
495
550
  }
496
551
 
497
552
  /** The robot's plan image size for its floor, for turning plan clicks into pixels. */
@@ -541,7 +596,7 @@ function onPick(p: TSavedPoint) {
541
596
  }
542
597
 
543
598
  /* ---- security-robot extras: live view (minted per open, never kept), manual control, resolve an alarm ---- */
544
- const liveOpen = ref(false);
599
+ const livePanel = ref<{ show(): void } | null>(null);
545
600
  const driveOpen = ref(false);
546
601
  const canDrive = computed(() => props.canCommand && caps.value?.manualDrive === true && online.value);
547
602
  const canResolve = computed(
@@ -147,7 +147,7 @@
147
147
  :loading="busy === item._id && lastCommand === 'return'"
148
148
  @click="send(item, { kind: 'return' }, 'return')"
149
149
  >
150
- Return to dock
150
+ Return to charger
151
151
  </AppButton>
152
152
  <AppButton
153
153
  v-if="canCommand"
@@ -1,24 +1,37 @@
1
1
  <template>
2
2
  <!--
3
- The robot's current camera stream, in the page (security robots, liveVideo).
3
+ The robot's current camera stream (security robots, liveVideo).
4
+ Two shapes, one player: the pop-up dialog (`panel` unset) and the inline
5
+ panel on the robot detail (`panel`), which plays by itself, remembers
6
+ "Always show live view" per browser, and releases the stream while the tab
7
+ is hidden, the panel is far off-screen, or the robot is offline.
4
8
  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.
9
+ never stored. Full screen reuses the same stream - never a second fetch.
6
10
  -->
7
- <div class="hero-dialog hero-live" role="dialog" aria-labelledby="hero-live-title">
11
+ <div
12
+ ref="root"
13
+ :class="panel ? 'hero-livepanel' : 'hero-dialog hero-live'"
14
+ :role="panel ? 'region' : 'dialog'"
15
+ :aria-labelledby="titleId"
16
+ >
8
17
  <div class="hero-live__head">
9
- <div id="hero-live-title" class="hero-dialog__title">Live view · {{ name }}</div>
18
+ <div :id="titleId" class="hero-dialog__title">{{ panel ? "Live view" : `Live view · ${name ?? ""}` }}</div>
10
19
  <span class="hero-head__spacer" />
11
- <div class="hero-live__quality" role="group" aria-label="Quality">
20
+ <v-switch v-if="panel" v-model="on" label="Always show live view" density="compact" color="primary" hide-details inset />
21
+ <div v-if="on" class="hero-live__quality" role="group" aria-label="Quality">
12
22
  <button type="button" :aria-pressed="quality === 'smooth'" @click="quality = 'smooth'">Smooth</button>
13
23
  <button type="button" :aria-pressed="quality === 'hd'" @click="quality = 'hd'">HD</button>
14
24
  </div>
15
25
  </div>
16
26
 
17
- <div class="hero-live__stage">
18
- <div v-if="loading && !url" class="hero-muted">Connecting to the robot's camera…</div>
27
+ <div v-if="on" class="hero-live__stage">
28
+ <div v-if="offline" class="hero-muted">Robot offline — live view resumes when it reconnects</div>
19
29
  <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" />
30
+ <div v-else-if="retrying" class="hero-muted" role="status">Reconnecting to the robot's camera…</div>
31
+ <div v-else-if="!active" class="hero-muted">Live view paused while it is off screen.</div>
32
+ <div v-else-if="!url" class="hero-muted" role="status">Connecting to the robot's camera…</div>
33
+ <template v-else>
34
+ <video v-if="plays" ref="videoEl" class="hero-live__video" controls autoplay muted playsinline />
22
35
  <iframe
23
36
  v-else-if="kind === 'page'"
24
37
  :key="url"
@@ -40,56 +53,139 @@
40
53
  </template>
41
54
  </div>
42
55
 
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>
56
+ <div v-if="on || !panel" class="hero-dialog__actions">
57
+ <v-btn v-if="error && !offline" variant="outlined" size="small" @click="restart">Retry</v-btn>
58
+ <v-btn v-if="url && plays && !error" variant="outlined" size="small" @click="fullScreen">Full screen</v-btn>
59
+ <a v-if="url && !plays && kind !== 'rtmp'" class="hero-live__open" :href="url" target="_blank" rel="noopener noreferrer">Open in new tab</a>
45
60
  <span class="hero-head__spacer" />
46
- <v-btn variant="outlined" @click="emit('close')">Close</v-btn>
61
+ <v-btn v-if="!panel" variant="outlined" @click="emit('close')">Close</v-btn>
47
62
  </div>
48
63
  </div>
49
64
  </template>
50
65
 
51
66
  <script setup lang="ts">
52
- import { computed, onBeforeUnmount, ref, watch } from "vue";
53
- import { liveVideoKind, remintDelayMs } from "../../utils/robot-control";
67
+ import { computed, onBeforeUnmount, onMounted, ref, watch } from "vue";
68
+ import {
69
+ LIVE_DOWN,
70
+ LIVE_NO_PICTURE,
71
+ LIVE_RETRY_LIMIT,
72
+ liveRetryDelayMs,
73
+ liveVideoKind,
74
+ liveViewActive,
75
+ readLivePref,
76
+ remintDelayMs,
77
+ startLivePlayer,
78
+ writeLivePref,
79
+ } from "../../utils/robot-control";
54
80
 
55
- const props = defineProps<{ robotId: string; name?: string }>();
81
+ const props = defineProps<{ robotId: string; name?: string; panel?: boolean; offline?: boolean }>();
56
82
  const emit = defineEmits<{ close: [] }>();
57
83
  const { getLiveVideo } = useRobots();
58
84
 
59
- const CAMERA_DOWN = "The robot's camera isn't available right now.";
85
+ const titleId = `hero-live-title-${Math.random().toString(36).slice(2, 8)}`;
86
+ // "Always show live view": one choice per browser, like a dashboard layout. ponytail: localStorage, no table.
87
+ const storage = () => (typeof localStorage === "undefined" ? undefined : localStorage);
88
+ const on = ref(props.panel ? readLivePref(storage()) : true);
89
+ watch(on, (v) => writeLivePref(storage(), v));
90
+
60
91
  const quality = ref<"hd" | "smooth">("smooth");
61
92
  const url = ref("");
62
93
  const error = ref("");
63
- const loading = ref(false);
64
- let timer: ReturnType<typeof setTimeout> | undefined;
65
- let alive = true;
66
-
94
+ const retrying = ref(false);
95
+ const pageVisible = ref(typeof document === "undefined" || document.visibilityState !== "hidden");
96
+ const inView = ref(true);
97
+ const root = ref<HTMLElement | null>(null);
98
+ const videoEl = ref<HTMLVideoElement | null>(null);
99
+ const active = computed(() => liveViewActive({ on: on.value, offline: props.offline, visible: pageVisible.value, inView: inView.value }));
67
100
  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
- );
101
+ // HLS always goes to the player: hls.js where the browser has MSE, native HLS otherwise.
102
+ const plays = computed(() => kind.value === "video" || kind.value === "hls");
73
103
 
104
+ let timer: ReturnType<typeof setTimeout> | undefined;
105
+ let gen = 0; // bumps on every mint/release so a late answer for an old quality/robot is dropped
106
+ let attempts = 0;
107
+ let stopPlayer: (() => void) | undefined;
108
+
109
+ function stopStream() {
110
+ stopPlayer?.();
111
+ stopPlayer = undefined;
112
+ }
113
+ /** Free everything: player, connection, timers, pending answers. */
114
+ function release() {
115
+ gen++;
116
+ clearTimeout(timer);
117
+ stopStream();
118
+ url.value = "";
119
+ retrying.value = false;
120
+ }
74
121
  async function mint() {
122
+ const my = ++gen;
75
123
  clearTimeout(timer);
76
- loading.value = true;
77
124
  error.value = "";
78
125
  try {
79
126
  const res = await getLiveVideo(props.robotId, quality.value);
80
- if (!alive) return;
81
- if (liveVideoKind(res?.url) === "bad") throw new Error(CAMERA_DOWN);
127
+ if (my !== gen) return;
128
+ if (liveVideoKind(res?.url) === "bad") throw new Error(LIVE_DOWN);
82
129
  url.value = res.url;
83
130
  const wait = remintDelayMs(res?.expiresAt);
84
131
  if (wait !== null) timer = setTimeout(mint, wait);
85
132
  } catch {
86
- if (alive) {
87
- url.value = "";
88
- error.value = CAMERA_DOWN;
89
- }
90
- } finally {
91
- loading.value = false;
133
+ if (my === gen) reconnectLater();
134
+ }
135
+ }
136
+ /** Automatic reconnect with backoff; after LIVE_RETRY_LIMIT tries, stop and offer Retry. */
137
+ function reconnectLater() {
138
+ const my = ++gen;
139
+ clearTimeout(timer);
140
+ stopStream();
141
+ url.value = "";
142
+ if (attempts >= LIVE_RETRY_LIMIT) {
143
+ retrying.value = false;
144
+ error.value = LIVE_DOWN;
145
+ return;
146
+ }
147
+ retrying.value = true;
148
+ timer = setTimeout(() => {
149
+ if (my !== gen) return;
150
+ retrying.value = false;
151
+ mint();
152
+ }, liveRetryDelayMs(attempts++));
153
+ }
154
+ /** Clean slate: destroy the player and, when showing, fetch a fresh URL. Also the Retry button. */
155
+ function restart() {
156
+ release();
157
+ attempts = 0;
158
+ error.value = "";
159
+ if (active.value) mint();
160
+ }
161
+
162
+ // The player starts only when the <video> exists and a URL is in hand, and is
163
+ // re-created only when either changes - never on an ordinary re-render.
164
+ watch([videoEl, url], ([el, src]) => {
165
+ stopStream();
166
+ if (!el || !src) return;
167
+ stopPlayer = startLivePlayer(el, src, onPlayerFail, { onPicture: () => (attempts = 0) });
168
+ });
169
+ function onPlayerFail(message: string) {
170
+ stopPlayer = undefined;
171
+ if (message === LIVE_NO_PICTURE) {
172
+ // The browser can't show H.265: no silent retries behind the message; Retry is the user's.
173
+ gen++;
174
+ clearTimeout(timer);
175
+ error.value = message;
176
+ return;
92
177
  }
178
+ reconnectLater(); // network, expired URL, stall: fetch a fresh URL and go again
179
+ }
180
+
181
+ watch(active, restart, { immediate: true });
182
+ watch([quality, () => props.robotId], restart);
183
+
184
+ function fullScreen() {
185
+ const v = videoEl.value as (HTMLVideoElement & { webkitEnterFullscreen?: () => void }) | null;
186
+ if (!v) return;
187
+ if (v.requestFullscreen) v.requestFullscreen().catch(() => {});
188
+ else v.webkitEnterFullscreen?.();
93
189
  }
94
190
  async function copy() {
95
191
  try {
@@ -98,10 +194,27 @@ async function copy() {
98
194
  /* the link is still shown by the browser's own copy */
99
195
  }
100
196
  }
101
- watch(quality, mint, { immediate: true });
197
+
198
+ const onVisibility = () => (pageVisible.value = document.visibilityState !== "hidden");
199
+ let observer: IntersectionObserver | undefined;
200
+ onMounted(() => {
201
+ document.addEventListener("visibilitychange", onVisibility);
202
+ if (props.panel && root.value && typeof IntersectionObserver !== "undefined") {
203
+ // "Far" off-screen = more than a screen's height away; scrolling briefly past keeps it playing.
204
+ observer = new IntersectionObserver(([e]) => (inView.value = !!e?.isIntersecting), { rootMargin: "100% 0px" });
205
+ observer.observe(root.value);
206
+ }
207
+ });
208
+ /** For the detail page's "Live view" buttons: switch the panel on and bring it into view. */
209
+ defineExpose({
210
+ show() {
211
+ on.value = true;
212
+ root.value?.scrollIntoView({ behavior: "smooth", block: "center" });
213
+ },
214
+ });
102
215
  onBeforeUnmount(() => {
103
- alive = false;
104
- clearTimeout(timer);
105
- url.value = "";
216
+ document.removeEventListener("visibilitychange", onVisibility);
217
+ observer?.disconnect();
218
+ release();
106
219
  });
107
220
  </script>
@@ -37,9 +37,10 @@
37
37
  @click:row="onRow"
38
38
  >
39
39
  <template #item.name="{ item }"><span class="hero-table__name">{{ item.name }}</span></template>
40
- <template #item.brand="{ item }"><span class="hero-dim">{{ brandName(item.brand, brands) }}</span></template>
40
+ <template #item.brand="{ item }"><span class="hero-dim">{{ brandModelText(item.brand, brands, live(item)) }}</span></template>
41
41
  <template #item.capability="{ item }">
42
- <span v-if="capsOf(item)" class="hero-tag hero-tag--neutral hero-tag--xs">{{ capabilityLabel(capsOf(item)) }}</span>
42
+ <!-- One badge per row; the model's abilities in its tooltip (a mixed fleet without clutter). -->
43
+ <span v-if="capsOf(item)" class="hero-tag hero-tag--neutral hero-tag--xs" :title="abilityTip(item)">{{ capabilityLabel(capsOf(item)) }}</span>
43
44
  </template>
44
45
  <template #item.status="{ item }">
45
46
  <RobotStatusChip v-if="statusOf(item).kind === 'live'" :mode="statusOf(item).mode" :type="item.type" />
@@ -92,8 +93,10 @@ import RobotMiniMap from "./MiniMap.vue";
92
93
  import useRobotLive, { siteStatesFeed } from "../../composables/useRobotLive";
93
94
  import {
94
95
  agoText,
96
+ brandModelText,
95
97
  brandName,
96
98
  capabilityLabel,
99
+ robotAbilities,
97
100
  listMeta,
98
101
  robotErrorMessage,
99
102
  rowStatus,
@@ -227,6 +230,14 @@ function capsOf(row: any) {
227
230
  if (s && s.monitorable === false) return {};
228
231
  return null;
229
232
  }
233
+ /** "Does: … · Reports: …" for the capability badge's tooltip. */
234
+ function abilityTip(row: any) {
235
+ const s = live(row);
236
+ return robotAbilities({ capabilities: capsOf(row), profile: s?.ok ? s.profile : null, type: row.type, brand: brandName(row.brand, brands.value) })
237
+ .map((r) => `${r.label}: ${r.value}`)
238
+ .join("
239
+ ");
240
+ }
230
241
  /** The live fault when the robot answers, else its stored open fault. */
231
242
  const faultOf = (row: any) => (live(row)?.ok ? live(row)?.fault ?? null : row.fault ?? null);
232
243
  function seenText(row: any) {
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.55.0",
5
+ "version": "4.57.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.",
@@ -57,6 +57,7 @@
57
57
  "@mdi/font": "^7.4.47",
58
58
  "@types/qrcode": "^1.5.6",
59
59
  "ckeditor5": "47.4.0",
60
+ "hls.js": "^1.6.13",
60
61
  "html2pdf.js": "^0.10.2",
61
62
  "html5-qrcode": "^2.3.8",
62
63
  "moment-timezone": "^0.6.0",
@@ -11,9 +11,9 @@ export type TVideoKind = "hls" | "video" | "flv" | "rtmp" | "page" | "bad";
11
11
 
12
12
  /**
13
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.
14
+ * PadBot sends HLS (MPEG-TS, H.265 video + AAC audio). HLS plays through
15
+ * hls.js wherever the browser has Media Source Extensions, native HLS
16
+ * otherwise (Safari). FLV gets "Open in new tab".
17
17
  */
18
18
  export function liveVideoKind(url: unknown): TVideoKind {
19
19
  if (typeof url !== "string") return "bad";
@@ -33,6 +33,163 @@ export function remintDelayMs(expiresAt: unknown, now: number = Date.now()): num
33
33
  return Math.max(5_000, t - now - 30_000);
34
34
  }
35
35
 
36
+ export const LIVE_NO_PICTURE =
37
+ "This robot's camera sends H.265 video, which this browser can't show. Try Microsoft Edge or Safari, or the PadBot app.";
38
+ export const LIVE_DOWN = "The robot's camera isn't available right now.";
39
+
40
+ /** Wait before automatic reconnect attempt n (0-based): 2 s, 4 s, 8 s, 16 s, then 30 s. */
41
+ export const liveRetryDelayMs = (n: number) => Math.min(30_000, 2_000 * 2 ** n);
42
+ /** Automatic reconnects before the dialog stops and offers Retry. */
43
+ export const LIVE_RETRY_LIMIT = 6;
44
+
45
+ type THls = {
46
+ on(event: string, fn: (event: string, data: { fatal?: boolean; type?: string; details?: string }) => void): void;
47
+ loadSource(url: string): void;
48
+ attachMedia(video: HTMLVideoElement): void;
49
+ recoverMediaError(): void;
50
+ swapAudioCodec(): void;
51
+ destroy(): void;
52
+ };
53
+ export type THlsCtor = { isSupported(): boolean; new (config?: Record<string, unknown>): THls };
54
+
55
+ /** The inline panel streams only while it is switched on, the robot is online, the tab is visible and the panel is near the screen. */
56
+ export const liveViewActive = (s: { on: boolean; offline?: boolean; visible: boolean; inView: boolean }) =>
57
+ s.on && !s.offline && s.visible && s.inView;
58
+
59
+ /** "Always show live view", remembered per browser (default on). Storage can throw (private window, blocked site data). */
60
+ const LIVE_PREF_KEY = "hero-robot-live-view";
61
+ export function readLivePref(storage: Pick<Storage, "getItem"> | undefined): boolean {
62
+ try {
63
+ return storage?.getItem(LIVE_PREF_KEY) !== "off";
64
+ } catch {
65
+ return true;
66
+ }
67
+ }
68
+ export function writeLivePref(storage: Pick<Storage, "setItem"> | undefined, on: boolean) {
69
+ try {
70
+ storage?.setItem(LIVE_PREF_KEY, on ? "on" : "off");
71
+ } catch {
72
+ /* the switch still works for this visit */
73
+ }
74
+ }
75
+
76
+ /** hls.js errors that mean "this browser cannot decode the codec", not "the camera is down". */
77
+ const CODEC_ERRORS = new Set(["manifestIncompatibleCodecsError", "bufferAddCodecError", "bufferIncompatibleCodecsError"]);
78
+
79
+ /**
80
+ * Plays one live-view URL into `video`. Returns a stop function that frees the
81
+ * player and the connection.
82
+ *
83
+ * `video.src` is owned here and must never be bound by the template: a
84
+ * re-render that re-sets `src` under hls.js detaches its MediaSource, and
85
+ * hls.js 1.6 then fails with a fatal bufferAppendError ("SourceBuffer has been
86
+ * removed") and a black picture - the 2026-09-26 production bug.
87
+ *
88
+ * `onFail` fires once, with LIVE_NO_PICTURE when the stream plays but no
89
+ * picture ever decodes (Chrome's native HLS takes H.265-in-TS audio but shows
90
+ * no video, videoWidth stays 0) or the codec is refused; LIVE_DOWN for
91
+ * everything else (network, expired URL, repeated media errors, a stall) -
92
+ * the caller fetches a fresh URL and starts again.
93
+ */
94
+ export function startLivePlayer(
95
+ video: HTMLVideoElement,
96
+ url: string,
97
+ onFail: (message: string) => void,
98
+ opts: {
99
+ loadHls?: () => Promise<THlsCtor>;
100
+ hasMse?: boolean;
101
+ onPicture?: () => void;
102
+ noPictureMs?: number;
103
+ stallMs?: number;
104
+ tickMs?: number;
105
+ } = {},
106
+ ): () => void {
107
+ const noPictureMs = opts.noPictureMs ?? 15_000;
108
+ const stallMs = opts.stallMs ?? 20_000;
109
+ const tickMs = opts.tickMs ?? 1_000;
110
+ let stopped = false;
111
+ let hls: THls | undefined;
112
+ let waited = 0;
113
+ let pictured = false;
114
+ let lastTime = -1;
115
+ let still = 0;
116
+
117
+ const fail = (message: string) => {
118
+ if (stopped) return;
119
+ stop();
120
+ onFail(message);
121
+ };
122
+ // ponytail: one 1 s poll instead of juggling media events - videoWidth has no reliable event everywhere.
123
+ const tick = setInterval(() => {
124
+ waited += tickMs;
125
+ if (!pictured) {
126
+ if (video.videoWidth > 0) {
127
+ pictured = true;
128
+ opts.onPicture?.();
129
+ } else if (waited >= noPictureMs) {
130
+ // Time moving with no picture = the codec; nothing moving = no stream.
131
+ return fail(video.currentTime > 0 ? LIVE_NO_PICTURE : LIVE_DOWN);
132
+ }
133
+ return;
134
+ }
135
+ if (video.paused) return (still = 0);
136
+ if (video.currentTime !== lastTime) {
137
+ lastTime = video.currentTime;
138
+ still = 0;
139
+ } else if ((still += tickMs) >= stallMs) fail(LIVE_DOWN);
140
+ }, tickMs);
141
+ const onError = () => fail(LIVE_DOWN);
142
+
143
+ function stop() {
144
+ stopped = true;
145
+ clearInterval(tick);
146
+ video.removeEventListener("error", onError);
147
+ hls?.destroy();
148
+ hls = undefined;
149
+ video.removeAttribute("src");
150
+ video.load();
151
+ }
152
+
153
+ function native() {
154
+ video.addEventListener("error", onError);
155
+ video.src = url;
156
+ video.play().catch(() => {});
157
+ }
158
+
159
+ const mse =
160
+ opts.hasMse ?? (typeof window !== "undefined" && !!((window as any).MediaSource || (window as any).ManagedMediaSource));
161
+ if (liveVideoKind(url) !== "hls" || !mse) {
162
+ native();
163
+ } else {
164
+ (opts.loadHls ?? (() => import("hls.js").then((m) => m.default as unknown as THlsCtor)))()
165
+ .then((Hls) => {
166
+ if (stopped) return;
167
+ if (!Hls.isSupported()) return native();
168
+ let recoveries = 0;
169
+ const h = (hls = new Hls({ enableWorker: true, lowLatencyMode: false }));
170
+ h.on("hlsError", (_e, data) => {
171
+ if (!data.fatal) return;
172
+ if (CODEC_ERRORS.has(data.details ?? "")) return fail(LIVE_NO_PICTURE);
173
+ // The standard hls.js ladder: recover, then swap audio codec and recover, then give up.
174
+ if (data.type === "mediaError" && recoveries < 2) {
175
+ if (recoveries++ === 1) h.swapAudioCodec();
176
+ return h.recoverMediaError();
177
+ }
178
+ fail(LIVE_DOWN);
179
+ });
180
+ h.loadSource(url);
181
+ h.attachMedia(video);
182
+ video.play().catch(() => {});
183
+ })
184
+ .catch(() => {
185
+ if (!stopped) native();
186
+ });
187
+ }
188
+ return () => {
189
+ if (!stopped) stop();
190
+ };
191
+ }
192
+
36
193
  /* ======================================================== manual drive */
37
194
 
38
195
  export type TDriveLimits = {
@@ -395,7 +395,7 @@ export type TRobotCommandKind =
395
395
  const COMMANDS: ReadonlyArray<{ kind: TRobotCommandKind; label: string }> = [
396
396
  { kind: "full", label: "Clean all" },
397
397
  { kind: "area", label: "Clean area" },
398
- { kind: "return", label: "Return to dock" },
398
+ { kind: "return", label: "Return to charger" },
399
399
  { kind: "pause", label: "Pause" },
400
400
  { kind: "resume", label: "Resume" },
401
401
  { kind: "cancel", label: "Cancel" },
@@ -610,11 +610,14 @@ export type TLiveRobotState = {
610
610
  position?: { x: number; y: number; theta: number } | null;
611
611
  /** Where it is on its own floor plan, 0-100 from the top-left; absent when the brand cannot say. */
612
612
  planPosition?: { x: number; y: number } | null;
613
- /** The brand's own status words ("CLEANING_AUTO"), quoted as "Brand status". */
613
+ /** The brand's own status words ("CLEANING_AUTO"), quoted as "Robot reports". */
614
614
  statusName?: string;
615
615
  model?: string;
616
616
  fault?: { code: string; message?: string; since?: number; imageUrl?: string | null; thermalImageUrl?: string | null } | null;
617
+ /** Brand defaults + this MODEL's differences (core `robotManifest`): two models of one brand can differ. */
617
618
  capabilities?: Record<string, unknown>;
619
+ /** Plain-words facts for this brand + model (core `robotManifest`). */
620
+ profile?: TRobotProfile;
618
621
  lastSeenAt?: string | null;
619
622
  /** Epoch ms of the last answer that was read (set by `mergeLiveStates`). */
620
623
  readAt?: number;
@@ -1212,14 +1215,16 @@ export type TRobotFact = { label: string; value: string; dim: boolean };
1212
1215
 
1213
1216
  /**
1214
1217
  * The detail screen's facts grid (README §3): Connection · Battery · Floor ·
1215
- * Live position · Brand status · Model · Serial · Last seen. A fact the brand
1218
+ * Live position · Robot reports · Model · Serial · Last seen. A fact the brand
1216
1219
  * cannot report reads "Not reported" at 50%; a brand with no interface
1217
- * reports nothing live at all.
1220
+ * reports nothing live at all. `hideUnreported` drops those cells instead
1221
+ * (owner 2026-09-26: show only what THIS robot supports - hide, never grey).
1218
1222
  */
1219
1223
  export function robotFacts(input: {
1220
1224
  record?: Record<string, any> | null;
1221
1225
  live?: (TLiveRobotState & { statusName?: string; model?: string }) | null;
1222
1226
  now?: number;
1227
+ hideUnreported?: boolean;
1223
1228
  }): TRobotFact[] {
1224
1229
  const live = input.live ?? null;
1225
1230
  const record = input.record ?? {};
@@ -1239,16 +1244,19 @@ export function robotFacts(input: {
1239
1244
  const position = !reports("readLocation")
1240
1245
  ? nr("Live position")
1241
1246
  : fact("Live position", live ? positionText(live, caps ?? { readLocation: true }) : "");
1242
- return [
1247
+ const all = [
1243
1248
  connection,
1244
1249
  battery,
1245
1250
  floor,
1246
1251
  position,
1247
- none ? nr("Brand status") : fact("Brand status", live?.ok ? live.statusName : ""),
1248
- fact("Model", live?.ok ? live.model : ""),
1252
+ // A bare vendor code ("CLEANING_AUTO") is not words: the status chip already says it in ours.
1253
+ none ? nr("Robot reports") : fact("Robot reports", live?.ok && !/^[A-Z0-9_]+$/.test(live.statusName ?? "") ? live.statusName : ""),
1254
+ fact("Model", live?.ok ? live.profile?.model || live.model : ""),
1249
1255
  fact("Serial", record.robotSn),
1250
1256
  fact("Last seen", agoText(live?.lastSeenAt ?? record.lastSeenAt, input.now)),
1251
1257
  ];
1258
+ // Hidden: a fact the brand cannot report, and a model nobody reported.
1259
+ return input.hideUnreported ? all.filter((f) => !(f.dim && f.value === "Not reported") && !((f.label === "Model" || f.label === "Robot reports") && f.value === "—")) : all;
1252
1260
  }
1253
1261
 
1254
1262
  /**
@@ -1417,6 +1425,129 @@ export function capabilityNote(capabilities: Record<string, unknown> | null | un
1417
1425
  return `${label} — no ${list}`;
1418
1426
  }
1419
1427
 
1428
+ /** Plain-words facts for one brand + model, from core's manifest (`TFleetRobot.profile`). */
1429
+ export type TRobotProfile = {
1430
+ model?: string;
1431
+ modelKnown?: boolean;
1432
+ alarms?: string[];
1433
+ makerApp?: string;
1434
+ makerAppOnly?: string[];
1435
+ };
1436
+
1437
+ export type TRobotAbilityRow = { label: string; value: string };
1438
+
1439
+ const listText = (items: string[]) =>
1440
+ items.length > 1 ? `${items.slice(0, -1).join(", ")} and ${items[items.length - 1]}` : items[0] ?? "";
1441
+ const firstUp = (t: string) => (t ? t.charAt(0).toUpperCase() + t.slice(1) : t);
1442
+
1443
+ /**
1444
+ * "What this robot can do" (owner 2026-09-26): a few short rows in our words,
1445
+ * built only from THIS robot's manifest (brand + model), so two models read
1446
+ * differently. Rows with nothing to say are left out; [] while the robot has
1447
+ * not been read, or when it has no interface (the no-monitoring panel says so).
1448
+ *
1449
+ * - Does: what it does when told (only what its maker's API lets us send);
1450
+ * - Reports: what it tells us (battery, floor, position, heat, past patrols);
1451
+ * - Alarms: the alarm kinds it raises;
1452
+ * - In <app>: what only the maker's own app does (so nobody hunts for it here);
1453
+ * - Not shared yet: the facts (battery, floor, position) its maker does not give us.
1454
+ */
1455
+ export function robotAbilities(input: {
1456
+ capabilities?: Record<string, unknown> | null;
1457
+ profile?: TRobotProfile | null;
1458
+ type?: unknown;
1459
+ brand?: unknown;
1460
+ } | null | undefined): TRobotAbilityRow[] {
1461
+ const caps = input?.capabilities;
1462
+ if (!caps || typeof caps !== "object" || capabilityLabel(caps) === "No interface") return [];
1463
+ const on = (k: string) => caps[k] === true;
1464
+ const patrols = input?.type === "security" && on("patrolControl");
1465
+ const cleans = input?.type !== "security";
1466
+ const does = [
1467
+ patrols && "patrols its routes (start, stop, resume)",
1468
+ cleans && on("startProcess") && "cleans a whole floor or chosen areas",
1469
+ cleans && on("spotClean") && "cleans spots you pick on the plan",
1470
+ on("pauseResume") && "pauses and resumes",
1471
+ !patrols && on("pauseProcess") && "stops a job",
1472
+ on("navigateToPoint") && "goes to a point you pick",
1473
+ on("relocalize") && "can be told where it is",
1474
+ on("sendToDock") && "returns to its charger",
1475
+ on("liveVideo") && "streams live video",
1476
+ on("manualDrive") && "can be driven by hand",
1477
+ ].filter(Boolean) as string[];
1478
+ const reports = [
1479
+ on("battery") && "battery",
1480
+ on("floorReporting") && "floor",
1481
+ on("readLocation") && "live position",
1482
+ on("thermalCamera") && "heat readings",
1483
+ on("patrolHistory") && (on("thermalCamera") ? "past patrols with photos and temperatures" : "past patrols with photos"),
1484
+ ].filter(Boolean) as string[];
1485
+ const p = input?.profile ?? {};
1486
+ const alarms = (Array.isArray(p.alarms) ? p.alarms : []).filter((a) => typeof a === "string" && a);
1487
+ const makerOnly = (Array.isArray(p.makerAppOnly) ? p.makerAppOnly : []).filter((a) => typeof a === "string" && a);
1488
+ const rows: TRobotAbilityRow[] = [];
1489
+ if (does.length) rows.push({ label: "Does", value: `${firstUp(listText(does))}.` });
1490
+ if (reports.length) rows.push({ label: "Reports", value: `${firstUp(listText(reports))}.` });
1491
+ if (alarms.length) rows.push({ label: "Alarms", value: `${firstUp(listText(alarms))}${on("resolveAlerts") ? " - mark them resolved here." : "."}` });
1492
+ if (makerOnly.length && p.makerApp) rows.push({ label: `In ${p.makerApp}`, value: `${firstUp(listText(makerOnly))}.` });
1493
+ // Facts only: an action it doesn't take is simply absent from "Does", never listed as missing.
1494
+ const missing = [!on("battery") && "battery", !on("floorReporting") && "floor", !on("readLocation") && "live position"].filter(Boolean) as string[];
1495
+ if (missing.length) rows.push({ label: "Not shared yet", value: `${firstUp(listText(missing))}.` });
1496
+ return rows;
1497
+ }
1498
+
1499
+ /**
1500
+ * The empty schedule for a robot whose maker keeps schedules in its own app:
1501
+ * "Patrol schedules are set in the PadBot app. PadBot doesn't share them with us yet."
1502
+ */
1503
+ export function scheduleElsewhereText(type: unknown, brand: unknown, profile?: TRobotProfile | null): string {
1504
+ const what = type === "security" ? "Patrol schedules" : "Cleaning schedules";
1505
+ const name = (typeof brand === "string" && brand.trim()) || "The maker";
1506
+ const app = profile?.makerApp || `${name}'s own app`;
1507
+ return `${what} are set in ${app}. ${name} doesn't share them with us yet.`;
1508
+ }
1509
+
1510
+ /**
1511
+ * The confirm step for a command (owner 2026-09-26: say what will happen
1512
+ * before it is sent). `point` names the saved point for Go to point / Set position.
1513
+ */
1514
+ export function commandConfirm(
1515
+ kind: unknown,
1516
+ input: { type?: unknown; name?: unknown; point?: unknown } = {},
1517
+ ): { title: string; body: string; action: string } {
1518
+ const who = (typeof input.name === "string" && input.name) || "The robot";
1519
+ const at = typeof input.point === "string" && input.point ? input.point : "";
1520
+ const label = kind === "full" && input.type === "security" ? "Start patrol" : commandLabel(kind);
1521
+ const body: Record<string, string> = {
1522
+ full: input.type === "security" ? `${who} starts walking its default patrol route once.` : `${who} starts cleaning the whole floor it is on.`,
1523
+ area: `${who} starts cleaning the chosen areas.`,
1524
+ return: `${who} stops what it is doing and drives back to its charger.`,
1525
+ pause: `${who} stops where it is and waits. Press Resume to carry on.`,
1526
+ resume: `${who} carries on with the job it paused.`,
1527
+ cancel: `${who} stops the current job and stays where it is. It will not carry on by itself.`,
1528
+ navigate: at ? `${who} drives to ${at}.` : `${who} drives to the point you pick.`,
1529
+ relocalize: at
1530
+ ? `${who} is told it is standing at ${at}. Only do this if it really is there - a wrong position gets it lost.`
1531
+ : `${who} is told where it is standing. Only do this if the position is right.`,
1532
+ "patrol-resume": `${who} carries on with the patrol that was interrupted.`,
1533
+ };
1534
+ return { title: `${label}?`, body: body[String(kind)] ?? `${who}: ${label}.`, action: label };
1535
+ }
1536
+
1537
+ /** The Brand column: "PadBot S2", "Demo S1 (simulated)", or just "Ounitech" when no model is reported. */
1538
+ export function brandModelText(
1539
+ brand: unknown,
1540
+ brands: Array<{ brand?: unknown; displayName?: unknown }> | null | undefined,
1541
+ live?: TLiveRobotState | null,
1542
+ ): string {
1543
+ const name = brandName(brand, brands);
1544
+ const model = live?.ok ? (live.profile?.model || live.model || "").trim() : "";
1545
+ if (!model) return name;
1546
+ const word = name.split(" ")[0]!.toLowerCase();
1547
+ const simulated = /\(simulated\)/i.test(name) ? " (simulated)" : "";
1548
+ return (model.toLowerCase().startsWith(word) ? model : `${name.replace(/\s*\(.*\)$/, "")} ${model}`) + simulated;
1549
+ }
1550
+
1420
1551
  /**
1421
1552
  * The note under the credentials. The handoff says "stored encrypted"; they
1422
1553
  * are not encrypted at rest today (see HERO-PENDING_INTEGRATIONS), so the note
@@ -0,0 +1,181 @@
1
+ {
2
+ "s2": {
3
+ "capabilities": {
4
+ "readLocation": true,
5
+ "navigateToPoint": true,
6
+ "battery": true,
7
+ "sendToDock": true,
8
+ "relocalize": true,
9
+ "startProcess": true,
10
+ "pauseProcess": true,
11
+ "processCompletion": true,
12
+ "floorReporting": true,
13
+ "patrolControl": true,
14
+ "liveVideo": true,
15
+ "patrolHistory": true,
16
+ "resolveAlerts": true,
17
+ "thermalCamera": true
18
+ },
19
+ "profile": {
20
+ "model": "PadBot S2",
21
+ "modelKnown": true,
22
+ "alarms": [
23
+ "person",
24
+ "high temperature",
25
+ "smoke",
26
+ "debris",
27
+ "noise",
28
+ "water on the floor",
29
+ "person falling",
30
+ "door open",
31
+ "window open",
32
+ "fire-fighting equipment"
33
+ ],
34
+ "makerApp": "the PadBot app",
35
+ "makerAppOnly": [
36
+ "patrol schedules",
37
+ "maps, points and routes",
38
+ "alarm thresholds and AI recognition",
39
+ "lights and voice announcements",
40
+ "recorded video",
41
+ "live thermal view"
42
+ ]
43
+ }
44
+ },
45
+ "padbot-other": {
46
+ "capabilities": {
47
+ "readLocation": true,
48
+ "navigateToPoint": true,
49
+ "battery": true,
50
+ "sendToDock": true,
51
+ "relocalize": true,
52
+ "startProcess": true,
53
+ "pauseProcess": true,
54
+ "processCompletion": true,
55
+ "floorReporting": true,
56
+ "patrolControl": true,
57
+ "liveVideo": true,
58
+ "patrolHistory": true,
59
+ "resolveAlerts": true
60
+ },
61
+ "profile": {
62
+ "model": "PadBot P9",
63
+ "modelKnown": false,
64
+ "alarms": [
65
+ "person",
66
+ "high temperature",
67
+ "smoke",
68
+ "debris",
69
+ "noise",
70
+ "water on the floor",
71
+ "person falling",
72
+ "door open",
73
+ "window open",
74
+ "fire-fighting equipment"
75
+ ],
76
+ "makerApp": "the PadBot app",
77
+ "makerAppOnly": [
78
+ "patrol schedules",
79
+ "maps, points and routes",
80
+ "alarm thresholds and AI recognition",
81
+ "lights and voice announcements",
82
+ "recorded video"
83
+ ]
84
+ }
85
+ },
86
+ "ounitech": {
87
+ "capabilities": {
88
+ "readLocation": true,
89
+ "sendToDock": true,
90
+ "startProcess": true
91
+ },
92
+ "profile": {
93
+ "model": "",
94
+ "modelKnown": false,
95
+ "alarms": [],
96
+ "makerApp": "the Ounitech app",
97
+ "makerAppOnly": [
98
+ "pausing or stopping a job",
99
+ "cleaning across floors"
100
+ ]
101
+ }
102
+ },
103
+ "demo-s1": {
104
+ "capabilities": {
105
+ "readLocation": true,
106
+ "navigateToPoint": true,
107
+ "pauseResume": true,
108
+ "pathAndEta": true,
109
+ "battery": true,
110
+ "sendToDock": true,
111
+ "relocalize": true,
112
+ "startProcess": true,
113
+ "pauseProcess": true,
114
+ "processCompletion": true,
115
+ "floorReporting": true,
116
+ "multiFloor": true,
117
+ "spotClean": true
118
+ },
119
+ "profile": {
120
+ "model": "Demo S1",
121
+ "modelKnown": false,
122
+ "alarms": [],
123
+ "makerApp": "",
124
+ "makerAppOnly": []
125
+ }
126
+ },
127
+ "demo-cx1": {
128
+ "capabilities": {
129
+ "readLocation": true,
130
+ "pauseResume": true,
131
+ "pathAndEta": true,
132
+ "battery": true,
133
+ "sendToDock": true,
134
+ "startProcess": true,
135
+ "pauseProcess": true,
136
+ "processCompletion": true,
137
+ "floorReporting": true,
138
+ "multiFloor": true
139
+ },
140
+ "profile": {
141
+ "model": "Demo CX-1",
142
+ "modelKnown": true,
143
+ "alarms": [],
144
+ "makerApp": "",
145
+ "makerAppOnly": []
146
+ }
147
+ },
148
+ "demo-sp1": {
149
+ "capabilities": {
150
+ "readLocation": true,
151
+ "navigateToPoint": true,
152
+ "pauseResume": true,
153
+ "pathAndEta": true,
154
+ "battery": true,
155
+ "sendToDock": true,
156
+ "relocalize": true,
157
+ "startProcess": true,
158
+ "pauseProcess": true,
159
+ "processCompletion": true,
160
+ "floorReporting": true,
161
+ "multiFloor": true
162
+ },
163
+ "profile": {
164
+ "model": "Demo SP-1",
165
+ "modelKnown": true,
166
+ "alarms": [],
167
+ "makerApp": "",
168
+ "makerAppOnly": []
169
+ }
170
+ },
171
+ "unknown": {
172
+ "capabilities": {},
173
+ "profile": {
174
+ "model": "Z9",
175
+ "modelKnown": false,
176
+ "alarms": [],
177
+ "makerApp": "",
178
+ "makerAppOnly": []
179
+ }
180
+ }
181
+ }