@7365admin1/layer-common 4.52.0 → 4.54.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.
@@ -0,0 +1,128 @@
1
+ <template>
2
+ <!--
3
+ Manual control (Manager only, PADBOT_MANUAL_DRIVE on the server). Hold a
4
+ button to move; letting go stops. The server forwards motion only while
5
+ this screen's heartbeat keeps arriving and stops the robot the moment it
6
+ does not - closing, hiding the tab or losing the connection all stop it.
7
+ -->
8
+ <div class="hero-dialog hero-drive" role="dialog" aria-labelledby="hero-drive-title" tabindex="-1" @keydown="onKey" @keyup="onKeyUp">
9
+ <div id="hero-drive-title" class="hero-dialog__title">Manual control · {{ name }}</div>
10
+ <div v-if="starting" class="hero-muted">Starting manual control…</div>
11
+ <div v-else-if="ended" class="hero-notice" role="alert">{{ ended }}</div>
12
+ <template v-else>
13
+ <div class="hero-dialog__body">Hold a button to move. Let go and the robot stops. {{ speedLimitText(limits) }}.</div>
14
+ <button type="button" class="hero-drive__stop" @click="client?.stop()">STOP</button>
15
+ <div class="hero-drive__pad" aria-label="Drive">
16
+ <span />
17
+ <button type="button" v-bind="hold('forward')">▲ Forward</button>
18
+ <span />
19
+ <button type="button" v-bind="hold('left')">◀ Turn left</button>
20
+ <span />
21
+ <button type="button" v-bind="hold('right')">Turn right ▶</button>
22
+ <span />
23
+ <button type="button" v-bind="hold('back')">▼ Back</button>
24
+ <span />
25
+ </div>
26
+ <div class="hero-drive__head" aria-label="Head">
27
+ <span class="hero-muted">Head</span>
28
+ <button type="button" @click="client?.look('left')">Look left</button>
29
+ <button type="button" @click="client?.look('right')">Look right</button>
30
+ <button type="button" @click="client?.look('up')">Look up</button>
31
+ <button type="button" @click="client?.look('down')">Look down</button>
32
+ <button type="button" @click="client?.look('centre')">Centre</button>
33
+ </div>
34
+ </template>
35
+ <div class="hero-dialog__actions">
36
+ <v-btn variant="outlined" @click="close">Close</v-btn>
37
+ </div>
38
+ </div>
39
+ </template>
40
+
41
+ <script setup lang="ts">
42
+ import { onBeforeUnmount, onMounted, ref } from "vue";
43
+ import { createDriveClient, speedLimitText, type TDriveDirection, type TDriveLimits } from "../../utils/robot-control";
44
+ import { robotErrorMessage } from "../../utils/robot-display";
45
+
46
+ const props = defineProps<{ robotId: string; name?: string }>();
47
+ const emit = defineEmits<{ close: [] }>();
48
+ const { startDrive, driveSignal } = useRobots();
49
+
50
+ const starting = ref(true);
51
+ const ended = ref("");
52
+ const limits = ref<TDriveLimits | null>(null);
53
+ let client: ReturnType<typeof createDriveClient> | null = null;
54
+
55
+ const release = () => client?.release();
56
+ function hold(dir: TDriveDirection) {
57
+ return {
58
+ onPointerdown: (e: PointerEvent) => {
59
+ (e.target as HTMLElement).setPointerCapture?.(e.pointerId);
60
+ client?.press(dir);
61
+ },
62
+ onPointerup: release,
63
+ onPointerleave: release,
64
+ onPointercancel: release,
65
+ onBlur: release,
66
+ onContextmenu: (e: Event) => {
67
+ e.preventDefault();
68
+ release();
69
+ },
70
+ };
71
+ }
72
+ const KEYS: Record<string, TDriveDirection> = { ArrowUp: "forward", ArrowDown: "back", ArrowLeft: "left", ArrowRight: "right" };
73
+ function onKey(e: KeyboardEvent) {
74
+ if (e.key === " ") {
75
+ e.preventDefault();
76
+ client?.stop();
77
+ } else if (KEYS[e.key] && !e.repeat) {
78
+ e.preventDefault();
79
+ client?.press(KEYS[e.key]!);
80
+ }
81
+ }
82
+ function onKeyUp(e: KeyboardEvent) {
83
+ if (KEYS[e.key]) release();
84
+ }
85
+
86
+ const finish = () => client?.end();
87
+ const onHidden = () => {
88
+ if (document.visibilityState === "hidden") finish();
89
+ };
90
+ function close() {
91
+ finish();
92
+ emit("close");
93
+ }
94
+
95
+ onMounted(async () => {
96
+ document.addEventListener("visibilitychange", onHidden);
97
+ window.addEventListener("pagehide", finish);
98
+ window.addEventListener("beforeunload", finish);
99
+ window.addEventListener("blur", release);
100
+ try {
101
+ const res = await startDrive(props.robotId);
102
+ const l = res?.limits ?? {};
103
+ limits.value = {
104
+ maxLinear: Number(l.maxLinear) || 0.3,
105
+ maxAngular: Number(l.maxAngular) || 30,
106
+ heartbeatMs: Math.min(100, Number(l.heartbeatMs) || 100),
107
+ headPan: Array.isArray(l.headPan) ? l.headPan : [-90, 90],
108
+ headTilt: Array.isArray(l.headTilt) ? l.headTilt : [-15, 30],
109
+ };
110
+ client = createDriveClient({
111
+ send: (body, opts) => driveSignal(props.robotId, res.session, body, opts),
112
+ limits: limits.value,
113
+ onEnd: (reason) => (ended.value = reason || "Manual control ended."),
114
+ });
115
+ } catch (e) {
116
+ ended.value = robotErrorMessage(e, "Manual control could not start.");
117
+ } finally {
118
+ starting.value = false;
119
+ }
120
+ });
121
+ onBeforeUnmount(() => {
122
+ finish();
123
+ document.removeEventListener("visibilitychange", onHidden);
124
+ window.removeEventListener("pagehide", finish);
125
+ window.removeEventListener("beforeunload", finish);
126
+ window.removeEventListener("blur", release);
127
+ });
128
+ </script>
@@ -33,7 +33,8 @@
33
33
  :key="r.id"
