@7365admin1/layer-common 4.129.0 → 4.130.1

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 CHANGED
@@ -1,5 +1,17 @@
1
1
  # @iservice365/layer-common
2
2
 
3
+ ## 4.130.1
4
+
5
+ ### Patch Changes
6
+
7
+ - 3c80d85: Robots setup checklist: OneWay's "not online" reason and its before-adding prerequisite, in the same words as Ounitech and PadBot.
8
+
9
+ ## 4.130.0
10
+
11
+ ### Minor Changes
12
+
13
+ - 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.
14
+
3
15
  ## 4.129.0
4
16
 
5
17
  ### Minor Changes
@@ -3322,11 +3334,11 @@ Monthly report: a month that had patrols opens the list of that month's patrol r
3322
3334
  `utils/console-tier.ts` + `composables/useConsoleTier.ts` mirror the server's
3323
3335
  own rule from two endpoints the console already calls, unprojected:
3324
3336
 
3325
- GET /api/members/user/:user/app/admin the Seven365 staff membership
3326
- GET /api/roles/id/:role that membership's role document
3337
+ GET /api/members/user/:user/app/admin the Seven365 staff membership
3338
+ GET /api/roles/id/:role that membership's role document
3327
3339
 
3328
- owner = member.type === "admin" && role.type === "admin" && role.default === true
3329
- staff = member.type === "admin" && role.type === "admin"
3340
+ owner = member.type === "admin" && role.type === "admin" && role.default === true
3341
+ staff = member.type === "admin" && role.type === "admin"
3330
3342
 
3331
3343
  `role.default` is the marker because it is the only property of a platform
3332
3344
  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></tr>
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
- <tr v-for="j in shownJobs" :key="j.key">
29
- <td>{{ j.when }}</td><td>{{ j.area }}</td><td>{{ j.job }}</td><td>{{ j.duration }}</td><td>{{ j.size }}</td><td>{{ j.done }}</td><td>{{ j.result }}</td><td>{{ j.by }}</td>
30
- </tr>
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) run(c.kind, stayHere === true ? { ...c.body, stayHere: true } : c.body, c.what);
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 === 'safe' ? 'hero-plan__safe' : 'hero-plan__slow'" :points="svgPoints(z.points)"><title>{{ z.name }}</title></polygon>
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 { DAY_CHOICES, daysText, scheduleProblem, settingsDiff, setupConfirmText } from "../../utils/robot-config";
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
- confirming.value = { change, text: setupConfirmText(change, { robot: props.name, map: ctx.map ?? currentMap.value?.name }) };
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 }] }>();
@@ -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.129.0",
5
+ "version": "4.130.1",
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.",
@@ -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,
@@ -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 "";
@@ -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
+ }
@@ -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 === "wall" || z?.kind === "safe" || z?.kind === "slow") && points.length >= 2 ? [{ kind: z.kind, name: String(z.name ?? ""), points }] : [];
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(" · ")}` : "";
@@ -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 = { key: string; when: string; area: string; job: string; duration: string; size: string; done: string; result: string; by: string };
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
  }
@@ -88,6 +88,7 @@ export function offlineReason(robot: Record<string, any>, state: Record<string,
88
88
  const why = state.reason ? ` (${state.reason})` : "";
89
89
  if (robot?.brand === "ounitech") return `${name}: the Ounitech cloud did not answer${why}. Ask Ounitech to link this robot's serial to our account.`;
90
90
  if (robot?.brand === "padbot") return `${name}: the PadBot cloud did not answer${why}. Check the robot's key and token.`;
91
+ if (robot?.brand === "oneway") return `${name}: OneWay's RMS did not answer${why}. Check the robot's API key and server address, and ask OneWay to put this robot under our company key.`;
91
92
  return `${name}: its cloud did not answer${why}.`;
92
93
  }
93
94
  return `${name}: its cloud says it is offline - switched off, charging dock unplugged or out of Wi-Fi.`;
@@ -129,7 +130,7 @@ export function robotSetupSteps(input: TSetupInput): TSetupStep[] {
129
130
  }
130
131
 
131
132
  // 2. Robots added.
132
- const prereq = "Before adding: Ounitech must link each robot's serial to our Seven365 account; for PadBot, save the robot's points (charger, stops) in the PadBot app first.";
133
+ const prereq = "Before adding: Ounitech must link each robot's serial to our Seven365 account; for PadBot, save the robot's points (charger, stops) in the PadBot app first; for OneWay, get our company API key from OneWay and have the robot's routes and charging point set in OneWay's RMS.";
133
134
  if (!robots) {
134
135
  steps.push({ key: "robots", title: "Robots added", status: "check", reason: "Could not read this site's robots.", button: { label: "Open fleet", target: "fleet" } });
135
136
  } else {