import { writable, type Readable } from './store'; import { writeLocalState } from './localstate'; import { pushControlDialog } from './controldialogs'; import type { SignalRef, SignalValue, SignalMeta, HistoryPoint } from './types'; // ── Types ──────────────────────────────────────────────────────────────────── type WsStatus = 'connecting' | 'connected' | 'disconnected'; interface SubscriberEntry { onUpdate: (v: SignalValue) => void; onMeta: (m: SignalMeta) => void; } // ── WsClient ───────────────────────────────────────────────────────────────── class WsClient { private ws: WebSocket | null = null; private url = ''; // Reconnect back-off state private retryDelay = 500; private readonly maxDelay = 30_000; private retryTimer: ReturnType | null = null; private intentionalClose = false; // Status store private _statusStore = writable('connecting'); readonly status: Readable = { subscribe: this._statusStore.subscribe }; // Subscription tracking: key is "ds\0name" // ref-count: how many callers are subscribed private refCounts = new Map(); // subscriber callbacks per signal private subscribers = new Map>(); // pending history request callbacks keyed by "ds\0name" private histCallbacks = new Map void>>(); connect(url: string): void { this.url = url; this.intentionalClose = false; this._open(); } private _open(): void { if (this.intentionalClose) return; this._statusStore.set('connecting'); try { // Resolve relative URL to absolute ws:// URL const wsUrl = this._resolveWsUrl(this.url); this.ws = new WebSocket(wsUrl); } catch { this._scheduleReconnect(); return; } this.ws.addEventListener('open', () => { this.retryDelay = 500; // reset back-off on success this._statusStore.set('connected'); // Re-subscribe all active signals after reconnect const signals: Array<{ ds: string; name: string }> = []; for (const key of this.refCounts.keys()) { if ((this.refCounts.get(key) ?? 0) > 0) { const [ds, name] = key.split('\0'); signals.push({ ds, name }); } } if (signals.length > 0) { this._send({ type: 'subscribe', signals }); } }); this.ws.addEventListener('message', (ev) => { this._handleMessage(ev.data as string); }); this.ws.addEventListener('close', () => { this._statusStore.set('disconnected'); if (!this.intentionalClose) { this._scheduleReconnect(); } }); this.ws.addEventListener('error', () => { // The close event fires right after, which will handle the reconnect. this._statusStore.set('disconnected'); }); } private _resolveWsUrl(path: string): string { if (path.startsWith('ws://') || path.startsWith('wss://')) { return path; } const proto = location.protocol === 'https:' ? 'wss:' : 'ws:'; return `${proto}//${location.host}${path}`; } private _scheduleReconnect(): void { if (this.retryTimer !== null) return; this.retryTimer = setTimeout(() => { this.retryTimer = null; this.retryDelay = Math.min(this.retryDelay * 2, this.maxDelay); this._open(); }, this.retryDelay); } private _send(obj: unknown): void { if (this.ws?.readyState === WebSocket.OPEN) { this.ws.send(JSON.stringify(obj)); } } private _handleMessage(data: string): void { let msg: any; try { msg = JSON.parse(data); } catch { return; } const key = msg.ds && msg.name ? `${msg.ds}\0${msg.name}` : ''; switch (msg.type) { case 'update': { const subs = this.subscribers.get(key); if (!subs) break; const val: SignalValue = { value: msg.value, quality: msg.quality ?? 'unknown', ts: msg.ts ?? null, severity: msg.severity ?? 0, status: msg.status ?? 0, }; for (const s of subs) s.onUpdate(val); break; } case 'meta': { const subs = this.subscribers.get(key); if (!subs || !msg.meta) break; const meta: SignalMeta = { type: msg.meta.type ?? 'unknown', unit: msg.meta.unit, displayLow: msg.meta.displayLow ?? 0, displayHigh: msg.meta.displayHigh ?? 100, writable: msg.meta.writable ?? false, enumStrings: msg.meta.enumStrings, description: msg.meta.description, }; for (const s of subs) s.onMeta(meta); break; } case 'history': { const hcbs = this.histCallbacks.get(key); if (hcbs) { const pts: HistoryPoint[] = (msg.points ?? []).map((p: any) => ({ ts: p.ts ?? p.TS, value: p.value ?? p.Value, })); this.histCallbacks.delete(key); for (const cb of hcbs) cb(pts); } break; } case 'dialog': { // Server-side control logic requesting a user notification / input. pushControlDialog({ id: String(msg.id), kind: msg.kind === 'error' || msg.kind === 'input' ? msg.kind : 'info', title: msg.title, message: msg.message, }); break; } case 'error': // Resolve any pending history callbacks with empty data on error if (key && this.histCallbacks.has(key)) { const hcbs = this.histCallbacks.get(key)!; this.histCallbacks.delete(key); for (const cb of hcbs) cb([]); } console.warn('[ws] server error', msg.code, msg.message); break; default: break; } } /** * Subscribe to a signal. Reference-counted: only one WS subscribe message * is sent even if multiple callers subscribe to the same signal. * Returns a cleanup function that unsubscribes when all callers are done. */ subscribe( ref: SignalRef, onUpdate: (v: SignalValue) => void, onMeta: (m: SignalMeta) => void, ): () => void { const key = `${ref.ds}\0${ref.name}`; const entry: SubscriberEntry = { onUpdate, onMeta }; // Add to subscriber set let set = this.subscribers.get(key); if (!set) { set = new Set(); this.subscribers.set(key, set); } set.add(entry); // Reference counting — send WS subscribe only on first subscriber const prev = this.refCounts.get(key) ?? 0; this.refCounts.set(key, prev + 1); if (prev === 0) { this._send({ type: 'subscribe', signals: [{ ds: ref.ds, name: ref.name }] }); } return () => { const subs = this.subscribers.get(key); subs?.delete(entry); const count = (this.refCounts.get(key) ?? 0) - 1; this.refCounts.set(key, Math.max(0, count)); if (count <= 0) { this.subscribers.delete(key); this.refCounts.delete(key); this._send({ type: 'unsubscribe', signals: [{ ds: ref.ds, name: ref.name }] }); } }; } /** * Request historical data for a signal. * Resolves with an array of HistoryPoint once the server responds. * Resolves with [] if the datasource doesn't support history or times out. */ history( ref: SignalRef, start: string, end: string, maxPoints = 5000, ): Promise { return new Promise((resolve) => { const key = `${ref.ds}\0${ref.name}`; // Timeout after 15 s — resolves with empty array const timer = setTimeout(() => { const cbs = this.histCallbacks.get(key); if (cbs) { const idx = cbs.indexOf(wrappedResolve); if (idx >= 0) cbs.splice(idx, 1); if (cbs.length === 0) this.histCallbacks.delete(key); } resolve([]); }, 15_000); const wrappedResolve = (pts: HistoryPoint[]) => { clearTimeout(timer); resolve(pts); }; let cbs = this.histCallbacks.get(key); if (!cbs) { cbs = []; this.histCallbacks.set(key, cbs); } cbs.push(wrappedResolve); this._send({ type: 'history', ds: ref.ds, name: ref.name, start, end, maxPoints }); }); } write(ref: SignalRef, value: any): void { // Panel-local variables never go to the server — update them in place. if (ref.ds === 'local') { writeLocalState(ref.name, value); return; } console.log('[ws] write', ref.ds, ref.name, value, 'ws state:', this.ws?.readyState); this._send({ type: 'write', ds: ref.ds, name: ref.name, value }); } /** * Answer a control-logic input dialog. `value === null` cancels (dismisses) * the dialog without writing its target server variable. */ sendDialogResponse(id: string, value: number | null): void { this._send({ type: 'dialogResponse', id, value }); } } // Singleton instance export const wsClient = new WsClient();