@7365admin1/layer-common 4.128.0 → 4.130.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 +2 -0
- package/components/Robot/CleaningRecords.vue +36 -5
- package/components/Robot/Detail.vue +36 -3
- package/components/Robot/EmergencyStop.vue +23 -2
- package/components/Robot/Form.vue +24 -9
- package/components/Robot/LiftQueue.vue +3 -0
- package/components/Robot/LivePlan.vue +3 -3
- package/components/Robot/SetupPanel.vue +60 -3
- package/components/Robot/SetupPoints.vue +3 -0
- package/composables/useRobots.ts +16 -0
- package/package.json +1 -1
- package/utils/robot-building.ts +28 -1
- package/utils/robot-config.ts +40 -0
- package/utils/robot-credential-form.ts +20 -0
- package/utils/robot-display.ts +56 -0
- package/utils/robot-maps.ts +5 -2
- package/utils/robot-records.ts +33 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,17 @@
|
|
|
1
1
|
# @iservice365/layer-common
|
|
2
2
|
|
|
3
|
+
## 4.130.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- 0814b16: Robots: start a clean with the documented mode / power / repeats (Ounitech), patrol rounds or until stopped (PadBot); PadBot no-go areas, virtual walls, speed limits, passable areas and tracks drawn on the map; cleaning jobs show who started them, their settings and a floor / area detail; Super Admin setup resets part hours and edits area settings; the lift card shows the B0S fault with its code (not Offline), load, floor range, mode, run data and registered calls.
|
|
8
|
+
|
|
9
|
+
## 4.129.0
|
|
10
|
+
|
|
11
|
+
### Minor Changes
|
|
12
|
+
|
|
13
|
+
- f06eeb9: Robots: OneWay brand in Add / Edit robot (API key + server address, OneWay's cloud prefilled, Find robots lists the company's robots) and a Release emergency stop button for brands whose emergency stop is a software one (OneWay).
|
|
14
|
+
|
|
3
15
|
## 4.128.0
|
|
4
16
|
|
|
5
17
|
### Minor Changes
|
|
@@ -3316,11 +3328,11 @@ Monthly report: a month that had patrols opens the list of that month's patrol r
|
|
|
3316
3328
|
`utils/console-tier.ts` + `composables/useConsoleTier.ts` mirror the server's
|
|
3317
3329
|
own rule from two endpoints the console already calls, unprojected:
|
|
3318
3330
|
|
|
3319
|
-
|
|
3320
|
-
|
|
3331
|
+
GET /api/members/user/:user/app/admin the Seven365 staff membership
|
|
3332
|
+
GET /api/roles/id/:role that membership's role document
|
|
3321
3333
|
|
|
3322
|
-
|
|
3323
|
-
|
|
3334
|
+
owner = member.type === "admin" && role.type === "admin" && role.default === true
|
|
3335
|
+
staff = member.type === "admin" && role.type === "admin"
|
|
3324
3336
|
|
|
3325
3337
|
`role.default` is the marker because it is the only property of a platform
|
|
3326
3338
|
staff role no API caller can set - `role.controller.ts` validates create and
|
|
@@ -681,6 +681,8 @@
|
|
|
681
681
|
.hero-plan__safe { fill: rgba(67, 160, 71, 0.18); stroke: #43a047; }
|
|
682
682
|
.hero-plan__slow { fill: rgba(251, 140, 0, 0.18); stroke: #fb8c00; }
|
|
683
683
|
.hero-plan__taught { fill: none; stroke: #1e88e5; stroke-dasharray: 4 3; }
|
|
684
|
+
.hero-plan__pass { fill: rgba(0, 172, 193, 0.14); stroke: #00acc1; stroke-dasharray: 2 2; }
|
|
685
|
+
.hero-plan__track { fill: none; stroke: #8e24aa; stroke-dasharray: 3 2; }
|
|
684
686
|
.hero-rec__obstacles { display: block; max-width: 100%; max-height: 320px; margin-top: 4px; border: 1px solid var(--hero-line, rgba(127, 127, 127, 0.3)); image-rendering: pixelated; }
|
|
685
687
|
.hero-plan__area { position: absolute; transform: translate(-50%, -50%); font-size: 12px; font-weight: 600; padding: 0 4px; white-space: nowrap; pointer-events: none; color: var(--hero-text); background: var(--hero-bg); border: 1px solid var(--hero-accent); }
|
|
686
688
|
.hero-rec { margin-top: 16px; }
|
|
@@ -22,12 +22,22 @@
|
|
|
22
22
|
<div v-else class="hero-rec__wrap">
|
|
23
23
|
<table class="hero-rec__table">
|
|
24
24
|
<thead>
|
|
25
|
-
<tr><th>Started</th><th>Area</th><th>Job</th><th>Took</th><th>Cleaned</th><th>Done</th><th>Result</th><th>Started by</th
|
|
25
|
+
<tr><th>Started</th><th>Area</th><th>Job</th><th>Took</th><th>Cleaned</th><th>Done</th><th>Result</th><th>Started by</th><th /></tr>
|
|
26
26
|
</thead>
|
|
27
27
|
<tbody>
|
|
28
|
-
<
|
|
29
|
-
<
|
|
30
|
-
|
|
28
|
+
<template v-for="j in shownJobs" :key="j.key">
|
|
29
|
+
<tr>
|
|
30
|
+
<td>{{ j.when }}</td><td>{{ j.area }}</td><td>{{ j.job }}<span v-if="j.settings" class="hero-muted"><br />{{ j.settings }}</span></td><td>{{ j.duration }}</td><td>{{ j.size }}</td><td>{{ j.done }}</td><td>{{ j.result }}</td><td>{{ j.by }}</td>
|
|
31
|
+
<td><v-btn v-if="j.sourceKey" variant="text" size="small" :loading="detailLoading === j.sourceKey" @click="toggleDetail(j.sourceKey)">{{ openDetail === j.sourceKey ? "Hide" : "Detail" }}</v-btn></td>
|
|
32
|
+
</tr>
|
|
33
|
+
<tr v-if="openDetail && openDetail === j.sourceKey">
|
|
34
|
+
<td colspan="9">
|
|
35
|
+
<div v-if="detailError" class="hero-notice hero-notice--error" role="status">{{ detailError }}</div>
|
|
36
|
+
<div v-else-if="!detailRows.length" class="hero-sched__empty">{{ makerApp }} sent no floor or area runs for this job.</div>
|
|
37
|
+
<div v-for="d in detailRows" :key="d.key" class="hero-sched__row"><b>{{ d.where }}</b><span>{{ d.when }} · {{ d.duration }} · {{ d.size }} · {{ d.done }}</span><span>{{ d.result }}</span></div>
|
|
38
|
+
</td>
|
|
39
|
+
</tr>
|
|
40
|
+
</template>
|
|
31
41
|
</tbody>
|
|
32
42
|
</table>
|
|
33
43
|
<v-btn v-if="jobs.length > 5" variant="text" size="small" @click="allJobs = !allJobs">{{ allJobs ? "Show fewer" : `See all ${jobs.length}` }}</v-btn>
|
|
@@ -149,10 +159,11 @@ import {
|
|
|
149
159
|
settingGroups,
|
|
150
160
|
statusLogRows,
|
|
151
161
|
zoneRows,
|
|
162
|
+
jobDetailRows,
|
|
152
163
|
} from "../../utils/robot-records";
|
|
153
164
|
|
|
154
165
|
const props = defineProps<{ robotId: string; makerApp: string; brand?: string }>();
|
|
155
|
-
const { getRobotRecords } = useRobots();
|
|
166
|
+
const { getRobotRecords, getRobotJobDetail } = useRobots();
|
|
156
167
|
|
|
157
168
|
const records = ref<Record<string, any> | null>(null);
|
|
158
169
|
const loading = ref(false);
|
|
@@ -186,6 +197,26 @@ watch(
|
|
|
186
197
|
{ immediate: true },
|
|
187
198
|
);
|
|
188
199
|
|
|
200
|
+
/* One job's floor / area runs, read when asked (one open at a time). */
|
|
201
|
+
const openDetail = ref<string | null>(null);
|
|
202
|
+
const detailLoading = ref<string | null>(null);
|
|
203
|
+
const detail = ref<Record<string, any> | null>(null);
|
|
204
|
+
const detailError = ref("");
|
|
205
|
+
const detailRows = computed(() => jobDetailRows(detail.value));
|
|
206
|
+
async function toggleDetail(source: string) {
|
|
207
|
+
if (openDetail.value === source) return void (openDetail.value = null);
|
|
208
|
+
detailLoading.value = source;
|
|
209
|
+
detailError.value = "";
|
|
210
|
+
try {
|
|
211
|
+
detail.value = await getRobotJobDetail(props.robotId, source);
|
|
212
|
+
} catch (e: any) {
|
|
213
|
+
detail.value = null;
|
|
214
|
+
detailError.value = `Couldn't read the job detail: ${robotErrorMessage(e)}`;
|
|
215
|
+
} finally {
|
|
216
|
+
detailLoading.value = null;
|
|
217
|
+
openDetail.value = source;
|
|
218
|
+
}
|
|
219
|
+
}
|
|
189
220
|
const jobs = computed(() => jobRows(records.value));
|
|
190
221
|
const shownJobs = computed(() => (allJobs.value ? jobs.value : jobs.value.slice(0, 5)));
|
|
191
222
|
const alerts = computed(() => alertHistoryRows(records.value));
|
|
@@ -30,8 +30,14 @@
|
|
|
30
30
|
</div>
|
|
31
31
|
</div>
|
|
32
32
|
|
|
33
|
-
<!-- Emergency stop: anyone allowed to command this robot, one tap, no confirm (PadBot
|
|
34
|
-
<RobotEmergencyStop
|
|
33
|
+
<!-- Emergency stop: anyone allowed to command this robot, one tap, no confirm (PadBot, OneWay). OneWay's is a software stop: Release lifts it. -->
|
|
34
|
+
<RobotEmergencyStop
|
|
35
|
+
v-if="cmdOk && caps?.emergencyStop === true"
|
|
36
|
+
:robot-id="robotId"
|
|
37
|
+
:name="record?.name"
|
|
38
|
+
:releasable="caps?.emergencyRelease === true && fault?.code === 'E-STOP'"
|
|
39
|
+
@stopped="fastUntil = Date.now() + COMMAND_FOLLOW_MS"
|
|
40
|
+
/>
|
|
35
41
|
|
|
36
42
|
<div v-if="error" class="hero-notice" role="alert">{{ error }}</div>
|
|
37
43
|
<div v-else-if="live && !live.ok && live.reason" class="hero-notice" role="status">Can't read this robot right now: {{ live.reason }}</div>
|
|
@@ -242,7 +248,16 @@
|
|
|
242
248
|
class="mb-2"
|
|
243
249
|
@update:model-value="pickRoute"
|
|
244
250
|
/>
|
|
251
|
+
<!-- Documented job options (Ounitech clean mode / power / repeats; PadBot patrol rounds), only where the brand declares them. -->
|
|
252
|
+
<template v-if="offered.clean">
|
|
253
|
+
<v-select v-model="jobOpts.modeFlag" :items="CLEAN_MODE_CHOICES" label="Cleaning mode" density="compact" hide-details class="mb-2" />
|
|
254
|
+
<v-checkbox v-if="jobOpts.modeFlag === 2" v-model="jobOpts.subModeFlag" :true-value="1" :false-value="0" label="Also vacuum while washing" density="compact" hide-details />
|
|
255
|
+
<v-select v-model="jobOpts.levelFlag" :items="CLEAN_POWER_CHOICES" label="Power" density="compact" hide-details class="mb-2" />
|
|
256
|
+
<v-select v-model="jobOpts.cleanTimes" :items="CLEAN_TIMES_CHOICES" label="Times to clean" density="compact" hide-details class="mb-2" />
|
|
257
|
+
</template>
|
|
258
|
+
<v-select v-if="offered.rounds" v-model="jobOpts.rounds" :items="PATROL_ROUND_CHOICES" label="Rounds" density="compact" hide-details class="mb-2" />
|
|
245
259
|
<div class="hero-dialog__body">{{ confirming.text.body }}</div>
|
|
260
|
+
<div v-if="optionsLine" class="hero-dialog__body">{{ optionsLine }}</div>
|
|
246
261
|
<div class="hero-dialog__actions">
|
|
247
262
|
<v-btn variant="outlined" @click="confirmOpen = false">Don't send</v-btn>
|
|
248
263
|
<!-- Cancel sends the robot home (core 2026-10-15); this keeps it where it stops. -->
|
|
@@ -368,6 +383,15 @@ import {
|
|
|
368
383
|
type TRobotCommandKind,
|
|
369
384
|
type TSavedPoint,
|
|
370
385
|
type TSavedPointGroup,
|
|
386
|
+
CLEAN_MODE_CHOICES,
|
|
387
|
+
CLEAN_POWER_CHOICES,
|
|
388
|
+
CLEAN_TIMES_CHOICES,
|
|
389
|
+
JOB_OPTION_DEFAULTS,
|
|
390
|
+
PATROL_ROUND_CHOICES,
|
|
391
|
+
jobOptionsLine,
|
|
392
|
+
jobOptionsOffered,
|
|
393
|
+
withJobOptions,
|
|
394
|
+
type TJobOptions,
|
|
371
395
|
} from "../../utils/robot-display";
|
|
372
396
|
|
|
373
397
|
const props = withDefaults(
|
|
@@ -798,10 +822,19 @@ function pickRoute(id: unknown) {
|
|
|
798
822
|
c.what = /^patrol/i.test(name) ? name : `patrolling ${name}`;
|
|
799
823
|
c.text = commandConfirm("area", { type: "security", name: record.value?.name, route: { name, stops }, map });
|
|
800
824
|
}
|
|
825
|
+
/* Job options on a start: reset each time the confirm opens. */
|
|
826
|
+
const jobOpts = ref<TJobOptions>({ ...JOB_OPTION_DEFAULTS });
|
|
827
|
+
const offered = computed(() => jobOptionsOffered(confirming.value?.kind, robotType.value, caps.value));
|
|
828
|
+
const optionsLine = computed(() => jobOptionsLine(offered.value, jobOpts.value));
|
|
829
|
+
watch(confirmOpen, (open) => {
|
|
830
|
+
if (open) jobOpts.value = { ...JOB_OPTION_DEFAULTS };
|
|
831
|
+
});
|
|
801
832
|
function confirmSend(stayHere = false) {
|
|
802
833
|
const c = confirming.value;
|
|
803
834
|
confirmOpen.value = false;
|
|
804
|
-
if (c)
|
|
835
|
+
if (!c) return;
|
|
836
|
+
const body = withJobOptions(c.body, offered.value, jobOpts.value);
|
|
837
|
+
run(c.kind, stayHere === true ? { ...body, stayHere: true } : body, c.what);
|
|
805
838
|
}
|
|
806
839
|
|
|
807
840
|
/** The robot's plan image size for its floor, for turning plan clicks into pixels. */
|
|
@@ -4,12 +4,15 @@
|
|
|
4
4
|
command this robot. One tap, NO confirm - a stop must never wait on a
|
|
5
5
|
dialog or on a state read. The server sends PadBot's "full machine stop"
|
|
6
6
|
over the live link AND its stop command, and says which reached the robot.
|
|
7
|
+
OneWay's stop is a software one that stays on: while the robot reports it
|
|
8
|
+
(`releasable`), Release lifts it - after a confirm, since the robot may move again.
|
|
7
9
|
-->
|
|
8
10
|
<div class="hero-estop" role="group" aria-label="Emergency stop">
|
|
9
11
|
<button type="button" class="hero-estop__btn" :disabled="busy" @click="stop">
|
|
10
12
|
<span class="hero-estop__icon" aria-hidden="true">■</span>
|
|
11
13
|
{{ busy ? "Stopping…" : "Emergency stop" }}
|
|
12
14
|
</button>
|
|
15
|
+
<v-btn v-if="releasable" variant="outlined" :loading="releasing" :disabled="busy" @click="release">Release emergency stop</v-btn>
|
|
13
16
|
<div v-if="result" class="hero-estop__msg" :class="{ 'hero-estop__msg--error': failed }" role="status" aria-live="assertive">{{ result }}</div>
|
|
14
17
|
</div>
|
|
15
18
|
</template>
|
|
@@ -18,11 +21,12 @@
|
|
|
18
21
|
import { ref } from "vue";
|
|
19
22
|
import { estopResultText } from "../../utils/robot-config";
|
|
20
23
|
|
|
21
|
-
const props = defineProps<{ robotId: string; name?: string }>();
|
|
24
|
+
const props = defineProps<{ robotId: string; name?: string; releasable?: boolean }>();
|
|
22
25
|
const emit = defineEmits<{ stopped: [] }>();
|
|
23
|
-
const { emergencyStopRobot } = useRobots();
|
|
26
|
+
const { emergencyStopRobot, releaseEmergencyStopRobot } = useRobots();
|
|
24
27
|
|
|
25
28
|
const busy = ref(false);
|
|
29
|
+
const releasing = ref(false);
|
|
26
30
|
const result = ref("");
|
|
27
31
|
const failed = ref(false);
|
|
28
32
|
|
|
@@ -42,6 +46,23 @@ async function stop() {
|
|
|
42
46
|
busy.value = false;
|
|
43
47
|
}
|
|
44
48
|
}
|
|
49
|
+
|
|
50
|
+
async function release() {
|
|
51
|
+
if (!window.confirm(`Release the emergency stop on ${props.name || "this robot"}? It can take commands and move again.`)) return;
|
|
52
|
+
releasing.value = true;
|
|
53
|
+
failed.value = false;
|
|
54
|
+
result.value = "";
|
|
55
|
+
try {
|
|
56
|
+
const r = await releaseEmergencyStopRobot(props.robotId);
|
|
57
|
+
result.value = r?.message || "Emergency stop released.";
|
|
58
|
+
emit("stopped");
|
|
59
|
+
} catch (e: any) {
|
|
60
|
+
failed.value = true;
|
|
61
|
+
result.value = e?.data?.message || e?.message || "The release did not reach the robot. The emergency stop is still on.";
|
|
62
|
+
} finally {
|
|
63
|
+
releasing.value = false;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
45
66
|
</script>
|
|
46
67
|
|
|
47
68
|
<style scoped>
|
|
@@ -67,7 +67,7 @@
|
|
|
67
67
|
:id="`${uid}-token`"
|
|
68
68
|
v-model="appToken"
|
|
69
69
|
class="hero-input"
|
|
70
|
-
type="password"
|
|
70
|
+
:type="tokenSecret ? 'password' : 'text'"
|
|
71
71
|
autocomplete="new-password"
|
|
72
72
|
spellcheck="false"
|
|
73
73
|
:placeholder="credentialPlaceholder(editing)"
|
|
@@ -108,7 +108,14 @@ import {
|
|
|
108
108
|
type TRobotType,
|
|
109
109
|
} from "../../utils/robot-display";
|
|
110
110
|
import { credentialsNote } from "../../utils/robot-floorplan";
|
|
111
|
-
import {
|
|
111
|
+
import {
|
|
112
|
+
credentialDiscoverable,
|
|
113
|
+
credentialLabels,
|
|
114
|
+
credentialMissingMessage,
|
|
115
|
+
credentialTokenDefault,
|
|
116
|
+
credentialTokenIsSecret,
|
|
117
|
+
discoveredRobotOptions,
|
|
118
|
+
} from "../../utils/robot-credential-form";
|
|
112
119
|
|
|
113
120
|
const props = withDefaults(
|
|
114
121
|
defineProps<{
|
|
@@ -154,9 +161,17 @@ const robotSn = ref(savedSn);
|
|
|
154
161
|
const found = ref<Array<{ value: string; label: string; disabled: boolean }>>([]);
|
|
155
162
|
const finding = ref(false);
|
|
156
163
|
const labels = computed(() => credentialLabels(brand.value));
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
const
|
|
164
|
+
// A prefilled server address with no key typed is not a typed pair (Find robots then uses the client's saved one).
|
|
165
|
+
const tokenValue = computed(() => (!appKey.value.trim() && appToken.value.trim() === credentialTokenDefault(brand.value) ? "" : appToken.value.trim()));
|
|
166
|
+
const typed = computed(() => !!appKey.value.trim() && !!tokenValue.value);
|
|
167
|
+
// Ounitech / PadBot / OneWay: one company credential lists its robots, so the robot is picked from that list.
|
|
168
|
+
const discoverable = computed(() => credentialDiscoverable(brand.value));
|
|
169
|
+
// OneWay's second field is its server's address: plain text, prefilled with OneWay's cloud when adding.
|
|
170
|
+
const tokenSecret = computed(() => credentialTokenIsSecret(brand.value));
|
|
171
|
+
watch(brand, (b, was) => {
|
|
172
|
+
if (editing.value) return;
|
|
173
|
+
if (!appToken.value.trim() || appToken.value === credentialTokenDefault(was)) appToken.value = credentialTokenDefault(b);
|
|
174
|
+
});
|
|
160
175
|
const findHint = computed(() =>
|
|
161
176
|
editing.value || typed.value
|
|
162
177
|
? "Press Find robots"
|
|
@@ -219,12 +234,12 @@ onMounted(async () => {
|
|
|
219
234
|
|
|
220
235
|
async function find() {
|
|
221
236
|
error.value = "";
|
|
222
|
-
if (!!appKey.value.trim() !== !!
|
|
237
|
+
if (!!appKey.value.trim() !== !!tokenValue.value) {
|
|
223
238
|
return void (error.value = `Enter both the ${labels.value.key} and the ${labels.value.token}.`);
|
|
224
239
|
}
|
|
225
240
|
finding.value = true;
|
|
226
241
|
try {
|
|
227
|
-
const pair = typed.value ? { appKey: appKey.value.trim(), appToken:
|
|
242
|
+
const pair = typed.value ? { appKey: appKey.value.trim(), appToken: tokenValue.value } : null;
|
|
228
243
|
const body = pair
|
|
229
244
|
? { site: site.value, brand: brand.value, ...pair }
|
|
230
245
|
: editing.value
|
|
@@ -247,11 +262,11 @@ async function submit() {
|
|
|
247
262
|
error.value = "";
|
|
248
263
|
if (!name.value.trim()) return void (error.value = "Give the robot a name.");
|
|
249
264
|
// Adding with no pair typed = reuse this client's saved one; needs the picked robot.
|
|
250
|
-
const useSaved = !editing.value && discoverable.value && !appKey.value.trim() && !
|
|
265
|
+
const useSaved = !editing.value && discoverable.value && !appKey.value.trim() && !tokenValue.value;
|
|
251
266
|
if (useSaved && !robotSn.value) {
|
|
252
267
|
return void (error.value = `Enter the ${labels.value.key} and ${labels.value.token}, or press Find robots to use this client's saved ones and pick the robot.`);
|
|
253
268
|
}
|
|
254
|
-
const creds = useSaved ? ({ ok: true, body: {} } as const) : credentialPatch(appKey.value,
|
|
269
|
+
const creds = useSaved ? ({ ok: true, body: {} } as const) : credentialPatch(appKey.value, tokenValue.value, editing.value);
|
|
255
270
|
if (!creds.ok) return void (error.value = credentialMissingMessage(labels.value, editing.value));
|
|
256
271
|
const serial = robotSn.value && robotSn.value !== savedSn ? { robotSn: robotSn.value } : {};
|
|
257
272
|
saving.value = true;
|
|
@@ -41,7 +41,10 @@
|
|
|
41
41
|
<div v-if="l.gateway || (canEditMap && setupMapId)" class="hero-lift__gw" :data-status="l.gateway?.status ?? 'none'">
|
|
42
42
|
<template v-if="l.gateway">
|
|
43
43
|
<span class="hero-chip" :class="`hero-tone--${l.gateway.tone}`" role="status">{{ l.gateway.text }}</span>
|
|
44
|
+
<span v-if="l.gateway.fault" class="hero-chip hero-tone--error" role="alert">{{ l.gateway.fault }}</span>
|
|
44
45
|
<span v-if="l.gateway.decoded" class="hero-lift__gwread">{{ l.gateway.decoded }}</span>
|
|
46
|
+
<span v-if="l.gateway.facts" class="hero-muted hero-lift__gwread">{{ l.gateway.facts }}</span>
|
|
47
|
+
<span v-if="l.gateway.calls" class="hero-muted hero-lift__gwread">{{ l.gateway.calls }}</span>
|
|
45
48
|
<span v-else-if="l.gateway.undecoded" class="hero-muted hero-lift__gwread">Real lift answering · floor/door not decoded yet (fill [decode] in the gateway settings)</span>
|
|
46
49
|
<span v-if="l.gateway.help" class="hero-muted hero-lift__gwhelp">{{ l.gateway.help }}</span>
|
|
47
50
|
<details v-if="l.gateway.registers.length" class="hero-lift__gwraw">
|
|
@@ -12,8 +12,8 @@
|
|
|
12
12
|
<img v-if="image && overlay" class="hero-plan__overlay" :src="overlay" alt="" aria-hidden="true" draggable="false" referrerpolicy="no-referrer" />
|
|
13
13
|
<svg v-if="image && (zones.length || taughtRoute.length > 1)" class="hero-plan__zones" viewBox="0 0 100 100" preserveAspectRatio="none" aria-hidden="true">
|
|
14
14
|
<template v-for="(z, i) in zones" :key="`${z.kind}-${i}`">
|
|
15
|
-
<polyline v-if="z.kind === 'wall'" class="hero-plan__wall" :points="svgPoints(z.points)"><title>{{ z.name }}</title></polyline>
|
|
16
|
-
<polygon v-else :class="z.kind
|
|
15
|
+
<polyline v-if="z.kind === 'wall' || z.kind === 'track'" :class="z.kind === 'wall' ? 'hero-plan__wall' : 'hero-plan__track'" :points="svgPoints(z.points)"><title>{{ z.name }}</title></polyline>
|
|
16
|
+
<polygon v-else :class="`hero-plan__${z.kind}`" :points="svgPoints(z.points)"><title>{{ z.name }}</title></polygon>
|
|
17
17
|
</template>
|
|
18
18
|
<polyline v-if="taughtRoute.length > 1" class="hero-plan__taught" :points="svgPoints(taughtRoute)" />
|
|
19
19
|
</svg>
|
|
@@ -69,7 +69,7 @@ const props = withDefaults(
|
|
|
69
69
|
overlay?: string | null;
|
|
70
70
|
areas?: Array<{ name: string; at: { x: number; y: number } | null }>;
|
|
71
71
|
/** Virtual walls / safe zones / slow zones and the taught route on the map shown (0-100 shares). */
|
|
72
|
-
zones?: Array<{ kind: "wall" | "safe" | "slow"; name: string; points: Array<{ x: number; y: number }> }>;
|
|
72
|
+
zones?: Array<{ kind: "wall" | "safe" | "slow" | "pass" | "track"; name: string; points: Array<{ x: number; y: number }> }>;
|
|
73
73
|
taughtRoute?: Array<{ x: number; y: number }>;
|
|
74
74
|
/** Saved points on the map shown (0-100 shares). */
|
|
75
75
|
points?: Array<{ targetPointId: string; name: string; x: number; y: number }>;
|
|
@@ -134,6 +134,43 @@
|
|
|
134
134
|
</span>
|
|
135
135
|
</div>
|
|
136
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
|
+
|
|
138
|
+
<div class="hero-section__title">Parts replaced</div>
|
|
139
|
+
<p class="hero-muted">After a brush, mop, filter or bag is replaced, reset its hours so the wear count starts again.</p>
|
|
140
|
+
<div v-if="setup.parts?.error" class="hero-notice hero-notice--error">{{ setup.parts.error }}</div>
|
|
141
|
+
<div v-else-if="!parts.length" class="hero-sched__empty">The robot reports no parts.</div>
|
|
142
|
+
<div v-for="p in parts" :key="p.code" class="hero-sched__row hero-setup__row">
|
|
143
|
+
<b>{{ p.part }}</b>
|
|
144
|
+
<span>{{ p.hours != null ? `${p.hours} h` : "hours not reported" }}{{ p.days != null ? ` · ${p.days} days` : "" }}</span>
|
|
145
|
+
<span class="hero-setup__actions">
|
|
146
|
+
<v-btn size="small" variant="outlined" :disabled="busy" @click="ask({ action: 'parts-reset', codes: [p.code] }, { parts: [p.part] })">Part replaced · reset hours</v-btn>
|
|
147
|
+
</span>
|
|
148
|
+
</div>
|
|
149
|
+
</section>
|
|
150
|
+
|
|
151
|
+
<!-- Area settings (Ounitech overwrite-area-list): name, path, location mode, edge cleaning -->
|
|
152
|
+
<section v-if="caps.setupZones === true && areas.length" class="hero-setup__section" aria-label="Area settings">
|
|
153
|
+
<div class="hero-section__title">Areas on {{ currentMap?.name ?? "this map" }}</div>
|
|
154
|
+
<p class="hero-muted">How the robot cleans each area. The area shapes themselves are drawn in the Ounitech app.</p>
|
|
155
|
+
<div v-for="a in areas" :key="a.name" class="hero-setup__form">
|
|
156
|
+
<input v-model="areaEdits[a.name]!.name" class="hero-input" maxlength="40" :aria-label="`Name of area ${a.name}`" />
|
|
157
|
+
<label>Path
|
|
158
|
+
<select v-model.number="areaEdits[a.name]!.pathType" class="hero-input hero-select hero-select--sm">
|
|
159
|
+
<option v-for="c in AREA_PATH_CHOICES" :key="c.value" :value="c.value">{{ c.title }}</option>
|
|
160
|
+
</select>
|
|
161
|
+
</label>
|
|
162
|
+
<label>Place
|
|
163
|
+
<select v-model="areaEdits[a.name]!.locationMode" class="hero-input hero-select hero-select--sm">
|
|
164
|
+
<option v-for="c in AREA_LOCATION_CHOICES" :key="c.value" :value="c.value">{{ c.title }}</option>
|
|
165
|
+
</select>
|
|
166
|
+
</label>
|
|
167
|
+
<label>Edges
|
|
168
|
+
<select v-model.number="areaEdits[a.name]!.alongWallMode" class="hero-input hero-select hero-select--sm">
|
|
169
|
+
<option v-for="c in AREA_EDGE_CHOICES" :key="c.value" :value="c.value">{{ c.title }}</option>
|
|
170
|
+
</select>
|
|
171
|
+
</label>
|
|
172
|
+
<v-btn size="small" variant="outlined" :disabled="busy || !areaChange(currentMap?.mapId, a, areaEdits[a.name]!)" @click="ask(areaChange(currentMap?.mapId, a, areaEdits[a.name]!)!)">Save area</v-btn>
|
|
173
|
+
</div>
|
|
137
174
|
</section>
|
|
138
175
|
</template>
|
|
139
176
|
|
|
@@ -154,7 +191,17 @@
|
|
|
154
191
|
import { computed, reactive, ref, watch } from "vue";
|
|
155
192
|
import RobotSetupZones from "./SetupZones.vue";
|
|
156
193
|
import RobotSetupPoints from "./SetupPoints.vue";
|
|
157
|
-
import {
|
|
194
|
+
import {
|
|
195
|
+
AREA_EDGE_CHOICES,
|
|
196
|
+
AREA_LOCATION_CHOICES,
|
|
197
|
+
AREA_PATH_CHOICES,
|
|
198
|
+
DAY_CHOICES,
|
|
199
|
+
areaChange,
|
|
200
|
+
daysText,
|
|
201
|
+
scheduleProblem,
|
|
202
|
+
settingsDiff,
|
|
203
|
+
setupConfirmText,
|
|
204
|
+
} from "../../utils/robot-config";
|
|
158
205
|
|
|
159
206
|
const props = defineProps<{ robotId: string; name?: string; brand?: string; capabilities?: Record<string, unknown> | null }>();
|
|
160
207
|
const emit = defineEmits<{ changed: [] }>();
|
|
@@ -198,8 +245,9 @@ const confirmOpen = ref(false);
|
|
|
198
245
|
const confirming = ref<{ change: Record<string, any>; text: ReturnType<typeof setupConfirmText> } | null>(null);
|
|
199
246
|
const currentMap = computed(() => (setup.value?.maps ?? []).find((m: any) => m.mapId === (setup.value?.mapId ?? mapId.value)) ?? null);
|
|
200
247
|
|
|
201
|
-
function ask(change: Record<string, any>, ctx: { map?: string } = {}) {
|
|
202
|
-
|
|
248
|
+
function ask(change: Record<string, any>, ctx: { map?: string; parts?: string[] } = {}) {
|
|
249
|
+
// `parts` is for the sentence only; the server gets the codes.
|
|
250
|
+
confirming.value = { change, text: setupConfirmText({ ...change, ...(ctx.parts ? { parts: ctx.parts } : {}) }, { robot: props.name, map: ctx.map ?? currentMap.value?.name }) };
|
|
203
251
|
confirmOpen.value = true;
|
|
204
252
|
}
|
|
205
253
|
async function send() {
|
|
@@ -266,6 +314,15 @@ watch(setup, (s) => {
|
|
|
266
314
|
if (!planForm.planId && planForm.floorNo == null) planForm.floorNo = s?.floorNo ?? s?.floors?.[0] ?? null;
|
|
267
315
|
});
|
|
268
316
|
const changedSettings = computed(() => settingsDiff(settings.value, edited));
|
|
317
|
+
|
|
318
|
+
/* ---- parts replaced, area settings (Ounitech) ---- */
|
|
319
|
+
const parts = computed<any[]>(() => setup.value?.parts?.value ?? []);
|
|
320
|
+
const areas = computed<any[]>(() => (currentMap.value?.areas ?? []).filter((a: any) => a?.name && a.name !== "default"));
|
|
321
|
+
const areaEdits = reactive<Record<string, { name: string; pathType: number | null; locationMode: string | null; alongWallMode: number | null }>>({});
|
|
322
|
+
watch(areas, (list) => {
|
|
323
|
+
for (const k of Object.keys(areaEdits)) delete areaEdits[k];
|
|
324
|
+
for (const a of list) areaEdits[a.name] = { name: a.name, pathType: a.pathType ?? null, locationMode: a.locationMode ?? null, alongWallMode: a.alongWallMode ?? null };
|
|
325
|
+
}, { immediate: true });
|
|
269
326
|
</script>
|
|
270
327
|
|
|
271
328
|
<style>
|
|
@@ -18,10 +18,12 @@
|
|
|
18
18
|
:image="map.imageUrl"
|
|
19
19
|
:aspect="map.width && map.height ? map.width / map.height : null"
|
|
20
20
|
:points="placed"
|
|
21
|
+
:zones="map.zones ?? []"
|
|
21
22
|
:spot="picked ? [picked] : []"
|
|
22
23
|
:pick="picking !== null"
|
|
23
24
|
@pick="(p) => (picked = p)"
|
|
24
25
|
/>
|
|
26
|
+
<p v-if="map.imageUrl && (map.zones ?? []).length" class="hero-muted">{{ mapLayerLegend({ zones: map.zones, taughtRoute: [] }) }}. Set in the PadBot app; shown here so new points stay out of them.</p>
|
|
25
27
|
<div v-if="picking" class="hero-spotbar" role="group" aria-label="Pick a spot">
|
|
26
28
|
<span>{{ picking === "add" ? "Click the map where the new point goes." : `Click the map where "${moving?.name}" goes.` }}</span>
|
|
27
29
|
<v-btn size="small" color="primary" variant="flat" :disabled="!picked || busy" @click="donePick">Use this spot</v-btn>
|
|
@@ -60,6 +62,7 @@
|
|
|
60
62
|
import { computed, reactive, ref, watch } from "vue";
|
|
61
63
|
import RobotLivePlan from "./LivePlan.vue";
|
|
62
64
|
import type { TShare } from "../../utils/robot-config";
|
|
65
|
+
import { mapLayerLegend } from "../../utils/robot-maps";
|
|
63
66
|
|
|
64
67
|
const props = defineProps<{ setup: Record<string, any>; busy?: boolean }>();
|
|
65
68
|
const emit = defineEmits<{ apply: [change: Record<string, any>, ctx?: { map?: string }] }>();
|
package/composables/useRobots.ts
CHANGED
|
@@ -155,6 +155,11 @@ export default function useRobots() {
|
|
|
155
155
|
return useNuxtApp().$api<Record<string, any>>(`/api/robots/${id}/records`, { method: "GET", query: { appSlug } });
|
|
156
156
|
}
|
|
157
157
|
|
|
158
|
+
/** One past cleaning job's floor / area runs, by the record's `sourceKey` (read-only). */
|
|
159
|
+
function getRobotJobDetail(id: string, source: string) {
|
|
160
|
+
return useNuxtApp().$api<{ items?: Array<Record<string, any>> }>(`/api/robots/${id}/records/job`, { method: "GET", query: { appSlug, source } });
|
|
161
|
+
}
|
|
162
|
+
|
|
158
163
|
/**
|
|
159
164
|
* Signed image URLs for a map's layers.
|
|
160
165
|
*
|
|
@@ -358,6 +363,15 @@ export default function useRobots() {
|
|
|
358
363
|
);
|
|
359
364
|
}
|
|
360
365
|
|
|
366
|
+
/** `emergencyRelease` (OneWay): lift a software emergency stop so the robot takes commands again. */
|
|
367
|
+
function releaseEmergencyStopRobot(id: string) {
|
|
368
|
+
return useNuxtApp().$api<{ ok: boolean; message: string }>(`/api/robots/${id}/emergency-stop`, {
|
|
369
|
+
method: "POST",
|
|
370
|
+
query: { appSlug },
|
|
371
|
+
body: { release: true },
|
|
372
|
+
});
|
|
373
|
+
}
|
|
374
|
+
|
|
361
375
|
/** Super Admin setup: what can be changed now, read fresh from the maker's cloud (`mapId` picks the map). */
|
|
362
376
|
function getRobotSetup(id: string, mapId?: string | null) {
|
|
363
377
|
return useNuxtApp().$api<Record<string, any>>(`/api/robots/${id}/setup`, {
|
|
@@ -656,6 +670,7 @@ export default function useRobots() {
|
|
|
656
670
|
saveSiteFleetCore,
|
|
657
671
|
createSiteRelayKey,
|
|
658
672
|
emergencyStopRobot,
|
|
673
|
+
releaseEmergencyStopRobot,
|
|
659
674
|
getRobotSetup,
|
|
660
675
|
applyRobotSetup,
|
|
661
676
|
listBuildingMaps,
|
|
@@ -696,6 +711,7 @@ export default function useRobots() {
|
|
|
696
711
|
getRobotMaps,
|
|
697
712
|
getRobotMapImages,
|
|
698
713
|
getRobotRecords,
|
|
714
|
+
getRobotJobDetail,
|
|
699
715
|
getRobotPlans,
|
|
700
716
|
sendRobotCommand,
|
|
701
717
|
getLiveVideo,
|
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.130.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.",
|
package/utils/robot-building.ts
CHANGED
|
@@ -1535,6 +1535,29 @@ const MODE_TEXT = ["Mode unknown", "Normal", "Robot mode", "FIRE mode", "Out of
|
|
|
1535
1535
|
* one status line, a tone, and the decoded floor/door/motion/mode once the
|
|
1536
1536
|
* gateway's [decode] settings are filled in. `null` = no gateway: the car is simulated.
|
|
1537
1537
|
*/
|
|
1538
|
+
/** The B0S details (`gateway.details`) as the lift card's lines: the fault, then facts, then registered calls. */
|
|
1539
|
+
export function liftBoardLines(d: Record<string, any> | null | undefined) {
|
|
1540
|
+
if (!d) return { fault: "", facts: "", calls: "" };
|
|
1541
|
+
const num = (v: unknown) => typeof v === "number" && Number.isFinite(v);
|
|
1542
|
+
const fault = d.fault ? String(d.fault.text || "Lift fault") : "";
|
|
1543
|
+
const facts = [
|
|
1544
|
+
d.detailedMode && d.detailedMode !== d.status ? `Mode: ${d.detailedMode}` : "",
|
|
1545
|
+
d.load ? `Load: ${d.load}` : "",
|
|
1546
|
+
num(d.bottomFloor) && num(d.topFloor) ? `Floors ${d.bottomFloor}-${d.topFloor}` : "",
|
|
1547
|
+
d.boardLink === "Offline" ? "Board has lost the lift controller" : "",
|
|
1548
|
+
num(d.speedMps) && d.speedMps > 0 ? `${d.speedMps} m/s` : "",
|
|
1549
|
+
num(d.runHours) ? `${d.runHours} run hours` : "",
|
|
1550
|
+
num(d.runCount) ? `${Number(d.runCount).toLocaleString("en-GB")} runs` : "",
|
|
1551
|
+
].filter(Boolean).join(" · ");
|
|
1552
|
+
const list = (a: unknown) => (Array.isArray(a) && a.length ? a.join(", ") : "");
|
|
1553
|
+
const calls = [
|
|
1554
|
+
list(d.carCalls) ? `Car calls: ${list(d.carCalls)}` : "",
|
|
1555
|
+
list(d.hallUp) ? `Hall up: ${list(d.hallUp)}` : "",
|
|
1556
|
+
list(d.hallDown) ? `Hall down: ${list(d.hallDown)}` : "",
|
|
1557
|
+
].filter(Boolean).join(" · ");
|
|
1558
|
+
return { fault, facts, calls };
|
|
1559
|
+
}
|
|
1560
|
+
|
|
1538
1561
|
export function liftGatewayLine(g: Record<string, any> | null) {
|
|
1539
1562
|
if (!g) return null;
|
|
1540
1563
|
const ago = g.ageS == null ? "" : agoText(0, Number(g.ageS) * 1000);
|
|
@@ -1558,12 +1581,15 @@ export function liftGatewayLine(g: Record<string, any> | null) {
|
|
|
1558
1581
|
};
|
|
1559
1582
|
const line = byStatus[g.status] ?? byStatus.offline!;
|
|
1560
1583
|
const st = g.status === "connected" ? g.state : null;
|
|
1584
|
+
// The board's own words (Inovance B0S registers, decoded by the API): a lift in fault says Fault, not Offline.
|
|
1585
|
+
const d = g.status === "connected" && g.details && typeof g.details === "object" ? (g.details as Record<string, any>) : null;
|
|
1586
|
+
const boardMode = d?.status && d.status !== "Normal" ? String(d.status) : "";
|
|
1561
1587
|
const decoded = st
|
|
1562
1588
|
? [
|
|
1563
1589
|
st.currentFloor ? `Floor ${st.currentFloor}` : "",
|
|
1564
1590
|
st.doorState != null ? DOOR_TEXT[st.doorState] ?? "" : "",
|
|
1565
1591
|
st.motionState != null ? MOTION_TEXT[st.motionState] ?? "" : "",
|
|
1566
|
-
st.currentMode != null ? MODE_TEXT[st.currentMode] ?? "" : "",
|
|
1592
|
+
boardMode || (st.currentMode != null ? MODE_TEXT[st.currentMode] ?? "" : ""),
|
|
1567
1593
|
].filter(Boolean).join(" · ")
|
|
1568
1594
|
: "";
|
|
1569
1595
|
const registers = Object.entries((g.registers ?? {}) as Record<string, number>)
|
|
@@ -1574,6 +1600,7 @@ export function liftGatewayLine(g: Record<string, any> | null) {
|
|
|
1574
1600
|
status: String(g.status ?? "offline"),
|
|
1575
1601
|
connected: g.status === "connected",
|
|
1576
1602
|
decoded,
|
|
1603
|
+
...liftBoardLines(d),
|
|
1577
1604
|
// Until the Inovance register list is in the gateway's settings, only raw numbers exist.
|
|
1578
1605
|
undecoded: g.status === "connected" && !decoded,
|
|
1579
1606
|
registers,
|
package/utils/robot-config.ts
CHANGED
|
@@ -145,11 +145,51 @@ export function setupConfirmText(change: Record<string, any>, ctx: { robot?: str
|
|
|
145
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
146
|
case "point-delete":
|
|
147
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
|
+
case "parts-reset": {
|
|
149
|
+
const parts = Array.isArray(change.parts) && change.parts.length ? change.parts.join(", ") : "this part";
|
|
150
|
+
return {
|
|
151
|
+
title: `Reset the hours of ${parts}?`,
|
|
152
|
+
body: `Only after the part has been replaced: its hours on ${robot} restart from 0 and the old count can't be brought back.`,
|
|
153
|
+
action: "Reset hours",
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
case "area-settings":
|
|
157
|
+
return {
|
|
158
|
+
title: `Change area "${change.areaName}"?`,
|
|
159
|
+
body: `${robot} cleans area "${change.areaName}" on ${map} with the new settings from its next job${change.name && change.name !== change.areaName ? `, and the area is called "${change.name}" here and in the Ounitech app (schedules keep working; Clean area uses the new name)` : ""}. Every other area is sent back unchanged.`,
|
|
160
|
+
action: "Save area",
|
|
161
|
+
};
|
|
148
162
|
default:
|
|
149
163
|
return { title: "Send this change?", body: "", action: "Send" };
|
|
150
164
|
}
|
|
151
165
|
}
|
|
152
166
|
|
|
167
|
+
/** overwrite-area-list values (Ounitech 2025.10): how the robot covers an area, how it keeps its place, how it cleans the edges. */
|
|
168
|
+
export const AREA_PATH_CHOICES = [
|
|
169
|
+
{ title: "Loop (outside in)", value: 0 },
|
|
170
|
+
{ title: "Zigzag (back and forth)", value: 1 },
|
|
171
|
+
{ title: "Taught route", value: 2 },
|
|
172
|
+
] as const;
|
|
173
|
+
export const AREA_LOCATION_CHOICES = [
|
|
174
|
+
{ title: "Open area", value: "open_area" },
|
|
175
|
+
{ title: "Narrow corridor", value: "narrow_tunnel" },
|
|
176
|
+
{ title: "Factory / warehouse", value: "factory" },
|
|
177
|
+
] as const;
|
|
178
|
+
export const AREA_EDGE_CHOICES = [
|
|
179
|
+
{ title: "Off", value: 0 },
|
|
180
|
+
{ title: "Standard", value: 1 },
|
|
181
|
+
{ title: "Deep", value: 2 },
|
|
182
|
+
] as const;
|
|
183
|
+
|
|
184
|
+
/** The area change to send: only fields that differ from what the robot has (null when nothing changed). */
|
|
185
|
+
export function areaChange(mapId: unknown, was: Record<string, any>, now: Record<string, any>) {
|
|
186
|
+
const out: Record<string, unknown> = {};
|
|
187
|
+
const name = String(now.name ?? "").trim();
|
|
188
|
+
if (name && name !== was.name) out.name = name;
|
|
189
|
+
for (const k of ["pathType", "locationMode", "alongWallMode"]) if (now[k] != null && now[k] !== was[k]) out[k] = now[k];
|
|
190
|
+
return Object.keys(out).length ? { action: "area-settings", mapId, areaName: was.name, ...out } : null;
|
|
191
|
+
}
|
|
192
|
+
|
|
153
193
|
/** The emergency stop's answer in plain words (never "accepted" when nothing reached the robot). */
|
|
154
194
|
export function estopResultText(r: { live?: boolean; rest?: boolean; message?: string } | null | undefined): string {
|
|
155
195
|
if (!r) return "";
|
|
@@ -14,9 +14,29 @@ export function credentialLabels(brand: unknown): { key: string; token: string }
|
|
|
14
14
|
// PadBot issues an AccessKey ID + AccessKey Secret per account (API v4.0 §2.1).
|
|
15
15
|
if (brand === "padbot") return { key: "AccessKey ID", token: "AccessKey Secret" };
|
|
16
16
|
if (brand === "demo") return { key: "App key (any value)", token: "Token (any value)" };
|
|
17
|
+
// OneWay (Anbot RMS): one API key per company; the second field is the RMS server's address.
|
|
18
|
+
if (brand === "oneway") return { key: "API key", token: "Server address" };
|
|
17
19
|
return { key: "App key", token: "Token" };
|
|
18
20
|
}
|
|
19
21
|
|
|
22
|
+
/** OneWay's cloud, prefilled in "Server address" when adding (change it only for a company's own RMS). */
|
|
23
|
+
export const ONEWAY_DEFAULT_SERVER = "https://www.anbotcloud.cn";
|
|
24
|
+
|
|
25
|
+
/** What the second field starts with when adding a robot of this brand ("" = empty). */
|
|
26
|
+
export function credentialTokenDefault(brand: unknown): string {
|
|
27
|
+
return brand === "oneway" ? ONEWAY_DEFAULT_SERVER : "";
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** False when the second field is not a secret (OneWay's server address): shown as plain text. */
|
|
31
|
+
export function credentialTokenIsSecret(brand: unknown): boolean {
|
|
32
|
+
return brand !== "oneway";
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Brands whose one company credential lists its robots, so Add robot picks the serial (Find robots). */
|
|
36
|
+
export function credentialDiscoverable(brand: unknown): boolean {
|
|
37
|
+
return brand === "ounitech" || brand === "padbot" || brand === "oneway";
|
|
38
|
+
}
|
|
39
|
+
|
|
20
40
|
/** "Enter the AppID and the Secret ..." - `credentialPatch`'s refusal, in the brand's words. */
|
|
21
41
|
export function credentialMissingMessage(labels: { key: string; token: string }, editing: boolean): string {
|
|
22
42
|
return editing
|
package/utils/robot-display.ts
CHANGED
|
@@ -1948,3 +1948,59 @@ export function nameTagSide(
|
|
|
1948
1948
|
/** The bigger tags: robot detail's "name + status" (11px 800, 14px from the dot, 26px above) and the building map's name (11px 800). */
|
|
1949
1949
|
export const DETAIL_TAG = { charW: 7.4, pad: 12, gap: 14, above: 30 };
|
|
1950
1950
|
export const BUILDING_TAG = { charW: 7.4, pad: 13, gap: 6, above: 0 };
|
|
1951
|
+
|
|
1952
|
+
/* ------------------------------------------------ job options (2026-10-19) */
|
|
1953
|
+
|
|
1954
|
+
/** Ounitech start-*-cleaning modeFlag / levelFlag, and PadBot §6.2 patrolCount (0 = until stopped). */
|
|
1955
|
+
export const CLEAN_MODE_CHOICES = [
|
|
1956
|
+
{ title: "Vacuum", value: 1 },
|
|
1957
|
+
{ title: "Wash floor", value: 2 },
|
|
1958
|
+
{ title: "Dust mop", value: 3 },
|
|
1959
|
+
] as const;
|
|
1960
|
+
export const CLEAN_POWER_CHOICES = [
|
|
1961
|
+
{ title: "Standard", value: 1 },
|
|
1962
|
+
{ title: "Strong", value: 2 },
|
|
1963
|
+
{ title: "Silent", value: 3 },
|
|
1964
|
+
] as const;
|
|
1965
|
+
export const CLEAN_TIMES_CHOICES = [1, 2, 3, 4, 5].map((n) => ({ title: n === 1 ? "Once" : `${n} times`, value: n }));
|
|
1966
|
+
export const PATROL_ROUND_CHOICES = [
|
|
1967
|
+
...[1, 2, 3, 5, 10].map((n) => ({ title: n === 1 ? "Once" : `${n} rounds`, value: n })),
|
|
1968
|
+
{ title: "Until stopped", value: 0 },
|
|
1969
|
+
];
|
|
1970
|
+
|
|
1971
|
+
export type TJobOptions = { modeFlag: number; subModeFlag: number; levelFlag: number; cleanTimes: number; rounds: number };
|
|
1972
|
+
export const JOB_OPTION_DEFAULTS: TJobOptions = { modeFlag: 1, subModeFlag: 0, levelFlag: 1, cleanTimes: 1, rounds: 1 };
|
|
1973
|
+
|
|
1974
|
+
/**
|
|
1975
|
+
* Which job options the confirm offers: clean options for a cleaning robot whose
|
|
1976
|
+
* brand declares `cleanOptions`, rounds for a security robot whose brand declares
|
|
1977
|
+
* `patrolRounds` - only on a start (full / area), never on any other command.
|
|
1978
|
+
*/
|
|
1979
|
+
export function jobOptionsOffered(kind: unknown, type: unknown, capabilities: Record<string, unknown> | null | undefined) {
|
|
1980
|
+
const start = kind === "full" || kind === "area";
|
|
1981
|
+
return {
|
|
1982
|
+
clean: start && type !== "security" && capabilities?.cleanOptions === true,
|
|
1983
|
+
rounds: start && type === "security" && capabilities?.patrolRounds === true,
|
|
1984
|
+
};
|
|
1985
|
+
}
|
|
1986
|
+
|
|
1987
|
+
/** The command body with the picked options (only the offered ones; vacuum-while-washing only for wash). */
|
|
1988
|
+
export function withJobOptions(body: Record<string, unknown>, offered: { clean: boolean; rounds: boolean }, o: TJobOptions) {
|
|
1989
|
+
if (offered.clean) {
|
|
1990
|
+
return { ...body, modeFlag: o.modeFlag, subModeFlag: o.modeFlag === 2 ? o.subModeFlag : 0, levelFlag: o.levelFlag, cleanTimes: o.cleanTimes };
|
|
1991
|
+
}
|
|
1992
|
+
if (offered.rounds) return { ...body, cleanTimes: o.rounds };
|
|
1993
|
+
return body;
|
|
1994
|
+
}
|
|
1995
|
+
|
|
1996
|
+
/** One line under the confirm saying what the options mean for this job. */
|
|
1997
|
+
export function jobOptionsLine(offered: { clean: boolean; rounds: boolean }, o: TJobOptions): string {
|
|
1998
|
+
if (offered.clean) {
|
|
1999
|
+
const mode = CLEAN_MODE_CHOICES.find((c) => c.value === o.modeFlag)?.title ?? "Vacuum";
|
|
2000
|
+
const power = CLEAN_POWER_CHOICES.find((c) => c.value === o.levelFlag)?.title ?? "Standard";
|
|
2001
|
+
const wet = o.modeFlag === 2 ? (o.subModeFlag === 1 ? ", vacuuming while it washes" : "") + ". Wash mode needs water in the clean-water tank" : "";
|
|
2002
|
+
return `${mode}, ${power.toLowerCase()} power, ${o.cleanTimes === 1 ? "once" : `${o.cleanTimes} times`}${wet}.`;
|
|
2003
|
+
}
|
|
2004
|
+
if (offered.rounds) return o.rounds === 0 ? "It keeps patrolling until someone presses Cancel." : `It patrols the route ${o.rounds === 1 ? "once" : `${o.rounds} times`}.`;
|
|
2005
|
+
return "";
|
|
2006
|
+
}
|
package/utils/robot-maps.ts
CHANGED
|
@@ -34,13 +34,14 @@ export type TRobotMapView = {
|
|
|
34
34
|
taughtRoute: Array<{ x: number; y: number }>;
|
|
35
35
|
};
|
|
36
36
|
|
|
37
|
-
export type TRobotMapZone = { kind: "wall" | "safe" | "slow"; name: string; points: Array<{ x: number; y: number }> };
|
|
37
|
+
export type TRobotMapZone = { kind: "wall" | "safe" | "slow" | "pass" | "track"; name: string; points: Array<{ x: number; y: number }> };
|
|
38
|
+
const ZONE_KINDS: readonly string[] = ["wall", "safe", "slow", "pass", "track"];
|
|
38
39
|
|
|
39
40
|
const share = (p: any) => isNum(p?.x) && isNum(p?.y) && p.x >= 0 && p.x <= 100 && p.y >= 0 && p.y <= 100;
|
|
40
41
|
function mapZones(list: unknown): TRobotMapZone[] {
|
|
41
42
|
return (Array.isArray(list) ? list : []).flatMap((z: any) => {
|
|
42
43
|
const points = (Array.isArray(z?.points) ? z.points : []).filter(share).map((p: any) => ({ x: p.x, y: p.y }));
|
|
43
|
-
return (z?.kind
|
|
44
|
+
return ZONE_KINDS.includes(z?.kind) && points.length >= 2 ? [{ kind: z.kind, name: String(z.name ?? ""), points }] : [];
|
|
44
45
|
});
|
|
45
46
|
}
|
|
46
47
|
|
|
@@ -53,6 +54,8 @@ export function mapLayerLegend(view: Pick<TRobotMapView, "zones" | "taughtRoute"
|
|
|
53
54
|
part(n("wall"), "virtual wall", "virtual walls", "red"),
|
|
54
55
|
part(n("safe"), "safe zone", "safe zones", "green"),
|
|
55
56
|
part(n("slow"), "slow zone", "slow zones", "amber"),
|
|
57
|
+
part(n("pass"), "passable area", "passable areas", "teal dashes"),
|
|
58
|
+
part(n("track"), "virtual track", "virtual tracks", "purple dashes"),
|
|
56
59
|
view.taughtRoute.length >= 2 ? "taught route (blue dashes)" : "",
|
|
57
60
|
].filter(Boolean);
|
|
58
61
|
return parts.length ? `Drawn on the map: ${parts.join(" · ")}` : "";
|
package/utils/robot-records.ts
CHANGED
|
@@ -32,7 +32,20 @@ export function whenText(iso: unknown, timeZone?: string): string {
|
|
|
32
32
|
return `${parts.day} ${MONTHS[Number(parts.month) - 1]}, ${parts.hour}:${parts.minute}`;
|
|
33
33
|
}
|
|
34
34
|
|
|
35
|
-
export type TJobRow = {
|
|
35
|
+
export type TJobRow = {
|
|
36
|
+
key: string; when: string; area: string; job: string; duration: string; size: string; done: string; result: string; by: string;
|
|
37
|
+
/** The settings it ran with ("Wash floor · strong · 2 times"), "" when not reported. */
|
|
38
|
+
settings: string;
|
|
39
|
+
/** The key for its floor / area detail (`/records/job?source=`), null when the brand has none. */
|
|
40
|
+
sourceKey: string | null;
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
/** "Wash floor · strong · 2 times · vacuum while washing" from a job's settings (get-source-list). */
|
|
44
|
+
export function jobSettingsText(s: Record<string, any> | null | undefined): string {
|
|
45
|
+
if (!s || typeof s !== "object") return "";
|
|
46
|
+
const times = s.repeats === "Until stopped" ? "until stopped" : s.repeats && s.repeats !== "1" ? `${s.repeats} times` : "";
|
|
47
|
+
return [s.mode, s.power ? `${String(s.power).toLowerCase()} power` : "", times, s.vacuumWhileWashing ? "vacuum while washing" : ""].filter(Boolean).join(" · ");
|
|
48
|
+
}
|
|
36
49
|
|
|
37
50
|
/** Cleaning jobs, newest first as the API sends them. */
|
|
38
51
|
export function jobRows(records: { tasks?: TPart<Record<string, any>> } | null | undefined, timeZone?: string): TJobRow[] {
|
|
@@ -49,7 +62,25 @@ export function jobRows(records: { tasks?: TPart<Record<string, any>> } | null |
|
|
|
49
62
|
size,
|
|
50
63
|
done: isNum(t.percent) ? `${Math.round(t.percent)}%` : "—",
|
|
51
64
|
result: t.result || "—",
|
|
52
|
-
by: t.source || "—",
|
|
65
|
+
by: (t.startedBy ? `${t.startedBy}${t.scheduleId ? ` #${t.scheduleId}` : ""}` : t.source) || "—",
|
|
66
|
+
settings: jobSettingsText(t.settings),
|
|
67
|
+
sourceKey: typeof t.sourceKey === "string" && t.sourceKey ? t.sourceKey : null,
|
|
68
|
+
};
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** One job's floor / area runs (`/records/job`), in the order the robot did them. */
|
|
73
|
+
export function jobDetailRows(detail: { items?: Array<Record<string, any>> } | null | undefined, timeZone?: string) {
|
|
74
|
+
return (Array.isArray(detail?.items) ? detail!.items! : []).map((t, i) => {
|
|
75
|
+
const floor = isNum(t.floorNo) ? (t.floorNo > 0 ? `L${t.floorNo}` : t.floorNo < 0 ? `B${-t.floorNo}` : "G") : "";
|
|
76
|
+
return {
|
|
77
|
+
key: `${t.id ?? ""}-${i}`,
|
|
78
|
+
where: [t.map, floor, isNum(t.areaNo) ? `area ${t.areaNo}` : ""].filter(Boolean).join(" · ") || "—",
|
|
79
|
+
when: whenText(t.start, timeZone),
|
|
80
|
+
duration: durationText(t.durationSec),
|
|
81
|
+
size: isNum(t.areaM2) ? (isNum(t.plannedM2) && t.plannedM2 > 0 ? `${t.areaM2} of ${t.plannedM2} m²` : `${t.areaM2} m²`) : "—",
|
|
82
|
+
done: isNum(t.percent) ? `${Math.round(t.percent)}%` : "—",
|
|
83
|
+
result: t.status || t.result || "—",
|
|
53
84
|
};
|
|
54
85
|
});
|
|
55
86
|
}
|