@7365admin1/layer-common 4.97.1-staging.568 → 4.97.1-staging.569
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/assets/css/robot-hero.css +2 -0
- package/components/Robot/CleaningRecords.vue +36 -5
- package/components/Robot/Detail.vue +28 -1
- 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 +6 -0
- package/package.json +1 -1
- package/utils/robot-building.ts +28 -1
- package/utils/robot-config.ts +40 -0
- package/utils/robot-display.ts +56 -0
- package/utils/robot-maps.ts +5 -2
- package/utils/robot-records.ts +33 -2
|
@@ -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));
|
|
@@ -248,7 +248,16 @@
|
|
|
248
248
|
class="mb-2"
|
|
249
249
|
@update:model-value="pickRoute"
|
|
250
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" />
|
|
251
259
|
<div class="hero-dialog__body">{{ confirming.text.body }}</div>
|
|
260
|
+
<div v-if="optionsLine" class="hero-dialog__body">{{ optionsLine }}</div>
|
|
252
261
|
<div class="hero-dialog__actions">
|
|
253
262
|
<v-btn variant="outlined" @click="confirmOpen = false">Don't send</v-btn>
|
|
254
263
|
<!-- Cancel sends the robot home (core 2026-10-15); this keeps it where it stops. -->
|
|
@@ -374,6 +383,15 @@ import {
|
|
|
374
383
|
type TRobotCommandKind,
|
|
375
384
|
type TSavedPoint,
|
|
376
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,
|
|
377
395
|
} from "../../utils/robot-display";
|
|
378
396
|
|
|
379
397
|
const props = withDefaults(
|
|
@@ -804,10 +822,19 @@ function pickRoute(id: unknown) {
|
|
|
804
822
|
c.what = /^patrol/i.test(name) ? name : `patrolling ${name}`;
|
|
805
823
|
c.text = commandConfirm("area", { type: "security", name: record.value?.name, route: { name, stops }, map });
|
|
806
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
|
+
});
|
|
807
832
|
function confirmSend(stayHere = false) {
|
|
808
833
|
const c = confirming.value;
|
|
809
834
|
confirmOpen.value = false;
|
|
810
|
-
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);
|
|
811
838
|
}
|
|
812
839
|
|
|
813
840
|
/** The robot's plan image size for its floor, for turning plan clicks into pixels. */
|
|
@@ -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
|
*
|
|
@@ -706,6 +711,7 @@ export default function useRobots() {
|
|
|
706
711
|
getRobotMaps,
|
|
707
712
|
getRobotMapImages,
|
|
708
713
|
getRobotRecords,
|
|
714
|
+
getRobotJobDetail,
|
|
709
715
|
getRobotPlans,
|
|
710
716
|
sendRobotCommand,
|
|
711
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.97.1-staging.
|
|
5
|
+
"version": "4.97.1-staging.569",
|
|
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 "";
|
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
|
}
|