@7365admin1/layer-common 4.55.0 → 4.56.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,11 @@
1
1
  # @iservice365/layer-common
2
2
 
3
+ ## 4.56.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 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.
8
+
3
9
  ## 4.55.0
4
10
 
5
11
  ### Minor Changes
@@ -1581,11 +1587,11 @@
1581
1587
  `utils/console-tier.ts` + `composables/useConsoleTier.ts` mirror the server's
1582
1588
  own rule from two endpoints the console already calls, unprojected:
1583
1589
 
1584
- GET /api/members/user/:user/app/admin the Seven365 staff membership
1585
- GET /api/roles/id/:role that membership's role document
1590
+ GET /api/members/user/:user/app/admin the Seven365 staff membership
1591
+ GET /api/roles/id/:role that membership's role document
1586
1592
 
1587
- owner = member.type === "admin" && role.type === "admin" && role.default === true
1588
- staff = member.type === "admin" && role.type === "admin"
1593
+ owner = member.type === "admin" && role.type === "admin" && role.default === true
1594
+ staff = member.type === "admin" && role.type === "admin"
1589
1595
 
1590
1596
  `role.default` is the marker because it is the only property of a platform
1591
1597
  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;
@@ -38,20 +38,17 @@
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">
68
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
+
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,29 @@
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
+ <div v-if="online || canDrive" class="hero-detail__video">
157
+ <v-btn v-if="caps?.liveVideo === true && online" variant="outlined" @click="liveOpen = true">Live view</v-btn>
158
+ <v-btn v-if="canDrive" variant="outlined" @click="driveOpen = true">Manual control</v-btn>
136
159
  </div>
160
+ <div v-else class="hero-notreported">Live view opens when the robot is online.</div>
137
161
  </div>
138
162
 
139
- <RobotPatrolHistory v-if="robotType === 'security' && caps?.patrolHistory === true" :robot-id="robotId" />
163
+ <v-dialog v-model="confirmOpen" width="440">
164
+ <div v-if="confirming" class="hero-dialog" role="alertdialog" aria-labelledby="hero-confirm-title">
165
+ <div id="hero-confirm-title" class="hero-dialog__title">{{ confirming.text.title }}</div>
166
+ <div class="hero-dialog__body">{{ confirming.text.body }}</div>
167
+ <div class="hero-dialog__actions">
168
+ <v-btn variant="outlined" @click="confirmOpen = false">Don't send</v-btn>
169
+ <v-btn color="primary" variant="flat" @click="confirmSend">{{ confirming.text.action }}</v-btn>
170
+ </div>
171
+ </div>
172
+ </v-dialog>
140
173
 
141
174
  <v-dialog v-model="editOpen" width="520" persistent>
142
175
  <RobotForm
@@ -208,14 +241,13 @@ import {
208
241
  capabilityLabel,
209
242
  cleaningScheduleRows,
210
243
  commandBody,
244
+ commandConfirm,
211
245
  commandLabel,
212
246
  faultBadge,
213
247
  headingDeg,
214
248
  isMoving,
215
249
  mergePose,
216
- notReportedLine,
217
250
  optimisticMode,
218
- patrolRecordRow,
219
251
  patrolRunHint,
220
252
  patrolScheduleRows,
221
253
  pointCommandBody,
@@ -223,9 +255,11 @@ import {
223
255
  unplacedStopsText,
224
256
  planPoint,
225
257
  positionText,
258
+ robotAbilities,
226
259
  robotErrorMessage,
227
260
  robotFacts,
228
261
  robotStatus,
262
+ scheduleElsewhereText,
229
263
  showSpotClean,
230
264
  sinceText,
231
265
  speedText,
@@ -327,8 +361,16 @@ const statusLabel = computed(() => robotStatus(statusMode.value, robotType.value
327
361
  const fault = computed(() => (live.value?.ok ? live.value.fault ?? null : record.value?.fault ?? null));
328
362
  const faultSince = computed(() => sinceText(fault.value?.since, now.value));
329
363
 
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));
364
+ // Only what THIS robot reports (hide, never grey); what its maker keeps back is said once, in the card.
365
+ const facts = computed(() => robotFacts({ record: record.value, live: live.value as any, now: now.value, hideUnreported: true }));
366
+ const LOCATION = new Set(["Floor", "Live position"]);
367
+ const statusFacts = computed(() => facts.value.filter((f) => !LOCATION.has(f.label)));
368
+ // The plan's readout already shows the live position; the cell only when there is no plan.
369
+ const locationFacts = computed(() => facts.value.filter((f) => LOCATION.has(f.label) && !(f.label === "Live position" && showPlan.value)));
370
+ const abilities = computed(() =>
371
+ robotAbilities({ capabilities: caps.value, profile: live.value?.ok ? live.value.profile : null, type: robotType.value, brand: brand.value }),
372
+ );
373
+ const scheduleElsewhere = computed(() => scheduleElsewhereText(robotType.value, brand.value, live.value?.ok ? live.value.profile : null));
332
374
 
333
375
  const online = computed(() => live.value?.ok === true && live.value.online === true);
334
376
  const commands = computed(() =>
@@ -352,8 +394,15 @@ const readout = computed(() => `${positionText(live.value, caps.value, now.value
352
394
  const spot = ref<Array<{ x: number; y: number }>>([]);
353
395
  const spotOn = computed(() => showSpotClean({ canCommand: props.canCommand, capabilities: caps.value, online: online.value, type: robotType.value }));
354
396
 
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 ?? "");
397
+ // The route drawn on the plan: the shown map's first route, else the first with placed checkpoints (the rest are listed by name).
398
+ const route = computed<TPatrolRouteView | null>(() =>
399
+ selectedMap.value ? mapRoutes.value[0] ?? null : patrol.value?.routes?.find((r) => r.checkpoints?.length) ?? null,
400
+ );
401
+ const floorText = computed(() => locationFacts.value.find((f) => f.label === "Floor")?.value ?? "");
402
+ // 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.
403
+ const showPlan = computed(
404
+ () => !caps.value || reportsPosition.value || spotOn.value || mapViews.value.length > 0 || !!route.value?.checkpoints?.length,
405
+ );
357
406
  const planTitle = computed(() => {
358
407
  if (spotOn.value) return "Spot clean";
359
408
  if (selectedMap.value && !selectedMap.value.current) return `Map · ${selectedMap.value.label}`;
@@ -364,7 +413,7 @@ const planHint = computed(() => {
364
413
  if (noInterface.value) return "No position feed for this model";
365
414
  if (selectedMap.value) return `${selectedMap.value.points.length} saved points · ${mapRoutes.value.length} routes`;
366
415
  if (route.value) return `${route.value.checkpoints.length} checkpoints · ${floorText.value}`;
367
- return floorText.value;
416
+ return ""; // the Floor cell above already says it
368
417
  });
369
418
 
370
419
  /* ---- schedule: cleaning plans from the brand, or the patrol contract ---- */
@@ -376,7 +425,6 @@ const unplacedRoutes = computed(() =>
376
425
  );
377
426
  // The brand keeps its schedules to itself: recent runs (and a labelled guess) instead.
378
427
  const runs = ref<any[]>([]);
379
- const recentRuns = computed(() => runs.value.slice(0, 5).map((r) => patrolRecordRow(r, now.value)));
380
428
  const runHint = computed(() => patrolRunHint(runs.value, now.value));
381
429
 
382
430
  async function loadSchedule() {
@@ -454,11 +502,13 @@ async function run(kind: TRobotCommandKind | "spot", body: Record<string, unknow
454
502
  const before = statusMode.value;
455
503
  busy.value = kind;
456
504
  optimistic.value = { mode: optimisticMode(kind, before), after: Number.POSITIVE_INFINITY };
457
- toast.value = `${commandLabel(kind)} → ${name}`;
505
+ const label = kind === "full" && robotType.value === "security" ? "Start patrol" : commandLabel(kind);
506
+ toast.value = `${label} → ${name} · sending…`;
458
507
  try {
459
508
  await sendRobotCommand(props.robotId, body as any);
460
509
  // A poll already in flight may still carry the old status; wait past it.
461
510
  optimistic.value = optimistic.value ? { ...optimistic.value, after: Date.now() + 300 } : null;
511
+ toast.value = `${label} → ${name} · accepted. Its status updates here.`;
462
512
  return true;
463
513
  } catch (e) {
464
514
  optimistic.value = null;
@@ -491,7 +541,16 @@ async function send(kind: TRobotCommandKind) {
491
541
  toast.value = `${commandLabel(kind)}: the robot's position is not known yet.`;
492
542
  return;
493
543
  }
494
- run(kind, body);
544
+ // Say what will happen before it is sent (the point picker is its own confirm).
545
+ confirming.value = { kind, body, text: commandConfirm(kind, { type: robotType.value, name: record.value?.name }) };
546
+ confirmOpen.value = true;
547
+ }
548
+ const confirmOpen = ref(false);
549
+ const confirming = ref<{ kind: TRobotCommandKind; body: Record<string, unknown>; text: { title: string; body: string; action: string } } | null>(null);
550
+ function confirmSend() {
551
+ const c = confirming.value;
552
+ confirmOpen.value = false;
553
+ if (c) run(c.kind, c.body);
495
554
  }
496
555
 
497
556
  /** The robot's plan image size for its floor, for turning plan clicks into pixels. */
@@ -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"
@@ -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.56.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.",
@@ -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
+ }