Merge branch 'websocket_context' into staging_environment
This commit is contained in:
commit
46ce5b6720
4 changed files with 83 additions and 10 deletions
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue