@7365admin1/layer-common 4.35.1-staging.412 → 4.35.1-staging.414

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.
@@ -30,6 +30,8 @@ const props = defineProps<{
30
30
  online?: boolean;
31
31
  mode?: unknown;
32
32
  type?: string;
33
+ /** A patrol that can be carried on (Resume patrol shows only then). */
34
+ resumable?: boolean;
33
35
  /** The command in flight, if any. */
34
36
  busy?: TRobotCommandKind | null;
35
37
  }>();
@@ -42,6 +44,7 @@ const commands = computed(() =>
42
44
  online: props.online,
43
45
  mode: props.mode,
44
46
  type: props.type,
47
+ resumable: props.resumable,
45
48
  }),
46
49
  );
47
50
  </script>
@@ -121,9 +121,13 @@
121
121
  :online="live?.ok === true && live.online === true"
122
122
  :mode="statusMode"
123
123
  :type="robotType"
124
+ :resumable="resumable"
124
125
  :busy="busy"
125
126
  @command="send"
126
127
  />
128
+ <div v-if="commandResult" class="hero-notice" :class="{ 'hero-notice--error': commandResult.warn }" role="status" aria-live="polite">
129
+ {{ commandResult.text }}
130
+ </div>
127
131
  </div>
128
132
  <RobotViewOnlyNotice v-if="!canCommand" />
129
133
  </section>
@@ -182,6 +186,18 @@
182
186
  <v-dialog v-model="confirmOpen" width="440">
183
187
  <div v-if="confirming" class="hero-dialog" role="alertdialog" aria-labelledby="hero-confirm-title">
184
188
  <div id="hero-confirm-title" class="hero-dialog__title">{{ confirming.text.title }}</div>
189
+ <v-select
190
+ v-if="confirming.routes?.length"
191
+ v-model="confirming.routeId"
192
+ :items="confirming.routes"
193
+ item-title="name"
194
+ item-value="id"
195
+ label="Patrol route"
196
+ density="compact"
197
+ hide-details
198
+ class="mb-2"
199
+ @update:model-value="pickRoute"
200
+ />
185
201
  <div class="hero-dialog__body">{{ confirming.text.body }}</div>
186
202
  <div class="hero-dialog__actions">
187
203
  <v-btn variant="outlined" @click="confirmOpen = false">Don't send</v-btn>
@@ -258,7 +274,11 @@ import {
258
274
  capabilityLabel,
259
275
  cleaningScheduleRows,
260
276
  commandBody,
277
+ COMMAND_WATCH_MS,
261
278
  commandConfirm,
279
+ commandOutcome,
280
+ patrolResumable,
281
+ patrolStartRoutes,
262
282
  commandLabel,
263
283
  faultBadge,
264
284
  headingDeg,
@@ -407,7 +427,7 @@ const makerApp = computed(() => (live.value?.ok ? live.value.profile?.makerApp :
407
427
 
408
428
  const online = computed(() => live.value?.ok === true && live.value.online === true);
409
429
  const commands = computed(() =>
410
- visibleCommands({ capabilities: caps.value, canCommand: props.canCommand, online: online.value, mode: statusMode.value, type: robotType.value }),
430
+ visibleCommands({ capabilities: caps.value, canCommand: props.canCommand, online: online.value, mode: statusMode.value, type: robotType.value, resumable: resumable.value }),
411
431
  );
412
432
 
413
433
  /* ---- the plan ---- */
@@ -470,6 +490,7 @@ const unplacedRoutes = computed(() =>
470
490
  const runs = ref<any[]>([]);
471
491
  const runsLoaded = ref(false);
472
492
  const detected = computed(() => detectPatrolSchedule(runs.value, now.value));
493
+ const resumable = computed(() => patrolResumable(runs.value));
473
494
  /** Newest first, 100 a page, until a page reaches 30 days back. ponytail: capped at 5 pages (500 runs); raise if a robot patrols more than ~16x a day. */
474
495
  async function loadRuns() {
475
496
  const since = Date.now() - 30 * 86_400_000;
@@ -559,22 +580,54 @@ watch([reportsPosition, online], ([r, o], old) => {
559
580
 
560
581
  /* ---- commands ---- */
561
582
  const busy = ref<TRobotCommandKind | "spot" | null>(null);
562
- async function run(kind: TRobotCommandKind | "spot", body: Record<string, unknown>) {
583
+ /** The last command's real outcome, kept on screen (a toast vanishes in 2.6 s). */
584
+ const commandResult = ref<{ text: string; warn: boolean } | null>(null);
585
+ const watching = ref<{ kind: string; at: number; what?: string } | null>(null);
586
+ watch([lastAt, now], () => {
587
+ const w = watching.value;
588
+ const at = lastAt.value?.getTime() ?? 0;
589
+ // Only a reading taken after the command was accepted counts (never the optimistic status).
590
+ if (!w || at <= w.at + 300) return;
591
+ const out = commandOutcome(w.kind, polled.value?.ok ? polled.value : null, Date.now() - w.at, { name: record.value?.name, what: w.what });
592
+ if (!out) return;
593
+ watching.value = null;
594
+ commandResult.value = { text: out.text, warn: !out.ok };
595
+ if (!out.ok) optimistic.value = null;
596
+ });
597
+ watch(
598
+ () => props.robotId,
599
+ () => {
600
+ commandResult.value = null;
601
+ watching.value = null;
602
+ },
603
+ );
604
+ async function run(kind: TRobotCommandKind | "spot", body: Record<string, unknown>, what?: string) {
563
605
  const name = record.value?.name ?? "the robot";
564
606
  const before = statusMode.value;
565
607
  busy.value = kind;
566
608
  optimistic.value = { mode: optimisticMode(kind, before), after: Number.POSITIVE_INFINITY };
567
- const label = kind === "full" && robotType.value === "security" ? "Start patrol" : commandLabel(kind);
609
+ const label = (kind === "full" || kind === "area") && robotType.value === "security" ? "Start patrol" : commandLabel(kind);
568
610
  toast.value = `${label} → ${name} · sending…`;
611
+ commandResult.value = { text: `${label} → ${name} · sending…`, warn: false };
612
+ watching.value = null;
569
613
  try {
570
614
  await sendRobotCommand(props.robotId, body as any);
571
615
  // A poll already in flight may still carry the old status; wait past it.
572
616
  optimistic.value = optimistic.value ? { ...optimistic.value, after: Date.now() + 300 } : null;
573
- toast.value = `${label} → ${name} · accepted. Its status updates here.`;
617
+ toast.value = `${label} → ${name} · accepted.`;
618
+ // Resume patrol follows the last run; re-read it once the robot has had time to act.
619
+ if (robotType.value === "security" && caps.value?.patrolHistory === true) setTimeout(loadRuns, 5000);
620
+ // "Accepted" is not "done": watch real readings for up to 30 s (commandOutcome).
621
+ const outcomeKind = body.kind === "go-to-point" ? "go-to-point" : kind;
622
+ if (commandOutcome(outcomeKind, { mode: "" }, COMMAND_WATCH_MS) !== null) {
623
+ watching.value = { kind: String(outcomeKind), at: Date.now(), what };
624
+ commandResult.value = { text: `${label} → ${name} · accepted. Waiting for the robot to move…`, warn: false };
625
+ } else commandResult.value = { text: `${label} → ${name} · accepted.`, warn: false };
574
626
  return true;
575
627
  } catch (e) {
576
628
  optimistic.value = null;
577
629
  toast.value = robotErrorMessage(e);
630
+ commandResult.value = { text: `${label} → ${name} · not sent: ${robotErrorMessage(e)}`, warn: true };
578
631
  return false;
579
632
  } finally {
580
633
  busy.value = null;
@@ -598,6 +651,20 @@ async function send(kind: TRobotCommandKind) {
598
651
  return;
599
652
  }
600
653
  }
654
+ // Start patrol (security): pick the route - the robot's own map's routes, first one preselected.
655
+ if (kind === "full" && robotType.value === "security") {
656
+ const routes = patrolStartRoutes(patrol.value?.routes, live.value?.mapId ?? null);
657
+ if (!routes.length && (patrol.value?.routes?.length ?? 0) > 0) {
658
+ commandResult.value = { text: `None of the robot's patrol routes are on ${currentMapLabel.value || "the map it is on"}. Move it to a route's map first.`, warn: true };
659
+ return;
660
+ }
661
+ if (routes.length) {
662
+ confirming.value = { kind: "area", body: {}, text: { title: "", body: "", action: "" }, routes, routeId: routes[0]!.id };
663
+ pickRoute(routes[0]!.id);
664
+ confirmOpen.value = true;
665
+ return;
666
+ }
667
+ }
601
668
  const body = commandBody(kind, live.value);
602
669
  if (!body) {
603
670
  toast.value = `${commandLabel(kind)}: the robot's position is not known yet.`;
@@ -608,11 +675,31 @@ async function send(kind: TRobotCommandKind) {
608
675
  confirmOpen.value = true;
609
676
  }
610
677
  const confirmOpen = ref(false);
611
- const confirming = ref<{ kind: TRobotCommandKind; body: Record<string, unknown>; text: { title: string; body: string; action: string } } | null>(null);
678
+ const confirming = ref<{
679
+ kind: TRobotCommandKind;
680
+ body: Record<string, unknown>;
681
+ text: { title: string; body: string; action: string };
682
+ routes?: TPatrolRouteView[];
683
+ routeId?: string;
684
+ what?: string;
685
+ } | null>(null);
686
+ /** The route picked in Start patrol's confirm: its exact words, and the body naming it. */
687
+ function pickRoute(id: unknown) {
688
+ const c = confirming.value;
689
+ const route = c?.routes?.find((r) => r.id === id);
690
+ if (!c || !route) return;
691
+ const stops = route.stops?.length ? route.stops : route.checkpoints.map((p) => p.name);
692
+ const map = currentMapLabel.value || "";
693
+ const name = route.name.replace(/ \(default\)$/, "");
694
+ c.routeId = route.id;
695
+ c.body = { kind: "area", areas: [route.id] };
696
+ c.what = /^patrol/i.test(name) ? name : `patrolling ${name}`;
697
+ c.text = commandConfirm("area", { type: "security", name: record.value?.name, route: { name, stops }, map });
698
+ }
612
699
  function confirmSend() {
613
700
  const c = confirming.value;
614
701
  confirmOpen.value = false;
615
- if (c) run(c.kind, c.body);
702
+ if (c) run(c.kind, c.body, c.what);
616
703
  }
617
704
 
618
705
  /** The robot's plan image size for its floor, for turning plan clicks into pixels. */
@@ -658,7 +745,7 @@ function onPick(p: TSavedPoint) {
658
745
  toast.value = "Set position: the robot must be on this map first.";
659
746
  return;
660
747
  }
661
- run(kind, body);
748
+ run(kind, body, kind === "navigate" ? `going to ${p.name}` : undefined);
662
749
  }
663
750
 
664
751
  /* ---- security-robot extras: live view (minted per open, never kept), manual control, resolve an alarm ---- */
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.35.1-staging.412",
5
+ "version": "4.35.1-staging.414",
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.",
@@ -180,6 +180,24 @@ export function writeLivePref(storage: Pick<Storage, "setItem"> | undefined, on:
180
180
  /** hls.js errors that mean "this browser cannot decode the codec", not "the camera is down". */
181
181
  const CODEC_ERRORS = new Set(["manifestIncompatibleCodecsError", "bufferAddCodecError", "bufferIncompatibleCodecsError"]);
182
182
 
183
+ /**
184
+ * hls.js live settings. The robot camera (EZVIZ via PadBot) serves plain HLS
185
+ * v3: 4 s segments, a 3-segment window, no LL-HLS parts. hls.js's default
186
+ * sits 3 segments (12 s) behind the newest picture and never catches up after
187
+ * a hiccup - the owner's "10-15 s delay". Now: 6 s behind (1.5 segments, so
188
+ * one late segment does not stall), play up to 1.2x to win back time lost to
189
+ * a hiccup, and jump forward if it ever falls 14 s behind.
190
+ * ponytail: a lower-latency protocol (FLV / WebRTC) is not offered by PadBot's
191
+ * §6.5 video API; ask PadBot if 6 s is still too slow.
192
+ */
193
+ export const LIVE_HLS_CONFIG = {
194
+ enableWorker: true,
195
+ lowLatencyMode: false,
196
+ liveSyncDuration: 6,
197
+ liveMaxLatencyDuration: 14,
198
+ maxLiveSyncPlaybackRate: 1.2,
199
+ } as const;
200
+
183
201
  /**
184
202
  * Plays one live-view URL into `video`. Returns a stop function that frees the
185
203
  * player and the connection.
@@ -286,7 +304,7 @@ export function startLivePlayer(
286
304
  video.addEventListener("error", noteDecode);
287
305
  let recoveries = 0;
288
306
  const Base = Hls.DefaultConfig?.loader;
289
- const h = (hls = new Hls({ enableWorker: true, lowLatencyMode: false, ...(Base ? { fLoader: strayAudLoader(Base) } : {}) }));
307
+ const h = (hls = new Hls({ ...LIVE_HLS_CONFIG, ...(Base ? { fLoader: strayAudLoader(Base) } : {}) }));
290
308
  h.on("hlsError", (_e, data) => {
291
309
  noteDecode();
292
310
  if (!data.fatal) return;
@@ -404,6 +404,9 @@ const COMMANDS: ReadonlyArray<{ kind: TRobotCommandKind; label: string }> = [
404
404
  { kind: "patrol-resume", label: "Resume patrol" },
405
405
  ];
406
406
 
407
+ /** A job is in hand (something for Cancel to stop). */
408
+ const ACTIVE: ReadonlySet<string> = new Set(["WORKING", "IN_TRANSIT", "PAUSED", "MANUAL"]);
409
+
407
410
  export type TRobotCommand = { kind: TRobotCommandKind; label: string; primary: boolean };
408
411
 
409
412
  /**
@@ -412,10 +415,12 @@ export type TRobotCommand = { kind: TRobotCommandKind; label: string; primary: b
412
415
  * - brand: only commands its declared capabilities support;
413
416
  * - role: only a Manager (`canCommand`, i.e. the app's command permission);
414
417
  * - online: only a robot confirmed online (`online === true`);
415
- * - context: Pause only while working, Resume only while paused;
418
+ * - context: Pause only while working, Resume only while paused, Cancel only
419
+ * while there is a job to stop (working, moving or paused);
416
420
  * - type: a security robot has no cleaning jobs, so no Clean all / Clean area -
417
- * but a brand with `patrolControl` gets Start patrol (the default route) and
418
- * Resume patrol (while it is not already patrolling).
421
+ * but a brand with `patrolControl` gets Start patrol (a route picker) and
422
+ * Resume patrol - only while a patrol is paused or its last run did not
423
+ * finish (`resumable`), never "just in case".
419
424
  *
420
425
  * Returns [] whenever the bar should not render at all.
421
426
  */
@@ -425,16 +430,19 @@ export function visibleCommands(input: {
425
430
  online?: unknown;
426
431
  mode?: unknown;
427
432
  type?: unknown;
433
+ /** A patrol that can be carried on: the last run did not finish (in progress / partial / failed). */
434
+ resumable?: unknown;
428
435
  } | null | undefined): TRobotCommand[] {
429
436
  if (!input || input.canCommand !== true || input.online !== true) return [];
430
437
  const caps = input.capabilities ?? {};
431
438
  const patrol = input.type === "security" && caps.patrolControl === true;
432
439
  return COMMANDS.filter((c) => {
433
- if (c.kind === "patrol-resume") return patrol && input.mode !== "WORKING" && input.mode !== "IN_TRANSIT";
440
+ if (c.kind === "patrol-resume") return patrol && (input.mode === "PAUSED" || (input.resumable === true && input.mode !== "WORKING" && input.mode !== "IN_TRANSIT"));
434
441
  if (caps[COMMAND_NEEDS[c.kind]!] !== true) return false;
435
442
  if (c.kind === "pause" && input.mode !== "WORKING") return false;
436
443
  if (c.kind === "resume" && input.mode !== "PAUSED") return false;
437
- if (c.kind === "full" && input.type === "security") return patrol;
444
+ if (c.kind === "cancel" && !ACTIVE.has(String(input.mode))) return false;
445
+ if (c.kind === "full" && input.type === "security") return patrol && !ACTIVE.has(String(input.mode)); // one patrol at a time
438
446
  if (c.kind === "area" && input.type === "security") return false;
439
447
  return true;
440
448
  }).map((c) => ({
@@ -1645,14 +1653,21 @@ export function scheduleElsewhereText(type: unknown, brand: unknown, profile?: T
1645
1653
  */
1646
1654
  export function commandConfirm(
1647
1655
  kind: unknown,
1648
- input: { type?: unknown; name?: unknown; point?: unknown } = {},
1656
+ input: { type?: unknown; name?: unknown; point?: unknown; route?: { name?: unknown; stops?: unknown } | null; map?: unknown } = {},
1649
1657
  ): { title: string; body: string; action: string } {
1650
1658
  const who = (typeof input.name === "string" && input.name) || "The robot";
1651
1659
  const at = typeof input.point === "string" && input.point ? input.point : "";
1652
- const label = kind === "full" && input.type === "security" ? "Start patrol" : commandLabel(kind);
1660
+ const patrol = input.type === "security" && (kind === "full" || kind === "area");
1661
+ const label = patrol ? "Start patrol" : commandLabel(kind);
1662
+ const route = input.route && typeof input.route.name === "string" ? input.route : null;
1663
+ const stops = Array.isArray(route?.stops) ? (route!.stops as unknown[]).filter((s) => typeof s === "string" && s) : [];
1664
+ const onMap = typeof input.map === "string" && input.map ? ` on ${input.map}` : "";
1665
+ const walk = route
1666
+ ? `${who} walks "${route.name}" once${stops.length ? ` (${stops.join(" → ")})` : ""}${onMap}, then stops.`
1667
+ : `${who} starts walking its default patrol route once.`;
1653
1668
  const body: Record<string, string> = {
1654
- full: input.type === "security" ? `${who} starts walking its default patrol route once.` : `${who} starts cleaning the whole floor it is on.`,
1655
- area: `${who} starts cleaning the chosen areas.`,
1669
+ full: input.type === "security" ? walk : `${who} starts cleaning the whole floor it is on.`,
1670
+ area: input.type === "security" ? walk : `${who} starts cleaning the chosen areas.`,
1656
1671
  return: `${who} stops what it is doing and drives back to its charger.`,
1657
1672
  pause: `${who} stops where it is and waits. Press Resume to carry on.`,
1658
1673
  resume: `${who} carries on with the job it paused.`,
@@ -1666,6 +1681,72 @@ export function commandConfirm(
1666
1681
  return { title: `${label}?`, body: body[String(kind)] ?? `${who}: ${label}.`, action: label };
1667
1682
  }
1668
1683
 
1684
+ /**
1685
+ * Start patrol's route choices: the routes on the map the robot is on (a route
1686
+ * on another map is refused by the server - the robot cannot walk it from
1687
+ * here). Unknown current map, or routes that say no map: all of them. The
1688
+ * default pick is the first (the server lists the walked / current-map /
1689
+ * default route first).
1690
+ */
1691
+ export function patrolStartRoutes(routes: TPatrolRouteView[] | null | undefined, currentMapId: unknown): TPatrolRouteView[] {
1692
+ const all = (Array.isArray(routes) ? routes : []).filter((r) => r && typeof r.id === "string" && r.id);
1693
+ if (typeof currentMapId !== "string" || !currentMapId) return all;
1694
+ return all.filter((r) => !r.mapId || r.mapId === currentMapId);
1695
+ }
1696
+
1697
+ /** Last patrol run not finished, so Resume patrol has something to carry on (PadBot §6.7 completionState 0/1/3). */
1698
+ export function patrolResumable(runs: Array<{ result?: unknown }> | null | undefined): boolean {
1699
+ const r = Array.isArray(runs) ? runs[0]?.result : undefined;
1700
+ return r === "in-progress" || r === "partial" || r === "failed";
1701
+ }
1702
+
1703
+ /** How long after "accepted" we wait for the robot to show it acted. */
1704
+ export const COMMAND_WATCH_MS = 30_000;
1705
+
1706
+ const GOING = ["WORKING", "IN_TRANSIT", "PAUSED"];
1707
+ const STARTED: Readonly<Record<string, ReadonlySet<string>>> = {
1708
+ full: new Set(GOING),
1709
+ area: new Set(GOING),
1710
+ "patrol-resume": new Set(GOING),
1711
+ "go-to-point": new Set(GOING),
1712
+ navigate: new Set(GOING),
1713
+ return: new Set([...GOING, "CHARGING_CYCLE", "CHARGED"]),
1714
+ };
1715
+ const RESTING = /^(Standing by|Charging|Charged|Working on a task)$/;
1716
+
1717
+ /**
1718
+ * What an accepted command actually did, from a reading taken AFTER it was
1719
+ * sent (never the optimistic status). The robot cloud's "accepted" is not
1720
+ * "done": PadBot answers code 0 and the robot may still not move.
1721
+ * - started: the robot shows it acted -> `${who} started ${what}.`
1722
+ * - still resting after COMMAND_WATCH_MS -> `${who} didn't start — <reason>`
1723
+ * (the robot's own words when it gave any, e.g. "Could not reach P2 (...)").
1724
+ * - otherwise: keep watching (null).
1725
+ * Commands without a visible outcome (cancel, set position, pause) -> null.
1726
+ */
1727
+ export function commandOutcome(
1728
+ kind: unknown,
1729
+ reading: { online?: unknown; mode?: unknown; statusName?: unknown; fault?: { message?: unknown } | null } | null | undefined,
1730
+ elapsedMs: number,
1731
+ input: { name?: unknown; what?: unknown } = {},
1732
+ ): { ok: boolean; text: string } | null {
1733
+ const started = STARTED[String(kind)];
1734
+ if (!started || !reading) return null;
1735
+ const who = (typeof input.name === "string" && input.name) || "The robot";
1736
+ const what = typeof input.what === "string" && input.what ? input.what : kind === "return" ? "going back to its charger" : "moving";
1737
+ if (typeof reading.mode === "string" && started.has(reading.mode)) return { ok: true, text: `${who} started ${what}.` };
1738
+ if (elapsedMs < COMMAND_WATCH_MS) return null;
1739
+ const said = typeof reading.statusName === "string" ? reading.statusName.split(" · ")[0]!.trim() : "";
1740
+ const fault = typeof reading.fault?.message === "string" ? reading.fault.message : "";
1741
+ const reason = reading.online === false ? "it stopped reporting" : fault || (said && !RESTING.test(said) ? said : "");
1742
+ return {
1743
+ ok: false,
1744
+ text: reason
1745
+ ? `${who} didn't start — ${reason}.`
1746
+ : `${who} didn't start — the robot cloud accepted the command, but the robot is still ${said ? said.toLowerCase() : "idle"}. Check the robot or its maker's app.`,
1747
+ };
1748
+ }
1749
+
1669
1750
  /** The Brand column: "PadBot S2", "Demo S1 (simulated)", or just "Ounitech" when no model is reported. */
1670
1751
  export function brandModelText(
1671
1752
  brand: unknown,