(() => { const el = (id) => document.getElementById(id); const LOCALE_META = { vi: { flag: "πŸ‡»πŸ‡³", labelKey: "topbar.localeVi" }, en: { flag: "πŸ‡ΊπŸ‡Έ", labelKey: "topbar.localeEn" }, }; const t = (key, vars) => window.I18n?.t(key, vars) ?? key; const getLocale = () => window.I18n?.getLocale?.() ?? "vi"; let robotStatus = null; let pollTimer = null; let eventsBound = false; let openPanel = null; let joystickActive = false; let joystickPointerId = null; let joystickRaf = null; let lastCmd = { linear: 0, angular: 0 }; function applyLocale(next) { if (window.I18n) window.I18n.setLocale(next); if (robotStatus) renderAll(robotStatus); } function loadLocale() { /* locale owned by I18n */ } function canSeeMissions() { return window.AuthApp?.canAccessPage?.("missions"); } function canControl() { return window.AuthApp?.canWrite?.("missions"); } async function apiJson(path, opts = {}) { const res = await fetch(path, { credentials: "include", headers: { "Content-Type": "application/json", ...(opts.headers || {}) }, ...opts, }); const text = await res.text(); let data = null; try { data = text ? JSON.parse(text) : null; } catch { data = null; } if (!res.ok) throw new Error((data && data.error) || text || res.statusText); return data; } function closePanels() { document.querySelectorAll(".mirPanel").forEach((p) => { p.hidden = true; }); document.querySelectorAll(".mirSegment[aria-haspopup='true']").forEach((btn) => { btn.setAttribute("aria-expanded", "false"); }); openPanel = null; } function togglePanel(btn, panel) { if (!btn || !panel) return; const isOpen = btn.getAttribute("aria-expanded") === "true"; closePanels(); if (!isOpen) { panel.hidden = false; btn.setAttribute("aria-expanded", "true"); openPanel = panel; } } function missionStripMessage(status) { const pending = Number(status.queue_pending) || 0; const runnerState = status.runner?.state || "idle"; const msg = status.message || ""; if (runnerState === "running" || runnerState === "paused") { if (msg && msg !== t("topbar.waiting")) return msg; const name = status.runner?.current_action; if (name) return String(name); } if (pending === 0 && runnerState === "idle") return t("topbar.noMissionsQueue"); if (msg && msg !== t("topbar.waiting")) return msg; return t("topbar.waiting"); } function renderControl(status) { const motion = status.motion || "paused"; const running = motion === "running"; const runnerState = status.runner?.state || "idle"; const isError = status.health === "error" || runnerState === "error"; const pauseIcon = el("mirControlIconPause"); const playIcon = el("mirControlIconPlay"); const pillEl = el("mirControlPill"); const msgEl = el("mirMissionMsg"); const btnEl = el("mirSegControl"); const stripEl = el("mirMissionStrip"); if (pauseIcon && playIcon) { pauseIcon.hidden = !running; playIcon.hidden = running; } if (pillEl) { pillEl.textContent = isError ? t("topbar.error") : running ? t("topbar.running") : t("topbar.paused"); pillEl.classList.toggle("is-running", running && !isError); pillEl.classList.toggle("is-paused", !running && !isError); pillEl.classList.toggle("is-error", isError); } if (msgEl) msgEl.textContent = missionStripMessage(status); if (stripEl) stripEl.classList.toggle("is-error", isError); if (btnEl) { btnEl.disabled = !canControl() || status.health === "error"; btnEl.title = running ? t("topbar.pauseHint") : t("topbar.startHint"); btnEl.classList.toggle("is-readonly", !canControl()); } } function renderStatus(status) { const health = status.health || "ok"; const runnerState = status.runner?.state || "idle"; const isError = health === "error" || runnerState === "error"; const labelEl = el("mirStatusLabel"); const iconEl = el("mirStatusIcon"); const bodyEl = el("mirStatusPanelBody"); const footerEl = el("mirStatusPanelFooter"); const segEl = el("mirSegStatus"); if (labelEl) labelEl.textContent = isError ? t("topbar.error") : t("topbar.allOk"); if (iconEl) { iconEl.classList.toggle("is-ok", !isError); iconEl.classList.toggle("is-error", isError); } if (segEl) segEl.classList.toggle("is-error", isError); if (!bodyEl) return; const err = status.error && typeof status.error === "object" ? status.error : null; const runnerErr = runnerState === "error" ? status.runner?.message : ""; const message = status.message || t("topbar.waiting"); if (isError && (err || runnerErr)) { bodyEl.innerHTML = `
${t("topbar.error")}
${err?.code != null ? `
${t("topbar.code")}: ${err.code}
` : ""} ${err?.module ? `
${t("topbar.module")}: ${err.module}
` : ""}
${err?.description || runnerErr || message}
`; if (footerEl) footerEl.hidden = !canControl(); } else { bodyEl.innerHTML = `
${t("topbar.allOk")}
${message}
${status.queue_pending > 0 ? `
${t("topbar.queueCount", { n: status.queue_pending })}
` : ""}`; if (footerEl) footerEl.hidden = true; } } function renderBattery(status) { const pct = Math.max(0, Math.min(100, Number(status.battery_percent) || 0)); const labelEl = el("mirBatteryLabel"); const levelEl = el("mirBatteryLevel"); const segEl = el("mirSegBattery"); if (labelEl) labelEl.textContent = `${pct}%`; if (levelEl) levelEl.style.width = `${pct}%`; if (segEl) { segEl.classList.toggle("is-low", pct < 20); segEl.classList.toggle("is-mid", pct >= 20 && pct < 50); segEl.classList.toggle("is-charging", !!status.battery_charging); } } function hideJoystickOverlay() { const overlay = el("joystickOverlay"); if (overlay) overlay.hidden = true; joystickActive = false; const stick = el("joystickStick"); if (stick) stick.style.transform = "translate(0, 0)"; lastCmd = { linear: 0, angular: 0 }; } function renderJoystick(status) { if (!window.AuthApp?.isReady?.()) { hideJoystickOverlay(); return; } const seg = el("mirSegJoystick"); const engaged = !!status.joystick_engaged; if (seg) seg.classList.toggle("is-active", engaged); const overlay = el("joystickOverlay"); if (overlay) overlay.hidden = !engaged; joystickActive = engaged; const speedSel = el("joystickSpeedSelect"); if (speedSel && status.joystick_speed) speedSel.value = status.joystick_speed; if (el("joystickSpeedLabel")) { const speed = status.joystick_speed || "fast"; el("joystickSpeedLabel").textContent = t(`topbar.joystickSpeed.${speed}`); } } function renderAll(status) { if (!window.AuthApp?.isReady?.()) { hideJoystickOverlay(); return; } robotStatus = status; if (!canSeeMissions()) { el("mirTopbar")?.classList.add("mirTopbar--no-missions"); return; } el("mirTopbar")?.classList.remove("mirTopbar--no-missions"); renderControl(status); renderStatus(status); renderBattery(status); renderJoystick(status); } async function fetchStatus() { if (!window.AuthApp?.isReady() || !canSeeMissions()) return; try { const data = await apiJson("/api/robot/status"); renderAll(data); window.dispatchEvent(new CustomEvent("lm:robot-status", { detail: data })); } catch (e) { if (String(e.message || "").includes("not authenticated")) return; } } function startPoll() { stopPoll(); fetchStatus(); pollTimer = setInterval(fetchStatus, 1500); window.MissionsApp?.startQueuePoll?.(); } function stopPoll() { if (pollTimer) { clearInterval(pollTimer); pollTimer = null; } window.MissionsApp?.stopQueuePoll?.(); } async function toggleRobotMotion() { if (!robotStatus || !canControl()) return; const running = robotStatus.motion === "running"; const path = running ? "/api/robot/pause" : "/api/robot/start"; const data = await apiJson(path, { method: "POST", body: "{}" }); renderAll(data); } async function resetError() { const data = await apiJson("/api/robot/errors/reset", { method: "POST", body: "{}" }); renderAll(data); closePanels(); } async function engageJoystick(engaged, speed) { const payload = { engaged }; if (speed) payload.speed = speed; const data = await apiJson("/api/robot/joystick", { method: "POST", body: JSON.stringify(payload), }); renderAll(data); } function sendCmdVel(linear, angular) { if (!joystickActive) return; if (Math.abs(linear - lastCmd.linear) < 0.02 && Math.abs(angular - lastCmd.angular) < 0.02) return; lastCmd = { linear, angular }; apiJson("/api/robot/cmd_vel", { method: "POST", body: JSON.stringify({ linear, angular }), }).catch(() => {}); } function bindJoystickPad() { const pad = el("joystickPad"); const stick = el("joystickStick"); if (!pad || !stick) return; const center = () => { const r = pad.getBoundingClientRect(); return { x: r.left + r.width / 2, y: r.top + r.height / 2, radius: r.width / 2 - 24 }; }; const moveStick = (clientX, clientY) => { const c = center(); let dx = clientX - c.x; let dy = clientY - c.y; const dist = Math.hypot(dx, dy); if (dist > c.radius) { dx = (dx / dist) * c.radius; dy = (dy / dist) * c.radius; } stick.style.transform = `translate(${dx}px, ${dy}px)`; const linear = -dy / c.radius; const angular = dx / c.radius; if (joystickRaf) cancelAnimationFrame(joystickRaf); joystickRaf = requestAnimationFrame(() => sendCmdVel(linear, angular)); }; const resetStick = () => { stick.style.transform = "translate(0, 0)"; sendCmdVel(0, 0); lastCmd = { linear: 0, angular: 0 }; }; const onDown = (evt) => { if (!joystickActive) return; joystickPointerId = evt.pointerId; pad.setPointerCapture(evt.pointerId); moveStick(evt.clientX, evt.clientY); }; const onMove = (evt) => { if (evt.pointerId !== joystickPointerId) return; moveStick(evt.clientX, evt.clientY); }; const onUp = (evt) => { if (evt.pointerId !== joystickPointerId) return; joystickPointerId = null; resetStick(); }; pad.addEventListener("pointerdown", onDown); pad.addEventListener("pointermove", onMove); pad.addEventListener("pointerup", onUp); pad.addEventListener("pointercancel", onUp); } function bindEvents() { if (eventsBound) return; eventsBound = true; el("mirSegControl")?.addEventListener("click", () => { toggleRobotMotion().catch((e) => alert(e.message)); }); el("mirSegStatus")?.addEventListener("click", (evt) => { evt.stopPropagation(); togglePanel(el("mirSegStatus"), el("mirStatusPanel")); }); el("mirSegLocale")?.addEventListener("click", (evt) => { evt.stopPropagation(); togglePanel(el("mirSegLocale"), el("mirLocalePanel")); }); el("mirUserBtn")?.addEventListener("click", (evt) => { evt.stopPropagation(); togglePanel(el("mirUserBtn"), el("mirUserPanel")); }); el("mirErrorResetBtn")?.addEventListener("click", () => { resetError().catch((e) => alert(e.message)); }); document.querySelectorAll(".mirLocaleOption").forEach((btn) => { btn.addEventListener("click", (evt) => { evt.stopPropagation(); applyLocale(btn.dataset.locale || "vi"); closePanels(); }); }); el("mirSegJoystick")?.addEventListener("click", async () => { if (!canControl()) { alert(t("topbar.noControlPermission")); return; } try { if (robotStatus?.joystick_engaged) await engageJoystick(false); else await engageJoystick(true, el("joystickSpeedSelect")?.value || "fast"); } catch (e) { alert(e.message); } }); el("joystickDisengageBtn")?.addEventListener("click", () => { engageJoystick(false).catch((e) => alert(e.message)); }); el("joystickSpeedSelect")?.addEventListener("change", (evt) => { if (robotStatus?.joystick_engaged) { engageJoystick(true, evt.target.value).catch(() => {}); } }); document.addEventListener("click", (evt) => { if (evt.target.closest(".mirSegment") || evt.target.closest(".mirPanel")) return; closePanels(); }); document.addEventListener("keydown", (evt) => { if (evt.key === "Escape") { closePanels(); if (robotStatus?.joystick_engaged) engageJoystick(false).catch(() => {}); } }); bindJoystickPad(); window.addEventListener("lm:locale-change", () => { if (robotStatus) renderAll(robotStatus); }); } function start() { loadLocale(); bindEvents(); if (!window.AuthApp?.isReady()) return; startPoll(); } function stop() { stopPoll(); closePanels(); hideJoystickOverlay(); if (window.AuthApp?.isReady?.()) { engageJoystick(false).catch(() => {}); } } async function disengageJoystick() { hideJoystickOverlay(); if (!window.AuthApp?.isReady?.()) return; try { await engageJoystick(false); } catch { /* session may already be gone */ } } window.TopbarApp = { t, getLocale, applyLocale, refresh: fetchStatus, getRobotStatus: () => robotStatus, hideJoystickOverlay, disengageJoystick, updateUserMenu(user) { const role = (user?.group_name || "USER").toUpperCase(); if (el("mirUserLabel")) el("mirUserLabel").textContent = role; if (el("mirUserPanelRole")) el("mirUserPanelRole").textContent = role; if (el("mirUserPanelName")) el("mirUserPanelName").textContent = user?.display_name || user?.username || "β€”"; if (el("mirProfileDisplayName")) el("mirProfileDisplayName").value = user?.display_name || user?.username || ""; }, }; if (window.AuthApp?.isReady()) start(); else window.addEventListener("lm:auth-ready", () => start(), { once: true }); window.addEventListener("lm:auth-logout", () => stop()); hideJoystickOverlay(); })();