138 lines
4.5 KiB
TypeScript
138 lines
4.5 KiB
TypeScript
import { pond } from "protobuf-ts/pond";
|
|
import { useEffect, useRef, useCallback, useMemo } from "react";
|
|
|
|
/**
|
|
* Derives the WebSocket base URL from VITE_APP_API_URL.
|
|
* Matches the logic in useWebSocket.ts.
|
|
*/
|
|
function getWsBaseUrl(): string {
|
|
const apiUrl = import.meta.env.VITE_APP_API_URL;
|
|
if (apiUrl) {
|
|
return apiUrl.replace(/^http/, "ws");
|
|
}
|
|
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
|
|
return `${protocol}//${window.location.host}`;
|
|
}
|
|
|
|
export interface UseDeviceStatusStreamsOptions {
|
|
/** Device IDs to stream status for (e.g. from the visible devices list) */
|
|
deviceIds: string[];
|
|
/** Called when a device status update is received */
|
|
onStatusUpdate: (deviceId: string, status: pond.DeviceStatus) => void;
|
|
/** Auth token passed as ?token= query param */
|
|
token?: string;
|
|
/** Whether the connections should be active. Default true. */
|
|
enabled?: boolean;
|
|
}
|
|
|
|
/**
|
|
* Subscribes to live device status streams for multiple devices.
|
|
* Uses the /v1/live/devices/:device/status endpoint per device.
|
|
* Readings (plenum, sen5x, co, co2, no2, o2, etc.) stream in real time.
|
|
*/
|
|
export function useDeviceStatusStreams(options: UseDeviceStatusStreamsOptions) {
|
|
const { deviceIds, onStatusUpdate, token, enabled = true } = options;
|
|
|
|
const onStatusUpdateRef = useRef(onStatusUpdate);
|
|
onStatusUpdateRef.current = onStatusUpdate;
|
|
|
|
const wsMapRef = useRef<Map<string, WebSocket>>(new Map());
|
|
const retriesRef = useRef<Map<string, number>>(new Map());
|
|
const reconnectTimeoutsRef = useRef<Map<string, ReturnType<typeof setTimeout>>>(new Map());
|
|
|
|
const connect = useCallback(
|
|
(deviceId: string) => {
|
|
if (!token || !deviceId) return;
|
|
|
|
const params = new URLSearchParams();
|
|
params.set("token", token);
|
|
|
|
const path = `/live/devices/${deviceId}/status`;
|
|
const url = `${getWsBaseUrl()}${path}?${params.toString()}`;
|
|
const ws = new WebSocket(url);
|
|
wsMapRef.current.set(deviceId, ws);
|
|
|
|
ws.onopen = () => {
|
|
console.debug(`[ws] connected: ${path}`);
|
|
retriesRef.current.set(deviceId, 0);
|
|
};
|
|
|
|
ws.onmessage = (event) => {
|
|
try {
|
|
const raw = JSON.parse(event.data);
|
|
const status = pond.DeviceStatus.fromObject(raw ?? {});
|
|
onStatusUpdateRef.current(deviceId, status);
|
|
} catch (err) {
|
|
console.warn(`[ws] failed to parse device status for ${deviceId}:`, err);
|
|
}
|
|
};
|
|
|
|
ws.onerror = () => {
|
|
console.warn(`[ws] error on ${path}`);
|
|
};
|
|
|
|
ws.onclose = (event) => {
|
|
wsMapRef.current.delete(deviceId);
|
|
|
|
// Don't reconnect on clean close or auth rejection
|
|
if (event.code === 1000 || event.code === 4001 || event.code === 4003) {
|
|
return;
|
|
}
|
|
|
|
const retries = retriesRef.current.get(deviceId) ?? 0;
|
|
const delay = Math.min(1000 * Math.pow(2, retries), 30000);
|
|
retriesRef.current.set(deviceId, retries + 1);
|
|
console.debug(`[ws] reconnecting ${path} in ${delay}ms...`);
|
|
|
|
const timeout = setTimeout(() => connect(deviceId), delay);
|
|
reconnectTimeoutsRef.current.set(deviceId, timeout);
|
|
};
|
|
},
|
|
[token]
|
|
);
|
|
|
|
// Normalize for stable comparison: same set of IDs = same string regardless of order
|
|
const deviceIdsKey = useMemo(
|
|
() => [...new Set(deviceIds)].sort().join(","),
|
|
[deviceIds.join(",")]
|
|
);
|
|
|
|
useEffect(() => {
|
|
if (!enabled || !token || deviceIds.length === 0) {
|
|
wsMapRef.current.forEach((ws) => ws.close(1000, "disabled"));
|
|
wsMapRef.current.clear();
|
|
reconnectTimeoutsRef.current.forEach((t) => clearTimeout(t));
|
|
reconnectTimeoutsRef.current.clear();
|
|
return;
|
|
}
|
|
|
|
const currentIds = new Set(deviceIds);
|
|
|
|
// Connect to new devices
|
|
deviceIds.forEach((id) => {
|
|
if (!wsMapRef.current.has(id)) {
|
|
connect(id);
|
|
}
|
|
});
|
|
|
|
// Disconnect from devices no longer in the list
|
|
wsMapRef.current.forEach((ws, id) => {
|
|
if (!currentIds.has(id)) {
|
|
ws.close(1000, "device removed");
|
|
wsMapRef.current.delete(id);
|
|
const t = reconnectTimeoutsRef.current.get(id);
|
|
if (t) {
|
|
clearTimeout(t);
|
|
reconnectTimeoutsRef.current.delete(id);
|
|
}
|
|
}
|
|
});
|
|
|
|
return () => {
|
|
wsMapRef.current.forEach((ws) => ws.close(1000, "cleanup"));
|
|
wsMapRef.current.clear();
|
|
reconnectTimeoutsRef.current.forEach((t) => clearTimeout(t));
|
|
reconnectTimeoutsRef.current.clear();
|
|
};
|
|
}, [enabled, token, deviceIdsKey, deviceIds.length, connect]);
|
|
}
|