@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 +16 -4
- package/assets/css/robot-hero.css +12 -0
- package/components/Robot/Detail.vue +129 -74
- package/components/Robot/Fleet.vue +1 -1
- package/components/Robot/LiveView.vue +151 -38
- package/components/Robot/SiteFleet.vue +13 -2
- package/package.json +2 -1
- package/utils/robot-control.ts +160 -3
- package/utils/robot-display.ts +138 -7
- package/utils/robot-models.fixture.json +181 -0
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
|
-
|
|
1585
|
-
|
|
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
|
-
|
|
1588
|
-
|
|
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="
|
|
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
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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
|
-
</
|
|
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">
|
|
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
|
|
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">
|
|
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
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
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
|
-
<
|
|
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
|
-
|
|
331
|
-
const
|
|
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
|
-
|
|
356
|
-
const
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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(
|
|
@@ -1,24 +1,37 @@
|
|
|
1
1
|
<template>
|
|
2
2
|
<!--
|
|
3
|
-
The robot's current camera stream
|
|
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.
|
|
9
|
+
never stored. Full screen reuses the same stream - never a second fetch.
|
|
6
10
|
-->
|
|
7
|
-
<div
|
|
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="
|
|
18
|
+
<div :id="titleId" class="hero-dialog__title">{{ panel ? "Live view" : `Live view · ${name ?? ""}` }}</div>
|
|
10
19
|
<span class="hero-head__spacer" />
|
|
11
|
-
<
|
|
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="
|
|
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
|
-
<
|
|
21
|
-
|
|
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
|
-
<
|
|
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 {
|
|
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
|
|
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
|
|
64
|
-
|
|
65
|
-
|
|
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
|
-
|
|
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 (
|
|
81
|
-
if (liveVideoKind(res?.url) === "bad") throw new Error(
|
|
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 (
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
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
|
-
|
|
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
|
-
|
|
104
|
-
|
|
105
|
-
|
|
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">{{
|
|
40
|
+
<template #item.brand="{ item }"><span class="hero-dim">{{ brandModelText(item.brand, brands, live(item)) }}</span></template>
|
|
41
41
|
<template #item.capability="{ item }">
|
|
42
|
-
|
|
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.
|
|
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",
|
package/utils/robot-control.ts
CHANGED
|
@@ -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
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
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 = {
|
package/utils/robot-display.ts
CHANGED
|
@@ -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
|
|
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 "
|
|
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 ·
|
|
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
|
-
|
|
1247
|
+
const all = [
|
|
1243
1248
|
connection,
|
|
1244
1249
|
battery,
|
|
1245
1250
|
floor,
|
|
1246
1251
|
position,
|
|
1247
|
-
|
|
1248
|
-
fact("
|
|
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
|
+
}
|