@7365admin1/layer-common 4.64.1 → 4.64.2

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,11 @@
1
1
  # @iservice365/layer-common
2
2
 
3
+ ## 4.64.2
4
+
5
+ ### Patch Changes
6
+
7
+ - 5f0c221: Robot detail: after a command the line follows the robot ("Leaving the charger…", "On the way to P4…", "Arrived at P4", "Docking…", "Back on charger") instead of a false "didn't start"; says up front that undocking takes about 10 s, with a spinner while it follows; reads the robot every 1 s for a minute after a command (then every 4 s) so the status and map pin keep up; a note under the live view says the video is about 8 s behind and the map is real time.
8
+
3
9
  ## 4.64.1
4
10
 
5
11
  ### Patch Changes
@@ -1703,11 +1709,11 @@
1703
1709
  `utils/console-tier.ts` + `composables/useConsoleTier.ts` mirror the server's
1704
1710
  own rule from two endpoints the console already calls, unprojected:
1705
1711
 
1706
- GET /api/members/user/:user/app/admin the Seven365 staff membership
1707
- GET /api/roles/id/:role that membership's role document
1712
+ GET /api/members/user/:user/app/admin the Seven365 staff membership
1713
+ GET /api/roles/id/:role that membership's role document
1708
1714
 
1709
- owner = member.type === "admin" && role.type === "admin" && role.default === true
1710
- staff = member.type === "admin" && role.type === "admin"
1715
+ owner = member.type === "admin" && role.type === "admin" && role.default === true
1716
+ staff = member.type === "admin" && role.type === "admin"
1711
1717
 
1712
1718
  `role.default` is the marker because it is the only property of a platform
1713
1719
  staff role no API caller can set - `role.controller.ts` validates create and
