/** * Connection + sample store. Owns the WebSocket, an optional REST history * prefetch, auto-reconnect with backoff, and the growing sample array. * Mirrors the pattern of leaflet_svelte's telemetryStore.svelte.ts. */ import { parsePacket, historyUrlFromWs, type RawPacket, type Sample } from './telemetry'; export type ConnStatus = 'idle' | 'connecting' | 'connected' | 'reconnecting' | 'error'; const LS_KEY = 'tdash.wsUrl'; const RECONNECT_MAX_S = 30; class TelemetryStore { wsUrl = $state( localStorage.getItem(LS_KEY) ?? (import.meta.env.VITE_DEFAULT_WS_URL as string | undefined) ?? 'ws://localhost:8000/api/ws/satellite//telemetry/', ); status = $state('idle'); error = $state(null); samples = $state([]); packetsReceived = $state(0); /** Ticks once a second — drives the "age of last packet" display. */ nowS = $state(Math.floor(Date.now() / 1000)); #ws: WebSocket | null = null; #reconnectTimer: ReturnType | null = null; #backoffS = 1; #wantConnected = false; constructor() { setInterval(() => (this.nowS = Math.floor(Date.now() / 1000)), 1000); } get latest(): Sample | null { return this.samples[this.samples.length - 1] ?? null; } /** Seconds since the last packet arrived (wall clock), or null before data. */ get lastPacketAge(): number | null { const last = this.latest; if (!last) return null; return Math.max(0, this.nowS - (this.#lastRxS ?? last.t)); } #lastRxS: number | null = null; async connect(): Promise { const url = this.wsUrl.trim(); if (!/^wss?:\/\//.test(url)) { this.status = 'error'; this.error = 'Адрес должен начинаться с ws:// или wss://'; return; } localStorage.setItem(LS_KEY, url); this.disconnect(); this.#wantConnected = true; this.samples = []; this.packetsReceived = 0; this.error = null; this.status = 'connecting'; // History prefetch — best-effort, only for the known stratoflights URL shape. const histUrl = historyUrlFromWs(url); if (histUrl) { try { const res = await fetch(histUrl); if (res.ok) { const body = (await res.json()) as RawPacket[] | { results: RawPacket[] }; const list = Array.isArray(body) ? body : (body.results ?? []); // API returns newest-first; reverse to chronological order. this.samples = [...list] .reverse() .map(parsePacket) .filter((s): s is Sample => s !== null); } } catch { // non-fatal: live stream still works without history } } if (!this.#wantConnected) return; // disconnected while fetching history this.#open(url); } #open(url: string): void { const ws = new WebSocket(url); this.#ws = ws; ws.onopen = () => { this.status = 'connected'; this.error = null; this.#backoffS = 1; }; ws.onmessage = ({ data }) => { try { const raw = JSON.parse(data) as { error?: unknown } & RawPacket; if (raw.error) { this.error = String(raw.error); return; } const sample = parsePacket(raw); if (sample) { // ponytail: uPlot needs strictly ascending x — drop out-of-order packets const last = this.latest; if (!last || sample.t > last.t) { this.samples.push(sample); } this.packetsReceived++; this.#lastRxS = Math.floor(Date.now() / 1000); } } catch { // ignore malformed frames } }; ws.onclose = () => { this.#ws = null; if (!this.#wantConnected) { this.status = 'idle'; return; } // Unwanted close (server restart, network drop) — auto-reconnect. this.status = 'reconnecting'; this.#reconnectTimer = setTimeout(() => { if (this.#wantConnected) this.#open(url); }, this.#backoffS * 1000); this.#backoffS = Math.min(this.#backoffS * 2, RECONNECT_MAX_S); }; ws.onerror = () => { if (this.status === 'connecting') { this.error = 'Не удалось подключиться'; } }; } disconnect(): void { this.#wantConnected = false; if (this.#reconnectTimer) clearTimeout(this.#reconnectTimer); this.#reconnectTimer = null; this.#ws?.close(); this.#ws = null; this.status = 'idle'; } } export const store = new TelemetryStore();