@7365admin1/layer-common 4.57.2 → 4.58.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,17 @@
1
1
  # @iservice365/layer-common
2
2
 
3
+ ## 4.58.0
4
+
5
+ ### Minor Changes
6
+
7
+ - fd4c0c9: Robots: when a robot is online but its position isn't arriving, robot detail, the site mini-map and the building map say so in a calm banner ("<Robot> is online, but we're not receiving its position right now.") and detail keeps the last position seen faded, tagged "last known · HH:MM". The live-view segment fix now also catches a stray H.264 delimiter split across two TS packets. New `scripts/sync-robot-live.mjs` copies the web live-view player into the mobile apps.
8
+
9
+ ## 4.57.3
10
+
11
+ ### Patch Changes
12
+
13
+ - c23d427: Robot live view: the PadBot S2's EZVIZ camera puts an H.264 access-unit delimiter in front of every H.265 frame, which made Chrome refuse every frame (decode error, black picture). The player now neutralises those units in each segment before playback, so the picture shows. The "couldn't be decoded" message no longer blames EZVIZ encryption (disproved).
14
+
3
15
  ## 4.57.2
4
16
 
5
17
  ### Patch Changes
@@ -1605,11 +1617,11 @@
1605
1617
  `utils/console-tier.ts` + `composables/useConsoleTier.ts` mirror the server's
1606
1618
  own rule from two endpoints the console already calls, unprojected:
1607
1619
 
1608
- GET /api/members/user/:user/app/admin the Seven365 staff membership
1609
- GET /api/roles/id/:role that membership's role document
1620
+ GET /api/members/user/:user/app/admin the Seven365 staff membership
1621
+ GET /api/roles/id/:role that membership's role document
1610
1622
 
1611
- owner = member.type === "admin" && role.type === "admin" && role.default === true
1612
- staff = member.type === "admin" && role.type === "admin"
1623
+ owner = member.type === "admin" && role.type === "admin" && role.default === true
1624
+ staff = member.type === "admin" && role.type === "admin"
1613
1625
 
1614
1626
  `role.default` is the marker because it is the only property of a platform
1615
1627
  staff role no API caller can set - `role.controller.ts` validates create and
@@ -219,6 +219,7 @@
219
219
  </div>
220
220
  <div class="hero-bmap__traytext">{{ unplacedLine }}</div>
221
221
  </div>
222
+ <div v-if="gapText" class="hero-notice" role="status">{{ gapText }}</div>
222
223
  </aside>
223
224
  </div>
224
225
  </template>
