This commit is contained in:
Anatoly Antonov 2026-07-04 03:23:46 +09:00
commit 43c120df4c
26 changed files with 3265 additions and 0 deletions

148
src/lib/store.svelte.ts Normal file
View file

@ -0,0 +1,148 @@
/**
* 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/<uuid>/telemetry/',
);
status = $state<ConnStatus>('idle');
error = $state<string | null>(null);
samples = $state<Sample[]>([]);
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<typeof setTimeout> | 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<void> {
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();