From 1e2003a5414c78ab175d2293dfda90d654e8774c Mon Sep 17 00:00:00 2001 From: Carter Date: Fri, 13 Mar 2026 13:20:51 -0600 Subject: [PATCH] added optional timestamp --- src/common/RelativeTimestamp.tsx | 38 ++++++++ src/common/StatusDust.tsx | 12 ++- src/common/StatusGas.tsx | 14 ++- src/common/StatusPlenum.tsx | 11 ++- src/common/StatusSen5x.tsx | 11 ++- src/hooks/useDeviceStatusStreams.ts | 138 ++++++++++++++++++++++++++++ src/pages/Devices.tsx | 33 +++++-- 7 files changed, 240 insertions(+), 17 deletions(-) create mode 100644 src/common/RelativeTimestamp.tsx create mode 100644 src/hooks/useDeviceStatusStreams.ts diff --git a/src/common/RelativeTimestamp.tsx b/src/common/RelativeTimestamp.tsx new file mode 100644 index 0000000..811ded6 --- /dev/null +++ b/src/common/RelativeTimestamp.tsx @@ -0,0 +1,38 @@ +import { Typography } from "@mui/material"; +import moment from "moment"; +import { useEffect, useState } from "react"; + +interface Props { + /** RFC3339 timestamp string */ + timestamp: string; +} + +/** + * Displays a timestamp as relative time (e.g. "a minute ago", "5 seconds ago"). + * Uses a subtle, muted typography style. + * Refreshes every 60 seconds to keep the relative text current. + */ +export default function RelativeTimestamp({ timestamp }: Props) { + const [, setTick] = useState(0); + + useEffect(() => { + const interval = setInterval(() => setTick((t) => t + 1), 60000); + return () => clearInterval(interval); + }, []); + + const date = moment(timestamp); + if (!date.isValid()) return null; + + return ( + + {date.fromNow()} + + ); +} diff --git a/src/common/StatusDust.tsx b/src/common/StatusDust.tsx index a7e8e3a..4963269 100644 --- a/src/common/StatusDust.tsx +++ b/src/common/StatusDust.tsx @@ -3,10 +3,13 @@ import { green } from "@mui/material/colors"; import { makeStyles } from "@mui/styles"; import { Device } from "models"; import PulseBox from "./PulseBox"; +import RelativeTimestamp from "./RelativeTimestamp"; import { or } from "utils"; import { useEffect, useState } from "react"; + interface Props { - device: Device + device: Device; + showTimestamp?: boolean; } const useStyles = makeStyles((theme: Theme) => { @@ -48,7 +51,7 @@ const useStyles = makeStyles((theme: Theme) => { }) export default function StatusDust(props: Props) { - const { device } = props; + const { device, showTimestamp } = props; const classes = useStyles() const colors = or(device.status.sen5x?.overlays.map(overlay => overlay.color), []); @@ -165,6 +168,11 @@ export default function StatusDust(props: Props) { + {showTimestamp && device.status.sen5x?.timestamp && ( + + + + )} diff --git a/src/common/StatusGas.tsx b/src/common/StatusGas.tsx index dcf7ed3..4571278 100644 --- a/src/common/StatusGas.tsx +++ b/src/common/StatusGas.tsx @@ -3,13 +3,14 @@ import { blue, deepOrange, teal } from "@mui/material/colors"; import { makeStyles } from "@mui/styles"; import { Device } from "models"; import PulseBox from "./PulseBox"; +import RelativeTimestamp from "./RelativeTimestamp"; import { or } from "utils"; import { useEffect, useState } from "react"; interface Props { - device: Device - // noDust?: boolean; - gasType: "co2" | "co" | "no2" | "o2" | "all" + device: Device; + gasType: "co2" | "co" | "no2" | "o2" | "all"; + showTimestamp?: boolean; } const useStyles = makeStyles((theme: Theme) => { @@ -61,7 +62,7 @@ const useStyles = makeStyles((theme: Theme) => { }) export default function StatusGas(props: Props) { - const { device, gasType } = props; + const { device, gasType, showTimestamp } = props; const classes = useStyles() // console.log(noDust) let colors: string[] = [] @@ -203,6 +204,11 @@ export default function StatusGas(props: Props) { Volt: {device.status[gas]?.millivolts.toFixed(1)}mV + {showTimestamp && device.status[gas]?.timestamp && ( + + + + )} diff --git a/src/common/StatusPlenum.tsx b/src/common/StatusPlenum.tsx index fe2b00d..250660a 100644 --- a/src/common/StatusPlenum.tsx +++ b/src/common/StatusPlenum.tsx @@ -3,11 +3,13 @@ import { blue, orange } from "@mui/material/colors"; import { makeStyles } from "@mui/styles"; import { Device } from "models"; import PulseBox from "./PulseBox"; +import RelativeTimestamp from "./RelativeTimestamp"; import { or } from "utils"; import { useEffect, useState } from "react"; interface Props { - device: Device + device: Device; + showTimestamp?: boolean; } const useStyles = makeStyles((theme: Theme) => { @@ -25,7 +27,7 @@ const useStyles = makeStyles((theme: Theme) => { }) export default function StatusPlenum(props: Props) { - const { device } = props; + const { device, showTimestamp } = props; const classes = useStyles() const colors = or(device.status.plenum?.overlays.map(overlay => overlay.color), []); @@ -117,6 +119,11 @@ export default function StatusPlenum(props: Props) { {device.status.plenum?.humidity.toFixed(1)}% + {showTimestamp && device.status.plenum?.timestamp && ( + + + + )} diff --git a/src/common/StatusSen5x.tsx b/src/common/StatusSen5x.tsx index fad0ecb..533fde2 100644 --- a/src/common/StatusSen5x.tsx +++ b/src/common/StatusSen5x.tsx @@ -3,12 +3,14 @@ import { blue, green, orange } from "@mui/material/colors"; import { makeStyles } from "@mui/styles"; import { Device } from "models"; import PulseBox from "./PulseBox"; +import RelativeTimestamp from "./RelativeTimestamp"; import { or } from "utils"; import { useEffect, useState } from "react"; interface Props { - device: Device + device: Device; noDust?: boolean; + showTimestamp?: boolean; } const useStyles = makeStyles((theme: Theme) => { @@ -50,7 +52,7 @@ const useStyles = makeStyles((theme: Theme) => { }) export default function StatusSen5x(props: Props) { - const { device, noDust } = props; + const { device, noDust, showTimestamp } = props; const classes = useStyles() // console.log(noDust) const colors = or(device.status.sen5x?.overlays.map(overlay => overlay.color), []); @@ -200,6 +202,11 @@ export default function StatusSen5x(props: Props) { } + {showTimestamp && device.status.sen5x?.timestamp && ( + + + + )} diff --git a/src/hooks/useDeviceStatusStreams.ts b/src/hooks/useDeviceStatusStreams.ts new file mode 100644 index 0000000..24ca8bb --- /dev/null +++ b/src/hooks/useDeviceStatusStreams.ts @@ -0,0 +1,138 @@ +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>(new Map()); + const retriesRef = useRef>(new Map()); + const reconnectTimeoutsRef = useRef>>(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]); +} diff --git a/src/pages/Devices.tsx b/src/pages/Devices.tsx index d24c829..d166134 100644 --- a/src/pages/Devices.tsx +++ b/src/pages/Devices.tsx @@ -190,6 +190,15 @@ export default function Devices() { localStorage.setItem('groupGas', groupGas.toString()); }, [groupGas]); + const [showTimestamp, setShowTimestamp] = useState(() => { + const stored = localStorage.getItem('showTimestamp'); + return stored !== null ? stored === 'true' : false; + }); + + useEffect(() => { + localStorage.setItem('showTimestamp', showTimestamp.toString()); + }, [showTimestamp]); + const [preferencesAnchor, setPreferencesAnchor] = useState(null) const [{ as, team }] = useGlobalState() @@ -646,7 +655,7 @@ export default function Devices() { if (device.status.plenum?.temperature) { return ( - + ) } else { @@ -666,7 +675,7 @@ export default function Devices() { if (device.status.sen5x?.temperature) { return ( - + ) } else { @@ -682,7 +691,7 @@ export default function Devices() { if (device.status.sen5x?.temperature) { return ( - + ) } else { @@ -700,7 +709,7 @@ export default function Devices() { if (device.status.co) { return ( - + ) } else { @@ -718,7 +727,7 @@ export default function Devices() { if (device.status.co2) { return ( - + ) } else { @@ -736,7 +745,7 @@ export default function Devices() { if (device.status.no2) { return ( - + ) } else { @@ -754,7 +763,7 @@ export default function Devices() { if (device.status.o2) { return ( - + ) } else { @@ -894,6 +903,16 @@ export default function Devices() { + setShowTimestamp(!showTimestamp)} + sx={{ marginLeft: -0.75 }} + dense + > + + + + + ) }