Files
uopi/web/src/lib/ws.ts
T
2026-06-20 14:28:28 +02:00

296 lines
9.1 KiB
TypeScript

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<typeof setTimeout> | null = null;
private intentionalClose = false;
// Status store
private _statusStore = writable<WsStatus>('connecting');
readonly status: Readable<WsStatus> = { subscribe: this._statusStore.subscribe };
// Subscription tracking: key is "ds\0name"
// ref-count: how many callers are subscribed
private refCounts = new Map<string, number>();
// subscriber callbacks per signal
private subscribers = new Map<string, Set<SubscriberEntry>>();
// pending history request callbacks keyed by "ds\0name"
private histCallbacks = new Map<string, Array<(pts: HistoryPoint[]) => 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<HistoryPoint[]> {
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();