296 lines
9.1 KiB
TypeScript
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();
|