34
34
  type="button"
35
35
  class="hero-bot"
36
- :title="r.name"
36
+ :class="{ 'hero-bot--stale': r.stale }"
37
+ :title="r.stale ? `${r.name} · ${r.seen}` : r.name"
37
38
  :style="{ left: r.left, top: r.top, transition: `left ${tickMs}ms linear, top ${tickMs}ms linear`, '--hero-bot': toneColor(r) }"
38
39
  @click="emit('open', r.id)"
39
40
  >
@@ -0,0 +1,100 @@
1
+ <template>
2
+ <!--
3
+ Patrol history (brands with `patrolHistory`, e.g. PadBot): the robot's past
4
+ patrols from its own cloud, newest first; a row opens what was captured at
5
+ each checkpoint. Read only; photo links open the brand's image.
6
+ -->
7
+ <div class="hero-patrolhist">
8
+ <div class="hero-aside__head">
9
+ <span class="hero-aside__title">Patrol history</span>
10
+ <span class="hero-aside__hint">route · result · time · distance · alerts</span>
11
+ </div>
12
+ <div v-if="error" class="hero-sched__empty">{{ error }}</div>
13
+ <div v-else-if="loaded && !rows.length" class="hero-sched__empty">No patrols recorded yet.</div>
14
+ <template v-for="r in rows" :key="r.key">
15
+ <button type="button" class="hero-sched__row hero-patrolhist__row" :aria-expanded="open === r.key" @click="toggle(r.key)">
16
+ <b>{{ r.when }}</b><span>{{ r.route }} · <span :class="`hero-patrolhist__${r.tone}`">{{ r.result }}</span></span><span>{{ r.detail }}</span>
17
+ </button>
18
+ <div v-if="open === r.key" class="hero-patrolhist__visits">
19
+ <div v-if="visitsLoading" class="hero-sched__empty">Loading checkpoints…</div>
20
+ <div v-else-if="!visits.length" class="hero-sched__empty">No checkpoint captures for this patrol.</div>
21
+ <div v-for="(v, i) in visits" :key="i" class="hero-patrolhist__visit">
22
+ <b>{{ v.checkpoint || "Checkpoint" }}</b>
23
+ <span>{{ v.skipped ? "Skipped" : activityWhen(v.at, now) }}</span>
24
+ <span v-if="v.centerTempC !== null">{{ v.centerTempC }}°C (max {{ v.maxTempC ?? "—" }}°C)</span>
25
+ <a v-if="safeUrl(v.imageUrl)" :href="safeUrl(v.imageUrl)" target="_blank" rel="noopener noreferrer">Photo</a>
26
+ <a v-if="safeUrl(v.thermalImageUrl)" :href="safeUrl(v.thermalImageUrl)" target="_blank" rel="noopener noreferrer">Thermal</a>
27
+ </div>
28
+ </div>
29
+ </template>
30
+ <div v-if="pages > 1" class="hero-patrolhist__foot">
31
+ <v-btn variant="text" size="small" :disabled="page <= 1" @click="load(page - 1)">Newer</v-btn>
32
+ <span>Page {{ page }} of {{ pages }}</span>
33
+ <v-btn variant="text" size="small" :disabled="page >= pages" @click="load(page + 1)">Older</v-btn>
34
+ </div>
35
+ </div>
36
+ </template>
37
+
38
+ <script setup lang="ts">
39
+ import { computed, ref, watch } from "vue";
40
+ import { activityWhen, patrolRecordRow, robotErrorMessage } from "../../utils/robot-display";
41
+
42
+ const props = defineProps<{ robotId: string }>();
43
+ const { getPatrolHistory, getPatrolVisits } = useRobots();
44
+
45
+ const items = ref<any[]>([]);
46
+ const page = ref(1);
47
+ const pages = ref(0);
48
+ const loaded = ref(false);
49
+ const error = ref("");
50
+ const open = ref("");
51
+ const visits = ref<any[]>([]);
52
+ const visitsLoading = ref(false);
53
+ const now = Date.now();
54
+ const rows = computed(() => items.value.map((r) => patrolRecordRow(r, now)));
55
+
56
+ /** Only http(s) links from the brand are rendered. */
57
+ const safeUrl = (u: unknown) => (typeof u === "string" && /^https?:\/\//i.test(u) ? u : "");
58
+
59
+ async function load(p = 1) {
60
+ error.value = "";
61
+ try {
62
+ const res = await getPatrolHistory(props.robotId, { page: p, limit: 10 });
63
+ items.value = res?.items ?? [];
64
+ pages.value = res?.pages ?? 0;
65
+ page.value = p;
66
+ } catch (e) {
67
+ error.value = robotErrorMessage(e, "Could not load the patrol history.");
68
+ } finally {
69
+ loaded.value = true;
70
+ }
71
+ }
72
+
73
+ async function toggle(key: string) {
74
+ if (open.value === key) return void (open.value = "");
75
+ open.value = key;
76
+ visits.value = [];
77
+ visitsLoading.value = true;
78
+ try {
79
+ visits.value = (await getPatrolVisits(props.robotId, key))?.items ?? [];
80
+ } catch {
81
+ visits.value = [];
82
+ } finally {
83
+ visitsLoading.value = false;
84
+ }
85
+ }
86
+
87
+ watch(() => props.robotId, () => load(1), { immediate: true });
88
+ </script>
89
+
90
+ <style scoped>
91
+ .hero-patrolhist { margin-top: 20px; }
92
+ .hero-patrolhist__row { width: 100%; text-align: left; background: none; border: 0; cursor: pointer; color: inherit; font: inherit; }
93
+ .hero-patrolhist__visits { padding: 4px 0 8px 16px; }
94
+ .hero-patrolhist__visit { display: flex; flex-wrap: wrap; gap: 12px; padding: 4px 0; font-size: 12px; }
95
+ .hero-patrolhist__visit a { color: inherit; text-decoration: underline; }
96
+ .hero-patrolhist__foot { display: flex; align-items: center; gap: 8px; font-size: 12px; margin-top: 6px; }
97
+ .hero-patrolhist__success { color: rgb(var(--v-theme-success)); }
98
+ .hero-patrolhist__warning { color: rgb(var(--v-theme-warning)); }
99
+ .hero-patrolhist__info { color: rgb(var(--v-theme-info)); }
100
+ </style>
@@ -0,0 +1,33 @@
1
+ <template>
2
+ <!-- Go to point / Set position: the robot's own saved points, per map. -->
3
+ <div class="hero-dialog" role="dialog" aria-labelledby="hero-pick-title">
4
+ <div id="hero-pick-title" class="hero-dialog__title">{{ kind === "navigate" ? "Go to point" : "Set position" }}</div>
5
+ <div v-if="kind === 'relocalize'" class="hero-dialog__body">Only points on the floor the robot reports are listed.</div>
6
+ <div v-if="!groups.length" class="hero-muted">No saved points{{ kind === "relocalize" ? " on the robot's current floor" : "" }}.</div>
7
+ <div v-for="g in groups" :key="g.mapId" class="hero-pick__group">
8
+ <div class="hero-aside__title">{{ g.title }}</div>
9
+ <button v-for="p in g.points" :key="p.targetPointId" type="button" class="hero-pick__point" :aria-pressed="chosen === p" @click="chosen = p">
10
+ {{ p.name }} <span class="hero-dim">{{ p.type }}</span>
11
+ </button>
12
+ </div>
13
+ <div v-if="chosen && kind === 'relocalize'" class="hero-notice">
14
+ Only do this when the robot is standing at {{ chosen.name }} right now - it tells the robot where it is.
15
+ </div>
16
+ <div class="hero-dialog__actions">
17
+ <v-btn variant="outlined" @click="emit('close')">Cancel</v-btn>
18
+ <v-btn color="primary" variant="flat" :disabled="!chosen" @click="chosen && emit('pick', chosen)">
19
+ {{ kind === "navigate" ? `Send to ${chosen?.name ?? "point"}` : `Robot is at ${chosen?.name ?? "point"}` }}
20
+ </v-btn>
21
+ </div>
22
+ </div>
23
+ </template>
24
+
25
+ <script setup lang="ts">
26
+ import { computed, ref } from "vue";
27
+ import type { TSavedPoint, TSavedPointGroup } from "../../utils/robot-display";
28
+
29
+ const props = defineProps<{ kind: "navigate" | "relocalize"; groups: TSavedPointGroup[]; floorNo?: number | null }>();
30
+ const emit = defineEmits<{ close: []; pick: [point: TSavedPoint] }>();
31
+ const chosen = ref<TSavedPoint | null>(null);
32
+ const groups = computed(() => (props.kind === "relocalize" ? props.groups.filter((g) => g.floorNo !== null && g.floorNo === props.floorNo) : props.groups));
33
+ </script>
@@ -273,7 +273,7 @@ export default function useRobots() {
273
273
  | { kind: "full"; cleanTimes?: number; modeFlag?: number; subModeFlag?: number; levelFlag?: number }
274
274
  | { kind: "area"; areas: string[]; cleanTimes?: number; modeFlag?: number; subModeFlag?: number; levelFlag?: number }
275
275
  | { kind: "spot"; points: Array<{ x: number; y: number }>; cleanTimes?: number; modeFlag?: number; subModeFlag?: number; levelFlag?: number }
276
- | { kind: "pause" | "resume" | "cancel" }
276
+ | { kind: "pause" | "resume" | "cancel" | "patrol-resume" }
277
277
  | { kind: "navigate"; x: number; y: number; theta?: number }
278
278
  | { kind: "relocalize"; floorNo: number; x: number; y: number; theta: number },
279
279
  ) {
@@ -283,6 +283,81 @@ export default function useRobots() {
283
283
  );
284
284
  }
285
285
 
286
+ /* ---------------------------------------- security-robot extras (brand capability-gated) */
287
+
288
+ /** `liveVideo`: the robot's camera URL, minted now (valid until `expiresAt`). Open it; never store it. */
289
+ function getLiveVideo(id: string, quality: "hd" | "smooth" = "smooth") {
290
+ return useNuxtApp().$api<{ url: string; expiresAt: string | null }>(`/api/robots/${id}/live-video`, {
291
+ method: "POST",
292
+ query: { appSlug },
293
+ body: { quality },
294
+ });
295
+ }
296
+
297
+ /** Manual control (Manager only): open a drive session. 400 carries the plain reason it cannot start. */
298
+ function startDrive(id: string) {
299
+ return useNuxtApp().$api<{ session: string; limits: Record<string, any> }>(`/api/robots/${id}/drive`, {
300
+ method: "POST",
301
+ query: { appSlug },
302
+ body: { action: "start" },
303
+ });
304
+ }
305
+
306
+ /**
307
+ * One signal on an open drive session (beat / move / stop / head / end).
308
+ * Plain `fetch` so the page-closing "end" can go out with `keepalive`;
309
+ * never throws - answers `{ ok, status, message }`.
310
+ */
311
+ async function driveSignal(id: string, session: string, body: Record<string, unknown>, opts: { keepalive?: boolean } = {}) {
312
+ const base = useRuntimeConfig().public?.apiBase ?? "";
313
+ const query = appSlug ? `?appSlug=${encodeURIComponent(appSlug)}` : "";
314
+ try {
315
+ const res = await fetch(`${base}/api/robots/${id}/drive/${encodeURIComponent(session)}${query}`, {
316
+ method: "POST",
317
+ headers: { "Content-Type": "application/json", Accept: "application/json" },
318
+ credentials: "include",
319
+ keepalive: opts.keepalive === true,
320
+ body: JSON.stringify(body),
321
+ });
322
+ let message = "";
323
+ if (!res.ok) {
324
+ try {
325
+ message = String((await res.json())?.message ?? "");
326
+ } catch {
327
+ /* no body */
328
+ }
329
+ }
330
+ return { ok: res.ok, status: res.status, message };
331
+ } catch {
332
+ return { ok: false, status: 0, message: "" };
333
+ }
334
+ }
335
+
336
+ /** `resolveAlerts`: mark the robot's open alerts (of one code, or all) resolved in the brand's system. */
337
+ function resolveRobotAlerts(id: string, code?: string) {
338
+ return useNuxtApp().$api<{ resolved: number }>(`/api/robots/${id}/alerts/resolve`, {
339
+ method: "POST",
340
+ query: { appSlug },
341
+ body: code ? { code } : {},
342
+ });
343
+ }
344
+
345
+ /** `patrolHistory`: past patrols, newest first. */
346
+ function getPatrolHistory(id: string, { page = 1, limit = 10 } = {}) {
347
+ return useNuxtApp().$api<{ items: any[]; total: number; pages: number }>(`/api/robots/${id}/patrol-history`, {
348
+ method: "GET",
349
+ query: { appSlug, page, limit },
350
+ });
351
+ }
352
+
353
+ /** `patrolHistory`: one past patrol's checkpoint visits. */
354
+ function getPatrolVisits(id: string, recordId: string, { page = 1, limit = 50 } = {}) {
355
+ return useNuxtApp().$api<{ items: any[]; total: number; pages: number }>(
356
+ `/api/robots/${id}/patrol-history/${encodeURIComponent(recordId)}`,
357
+ { method: "GET", query: { appSlug, page, limit } },
358
+ );
359
+ }
360
+
286
361
  /* ---------------------------------------- building maps and lifts */
287
362
 
288
363
  /**
@@ -519,5 +594,11 @@ export default function useRobots() {
519
594
  getRobotMapImages,
520
595
  getRobotPlans,
521
596
  sendRobotCommand,
597
+ getLiveVideo,
598
+ startDrive,
599
+ driveSignal,
600
+ resolveRobotAlerts,
601
+ getPatrolHistory,
602
+ getPatrolVisits,
522
603
  };
523
604
  }
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.52.0",
5
+ "version": "4.54.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.",
@@ -0,0 +1,159 @@
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).
6
+ */
7
+
8
+ /* ============================================================ live view */
9
+
10
+ export type TVideoKind = "hls" | "video" | "flv" | "rtmp" | "page" | "bad";
11
+
12
+ /**
13
+ * What kind of stream URL the robot's cloud handed us.
14
+ * ponytail: no hls.js / mpegts.js yet - the real PadBot URL type is unknown;
15
+ * HLS plays natively where the browser can (Safari), FLV and HLS elsewhere
16
+ * get "Open in new tab". Add the player library once a real URL is seen.
17
+ */
18
+ export function liveVideoKind(url: unknown): TVideoKind {
19
+ if (typeof url !== "string") return "bad";
20
+ if (/^rtmps?:\/\//i.test(url)) return "rtmp";
21
+ if (!/^https?:\/\//i.test(url)) return "bad";
22
+ const path = url.split(/[?#]/)[0]!.toLowerCase();
23
+ if (path.endsWith(".m3u8")) return "hls";
24
+ if (path.endsWith(".flv")) return "flv";
25
+ if (/\.(mp4|webm)$/.test(path)) return "video";
26
+ return "page";
27
+ }
28
+
29
+ /** When to ask for a fresh URL: 30 s before it expires (never sooner than 5 s); null when it does not expire. */
30
+ export function remintDelayMs(expiresAt: unknown, now: number = Date.now()): number | null {
31
+ const t = typeof expiresAt === "string" ? Date.parse(expiresAt) : NaN;
32
+ if (!Number.isFinite(t)) return null;
33
+ return Math.max(5_000, t - now - 30_000);
34
+ }
35
+
36
+ /* ======================================================== manual drive */
37
+
38
+ export type TDriveLimits = {
39
+ maxLinear: number;
40
+ maxAngular: number;
41
+ heartbeatMs: number;
42
+ commandMaxMs?: number;
43
+ headPan: [number, number];
44
+ headTilt: [number, number];
45
+ };
46
+ export type TDriveDirection = "forward" | "back" | "left" | "right";
47
+ export type TDriveSend = (body: Record<string, unknown>, opts?: { keepalive?: boolean }) => Promise<{ ok: boolean; status: number; message: string }>;
48
+
49
+ /** Chassis vector for a held direction, at the server's cap (left turn = positive angular speed). */
50
+ export function driveVector(dir: TDriveDirection, limits: Pick<TDriveLimits, "maxLinear" | "maxAngular">) {
51
+ if (dir === "forward") return { lv: limits.maxLinear, av: 0 };
52
+ if (dir === "back") return { lv: -limits.maxLinear, av: 0 };
53
+ return { lv: 0, av: dir === "left" ? limits.maxAngular : -limits.maxAngular };
54
+ }
55
+
56
+ /** "Speed limited to 0.3 m/s". */
57
+ export const speedLimitText = (limits: Pick<TDriveLimits, "maxLinear"> | null | undefined) =>
58
+ limits && Number.isFinite(limits.maxLinear) ? `Speed limited to ${limits.maxLinear} m/s` : "";
59
+
60
+ const MOVE_REPEAT_MS = 500;
61
+
62
+ /**
63
+ * The screen side of a drive session: a heartbeat every `heartbeatMs` for as
64
+ * long as the dialog is open, a move on press and every 500 ms while held, a
65
+ * stop on release. Any refused signal ends the session (the server has
66
+ * already stopped the robot) and `onEnd` gets the server's words.
67
+ */
68
+ export function createDriveClient(o: {
69
+ send: TDriveSend;
70
+ limits: TDriveLimits;
71
+ onEnd: (reason: string) => void;
72
+ setInterval?: typeof setInterval;
73
+ clearInterval?: typeof clearInterval;
74
+ }) {
75
+ const si = o.setInterval ?? setInterval;
76
+ const ci = o.clearInterval ?? clearInterval;
77
+ let seq = 0;
78
+ let ended = false;
79
+ let held: TDriveDirection | null = null;
80
+ let moveTimer: ReturnType<typeof setInterval> | null = null;
81
+ const head = { pan: 0, tilt: 0 };
82
+
83
+ const post = (body: Record<string, unknown>, opts?: { keepalive?: boolean }) => {
84
+ if (ended) return;
85
+ const signal = { seq: ++seq, ...body };
86
+ void o.send(signal, opts).then((r) => {
87
+ if (!r.ok && body.action !== "end") end(r.message || (r.status ? "Manual control ended." : "Manual control ended: connection lost."), false);
88
+ });
89
+ };
90
+ const beatTimer = si(() => post({ action: "beat" }), o.limits.heartbeatMs);
91
+ post({ action: "beat" });
92
+
93
+ function clearMove() {
94
+ if (moveTimer) ci(moveTimer);
95
+ moveTimer = null;
96
+ }
97
+ function end(reason = "", tellServer = true) {
98
+ if (ended) return;
99
+ clearMove();
100
+ ci(beatTimer);
101
+ if (tellServer) post({ action: "end" }, { keepalive: true });
102
+ ended = true;
103
+ held = null;
104
+ o.onEnd(reason);
105
+ }
106
+ return {
107
+ press(dir: TDriveDirection) {
108
+ if (ended) return;
109
+ held = dir;
110
+ clearMove();
111
+ const send = () => held && post({ action: "move", ...driveVector(held, o.limits) });
112
+ send();
113
+ moveTimer = si(send, MOVE_REPEAT_MS);
114
+ },
115
+ release() {
116
+ if (ended || !held) return;
117
+ held = null;
118
+ clearMove();
119
+ post({ action: "stop" });
120
+ },
121
+ /** The big STOP: always sends, held or not. */
122
+ stop() {
123
+ held = null;
124
+ clearMove();
125
+ post({ action: "stop" });
126
+ },
127
+ look(dir: "left" | "right" | "up" | "down" | "centre", step = 15) {
128
+ if (dir === "centre") {
129
+ head.pan = head.tilt = 0;
130
+ return post({ action: "head", axis: "reset" });
131
+ }
132
+ const clamp = (v: number, [lo, hi]: [number, number]) => Math.max(lo, Math.min(hi, v));
133
+ if (dir === "left" || dir === "right") {
134
+ head.pan = clamp(head.pan + (dir === "left" ? step : -step), o.limits.headPan);
135
+ return post({ action: "head", axis: "pan", angle: head.pan });
136
+ }
137
+ head.tilt = clamp(head.tilt + (dir === "up" ? step : -step), o.limits.headTilt);
138
+ post({ action: "head", axis: "tilt", angle: head.tilt });
139
+ },
140
+ end: (reason = "") => end(reason, true),
141
+ get ended() {
142
+ return ended;
143
+ },
144
+ get held() {
145
+ return held;
146
+ },
147
+ };
148
+ }
149
+
150
+ /* ======================================================== alert photos */
151
+
152
+ /** "Photo" / "Thermal photo" links for a fault that carries the robot's pictures (http/https only). */
153
+ export function faultPhotos(fault: { imageUrl?: unknown; thermalImageUrl?: unknown } | null | undefined): Array<{ label: string; url: string }> {
154
+ const ok = (v: unknown): v is string => typeof v === "string" && /^https?:\/\//i.test(v);
155
+ return [
156
+ ...(ok(fault?.imageUrl) ? [{ label: "Photo", url: fault!.imageUrl as string }] : []),
157
+ ...(ok(fault?.thermalImageUrl) ? [{ label: "Thermal photo", url: fault!.thermalImageUrl as string }] : []),
158
+ ];
159
+ }
@@ -11,6 +11,8 @@
11
11
  */
12
12
  export function credentialLabels(brand: unknown): { key: string; token: string } {
13
13
  if (brand === "ounitech") return { key: "AppID", token: "Secret" };
14
+ // PadBot issues an AccessKey ID + AccessKey Secret per account (API v4.0 §2.1).
15
+ if (brand === "padbot") return { key: "AccessKey ID", token: "AccessKey Secret" };
14
16
  if (brand === "demo") return { key: "App key (any value)", token: "Token (any value)" };
15
17
  return { key: "App key", token: "Token" };
16
18
  }