Merge branch 'websocket_context' into staging_environment

This commit is contained in:
Carter 2026-03-20 11:17:08 -06:00
commit 46ce5b6720
4 changed files with 83 additions and 10 deletions

View file

@ -24,6 +24,12 @@ export interface UseDeviceStatusStreamsOptions {
onStatusUpdate: (deviceId: string, status: pond.DeviceStatus) => void; onStatusUpdate: (deviceId: string, status: pond.DeviceStatus) => void;
/** Auth token passed as ?token= query param */ /** Auth token passed as ?token= query param */
token?: string; token?: string;
/** Same as REST/listDevices (?keys=) — required when listing under a context path or group tab */
keys?: string[];
/** Same as REST (?types=) */
types?: string[];
/** View-as / impersonation (?as=), same as device list when not already team-first in URL */
as?: string;
/** Whether the connections should be active. Default true. */ /** Whether the connections should be active. Default true. */
enabled?: boolean; enabled?: boolean;
} }
@ -34,7 +40,7 @@ export interface UseDeviceStatusStreamsOptions {
* Readings (plenum, sen5x, co, co2, no2, o2, etc.) stream in real time. * Readings (plenum, sen5x, co, co2, no2, o2, etc.) stream in real time.
*/ */
export function useDeviceStatusStreams(options: UseDeviceStatusStreamsOptions) { export function useDeviceStatusStreams(options: UseDeviceStatusStreamsOptions) {
const { deviceIds, onStatusUpdate, token, enabled = true } = options; const { deviceIds, onStatusUpdate, token, keys, types, as, enabled = true } = options;
const onStatusUpdateRef = useRef(onStatusUpdate); const onStatusUpdateRef = useRef(onStatusUpdate);
onStatusUpdateRef.current = onStatusUpdate; onStatusUpdateRef.current = onStatusUpdate;
@ -49,8 +55,12 @@ export function useDeviceStatusStreams(options: UseDeviceStatusStreamsOptions) {
const params = new URLSearchParams(); const params = new URLSearchParams();
params.set("token", token); params.set("token", token);
if (keys?.length) params.set("keys", keys.toString());
if (types?.length) params.set("types", types.toString());
if (as) params.set("as", as);
const path = `/live/devices/${deviceId}/status`; // Match useWebSocket / pond routes: /v1/live/devices/:id/status
const path = `/v1/live/devices/${deviceId}/status`;
const url = `${getWsBaseUrl()}${path}?${params.toString()}`; const url = `${getWsBaseUrl()}${path}?${params.toString()}`;
const ws = new WebSocket(url); const ws = new WebSocket(url);
wsMapRef.current.set(deviceId, ws); wsMapRef.current.set(deviceId, ws);
@ -91,7 +101,7 @@ export function useDeviceStatusStreams(options: UseDeviceStatusStreamsOptions) {
reconnectTimeoutsRef.current.set(deviceId, timeout); reconnectTimeoutsRef.current.set(deviceId, timeout);
}; };
}, },
[token] [token, keys?.join(), types?.join(), as]
); );
// Normalize for stable comparison: same set of IDs = same string regardless of order // Normalize for stable comparison: same set of IDs = same string regardless of order

View file

