/* ========================================================================== Core: store, transport, notifications, formatting. Every URL here is derived from window.location, so the dashboard works from whatever address you happened to open it on - laptop, phone, tablet, or a hostname. Nothing is hardcoded. ========================================================================== */ export const API = `${location.origin}`; export const WS_URL = `${location.protocol === 'https:' ? 'wss' : 'ws'}://${location.host}/ws`; /* -- Store ---------------------------------------------------------------- */ class Store { constructor() { this.state = null; // live robot state this.spec = null; // constants from the backend this.settings = null; this.plugins = { plugins: [], errors: [] }; this.bridge = null; this.network = null; this.server = null; this.events = []; this.connected = false; this._subs = new Map(); this._nextId = 1; } on(channel, fn) { if (!this._subs.has(channel)) this._subs.set(channel, new Map()); const id = this._nextId++; this._subs.get(channel).set(id, fn); return () => this._subs.get(channel)?.delete(id); } emit(channel, payload) { const subs = this._subs.get(channel); if (!subs) return; for (const fn of subs.values()) { try { fn(payload); } catch (err) { console.error(`[${channel}]`, err); } } } } export const store = new Store(); /* -- HTTP ----------------------------------------------------------------- */ export async function api(path, options = {}) { const { method = 'GET', body, timeout = 15000 } = options; const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), timeout); try { const res = await fetch(`${API}${path}`, { method, headers: body ? { 'Content-Type': 'application/json' } : undefined, body: body ? JSON.stringify(body) : undefined, signal: controller.signal, }); const text = await res.text(); let data = null; try { data = text ? JSON.parse(text) : null; } catch { data = { detail: text }; } if (!res.ok) { const message = data?.detail || data?.message || `${res.status} ${res.statusText}`; const error = new Error(typeof message === 'string' ? message : JSON.stringify(message)); error.status = res.status; error.data = data; throw error; } return data; } catch (err) { if (err.name === 'AbortError') throw new Error(`Request timed out after ${timeout / 1000}s`); throw err; } finally { clearTimeout(timer); } } export const get = (path) => api(path); export const post = (path, body) => api(path, { method: 'POST', body: body ?? {} }); /** * POST that reports its own outcome as a toast. Returns the payload on success * and null on failure, so callers can `if (!await command(...)) return;`. */ export async function command(path, body, { silent = false, successTitle } = {}) { try { const data = await post(path, body); if (!silent) toast(successTitle || 'Done', data?.message || '', 'good'); return data; } catch (err) { if (!silent) toast('Command failed', err.message, 'critical'); return null; } } /* -- WebSocket ------------------------------------------------------------ */ class Socket { constructor() { this.ws = null; this.attempts = 0; this.closing = false; this._pending = null; this._pendingTimer = null; } connect() { if (this.ws && (this.ws.readyState === WebSocket.OPEN || this.ws.readyState === WebSocket.CONNECTING)) return; this.closing = false; try { this.ws = new WebSocket(WS_URL); } catch (err) { this._retry(); return; } this.ws.onopen = () => { this.attempts = 0; store.connected = true; store.emit('link', { connected: true }); }; this.ws.onmessage = (event) => { let message; try { message = JSON.parse(event.data); } catch { return; } this._route(message); }; this.ws.onclose = () => { store.connected = false; store.emit('link', { connected: false }); if (!this.closing) this._retry(); }; this.ws.onerror = () => { /* onclose handles recovery */ }; } _route(message) { const { type, data } = message; switch (type) { case 'state': store.state = data; store.emit('state', data); break; case 'event': store.events.push(data); if (store.events.length > 500) store.events.splice(0, store.events.length - 500); store.emit('event', data); if (data.level === 'error') toast('Robot', data.message, 'critical'); else if (data.level === 'warn') toast('Robot', data.message, 'warning'); break; case 'plugins': store.plugins = data; store.emit('plugins', data); break; case 'settings': store.settings = data; store.emit('settings', data); break; case 'scan_progress': store.emit('scan_progress', data); break; case 'command_error': store.emit('command_error', data); break; default: store.emit(type, data); } } _retry() { // Back off to 8 s so a robot that is off overnight does not spam the network. const delay = Math.min(8000, 500 * Math.pow(1.6, this.attempts++)); setTimeout(() => this.connect(), delay); } send(type, data) { if (this.ws?.readyState !== WebSocket.OPEN) return false; this.ws.send(JSON.stringify({ type, data })); return true; } /** * Rate-limited send for the joystick. Coalesces to ~25 Hz so dragging never * floods the socket, but always delivers the final position. */ sendThrottled(type, data, interval = 40) { this._pending = { type, data }; if (this._pendingTimer) return; const flush = () => { if (!this._pending) { this._pendingTimer = null; return; } const { type: t, data: d } = this._pending; this._pending = null; this.send(t, d); this._pendingTimer = setTimeout(flush, interval); }; flush(); } } export const socket = new Socket(); /* -- Toasts --------------------------------------------------------------- */ const toastHost = () => document.getElementById('toasts'); export function toast(title, message = '', tone = 'default', ttl = 4200) { const host = toastHost(); if (!host) return; const node = document.createElement('div'); node.className = 'toast'; node.dataset.tone = tone; node.innerHTML = `
${message ? '
' : ''}
`; node.querySelector('.toast-title').textContent = title; if (message) node.querySelector('.toast-msg').textContent = message; host.appendChild(node); while (host.children.length > 5) host.firstElementChild.remove(); setTimeout(() => { node.classList.add('leaving'); setTimeout(() => node.remove(), 200); }, ttl); } /* -- Confirm modal -------------------------------------------------------- */ export function confirmDialog(title, body, { confirmLabel = 'Confirm', danger = true } = {}) { return new Promise((resolve) => { const backdrop = document.getElementById('modal-backdrop'); const confirmBtn = document.getElementById('modal-confirm'); const cancelBtn = document.getElementById('modal-cancel'); document.getElementById('modal-title').textContent = title; document.getElementById('modal-body').textContent = body; confirmBtn.textContent = confirmLabel; confirmBtn.className = danger ? 'btn btn-danger' : 'btn btn-primary'; backdrop.hidden = false; confirmBtn.focus(); const finish = (value) => { backdrop.hidden = true; confirmBtn.removeEventListener('click', onYes); cancelBtn.removeEventListener('click', onNo); backdrop.removeEventListener('click', onBackdrop); document.removeEventListener('keydown', onKey); resolve(value); }; const onYes = () => finish(true); const onNo = () => finish(false); const onBackdrop = (e) => { if (e.target === backdrop) finish(false); }; const onKey = (e) => { if (e.key === 'Escape') finish(false); }; confirmBtn.addEventListener('click', onYes); cancelBtn.addEventListener('click', onNo); backdrop.addEventListener('click', onBackdrop); document.addEventListener('keydown', onKey); }); } /* -- Formatting ----------------------------------------------------------- */ export const RAD2DEG = 180 / Math.PI; export const DEG2RAD = Math.PI / 180; export function num(value, precision = 2, fallback = '—') { if (value === null || value === undefined || Number.isNaN(Number(value))) return fallback; return Number(value).toFixed(precision); } export function compact(value) { if (value === null || value === undefined) return '—'; const abs = Math.abs(value); if (abs >= 1e9) return `${(value / 1e9).toFixed(1)}B`; if (abs >= 1e6) return `${(value / 1e6).toFixed(1)}M`; if (abs >= 1e4) return `${(value / 1e3).toFixed(1)}K`; return value.toLocaleString(undefined, { maximumFractionDigits: 0 }); } export function clockTime(ts) { const d = new Date(ts * 1000); return d.toLocaleTimeString([], { hour12: false }); } export function duration(seconds) { if (!seconds || seconds < 0) return '—'; const s = Math.floor(seconds % 60); const m = Math.floor((seconds / 60) % 60); const h = Math.floor(seconds / 3600); if (h) return `${h}h ${m}m`; if (m) return `${m}m ${s}s`; return `${s}s`; } export function batteryTone(pct) { if (pct === null || pct === undefined) return 'default'; if (pct <= 10) return 'critical'; if (pct <= 25) return 'warning'; return 'good'; } export function tempTone(celsius, warn = 55, crit = 70) { if (celsius === null || celsius === undefined) return 'default'; if (celsius >= crit) return 'critical'; if (celsius >= warn) return 'warning'; return 'good'; } export function throttle(fn, ms) { let last = 0, timer = null, queued = null; return (...args) => { const now = Date.now(); queued = args; if (now - last >= ms) { last = now; fn(...queued); queued = null; return; } if (timer) return; timer = setTimeout(() => { timer = null; last = Date.now(); if (queued) { fn(...queued); queued = null; } }, ms - (now - last)); }; } export function debounce(fn, ms) { let timer = null; return (...args) => { clearTimeout(timer); timer = setTimeout(() => fn(...args), ms); }; } /* Series colours, read from CSS so the theme toggle stays authoritative. */ export function seriesColor(index) { const styles = getComputedStyle(document.documentElement); return styles.getPropertyValue(`--series-${(index % 8) + 1}`).trim() || '#3987e5'; } export function cssVar(name) { return getComputedStyle(document.documentElement).getPropertyValue(name).trim(); }