@7365admin1/layer-common 4.52.0 → 4.54.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +16 -4
- package/assets/css/robot-hero.css +24 -1
- package/components/Robot/AlertsTable.vue +10 -1
- package/components/Robot/BuildingMap.vue +2 -0
- package/components/Robot/Detail.vue +131 -10
- package/components/Robot/Fleet.vue +13 -0
- package/components/Robot/Form.vue +2 -2
- package/components/Robot/LivePlan.vue +4 -2
- package/components/Robot/LiveView.vue +107 -0
- package/components/Robot/ManualDrive.vue +128 -0
- package/components/Robot/MiniMap.vue +2 -1
- package/components/Robot/PatrolHistory.vue +100 -0
- package/components/Robot/PointPicker.vue +33 -0
- package/composables/useRobots.ts +82 -1
- package/package.json +1 -1
- package/utils/robot-control.ts +159 -0
- package/utils/robot-credential-form.ts +2 -0
- package/utils/robot-display.ts +206 -21
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,17 @@
|
|
|
1
1
|
# @iservice365/layer-common
|
|
2
2
|
|
|
3
|
+
## 4.54.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- 6bfc7d1: Robots (PadBot S2 and all brands): a robot no longer vanishes from the map on one missed or failed reading - the last position is kept for up to 15 s, drawn faded with "last seen N s ago", then "Reconnecting"; the floor becomes unknown only when the robot says so; an online robot without a position reads "Position not reported right now". Patrol routes whose stops cannot be placed on the plan are listed ("Stops: Gate → Lobby"), never drawn at a guessed spot; when the brand keeps schedules to itself (`scheduleShared: false`) the panel says so and shows recent patrol runs plus a clearly labelled guess ("Looks like ... runs daily around 20:00"). Live view opens in the page (HD / Smooth, re-minted before expiry, never stored; iframe / native video / "Open in new tab" / RTMP explanation), also from the fleet row menu. Alert photos ("Photo", "Thermal photo") on the fault bar and Alerts rows. Go to point / Set position pick from the robot's saved points when its maps list them (`go-to-point`; Set position only on the robot's floor, with a confirmation line). Manual control (only when the server reports `manualDrive` and the user can update robots): hold-to-move with a big STOP, head controls, heartbeat-guarded session that ends on release, tab hide, close or connection loss.
|
|
8
|
+
|
|
9
|
+
## 4.53.0
|
|
10
|
+
|
|
11
|
+
### Minor Changes
|
|
12
|
+
|
|
13
|
+
- 74aabc6: PadBot security robots on the HERO robot screens: Add robot labels the fields "AccessKey ID" / "AccessKey Secret" and picks the robot from the account; robot detail offers Start patrol / Resume patrol (brands with `patrolControl`), Live video (minted per click), Mark resolved on an alarm, an ALARM badge for security alerts, and a Patrol history panel. Pause / Resume now need the brand's `pauseResume`, Cancel its `pauseProcess` (no change for existing brands).
|
|
14
|
+
|
|
3
15
|
## 4.52.0
|
|
4
16
|
|
|
5
17
|
### Minor Changes
|
|
@@ -1557,11 +1569,11 @@
|
|
|
1557
1569
|
`utils/console-tier.ts` + `composables/useConsoleTier.ts` mirror the server's
|
|
1558
1570
|
own rule from two endpoints the console already calls, unprojected:
|
|
1559
1571
|
|
|
1560
|
-
|
|
1561
|
-
|
|
1572
|
+
GET /api/members/user/:user/app/admin the Seven365 staff membership
|
|
1573
|
+
GET /api/roles/id/:role that membership's role document
|
|
1562
1574
|
|
|
1563
|
-
|
|
1564
|
-
|
|
1575
|
+
owner = member.type === "admin" && role.type === "admin" && role.default === true
|
|
1576
|
+
staff = member.type === "admin" && role.type === "admin"
|
|
1565
1577
|
|
|
1566
1578
|
`role.default` is the marker because it is the only property of a platform
|
|
1567
1579
|
staff role no API caller can set - `role.controller.ts` validates create and
|
|
@@ -757,7 +757,7 @@
|
|
|
757
757
|
.hero .v-btn.hero-ghost { color: var(--hero-accent); padding-inline: 4px; }
|
|
758
758
|
.hero .v-btn.hero-ghost:hover { background: color-mix(in srgb, var(--hero-accent) 10%, transparent); }
|
|
759
759
|
|
|
760
|
-
.hero-faultbar { display: grid; grid-template-columns: auto 1fr auto; gap: 14px; align-items: center; padding: 12px 14px; border: 2px solid var(--hero-accent); }
|
|
760
|
+
.hero-faultbar { display: grid; grid-template-columns: auto 1fr; grid-auto-flow: column; grid-auto-columns: auto; gap: 14px; align-items: center; padding: 12px 14px; border: 2px solid var(--hero-accent); }
|
|
761
761
|
.hero-faultbar__code { font-family: var(--hero-font); font-weight: 800; color: var(--hero-accent); font-size: 18px; }
|
|
762
762
|
.hero-faultbar__msg { font-weight: 600; }
|
|
763
763
|
.hero-faultbar__since { opacity: 0.65; font-size: 12px; }
|
|
@@ -1234,3 +1234,26 @@ div.hero-editor__wp.hero-editor__sel, div.hero-editor__fid.hero-editor__sel { ou
|
|
|
1234
1234
|
}
|
|
1235
1235
|
.hero-cell__shimmer { display: none; }
|
|
1236
1236
|
}
|
|
1237
|
+
|
|
1238
|
+
/* PadBot security extras: live video button row under the facts. */
|
|
1239
|
+
.hero-detail__video { margin: 10px 0 4px; }
|
|
1240
|
+
|
|
1241
|
+
/* A kept (stale) reading: faded, no ping - never drawn as live (owner 2026-09-26). */
|
|
1242
|
+
.hero-bot--stale, .hero-me--stale { opacity: 0.45; }
|
|
1243
|
+
.hero-bot--stale .hero-bot__ping, .hero-bot--stale .hero-bmap__ping, .hero-me--stale .hero-me__ping, .hero-me--stale .hero-me__radar { display: none; }
|
|
1244
|
+
.hero-sched__hint { font-size: 12px; font-style: italic; opacity: 0.75; margin: 4px 0; }
|
|
1245
|
+
|
|
1246
|
+
/* Live view / manual control / point picker dialogs */
|
|
1247
|
+
.hero-live__head { display: flex; align-items: center; gap: 8px; }
|
|
1248
|
+
.hero-live__quality button, .hero-drive__head button, .hero-pick__point { border: 1px solid var(--hero-line, currentColor); background: none; color: inherit; font: inherit; padding: 4px 10px; cursor: pointer; }
|
|
1249
|
+
.hero-live__quality button[aria-pressed="true"], .hero-pick__point[aria-pressed="true"] { background: var(--hero-accent); color: var(--hero-bg); }
|
|
1250
|
+
.hero-live__stage { min-height: 240px; display: flex; align-items: center; justify-content: center; background: #000; color: #fff; }
|
|
1251
|
+
.hero-live__video { width: 100%; aspect-ratio: 16 / 9; border: 0; background: #000; }
|
|
1252
|
+
.hero-live__copy { margin-top: 8px; }
|
|
1253
|
+
.hero-live__open { align-self: center; }
|
|
1254
|
+
.hero-drive__stop { width: 100%; padding: 18px; font-size: 24px; font-weight: 800; letter-spacing: 0.08em; color: #fff; background: #c62828; border: 0; cursor: pointer; }
|
|
1255
|
+
.hero-drive__pad { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; }
|
|
1256
|
+
.hero-drive__pad button { padding: 16px 8px; border: 1px solid var(--hero-line, currentColor); background: var(--hero-surface); color: inherit; font: inherit; cursor: pointer; touch-action: none; user-select: none; }
|
|
1257
|
+
.hero-drive__pad button:active { background: var(--hero-accent); color: var(--hero-bg); }
|
|
1258
|
+
.hero-drive__head { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; }
|
|
1259
|
+
.hero-pick__group { display: flex; flex-wrap: wrap; gap: 6px; align-items: center; margin-bottom: 6px; }
|
|
@@ -21,7 +21,10 @@
|
|
|
21
21
|
@click:row="onRow"
|
|
22
22
|
>
|
|
23
23
|
<template #item.code="{ item }"><span class="hero-alert__code">{{ attentionText(item).code }}</span></template>
|
|
24
|
-
<template #item.message="{ item }">
|
|
24
|
+
<template #item.message="{ item }">
|
|
25
|
+
{{ attentionText(item).message }}
|
|
26
|
+
<template v-for="ph in photosOf(item)" :key="ph.label"> · <a :href="ph.url" target="_blank" rel="noopener noreferrer" @click.stop>{{ ph.label }}</a></template>
|
|
27
|
+
</template>
|
|
25
28
|
<template #item.robot="{ item }"><span class="hero-table__name">{{ item.robot?.name }}</span></template>
|
|
26
29
|
<template #item.site="{ item }"><span class="hero-dim">{{ item.client?.name }} · {{ item.site?.name }}</span></template>
|
|
27
30
|
<template #item.floor="{ item }">
|
|
@@ -36,6 +39,7 @@
|
|
|
36
39
|
import RobotFloorLabel from "./FloorLabel.vue";
|
|
37
40
|
import RobotTable from "./Table.vue";
|
|
38
41
|
import { attentionText, sinceText } from "../../utils/robot-display";
|
|
42
|
+
import { faultPhotos } from "../../utils/robot-control";
|
|
39
43
|
|
|
40
44
|
const props = withDefaults(
|
|
41
45
|
defineProps<{
|
|
@@ -57,6 +61,11 @@ function floorOf(item: any) {
|
|
|
57
61
|
const s = props.states[String(item?.robot?._id ?? "")];
|
|
58
62
|
return s?.ok ? { floorNo: s.floorNo, floorKnown: s.floorKnown === true } : { floorNo: null, floorKnown: false };
|
|
59
63
|
}
|
|
64
|
+
/** The robot's photos for this alert, when its live fault is this one and carries them. */
|
|
65
|
+
function photosOf(item: any) {
|
|
66
|
+
const f = props.states[String(item?.robot?._id ?? "")]?.fault;
|
|
67
|
+
return f && f.code === item?.code ? faultPhotos(f) : [];
|
|
68
|
+
}
|
|
60
69
|
function onRow(_e: unknown, { item }: { item: any }) {
|
|
61
70
|
emit("open", item);
|
|
62
71
|
}
|
|
@@ -163,6 +163,8 @@
|
|
|
163
163
|
v-for="r in f.robots"
|
|
164
164
|
:key="r.id"
|
|
165
165
|
class="hero-bmap__anchor hero-bmap__bot"
|
|
166
|
+
:class="{ 'hero-bot--stale': r.stale }"
|
|
167
|
+
:title="r.stale ? `${r.name} · ${r.seen}` : undefined"
|
|
166
168
|
:style="{ left: r.left, top: r.top, transition: `left ${tickMs}ms linear, top ${tickMs}ms linear`, '--hero-bot': toneColor(r) }"
|
|
167
169
|
>
|
|
168
170
|
<span class="hero-bmap__ping" aria-hidden="true" />
|
|
@@ -25,9 +25,14 @@
|
|
|
25
25
|
<span class="hero-faultbar__code">{{ fault.code }}</span>
|
|
26
26
|
<div>
|
|
27
27
|
<div class="hero-faultbar__msg">{{ fault.message || fault.code }}</div>
|
|
28
|
-
<div class="hero-faultbar__since">
|
|
28
|
+
<div class="hero-faultbar__since">
|
|
29
|
+
Since {{ faultSince }} · notified once
|
|
30
|
+
<template v-for="ph in faultPhotos(fault)" :key="ph.label"> · <a :href="ph.url" target="_blank" rel="noopener noreferrer">{{ ph.label }}</a></template>
|
|
31
|
+
</div>
|
|
29
32
|
</div>
|
|
30
|
-
<
|
|
33
|
+
<v-btn v-if="caps?.liveVideo === true && online" variant="outlined" size="small" @click="liveOpen = true">Live view</v-btn>
|
|
34
|
+
<v-btn v-if="canResolve" variant="outlined" size="small" :loading="resolving" @click="resolveFault">Mark resolved</v-btn>
|
|
35
|
+
<span class="hero-faultbar__badge">{{ faultBadge(fault) }}</span>
|
|
31
36
|
</div>
|
|
32
37
|
|
|
33
38
|
<RobotNoMonitoring v-if="noInterface" :brand-name="brand" :model="live?.model || undefined" />
|
|
@@ -39,6 +44,10 @@
|
|
|
39
44
|
</div>
|
|
40
45
|
</div>
|
|
41
46
|
<div v-if="notReported" class="hero-notreported">{{ notReported }}</div>
|
|
47
|
+
<div v-if="(caps?.liveVideo === true && online && !fault) || canDrive" class="hero-detail__video">
|
|
48
|
+
<v-btn v-if="caps?.liveVideo === true && online && !fault" variant="outlined" @click="liveOpen = true">Live view</v-btn>
|
|
49
|
+
<v-btn v-if="canDrive" variant="outlined" @click="driveOpen = true">Manual control</v-btn>
|
|
50
|
+
</div>
|
|
42
51
|
|
|
43
52
|
<div v-if="commands.length">
|
|
44
53
|
<div class="hero-section__title">Command bar</div>
|
|
@@ -62,10 +71,26 @@
|
|
|
62
71
|
robotType === "security" ? "days · start–end · floor · checkpoints × rounds" : "days · start–end · jobs per floor · passes"
|
|
63
72
|
}}</span>
|
|
64
73
|
</div>
|
|
65
|
-
<
|
|
74
|
+
<template v-if="!schedule.length && robotType === 'security' && patrol?.scheduleShared === false">
|
|
75
|
+
<div class="hero-sched__empty">Patrol schedules are set in the PadBot app. PadBot doesn't share them with us yet.</div>
|
|
76
|
+
<div v-if="runHint" class="hero-sched__hint">{{ runHint }} - a guess from recent runs, not the schedule.</div>
|
|
77
|
+
<template v-if="recentRuns.length">
|
|
78
|
+
<div class="hero-aside__hint">Recent patrol runs</div>
|
|
79
|
+
<div v-for="r in recentRuns" :key="r.key" class="hero-sched__row">
|
|
80
|
+
<b>{{ r.when }}</b><span>{{ r.route }} · {{ r.result }}</span><span>{{ r.detail }}</span>
|
|
81
|
+
</div>
|
|
82
|
+
</template>
|
|
83
|
+
</template>
|
|
84
|
+
<div v-else-if="!schedule.length" class="hero-sched__empty">No schedule yet.</div>
|
|
66
85
|
<div v-for="p in schedule" :key="p.key" class="hero-sched__row">
|
|
67
86
|
<b>{{ p.days }}</b><span>{{ p.time }}</span><span>{{ p.jobs }}</span>
|
|
68
87
|
</div>
|
|
88
|
+
<template v-if="unplacedRoutes.length">
|
|
89
|
+
<div class="hero-aside__hint">Patrol routes (stops not placed on the plan)</div>
|
|
90
|
+
<div v-for="r in unplacedRoutes" :key="r.id" class="hero-sched__row">
|
|
91
|
+
<b>{{ r.name }}</b><span>Stops: {{ r.text }}</span>
|
|
92
|
+
</div>
|
|
93
|
+
</template>
|
|
69
94
|
</div>
|
|
70
95
|
|
|
71
96
|
<div>
|
|
@@ -77,6 +102,7 @@
|
|
|
77
102
|
:name="record?.name"
|
|
78
103
|
:status-label="statusLabel"
|
|
79
104
|
:point="posLive ? point : null"
|
|
105
|
+
:stale="shown?.stale === true"
|
|
80
106
|
:heading="headingDeg(shown)"
|
|
81
107
|
:trail="trail"
|
|
82
108
|
:readout="readout"
|
|
@@ -97,6 +123,8 @@
|
|
|
97
123
|
</div>
|
|
98
124
|
</div>
|
|
99
125
|
|
|
126
|
+
<RobotPatrolHistory v-if="robotType === 'security' && caps?.patrolHistory === true" :robot-id="robotId" />
|
|
127
|
+
|
|
100
128
|
<v-dialog v-model="editOpen" width="520" persistent>
|
|
101
129
|
<RobotForm
|
|
102
130
|
v-if="editOpen && record"
|
|
@@ -124,6 +152,16 @@
|
|
|
124
152
|
</div>
|
|
125
153
|
</v-dialog>
|
|
126
154
|
|
|
155
|
+
<v-dialog v-model="liveOpen" width="860">
|
|
156
|
+
<RobotLiveView v-if="liveOpen" :robot-id="robotId" :name="record?.name" @close="liveOpen = false" />
|
|
157
|
+
</v-dialog>
|
|
158
|
+
<v-dialog v-model="driveOpen" width="520" persistent>
|
|
159
|
+
<RobotManualDrive v-if="driveOpen" :robot-id="robotId" :name="record?.name" @close="driveOpen = false" />
|
|
160
|
+
</v-dialog>
|
|
161
|
+
<v-dialog v-model="pickOpen" width="520">
|
|
162
|
+
<RobotPointPicker v-if="pickOpen && pickKind" :kind="pickKind" :groups="pointGroups" :floor-no="live?.floorNo ?? null" @close="pickOpen = false" @pick="onPick" />
|
|
163
|
+
</v-dialog>
|
|
164
|
+
|
|
127
165
|
<RobotToast v-model="toast" />
|
|
128
166
|
</template>
|
|
129
167
|
|
|
@@ -136,6 +174,11 @@ import RobotViewOnlyNotice from "./ViewOnlyNotice.vue";
|
|
|
136
174
|
import RobotLivePlan from "./LivePlan.vue";
|
|
137
175
|
import RobotToast from "./Toast.vue";
|
|
138
176
|
import RobotForm from "./Form.vue";
|
|
177
|
+
import RobotPatrolHistory from "./PatrolHistory.vue";
|
|
178
|
+
import RobotLiveView from "./LiveView.vue";
|
|
179
|
+
import RobotManualDrive from "./ManualDrive.vue";
|
|
180
|
+
import RobotPointPicker from "./PointPicker.vue";
|
|
181
|
+
import { faultPhotos } from "../../utils/robot-control";
|
|
139
182
|
import useRobotLive, { siteStatesFeed } from "../../composables/useRobotLive";
|
|
140
183
|
import {
|
|
141
184
|
brandName,
|
|
@@ -143,12 +186,18 @@ import {
|
|
|
143
186
|
cleaningScheduleRows,
|
|
144
187
|
commandBody,
|
|
145
188
|
commandLabel,
|
|
189
|
+
faultBadge,
|
|
146
190
|
headingDeg,
|
|
147
191
|
isMoving,
|
|
148
192
|
mergePose,
|
|
149
193
|
notReportedLine,
|
|
150
194
|
optimisticMode,
|
|
195
|
+
patrolRecordRow,
|
|
196
|
+
patrolRunHint,
|
|
151
197
|
patrolScheduleRows,
|
|
198
|
+
pointCommandBody,
|
|
199
|
+
savedPointGroups,
|
|
200
|
+
unplacedStopsText,
|
|
152
201
|
planPoint,
|
|
153
202
|
positionText,
|
|
154
203
|
robotErrorMessage,
|
|
@@ -164,6 +213,8 @@ import {
|
|
|
164
213
|
type TLiveRobotState,
|
|
165
214
|
type TPatrolRouteView,
|
|
166
215
|
type TRobotCommandKind,
|
|
216
|
+
type TSavedPoint,
|
|
217
|
+
type TSavedPointGroup,
|
|
167
218
|
} from "../../utils/robot-display";
|
|
168
219
|
|
|
169
220
|
const props = withDefaults(
|
|
@@ -188,8 +239,19 @@ const emit = defineEmits<{
|
|
|
188
239
|
status: [status: { connected: boolean }];
|
|
189
240
|
}>();
|
|
190
241
|
|
|
191
|
-
const {
|
|
192
|
-
|
|
242
|
+
const {
|
|
243
|
+
getRobot,
|
|
244
|
+
getRobotBrands,
|
|
245
|
+
getRobotPlans,
|
|
246
|
+
getPatrolRoutes,
|
|
247
|
+
sendRobotCommand,
|
|
248
|
+
trackRobot,
|
|
249
|
+
deleteRobot,
|
|
250
|
+
getRobotMaps,
|
|
251
|
+
getRobotMapImages,
|
|
252
|
+
resolveRobotAlerts,
|
|
253
|
+
getPatrolHistory,
|
|
254
|
+
} = useRobots();
|
|
193
255
|
|
|
194
256
|
const record = ref<Record<string, any> | null>(null);
|
|
195
257
|
const brands = ref<Array<Record<string, any>>>([]);
|
|
@@ -262,7 +324,7 @@ watch(
|
|
|
262
324
|
if (p && posLive.value && isMoving(statusMode.value)) trail.value = trailPush(trail.value, p);
|
|
263
325
|
},
|
|
264
326
|
);
|
|
265
|
-
const readout = computed(() => `${positionText(live.value, caps.value)} · ${speedText(prevPose.value, nextPose.value, statusMode.value)}`);
|
|
327
|
+
const readout = computed(() => `${positionText(live.value, caps.value, now.value)} · ${speedText(prevPose.value, nextPose.value, statusMode.value)}`);
|
|
266
328
|
|
|
267
329
|
const spot = ref<Array<{ x: number; y: number }>>([]);
|
|
268
330
|
const spotOn = computed(() => showSpotClean({ canCommand: props.canCommand, capabilities: caps.value, online: online.value, type: robotType.value }));
|
|
@@ -279,14 +341,25 @@ const planHint = computed(() => {
|
|
|
279
341
|
|
|
280
342
|
/* ---- schedule: cleaning plans from the brand, or the patrol contract ---- */
|
|
281
343
|
const plans = ref<Array<Record<string, any>>>([]);
|
|
282
|
-
const patrol = ref<{ supported?: boolean; routes?: TPatrolRouteView[]; schedule?: any[] } | null>(null);
|
|
344
|
+
const patrol = ref<{ supported?: boolean; routes?: TPatrolRouteView[]; schedule?: any[]; scheduleShared?: boolean } | null>(null);
|
|
283
345
|
const schedule = computed(() => (robotType.value === "security" ? patrolScheduleRows(patrol.value) : cleaningScheduleRows(plans.value)));
|
|
346
|
+
const unplacedRoutes = computed(() =>
|
|
347
|
+
(patrol.value?.routes ?? []).map((r) => ({ id: r.id, name: r.name, text: unplacedStopsText(r) })).filter((r) => r.text),
|
|
348
|
+
);
|
|
349
|
+
// The brand keeps its schedules to itself: recent runs (and a labelled guess) instead.
|
|
350
|
+
const runs = ref<any[]>([]);
|
|
351
|
+
const recentRuns = computed(() => runs.value.slice(0, 5).map((r) => patrolRecordRow(r, now.value)));
|
|
352
|
+
const runHint = computed(() => patrolRunHint(runs.value, now.value));
|
|
284
353
|
|
|
285
354
|
async function loadSchedule() {
|
|
286
355
|
if (!record.value || noInterface.value) return;
|
|
287
356
|
try {
|
|
288
|
-
if (robotType.value === "security")
|
|
289
|
-
|
|
357
|
+
if (robotType.value === "security") {
|
|
358
|
+
patrol.value = await getPatrolRoutes(props.robotId);
|
|
359
|
+
if (patrol.value?.scheduleShared === false && caps.value?.patrolHistory === true && !runs.value.length) {
|
|
360
|
+
runs.value = (await getPatrolHistory(props.robotId, { page: 1, limit: 20 }).catch(() => null))?.items ?? [];
|
|
361
|
+
}
|
|
362
|
+
} else plans.value = (await getRobotPlans(props.robotId))?.items ?? [];
|
|
290
363
|
} catch {
|
|
291
364
|
// A schedule the brand cannot serve reads "No schedule yet"; the facts still show.
|
|
292
365
|
}
|
|
@@ -336,7 +409,23 @@ async function run(kind: TRobotCommandKind | "spot", body: Record<string, unknow
|
|
|
336
409
|
busy.value = null;
|
|
337
410
|
}
|
|
338
411
|
}
|
|
339
|
-
|
|
412
|
+
/* Go to point / Set position: the robot's own saved points when its brand lists them (PadBot). */
|
|
413
|
+
const pointGroups = ref<TSavedPointGroup[]>([]);
|
|
414
|
+
const pickOpen = ref(false);
|
|
415
|
+
const pickKind = ref<"navigate" | "relocalize" | null>(null);
|
|
416
|
+
async function send(kind: TRobotCommandKind) {
|
|
417
|
+
if (kind === "navigate" || kind === "relocalize") {
|
|
418
|
+
try {
|
|
419
|
+
pointGroups.value = savedPointGroups((await getRobotMaps(props.robotId))?.items);
|
|
420
|
+
} catch {
|
|
421
|
+
pointGroups.value = [];
|
|
422
|
+
}
|
|
423
|
+
if (pointGroups.value.length) {
|
|
424
|
+
pickKind.value = kind;
|
|
425
|
+
pickOpen.value = true;
|
|
426
|
+
return;
|
|
427
|
+
}
|
|
428
|
+
}
|
|
340
429
|
const body = commandBody(kind, live.value);
|
|
341
430
|
if (!body) {
|
|
342
431
|
toast.value = `${commandLabel(kind)}: the robot's position is not known yet.`;
|
|
@@ -379,6 +468,38 @@ async function sendSpot() {
|
|
|
379
468
|
}
|
|
380
469
|
}
|
|
381
470
|
|
|
471
|
+
function onPick(p: TSavedPoint) {
|
|
472
|
+
const kind = pickKind.value;
|
|
473
|
+
pickOpen.value = false;
|
|
474
|
+
if (!kind) return;
|
|
475
|
+
const body = pointCommandBody(kind, p, live.value);
|
|
476
|
+
if (!body) {
|
|
477
|
+
toast.value = "Set position: the robot is not on that floor.";
|
|
478
|
+
return;
|
|
479
|
+
}
|
|
480
|
+
run(kind, body);
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
/* ---- security-robot extras: live view (minted per open, never kept), manual control, resolve an alarm ---- */
|
|
484
|
+
const liveOpen = ref(false);
|
|
485
|
+
const driveOpen = ref(false);
|
|
486
|
+
const canDrive = computed(() => props.canCommand && caps.value?.manualDrive === true && online.value);
|
|
487
|
+
const canResolve = computed(
|
|
488
|
+
() => props.canCommand && caps.value?.resolveAlerts === true && faultBadge(fault.value) === "ALARM" && typeof fault.value?.code === "string",
|
|
489
|
+
);
|
|
490
|
+
const resolving = ref(false);
|
|
491
|
+
async function resolveFault() {
|
|
492
|
+
resolving.value = true;
|
|
493
|
+
try {
|
|
494
|
+
const res = await resolveRobotAlerts(props.robotId, String(fault.value?.code ?? ""));
|
|
495
|
+
toast.value = res?.resolved ? `Marked resolved · ${res.resolved} alert${res.resolved === 1 ? "" : "s"}` : "Nothing open to resolve.";
|
|
496
|
+
} catch (e) {
|
|
497
|
+
toast.value = robotErrorMessage(e, "Could not mark the alert resolved.");
|
|
498
|
+
} finally {
|
|
499
|
+
resolving.value = false;
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
|
|
382
503
|
/* ---- edit / remove ---- */
|
|
383
504
|
function onSaved() {
|
|
384
505
|
editOpen.value = false;
|
|
@@ -172,6 +172,12 @@
|
|
|
172
172
|
</template>
|
|
173
173
|
<v-list density="compact">
|
|
174
174
|
<v-list-item title="Schedule" prepend-icon="mdi-calendar-clock" @click="openPlans(item)" />
|
|
175
|
+
<v-list-item
|
|
176
|
+
v-if="states[item._id]?.capabilities?.liveVideo === true && states[item._id]?.online === true"
|
|
177
|
+
title="Live view"
|
|
178
|
+
prepend-icon="mdi-cctv"
|
|
179
|
+
@click="liveRobot = item"
|
|
180
|
+
/>
|
|
175
181
|
<!--
|
|
176
182
|
Only for a brand that can take a click on the plan. A brand
|
|
177
183
|
without it gets no control, not one the server refuses.
|
|
@@ -349,9 +355,14 @@
|
|
|
349
355
|
</v-row>
|
|
350
356
|
</template>
|
|
351
357
|
</ConfirmDialog>
|
|
358
|
+
|
|
359
|
+
<v-dialog :model-value="!!liveRobot" width="860" @update:model-value="(v) => !v && (liveRobot = null)">
|
|
360
|
+
<RobotLiveView v-if="liveRobot" :robot-id="String(liveRobot._id)" :name="liveRobot.name" @close="liveRobot = null" />
|
|
361
|
+
</v-dialog>
|
|
352
362
|
</template>
|
|
353
363
|
|
|
354
364
|
<script lang="ts" setup>
|
|
365
|
+
import RobotLiveView from "./LiveView.vue";
|
|
355
366
|
/**
|
|
356
367
|
* The fleet screen, shared by every application that shows robots.
|
|
357
368
|
*
|
|
@@ -700,6 +711,8 @@ function onCreated() {
|
|
|
700
711
|
|
|
701
712
|
onMounted(load);
|
|
702
713
|
onBeforeUnmount(stopWatching);
|
|
714
|
+
/** The row whose Live view is open (security robots with a camera URL). */
|
|
715
|
+
const liveRobot = ref<any>(null);
|
|
703
716
|
</script>
|
|
704
717
|
|
|
705
718
|
<style scoped>
|
|
@@ -155,8 +155,8 @@ const found = ref<Array<{ value: string; label: string; disabled: boolean }>>([]
|
|
|
155
155
|
const finding = ref(false);
|
|
156
156
|
const labels = computed(() => credentialLabels(brand.value));
|
|
157
157
|
const typed = computed(() => !!appKey.value.trim() && !!appToken.value.trim());
|
|
158
|
-
// Ounitech: one
|
|
159
|
-
const discoverable = computed(() => brand.value === "ounitech");
|
|
158
|
+
// Ounitech / PadBot: one credential pair per company account, so the robot is picked from its list.
|
|
159
|
+
const discoverable = computed(() => brand.value === "ounitech" || brand.value === "padbot");
|
|
160
160
|
const findHint = computed(() =>
|
|
161
161
|
editing.value || typed.value
|
|
162
162
|
? "Press Find robots"
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
<span class="hero-cross hero-cross--h" :style="{ top: point.top, transition: `top ${tickMs}ms linear` }" aria-hidden="true" />
|
|
16
16
|
<span class="hero-cross hero-cross--v" :style="{ left: point.left, transition: `left ${tickMs}ms linear` }" aria-hidden="true" />
|
|
17
17
|
<div class="hero-readout">{{ readout }}</div>
|
|
18
|
-
<div class="hero-me" :style="{ left: point.left, top: point.top, transition: `left ${tickMs}ms linear, top ${tickMs}ms linear` }">
|
|
18
|
+
<div class="hero-me" :class="{ 'hero-me--stale': stale }" :style="{ left: point.left, top: point.top, transition: `left ${tickMs}ms linear, top ${tickMs}ms linear` }">
|
|
19
19
|
<div class="hero-me__tag">{{ name }} <span>{{ statusLabel }}</span></div>
|
|
20
20
|
<div class="hero-me__radar" aria-hidden="true" />
|
|
21
21
|
<div class="hero-me__ping" aria-hidden="true" />
|
|
@@ -52,8 +52,10 @@ withDefaults(
|
|
|
52
52
|
spot?: Array<{ x: number; y: number }>;
|
|
53
53
|
pick?: boolean;
|
|
54
54
|
tickMs?: number;
|
|
55
|
+
/** The last reading kept through a short gap: drawn faded, never as live. */
|
|
56
|
+
stale?: boolean;
|
|
55
57
|
}>(),
|
|
56
|
-
{ name: "", statusLabel: "", point: null, heading: 0, trail: () => [], readout: "", lost: false, route: null, routeMoving: false, spot: () => [], pick: false, tickMs: 1200 },
|
|
58
|
+
{ stale: false, name: "", statusLabel: "", point: null, heading: 0, trail: () => [], readout: "", lost: false, route: null, routeMoving: false, spot: () => [], pick: false, tickMs: 1200 },
|
|
57
59
|
);
|
|
58
60
|
const emit = defineEmits<{ pick: [point: { x: number; y: number }] }>();
|
|
59
61
|
</script>
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<!--
|
|
3
|
+
The robot's current camera stream, in the page (security robots, liveVideo).
|
|
4
|
+
The URL is minted per open and re-minted shortly before it expires; it is
|
|
5
|
+
never stored. What the browser cannot play in place opens in a new tab.
|
|
6
|
+
-->
|
|
7
|
+
<div class="hero-dialog hero-live" role="dialog" aria-labelledby="hero-live-title">
|
|
8
|
+
<div class="hero-live__head">
|
|
9
|
+
<div id="hero-live-title" class="hero-dialog__title">Live view · {{ name }}</div>
|
|
10
|
+
<span class="hero-head__spacer" />
|
|
11
|
+
<div class="hero-live__quality" role="group" aria-label="Quality">
|
|
12
|
+
<button type="button" :aria-pressed="quality === 'smooth'" @click="quality = 'smooth'">Smooth</button>
|
|
13
|
+
<button type="button" :aria-pressed="quality === 'hd'" @click="quality = 'hd'">HD</button>
|
|
14
|
+
</div>
|
|
15
|
+
</div>
|
|
16
|
+
|
|
17
|
+
<div class="hero-live__stage">
|
|
18
|
+
<div v-if="loading && !url" class="hero-muted">Connecting to the robot's camera…</div>
|
|
19
|
+
<div v-else-if="error" class="hero-notice" role="alert">{{ error }}</div>
|
|
20
|
+
<template v-else-if="url">
|
|
21
|
+
<video v-if="plays" :key="url" class="hero-live__video" :src="url" controls autoplay muted playsinline @error="error = CAMERA_DOWN" />
|
|
22
|
+
<iframe
|
|
23
|
+
v-else-if="kind === 'page'"
|
|
24
|
+
:key="url"
|
|
25
|
+
class="hero-live__video"
|
|
26
|
+
:src="url"
|
|
27
|
+
title="Robot camera"
|
|
28
|
+
sandbox="allow-scripts allow-same-origin"
|
|
29
|
+
allow="autoplay; fullscreen"
|
|
30
|
+
allowfullscreen
|
|
31
|
+
referrerpolicy="no-referrer"
|
|
32
|
+
/>
|
|
33
|
+
<div v-else-if="kind === 'rtmp'" class="hero-dialog__body">
|
|
34
|
+
This stream uses RTMP, which browsers can't play. Open it in a player app such as VLC.
|
|
35
|
+
<div class="hero-live__copy">
|
|
36
|
+
<v-btn variant="outlined" size="small" @click="copy">Copy stream link</v-btn>
|
|
37
|
+
</div>
|
|
38
|
+
</div>
|
|
39
|
+
<div v-else class="hero-dialog__body">This stream can't play inside the page in this browser. Open it in a new tab.</div>
|
|
40
|
+
</template>
|
|
41
|
+
</div>
|
|
42
|
+
|
|
43
|
+
<div class="hero-dialog__actions">
|
|
44
|
+
<a v-if="url && kind !== 'rtmp'" class="hero-live__open" :href="url" target="_blank" rel="noopener noreferrer">Open in new tab</a>
|
|
45
|
+
<span class="hero-head__spacer" />
|
|
46
|
+
<v-btn variant="outlined" @click="emit('close')">Close</v-btn>
|
|
47
|
+
</div>
|
|
48
|
+
</div>
|
|
49
|
+
</template>
|
|
50
|
+
|
|
51
|
+
<script setup lang="ts">
|
|
52
|
+
import { computed, onBeforeUnmount, ref, watch } from "vue";
|
|
53
|
+
import { liveVideoKind, remintDelayMs } from "../../utils/robot-control";
|
|
54
|
+
|
|
55
|
+
const props = defineProps<{ robotId: string; name?: string }>();
|
|
56
|
+
const emit = defineEmits<{ close: [] }>();
|
|
57
|
+
const { getLiveVideo } = useRobots();
|
|
58
|
+
|
|
59
|
+
const CAMERA_DOWN = "The robot's camera isn't available right now.";
|
|
60
|
+
const quality = ref<"hd" | "smooth">("smooth");
|
|
61
|
+
const url = ref("");
|
|
62
|
+
const error = ref("");
|
|
63
|
+
const loading = ref(false);
|
|
64
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
65
|
+
let alive = true;
|
|
66
|
+
|
|
67
|
+
const kind = computed(() => liveVideoKind(url.value));
|
|
68
|
+
const plays = computed(
|
|
69
|
+
() =>
|
|
70
|
+
kind.value === "video" ||
|
|
71
|
+
(kind.value === "hls" && typeof document !== "undefined" && !!document.createElement("video").canPlayType("application/vnd.apple.mpegurl")),
|
|
72
|
+
);
|
|
73
|
+
|
|
74
|
+
async function mint() {
|
|
75
|
+
clearTimeout(timer);
|
|
76
|
+
loading.value = true;
|
|
77
|
+
error.value = "";
|
|
78
|
+
try {
|
|
79
|
+
const res = await getLiveVideo(props.robotId, quality.value);
|
|
80
|
+
if (!alive) return;
|
|
81
|
+
if (liveVideoKind(res?.url) === "bad") throw new Error(CAMERA_DOWN);
|
|
82
|
+
url.value = res.url;
|
|
83
|
+
const wait = remintDelayMs(res?.expiresAt);
|
|
84
|
+
if (wait !== null) timer = setTimeout(mint, wait);
|
|
85
|
+
} catch {
|
|
86
|
+
if (alive) {
|
|
87
|
+
url.value = "";
|
|
88
|
+
error.value = CAMERA_DOWN;
|
|
89
|
+
}
|
|
90
|
+
} finally {
|
|
91
|
+
loading.value = false;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
async function copy() {
|
|
95
|
+
try {
|
|
96
|
+
await navigator.clipboard.writeText(url.value);
|
|
97
|
+
} catch {
|
|
98
|
+
/* the link is still shown by the browser's own copy */
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
watch(quality, mint, { immediate: true });
|
|
102
|
+
onBeforeUnmount(() => {
|
|
103
|
+
alive = false;
|
|
104
|
+
clearTimeout(timer);
|
|
105
|
+
url.value = "";
|
|
106
|
+
});
|
|
107
|
+
</script>
|