@ -18,7 +18,7 @@ function getWsBaseUrl(): string {
} }
interface UseWebSocketOptions<T> { interface UseWebSocketOptions<T> {
/** The API path, e.g. "/v1/live/devices/123/components" */ /** The API path including /v1, e.g. "/v1/live/devices/123/components" */
path: string; path: string;
/** Transform the raw MessageEvent into your domain type */ /** Transform the raw MessageEvent into your domain type */
parse: (event: MessageEvent) => T; parse: (event: MessageEvent) => T;
@ -28,6 +28,12 @@ interface UseWebSocketOptions<T> {
token?: string; token?: string;
/** Minimum seconds between updates, passed as ?rate= to the backend */ /** Minimum seconds between updates, passed as ?rate= to the backend */
rate?: number; rate?: number;
/** Context keys, same as REST device APIs (?keys=) */
keys?: string[];
/** Context types, same as REST device APIs (?types=) */
types?: string[];
/** Impersonation / team view key, same as REST (?as=) */
as?: string;
/** Whether the connection should be active. Default true. */ /** Whether the connection should be active. Default true. */
enabled?: boolean; enabled?: boolean;
} }
@ -44,7 +50,7 @@ interface UseWebSocketOptions<T> {
* }); * });
*/ */
export function useWebSocket<T>(options: UseWebSocketOptions<T>) { export function useWebSocket<T>(options: UseWebSocketOptions<T>) {
const { path, parse, onMessage, token, rate = 0, enabled = true } = options; const { path, parse, onMessage, token, rate = 0, keys, types, as, enabled = true } = options;
// Keep latest callbacks in refs so reconnects don't use stale closures // Keep latest callbacks in refs so reconnects don't use stale closures
const onMessageRef = useRef(onMessage); const onMessageRef = useRef(onMessage);
@ -62,6 +68,9 @@ export function useWebSocket<T>(options: UseWebSocketOptions<T>) {
const params = new URLSearchParams(); const params = new URLSearchParams();
params.set("token", token); params.set("token", token);
if (rate > 0) params.set("rate", rate.toString()); if (rate > 0) params.set("rate", rate.toString());
if (keys?.length) params.set("keys", keys.toString());
if (types?.length) params.set("types", types.toString());
if (as) params.set("as", as);
const url = `${getWsBaseUrl()}${path}?${params.toString()}`; const url = `${getWsBaseUrl()}${path}?${params.toString()}`;
const ws = new WebSocket(url); const ws = new WebSocket(url);
@ -100,7 +109,7 @@ export function useWebSocket<T>(options: UseWebSocketOptions<T>) {
console.debug(`[ws] reconnecting in ${delay}ms...`); console.debug(`[ws] reconnecting in ${delay}ms...`);
reconnectTimeoutRef.current = setTimeout(connect, delay); reconnectTimeoutRef.current = setTimeout(connect, delay);
}; };
}, [path, token, rate]); }, [path, token, rate, keys?.join(), types?.join(), as]);
useEffect(() => { useEffect(() => {
if (!WEBSOCKETS_ENABLED || !enabled || !token) { if (!WEBSOCKETS_ENABLED || !enabled || !token) {

View file

@ -3,7 +3,7 @@ import Grid from '@mui/material/Grid2';
import { Component, Device, Interaction, User } from "models"; import { Component, Device, Interaction, User } from "models";
import { getContextKeys, getContextTypes } from "pbHelpers/Context"; import { getContextKeys, getContextTypes } from "pbHelpers/Context";
import { useDeviceAPI, useGlobalState, useSnackbar } from "providers"; import { useDeviceAPI, useGlobalState, useSnackbar } from "providers";
import { useEffect, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { useLocation, useParams } from "react-router-dom"; import { useLocation, useParams } from "react-router-dom";
import PageContainer from "./PageContainer"; import PageContainer from "./PageContainer";
import { useHTTP, useMobile } from "hooks"; import { useHTTP, useMobile } from "hooks";
@ -38,7 +38,7 @@ export default function DevicePage() {
const isMobile = useMobile() const isMobile = useMobile()
const deviceID = useParams<{ deviceID: string }>()?.deviceID ?? ""; const deviceID = useParams<{ deviceID: string }>()?.deviceID ?? "";
const groupID = useParams<{ groupID: string }>()?.groupID ?? ""; const groupID = useParams<{ groupID: string }>()?.groupID ?? "";
const { state } = useLocation(); const { state, pathname: locationPathname } = useLocation();
const [{ as, team, user }] = useGlobalState() const [{ as, team, user }] = useGlobalState()
// console.log(state) // console.log(state)
const [device, setDevice] = useState<Device>(state?.device ? Device.create(state.device) : Device.create()) const [device, setDevice] = useState<Device>(state?.device ? Device.create(state.device) : Device.create())
@ -68,6 +68,14 @@ export default function DevicePage() {
const { token } = useHTTP(); const { token } = useHTTP();
const liveContextKeys = useMemo(() => getContextKeys(), [locationPathname]);
const liveContextTypes = useMemo(() => getContextTypes(), [locationPathname]);
/** Matches getDevicePageData: omit ?as= when first context type is "team". */
const liveAs =
as && !(liveContextTypes.length > 0 && liveContextTypes[0] === "team")
? as
: undefined;
const loadDevice = () => { const loadDevice = () => {
if (loading) return if (loading) return
if (state?.devicePageData) { if (state?.devicePageData) {
@ -173,7 +181,7 @@ export default function DevicePage() {
// When the backend receives a new reading and updates a component, // When the backend receives a new reading and updates a component,
// this fires and updates the component in state without a page refresh. // this fires and updates the component in state without a page refresh.
useWebSocket<{ key: string; component: Component } | null>({ useWebSocket<{ key: string; component: Component } | null>({
path: `/live/devices/${deviceID}/components`, path: `/v1/live/devices/${deviceID}/components`,
parse: (e) => { parse: (e) => {
try { try {
const raw = JSON.parse(e.data); const raw = JSON.parse(e.data);
@ -197,6 +205,9 @@ export default function DevicePage() {
return updated; return updated;
}); });
}, },
keys: liveContextKeys,
types: liveContextTypes,
as: liveAs,
token, token,
enabled: !!deviceID, enabled: !!deviceID,
}); });
@ -204,7 +215,7 @@ export default function DevicePage() {
// --- Real-time device updates (optional) --- // --- Real-time device updates (optional) ---
// Updates device-level info (name, status, lastActive, etc.) // Updates device-level info (name, status, lastActive, etc.)
useWebSocket<Device | null>({ useWebSocket<Device | null>({
path: `/live/devices/${deviceID}`, path: `/v1/live/devices/${deviceID}`,
parse: (e) => { parse: (e) => {
try { try {
const raw = JSON.parse(e.data); const raw = JSON.parse(e.data);
@ -223,6 +234,9 @@ export default function DevicePage() {
if (!updatedDevice) return; if (!updatedDevice) return;
setDevice(updatedDevice); setDevice(updatedDevice);
}, },
keys: liveContextKeys,
types: liveContextTypes,
as: liveAs,
token, token,
enabled: !!deviceID, enabled: !!deviceID,
}); });

View file

@ -264,10 +264,50 @@ export default function Devices() {
) )
}, []) }, [])
/** Same context chain as deviceAPI.list / GetContext on the backend (path + group tab + ?as=). */
const statusStreamKeys = useMemo(() => {
if (tab !== "all") {
const g = groups[parseInt(tab)]
if (g) {
const keys = [...getContextKeys()]
keys.push(g.id().toString())
return keys
}
}
return getContextKeys()
}, [tab, groups, location.pathname])
const statusStreamTypes = useMemo(() => {
if (tab !== "all") {
const g = groups[parseInt(tab)]
if (g) {
const types = [...getContextTypes()]
types.push("group")
return types
}
}
return getContextTypes()
}, [tab, groups, location.pathname])
/** Match Device page / getDevicePageData: omit ?as= when URL context already starts with team. */
const statusStreamAs = useMemo(() => {
const types =
tab !== "all" && groups[parseInt(tab)]
? [...getContextTypes(), "group"]
: getContextTypes()
if (as && !(types.length > 0 && types[0] === "team")) {
return as
}
return undefined
}, [as, tab, groups, location.pathname])
useDeviceStatusStreams({ useDeviceStatusStreams({
deviceIds, deviceIds,
onStatusUpdate: handleStatusUpdate, onStatusUpdate: handleStatusUpdate,
token, token,
keys: statusStreamKeys,
types: statusStreamTypes,
as: statusStreamAs,
enabled: devices.length > 0 && !!token, enabled: devices.length > 0 && !!token,
}) })