From 7939d540acd1ad82ce42fd46001ff5b36c3222a1 Mon Sep 17 00:00:00 2001 From: Carter Date: Fri, 6 Mar 2026 12:23:34 -0600 Subject: [PATCH 1/4] implemented web sockets --- src/pages/Device.tsx | 40 +++++++++++++++++++++++++++++++++++++++- src/providers/http.tsx | 4 +++- 2 files changed, 42 insertions(+), 2 deletions(-) diff --git a/src/pages/Device.tsx b/src/pages/Device.tsx index 0a47a05..281842d 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,41 @@ 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 }>({ + path: `/v1/live/devices/${deviceID}/components`, + parse: (e) => { + const raw = JSON.parse(e.data); + const comp = Component.any(raw); + return { key: comp.key(), component: comp }; + }, + onMessage: ({ key, component }) => { + // Functional update so we always work with the latest state + 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: `/v1/live/devices/${deviceID}`, + parse: (e) => Device.any(JSON.parse(e.data)), + onMessage: (updatedDevice) => { + 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 }}> {/* */} From db9742ed3288ac3420c0ad6595af4e01ff0989a3 Mon Sep 17 00:00:00 2001 From: Carter Date: Fri, 6 Mar 2026 12:49:05 -0600 Subject: [PATCH 2/4] implemented web sockets --- src/hooks/useWebSocket.ts | 123 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 123 insertions(+) create mode 100644 src/hooks/useWebSocket.ts 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]); +} From 55bff3af0073fa8d173bcbca3b79c282ebc96f98 Mon Sep 17 00:00:00 2001 From: Carter Date: Fri, 6 Mar 2026 13:04:46 -0600 Subject: [PATCH 3/4] guarding component web socket a little better --- src/pages/Device.tsx | 24 +++++++++++++++++------- 1 file changed, 17 insertions(+), 7 deletions(-) diff --git a/src/pages/Device.tsx b/src/pages/Device.tsx index 281842d..0fb59a9 100644 --- a/src/pages/Device.tsx +++ b/src/pages/Device.tsx @@ -172,15 +172,25 @@ export default function DevicePage() { // 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 }>({ - path: `/v1/live/devices/${deviceID}/components`, + useWebSocket<{ key: string; component: Component } | null>({ + path: `/live/devices/${deviceID}/components`, parse: (e) => { - const raw = JSON.parse(e.data); - const comp = Component.any(raw); - return { key: comp.key(), component: comp }; + 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: ({ key, component }) => { - // Functional update so we always work with the latest state + onMessage: (data) => { + if (!data) return; + const { key, component } = data; setComponents((prev) => { const updated = new Map(prev); updated.set(key, component); From 4546796db9d35bc5bfd66462ecea74f287be9121 Mon Sep 17 00:00:00 2001 From: Carter Date: Fri, 6 Mar 2026 13:05:30 -0600 Subject: [PATCH 4/4] guarding device web socket a little better --- src/pages/Device.tsx | 20 +++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/src/pages/Device.tsx b/src/pages/Device.tsx index 0fb59a9..db9914e 100644 --- a/src/pages/Device.tsx +++ b/src/pages/Device.tsx @@ -203,10 +203,24 @@ export default function DevicePage() { // --- Real-time device updates (optional) --- // Updates device-level info (name, status, lastActive, etc.) - useWebSocket({ - path: `/v1/live/devices/${deviceID}`, - parse: (e) => Device.any(JSON.parse(e.data)), + 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,