@@ -246,6 +247,8 @@ import {
246
247
  routeTags,
247
248
  rowStatus,
248
249
  unplacedRobots,
250
+ positionGap,
251
+ positionGapText,
249
252
  unplacedText,
250
253
  type TMapRobot,
251
254
  type TPatrolRouteView,
@@ -430,6 +433,11 @@ const shafts = computed(() =>
430
433
  );
431
434
 
432
435
  const unplaced = computed(() => unplacedRobots(rows.value, states.value));
436
+ /** Online robots whose position isn't arriving: not drawn, named instead. */
437
+ const gapText = computed(() => {
438
+ const names = rows.value.filter((r) => positionGap(states.value[String(r._id)], states.value[String(r._id)]?.capabilities)).map((r) => String(r.name ?? ""));
439
+ return names.length ? positionGapText(names) : "";
440
+ });
433
441
  const unplacedLine = computed(() => unplacedText(unplaced.value.map((r) => brandName(r.brand, brands.value))) ?? "");
434
442
 
435
443
  const emptyNote = computed(() => {
@@ -74,6 +74,7 @@
74
74
  <div v-if="!noInterface" class="hero-detail__grid">
75
75
  <div>
76
76
  <div class="hero-section__title">Location</div>
77
+ <div v-if="gapText" class="hero-notice" role="status">{{ gapText }}</div>
77
78
  <div v-if="locationFacts.length" class="hero-facts hero-facts--tight" role="list" aria-label="Robot location">
78
79
  <div v-for="f in locationFacts" :key="f.label" class="hero-fact" role="listitem">
79
80
  <div class="hero-fact__label">{{ f.label }}</div>
@@ -95,12 +96,12 @@
95
96
  </div>
96
97
  <RobotLivePlan
97
98
  :name="record?.name"
98
- :status-label="statusLabel"
99
- :point="posLive && robotOnShownMap ? point : null"
100
- :stale="shown?.stale === true"
101
- :heading="headingDeg(shown)"
102
- :trail="trail"
103
- :readout="readout"
99
+ :status-label="showLastKnown ? lastKnownLabel(lastKnown!.at, now) : statusLabel"
100
+ :point="posLive && robotOnShownMap ? point : showLastKnown ? lastKnown!.point : null"
101
+ :stale="shown?.stale === true || showLastKnown"
102
+ :heading="showLastKnown ? lastKnown!.heading : headingDeg(shown)"
103
+ :trail="showLastKnown ? [] : trail"
104
+ :readout="showLastKnown ? '' : readout"
104
105
  :lost="posLost"
105
106
  :route="route"
106
107
  :routes="selectedMap ? mapRoutes : null"
@@ -242,6 +243,7 @@ import {
242
243
  faultBadge,
243
244
  headingDeg,
244
245
  isMoving,
246
+ lastKnownLabel,
245
247
  mergePose,
246
248
  optimisticMode,
247
249
  patrolRunHint,
@@ -250,6 +252,8 @@ import {
250
252
  savedPointGroups,
251
253
  unplacedStopsText,
252
254
  planPoint,
255
+ positionGap,
256
+ positionGapText,
253
257
  positionText,
254
258
  robotAbilities,
255
259
  robotErrorMessage,
@@ -379,6 +383,16 @@ const point = computed(() => planPoint(shown.value as any));
379
383
  const posLive = computed(() => reportsPosition.value && online.value && !streamLost.value && !!point.value);
380
384
  const posLost = computed(() => reportsPosition.value && !!live.value && (!live.value.ok || live.value.online === false || streamLost.value));
381
385
  const trail = ref<Array<{ x: number; y: number }>>([]);
386
+ /* Online, but no coordinates arriving (a gap on our side, not "the robot is lost"): a calm banner, and the last position seen on this screen drawn faded. */
387
+ const gap = computed(() => positionGap(live.value, caps.value));
388
+ const lastKnown = ref<{ point: { left: string; top: string }; heading: number; at: number; mapId: string | null } | null>(null);
389
+ watch(point, (p) => {
390
+ if (p && posLive.value) lastKnown.value = { point: p, heading: headingDeg(shown.value), at: live.value?.positionAt ?? Date.now(), mapId: live.value?.mapId ?? null };
391
+ });
392
+ const showLastKnown = computed(
393
+ () => gap.value && !!lastKnown.value && (!selectedMap.value || !lastKnown.value.mapId || lastKnown.value.mapId === selectedMap.value.mapId),
394
+ );
395
+ const gapText = computed(() => (gap.value ? positionGapText(record.value?.name ?? "", showLastKnown.value) : ""));
382
396
  watch(
383
397
  () => shown.value?.planPosition,
384
398
  (p) => {
@@ -455,7 +469,9 @@ watch(
455
469
  /** Without robot maps the placeholder plan shows the robot as before; with them, only on its own map. */
456
470
  const robotOnShownMap = computed(() => !selectedMap.value || selectedMap.value.current);
457
471
  const currentMapLabel = computed(() => mapViews.value.find((m) => m.current)?.label ?? "");
458
- const hereText = computed(() => (currentMapLabel.value ? `The robot is on ${currentMapLabel.value}` : "The robot has not said which map it is on"));
472
+ const hereText = computed(() =>
473
+ currentMapLabel.value ? `The robot is on ${currentMapLabel.value}` : gap.value ? "" : "We're not receiving which map the robot is on right now",
474
+ );
459
475
  /** The routes saved on the map shown (a route without a map id goes with the robot's current map). */
460
476
  const mapRoutes = computed(() =>
461
477
  (patrol.value?.routes ?? []).filter((r) => (r.mapId ? r.mapId === selectedMap.value?.mapId : selectedMap.value?.current)),
@@ -18,7 +18,7 @@
18
18
  <span v-for="t in trailDots(trail)" :key="t.key" class="hero-trail" :style="{ left: t.left, top: t.top, opacity: t.op }" aria-hidden="true" />
19
19
  <span class="hero-cross hero-cross--h" :style="{ top: point.top, transition: `top ${tickMs}ms linear` }" aria-hidden="true" />
20
20
  <span class="hero-cross hero-cross--v" :style="{ left: point.left, transition: `left ${tickMs}ms linear` }" aria-hidden="true" />
21
- <div class="hero-readout">{{ readout }}</div>
21
+ <div v-if="readout" class="hero-readout">{{ readout }}</div>
22
22
  <div class="hero-me" :class="{ 'hero-me--stale': stale }" :style="{ left: point.left, top: point.top, transition: `left ${tickMs}ms linear, top ${tickMs}ms linear` }">
23
23
  <div class="hero-me__tag">{{ name }} <span>{{ statusLabel }}</span></div>
24
24
  <div class="hero-me__radar" aria-hidden="true" />
@@ -55,6 +55,7 @@
55
55
  <span v-if="floor !== null" class="hero-mini__count">{{ robots.length }} on {{ floorName(floor) }}</span>
56
56
  </div>
57
57
  <div v-if="unplaced" class="hero-notice">{{ unplaced }}</div>
58
+ <div v-if="gapText" class="hero-notice" role="status">{{ gapText }}</div>
58
59
  </div>
59
60
  </template>
60
61
 
@@ -72,6 +73,8 @@ import {
72
73
  mapRobots,
73
74
  nameTagSide,
74
75
  pickFloor,
76
+ positionGap,
77
+ positionGapText,
75
78
  robotStatus,
76
79
  unplacedRobots,
77
80
  unplacedText,
@@ -159,6 +162,12 @@ const unplaced = computed(() =>
159
162
  unplacedText(unplacedRobots(props.rows, props.states).map((r) => brandName(r.brand, props.brands))),
160
163
  );
161
164
 
165
+ /** Online robots whose position isn't arriving: not drawn (never a guessed dot), named here instead. */
166
+ const gapText = computed(() => {
167
+ const names = props.rows.filter((r) => positionGap(props.states[String(r._id)], props.states[String(r._id)]?.capabilities)).map((r) => String(r.name ?? ""));
168
+ return names.length ? positionGapText(names) : "";
169
+ });
170
+
162
171
  const TONE: Record<string, string> = {
163
172
  success: "rgb(var(--v-theme-success))",
164
173
  warning: "rgb(var(--v-theme-warning))",
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.57.2",
5
+ "version": "4.58.0",
6
6
  "author": "7365admin1",
7
7
  "main": "./nuxt.config.ts",
8
8
  "//files": "What a consumer extending this layer actually loads. Without this npm ships the whole working tree - the changesets, the CI workflows, the render harness in tools/ and any scratch directory that happened to exist at publish time. Nuxt resolves a layer by directory, so every runtime directory below has to stay listed; adding a new top-level runtime directory means adding it here too.",
@@ -1,8 +1,9 @@
1
1
  /**
2
- * Web-only robot controls: the live-view player's URL handling and the
3
- * manual-drive client. Not part of the mobile-synced `robot-display.ts`
4
- * (mobile has no live view or manual control - parity exception, like map
5
- * editing, decision 81).
2
+ * Robot controls: the live-view player's URL handling and the manual-drive
3
+ * client. Not part of the mobile-synced `robot-display.ts`. The live-view
4
+ * player also runs on the phones: `scripts/sync-robot-live.mjs` bundles
5
+ * `startLivePlayer` into a WebView page for the RN apps. Manual control stays
6
+ * web-only (parity exception, like map editing, decision 81).
6
7
  */
7
8
 
8
9
  /* ============================================================ live view */
@@ -38,11 +39,12 @@ export const LIVE_NO_PICTURE =
38
39
  export const LIVE_DOWN = "The robot's camera isn't available right now.";
39
40
  /**
40
41
  * The decoder says it supports the codec but the frames still won't decode
41
- * (MEDIA_ERR_DECODE). Seen on the PadBot S2 (EZVIZ H3): EZVIZ device video
42
- * encryption scrambles the frames, so plain HLS can't show them. It's not the browser.
42
+ * (MEDIA_ERR_DECODE). On the PadBot S2 (EZVIZ H3) that was the stray H.264
43
+ * delimiters `neutraliseStrayAud` now removes (NOT encryption - disproved
44
+ * 2026-09-26 by a software decode of the real stream); kept for anything else.
43
45
  */
44
46
  export const LIVE_UNDECODABLE =
45
- "The robot's camera video couldn't be decoded. This usually means video encryption is switched on in the camera's EZVIZ app. Ask the administrator to turn it off, or contact PadBot.";
47
+ "The robot's camera video couldn't be decoded. Press Retry. If it keeps happening, contact Seven365 support.";
46
48
  const MEDIA_ERR_DECODE = 3;
47
49
 
48
50
  /** Wait before automatic reconnect attempt n (0-based): 2 s, 4 s, 8 s, 16 s, then 30 s. */
@@ -58,7 +60,72 @@ type THls = {
58
60
  swapAudioCodec(): void;
59
61
  destroy(): void;
60
62
  };
61
- export type THlsCtor = { isSupported(): boolean; new (config?: Record<string, unknown>): THls };
63
+ export type THlsCtor = { isSupported(): boolean; new (config?: Record<string, unknown>): THls; DefaultConfig?: { loader?: any } };
64
+
65
+ /**
66
+ * The EZVIZ camera on the PadBot S2 puts an H.264 access-unit delimiter
67
+ * (00 00 01 09 xx) in front of every H.265 frame. Read as H.265 that is a
68
+ * "TSA_N" slice with temporal id 0 - invalid. ffmpeg skips it; hls.js packs it
69
+ * into the frame and Chrome's decoder then refuses every frame
70
+ * (PIPELINE_ERROR_DECODE, 0 frames). Proven on the real stream 2026-09-26:
71
+ * with these units removed Chrome's own decoder decodes 40/40 frames.
72
+ *
73
+ * Rewrites each such header in place, inside the TS packets of video PIDs
74
+ * only, to an "unspecified" H.265 unit (type 48) that hls.js and decoders
75
+ * ignore. Same length, so the TS stays intact. A valid single-layer H.265
76
+ * header never matches (its temporal id is never 0). Returns the count.
77
+ */
78
+ export function neutraliseStrayAud(data: ArrayBuffer | Uint8Array): number {
79
+ const b = data instanceof Uint8Array ? data : new Uint8Array(data);
80
+ // Per video PID: where its last 4 payload bytes sit, so a header split across
81
+ // two TS packets is still found (a remuxed stream can put one there - 6 of 240
82
+ // in a local ffmpeg remux, and one missed header stops the picture).
83
+ const tails = new Map<number, number[]>();
84
+ let n = 0;
85
+ for (let i = 0; i + 188 <= b.length; i += 188) {
86
+ if (b[i] !== 0x47) continue;
87
+ const afc = (b[i + 3]! >> 4) & 3;
88
+ if (!(afc & 1)) continue;
89
+ const pid = ((b[i + 1]! & 0x1f) << 8) | b[i + 2]!;
90
+ let p = i + 4;
91
+ if (afc & 2) p += 1 + b[p]!;
92
+ const end = i + 188;
93
+ if (p >= end) continue;
94
+ // A PES start names its stream: 0xE0-0xEF = video. The PES header is never inside a NAL unit.
95
+ if (b[i + 1]! & 0x40 && b[p] === 0 && b[p + 1] === 0 && b[p + 2] === 1 && (b[p + 3]! & 0xf0) === 0xe0) tails.set(pid, []);
96
+ const tail = tails.get(pid);
97
+ if (!tail) continue;
98
+ const t = tail.length;
99
+ const at = (k: number) => (k < t ? tail[k]! : p + k - t);
100
+ const len = t + end - p;
101
+ for (let k = 0; k + 4 < len; k++) {
102
+ if (b[at(k)] === 0 && b[at(k + 1)] === 0 && b[at(k + 2)] === 1 && b[at(k + 3)] === 0x09 && (b[at(k + 4)]! & 7) === 0) {
103
+ b[at(k + 3)] = 0x60;
104
+ b[at(k + 4)] = 0x01;
105
+ n++;
106
+ k += 4;
107
+ }
108
+ }
109
+ tails.set(pid, Array.from({ length: Math.min(4, len) }, (_, j) => at(len - Math.min(4, len) + j)));
110
+ }
111
+ return n;
112
+ }
113
+
114
+ /** hls.js fragment loader that runs `neutraliseStrayAud` on every loaded segment. */
115
+ function strayAudLoader(Base: any) {
116
+ return class extends Base {
117
+ load(context: any, config: any, callbacks: any) {
118
+ const ok = callbacks.onSuccess;
119
+ super.load(context, config, {
120
+ ...callbacks,
121
+ onSuccess: (response: any, ...rest: any[]) => {
122
+ if (response?.data instanceof ArrayBuffer) neutraliseStrayAud(response.data);
123
+ ok(response, ...rest);
124
+ },
125
+ });
126
+ }
127
+ };
128
+ }
62
129
 
63
130
  /** The inline panel streams only while it is switched on, the robot is online, the tab is visible and the panel is near the screen. */
64
131
  export const liveViewActive = (s: { on: boolean; offline?: boolean; visible: boolean; inView: boolean }) =>
@@ -186,7 +253,8 @@ export function startLivePlayer(
186
253
  if (!Hls.isSupported()) return native();
187
254
  video.addEventListener("error", noteDecode);
188
255
  let recoveries = 0;
189
- const h = (hls = new Hls({ enableWorker: true, lowLatencyMode: false }));
256
+ const Base = Hls.DefaultConfig?.loader;
257
+ const h = (hls = new Hls({ enableWorker: true, lowLatencyMode: false, ...(Base ? { fLoader: strayAudLoader(Base) } : {}) }));
190
258
  h.on("hlsError", (_e, data) => {
191
259
  noteDecode();
192
260
  if (!data.fatal) return;
@@ -1204,13 +1204,44 @@ export function positionText(live: TLiveRobotState | null | undefined, capabilit
1204
1204
  if (capabilities?.readLocation !== true) return "Not reported";
1205
1205
  const p = live?.position;
1206
1206
  if (!live?.ok || live.online === false) return "Reconnecting";
1207
- // Answering, but it has no position right now (e.g. not relocalised) - said, not guessed.
1207
+ // Answering, but no position came with this reading - said, not guessed (see positionGap).
1208
1208
  if (!p || !finite(p.x) || !finite(p.y)) return "Position not reported right now";
1209
1209
  const deg = finite(p.theta) ? Math.round(((((p.theta * 180) / Math.PI) % 360) + 360) % 360) : 0;
1210
1210
  const seen = staleText(live, now);
1211
1211
  return `x ${p.x.toFixed(1)} · y ${p.y.toFixed(1)} · ${deg}°${seen ? ` · ${seen}` : ""}`;
1212
1212
  }
1213
1213
 
1214
+ /**
1215
+ * The robot is online and its brand is meant to report where it is, but this
1216
+ * reading carries no coordinates. Said as a gap in what we receive - never as
1217
+ * "the robot is lost": only the brand can say that, and none does today.
1218
+ */
1219
+ export function positionGap(live: TLiveRobotState | null | undefined, capabilities: Record<string, unknown> | null | undefined): boolean {
1220
+ if (capabilities?.readLocation !== true || !live?.ok || live.online !== true || live.monitorable === false) return false;
1221
+ const p = live.position;
1222
+ return !p || !finite(p.x) || !finite(p.y);
1223
+ }
1224
+
1225
+ /**
1226
+ * "HERO-OTH is online, but we're not receiving its position right now. Its last
1227
+ * known position is shown faded." Several robots: one sentence, no faded part.
1228
+ */
1229
+ export function positionGapText(names: string | string[], lastKnownShown = false): string {
1230
+ const list = (Array.isArray(names) ? names : [names]).map((n) => (typeof n === "string" && n.trim()) || "The robot");
1231
+ if (!list.length) return "";
1232
+ const one = list.length === 1;
1233
+ const who = one ? list[0] : `${list.slice(0, -1).join(", ")} and ${list[list.length - 1]}`;
1234
+ const head = `${who} ${one ? "is" : "are"} online, but we're not receiving ${one ? "its position" : "their positions"} right now.`;
1235
+ return one && lastKnownShown ? `${head} Its last known position is shown faded.` : head;
1236
+ }
1237
+
1238
+ /** The faded marker's tag: "last known · 14:05" (device clock); "last known · 2 d ago" once older than 20 h. */
1239
+ export function lastKnownLabel(at: unknown, now: number = Date.now()): string {
1240
+ const t = typeof at === "number" ? at : at instanceof Date ? at.getTime() : NaN;
1241
+ if (!Number.isFinite(t)) return "last known";
1242
+ return now - t > 20 * 3_600_000 ? `last known · ${agoText(t, now)}` : `last known · ${new Date(t).toTimeString().slice(0, 5)}`;
1243
+ }
1244
+
1214
1245
  export type TRobotFact = { label: string; value: string; dim: boolean };
1215
1246
 
1216
1247
  /**