Initial
This commit is contained in:
commit
43c120df4c
26 changed files with 3265 additions and 0 deletions
148
src/lib/store.svelte.ts
Normal file
148
src/lib/store.svelte.ts
Normal 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();
|
||||
Loading…
Add table
Add a link
Reference in a new issue