diff --git a/src/hooks/useWebSocket.ts b/src/hooks/useWebSocket.ts new file mode 100644 index 0000000..6a4029d --- /dev/null +++ b/src/hooks/useWebSocket.ts @@ -0,0 +1,123 @@ +import { useEffect, useRef, useCallback } from "react"; + +/** + * Derives the WebSocket base URL from VITE_APP_API_URL. + * e.g. "https://api.brandxtech.ca" -> "wss://api.brandxtech.ca" + * Falls back to current page host for local dev. + */ +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}`; +} + +interface UseWebSocketOptions { + /** The API path, e.g. "/v1/live/devices/123/components" */ + path: string; + /** Transform the raw MessageEvent into your domain type */ + parse: (event: MessageEvent) => T; + /** Called whenever a new parsed message arrives */ + onMessage: (data: T) => void; + /** Auth token passed as ?token= query param */ + token?: string; + /** Minimum seconds between updates, passed as ?rate= to the backend */ + rate?: number; + /** Whether the connection should be active. Default true. */ + enabled?: boolean; +} + +/** + * useWebSocket manages a WebSocket connection with automatic reconnection. + * + * Usage: + * useWebSocket({ + * path: `/v1/live/devices/${deviceID}/components`, + * parse: (e) => Component.any(JSON.parse(e.data)), + * onMessage: (component) => handleUpdate(component), + * token: authToken, + * }); + */ +export function useWebSocket(options: UseWebSocketOptions) { + const { path, parse, onMessage, token, rate = 0, enabled = true } = options; + + // Keep latest callbacks in refs so reconnects don't use stale closures + const onMessageRef = useRef(onMessage); + onMessageRef.current = onMessage; + const parseRef = useRef(parse); + parseRef.current = parse; + + const wsRef = useRef(null); + const reconnectTimeoutRef = useRef>(); + const retriesRef = useRef(0); + + const connect = useCallback(() => { + if (!token) return; + + const params = new URLSearchParams(); + params.set("token", token); + if (rate > 0) params.set("rate", rate.toString()); + + const url = `${getWsBaseUrl()}${path}?${params.toString()}`; + const ws = new WebSocket(url); + wsRef.current = ws; + + ws.onopen = () => { + console.debug(`[ws] connected: ${path}`); + retriesRef.current = 0; + }; + + ws.onmessage = (event) => { + try { + const parsed = parseRef.current(event); + onMessageRef.current(parsed); + } catch (err) { + console.warn(`[ws] parse error on ${path}:`, err); + } + }; + + ws.onerror = (err) => { + console.warn(`[ws] error on ${path}:`, err); + }; + + ws.onclose = (event) => { + console.debug(`[ws] closed: ${path} (code: ${event.code})`); + wsRef.current = null; + + // Don't reconnect on clean close or auth rejection + if (event.code === 1000 || event.code === 4001 || event.code === 4003) { + return; + } + + // Exponential backoff: 1s, 2s, 4s, 8s, ... capped at 30s + const delay = Math.min(1000 * Math.pow(2, retriesRef.current), 30000); + retriesRef.current += 1; + console.debug(`[ws] reconnecting in ${delay}ms...`); + reconnectTimeoutRef.current = setTimeout(connect, delay); + }; + }, [path, token, rate]); + + useEffect(() => { + if (!enabled || !token) { + if (wsRef.current) { + wsRef.current.close(1000, "disabled"); + wsRef.current = null; + } + return; + } + + connect(); + + return () => { + if (reconnectTimeoutRef.current) { + clearTimeout(reconnectTimeoutRef.current); + } + if (wsRef.current) { + wsRef.current.close(1000, "cleanup"); + wsRef.current = null; + } + }; + }, [connect, enabled, token]); +} diff --git a/src/pages/Device.tsx b/src/pages/Device.tsx index 0a47a05..db9914e 100644 --- a/src/pages/Device.tsx +++ b/src/pages/Device.tsx @@ -6,7 +6,7 @@ import { useDeviceAPI, useGlobalState, useSnackbar } from "providers"; import { useEffect, useState } from "react"; import { useLocation, useParams } from "react-router-dom"; import PageContainer from "./PageContainer"; -import { useMobile } from "hooks"; +import { useHTTP, useMobile } from "hooks"; import LoadingScreen from "app/LoadingScreen"; import SmartBreadcrumb from "common/SmartBreadcrumb"; import DeviceActions from "device/DeviceActions"; @@ -21,6 +21,7 @@ import { or } from "utils"; import ComponentDiagnostics from "component/ComponentDiagnostics"; import DeviceWizard from "device/DeviceWizard"; import DeviceScannedComponents from "device/autoDetect/DeviceScannedComponents"; +import { useWebSocket } from "hooks/useWebSocket"; export interface DevicePageData { device: Device; @@ -65,6 +66,8 @@ export default function DevicePage() { // const [devicePageData, setDevicePageData] = useState(pond.GetDevicePageDataResponse.create()) + const { token } = useHTTP(); + const loadDevice = () => { if (loading) return if (state?.devicePageData) { @@ -165,6 +168,65 @@ export default function DevicePage() { .finally(() => setLoading(false)); } + // --- Real-time component updates --- + // Streams all component changes for this device. + // When the backend receives a new reading and updates a component, + // this fires and updates the component in state without a page refresh. + useWebSocket<{ key: string; component: Component } | null>({ + path: `/live/devices/${deviceID}/components`, + parse: (e) => { + try { + const raw = JSON.parse(e.data); + const comp = Component.any(raw); + if (!comp?.settings) { + console.warn("[ws] received component without settings:", raw); + return null; + } + return { key: comp.key(), component: comp }; + } catch (err) { + console.warn("[ws] failed to parse component:", err); + return null; + } + }, + onMessage: (data) => { + if (!data) return; + const { key, component } = data; + setComponents((prev) => { + const updated = new Map(prev); + updated.set(key, component); + return updated; + }); + }, + token, + enabled: !!deviceID, + }); + + // --- Real-time device updates (optional) --- + // Updates device-level info (name, status, lastActive, etc.) + useWebSocket({ + path: `/live/devices/${deviceID}`, + parse: (e) => { + try { + const raw = JSON.parse(e.data); + const dev = Device.any(raw); + if (!dev?.settings) { + console.warn("[ws] received device without settings:", raw); + return null; + } + return dev; + } catch (err) { + console.warn("[ws] failed to parse device:", err); + return null; + } + }, + onMessage: (updatedDevice) => { + if (!updatedDevice) return; + setDevice(updatedDevice); + }, + token, + enabled: !!deviceID, + }); + const loadPortScan = () => { deviceAPI.listFoundComponents(parseInt(deviceID)) .then(resp => { diff --git a/src/providers/http.tsx b/src/providers/http.tsx index c4d9f64..3e93f04 100644 --- a/src/providers/http.tsx +++ b/src/providers/http.tsx @@ -19,6 +19,7 @@ interface IHTTPContext { ) => Promise>; del: (url: string, spreadOptions?: AxiosRequestConfig) => Promise>; options: (demo?: boolean) => AxiosRequestConfig; + token: string; } interface Props extends PropsWithChildren{ @@ -104,7 +105,8 @@ export default function HTTPProvider(props: Props) { put, post, del, - options: defaultOptions + options: defaultOptions, + token }}> {/* */}