/* ========================================================================== 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 = `