diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md
index 6da86872..30cbda94 100644
Binary files a/.claude/CLAUDE.md and b/.claude/CLAUDE.md differ
diff --git a/.claude/notes.md b/.claude/notes.md
index e689f411..1300ea21 100644
Binary files a/.claude/notes.md and b/.claude/notes.md differ
diff --git a/public/app.js b/public/app.js
index 96d29cd1..e108de12 100644
--- a/public/app.js
+++ b/public/app.js
@@ -23,10 +23,13 @@ import { initMotorsKeyboard } from "./capabilities/runtime/signed-pair.js";
import { initAuthUI, fingerprint as dashFingerprint, pubkeySsh, onKeyChange } from "./auth.js";
import { initPasswordsUI } from "./passwords.js";
import { initAssistant, emitPipEvent } from "./assistant.js";
-import { initPhones, broadcastTargetInfo, sendArucoStatus } from "./phones.js";
+import { initPhones, broadcastTargetInfo } from "./phones.js";
// (local-llm imports moved to assistant.js where the /install slash lives)
import { initHelpers, setHelpersRobotRenderer, renderHelpers } from "./helpers.js";
-import { startTracking as startArucoTracking, stopTracking as stopArucoTracking } from "./aruco.js";
+// aruco.js is wired through helpers.js — phone helpers can be designated
+// as the overhead camera; detection runs against the helper's existing
+// preview tile. No init call here.
+import "./aruco.js";
import {
setupServiceWorker, wireInstallMenuItem, wireCheckUpdatesMenuItem,
wireHardRefresh, wireDiagnosticsMenuItem, setReportIssueLink, readSwVersion,
@@ -40,9 +43,7 @@ setExpectingReconnectHandler((id) => markExpectingReconnect(id));
// A phone helper's camera mounted on this robot (phone-as-eye). The video
// element is discoverable by perception.js's findCameraElement enumerator
// via [data-attached-camera-id]. srcObject is bound by renderEntry after
-// innerHTML rebuild. The SVG sibling is the ArUco debug overlay — sized
-// to match the video's natural dims via patchArucoOverlay so corner
-// coords from aruco.js (image-pixel) don't need re-scaling per render.
+// innerHTML rebuild.
function attachedCameraHtml(entry) {
if (!entry.attachedCameraStream) return "";
return `
@@ -53,89 +54,12 @@ function attachedCameraHtml(entry) {
`;
}
-// Surgical patcher for the ArUco debug overlay. Called from the tracker
-// each tick — mutates the SVG in place so a 10 Hz detection rhythm
-// doesn't trigger full-card re-renders that would destroy other
-// in-flight UI (perception prompt, hover state, etc).
-//
-// `frameCount` in the status is load-bearing diagnostic — without it,
-// "detector still loading", "loop running but nothing found", and
-// "loop wedged" all read identically to the operator.
-function patchArucoOverlay(entry, { markers, frameCount, error }) {
- const node = entry.node;
- if (!node) return;
- const svg = node.querySelector(`svg[data-aruco-overlay-id="${entry.id}"]`);
- if (!svg) return;
- const status = node.querySelector(`[data-aruco-status-id="${entry.id}"]`);
- if (error) {
- if (svg) svg.innerHTML = "";
- if (status) {
- status.classList.remove("aruco-locked");
- status.textContent = `Detector error: ${error}`;
- }
- return;
- }
- if (markers.length === 0) {
- svg.innerHTML = "";
- if (status) {
- status.classList.remove("aruco-locked");
- status.textContent = `Scanning · ${frameCount} frame${frameCount === 1 ? "" : "s"} · no marker yet`;
- }
- // Push to phone-as-eye holder so they see lock state without checking
- // the dashboard. Only on lock-state transitions to keep the data
- // channel quiet (10 Hz of no-marker pings would be churn for nothing).
- if (entry.attachedFromPhoneId && entry.arucoLastLocked !== false) {
- sendArucoStatus(entry.attachedFromPhoneId, { locked: false, detail: "Scanning for marker…" });
- entry.arucoLastLocked = false;
- }
- return;
- }
- const { frameW, frameH } = markers[0];
- svg.setAttribute("viewBox", `0 0 ${frameW} ${frameH}`);
- // preserveAspectRatio default ("xMidYMid meet") matches how the video
- // is letterboxed in its container — corners line up.
- const pieces = [];
- for (const m of markers) {
- const pts = m.corners.map(c => `${c.x.toFixed(1)},${c.y.toFixed(1)}`).join(" ");
- // Heading line from center along the marker's "top edge" direction.
- const len = Math.min(frameW, frameH) * 0.08;
- const hx = m.cx + Math.cos(m.headingRad) * len;
- const hy = m.cy + Math.sin(m.headingRad) * len;
- pieces.push(``);
- pieces.push(``);
- pieces.push(`id ${m.id}`);
- }
- svg.innerHTML = pieces.join("");
- if (status) {
- status.classList.add("aruco-locked");
- const ids = markers.map(m => `id ${m.id}`).join(", ");
- status.textContent = `Tracking ${ids} · frame ${frameCount}`;
- }
- // Phone-side lock indicator. Send on transition into locked AND on
- // marker-id change while locked; suppress while still locked on the
- // same id to avoid 10 Hz traffic.
- if (entry.attachedFromPhoneId) {
- const primaryId = markers[0].id;
- if (entry.arucoLastLocked !== true || entry.arucoLastMarkerId !== primaryId) {
- sendArucoStatus(entry.attachedFromPhoneId, { locked: true, markerId: primaryId });
- entry.arucoLastLocked = true;
- entry.arucoLastMarkerId = primaryId;
- }
- }
-}
-
// The header meta line ("WiFi … · up …h · reset: …"). Reused by renderEntry
// (full render) and patchSecondaryRow (telemetry-driven updates that would
// otherwise flash the whole card every 10 s). Composes pure formatters
@@ -263,9 +187,9 @@ function gattConnectWithTimeout(device) {
async function loadPaired() {
// Restore remembered robots first — works even when getDevices() is missing.
- for (const { id, name, fwType, autoReconnect, lastConnectedAt } of loadKnown()) {
+ for (const { id, name, fwType, autoReconnect, lastConnectedAt, arucoMarkerId } of loadKnown()) {
if (!state.devices.has(id)) {
- state.devices.set(id, makeEntry(id, name, fwType, { autoReconnect, lastConnectedAt }));
+ state.devices.set(id, makeEntry(id, name, fwType, { autoReconnect, lastConnectedAt, arucoMarkerId }));
}
}
if (navigator.bluetooth.getDevices) {
@@ -1056,16 +980,6 @@ function renderEntry(entry) {
if (entry.attachedCameraStream) {
const v = entry.node.querySelector(`video[data-attached-camera-id="${entry.id}"]`);
if (v) v.srcObject = entry.attachedCameraStream;
- // Lazy-start ArUco tracking. The tracker is idempotent (returns if
- // already running) — sourceFn re-resolves the