@7365admin1/layer-common 4.123.0 → 4.124.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/components/Robot/Detail.vue +10 -0
- package/components/Robot/EmergencyStop.vue +91 -0
- package/components/Robot/SetupPanel.vue +302 -0
- package/components/Robot/SetupPoints.vue +109 -0
- package/components/Robot/SetupZones.vue +152 -0
- package/composables/useRobots.ts +31 -0
- package/package.json +1 -1
- package/utils/robot-config.ts +158 -0
- package/utils/robot-display.ts +1 -0
- package/utils/robot-setup.ts +8 -9
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,17 @@
|
|
|
1
1
|
# @iservice365/layer-common
|
|
2
2
|
|
|
3
|
+
## 4.124.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- 82bac6e: Robots: big red "Emergency stop" on robot detail for anyone who may command the robot (PadBot; one tap, no confirm, says which path reached the robot). Super Admin "Robot setup" section on robot detail: draw / rename / remove no-go areas, virtual walls and slow zones on the robot's real map (Ounitech; safe zones view only), cleaning schedules and timed switch on/off (add, edit, on/off, delete), robot / site / map names, save dock where the robot is charging, switch floor, named settings (speeds only lower), and PadBot saved points (add at the robot or on the map, move, rename, delete; charger locked). Every change has a one-sentence confirm and shows the maker's read-back.
|
|
8
|
+
|
|
9
|
+
## 4.123.1
|
|
10
|
+
|
|
11
|
+
### Patch Changes
|
|
12
|
+
|
|
13
|
+
- 2a14035: Robots Setup: "Floors on the map" now names the robot that reports a floor the map lacks and lists the map's floors; a lift whose gateway exists but is not connected offers "Open lift queue" instead of "Connect gateway" (which would replace its code).
|
|
14
|
+
|
|
3
15
|
## 4.123.0
|
|
4
16
|
|
|
5
17
|
### Minor Changes
|
|
@@ -3252,11 +3264,11 @@ Monthly report: a month that had patrols opens the list of that month's patrol r
|
|
|
3252
3264
|
`utils/console-tier.ts` + `composables/useConsoleTier.ts` mirror the server's
|
|
3253
3265
|
own rule from two endpoints the console already calls, unprojected:
|
|
3254
3266
|
|
|
3255
|
-
|
|
3256
|
-
|
|
3267
|
+
GET /api/members/user/:user/app/admin the Seven365 staff membership
|
|
3268
|
+
GET /api/roles/id/:role that membership's role document
|
|
3257
3269
|
|
|
3258
|
-
|
|
3259
|
-
|
|
3270
|
+
owner = member.type === "admin" && role.type === "admin" && role.default === true
|
|
3271
|
+
staff = member.type === "admin" && role.type === "admin"
|
|
3260
3272
|
|
|
3261
3273
|
`role.default` is the marker because it is the only property of a platform
|
|
3262
3274
|
staff role no API caller can set - `role.controller.ts` validates create and
|
|
@@ -30,6 +30,9 @@
|
|
|
30
30
|
</div>
|
|
31
31
|
</div>
|
|
32
32
|
|
|
33
|
+
<!-- Emergency stop: anyone allowed to command this robot, one tap, no confirm (PadBot today). -->
|
|
34
|
+
<RobotEmergencyStop v-if="cmdOk && caps?.emergencyStop === true" :robot-id="robotId" :name="record?.name" @stopped="fastUntil = Date.now() + COMMAND_FOLLOW_MS" />
|
|
35
|
+
|
|
33
36
|
<div v-if="error" class="hero-notice" role="alert">{{ error }}</div>
|
|
34
37
|
<div v-else-if="live && !live.ok && live.reason" class="hero-notice" role="status">Can't read this robot right now: {{ live.reason }}</div>
|
|
35
38
|
|
|
@@ -199,6 +202,9 @@
|
|
|
199
202
|
<!-- Cleaning robots: recent jobs, alert history, every reading, parts wear, power schedule, lifts (read-only). -->
|
|
200
203
|
<RobotCleaningRecords v-if="!noInterface && record && robotType !== 'security'" :robot-id="robotId" :maker-app="makerApp" :brand="brand" />
|
|
201
204
|
|
|
205
|
+
<!-- Seven365 Super Admin: change the robot's setup in its maker's cloud (zones, schedules, settings, points). -->
|
|
206
|
+
<RobotSetupPanel v-if="superAdmin && hasSetup" :robot-id="robotId" :name="record?.name" :brand="brand" :capabilities="caps" />
|
|
207
|
+
|
|
202
208
|
<!-- What THIS robot (brand + model) can do, in our words; folded so it never pushes the camera down. -->
|
|
203
209
|
<details v-if="abilities.length" class="hero-can hero-can--fold">
|
|
204
210
|
<summary class="hero-can__title">What this robot can do</summary>
|
|
@@ -304,6 +310,8 @@ import RobotPatrolHistory from "./PatrolHistory.vue";
|
|
|
304
310
|
import RobotLiveView from "./LiveView.vue";
|
|
305
311
|
import RobotManualDrive from "./ManualDrive.vue";
|
|
306
312
|
import RobotPointPicker from "./PointPicker.vue";
|
|
313
|
+
import RobotEmergencyStop from "./EmergencyStop.vue";
|
|
314
|
+
import RobotSetupPanel from "./SetupPanel.vue";
|
|
307
315
|
import { faultPhotos } from "../../utils/robot-control";
|
|
308
316
|
import { cleanAreaChoices, cleanAreaCommand, mapLayerLegend, robotMapDefault, robotMapViews } from "../../utils/robot-maps";
|
|
309
317
|
import useRobotLive, { siteStatesFeed } from "../../composables/useRobotLive";
|
|
@@ -480,6 +488,8 @@ const abilities = computed(() =>
|
|
|
480
488
|
const makerApp = computed(() => (live.value?.ok ? live.value.profile?.makerApp : "") || `${brand.value}'s own app`);
|
|
481
489
|
|
|
482
490
|
const online = computed(() => live.value?.ok === true && live.value.online === true);
|
|
491
|
+
// Setup sections this brand + model offers (Super Admin only; the server refuses anyone else).
|
|
492
|
+
const hasSetup = computed(() => ["setupZones", "setupSchedules", "setupSettings", "setupPoints"].some((k) => caps.value?.[k] === true));
|
|
483
493
|
const commands = computed(() =>
|
|
484
494
|
visibleCommands({ capabilities: caps.value, canCommand: cmdOk.value, online: online.value, mode: statusMode.value, type: robotType.value, resumable: resumable.value }),
|
|
485
495
|
);
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<!--
|
|
3
|
+
Emergency stop (owner 2026-10-15): a big red button for anyone allowed to
|
|
4
|
+
command this robot. One tap, NO confirm - a stop must never wait on a
|
|
5
|
+
dialog or on a state read. The server sends PadBot's "full machine stop"
|
|
6
|
+
over the live link AND its stop command, and says which reached the robot.
|
|
7
|
+
-->
|
|
8
|
+
<div class="hero-estop" role="group" aria-label="Emergency stop">
|
|
9
|
+
<button type="button" class="hero-estop__btn" :disabled="busy" @click="stop">
|
|
10
|
+
<span class="hero-estop__icon" aria-hidden="true">■</span>
|
|
11
|
+
{{ busy ? "Stopping…" : "Emergency stop" }}
|
|
12
|
+
</button>
|
|
13
|
+
<div v-if="result" class="hero-estop__msg" :class="{ 'hero-estop__msg--error': failed }" role="status" aria-live="assertive">{{ result }}</div>
|
|
14
|
+
</div>
|
|
15
|
+
</template>
|
|
16
|
+
|
|
17
|
+
<script setup lang="ts">
|
|
18
|
+
import { ref } from "vue";
|
|
19
|
+
import { estopResultText } from "../../utils/robot-config";
|
|
20
|
+
|
|
21
|
+
const props = defineProps<{ robotId: string; name?: string }>();
|
|
22
|
+
const emit = defineEmits<{ stopped: [] }>();
|
|
23
|
+
const { emergencyStopRobot } = useRobots();
|
|
24
|
+
|
|
25
|
+
const busy = ref(false);
|
|
26
|
+
const result = ref("");
|
|
27
|
+
const failed = ref(false);
|
|
28
|
+
|
|
29
|
+
async function stop() {
|
|
30
|
+
// A second press while the first is on its way is not lost: the server sends the stop again.
|
|
31
|
+
busy.value = true;
|
|
32
|
+
failed.value = false;
|
|
33
|
+
result.value = "";
|
|
34
|
+
try {
|
|
35
|
+
const r = await emergencyStopRobot(props.robotId);
|
|
36
|
+
result.value = `${estopResultText(r)}${r?.holdMinutes ? ` HERO Fleet Core can't move it for ${r.holdMinutes} minutes.` : ""}`;
|
|
37
|
+
emit("stopped");
|
|
38
|
+
} catch (e: any) {
|
|
39
|
+
failed.value = true;
|
|
40
|
+
result.value = e?.data?.message || e?.message || `The emergency stop did not reach ${props.name || "the robot"}. Press the red button on the robot.`;
|
|
41
|
+
} finally {
|
|
42
|
+
busy.value = false;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
</script>
|
|
46
|
+
|
|
47
|
+
<style scoped>
|
|
48
|
+
.hero-estop {
|
|
49
|
+
display: flex;
|
|
50
|
+
flex-wrap: wrap;
|
|
51
|
+
align-items: center;
|
|
52
|
+
gap: 8px 12px;
|
|
53
|
+
margin: 8px 0;
|
|
54
|
+
}
|
|
55
|
+
.hero-estop__btn {
|
|
56
|
+
display: inline-flex;
|
|
57
|
+
align-items: center;
|
|
58
|
+
gap: 10px;
|
|
59
|
+
min-height: 52px;
|
|
60
|
+
padding: 0 24px;
|
|
61
|
+
border: 2px solid #7f1d1d;
|
|
62
|
+
border-radius: 10px;
|
|
63
|
+
background: #dc2626;
|
|
64
|
+
color: #fff;
|
|
65
|
+
font-size: 1.05rem;
|
|
66
|
+
font-weight: 700;
|
|
67
|
+
letter-spacing: 0.02em;
|
|
68
|
+
cursor: pointer;
|
|
69
|
+
}
|
|
70
|
+
.hero-estop__btn:hover:not(:disabled) {
|
|
71
|
+
background: #b91c1c;
|
|
72
|
+
}
|
|
73
|
+
.hero-estop__btn:focus-visible {
|
|
74
|
+
outline: 3px solid #fca5a5;
|
|
75
|
+
outline-offset: 2px;
|
|
76
|
+
}
|
|
77
|
+
.hero-estop__btn:disabled {
|
|
78
|
+
opacity: 0.75;
|
|
79
|
+
cursor: progress;
|
|
80
|
+
}
|
|
81
|
+
.hero-estop__icon {
|
|
82
|
+
font-size: 0.9rem;
|
|
83
|
+
}
|
|
84
|
+
.hero-estop__msg {
|
|
85
|
+
font-size: 0.9rem;
|
|
86
|
+
}
|
|
87
|
+
.hero-estop__msg--error {
|
|
88
|
+
color: #b91c1c;
|
|
89
|
+
font-weight: 600;
|
|
90
|
+
}
|
|
91
|
+
</style>
|
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<!--
|
|
3
|
+
Robot setup - Seven365 Super Admin only (owner rule 12 Oct 2026; the server
|
|
4
|
+
refuses anyone else). Everything here changes the robot in its maker's
|
|
5
|
+
cloud: one change at a time, a one-sentence confirm, sent once, then READ
|
|
6
|
+
BACK from the maker so what shows is the robot's own copy. Sections appear
|
|
7
|
+
only for what this brand + model can do (hide, never grey).
|
|
8
|
+
-->
|
|
9
|
+
<details class="hero-can hero-can--fold hero-setup" :open="open" @toggle="onToggle">
|
|
10
|
+
<summary class="hero-can__title">Robot setup · Seven365 Super Admin</summary>
|
|
11
|
+
<p class="hero-muted">Changes go straight to the robot's own cloud ({{ brandLabel }}) and are read back from it. Clients never see this section.</p>
|
|
12
|
+
|
|
13
|
+
<div v-if="loading && !setup" class="hero-sched__empty">Reading the robot's setup from {{ brandLabel }}…</div>
|
|
14
|
+
<div v-if="loadError" class="hero-notice hero-notice--error" role="alert">{{ loadError }}</div>
|
|
15
|
+
<div v-if="result" class="hero-notice" :class="{ 'hero-notice--error': result.error }" role="status" aria-live="polite">{{ result.text }}</div>
|
|
16
|
+
|
|
17
|
+
<template v-if="setup">
|
|
18
|
+
<!-- Zones: virtual walls / no-go areas, slow zones (Ounitech) -->
|
|
19
|
+
<section v-if="caps.setupZones === true" class="hero-setup__section" aria-label="Walls and zones">
|
|
20
|
+
<div class="hero-section__title">No-go areas, virtual walls and slow zones</div>
|
|
21
|
+
<RobotSetupZones :setup="setup" :busy="busy" :robot-name="name" @map="loadMap" @apply="ask" />
|
|
22
|
+
</section>
|
|
23
|
+
|
|
24
|
+
<!-- Saved target points (PadBot) -->
|
|
25
|
+
<section v-if="caps.setupPoints === true" class="hero-setup__section" aria-label="Saved points">
|
|
26
|
+
<div class="hero-section__title">Saved points (patrol and go-to points)</div>
|
|
27
|
+
<RobotSetupPoints :setup="setup" :busy="busy" @apply="ask" />
|
|
28
|
+
</section>
|
|
29
|
+
|
|
30
|
+
<!-- Cleaning schedules + timed switch on / off (Ounitech) -->
|
|
31
|
+
<section v-if="caps.setupSchedules === true" class="hero-setup__section" aria-label="Schedules">
|
|
32
|
+
<div class="hero-section__title">Cleaning schedules</div>
|
|
33
|
+
<p class="hero-muted">Times are the robot's local time (Singapore, UTC+8). Schedules run only while "Schedules switched on" is On under Settings.</p>
|
|
34
|
+
<div v-if="setup.plans?.error" class="hero-notice hero-notice--error">{{ setup.plans.error }}</div>
|
|
35
|
+
<div v-else-if="!plans.length" class="hero-sched__empty">No cleaning schedules on the robot.</div>
|
|
36
|
+
<div v-for="p in plans" :key="p.planId" class="hero-sched__row hero-setup__row">
|
|
37
|
+
<b>{{ daysText(p.days) }}</b>
|
|
38
|
+
<span>{{ p.startTime }}-{{ p.endTime }} · {{ p.times }} pass{{ p.times === 1 ? "" : "es" }} · {{ p.floors.length ? `floor ${p.floors.join(", ")}` : "floor not set" }}{{ p.wholeFloor ? "" : " · areas set in the Ounitech app" }}</span>
|
|
39
|
+
<span class="hero-setup__actions">
|
|
40
|
+
<v-btn size="small" variant="outlined" :disabled="busy" @click="ask({ action: 'plan-enable', planIds: [p.planId], enabled: !p.enabled })">{{ p.enabled ? "On · switch off" : "Off · switch on" }}</v-btn>
|
|
41
|
+
<v-btn size="small" variant="text" :disabled="busy" @click="editPlan(p)">Edit</v-btn>
|
|
42
|
+
<v-btn size="small" variant="text" :disabled="busy" @click="ask({ action: 'plan-delete', planIds: [p.planId] })">Delete</v-btn>
|
|
43
|
+
</span>
|
|
44
|
+
</div>
|
|
45
|
+
<div class="hero-setup__form">
|
|
46
|
+
<div class="hero-setup__formtitle">{{ planForm.planId ? "Edit schedule" : "Add a schedule (whole floor, vacuum)" }}</div>
|
|
47
|
+
<label v-if="!planForm.planId">Floor
|
|
48
|
+
<select v-model.number="planForm.floorNo" class="hero-input hero-select hero-select--sm">
|
|
49
|
+
<option v-for="f in setup.floors ?? []" :key="f" :value="f">{{ f }}</option>
|
|
50
|
+
</select>
|
|
51
|
+
</label>
|
|
52
|
+
<label>Start <input v-model="planForm.startTime" type="time" class="hero-input" /></label>
|
|
53
|
+
<label>End <input v-model="planForm.endTime" type="time" class="hero-input" /></label>
|
|
54
|
+
<label>Passes <input v-model.number="planForm.times" type="number" min="1" max="99" class="hero-input hero-setup__num" /></label>
|
|
55
|
+
<span class="hero-setup__days">
|
|
56
|
+
<label v-for="d in DAY_CHOICES" :key="d.value"><input v-model="planForm.days" type="checkbox" :value="d.value" /> {{ d.title }}</label>
|
|
57
|
+
</span>
|
|
58
|
+
<label><input v-model="planForm.enabled" type="checkbox" /> Switched on</label>
|
|
59
|
+
<div v-if="planProblem" class="hero-muted">{{ planProblem }}</div>
|
|
60
|
+
<span class="hero-setup__actions">
|
|
61
|
+
<v-btn size="small" color="primary" variant="flat" :disabled="busy || !!planProblem || (!planForm.planId && planForm.floorNo == null)" @click="savePlan">Save schedule</v-btn>
|
|
62
|
+
<v-btn v-if="planForm.planId" size="small" variant="text" @click="resetPlan">Cancel edit</v-btn>
|
|
63
|
+
</span>
|
|
64
|
+
</div>
|
|
65
|
+
|
|
66
|
+
<div class="hero-section__title">Timed switch on / off</div>
|
|
67
|
+
<div v-if="setup.power?.error" class="hero-notice hero-notice--error">{{ setup.power.error }}</div>
|
|
68
|
+
<div v-else-if="!power.length" class="hero-sched__empty">No timed switch on / off on the robot.</div>
|
|
69
|
+
<div v-for="p in power" :key="p.planId" class="hero-sched__row hero-setup__row">
|
|
70
|
+
<b>{{ daysText(p.days) }}</b>
|
|
71
|
+
<span>Switch {{ p.action }} at {{ p.time }}</span>
|
|
72
|
+
<span class="hero-setup__actions">
|
|
73
|
+
<v-btn size="small" variant="outlined" :disabled="busy" @click="ask({ action: 'power-enable', planIds: [p.planId], enabled: !p.enabled })">{{ p.enabled ? "On · switch off" : "Off · switch on" }}</v-btn>
|
|
74
|
+
<v-btn size="small" variant="text" :disabled="busy" @click="editPower(p)">Edit</v-btn>
|
|
75
|
+
<v-btn size="small" variant="text" :disabled="busy" @click="ask({ action: 'power-delete', planIds: [p.planId] })">Delete</v-btn>
|
|
76
|
+
</span>
|
|
77
|
+
</div>
|
|
78
|
+
<div class="hero-setup__form">
|
|
79
|
+
<div class="hero-setup__formtitle">{{ powerForm.planId ? "Edit timed switch" : "Add a timed switch" }}</div>
|
|
80
|
+
<label>Switch
|
|
81
|
+
<select v-model="powerForm.switchTo" class="hero-input hero-select hero-select--sm">
|
|
82
|
+
<option value="off">off</option>
|
|
83
|
+
<option value="on">on</option>
|
|
84
|
+
</select>
|
|
85
|
+
</label>
|
|
86
|
+
<label>at <input v-model="powerForm.time" type="time" class="hero-input" /></label>
|
|
87
|
+
<span class="hero-setup__days">
|
|
88
|
+
<label v-for="d in DAY_CHOICES" :key="d.value"><input v-model="powerForm.days" type="checkbox" :value="d.value" /> {{ d.title }}</label>
|
|
89
|
+
</span>
|
|
90
|
+
<label><input v-model="powerForm.enabled" type="checkbox" /> Switched on</label>
|
|
91
|
+
<span class="hero-setup__actions">
|
|
92
|
+
<v-btn size="small" color="primary" variant="flat" :disabled="busy || !/^\d\d:\d\d$/.test(powerForm.time) || !powerForm.days.length" @click="savePower">Save</v-btn>
|
|
93
|
+
<v-btn v-if="powerForm.planId" size="small" variant="text" @click="resetPower">Cancel edit</v-btn>
|
|
94
|
+
</span>
|
|
95
|
+
</div>
|
|
96
|
+
</section>
|
|
97
|
+
|
|
98
|
+
<!-- Names, dock, floor, settings (Ounitech) -->
|
|
99
|
+
<section v-if="caps.setupSettings === true" class="hero-setup__section" aria-label="Robot settings">
|
|
100
|
+
<div class="hero-section__title">Names, dock and floor</div>
|
|
101
|
+
<div class="hero-setup__form">
|
|
102
|
+
<label>Name in the Ounitech app <input v-model="names.robot" class="hero-input" maxlength="40" /></label>
|
|
103
|
+
<v-btn size="small" variant="outlined" :disabled="busy || !names.robot.trim() || names.robot.trim() === setup.robotName" @click="ask({ action: 'robot-name', name: names.robot.trim() })">Rename robot</v-btn>
|
|
104
|
+
</div>
|
|
105
|
+
<div class="hero-setup__form">
|
|
106
|
+
<label>Ounitech site name <input v-model="names.scene" class="hero-input" maxlength="40" /></label>
|
|
107
|
+
<v-btn size="small" variant="outlined" :disabled="busy || !names.scene.trim() || names.scene.trim() === setup.sceneName" @click="ask({ action: 'scene-name', name: names.scene.trim() })">Rename site</v-btn>
|
|
108
|
+
</div>
|
|
109
|
+
<div v-if="currentMap" class="hero-setup__form">
|
|
110
|
+
<label>Name of map shown ({{ currentMap.name }}) <input v-model="names.map" class="hero-input" maxlength="40" /></label>
|
|
111
|
+
<v-btn size="small" variant="outlined" :disabled="busy || !names.map.trim() || names.map.trim() === currentMap.name" @click="ask({ action: 'map-name', mapId: currentMap.mapId, name: names.map.trim() }, { map: currentMap.name })">Rename map</v-btn>
|
|
112
|
+
</div>
|
|
113
|
+
<div class="hero-setup__form">
|
|
114
|
+
<span>Dock: {{ setup.docked ? "the robot is on its dock and charging now." : "push the robot onto its dock (it must show Charging) to save the dock from here." }}</span>
|
|
115
|
+
<v-btn size="small" variant="outlined" :disabled="busy || !setup.docked" @click="ask({ action: 'dock-here' })">Save dock where the robot is</v-btn>
|
|
116
|
+
</div>
|
|
117
|
+
<div v-if="(setup.floors ?? []).length > 1" class="hero-setup__form">
|
|
118
|
+
<span>Floor now: {{ setup.floorNo ?? "not reported" }}. Switch only after the robot has been taken to the other floor.</span>
|
|
119
|
+
<select v-model.number="floorTo" class="hero-input hero-select hero-select--sm" aria-label="Floor">
|
|
120
|
+
<option v-for="f in setup.floors" :key="f" :value="f">{{ f }}</option>
|
|
121
|
+
</select>
|
|
122
|
+
<v-btn size="small" variant="outlined" :disabled="busy || setup.working || floorTo == null || floorTo === setup.floorNo" @click="ask({ action: 'switch-floor', floorNo: floorTo })">Switch floor</v-btn>
|
|
123
|
+
</div>
|
|
124
|
+
|
|
125
|
+
<div class="hero-section__title">Settings</div>
|
|
126
|
+
<p class="hero-muted">Only settings we can name are listed. Safety sensors, the time zone and Wi-Fi stay in the Ounitech app. Speeds can only be lowered here.</p>
|
|
127
|
+
<div v-if="setup.settings?.error" class="hero-notice hero-notice--error">{{ setup.settings.error }}</div>
|
|
128
|
+
<div v-for="s in settings" :key="s.key" class="hero-sched__row hero-setup__row">
|
|
129
|
+
<b>{{ s.label }}</b>
|
|
130
|
+
<span v-if="s.kind === 'bool'"><label><input v-model="edited[s.key]" type="checkbox" /> {{ edited[s.key] ? "On" : "Off" }}</label></span>
|
|
131
|
+
<span v-else>
|
|
132
|
+
<input v-model.number="edited[s.key]" type="number" :min="s.min" :max="s.max" :step="s.kind === 'int' ? 1 : 0.05" class="hero-input hero-setup__num" :aria-label="s.label" />
|
|
133
|
+
{{ s.unit }} <span class="hero-muted">({{ s.min }}-{{ s.max }})</span>
|
|
134
|
+
</span>
|
|
135
|
+
</div>
|
|
136
|
+
<v-btn v-if="settings.length" size="small" color="primary" variant="flat" :disabled="busy || !Object.keys(changedSettings).length" @click="ask({ action: 'settings', values: changedSettings })">Save settings</v-btn>
|
|
137
|
+
</section>
|
|
138
|
+
</template>
|
|
139
|
+
|
|
140
|
+
<v-dialog v-model="confirmOpen" width="460">
|
|
141
|
+
<div v-if="confirming" class="hero-dialog" role="alertdialog" aria-labelledby="hero-setup-confirm">
|
|
142
|
+
<div id="hero-setup-confirm" class="hero-dialog__title">{{ confirming.text.title }}</div>
|
|
143
|
+
<div class="hero-dialog__body">{{ confirming.text.body }}</div>
|
|
144
|
+
<div class="hero-dialog__actions">
|
|
145
|
+
<v-btn variant="outlined" @click="confirmOpen = false">Don't change</v-btn>
|
|
146
|
+
<v-btn color="primary" variant="flat" :loading="busy" @click="send">{{ confirming.text.action }}</v-btn>
|
|
147
|
+
</div>
|
|
148
|
+
</div>
|
|
149
|
+
</v-dialog>
|
|
150
|
+
</details>
|
|
151
|
+
</template>
|
|
152
|
+
|
|
153
|
+
<script setup lang="ts">
|
|
154
|
+
import { computed, reactive, ref, watch } from "vue";
|
|
155
|
+
import RobotSetupZones from "./SetupZones.vue";
|
|
156
|
+
import RobotSetupPoints from "./SetupPoints.vue";
|
|
157
|
+
import { DAY_CHOICES, daysText, scheduleProblem, settingsDiff, setupConfirmText } from "../../utils/robot-config";
|
|
158
|
+
|
|
159
|
+
const props = defineProps<{ robotId: string; name?: string; brand?: string; capabilities?: Record<string, unknown> | null }>();
|
|
160
|
+
const emit = defineEmits<{ changed: [] }>();
|
|
161
|
+
const { getRobotSetup, applyRobotSetup } = useRobots();
|
|
162
|
+
|
|
163
|
+
const caps = computed(() => props.capabilities ?? {});
|
|
164
|
+
const brandLabel = computed(() => props.brand || "the maker");
|
|
165
|
+
const open = ref(false);
|
|
166
|
+
const setup = ref<Record<string, any> | null>(null);
|
|
167
|
+
const loading = ref(false);
|
|
168
|
+
const loadError = ref("");
|
|
169
|
+
const busy = ref(false);
|
|
170
|
+
const result = ref<{ text: string; error: boolean } | null>(null);
|
|
171
|
+
const mapId = ref<string | null>(null);
|
|
172
|
+
|
|
173
|
+
const errText = (e: any, fallback: string) => e?.data?.message || e?.message || fallback;
|
|
174
|
+
|
|
175
|
+
async function load() {
|
|
176
|
+
loading.value = true;
|
|
177
|
+
loadError.value = "";
|
|
178
|
+
try {
|
|
179
|
+
setup.value = await getRobotSetup(props.robotId, mapId.value);
|
|
180
|
+
mapId.value = setup.value?.mapId ?? setup.value?.currentMapId ?? mapId.value;
|
|
181
|
+
} catch (e: any) {
|
|
182
|
+
loadError.value = errText(e, "Couldn't read the robot's setup from its cloud.");
|
|
183
|
+
} finally {
|
|
184
|
+
loading.value = false;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
function onToggle(e: Event) {
|
|
188
|
+
open.value = (e.target as HTMLDetailsElement).open;
|
|
189
|
+
if (open.value && !setup.value && !loading.value) void load();
|
|
190
|
+
}
|
|
191
|
+
function loadMap(id: string) {
|
|
192
|
+
mapId.value = id;
|
|
193
|
+
void load();
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/* ---- confirm -> send once -> read back ---- */
|
|
197
|
+
const confirmOpen = ref(false);
|
|
198
|
+
const confirming = ref<{ change: Record<string, any>; text: ReturnType<typeof setupConfirmText> } | null>(null);
|
|
199
|
+
const currentMap = computed(() => (setup.value?.maps ?? []).find((m: any) => m.mapId === (setup.value?.mapId ?? mapId.value)) ?? null);
|
|
200
|
+
|
|
201
|
+
function ask(change: Record<string, any>, ctx: { map?: string } = {}) {
|
|
202
|
+
confirming.value = { change, text: setupConfirmText(change, { robot: props.name, map: ctx.map ?? currentMap.value?.name }) };
|
|
203
|
+
confirmOpen.value = true;
|
|
204
|
+
}
|
|
205
|
+
async function send() {
|
|
206
|
+
if (!confirming.value || busy.value) return;
|
|
207
|
+
busy.value = true;
|
|
208
|
+
result.value = null;
|
|
209
|
+
try {
|
|
210
|
+
const r = await applyRobotSetup(props.robotId, confirming.value.change);
|
|
211
|
+
result.value = { text: `${r.summary}. Read back from the robot's cloud.`, error: false };
|
|
212
|
+
confirmOpen.value = false;
|
|
213
|
+
resetPlan();
|
|
214
|
+
resetPower();
|
|
215
|
+
await load();
|
|
216
|
+
emit("changed");
|
|
217
|
+
} catch (e: any) {
|
|
218
|
+
result.value = { text: `Not changed: ${errText(e, "the robot's cloud refused it.")}`, error: true };
|
|
219
|
+
confirmOpen.value = false;
|
|
220
|
+
} finally {
|
|
221
|
+
busy.value = false;
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/* ---- schedules ---- */
|
|
226
|
+
const plans = computed<any[]>(() => setup.value?.plans?.value ?? []);
|
|
227
|
+
const power = computed<any[]>(() => setup.value?.power?.value ?? []);
|
|
228
|
+
const blankPlan = () => ({ planId: 0, floorNo: (setup.value?.floorNo ?? setup.value?.floors?.[0] ?? null) as number | null, startTime: "08:00", endTime: "10:00", days: [1, 2, 3, 4, 5] as number[], times: 1, enabled: true });
|
|
229
|
+
const planForm = reactive(blankPlan());
|
|
230
|
+
const planProblem = computed(() => scheduleProblem(planForm.startTime, planForm.endTime, planForm.days));
|
|
231
|
+
function resetPlan() {
|
|
232
|
+
Object.assign(planForm, blankPlan());
|
|
233
|
+
}
|
|
234
|
+
function editPlan(p: any) {
|
|
235
|
+
Object.assign(planForm, { planId: p.planId, floorNo: p.floors?.[0] ?? null, startTime: p.startTime, endTime: p.endTime, days: [...p.days], times: p.times, enabled: p.enabled });
|
|
236
|
+
}
|
|
237
|
+
function savePlan() {
|
|
238
|
+
const { planId, floorNo, ...rest } = planForm;
|
|
239
|
+
ask({ action: "plan-save", ...(planId ? { planId } : { floorNo }), ...rest, days: [...rest.days] });
|
|
240
|
+
}
|
|
241
|
+
const blankPower = () => ({ planId: 0, switchTo: "off" as "on" | "off", time: "20:00", days: [1, 2, 3, 4, 5] as number[], enabled: true });
|
|
242
|
+
const powerForm = reactive(blankPower());
|
|
243
|
+
function resetPower() {
|
|
244
|
+
Object.assign(powerForm, blankPower());
|
|
245
|
+
}
|
|
246
|
+
function editPower(p: any) {
|
|
247
|
+
Object.assign(powerForm, { planId: p.planId, switchTo: p.action, time: p.time, days: [...p.days], enabled: p.enabled });
|
|
248
|
+
}
|
|
249
|
+
function savePower() {
|
|
250
|
+
const { planId, ...rest } = powerForm;
|
|
251
|
+
ask({ action: "power-save", ...(planId ? { planId } : {}), ...rest, days: [...rest.days] });
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/* ---- names, floor, settings ---- */
|
|
255
|
+
const names = reactive({ robot: "", scene: "", map: "" });
|
|
256
|
+
const floorTo = ref<number | null>(null);
|
|
257
|
+
const settings = computed<any[]>(() => setup.value?.settings?.value ?? []);
|
|
258
|
+
const edited = reactive<Record<string, any>>({});
|
|
259
|
+
watch(setup, (s) => {
|
|
260
|
+
names.robot = s?.robotName ?? "";
|
|
261
|
+
names.scene = s?.sceneName ?? "";
|
|
262
|
+
names.map = currentMap.value?.name ?? "";
|
|
263
|
+
floorTo.value = s?.floorNo ?? null;
|
|
264
|
+
for (const k of Object.keys(edited)) delete edited[k];
|
|
265
|
+
for (const x of s?.settings?.value ?? []) edited[x.key] = x.value;
|
|
266
|
+
if (!planForm.planId && planForm.floorNo == null) planForm.floorNo = s?.floorNo ?? s?.floors?.[0] ?? null;
|
|
267
|
+
});
|
|
268
|
+
const changedSettings = computed(() => settingsDiff(settings.value, edited));
|
|
269
|
+
</script>
|
|
270
|
+
|
|
271
|
+
<style>
|
|
272
|
+
.hero-setup__section {
|
|
273
|
+
margin-top: 16px;
|
|
274
|
+
}
|
|
275
|
+
.hero-setup__row {
|
|
276
|
+
align-items: center;
|
|
277
|
+
}
|
|
278
|
+
.hero-setup__actions {
|
|
279
|
+
display: inline-flex;
|
|
280
|
+
flex-wrap: wrap;
|
|
281
|
+
gap: 4px;
|
|
282
|
+
}
|
|
283
|
+
.hero-setup__form {
|
|
284
|
+
display: flex;
|
|
285
|
+
flex-wrap: wrap;
|
|
286
|
+
align-items: center;
|
|
287
|
+
gap: 8px 12px;
|
|
288
|
+
margin: 8px 0;
|
|
289
|
+
}
|
|
290
|
+
.hero-setup__formtitle {
|
|
291
|
+
width: 100%;
|
|
292
|
+
font-weight: 600;
|
|
293
|
+
}
|
|
294
|
+
.hero-setup__days {
|
|
295
|
+
display: inline-flex;
|
|
296
|
+
flex-wrap: wrap;
|
|
297
|
+
gap: 6px;
|
|
298
|
+
}
|
|
299
|
+
.hero-setup__num {
|
|
300
|
+
width: 90px;
|
|
301
|
+
}
|
|
302
|
+
</style>
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<!--
|
|
3
|
+
PadBot saved target points (§5.2-5.4) without the PadBot app: add one where
|
|
4
|
+
the robot stands now (the surest way) or where you click on the map, move,
|
|
5
|
+
rename, delete. The charging point is locked (set in the PadBot app). Every
|
|
6
|
+
change is confirmed, sent once and read back from PadBot.
|
|
7
|
+
-->
|
|
8
|
+
<div>
|
|
9
|
+
<div v-if="(setup.maps ?? []).length > 1" class="hero-mapsel">
|
|
10
|
+
<select v-model="mapId" class="hero-input hero-select hero-select--sm" aria-label="Robot map">
|
|
11
|
+
<option v-for="m in setup.maps" :key="m.mapId" :value="m.mapId">{{ m.name }}{{ m.mapId === setup.currentMapId ? " · Robot is here" : "" }}</option>
|
|
12
|
+
</select>
|
|
13
|
+
</div>
|
|
14
|
+
<div v-if="!map" class="hero-sched__empty">PadBot sent no maps for this robot.</div>
|
|
15
|
+
<template v-else>
|
|
16
|
+
<RobotLivePlan
|
|
17
|
+
v-if="map.imageUrl"
|
|
18
|
+
:image="map.imageUrl"
|
|
19
|
+
:aspect="map.width && map.height ? map.width / map.height : null"
|
|
20
|
+
:points="placed"
|
|
21
|
+
:spot="picked ? [picked] : []"
|
|
22
|
+
:pick="picking !== null"
|
|
23
|
+
@pick="(p) => (picked = p)"
|
|
24
|
+
/>
|
|
25
|
+
<div v-if="picking" class="hero-spotbar" role="group" aria-label="Pick a spot">
|
|
26
|
+
<span>{{ picking === "add" ? "Click the map where the new point goes." : `Click the map where "${moving?.name}" goes.` }}</span>
|
|
27
|
+
<v-btn size="small" color="primary" variant="flat" :disabled="!picked || busy" @click="donePick">Use this spot</v-btn>
|
|
28
|
+
<v-btn size="small" variant="text" @click="stopPick">Cancel</v-btn>
|
|
29
|
+
</div>
|
|
30
|
+
|
|
31
|
+
<div v-if="!map.points.length" class="hero-sched__empty">No saved points on this map.</div>
|
|
32
|
+
<div v-for="p in map.points" :key="p.targetPointId" class="hero-sched__row">
|
|
33
|
+
<template v-if="p.locked">
|
|
34
|
+
<b>{{ p.name }}</b><span>Charging point · set in the PadBot app</span>
|
|
35
|
+
</template>
|
|
36
|
+
<template v-else>
|
|
37
|
+
<input v-model="names[p.targetPointId]" class="hero-input" maxlength="40" :aria-label="`Name of ${p.name}`" />
|
|
38
|
+
<span>{{ where(p) }}</span>
|
|
39
|
+
<span class="hero-setup__actions">
|
|
40
|
+
<v-btn size="small" variant="text" :disabled="busy || !names[p.targetPointId]?.trim() || names[p.targetPointId]?.trim() === p.name" @click="emit('apply', { action: 'point-update', mapId: map.mapId, targetPointId: p.targetPointId, name: names[p.targetPointId]!.trim() })">Rename</v-btn>
|
|
41
|
+
<v-btn size="small" variant="text" :disabled="busy || !map.imageUrl" @click="startPick('move', p)">Move</v-btn>
|
|
42
|
+
<v-btn size="small" variant="text" :disabled="busy || map.mapId !== setup.currentMapId" :title="map.mapId !== setup.currentMapId ? 'The robot is on another map' : ''" @click="emit('apply', { action: 'point-update', mapId: map.mapId, targetPointId: p.targetPointId, at: 'robot' })">Move to robot</v-btn>
|
|
43
|
+
<v-btn size="small" variant="text" :disabled="busy" @click="emit('apply', { action: 'point-delete', mapId: map.mapId, targetPointId: p.targetPointId })">Delete</v-btn>
|
|
44
|
+
</span>
|
|
45
|
+
</template>
|
|
46
|
+
</div>
|
|
47
|
+
|
|
48
|
+
<div class="hero-setup__form">
|
|
49
|
+
<div class="hero-setup__formtitle">Add a point</div>
|
|
50
|
+
<input v-model="newName" class="hero-input" maxlength="40" placeholder="Point name (e.g. P5 Lobby)" aria-label="New point name" />
|
|
51
|
+
<label>Facing (degrees, optional) <input v-model="newYaw" type="number" min="-180" max="180" class="hero-input hero-setup__num" /></label>
|
|
52
|
+
<v-btn size="small" color="primary" variant="flat" :disabled="busy || !newName.trim() || map.mapId !== setup.currentMapId" @click="add('robot')">Add where the robot is now</v-btn>
|
|
53
|
+
<v-btn size="small" variant="outlined" :disabled="busy || !newName.trim() || !map.imageUrl" @click="startPick('add')">Pick on the map</v-btn>
|
|
54
|
+
</div>
|
|
55
|
+
</template>
|
|
56
|
+
</div>
|
|
57
|
+
</template>
|
|
58
|
+
|
|
59
|
+
<script setup lang="ts">
|
|
60
|
+
import { computed, reactive, ref, watch } from "vue";
|
|
61
|
+
import RobotLivePlan from "./LivePlan.vue";
|
|
62
|
+
import type { TShare } from "../../utils/robot-config";
|
|
63
|
+
|
|
64
|
+
const props = defineProps<{ setup: Record<string, any>; busy?: boolean }>();
|
|
65
|
+
const emit = defineEmits<{ apply: [change: Record<string, any>, ctx?: { map?: string }] }>();
|
|
66
|
+
|
|
67
|
+
const mapId = ref<string | null>(null);
|
|
68
|
+
watch(() => props.setup, (s) => {
|
|
69
|
+
if (!mapId.value || !(s.maps ?? []).some((m: any) => m.mapId === mapId.value)) mapId.value = s.currentMapId ?? s.maps?.[0]?.mapId ?? null;
|
|
70
|
+
for (const k of Object.keys(names)) delete names[k];
|
|
71
|
+
for (const m of s.maps ?? []) for (const p of m.points ?? []) names[p.targetPointId] = p.name;
|
|
72
|
+
newName.value = "";
|
|
73
|
+
stopPick();
|
|
74
|
+
}, { immediate: true });
|
|
75
|
+
const map = computed(() => (props.setup.maps ?? []).find((m: any) => m.mapId === mapId.value) ?? null);
|
|
76
|
+
const placed = computed(() => (map.value?.points ?? []).filter((p: any) => p.at).map((p: any) => ({ targetPointId: p.targetPointId, name: p.name, x: p.at.x, y: p.at.y })));
|
|
77
|
+
const names = reactive<Record<string, string>>({});
|
|
78
|
+
const newName = ref("");
|
|
79
|
+
const newYaw = ref<string | number>("");
|
|
80
|
+
const where = (p: any) => (p.worldX != null && p.worldY != null ? `x ${p.worldX.toFixed(2)} m · y ${p.worldY.toFixed(2)} m${p.yaw != null ? ` · ${p.yaw}°` : ""}` : "position not given");
|
|
81
|
+
|
|
82
|
+
const picking = ref<"add" | "move" | null>(null);
|
|
83
|
+
const moving = ref<any>(null);
|
|
84
|
+
const picked = ref<TShare | null>(null);
|
|
85
|
+
function startPick(kind: "add" | "move", p: any = null) {
|
|
86
|
+
picking.value = kind;
|
|
87
|
+
moving.value = p;
|
|
88
|
+
picked.value = null;
|
|
89
|
+
}
|
|
90
|
+
function stopPick() {
|
|
91
|
+
picking.value = null;
|
|
92
|
+
moving.value = null;
|
|
93
|
+
picked.value = null;
|
|
94
|
+
}
|
|
95
|
+
const yaw = () => {
|
|
96
|
+
const n = Number(newYaw.value);
|
|
97
|
+
return newYaw.value === "" || !Number.isFinite(n) ? {} : { yaw: Math.max(-180, Math.min(180, Math.round(n))) };
|
|
98
|
+
};
|
|
99
|
+
function add(at: "robot" | TShare) {
|
|
100
|
+
if (!map.value) return;
|
|
101
|
+
emit("apply", { action: "point-add", mapId: map.value.mapId, name: newName.value.trim(), at, ...yaw() }, { map: map.value.name });
|
|
102
|
+
}
|
|
103
|
+
function donePick() {
|
|
104
|
+
if (!picked.value || !map.value) return;
|
|
105
|
+
const at = { x: Math.round(picked.value.x * 100) / 100, y: Math.round(picked.value.y * 100) / 100 };
|
|
106
|
+
if (picking.value === "add") add(at);
|
|
107
|
+
else if (moving.value) emit("apply", { action: "point-update", mapId: map.value.mapId, targetPointId: moving.value.targetPointId, at }, { map: map.value.name });
|
|
108
|
+
}
|
|
109
|
+
</script>
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<!--
|
|
3
|
+
Draw on the robot's real map picture: a no-go area (a closed virtual wall -
|
|
4
|
+
the Ounitech manual's way to keep the robot out of a place), a wall line,
|
|
5
|
+
or a slow zone. Click to add corners. Saved per kind as the robot's FULL
|
|
6
|
+
list (its save replaces the list), refused if the list changed in the
|
|
7
|
+
Ounitech app meanwhile, then read back. Safe zones are shown, not edited:
|
|
8
|
+
inside one the robot IGNORES obstacles from its mapping run, so a wrong one
|
|
9
|
+
is a collision risk - they stay with the Ounitech engineer.
|
|
10
|
+
-->
|
|
11
|
+
<div>
|
|
12
|
+
<div v-if="(setup.maps ?? []).length > 1" class="hero-mapsel">
|
|
13
|
+
<select :value="map?.mapId" class="hero-input hero-select hero-select--sm" aria-label="Robot map" :disabled="dirty" @change="emit('map', ($event.target as HTMLSelectElement).value)">
|
|
14
|
+
<option v-for="m in setup.maps" :key="m.mapId" :value="m.mapId">{{ m.name }}{{ m.floorNo != null ? ` · floor ${m.floorNo}` : "" }}{{ m.current ? " · Robot is here" : "" }}</option>
|
|
15
|
+
</select>
|
|
16
|
+
<span v-if="dirty" class="hero-muted">Save or discard first to change map.</span>
|
|
17
|
+
</div>
|
|
18
|
+
<div v-for="e in errors" :key="e" class="hero-notice hero-notice--error">{{ e }}</div>
|
|
19
|
+
<div v-if="!map?.imageUrl" class="hero-sched__empty">Ounitech sent no picture for this map, so nothing can be drawn on it.</div>
|
|
20
|
+
<template v-else>
|
|
21
|
+
<RobotLivePlan
|
|
22
|
+
:name="robotName"
|
|
23
|
+
:image="map.imageUrl"
|
|
24
|
+
:aspect="map.width && map.height ? map.width / map.height : null"
|
|
25
|
+
:zones="drawn"
|
|
26
|
+
:spot="corners"
|
|
27
|
+
:pick="drawing !== null"
|
|
28
|
+
@pick="(p) => corners.push(p)"
|
|
29
|
+
/>
|
|
30
|
+
<p class="hero-muted">Red lines = virtual walls and no-go areas · amber = slow zones · green = safe zones (view only). Positions use the robot's own map frame; check a drawing lines up with the picture's walls before saving.</p>
|
|
31
|
+
|
|
32
|
+
<div v-if="drawing" class="hero-spotbar" role="group" aria-label="Drawing">
|
|
33
|
+
<span>{{ DRAW_WORDS[drawing.kind] }}: click the map to add corners ({{ corners.length }} so far).</span>
|
|
34
|
+
<input v-model="drawing.name" class="hero-input" maxlength="40" placeholder="Name" aria-label="Name" />
|
|
35
|
+
<v-btn size="small" color="primary" variant="flat" :disabled="corners.length < minCorners || !drawing.name.trim()" @click="finish">Done</v-btn>
|
|
36
|
+
<v-btn size="small" variant="outlined" :disabled="!corners.length" @click="corners.pop()">Undo corner</v-btn>
|
|
37
|
+
<v-btn size="small" variant="text" @click="cancelDraw">Cancel</v-btn>
|
|
38
|
+
</div>
|
|
39
|
+
<div v-else class="hero-setup__form">
|
|
40
|
+
<v-btn size="small" variant="outlined" :disabled="busy" @click="startDraw('nogo')">Draw no-go area</v-btn>
|
|
41
|
+
<v-btn size="small" variant="outlined" :disabled="busy" @click="startDraw('line')">Draw wall line</v-btn>
|
|
42
|
+
<v-btn size="small" variant="outlined" :disabled="busy" @click="startDraw('slow')">Draw slow zone</v-btn>
|
|
43
|
+
<v-btn size="small" variant="text" :disabled="busy || dirty" @click="emit('apply', { action: 'auto-walls' })">Draw walls automatically</v-btn>
|
|
44
|
+
</div>
|
|
45
|
+
|
|
46
|
+
<div class="hero-setup__formtitle">Virtual walls and no-go areas</div>
|
|
47
|
+
<div v-if="!walls.length" class="hero-sched__empty">None on this map.</div>
|
|
48
|
+
<div v-for="(w, i) in walls" :key="`w${i}`" class="hero-sched__row">
|
|
49
|
+
<input v-model="w.name" class="hero-input" maxlength="40" :aria-label="`Name of wall ${i + 1}`" @input="wallsDirty = true" />
|
|
50
|
+
<span>{{ w.closed ? "No-go area" : "Wall line" }}{{ w.points ? " · redrawn" : "" }}{{ w.id ? "" : " · new" }}</span>
|
|
51
|
+
<span class="hero-setup__actions">
|
|
52
|
+
<v-btn size="small" variant="text" :disabled="busy || !!drawing" @click="startDraw(w.closed ? 'nogo' : 'line', w)">Redraw</v-btn>
|
|
53
|
+
<v-btn size="small" variant="text" :disabled="busy" @click="walls.splice(i, 1); wallsDirty = true">Remove</v-btn>
|
|
54
|
+
</span>
|
|
55
|
+
</div>
|
|
56
|
+
<div class="hero-setup__form">
|
|
57
|
+
<v-btn size="small" color="primary" variant="flat" :disabled="busy || !wallsDirty || !!drawing" @click="save('wall')">Save walls to robot</v-btn>
|
|
58
|
+
</div>
|
|
59
|
+
|
|
60
|
+
<div class="hero-setup__formtitle">Slow zones</div>
|
|
61
|
+
<div v-if="!slow.length" class="hero-sched__empty">None on this map.</div>
|
|
62
|
+
<div v-for="(z, i) in slow" :key="`s${i}`" class="hero-sched__row">
|
|
63
|
+
<input v-model="z.name" class="hero-input" maxlength="40" :aria-label="`Name of slow zone ${i + 1}`" @input="slowDirty = true" />
|
|
64
|
+
<span>Slow zone{{ z.points ? " · redrawn" : "" }}{{ z.id ? "" : " · new" }}</span>
|
|
65
|
+
<span class="hero-setup__actions">
|
|
66
|
+
<v-btn size="small" variant="text" :disabled="busy || !!drawing" @click="startDraw('slow', z)">Redraw</v-btn>
|
|
67
|
+
<v-btn size="small" variant="text" :disabled="busy" @click="slow.splice(i, 1); slowDirty = true">Remove</v-btn>
|
|
68
|
+
</span>
|
|
69
|
+
</div>
|
|
70
|
+
<div class="hero-setup__form">
|
|
71
|
+
<v-btn size="small" color="primary" variant="flat" :disabled="busy || !slowDirty || !!drawing" @click="save('slow')">Save slow zones to robot</v-btn>
|
|
72
|
+
<v-btn v-if="dirty" size="small" variant="text" :disabled="busy" @click="reset">Discard changes</v-btn>
|
|
73
|
+
</div>
|
|
74
|
+
|
|
75
|
+
<div class="hero-setup__formtitle">Safe zones (view only)</div>
|
|
76
|
+
<p class="hero-muted">Inside a safe zone the robot ignores obstacles recorded while mapping. They are set by the Ounitech engineer in the Ounitech app.</p>
|
|
77
|
+
<div v-for="(z, i) in safe" :key="`z${i}`" class="hero-sched__row"><span>{{ z.name || `Safe zone ${i + 1}` }}</span></div>
|
|
78
|
+
</template>
|
|
79
|
+
</div>
|
|
80
|
+
</template>
|
|
81
|
+
|
|
82
|
+
<script setup lang="ts">
|
|
83
|
+
import { computed, ref, watch } from "vue";
|
|
84
|
+
import RobotLivePlan from "./LivePlan.vue";
|
|
85
|
+
import { draftsFrom, zonesChange, type TDraftShape, type TSetupShape, type TShare } from "../../utils/robot-config";
|
|
86
|
+
|
|
87
|
+
const props = defineProps<{ setup: Record<string, any>; busy?: boolean; robotName?: string }>();
|
|
88
|
+
const emit = defineEmits<{ map: [mapId: string]; apply: [change: Record<string, any>, ctx?: { map?: string }] }>();
|
|
89
|
+
|
|
90
|
+
const DRAW_WORDS = { nogo: "No-go area", line: "Wall line", slow: "Slow zone" } as const;
|
|
91
|
+
type TKind = keyof typeof DRAW_WORDS;
|
|
92
|
+
|
|
93
|
+
const map = computed(() => (props.setup.maps ?? []).find((m: any) => m.mapId === props.setup.mapId) ?? null);
|
|
94
|
+
const loadedWalls = computed<TSetupShape[]>(() => props.setup.zones?.walls?.value ?? []);
|
|
95
|
+
const loadedSlow = computed<TSetupShape[]>(() => props.setup.zones?.slow?.value ?? []);
|
|
96
|
+
const safe = computed<TSetupShape[]>(() => props.setup.zones?.safe?.value ?? []);
|
|
97
|
+
const errors = computed(() =>
|
|
98
|
+
(["walls", "slow", "safe"] as const).map((k) => props.setup.zones?.[k]?.error).filter((e): e is string => !!e),
|
|
99
|
+
);
|
|
100
|
+
|
|
101
|
+
const walls = ref<TDraftShape[]>([]);
|
|
102
|
+
const slow = ref<TDraftShape[]>([]);
|
|
103
|
+
const wallsDirty = ref(false);
|
|
104
|
+
const slowDirty = ref(false);
|
|
105
|
+
const dirty = computed(() => wallsDirty.value || slowDirty.value);
|
|
106
|
+
function reset() {
|
|
107
|
+
walls.value = draftsFrom(loadedWalls.value);
|
|
108
|
+
slow.value = draftsFrom(loadedSlow.value);
|
|
109
|
+
wallsDirty.value = slowDirty.value = false;
|
|
110
|
+
cancelDraw();
|
|
111
|
+
}
|
|
112
|
+
watch(() => props.setup, reset, { immediate: true });
|
|
113
|
+
|
|
114
|
+
/* ---- drawing ---- */
|
|
115
|
+
const drawing = ref<{ kind: TKind; name: string; target: TDraftShape | null } | null>(null);
|
|
116
|
+
const corners = ref<TShare[]>([]);
|
|
117
|
+
const minCorners = computed(() => (drawing.value?.kind === "line" ? 2 : 3));
|
|
118
|
+
function startDraw(kind: TKind, target: TDraftShape | null = null) {
|
|
119
|
+
drawing.value = { kind, name: target?.name ?? "", target };
|
|
120
|
+
corners.value = [];
|
|
121
|
+
}
|
|
122
|
+
function cancelDraw() {
|
|
123
|
+
drawing.value = null;
|
|
124
|
+
corners.value = [];
|
|
125
|
+
}
|
|
126
|
+
function finish() {
|
|
127
|
+
const d = drawing.value;
|
|
128
|
+
if (!d) return;
|
|
129
|
+
const pts = corners.value.slice();
|
|
130
|
+
const shape: TDraftShape = { ...(d.target?.id ? { id: d.target.id } : {}), name: d.name.trim(), closed: d.kind !== "line", points: pts, shown: pts };
|
|
131
|
+
const list = d.kind === "slow" ? slow : walls;
|
|
132
|
+
const at = d.target ? list.value.indexOf(d.target) : -1;
|
|
133
|
+
if (at >= 0) list.value.splice(at, 1, shape);
|
|
134
|
+
else list.value.push(shape);
|
|
135
|
+
if (d.kind === "slow") slowDirty.value = true;
|
|
136
|
+
else wallsDirty.value = true;
|
|
137
|
+
cancelDraw();
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/* What the plan draws: the editor's walls / slow zones (closed ones closed) and the read-only safe zones. */
|
|
141
|
+
const close = (pts: TShare[], closed: boolean) => (closed && pts.length > 2 ? [...pts, pts[0]!] : pts);
|
|
142
|
+
const drawn = computed(() => [
|
|
143
|
+
...walls.value.map((w) => ({ kind: "wall" as const, name: w.name, points: close(w.shown, w.closed) })),
|
|
144
|
+
...slow.value.map((z) => ({ kind: "slow" as const, name: z.name, points: z.shown })),
|
|
145
|
+
...safe.value.map((z) => ({ kind: "safe" as const, name: z.name, points: z.points })),
|
|
146
|
+
]);
|
|
147
|
+
|
|
148
|
+
function save(kind: "wall" | "slow") {
|
|
149
|
+
if (!map.value) return;
|
|
150
|
+
emit("apply", zonesChange(map.value.mapId, kind, kind === "wall" ? loadedWalls.value : loadedSlow.value, kind === "wall" ? walls.value : slow.value), { map: map.value.name });
|
|
151
|
+
}
|
|
152
|
+
</script>
|
package/composables/useRobots.ts
CHANGED
|
@@ -347,6 +347,34 @@ export default function useRobots() {
|
|
|
347
347
|
});
|
|
348
348
|
}
|
|
349
349
|
|
|
350
|
+
/**
|
|
351
|
+
* `emergencyStop` (PadBot): stop everything now - one tap, no confirm. The
|
|
352
|
+
* answer says which way it reached the robot (live link and / or PadBot's cloud).
|
|
353
|
+
*/
|
|
354
|
+
function emergencyStopRobot(id: string) {
|
|
355
|
+
return useNuxtApp().$api<{ ok: boolean; live: boolean; rest: boolean; message: string; holdMinutes?: number }>(
|
|
356
|
+
`/api/robots/${id}/emergency-stop`,
|
|
357
|
+
{ method: "POST", query: { appSlug }, body: {} },
|
|
358
|
+
);
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/** Super Admin setup: what can be changed now, read fresh from the maker's cloud (`mapId` picks the map). */
|
|
362
|
+
function getRobotSetup(id: string, mapId?: string | null) {
|
|
363
|
+
return useNuxtApp().$api<Record<string, any>>(`/api/robots/${id}/setup`, {
|
|
364
|
+
method: "GET",
|
|
365
|
+
query: { appSlug, ...(mapId ? { mapId } : {}) },
|
|
366
|
+
});
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
/** Super Admin setup: ONE change (`{ action, ... }`); answers the maker's read-back. 400 carries the plain reason. */
|
|
370
|
+
function applyRobotSetup(id: string, change: Record<string, unknown>) {
|
|
371
|
+
return useNuxtApp().$api<{ ok: boolean; summary: string; readBack: unknown }>(`/api/robots/${id}/setup`, {
|
|
372
|
+
method: "POST",
|
|
373
|
+
query: { appSlug },
|
|
374
|
+
body: change,
|
|
375
|
+
});
|
|
376
|
+
}
|
|
377
|
+
|
|
350
378
|
/** `patrolHistory`: past patrols, newest first. */
|
|
351
379
|
function getPatrolHistory(id: string, { page = 1, limit = 10 } = {}) {
|
|
352
380
|
return useNuxtApp().$api<{ items: any[]; total: number; pages: number }>(`/api/robots/${id}/patrol-history`, {
|
|
@@ -594,6 +622,9 @@ export default function useRobots() {
|
|
|
594
622
|
}
|
|
595
623
|
|
|
596
624
|
return {
|
|
625
|
+
emergencyStopRobot,
|
|
626
|
+
getRobotSetup,
|
|
627
|
+
applyRobotSetup,
|
|
597
628
|
listBuildingMaps,
|
|
598
629
|
listSiteBuildingMaps,
|
|
599
630
|
getFleetCoreBuilding,
|
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.124.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.",
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Super Admin robot setup screens (Ounitech zones / schedules / settings,
|
|
3
|
+
* PadBot target points) and the emergency stop: the pure parts - the change
|
|
4
|
+
* each screen sends, the one-sentence confirm, day and time words.
|
|
5
|
+
* Pure: no Vue, no Nuxt (tested in robot-config.test.ts). The server validates
|
|
6
|
+
* again and reads every change back from the maker's cloud.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
export type TShare = { x: number; y: number };
|
|
10
|
+
export type TSetupShape = { id: string; name: string; kind: "wall" | "slow" | "safe"; closed: boolean; points: TShare[] };
|
|
11
|
+
/** A shape as the editor holds it: `points` only when it was drawn or redrawn here. */
|
|
12
|
+
export type TDraftShape = { id?: string; name: string; closed: boolean; points?: TShare[]; shown: TShare[] };
|
|
13
|
+
|
|
14
|
+
const DAY = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"];
|
|
15
|
+
|
|
16
|
+
/** [1..7] (Monday first, the vendor's numbering) -> "Every day" / "Mon-Fri" / "Mon, Wed". */
|
|
17
|
+
export function daysText(days: unknown): string {
|
|
18
|
+
const d = [...new Set((Array.isArray(days) ? days : []).filter((x) => Number.isInteger(x) && x >= 1 && x <= 7))].sort((a, b) => a - b) as number[];
|
|
19
|
+
if (!d.length) return "No days";
|
|
20
|
+
if (d.length === 7) return "Every day";
|
|
21
|
+
if (d.join() === "1,2,3,4,5") return "Mon-Fri";
|
|
22
|
+
if (d.join() === "6,7") return "Weekends";
|
|
23
|
+
return d.map((x) => DAY[x - 1]).join(", ");
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export const DAY_CHOICES = DAY.map((title, i) => ({ title, value: i + 1 }));
|
|
27
|
+
|
|
28
|
+
/** Same-day window check, in the words the screen shows. Empty = fine. */
|
|
29
|
+
export function scheduleProblem(start: string, end: string, days: number[]): string {
|
|
30
|
+
const hhmm = /^([01]\d|2[0-3]):[0-5]\d$/;
|
|
31
|
+
if (!hhmm.test(start) || !hhmm.test(end)) return "Enter the start and end as HH:MM.";
|
|
32
|
+
if (end <= start) return "The end time must be after the start time (same day).";
|
|
33
|
+
if (!days.length) return "Pick at least one day.";
|
|
34
|
+
return "";
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** The editor's starting list: every saved wall / slow zone, untouched (no `points` = keep the robot's own geometry). */
|
|
38
|
+
export function draftsFrom(shapes: TSetupShape[] | null | undefined): TDraftShape[] {
|
|
39
|
+
return (shapes ?? []).map((s) => ({ id: s.id || undefined, name: s.name || "Unnamed", closed: s.closed, shown: s.points }));
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* The `zones` change for one map and one kind: the FULL list (the robot's save
|
|
44
|
+
* replaces the whole list), `known` = the ids this screen loaded, so a list
|
|
45
|
+
* changed in the maker's app meanwhile is refused rather than overwritten.
|
|
46
|
+
*/
|
|
47
|
+
export function zonesChange(mapId: string, kind: "wall" | "slow", loaded: TSetupShape[] | null | undefined, drafts: TDraftShape[]) {
|
|
48
|
+
return {
|
|
49
|
+
action: "zones",
|
|
50
|
+
mapId,
|
|
51
|
+
kind,
|
|
52
|
+
known: (loaded ?? []).map((s) => s.id).filter(Boolean),
|
|
53
|
+
items: drafts.map((d) => ({
|
|
54
|
+
...(d.id ? { id: d.id } : {}),
|
|
55
|
+
name: d.name.trim() || "Unnamed",
|
|
56
|
+
...(d.points ? { points: d.points.map((p) => ({ x: round2(p.x), y: round2(p.y) })), closed: d.closed } : {}),
|
|
57
|
+
})),
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const round2 = (v: number) => Math.round(v * 100) / 100;
|
|
62
|
+
|
|
63
|
+
/** Only the settings the person changed (the server refuses "nothing changed"). */
|
|
64
|
+
export function settingsDiff(loaded: Array<{ key: string; value: unknown }> | null | undefined, edited: Record<string, unknown>) {
|
|
65
|
+
const out: Record<string, boolean | number> = {};
|
|
66
|
+
for (const s of loaded ?? []) {
|
|
67
|
+
if (!(s.key in edited)) continue;
|
|
68
|
+
const v = edited[s.key];
|
|
69
|
+
const want = typeof s.value === "boolean" ? v === true : Number(v);
|
|
70
|
+
if (typeof want === "number" && !Number.isFinite(want)) continue;
|
|
71
|
+
if (want !== s.value) out[s.key] = want;
|
|
72
|
+
}
|
|
73
|
+
return out;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** The one sentence the confirm dialog says before a setup change is sent (owner: say what will happen). */
|
|
77
|
+
export function setupConfirmText(change: Record<string, any>, ctx: { robot?: string; map?: string } = {}): { title: string; body: string; action: string } {
|
|
78
|
+
const robot = ctx.robot || "the robot";
|
|
79
|
+
const map = ctx.map ? `map "${ctx.map}"` : "this map";
|
|
80
|
+
switch (change.action) {
|
|
81
|
+
case "zones": {
|
|
82
|
+
const n = Array.isArray(change.items) ? change.items.length : 0;
|
|
83
|
+
const what = change.kind === "wall" ? "virtual walls and no-go areas" : "slow zones";
|
|
84
|
+
return {
|
|
85
|
+
title: `Save ${what} to ${robot}?`,
|
|
86
|
+
body: `The robot's ${what} on ${map} are replaced by these ${n}. It follows them on its next move. Anything removed here is removed from the robot.`,
|
|
87
|
+
action: "Save to robot",
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
case "auto-walls":
|
|
91
|
+
return {
|
|
92
|
+
title: "Draw virtual walls automatically?",
|
|
93
|
+
body: `Ounitech draws walls around the obstacles on ${robot}'s current map. It may replace walls drawn by hand; check the map afterwards and fix any wall here.`,
|
|
94
|
+
action: "Draw walls",
|
|
95
|
+
};
|
|
96
|
+
case "plan-save":
|
|
97
|
+
return {
|
|
98
|
+
title: change.planId ? "Change this cleaning schedule?" : "Add this cleaning schedule?",
|
|
99
|
+
body: `${robot} will start cleaning ${daysText(change.days)} ${change.startTime}-${change.endTime} (robot's local time)${change.enabled === false ? ", switched off for now" : ""}. Schedules run only while "Schedules switched on" is On in its settings.`,
|
|
100
|
+
action: "Save schedule",
|
|
101
|
+
};
|
|
102
|
+
case "plan-enable":
|
|
103
|
+
return { title: `Switch this schedule ${change.enabled ? "on" : "off"}?`, body: change.enabled ? `${robot} will start cleaning at the scheduled times.` : `${robot} will not start this schedule until it is switched on again.`, action: change.enabled ? "Switch on" : "Switch off" };
|
|
104
|
+
case "plan-delete":
|
|
105
|
+
return { title: "Delete this cleaning schedule?", body: `It is removed from ${robot} and from the Ounitech app. This can't be undone.`, action: "Delete" };
|
|
106
|
+
case "power-save":
|
|
107
|
+
return {
|
|
108
|
+
title: change.planId ? "Change this timed switch?" : "Add a timed switch?",
|
|
109
|
+
body: `${robot} will switch itself ${change.switchTo} at ${change.time} on ${daysText(change.days)} (robot's local time).${change.switchTo === "off" ? " A robot switched off does not clean or answer commands until it is switched on." : ""}`,
|
|
110
|
+
action: "Save",
|
|
111
|
+
};
|
|
112
|
+
case "power-enable":
|
|
113
|
+
return { title: `Switch this timed on/off ${change.enabled ? "on" : "off"}?`, body: change.enabled ? "It will run at its time again." : "It stops running until switched on again.", action: change.enabled ? "Switch on" : "Switch off" };
|
|
114
|
+
case "power-delete":
|
|
115
|
+
return { title: "Delete this timed switch?", body: `It is removed from ${robot}. This can't be undone.`, action: "Delete" };
|
|
116
|
+
case "robot-name":
|
|
117
|
+
return { title: "Rename the robot in Ounitech?", body: `${robot} will show as "${change.name}" in the Ounitech app. Its name here does not change (use Edit for that).`, action: "Rename" };
|
|
118
|
+
case "scene-name":
|
|
119
|
+
return { title: "Rename the Ounitech site?", body: `The robot's site will show as "${change.name}" in the Ounitech app.`, action: "Rename" };
|
|
120
|
+
case "map-name":
|
|
121
|
+
return { title: "Rename this map?", body: `${map} will show as "${change.name}" here and in the Ounitech app.`, action: "Rename" };
|
|
122
|
+
case "dock-here":
|
|
123
|
+
return {
|
|
124
|
+
title: "Save the dock where the robot is now?",
|
|
125
|
+
body: `Only do this with ${robot} sitting on its dock and charging. The robot returns to this spot to charge from now on; a wrong spot means it can't find its dock.`,
|
|
126
|
+
action: "Save dock",
|
|
127
|
+
};
|
|
128
|
+
case "switch-floor":
|
|
129
|
+
return {
|
|
130
|
+
title: `Switch the robot to floor ${change.floorNo}?`,
|
|
131
|
+
body: `Only after ${robot} has been physically taken to floor ${change.floorNo}. It then uses that floor's map; on the wrong floor it gets lost and may bump into things.`,
|
|
132
|
+
action: "Switch floor",
|
|
133
|
+
};
|
|
134
|
+
case "settings": {
|
|
135
|
+
const keys = Object.keys(change.values ?? {});
|
|
136
|
+
return { title: "Change the robot's settings?", body: `${keys.length} setting${keys.length === 1 ? "" : "s"} will change on ${robot} straight away.`, action: "Save settings" };
|
|
137
|
+
}
|
|
138
|
+
case "point-add":
|
|
139
|
+
return {
|
|
140
|
+
title: `Add point "${change.name}"?`,
|
|
141
|
+
body: change.at === "robot" ? `It is saved where ${robot} stands now, facing the way it faces.` : `It is saved where you clicked on ${map}. The robot can then be sent there.`,
|
|
142
|
+
action: "Add point",
|
|
143
|
+
};
|
|
144
|
+
case "point-update":
|
|
145
|
+
return { title: "Change this point?", body: `Patrol routes and buttons that use it go to the new place / name from now on.`, action: "Save point" };
|
|
146
|
+
case "point-delete":
|
|
147
|
+
return { title: "Delete this point?", body: `It is removed from ${robot}'s map. Patrol routes that use it may stop working; check them in the PadBot app.`, action: "Delete point" };
|
|
148
|
+
default:
|
|
149
|
+
return { title: "Send this change?", body: "", action: "Send" };
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** The emergency stop's answer in plain words (never "accepted" when nothing reached the robot). */
|
|
154
|
+
export function estopResultText(r: { live?: boolean; rest?: boolean; message?: string } | null | undefined): string {
|
|
155
|
+
if (!r) return "";
|
|
156
|
+
if (r.message) return r.message;
|
|
157
|
+
return r.live || r.rest ? "Emergency stop sent." : "The emergency stop did not reach the robot. Press the red button on the robot.";
|
|
158
|
+
}
|
package/utils/robot-display.ts
CHANGED
|
@@ -1707,6 +1707,7 @@ export function robotAbilities(input: {
|
|
|
1707
1707
|
on("sendToDock") && "returns to its charger",
|
|
1708
1708
|
on("liveVideo") && "streams live video",
|
|
1709
1709
|
on("manualDrive") && "can be driven by hand",
|
|
1710
|
+
on("emergencyStop") && "stops at once with Emergency stop",
|
|
1710
1711
|
].filter(Boolean) as string[];
|
|
1711
1712
|
const reports = [
|
|
1712
1713
|
on("battery") && "battery",
|
package/utils/robot-setup.ts
CHANGED
|
@@ -186,14 +186,12 @@ export function robotSetupSteps(input: TSetupInput): TSetupStep[] {
|
|
|
186
186
|
|
|
187
187
|
// 5. Floors present: one per floor the robots report.
|
|
188
188
|
const floors = floorsOf(map);
|
|
189
|
-
const
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
.map((s) => Number(s.floorNo)),
|
|
194
|
-
),
|
|
195
|
-
];
|
|
189
|
+
const onFloor = (robots ?? [])
|
|
190
|
+
.map((r) => ({ name: String(r?.name || "A robot"), s: input.states?.[String(r?._id)] }))
|
|
191
|
+
.filter((x) => x.s?.ok && x.s.floorKnown === true && Number.isInteger(x.s.floorNo));
|
|
192
|
+
const reported = [...new Set<number>(onFloor.map((x) => Number(x.s!.floorNo)))];
|
|
196
193
|
const missing = reported.filter((f) => !floors.includes(f));
|
|
194
|
+
const who = (f: number) => onFloor.filter((x) => Number(x.s!.floorNo) === f).map((x) => x.name).join(", ");
|
|
197
195
|
if (map === undefined) {
|
|
198
196
|
steps.push({ key: "floors", title: "Floors on the map", status: "check", reason: "Could not read the saved building map.", button: { label: "Open building map", target: "map" } });
|
|
199
197
|
} else if (!map) {
|
|
@@ -206,7 +204,7 @@ export function robotSetupSteps(input: TSetupInput): TSetupStep[] {
|
|
|
206
204
|
reason: !floors.length
|
|
207
205
|
? "The saved map has no floors. Use Edit map → Add floor."
|
|
208
206
|
: missing.length
|
|
209
|
-
?
|
|
207
|
+
? `${missing.map((f) => `${who(f)} report${who(f).includes(",") ? "" : "s"} ${floorName(f)}`).join("; ")} - the map has no such floor (it has ${floors.map(floorName).join(", ")}). Add it in Edit map → Add floor, or check the floor set in the robot's own app.`
|
|
210
208
|
: `${floors.map(floorName).join(", ")}${reported.length ? " - every floor the robots report." : "."}`,
|
|
211
209
|
button: { label: "Edit floors", target: "map" },
|
|
212
210
|
});
|
|
@@ -268,7 +266,8 @@ export function robotSetupSteps(input: TSetupInput): TSetupStep[] {
|
|
|
268
266
|
? "Floor and door are read from the real lift."
|
|
269
267
|
: "Floor and door not decoded yet - waiting on the lift company's register list."
|
|
270
268
|
: undefined,
|
|
271
|
-
|
|
269
|
+
// An existing (not connected) gateway is checked on the lift queue; Connect there would replace its code.
|
|
270
|
+
button: { label: up.length || gws.length ? "Open lift queue" : "Connect gateway", target: "lifts" },
|
|
272
271
|
});
|
|
273
272
|
}
|
|
274
273
|
|