@@ -1284,6 +1284,7 @@ div.hero-editor__wp.hero-editor__sel, div.hero-editor__fid.hero-editor__sel { ou
1284
1284
 
1285
1285
  /* PadBot security extras: live video button row under the facts. */
1286
1286
  .hero-detail__video { margin: 10px 0 4px; }
1287
+ .hero-detail__lag { margin: 6px 0 0; font-size: 12px; }
1287
1288
 
1288
1289
  /* A kept (stale) reading: faded, no ping - never drawn as live (owner 2026-09-26). */
1289
1290
  .hero-bot--stale, .hero-me--stale { opacity: 0.45; }
@@ -60,6 +60,7 @@
60
60
  <div v-if="!noInterface" class="hero-detail__main" :class="{ 'hero-detail__main--single': !hasCamera }">
61
61
  <section v-if="hasCamera" class="hero-detail__cam" aria-label="Camera">
62
62
  <RobotLiveView v-if="caps?.liveVideo === true" ref="livePanel" panel :robot-id="robotId" :name="record?.name" :offline="!online" />
63
+ <p v-if="caps?.liveVideo === true && reportsPosition" class="hero-muted hero-detail__lag">{{ VIDEO_LAG_NOTE }}</p>
63
64
  <div v-if="canDrive" class="hero-detail__video">
64
65
  <v-btn variant="outlined" @click="driveOpen = true">Manual control</v-btn>
65
66
  </div>
@@ -126,6 +127,7 @@
126
127
  @command="send"
127
128
  />
128
129
  <div v-if="commandResult" class="hero-notice" :class="{ 'hero-notice--error': commandResult.warn }" role="status" aria-live="polite">
130
+ <v-progress-circular v-if="watching" indeterminate size="14" width="2" class="mr-2" aria-hidden="true" />
129
131
  {{ commandResult.text }}
130
132
  </div>
131
133
  </div>
@@ -274,7 +276,11 @@ import {
274
276
  capabilityLabel,
275
277
  cleaningScheduleRows,
276
278
  commandBody,
279
+ COMMAND_FOLLOW_MS,
277
280
  COMMAND_WATCH_MS,
281
+ FAST_POLL_MS,
282
+ VIDEO_LAG_NOTE,
283
+ sentText,
278
284
  commandConfirm,
279
285
  commandOutcome,
280
286
  patrolResumable,
@@ -366,7 +372,9 @@ const brand = computed(() => brandName(record.value?.brand, brands.value));
366
372
 
367
373
  /* ---- live: the 4 s poll (swappable feed) + the per-robot position stream ---- */
368
374
  const ids = computed(() => (props.robotId ? [props.robotId] : []));
369
- const { states, connected, lastAt } = useRobotLive(ids, siteStatesFeed());
375
+ // Every 4 s; every 1 s for a minute after a command, so the status and the map pin keep up with the robot.
376
+ const fastUntil = ref(0);
377
+ const { states, connected, lastAt } = useRobotLive(ids, siteStatesFeed(() => (Date.now() < fastUntil.value ? FAST_POLL_MS : 4000)));
370
378
  const polled = computed<TLiveRobotState | null>(() => states.value[props.robotId] ?? null);
371
379
 
372
380
  const lastPose = ref<{ event: any; at: number } | null>(null);
@@ -590,7 +598,8 @@ watch([lastAt, now], () => {
590
598
  if (!w || at <= w.at + 300) return;
591
599
  const out = commandOutcome(w.kind, polled.value?.ok ? polled.value : null, Date.now() - w.at, { name: record.value?.name, what: w.what });
592
600
  if (!out) return;
593
- watching.value = null;
601
+ // Keep following (Leaving the charger… -> On the way to P4… -> Arrived at P4) until it is done.
602
+ if (out.done) watching.value = null;
594
603
  commandResult.value = { text: out.text, warn: !out.ok };
595
604
  if (!out.ok) optimistic.value = null;
596
605
  });
@@ -604,6 +613,7 @@ watch(
604
613
  async function run(kind: TRobotCommandKind | "spot", body: Record<string, unknown>, what?: string) {
605
614
  const name = record.value?.name ?? "the robot";
606
615
  const before = statusMode.value;
616
+ const docked = before === "CHARGED" || before === "CHARGING_CYCLE" || /At charger/.test(String(live.value?.ok ? live.value.statusName ?? "" : ""));
607
617
  busy.value = kind;
608
618
  optimistic.value = { mode: optimisticMode(kind, before), after: Number.POSITIVE_INFINITY };
609
619
  const label = (kind === "full" || kind === "area") && robotType.value === "security" ? "Start patrol" : commandLabel(kind);
@@ -621,7 +631,8 @@ async function run(kind: TRobotCommandKind | "spot", body: Record<string, unknow
621
631
  const outcomeKind = body.kind === "go-to-point" ? "go-to-point" : kind;
622
632
  if (commandOutcome(outcomeKind, { mode: "" }, COMMAND_WATCH_MS) !== null) {
623
633
  watching.value = { kind: String(outcomeKind), at: Date.now(), what };
624
- commandResult.value = { text: `${label} → ${name} · accepted. Waiting for the robot to move…`, warn: false };
634
+ fastUntil.value = Date.now() + COMMAND_FOLLOW_MS;
635
+ commandResult.value = { text: sentText(docked), warn: false };
625
636
  } else commandResult.value = { text: `${label} → ${name} · accepted.`, warn: false };
626
637
  return true;
627
638
  } catch (e) {
@@ -36,16 +36,25 @@ export type TRobotLiveFeed = {
36
36
  */
37
37
  export function robotPollingFeed(
38
38
  fetchStates: (ids: string[]) => Promise<{ items?: Array<Partial<TLiveRobotState> & { id: string }> }>,
39
- intervalMs = 4000,
39
+ /** A number, or a function read before every wait (robot detail: 1 s for a minute after a command). */
40
+ intervalMs: number | (() => number) = 4000,
40
41
  ): TRobotLiveFeed {
42
+ const every = typeof intervalMs === "function" ? intervalMs : () => intervalMs;
41
43
  return {
42
44
  watch(ids, push, connected) {
43
45
  let stopped = false;
44
46
  let timer: ReturnType<typeof setTimeout> | undefined;
47
+ let lastTick = 0;
45
48
  const hidden = () => typeof document !== "undefined" && document.visibilityState === "hidden";
49
+ // Waits in 250 ms steps, so a shorter interval (a command just sent) starts at once.
50
+ const wait = () => {
51
+ if (stopped) return;
52
+ timer = setTimeout(() => (Date.now() - lastTick >= every() ? tick() : wait()), 250);
53
+ };
46
54
 
47
55
  async function tick() {
48
56
  if (stopped) return;
57
+ lastTick = Date.now();
49
58
  if (!hidden() && ids.length) {
50
59
  try {
51
60
  const res = await fetchStates(ids);
@@ -58,7 +67,7 @@ export function robotPollingFeed(
58
67
  connected(false);
59
68
  }
60
69
  }
61
- if (!stopped) timer = setTimeout(tick, intervalMs);
70
+ wait();
62
71
  }
63
72
  // Coming back to the tab refreshes at once instead of waiting a tick.
64
73
  const onVisible = () => {
@@ -95,7 +104,7 @@ export function networkStatesFeed(intervalMs = 4000): TRobotLiveFeed {
95
104
  * The API checks every robot against its own site and narrows by the app's
96
105
  * `appSlug`, so a robot this screen may not see simply never arrives.
97
106
  */
98
- export function siteStatesFeed(intervalMs = 4000): TRobotLiveFeed {
107
+ export function siteStatesFeed(intervalMs: number | (() => number) = 4000): TRobotLiveFeed {
99
108
  const appSlug = useRuntimeConfig().public?.appSlug as string | undefined;
100
109
  return robotPollingFeed(
101
110
  (ids) =>
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.64.1",
5
+ "version": "4.64.2",
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.",
@@ -1713,34 +1713,60 @@ const STARTED: Readonly<Record<string, ReadonlySet<string>>> = {
1713
1713
  return: new Set([...GOING, "CHARGING_CYCLE", "CHARGED"]),
1714
1714
  };
1715
1715
  const RESTING = /^(Standing by|Charging|Charged|Working on a task)$/;
1716
+ /** The robot got there: the line stops following. */
1717
+ const ARRIVED = /^(Arrived at|Back on charger)/;
1718
+ /** After a start, the line follows the robot's own words this long (then keeps the last). */
1719
+ export const COMMAND_FOLLOW_MS = 60_000;
1720
+ /** Robot detail reads the robot this often for COMMAND_FOLLOW_MS after a command (else every 4 s). */
1721
+ export const FAST_POLL_MS = 1000;
1722
+ /**
1723
+ * Under the live view. Measured on prod 2026-09-27: the PadBot/EZVIZ picture
1724
+ * is 7-10 s behind (4 s HLS segments upstream); the map pin comes from the
1725
+ * position read / MQTT push and leads it.
1726
+ */
1727
+ export const VIDEO_LAG_NOTE = "Video is about 8 s behind live; the map shows the robot's position in real time.";
1728
+ /** The line the moment the robot cloud accepts a moving command (the S2 takes 8-13 s to undock). */
1729
+ export function sentText(docked: boolean): string {
1730
+ return docked ? "Sent to robot — it usually takes about 10 s to leave the charger." : "Sent to robot — waiting for it to move…";
1731
+ }
1716
1732
 
1717
1733
  /**
1718
1734
  * What an accepted command actually did, from a reading taken AFTER it was
1719
1735
  * sent (never the optimistic status). The robot cloud's "accepted" is not
1720
1736
  * "done": PadBot answers code 0 and the robot may still not move.
1721
- * - started: the robot shows it acted -> `${who} started ${what}.`
1737
+ * - started: the robot shows it acted -> what it is doing in its own words
1738
+ * (`${who}: Leaving the charger…`, `${who}: On the way to P4…`), else
1739
+ * `${who} started ${what}.`; `done` once it arrives ("Arrived at P4",
1740
+ * "Back on charger") or COMMAND_FOLLOW_MS has passed.
1722
1741
  * - still resting after COMMAND_WATCH_MS -> `${who} didn't start — <reason>`
1723
1742
  * (the robot's own words when it gave any, e.g. "Could not reach P2 (...)").
1724
1743
  * - otherwise: keep watching (null).
1725
1744
  * Commands without a visible outcome (cancel, set position, pause) -> null.
1745
+ * The PadBot S2 drives with its navigation state idle; the API reads the
1746
+ * charging state and the position instead, so "started" is real movement.
1726
1747
  */
1727
1748
  export function commandOutcome(
1728
1749
  kind: unknown,
1729
1750
  reading: { online?: unknown; mode?: unknown; statusName?: unknown; fault?: { message?: unknown } | null } | null | undefined,
1730
1751
  elapsedMs: number,
1731
1752
  input: { name?: unknown; what?: unknown } = {},
1732
- ): { ok: boolean; text: string } | null {
1753
+ ): { ok: boolean; text: string; done: boolean } | null {
1733
1754
  const started = STARTED[String(kind)];
1734
1755
  if (!started || !reading) return null;
1735
1756
  const who = (typeof input.name === "string" && input.name) || "The robot";
1736
1757
  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
1758
  const said = typeof reading.statusName === "string" ? reading.statusName.split(" · ")[0]!.trim() : "";
1759
+ if (ARRIVED.test(said)) return { ok: true, text: `${who}: ${said}.`, done: true };
1760
+ if (typeof reading.mode === "string" && started.has(reading.mode)) {
1761
+ const doing = said && !RESTING.test(said) ? said : "";
1762
+ return { ok: true, text: doing ? `${who}: ${doing}…` : `${who} started ${what}.`, done: elapsedMs >= COMMAND_FOLLOW_MS };
1763
+ }
1764
+ if (elapsedMs < COMMAND_WATCH_MS) return null;
1740
1765
  const fault = typeof reading.fault?.message === "string" ? reading.fault.message : "";
1741
1766
  const reason = reading.online === false ? "it stopped reporting" : fault || (said && !RESTING.test(said) ? said : "");
1742
1767
  return {
1743
1768
  ok: false,
1769
+ done: true,
1744
1770
  text: reason
1745
1771
  ? `${who} didn't start — ${reason}.`
1746
1772
  : `${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.`,