From 60f7804d54656a51c3e495cf5df4cdcf8d0268a3 Mon Sep 17 00:00:00 2001 From: lcastel Date: Fri, 19 Jun 2026 10:50:05 +0200 Subject: [PATCH 01/29] fix(electron): added missing file from previous commit for electron integration. --- electron-app/src/windows/mainWindow.js | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/electron-app/src/windows/mainWindow.js b/electron-app/src/windows/mainWindow.js index 0bf125b85..9b88c524f 100644 --- a/electron-app/src/windows/mainWindow.js +++ b/electron-app/src/windows/mainWindow.js @@ -16,7 +16,7 @@ const appPath = getAppPath(); // Store the main window instance let mainWindow = null; // Track the currently loaded view -let currentView = "testing-view"; +let currentView = "competition-view"; /** * Creates and initializes the main application window. @@ -93,10 +93,13 @@ function loadView(view) { // Load the view HTML file mainWindow.loadFile(viewPath); // Update window title based on view type + const titles = { + "competition-view": "Competition View", + "testing-view": "Testing View", + "flashing-view": "Flashing View", + }; mainWindow.setTitle( - `Hyperloop Control Station - ${ - view === "control-station" ? "Competition View" : "Testing View" - }` + `Hyperloop Control Station - ${titles[view] ?? view}`, ); } else { // Log error and show dialog if view not found From caa6a40cccbcfcc85e72832128f739445c93dba2 Mon Sep 17 00:00:00 2001 From: lcastel Date: Wed, 24 Jun 2026 13:59:52 +0200 Subject: [PATCH 02/29] fix(competition-view): fixed bug found in the telemetry update --- .../src/store/slices/telemetrySlice.ts | 16 ++++++------ .../competition-view/src/types/telemetry.ts | 25 ++++++------------- 2 files changed, 16 insertions(+), 25 deletions(-) diff --git a/frontend/competition-view/src/store/slices/telemetrySlice.ts b/frontend/competition-view/src/store/slices/telemetrySlice.ts index 9e2578134..5a336b497 100644 --- a/frontend/competition-view/src/store/slices/telemetrySlice.ts +++ b/frontend/competition-view/src/store/slices/telemetrySlice.ts @@ -4,7 +4,7 @@ import type { Store } from "../store"; export interface TelemetrySlice { telemetry: TelemetryState; - updateTelemetry: (packet: TelemetryData) => void; + updateTelemetry: (packets: TelemetryData) => void; getMeasurement: (id: string) => number | boolean | string | undefined; } @@ -16,14 +16,16 @@ export const createTelemetrySlice: StateCreator< > = (set, get) => ({ telemetry: {}, - updateTelemetry: (packet) => { + updateTelemetry: (packets) => { const flat: TelemetryState = {}; - for (const [key, value] of Object.entries(packet.measurementUpdates)) { - if (typeof value === "object" && value !== null && "last" in value) { - flat[key] = value.last; - } else { - flat[key] = value as number | boolean | string; + for (const packet of Object.values(packets)) { + for (const [key, value] of Object.entries(packet.measurementUpdates)) { + if (typeof value === "object" && value !== null && "last" in value) { + flat[key] = value.last; + } else { + flat[key] = value as number | boolean | string; + } } } diff --git a/frontend/competition-view/src/types/telemetry.ts b/frontend/competition-view/src/types/telemetry.ts index 9cbf175d0..1d17923ec 100644 --- a/frontend/competition-view/src/types/telemetry.ts +++ b/frontend/competition-view/src/types/telemetry.ts @@ -1,24 +1,13 @@ -/** Single variable value as received from the backend. */ -export type VariableValue = - | { last: number; average: number } - | boolean - | string - | number; +export type { TelemetryPacket, VariableValue, Variables } from "@workspace/core"; -/** Map of variable names to their current values inside a packet. */ -export type Variables = Record; - -/** High-frequency telemetry packet from the backend. */ -export interface TelemetryData { - id: number; - count: number; - cycleTime: number; - hexValue: string; - measurementUpdates: Variables; -} +/** + * The backend sends a batch of packets keyed by numeric packet ID. + * Each packet carries its own `measurementUpdates` map. + */ +export type TelemetryData = Record; /** - * Flat map from measurement ID (string) to the latest numeric value. + * Flat map from measurement ID (string) to the latest value. * The store keeps only the most recent value per measurement to avoid * unbounded memory growth. */ From 22acceaac81dc41a25f261b8255982a051e22249 Mon Sep 17 00:00:00 2001 From: lcastel Date: Wed, 24 Jun 2026 17:55:34 +0200 Subject: [PATCH 03/29] feat(competition-view): created unified dashoard instead of separate tabs --- frontend/competition-view/src/App.tsx | 14 +- .../components/header/DashboardStatusBar.tsx | 126 ++++++++ .../src/components/header/Header.tsx | 22 +- .../competition-view/src/constants/pages.ts | 13 +- .../competition-view/src/layout/AppLayout.tsx | 2 +- .../Charts/components/MultiSeriesChart.tsx | 46 ++- .../Charts/components/TelemetryChart.tsx | 36 ++- .../src/pages/Overview/Overview.tsx | 302 ++++++++++++++---- .../Overview/components/BrakeIndicator.tsx | 15 +- .../components/VehicleStateBanner.tsx | 25 +- 10 files changed, 446 insertions(+), 155 deletions(-) create mode 100644 frontend/competition-view/src/components/header/DashboardStatusBar.tsx diff --git a/frontend/competition-view/src/App.tsx b/frontend/competition-view/src/App.tsx index e9a210de3..f82487176 100644 --- a/frontend/competition-view/src/App.tsx +++ b/frontend/competition-view/src/App.tsx @@ -12,10 +12,6 @@ import useKeyboardShortcuts from "./hooks/useKeyboardShortcuts"; import useSendOrder from "./hooks/useSendOrder"; import AppLayout from "./layout/AppLayout"; import Batteries from "./pages/Batteries"; -import Boards from "./pages/Boards"; -import Booster from "./pages/Booster"; -import Charts from "./pages/Charts"; -import Messages from "./pages/Messages"; import Orders from "./pages/Orders"; import Overview from "./pages/Overview"; import { useStore } from "./store/store"; @@ -67,13 +63,9 @@ const App = () => { onShowShortcuts={() => setHelpOpen(true)} > - } /> - } /> - } /> - } /> - } /> - } /> - } /> + } /> + } /> + } /> diff --git a/frontend/competition-view/src/components/header/DashboardStatusBar.tsx b/frontend/competition-view/src/components/header/DashboardStatusBar.tsx new file mode 100644 index 000000000..dddc2a46a --- /dev/null +++ b/frontend/competition-view/src/components/header/DashboardStatusBar.tsx @@ -0,0 +1,126 @@ +import { Button, Separator } from "@workspace/ui/components"; +import { useState } from "react"; +import { VCU } from "../../constants/measurements"; +import { + BRAKE_ORDERS, + EMERGENCY_STOP_ORDERS, + OPEN_CONTACTORS_ORDERS, +} from "../../constants/orders"; +import useMeasurement from "../../hooks/useMeasurement"; +import useSendOrder from "../../hooks/useSendOrder"; + +/* ─── State colour helper ────────────────────────────────────────────────── */ + +const stateColor = (state: string | number | boolean | undefined): string => { + if (state === undefined) return "text-muted-foreground"; + const s = String(state).toUpperCase(); + if (s.includes("EMERGENCY") || s.includes("FAULT") || s.includes("ERROR")) return "text-red-500"; + if (s.includes("RUN") || s.includes("NOMINAL") || s.includes("ACCELERAT")) return "text-green-500"; + if (s.includes("BRAKE") || s.includes("DECELER") || s.includes("WARN")) return "text-amber-500"; + return "text-muted-foreground"; +}; + +/* ─── Emergency stop button (requires two clicks within 2 s) ────────────── */ + +const EStopButton = ({ onConfirm }: { onConfirm: () => void }) => { + const [armed, setArmed] = useState(false); + + const handleClick = () => { + if (armed) { onConfirm(); setArmed(false); return; } + setArmed(true); + setTimeout(() => setArmed(false), 2000); + }; + + return ( + + ); +}; + +/* ─── Compact status label ───────────────────────────────────────────────── */ + +const StatusLabel = ({ + heading, + value, + valueClass = "text-foreground", +}: { + heading: string; + value: string; + valueClass?: string; +}) => ( +
+ + {heading} + + {value} +
+); + +/* ─── DashboardStatusBar ─────────────────────────────────────────────────── */ + +const DashboardStatusBar = () => { + const sendOrder = useSendOrder(); + + const generalState = useMeasurement(VCU.generalState); + const operationalState = useMeasurement(VCU.operationalState); + const brakeRaw = useMeasurement(VCU.allReeds); + + const brakeLabel = brakeRaw === undefined ? "—" : brakeRaw ? "BRAKED" : "UNBRAKED"; + const brakeClass = + brakeRaw === true ? "text-red-500" : + brakeRaw === false ? "text-blue-500" : + "text-muted-foreground"; + + return ( +
+ + + + + + + + + + + + + + + + + sendOrder(EMERGENCY_STOP_ORDERS)} /> +
+ ); +}; + +export default DashboardStatusBar; diff --git a/frontend/competition-view/src/components/header/Header.tsx b/frontend/competition-view/src/components/header/Header.tsx index 74ebf2424..33bd644a5 100644 --- a/frontend/competition-view/src/components/header/Header.tsx +++ b/frontend/competition-view/src/components/header/Header.tsx @@ -3,6 +3,7 @@ import { Keyboard } from "@workspace/ui/icons"; import { useLocation } from "react-router"; import { PAGES } from "../../constants/pages"; import ConnectionBadge from "./ConnectionBadge"; +import DashboardStatusBar from "./DashboardStatusBar"; interface HeaderProps { backendConnected: boolean; @@ -13,25 +14,24 @@ const Header = ({ backendConnected, onShowShortcuts }: HeaderProps) => { const location = useLocation(); const page = PAGES[location.pathname as keyof typeof PAGES]; const pageTitle = page?.title ?? "Competition View"; + const isDashboard = location.pathname === "/"; return ( -
+
- +

{pageTitle}

+ {isDashboard && ( +
+ +
+ )} +
- diff --git a/frontend/competition-view/src/constants/pages.ts b/frontend/competition-view/src/constants/pages.ts index b422cc086..367af2ba6 100644 --- a/frontend/competition-view/src/constants/pages.ts +++ b/frontend/competition-view/src/constants/pages.ts @@ -1,14 +1,9 @@ -import { Activity, Layout, ScrollText, Send, Terminal, Wrench } from "@workspace/ui/icons"; -import { Zap } from "lucide-react"; +import { Layout, Send, Wrench } from "@workspace/ui/icons"; export const PAGES = { - "/": { title: "Overview", icon: Layout }, - "/charts": { title: "Charts", icon: Activity }, - "/batteries": { title: "Batteries", icon: Wrench }, - "/boards": { title: "Boards", icon: Terminal }, - "/booster": { title: "Booster", icon: Zap }, - "/orders": { title: "Orders", icon: Send }, - "/messages": { title: "Messages", icon: ScrollText }, + "/": { title: "Dashboard", icon: Layout }, + "/orders": { title: "Orders", icon: Send }, + "/batteries": { title: "Batteries", icon: Wrench }, } as const; export const PAGES_ARRAY = Object.entries(PAGES).map( diff --git a/frontend/competition-view/src/layout/AppLayout.tsx b/frontend/competition-view/src/layout/AppLayout.tsx index 73ad4321a..0469d821f 100644 --- a/frontend/competition-view/src/layout/AppLayout.tsx +++ b/frontend/competition-view/src/layout/AppLayout.tsx @@ -25,7 +25,7 @@ const AppLayout = ({ children, backendConnected, onShowShortcuts }: AppLayoutPro
-
{children}
+
{children}
diff --git a/frontend/competition-view/src/pages/Charts/components/MultiSeriesChart.tsx b/frontend/competition-view/src/pages/Charts/components/MultiSeriesChart.tsx index 843514af7..fd3a32829 100644 --- a/frontend/competition-view/src/pages/Charts/components/MultiSeriesChart.tsx +++ b/frontend/competition-view/src/pages/Charts/components/MultiSeriesChart.tsx @@ -36,10 +36,10 @@ interface MultiSeriesChartProps { * Double-click resets the zoom. */ const MultiSeriesChart = memo(({ title, series, unit = "" }: MultiSeriesChartProps) => { - const containerRef = useRef(null); + const wrapperRef = useRef(null); // flex-1 div sized by CSS layout + const containerRef = useRef(null); // uPlot mounting point const uplotRef = useRef(null); const xRef = useRef([]); - // One data array per series, initialised lazily on first render. const yRefs = useRef(series.map(() => [])); const counterRef = useRef(0); @@ -54,7 +54,7 @@ const MultiSeriesChart = memo(({ title, series, unit = "" }: MultiSeriesChartPro // ── Initialise uPlot ──────────────────────────────────────────────────── useEffect(() => { - if (!containerRef.current) return; + if (!wrapperRef.current || !containerRef.current) return; const getVar = (name: string) => getComputedStyle(document.documentElement).getPropertyValue(name).trim(); @@ -73,8 +73,8 @@ const MultiSeriesChart = memo(({ title, series, unit = "" }: MultiSeriesChartPro ]; const opts: uPlot.Options = { - width: containerRef.current.clientWidth, - height: CHART_HEIGHT, + width: wrapperRef.current.clientWidth || 300, + height: wrapperRef.current.clientHeight || CHART_HEIGHT, legend: { show: false }, padding: [16, 8, 4, 12], scales: { @@ -116,12 +116,12 @@ const MultiSeriesChart = memo(({ title, series, unit = "" }: MultiSeriesChartPro min: null as unknown as number, max: null as unknown as number, }); - containerRef.current.addEventListener("dblclick", handleDblClick); + wrapperRef.current.addEventListener("dblclick", handleDblClick); return () => { uplotRef.current?.destroy(); uplotRef.current = null; - containerRef.current?.removeEventListener("dblclick", handleDblClick); + wrapperRef.current?.removeEventListener("dblclick", handleDblClick); }; // Intentionally runs once on mount — series config is stable. // eslint-disable-next-line react-hooks/exhaustive-deps @@ -147,45 +147,41 @@ const MultiSeriesChart = memo(({ title, series, unit = "" }: MultiSeriesChartPro uplotRef.current.setData([xRef.current, ...yRefs.current]); }, [values]); - // ── Resize to container ────────────────────────────────────────────────── + // ── Resize to wrapper ─────────────────────────────────────────────────── useEffect(() => { - if (!containerRef.current) return; + if (!wrapperRef.current) return; const observer = new ResizeObserver((entries) => { for (const entry of entries) { - uplotRef.current?.setSize({ - width: entry.contentRect.width, - height: CHART_HEIGHT, - }); + const { width, height } = entry.contentRect; + if (width > 0 && height > 0) { + uplotRef.current?.setSize({ width, height }); + } } }); - observer.observe(containerRef.current); + observer.observe(wrapperRef.current); return () => observer.disconnect(); }, []); return ( -
-
- {/* Title + inline phase legend */} +
+
{title} {series.map(({ label, colorIndex }, i) => { const color = CHART_COLORS[(colorIndex ?? i) % CHART_COLORS.length]; return ( - + {label} ); })}
- {unit && ( - {unit} - )} + {unit && {unit}} +
+
+
-
); }); diff --git a/frontend/competition-view/src/pages/Charts/components/TelemetryChart.tsx b/frontend/competition-view/src/pages/Charts/components/TelemetryChart.tsx index 5f6cadc9c..22b4fcdf8 100644 --- a/frontend/competition-view/src/pages/Charts/components/TelemetryChart.tsx +++ b/frontend/competition-view/src/pages/Charts/components/TelemetryChart.tsx @@ -34,7 +34,8 @@ const TelemetryChart = memo(({ unit = "", colorIndex = 0, }: TelemetryChartProps) => { - const containerRef = useRef(null); + const wrapperRef = useRef(null); // flex-1 div sized by CSS layout + const containerRef = useRef(null); // uPlot mounting point const uplotRef = useRef(null); const xRef = useRef([]); const yRef = useRef([]); @@ -45,14 +46,14 @@ const TelemetryChart = memo(({ // ── Initialise uplot ──────────────────────────────────────────────────── useEffect(() => { - if (!containerRef.current) return; + if (!wrapperRef.current || !containerRef.current) return; const getVar = (name: string) => getComputedStyle(document.documentElement).getPropertyValue(name).trim(); const opts: uPlot.Options = { - width: containerRef.current.clientWidth, - height: CHART_HEIGHT, + width: wrapperRef.current.clientWidth || 300, + height: wrapperRef.current.clientHeight || CHART_HEIGHT, legend: { show: false }, padding: [16, 8, 4, 12], scales: { @@ -96,14 +97,13 @@ const TelemetryChart = memo(({ uplotRef.current = new uPlot(opts, [[], []], containerRef.current); - // Pass null to reset zoom; cast needed since uplot's TS types omit null here. const handleDblClick = () => uplotRef.current?.setScale("x", { min: null as unknown as number, max: null as unknown as number }); - containerRef.current.addEventListener("dblclick", handleDblClick); + wrapperRef.current.addEventListener("dblclick", handleDblClick); return () => { uplotRef.current?.destroy(); uplotRef.current = null; - containerRef.current?.removeEventListener("dblclick", handleDblClick); + wrapperRef.current?.removeEventListener("dblclick", handleDblClick); }; // Intentionally runs once on mount — series config is stable. // eslint-disable-next-line react-hooks/exhaustive-deps @@ -124,32 +124,34 @@ const TelemetryChart = memo(({ uplotRef.current.setData([xRef.current, yRef.current]); }, [value]); - // ── Resize to container ───────────────────────────────────────────────── + // ── Resize to wrapper ─────────────────────────────────────────────────── useEffect(() => { - if (!containerRef.current) return; + if (!wrapperRef.current) return; const observer = new ResizeObserver((entries) => { for (const entry of entries) { - uplotRef.current?.setSize({ - width: entry.contentRect.width, - height: CHART_HEIGHT, - }); + const { width, height } = entry.contentRect; + if (width > 0 && height > 0) { + uplotRef.current?.setSize({ width, height }); + } } }); - observer.observe(containerRef.current); + observer.observe(wrapperRef.current); return () => observer.disconnect(); }, []); return ( -
-
+
+
{title} {unit && ( {unit} )}
-
+
+
+
); }); diff --git a/frontend/competition-view/src/pages/Overview/Overview.tsx b/frontend/competition-view/src/pages/Overview/Overview.tsx index bf986aa8c..ba769bf61 100644 --- a/frontend/competition-view/src/pages/Overview/Overview.tsx +++ b/frontend/competition-view/src/pages/Overview/Overview.tsx @@ -1,23 +1,136 @@ -import { HVBMS, PCU, VCU } from "../../constants/measurements"; +import { Button, Separator } from "@workspace/ui/components"; +import { ChevronUp } from "@workspace/ui/icons"; +import { useEffect, useRef, useState } from "react"; +import { + BCU, + BLCU, + HVBMS, + HVBMS_CABINET, + LCU, + LVBMS, + PCU, + PCU_BOARD, + VCU, +} from "../../constants/measurements"; import useMeasurement from "../../hooks/useMeasurement"; -import BrakeIndicator from "./components/BrakeIndicator"; -import MetricCard from "./components/MetricCard"; -import OrdersPanel from "./components/OrdersPanel"; -import RecentMessages from "./components/RecentMessages"; -import VehicleStateBanner from "./components/VehicleStateBanner"; - -/** - * Main competition dashboard. - * - * Layout: - * - Vehicle state banner (full width, colour-coded) - * - Row 1: Brake indicator + Speed + Position + SOC - * - Row 2: HV Voltage + HV Current + Pack Temp Max + Brake Pressure - * - Row 3: High Pressure + Capsule Pressure + Acceleration - * - Quick orders panel - * - Recent messages - */ -const Overview = () => { +import { useStore } from "../../store/store"; +import type { MessageKind } from "../../types/message"; +import BoardCard from "../Boards/components/BoardCard"; +import MultiSeriesChart, { type SeriesConfig } from "../Charts/components/MultiSeriesChart"; +import TelemetryChart from "../Charts/components/TelemetryChart"; +import MessageItem from "../Messages/components/MessageItem"; + +/* ─── Stable series configs ─────────────────────────────────────────────── */ + +const DLIM_SERIES: SeriesConfig[] = [ + { measurementKey: PCU.motorCurrentU, label: "U", colorIndex: 0 }, + { measurementKey: PCU.motorCurrentV, label: "V", colorIndex: 1 }, + { measurementKey: PCU.motorCurrentW, label: "W", colorIndex: 2 }, +]; + +const LSM_SERIES: SeriesConfig[] = [ + { measurementKey: BCU.averageCurrentU, label: "U", colorIndex: 0 }, + { measurementKey: BCU.averageCurrentV, label: "V", colorIndex: 1 }, + { measurementKey: BCU.averageCurrentW, label: "W", colorIndex: 2 }, +]; + +/* ─── Compact metric tile ───────────────────────────────────────────────── */ + +interface MetricTileProps { + label: string; + value: string | undefined; + unit?: string; + valueClassName?: string; +} + +const MetricTile = ({ label, value, unit, valueClassName = "" }: MetricTileProps) => ( +
+ + {label} + + + {value ?? "—"} + {value !== undefined && unit && ( + {unit} + )} + +
+); + +/* ─── Messages panel ────────────────────────────────────────────────────── */ + +const ALL_KINDS: MessageKind[] = ["info", "warning", "error", "debug"]; +const KIND_LABEL: Record = { info: "Info", warning: "Warn", error: "Err", debug: "Dbg" }; + +const MessagesPanel = () => { + const messages = useStore((s) => s.messages); + const clearMessages = useStore((s) => s.clearMessages); + + const [activeKinds, setActiveKinds] = useState>(new Set(ALL_KINDS)); + const [scrolledAway, setScrolledAway] = useState(false); + const scrollRef = useRef(null); + const prevLenRef = useRef(0); + + const filtered = messages.filter((m) => activeKinds.has(m.kind)); + + const toggleKind = (kind: MessageKind) => + setActiveKinds((prev) => { + const next = new Set(prev); + next.has(kind) ? next.delete(kind) : next.add(kind); + return next; + }); + + useEffect(() => { + if (filtered.length > prevLenRef.current && !scrolledAway) { + scrollRef.current?.scrollTo({ top: 0, behavior: "smooth" }); + } + prevLenRef.current = filtered.length; + }, [filtered.length, scrolledAway]); + + return ( +
+
+ {ALL_KINDS.map((kind) => ( + + ))} +
+ {filtered.length}/{messages.length} + + +
+
+ +
setScrolledAway(e.currentTarget.scrollTop > 60)} + className="relative min-h-0 flex-1 overflow-y-auto" + > + {filtered.length === 0 ? ( +

No messages

+ ) : ( + filtered.map((msg) => ) + )} + {scrolledAway && ( + + )} +
+
+ ); +}; + +/* ─── Dashboard ─────────────────────────────────────────────────────────── */ + +const Dashboard = () => { const speed = useMeasurement(PCU.speed); const position = useMeasurement(PCU.position); const acceleration = useMeasurement(PCU.acceleration); @@ -27,61 +140,122 @@ const Overview = () => { const tempMax = useMeasurement(HVBMS.tempMax); const brakePsi = useMeasurement(VCU.pressureBrakes); const highPsi = useMeasurement(VCU.highPressure); - const capsulePsi = useMeasurement(VCU.pressureCapsule); - const fmt = (v: ReturnType, decimals = 1): string | undefined => { - if (v === undefined) return undefined; - if (typeof v === "number") return v.toFixed(decimals); - return String(v); - }; + const fmt = (v: ReturnType, d = 1) => + v === undefined ? undefined : typeof v === "number" ? v.toFixed(d) : String(v); return ( -
- {/* Vehicle state banner */} - - - {/* Row 1 — Brake indicator + kinematic metrics */} -
-
- +
+ + {/* ── Left column: metrics (top) + charts (bottom) ─────────────── */} +
+ + {/* Metric tiles — 3×3 grid, one row per group */} +
+ {/* Kinematic */} + + + + {/* Electrical */} + + + + {/* Pressure */} + 55 ? "text-red-500" : ""} /> + +
- - - -
- {/* Row 2 — Electrical & thermal */} -
- - - 55 ? "text-red-500" : ""} - /> - -
+ {/* Charts — 2 cols × 3 rows, each fills its grid cell */} +
+ + + + + + +
- {/* Row 3 — Pneumatics + acceleration */} -
- - -
- {/* Quick orders */} - + {/* ── Right column: boards (top) + messages (bottom) ───────────── */} +
+ + {/* Board state cards — 2-col grid */} +
+ + + + + + + + +
+ + {/* Messages — fills the remaining height */} + + +
- {/* Recent messages */} -
); }; -export default Overview; +export default Dashboard; diff --git a/frontend/competition-view/src/pages/Overview/components/BrakeIndicator.tsx b/frontend/competition-view/src/pages/Overview/components/BrakeIndicator.tsx index 5b37972b7..8f5e513fe 100644 --- a/frontend/competition-view/src/pages/Overview/components/BrakeIndicator.tsx +++ b/frontend/competition-view/src/pages/Overview/components/BrakeIndicator.tsx @@ -9,11 +9,16 @@ const STATUS_STYLES: Record { +const BrakeIndicator = ({ compact = false }: BrakeIndicatorProps) => { const raw = useMeasurement(VCU.allReeds); const status: BrakeStatus = @@ -23,12 +28,12 @@ const BrakeIndicator = () => { return (
- + Brake State - + {label}
diff --git a/frontend/competition-view/src/pages/Overview/components/VehicleStateBanner.tsx b/frontend/competition-view/src/pages/Overview/components/VehicleStateBanner.tsx index 2eb42ace9..91e4e1169 100644 --- a/frontend/competition-view/src/pages/Overview/components/VehicleStateBanner.tsx +++ b/frontend/competition-view/src/pages/Overview/components/VehicleStateBanner.tsx @@ -45,15 +45,16 @@ const categorise = (state: string | number | boolean | undefined): StateCategory return "unknown"; }; +interface VehicleStateBannerProps { + /** Reduces padding and font sizes to fit the compact dashboard top row. */ + compact?: boolean; +} + /** - * Full-width banner showing the vehicle's general and operational states. - * Background and text colour change based on the detected state category: - * - Emergency / Fault / Error → red - * - Running / Nominal → green - * - Braking / Decelerating → amber - * - Idle / Ready / Standby → muted + * Banner showing the vehicle's general and operational states. + * Background and text colour change based on the detected state category. */ -const VehicleStateBanner = () => { +const VehicleStateBanner = ({ compact = false }: VehicleStateBannerProps) => { const generalState = useMeasurement(VCU.generalState); const operationalState = useMeasurement(VCU.operationalState); @@ -61,23 +62,23 @@ const VehicleStateBanner = () => { const { banner, valueText, badgeClass } = STATE_STYLES[category]; return ( -
-
+
+
Vehicle State - + {generalState !== undefined ? String(generalState) : "—"}
-
+
Operational State {operationalState !== undefined ? String(operationalState) : "—"} From 180186bbc41c438dca75bf9ffa1f0469759e8852 Mon Sep 17 00:00:00 2001 From: lcastel Date: Thu, 25 Jun 2026 11:11:30 +0200 Subject: [PATCH 04/29] feat(competition-view): implemented cell visualization in battery management tab --- .../Batteries/components/BatteryPackCard.tsx | 119 ++++++----- .../Batteries/components/HvBatterySection.tsx | 11 +- .../Batteries/components/LvBatterySection.tsx | 187 +++++++++--------- 3 files changed, 174 insertions(+), 143 deletions(-) diff --git a/frontend/competition-view/src/pages/Batteries/components/BatteryPackCard.tsx b/frontend/competition-view/src/pages/Batteries/components/BatteryPackCard.tsx index 8137fe196..be3dd4e6c 100644 --- a/frontend/competition-view/src/pages/Batteries/components/BatteryPackCard.tsx +++ b/frontend/competition-view/src/pages/Batteries/components/BatteryPackCard.tsx @@ -1,39 +1,67 @@ -import { - Card, - CardContent, - CardHeader, - CardTitle, -} from "@workspace/ui/components"; +import { Card, CardContent, CardHeader, CardTitle } from "@workspace/ui/components"; import { hvbmsPack } from "../../../constants/measurements"; import useMeasurement from "../../../hooks/useMeasurement"; interface BatteryPackCardProps { - /** Pack number, 1-based (1–18). */ packNumber: number; } +const CELL_MIN = 3.0; +const CELL_MAX = 4.2; +const CELL_WARN_LOW = 3.1; +const CELL_WARN_HIGH = 4.15; + const fmt = (v: number | boolean | string | undefined, decimals = 1) => typeof v === "number" ? v.toFixed(decimals) : "—"; +/* ─── Individual cell tile ───────────────────────────────────────────────── */ + +const CellTile = ({ cellNum, measurementKey }: { cellNum: number; measurementKey: string }) => { + const raw = useMeasurement(measurementKey); + const v = typeof raw === "number" ? raw : null; + const isLow = v !== null && v < CELL_WARN_LOW; + const isHigh = v !== null && v > CELL_WARN_HIGH; + const fill = v !== null + ? Math.min(100, Math.max(0, ((v - CELL_MIN) / (CELL_MAX - CELL_MIN)) * 100)) + : 0; + + return ( +
+ {cellNum} +
+
+
+ + {v !== null ? v.toFixed(2) : "—"} + V + +
+ ); +}; + +/* ─── Pack card ──────────────────────────────────────────────────────────── */ + const BatteryPackCard = ({ packNumber }: BatteryPackCardProps) => { const keys = hvbmsPack(packNumber); const soc = useMeasurement(keys.soc); const voltage = useMeasurement(keys.voltage); const temp = useMeasurement(keys.temperature); - const cell1 = useMeasurement(keys.cell1); - const cell2 = useMeasurement(keys.cell2); - const cell3 = useMeasurement(keys.cell3); - const cell4 = useMeasurement(keys.cell4); - const cell5 = useMeasurement(keys.cell5); - const cell6 = useMeasurement(keys.cell6); - - const cells = [cell1, cell2, cell3, cell4, cell5, cell6]; - const cellNums = cells.filter((c): c is number => typeof c === "number"); - const cellMax = cellNums.length ? Math.max(...cellNums) : undefined; - const cellMin = cellNums.length ? Math.min(...cellNums) : undefined; - const socNum = typeof soc === "number" ? soc : null; + const socNum = typeof soc === "number" ? soc : null; const socColor = socNum === null ? "bg-muted" : socNum < 15 ? "bg-red-500" : @@ -43,46 +71,41 @@ const BatteryPackCard = ({ packNumber }: BatteryPackCardProps) => { return ( - - Pack {packNumber} - +
+ Group {packNumber} +
+
+
+
+ + {fmt(soc, 0)}% + +
+
- {/* SOC bar */} -
-
-
-
- - {fmt(soc, 0)}% - + {/* Summary stats */} +
+ +
- {/* Key values */} -
- - - - + {/* 12-cell grid: 6 cols × 2 rows */} +
+ {keys.cells.map((key, i) => ( + + ))}
); }; -const Stat = ({ - label, - value, - unit, -}: { - label: string; - value: string; - unit: string; -}) => ( +const Stat = ({ label, value, unit }: { label: string; value: string; unit: string }) => (
{label} diff --git a/frontend/competition-view/src/pages/Batteries/components/HvBatterySection.tsx b/frontend/competition-view/src/pages/Batteries/components/HvBatterySection.tsx index 1909aabf4..1768de4f6 100644 --- a/frontend/competition-view/src/pages/Batteries/components/HvBatterySection.tsx +++ b/frontend/competition-view/src/pages/Batteries/components/HvBatterySection.tsx @@ -3,7 +3,7 @@ import { HVBMS, HVBMS_CABINET } from "../../../constants/measurements"; import useMeasurement from "../../../hooks/useMeasurement"; import BatteryPackCard from "./BatteryPackCard"; -const PACK_COUNT = 18; +const PACK_COUNT = 8; const PACK_NUMBERS = Array.from({ length: PACK_COUNT }, (_, i) => i + 1); const fmt = (v: number | boolean | string | undefined, decimals = 1) => @@ -22,9 +22,8 @@ const HvBatterySection = () => {
{/* Section header */}
-

- High Voltage -

+

High Voltage

+ 8 groups · 12 cells each {contactors !== undefined && ( { - {/* Pack grid */} -
+ {/* Group grid: 2 rows of 4 */} +
{PACK_NUMBERS.map((n) => ( ))} diff --git a/frontend/competition-view/src/pages/Batteries/components/LvBatterySection.tsx b/frontend/competition-view/src/pages/Batteries/components/LvBatterySection.tsx index f235dd70b..82f799a58 100644 --- a/frontend/competition-view/src/pages/Batteries/components/LvBatterySection.tsx +++ b/frontend/competition-view/src/pages/Batteries/components/LvBatterySection.tsx @@ -1,13 +1,55 @@ -import { Separator } from "@workspace/ui/components"; +import { Card, CardContent, CardHeader, CardTitle } from "@workspace/ui/components"; import { LVBMS } from "../../../constants/measurements"; import useMeasurement from "../../../hooks/useMeasurement"; +const CELL_MIN = 3.0; +const CELL_MAX = 4.2; +const CELL_WARN_LOW = 3.1; +const CELL_WARN_HIGH = 4.15; + const fmt = (v: number | boolean | string | undefined, decimals = 2) => typeof v === "number" ? v.toFixed(decimals) : "—"; -/** - * Low-voltage battery section: BMSL summary stats + individual cell voltages. - */ +/* ─── Cell tile (same style as HV) ──────────────────────────────────────── */ + +const CellTile = ({ cellNum, measurementKey }: { cellNum: number; measurementKey: string }) => { + const raw = useMeasurement(measurementKey); + const v = typeof raw === "number" ? raw : null; + const isLow = v !== null && v < CELL_WARN_LOW; + const isHigh = v !== null && v > CELL_WARN_HIGH; + const fill = v !== null + ? Math.min(100, Math.max(0, ((v - CELL_MIN) / (CELL_MAX - CELL_MIN)) * 100)) + : 0; + + return ( +
+ Cell {cellNum} +
+
+
+ + {v !== null ? v.toFixed(3) : "—"} + V + +
+ ); +}; + +/* ─── LV section ─────────────────────────────────────────────────────────── */ + const LvBatterySection = () => { const soc = useMeasurement(LVBMS.soc); const totalVoltage = useMeasurement(LVBMS.totalVoltage); @@ -18,13 +60,18 @@ const LvBatterySection = () => { const current = useMeasurement(LVBMS.current); const state = useMeasurement(LVBMS.generalState); + const socNum = typeof soc === "number" ? soc : null; + const socColor = + socNum === null ? "bg-muted" : + socNum < 20 ? "bg-red-500" : + socNum < 40 ? "bg-amber-500" : + "bg-green-500"; + return (
- {/* Section header */}
-

- Low Voltage (LVBMS) -

+

Low Voltage

+ 1 group · 6 cells {state !== undefined && ( {String(state)} @@ -32,92 +79,54 @@ const LvBatterySection = () => { )}
- {/* Summary stats */} -
- {[ - { label: "SOC", value: fmt(soc, 0), unit: "%" }, - { label: "Total V", value: fmt(totalVoltage), unit: "V" }, - { label: "Current", value: fmt(current), unit: "A" }, - { label: "V max", value: fmt(voltageMax), unit: "V" }, - { label: "V min", value: fmt(voltageMin), unit: "V" }, - { label: "T max", value: fmt(tempMax, 1), unit: "°C" }, - { label: "T min", value: fmt(tempMin, 1), unit: "°C" }, - ].map(({ label, value, unit }) => ( -
- {label} - - {value} - - {unit} + + +
+ Group 1 +
+
+
+
+ + {fmt(soc, 0)}% - +
+
+ + + + {/* Summary stats */} +
+ {[ + { label: "Total V", value: fmt(totalVoltage), unit: "V" }, + { label: "Current", value: fmt(current), unit: "A" }, + { label: "V max", value: fmt(voltageMax), unit: "V" }, + { label: "V min", value: fmt(voltageMin), unit: "V" }, + { label: "T max", value: fmt(tempMax, 1), unit: "°C" }, + { label: "T min", value: fmt(tempMin, 1), unit: "°C" }, + ].map(({ label, value, unit }) => ( +
+ {label} + + {value} {unit} + +
+ ))}
- ))} -
- - - {/* Individual cell voltages */} -
- {LVBMS.cells.map((key, i) => ( - - ))} -
+ {/* 6-cell grid */} +
+ {LVBMS.cells.map((key, i) => ( + + ))} +
+ +
); }; -/* ─── Sub-component ───────────────────────────────────────────────────── */ - -interface CellCardProps { - cellNumber: number; - measurementKey: string; -} - -const CELL_MIN = 3.0; -const CELL_MAX = 4.2; - -const CellCard = ({ cellNumber, measurementKey }: CellCardProps) => { - const voltage = useMeasurement(measurementKey); - const v = typeof voltage === "number" ? voltage : null; - - const isLow = v !== null && v < CELL_MIN + 0.1; - const isHigh = v !== null && v > CELL_MAX - 0.05; - - // Fill percentage within the useful range - const fill = - v !== null - ? Math.min(100, Math.max(0, ((v - CELL_MIN) / (CELL_MAX - CELL_MIN)) * 100)) - : 0; - - return ( -
- Cell {cellNumber} - - {/* Mini bar */} -
-
-
- - - {v !== null ? v.toFixed(3) : "—"} - V - -
- ); -}; - export default LvBatterySection; From 8c44775c8b0de7f18178e5de2810a11a97e53b0c Mon Sep 17 00:00:00 2001 From: lcastel Date: Wed, 8 Jul 2026 15:57:13 +0200 Subject: [PATCH 05/29] feat(competition-view): new design, new values, correct handling of measurement ids based on latest adj configurations (astra-wip4) --- frontend/competition-view/src/App.tsx | 4 + .../components/header/DashboardStatusBar.tsx | 8 +- .../src/constants/measurements.ts | 185 +++++----- .../src/hooks/useMeasurement.ts | 9 +- .../src/hooks/usePodCatalog.ts | 44 +++ .../Batteries/components/BatteryPackCard.tsx | 33 +- .../Batteries/components/HvBatterySection.tsx | 20 +- .../Batteries/components/LvBatterySection.tsx | 24 +- .../src/pages/Boards/Boards.tsx | 61 ++-- .../src/pages/Boards/components/BoardCard.tsx | 12 +- .../pages/Boards/components/LcuAirgapCard.tsx | 75 ++-- .../src/pages/Booster/Booster.tsx | 139 +++++--- .../src/pages/Charts/Charts.tsx | 104 +++--- .../Charts/components/MultiSeriesChart.tsx | 5 +- .../Charts/components/TelemetryChart.tsx | 7 +- .../src/pages/Overview/Overview.tsx | 328 ++++++++++++------ .../Overview/components/BrakeIndicator.tsx | 6 +- .../components/VehicleStateBanner.tsx | 6 +- .../src/store/slices/telemetrySlice.ts | 32 +- .../competition-view/src/types/telemetry.ts | 8 +- 20 files changed, 661 insertions(+), 449 deletions(-) create mode 100644 frontend/competition-view/src/hooks/usePodCatalog.ts diff --git a/frontend/competition-view/src/App.tsx b/frontend/competition-view/src/App.tsx index f82487176..f1b02f80d 100644 --- a/frontend/competition-view/src/App.tsx +++ b/frontend/competition-view/src/App.tsx @@ -9,6 +9,7 @@ import { OPEN_CONTACTORS_ORDERS, } from "./constants/orders"; import useKeyboardShortcuts from "./hooks/useKeyboardShortcuts"; +import usePodCatalog from "./hooks/usePodCatalog"; import useSendOrder from "./hooks/useSendOrder"; import AppLayout from "./layout/AppLayout"; import Batteries from "./pages/Batteries"; @@ -28,6 +29,9 @@ const App = () => { const sendOrder = useSendOrder(); + // Fetch pod catalog to build packetId → boardName map for scoped telemetry + usePodCatalog(); + // Keyboard shortcuts help dialog const [helpOpen, setHelpOpen] = useState(false); diff --git a/frontend/competition-view/src/components/header/DashboardStatusBar.tsx b/frontend/competition-view/src/components/header/DashboardStatusBar.tsx index dddc2a46a..16c831147 100644 --- a/frontend/competition-view/src/components/header/DashboardStatusBar.tsx +++ b/frontend/competition-view/src/components/header/DashboardStatusBar.tsx @@ -1,6 +1,6 @@ import { Button, Separator } from "@workspace/ui/components"; import { useState } from "react"; -import { VCU } from "../../constants/measurements"; +import { BOARDS, VCU } from "../../constants/measurements"; import { BRAKE_ORDERS, EMERGENCY_STOP_ORDERS, @@ -70,9 +70,9 @@ const StatusLabel = ({ const DashboardStatusBar = () => { const sendOrder = useSendOrder(); - const generalState = useMeasurement(VCU.generalState); - const operationalState = useMeasurement(VCU.operationalState); - const brakeRaw = useMeasurement(VCU.allReeds); + const generalState = useMeasurement(BOARDS.VCU, VCU.generalState); + const operationalState = useMeasurement(BOARDS.VCU, VCU.operationalState); + const brakeRaw = useMeasurement(BOARDS.VCU, VCU.activeBrakes); const brakeLabel = brakeRaw === undefined ? "—" : brakeRaw ? "BRAKED" : "UNBRAKED"; const brakeClass = diff --git a/frontend/competition-view/src/constants/measurements.ts b/frontend/competition-view/src/constants/measurements.ts index 5e63acbe5..e6bedc080 100644 --- a/frontend/competition-view/src/constants/measurements.ts +++ b/frontend/competition-view/src/constants/measurements.ts @@ -1,116 +1,125 @@ /** - * Backend telemetry measurement IDs. - * Centralised here so every component imports a named constant - * rather than an inline magic string. + * Backend telemetry measurement IDs and board names sourced from the ADJ + * repository (branch: astra-wip4). + * + * IDs are the raw values sent by the backend inside `measurementUpdates`. + * The BOARDS map provides the board name needed for the two-level lookup + * `telemetry[board][measurementId]` that prevents cross-board collisions. */ + +/** Board names as reported by the backend (from the ADJ). */ +export const BOARDS = { + VCU: "VCU", + PCU: "PCU", + LCU: "LCU", + HVBMS: "HVBMS", + LVBMS: "LVBMS", + BLCU: "BLCU", +} as const; + export const VCU = { - generalState: "VCU/general_state", - operationalState: "VCU/operational_state", - allReeds: "VCU/all_reeds", - highPressure: "VCU/pressure_high", - pressureBrakes: "VCU/pressure_brakes", - pressureCapsule: "VCU/pressure_capsule", + generalState: "general_state", + operationalState: "operational_state", + highPressure: "high_pressure", + lowPressure: "low_pressure", + sdcClosed: "sdc_closed", + contactorsClosed: "contactors_closed", + activeBrakes: "active_brakes", + brakeFault: "brake_fault_detected", + // Sub-system states as orchestrated by the VCU + hvbmsState: "hvbms_state", + pcuState: "pcu_state", + lcuVerticalState: "lcu_vertical_state", + lcuHorizontalState: "lcu_horizontal_state", } as const; export const PCU = { - speed: "PCU/encoder_speed_km_h", - position: "PCU/encoder_position", - acceleration: "PCU/encoder_acceleration", - // DLIM phase currents (motor A) - motorCurrentU: "PCU/current_sensor_u_a", - motorCurrentV: "PCU/current_sensor_v_a", - motorCurrentW: "PCU/current_sensor_w_a", + speed: "encoder_speed_km_h", + position: "encoder_position", + acceleration: "encoder_acceleration", + motorCurrentU: "current_sensor_u_a", + motorCurrentV: "current_sensor_v_a", + motorCurrentW: "current_sensor_w_a", } as const; export const PCU_BOARD = { - generalState: "PCU/general_state_machine", - operatingState: "PCU/operational_state_machine", - peakCurrent: "PCU/current_Peak", - frequency: "PCU/frequency", -} as const; - -/** BCU (Booster Control Unit) — LSM phase currents and board states. */ -export const BCU = { - averageCurrentU: "BCU/average_current_u", - averageCurrentV: "BCU/average_current_v", - averageCurrentW: "BCU/average_current_w", - generalState: "BCU/bcu_general_state", - operationalState: "BCU/bcu_operational_state", - nestedState: "BCU/bcu_nested_state", + generalState: "general_state_machine", + operatingState: "operational_state_machine", + peakCurrent: "current_Peak", + frequency: "frequency", } as const; /** HVBMS — high-voltage battery management system. */ export const HVBMS = { - minimumSoc: "HVBMS/minimum_soc", - voltageReading: "HVBMS/voltage_reading", - batteriesVoltage: "HVBMS/batteries_voltage_reading", - currentReading: "HVBMS/current_reading", - tempMax: "HVBMS/temp_max", - tempMin: "HVBMS/temp_min", - voltageMax: "HVBMS/voltage_max", - voltageMin: "HVBMS/voltage_min", - imdOk: "HVBMS/imd_is_ok", - sdcStatus: "HVBMS/sdc_status", - operationalState: "HVBMS/operational_state_machine_status", -} as const; - -/** HVBMS-Cabinet — supercapacitor bank and HV bus. */ -export const HVBMS_CABINET = { - contactorsState: "HVBMS-Cabinet/HVBMS-Cabinet_contactors_state", - busVoltage: "HVBMS-Cabinet/HVBMS-Cabinet_bus_voltage", - outputCurrent: "HVBMS-Cabinet/HVBMS-Cabinet_output_current", - sdcGood: "HVBMS-Cabinet/HVBMS-Cabinet_sdc_good", - totalSupercapsVoltage: "HVBMS-Cabinet/HVBMS-Cabinet_total_supercaps_voltage", + minimumSoc: "minimum_soc", + soc: "soc", + voltageReading: "voltage_reading", + batteriesVoltage: "batteries_voltage_reading", + currentReading: "current_reading", + tempMax: "temp_max", + tempMin: "temp_min", + voltageMax: "voltage_max", + voltageMin: "voltage_min", + imdOk: "imd_is_ok", + imdStatus: "imd_status", + imdResistance: "imd_resistance", + sdcStatus: "sdc_status", + operationalState: "gsm_status", + contactorPrecharge: "contactor_precharge", + contactorDischarge: "contactor_discharge", + contactorHigh: "contactor_high", + contactorLow: "contactor_low", + contactorCommonHigh: "contactor_common_high", } as const; -/** Per-pack indices are 1-based (1–18). */ +/** Per-group indices are 1-based (1–8). Each group has 12 cells. */ export const hvbmsPack = (n: number) => ({ - soc: `HVBMS/battery${n}_SOC`, - temperature: `HVBMS/battery${n}_temperature1`, - voltage: `HVBMS/battery${n}_total_voltage`, - cell1: `HVBMS/battery${n}_cell1`, - cell2: `HVBMS/battery${n}_cell2`, - cell3: `HVBMS/battery${n}_cell3`, - cell4: `HVBMS/battery${n}_cell4`, - cell5: `HVBMS/battery${n}_cell5`, - cell6: `HVBMS/battery${n}_cell6`, + voltage: `battery${n}_total_voltage`, + tempMax: `battery${n}_max_temp`, + tempMin: `battery${n}_min_temp`, + cells: Array.from({ length: 12 }, (_, i) => `battery${n}_cell${i + 1}`), }); /** LVBMS — low-voltage battery management system. */ export const LVBMS = { - cells: ["LVBMS/cell_1","LVBMS/cell_2","LVBMS/cell_3","LVBMS/cell_4","LVBMS/cell_5","LVBMS/cell_6"] as string[], - soc: "LVBMS/SOC", - totalVoltage: "LVBMS/total_voltage", - voltageMin: "LVBMS/voltage_min", - voltageMax: "LVBMS/voltage_max", - tempMin: "LVBMS/temp_min", - tempMax: "LVBMS/temp_max", - current: "LVBMS/current", - generalState: "LVBMS/state", + cells: ["cell_1", "cell_2", "cell_3", "cell_4", "cell_5", "cell_6"] as string[], + soc: "SOC", + totalVoltage: "total_voltage", + voltageMin: "voltage_min", + voltageMax: "voltage_max", + temperature: "temperature_1", + current: "current", + generalState: "State", } as const; /** BLCU — bootloader control unit (firmware flashing). */ export const BLCU = { - state: "BLCU/state", + state: "general_state_machine", + operationalState: "operational_state_machine", } as const; +/** LCU — levitation control unit. */ export const LCU = { - // Airgaps — vertical (V1–V4) and horizontal (H1–H4) - verticalAirgap1: "LCU/lcu_airgap_1", - verticalAirgap2: "LCU/lcu_airgap_2", - verticalAirgap3: "LCU/lcu_airgap_3", - verticalAirgap4: "LCU/lcu_airgap_4", - horizontalAirgap1: "LCU/lcu_airgap_5", - horizontalAirgap2: "LCU/lcu_airgap_6", - horizontalAirgap3: "LCU/lcu_airgap_7", - horizontalAirgap4: "LCU/lcu_airgap_8", - // Position control outputs - positionY: "LCU/dist_control_y", - positionZ: "LCU/dist_control_z", - // Rotation control outputs - rotationPitch: "LCU/rot_control_y", - rotationRoll: "LCU/rot_control_x", - rotationYaw: "LCU/rot_control_z", - // State - generalState: "LCU/general_state", + // Airgaps 1–4: vertical, 5–8: lateral/horizontal + verticalAirgap1: "airgap_1", + verticalAirgap2: "airgap_2", + verticalAirgap3: "airgap_3", + verticalAirgap4: "airgap_4", + horizontalAirgap1: "airgap_5", + horizontalAirgap2: "airgap_6", + horizontalAirgap3: "airgap_7", + horizontalAirgap4: "airgap_8", + masterState: "master_state_machine", + slaveState: "slave_state_machine", + // Coil currents: 1–4 = HEMS (vertical), 5–10 = EMS (lateral) + coilCurrentHEMS1: "coil_current_1", + coilCurrentHEMS2: "coil_current_2", + coilCurrentHEMS3: "coil_current_3", + coilCurrentHEMS4: "coil_current_4", + coilCurrentEMS1: "coil_current_5", + coilCurrentEMS2: "coil_current_6", + coilCurrentEMS3: "coil_current_7", + coilCurrentEMS4: "coil_current_8", + coilCurrentEMS5: "coil_current_9", + coilCurrentEMS6: "coil_current_10", } as const; diff --git a/frontend/competition-view/src/hooks/useMeasurement.ts b/frontend/competition-view/src/hooks/useMeasurement.ts index 802b839cf..4c68d38a6 100644 --- a/frontend/competition-view/src/hooks/useMeasurement.ts +++ b/frontend/competition-view/src/hooks/useMeasurement.ts @@ -1,10 +1,11 @@ import { useStore } from "../store/store"; /** - * Reads a single telemetry measurement from the store by its backend key - * (e.g. "VCU/general_state"). Returns `undefined` if no data has arrived yet. + * Reads a single telemetry measurement from the store. + * Both board and id must match what the backend sends in `measurementUpdates`. + * Use the BOARDS and per-board constants from `constants/measurements.ts`. */ -const useMeasurement = (key: string) => - useStore((s) => s.getMeasurement(key)); +const useMeasurement = (board: string, id: string) => + useStore((s) => s.getMeasurement(board, id)); export default useMeasurement; diff --git a/frontend/competition-view/src/hooks/usePodCatalog.ts b/frontend/competition-view/src/hooks/usePodCatalog.ts new file mode 100644 index 000000000..2d414132a --- /dev/null +++ b/frontend/competition-view/src/hooks/usePodCatalog.ts @@ -0,0 +1,44 @@ +import { useFetchConfig, useWebSocket } from "@workspace/ui/hooks"; +import { useEffect } from "react"; +import { useStore } from "../store/store"; + +interface PodPacket { id: number } +interface PodBoard { name: string; packets: PodPacket[] } +interface PodDataStructure { boards: PodBoard[] } + +const BACKEND_URL = + (import.meta.env.VITE_BACKEND_URL as string | undefined) ?? + "http://127.0.0.1:4000/backend"; + +/** + * Fetches `podDataStructure` on every WebSocket connect and populates the + * `packetBoard` map (packetId → boardName) used by `updateTelemetry` to + * scope measurements under the correct board, preventing collisions between + * boards that share measurement names. + */ +const usePodCatalog = () => { + const { isConnected } = useWebSocket(); + const setPacketBoard = useStore((s) => s.setPacketBoard); + + const { data, refetch } = useFetchConfig( + BACKEND_URL, + "podDataStructure", + ); + + useEffect(() => { + if (isConnected) refetch(); + }, [isConnected, refetch]); + + useEffect(() => { + if (!data) return; + const map: Record = {}; + for (const board of data.boards) { + for (const packet of board.packets) { + map[packet.id] = board.name; + } + } + setPacketBoard(map); + }, [data, setPacketBoard]); +}; + +export default usePodCatalog; diff --git a/frontend/competition-view/src/pages/Batteries/components/BatteryPackCard.tsx b/frontend/competition-view/src/pages/Batteries/components/BatteryPackCard.tsx index be3dd4e6c..62e2de6ca 100644 --- a/frontend/competition-view/src/pages/Batteries/components/BatteryPackCard.tsx +++ b/frontend/competition-view/src/pages/Batteries/components/BatteryPackCard.tsx @@ -1,5 +1,5 @@ import { Card, CardContent, CardHeader, CardTitle } from "@workspace/ui/components"; -import { hvbmsPack } from "../../../constants/measurements"; +import { BOARDS, hvbmsPack } from "../../../constants/measurements"; import useMeasurement from "../../../hooks/useMeasurement"; interface BatteryPackCardProps { @@ -17,7 +17,7 @@ const fmt = (v: number | boolean | string | undefined, decimals = 1) => /* ─── Individual cell tile ───────────────────────────────────────────────── */ const CellTile = ({ cellNum, measurementKey }: { cellNum: number; measurementKey: string }) => { - const raw = useMeasurement(measurementKey); + const raw = useMeasurement(BOARDS.HVBMS, measurementKey); const v = typeof raw === "number" ? raw : null; const isLow = v !== null && v < CELL_WARN_LOW; const isHigh = v !== null && v > CELL_WARN_HIGH; @@ -57,34 +57,13 @@ const CellTile = ({ cellNum, measurementKey }: { cellNum: number; measurementKey const BatteryPackCard = ({ packNumber }: BatteryPackCardProps) => { const keys = hvbmsPack(packNumber); - const soc = useMeasurement(keys.soc); - const voltage = useMeasurement(keys.voltage); - const temp = useMeasurement(keys.temperature); - - const socNum = typeof soc === "number" ? soc : null; - const socColor = - socNum === null ? "bg-muted" : - socNum < 15 ? "bg-red-500" : - socNum < 30 ? "bg-amber-500" : - "bg-green-500"; + const voltage = useMeasurement(BOARDS.HVBMS, keys.voltage); + const temp = useMeasurement(BOARDS.HVBMS, keys.tempMax); return ( -
- Group {packNumber} -
-
-
-
- - {fmt(soc, 0)}% - -
-
+ Group {packNumber} @@ -94,7 +73,7 @@ const BatteryPackCard = ({ packNumber }: BatteryPackCardProps) => {
- {/* 12-cell grid: 6 cols × 2 rows */} + {/* Cell grid */}
{keys.cells.map((key, i) => ( diff --git a/frontend/competition-view/src/pages/Batteries/components/HvBatterySection.tsx b/frontend/competition-view/src/pages/Batteries/components/HvBatterySection.tsx index 1768de4f6..e8d11674a 100644 --- a/frontend/competition-view/src/pages/Batteries/components/HvBatterySection.tsx +++ b/frontend/competition-view/src/pages/Batteries/components/HvBatterySection.tsx @@ -1,5 +1,5 @@ import { Separator } from "@workspace/ui/components"; -import { HVBMS, HVBMS_CABINET } from "../../../constants/measurements"; +import { BOARDS, HVBMS, VCU } from "../../../constants/measurements"; import useMeasurement from "../../../hooks/useMeasurement"; import BatteryPackCard from "./BatteryPackCard"; @@ -10,13 +10,13 @@ const fmt = (v: number | boolean | string | undefined, decimals = 1) => typeof v === "number" ? v.toFixed(decimals) : "—"; const HvBatterySection = () => { - const totalVoltage = useMeasurement(HVBMS.batteriesVoltage); - const voltageMax = useMeasurement(HVBMS.voltageMax); - const voltageMin = useMeasurement(HVBMS.voltageMin); - const tempMax = useMeasurement(HVBMS.tempMax); - const tempMin = useMeasurement(HVBMS.tempMin); - const soc = useMeasurement(HVBMS.minimumSoc); - const contactors = useMeasurement(HVBMS_CABINET.contactorsState); + const totalVoltage = useMeasurement(BOARDS.HVBMS, HVBMS.batteriesVoltage); + const voltageMax = useMeasurement(BOARDS.HVBMS, HVBMS.voltageMax); + const voltageMin = useMeasurement(BOARDS.HVBMS, HVBMS.voltageMin); + const tempMax = useMeasurement(BOARDS.HVBMS, HVBMS.tempMax); + const tempMin = useMeasurement(BOARDS.HVBMS, HVBMS.tempMin); + const soc = useMeasurement(BOARDS.HVBMS, HVBMS.minimumSoc); + const contactors = useMeasurement(BOARDS.VCU, VCU.contactorsClosed); return (
@@ -27,12 +27,12 @@ const HvBatterySection = () => { {contactors !== undefined && ( - Contactors {contactors} + Contactors {contactors === true ? "Closed" : "Open"} )}
diff --git a/frontend/competition-view/src/pages/Batteries/components/LvBatterySection.tsx b/frontend/competition-view/src/pages/Batteries/components/LvBatterySection.tsx index 82f799a58..1b006e88f 100644 --- a/frontend/competition-view/src/pages/Batteries/components/LvBatterySection.tsx +++ b/frontend/competition-view/src/pages/Batteries/components/LvBatterySection.tsx @@ -1,5 +1,5 @@ import { Card, CardContent, CardHeader, CardTitle } from "@workspace/ui/components"; -import { LVBMS } from "../../../constants/measurements"; +import { BOARDS, LVBMS } from "../../../constants/measurements"; import useMeasurement from "../../../hooks/useMeasurement"; const CELL_MIN = 3.0; @@ -13,7 +13,7 @@ const fmt = (v: number | boolean | string | undefined, decimals = 2) => /* ─── Cell tile (same style as HV) ──────────────────────────────────────── */ const CellTile = ({ cellNum, measurementKey }: { cellNum: number; measurementKey: string }) => { - const raw = useMeasurement(measurementKey); + const raw = useMeasurement(BOARDS.LVBMS, measurementKey); const v = typeof raw === "number" ? raw : null; const isLow = v !== null && v < CELL_WARN_LOW; const isHigh = v !== null && v > CELL_WARN_HIGH; @@ -51,14 +51,13 @@ const CellTile = ({ cellNum, measurementKey }: { cellNum: number; measurementKey /* ─── LV section ─────────────────────────────────────────────────────────── */ const LvBatterySection = () => { - const soc = useMeasurement(LVBMS.soc); - const totalVoltage = useMeasurement(LVBMS.totalVoltage); - const voltageMax = useMeasurement(LVBMS.voltageMax); - const voltageMin = useMeasurement(LVBMS.voltageMin); - const tempMax = useMeasurement(LVBMS.tempMax); - const tempMin = useMeasurement(LVBMS.tempMin); - const current = useMeasurement(LVBMS.current); - const state = useMeasurement(LVBMS.generalState); + const soc = useMeasurement(BOARDS.LVBMS, LVBMS.soc); + const totalVoltage = useMeasurement(BOARDS.LVBMS, LVBMS.totalVoltage); + const voltageMax = useMeasurement(BOARDS.LVBMS, LVBMS.voltageMax); + const voltageMin = useMeasurement(BOARDS.LVBMS, LVBMS.voltageMin); + const temperature = useMeasurement(BOARDS.LVBMS, LVBMS.temperature); + const current = useMeasurement(BOARDS.LVBMS, LVBMS.current); + const state = useMeasurement(BOARDS.LVBMS, LVBMS.generalState); const socNum = typeof soc === "number" ? soc : null; const socColor = @@ -99,14 +98,13 @@ const LvBatterySection = () => { {/* Summary stats */} -
+
{[ { label: "Total V", value: fmt(totalVoltage), unit: "V" }, { label: "Current", value: fmt(current), unit: "A" }, { label: "V max", value: fmt(voltageMax), unit: "V" }, { label: "V min", value: fmt(voltageMin), unit: "V" }, - { label: "T max", value: fmt(tempMax, 1), unit: "°C" }, - { label: "T min", value: fmt(tempMin, 1), unit: "°C" }, + { label: "Temp", value: fmt(temperature, 1), unit: "°C" }, ].map(({ label, value, unit }) => (
{label} diff --git a/frontend/competition-view/src/pages/Boards/Boards.tsx b/frontend/competition-view/src/pages/Boards/Boards.tsx index 11eb623e1..d167a7ab9 100644 --- a/frontend/competition-view/src/pages/Boards/Boards.tsx +++ b/frontend/competition-view/src/pages/Boards/Boards.tsx @@ -1,4 +1,4 @@ -import { BCU, BLCU, HVBMS, HVBMS_CABINET, LCU, LVBMS, PCU_BOARD, VCU } from "../../constants/measurements"; +import { BLCU, BOARDS, HVBMS, LCU, LVBMS, PCU_BOARD, VCU } from "../../constants/measurements"; import BoardCard from "./components/BoardCard"; import LcuAirgapCard from "./components/LcuAirgapCard"; @@ -9,41 +9,35 @@ const Boards = () => (
- - ( /> - - - +
diff --git a/frontend/competition-view/src/pages/Boards/components/BoardCard.tsx b/frontend/competition-view/src/pages/Boards/components/BoardCard.tsx index 69f8ba297..9f8689c29 100644 --- a/frontend/competition-view/src/pages/Boards/components/BoardCard.tsx +++ b/frontend/competition-view/src/pages/Boards/components/BoardCard.tsx @@ -15,6 +15,8 @@ interface Stat { } interface BoardCardProps { + /** Board name used to scope the telemetry lookup (must match backend). */ + board: string; /** Display name shown in the card header. */ name: string; /** Measurement key for the board's general/operational state string. */ @@ -27,8 +29,8 @@ interface BoardCardProps { * Generic board status card. * Shows a state badge and an optional grid of secondary measurements. */ -const BoardCard = ({ name, stateMeasurementKey, stats = [] }: BoardCardProps) => { - const state = useMeasurement(stateMeasurementKey); +const BoardCard = ({ board, name, stateMeasurementKey, stats = [] }: BoardCardProps) => { + const state = useMeasurement(board, stateMeasurementKey); const hasData = state !== undefined; return ( @@ -53,7 +55,7 @@ const BoardCard = ({ name, stateMeasurementKey, stats = [] }: BoardCardProps) =>
{stats.map((s) => ( - + ))}
@@ -62,8 +64,8 @@ const BoardCard = ({ name, stateMeasurementKey, stats = [] }: BoardCardProps) => ); }; -const StatRow = ({ stat }: { stat: Stat }) => { - const raw = useMeasurement(stat.measurementKey); +const StatRow = ({ board, stat }: { board: string; stat: Stat }) => { + const raw = useMeasurement(board, stat.measurementKey); const display = typeof raw === "number" ? raw.toFixed(stat.decimals ?? 1) diff --git a/frontend/competition-view/src/pages/Boards/components/LcuAirgapCard.tsx b/frontend/competition-view/src/pages/Boards/components/LcuAirgapCard.tsx index 4104826b9..e1828d0cc 100644 --- a/frontend/competition-view/src/pages/Boards/components/LcuAirgapCard.tsx +++ b/frontend/competition-view/src/pages/Boards/components/LcuAirgapCard.tsx @@ -1,10 +1,11 @@ +import React from "react"; import { Card, CardContent, CardHeader, CardTitle, } from "@workspace/ui/components"; -import { LCU } from "../../../constants/measurements"; +import { BOARDS, LCU } from "../../../constants/measurements"; import useMeasurement from "../../../hooks/useMeasurement"; /** Airgap warning threshold in mm. */ @@ -13,7 +14,7 @@ const AIRGAP_WARN_MM = 5; const fmt = (v: number | boolean | string | undefined, decimals = 1) => typeof v === "number" ? v.toFixed(decimals) : "—"; -/* ─── Shared row components ────────────────────────────────────────────── */ +/* ─── Shared row component ─────────────────────────────────────────────── */ interface MeasurementRowProps { label: string; @@ -30,7 +31,7 @@ const MeasurementRow = ({ decimals = 1, warnBelow, }: MeasurementRowProps) => { - const value = useMeasurement(measurementKey); + const value = useMeasurement(BOARDS.LCU, measurementKey); const isWarning = warnBelow !== undefined && typeof value === "number" && @@ -39,11 +40,7 @@ const MeasurementRow = ({ return (
{label} - + {fmt(value, decimals)} {unit} @@ -51,68 +48,66 @@ const MeasurementRow = ({ ); }; -/* ─── Section components ───────────────────────────────────────────────── */ - const SectionLabel = ({ children }: { children: React.ReactNode }) => (

{children}

); -const PositionSection = () => ( +/* ─── Airgap sections ──────────────────────────────────────────────────── */ + +const VerticalAirgapsSection = () => (
- Position + Vertical Airgaps
- - + + + +
); -const RotationSection = () => ( +const HorizontalAirgapsSection = () => (
- Rotation + Lateral Airgaps
- - - + + + +
); -const VerticalAirgapsSection = () => ( +const HemsCurrentsSection = () => (
- Vertical Airgaps + HEMS Currents
- - - - + + + +
); -const HorizontalAirgapsSection = () => ( +const EmsCurrentsSection = () => (
- Horizontal Airgaps + EMS Currents
- - - - + + + + + +
); /* ─── Main card ─────────────────────────────────────────────────────────── */ -import React from "react"; - -/** - * Full levitation status card for the LCU. - * Displays position, rotation, and all 8 airgap sensors in a 4-column grid. - * Airgap values below 5 mm are highlighted in amber. - */ const LcuAirgapCard = () => ( @@ -121,10 +116,10 @@ const LcuAirgapCard = () => (
- - + +
diff --git a/frontend/competition-view/src/pages/Booster/Booster.tsx b/frontend/competition-view/src/pages/Booster/Booster.tsx index 1113fe616..755c93138 100644 --- a/frontend/competition-view/src/pages/Booster/Booster.tsx +++ b/frontend/competition-view/src/pages/Booster/Booster.tsx @@ -1,62 +1,101 @@ -import { BCU, HVBMS_CABINET } from "../../constants/measurements"; -import BoardCard from "../Boards/components/BoardCard"; - -/** - * Booster page — BCU (Booster Control Unit) state + HVBMS-Cabinet health. - * - * Layout: - * Section 1 — BCU state: general/operational/nested states + LSM phase currents - * Section 2 — HVBMS-Cabinet: contactors, bus voltage, output current, supercaps - */ +import { BOARDS, HVBMS, VCU } from "../../constants/measurements"; +import useMeasurement from "../../hooks/useMeasurement"; + +/* ─── Helpers ───────────────────────────────────────────────────────────── */ + +const boolColor = (v: number | boolean | string | undefined, trueIsGood: boolean) => { + if (v === undefined) return "text-muted-foreground"; + const t = v === true || String(v).toUpperCase() === "TRUE"; + const f = v === false || String(v).toUpperCase() === "FALSE"; + if (!t && !f) return "text-muted-foreground"; + return (t === trueIsGood) ? "text-green-500" : "text-red-500"; +}; + +/* ─── Status row ─────────────────────────────────────────────────────────── */ + +interface StatusRowProps { + label: string; + board: string; + measurementKey: string; + trueLabel?: string; + falseLabel?: string; + trueIsGood?: boolean; +} + +const StatusRow = ({ label, board, measurementKey, trueLabel = "YES", falseLabel = "NO", trueIsGood = true }: StatusRowProps) => { + const v = useMeasurement(board, measurementKey); + const isTrue = v === true || String(v).toUpperCase() === "TRUE"; + const isFalse = v === false || String(v).toUpperCase() === "FALSE"; + const text = v === undefined ? "—" : isTrue ? trueLabel : isFalse ? falseLabel : String(v); + + return ( +
+ {label} + {text} +
+ ); +}; + +/* ─── Enum row ───────────────────────────────────────────────────────────── */ + +const EnumRow = ({ label, board, measurementKey, goodValues = [] }: { + label: string; board: string; measurementKey: string; goodValues?: string[] +}) => { + const v = useMeasurement(board, measurementKey); + const text = v === undefined ? "—" : String(v); + const isGood = goodValues.some(g => text.toUpperCase() === g.toUpperCase()); + const color = v === undefined ? "text-muted-foreground" : isGood ? "text-green-500" : "text-foreground"; + + return ( +
+ {label} + {text} +
+ ); +}; + +/* ─── Booster page ───────────────────────────────────────────────────────── */ + const Booster = () => (
- {/* ── BCU ─────────────────────────────────────────────────────────── */} + + {/* ── VCU Subsystem States ──────────────────────────────────────────── */} +
+

VCU — Subsystem States

+
+ + + + +
+
+ + {/* ── Safety ───────────────────────────────────────────────────────── */}
-

- BCU — Booster Control Unit -

- -
- - - +

Safety

+
+ + + + + + +
- {/* ── HVBMS-Cabinet ────────────────────────────────────────────────── */} + {/* ── HVBMS Contactors ─────────────────────────────────────────────── */}
-

- HVBMS-Cabinet -

- -
- +

HVBMS — Contactors

+
+ + + + +
+
); diff --git a/frontend/competition-view/src/pages/Charts/Charts.tsx b/frontend/competition-view/src/pages/Charts/Charts.tsx index b4611ec22..2e44fb0b7 100644 --- a/frontend/competition-view/src/pages/Charts/Charts.tsx +++ b/frontend/competition-view/src/pages/Charts/Charts.tsx @@ -1,75 +1,75 @@ -import { BCU, HVBMS, PCU, VCU } from "../../constants/measurements"; +import { BOARDS, HVBMS, LCU, PCU, VCU } from "../../constants/measurements"; import MultiSeriesChart, { type SeriesConfig } from "./components/MultiSeriesChart"; import TelemetryChart from "./components/TelemetryChart"; /** * Real-time telemetry charts page. * - * Row 1 — Kinematic: Speed · Position - * Row 2 — Electrical: HV Battery SOC · Brake Pressure - * Row 3 — Motor phase: DLIM (PCU motorA U/V/W) · LSM (BCU average U/V/W) - * - * Each chart accumulates a rolling 500-point history and supports - * click-drag zoom with double-click to reset. - * - * DLIM/LSM series configs are defined at module level so the - * MultiSeriesChart's Zustand selector and uPlot init are stable. + * Row 1 — Kinematic: Speed · Position + * Row 2 — Electrical: HV Battery SOC · HV Current + * Row 3 — Motor phase: DLIM (PCU U/V/W) + * Row 4 — Levitation: Vertical Airgaps · Lateral Airgaps + * Row 5 — Lev. currents: HEMS coil currents · EMS coil currents */ const DLIM_SERIES: SeriesConfig[] = [ - { measurementKey: PCU.motorCurrentU, label: "U", colorIndex: 0 }, - { measurementKey: PCU.motorCurrentV, label: "V", colorIndex: 1 }, - { measurementKey: PCU.motorCurrentW, label: "W", colorIndex: 2 }, + { board: BOARDS.PCU, measurementKey: PCU.motorCurrentU, label: "U", colorIndex: 0 }, + { board: BOARDS.PCU, measurementKey: PCU.motorCurrentV, label: "V", colorIndex: 1 }, + { board: BOARDS.PCU, measurementKey: PCU.motorCurrentW, label: "W", colorIndex: 2 }, +]; + +const VERT_AIRGAP_SERIES: SeriesConfig[] = [ + { board: BOARDS.LCU, measurementKey: LCU.verticalAirgap1, label: "V1", colorIndex: 0 }, + { board: BOARDS.LCU, measurementKey: LCU.verticalAirgap2, label: "V2", colorIndex: 1 }, + { board: BOARDS.LCU, measurementKey: LCU.verticalAirgap3, label: "V3", colorIndex: 2 }, + { board: BOARDS.LCU, measurementKey: LCU.verticalAirgap4, label: "V4", colorIndex: 3 }, +]; + +const LAT_AIRGAP_SERIES: SeriesConfig[] = [ + { board: BOARDS.LCU, measurementKey: LCU.horizontalAirgap1, label: "L1", colorIndex: 0 }, + { board: BOARDS.LCU, measurementKey: LCU.horizontalAirgap2, label: "L2", colorIndex: 1 }, + { board: BOARDS.LCU, measurementKey: LCU.horizontalAirgap3, label: "L3", colorIndex: 2 }, + { board: BOARDS.LCU, measurementKey: LCU.horizontalAirgap4, label: "L4", colorIndex: 3 }, +]; + +const HEMS_SERIES: SeriesConfig[] = [ + { board: BOARDS.LCU, measurementKey: LCU.coilCurrentHEMS1, label: "H1", colorIndex: 0 }, + { board: BOARDS.LCU, measurementKey: LCU.coilCurrentHEMS2, label: "H2", colorIndex: 1 }, + { board: BOARDS.LCU, measurementKey: LCU.coilCurrentHEMS3, label: "H3", colorIndex: 2 }, + { board: BOARDS.LCU, measurementKey: LCU.coilCurrentHEMS4, label: "H4", colorIndex: 3 }, ]; -const LSM_SERIES: SeriesConfig[] = [ - { measurementKey: BCU.averageCurrentU, label: "U", colorIndex: 0 }, - { measurementKey: BCU.averageCurrentV, label: "V", colorIndex: 1 }, - { measurementKey: BCU.averageCurrentW, label: "W", colorIndex: 2 }, +const EMS_SERIES: SeriesConfig[] = [ + { board: BOARDS.LCU, measurementKey: LCU.coilCurrentEMS1, label: "E1", colorIndex: 0 }, + { board: BOARDS.LCU, measurementKey: LCU.coilCurrentEMS2, label: "E2", colorIndex: 1 }, + { board: BOARDS.LCU, measurementKey: LCU.coilCurrentEMS3, label: "E3", colorIndex: 2 }, + { board: BOARDS.LCU, measurementKey: LCU.coilCurrentEMS4, label: "E4", colorIndex: 3 }, + { board: BOARDS.LCU, measurementKey: LCU.coilCurrentEMS5, label: "E5", colorIndex: 4 }, + { board: BOARDS.LCU, measurementKey: LCU.coilCurrentEMS6, label: "E6", colorIndex: 5 }, ]; const Charts = () => (
{/* Row 1 — Kinematic */} - - + + {/* Row 2 — Electrical */} - - + + + + {/* Row 3 — DLIM motor currents */} + + + + {/* Row 4 — Airgaps */} + + - {/* Row 3 — Motor phase currents */} - - + {/* Row 5 — Levitation currents */} + +
); diff --git a/frontend/competition-view/src/pages/Charts/components/MultiSeriesChart.tsx b/frontend/competition-view/src/pages/Charts/components/MultiSeriesChart.tsx index fd3a32829..f0d334c86 100644 --- a/frontend/competition-view/src/pages/Charts/components/MultiSeriesChart.tsx +++ b/frontend/competition-view/src/pages/Charts/components/MultiSeriesChart.tsx @@ -12,6 +12,9 @@ import { import { useStore } from "../../../store/store"; export interface SeriesConfig { + /** Board name (must match backend, use BOARDS constants). */ + board: string; + /** Measurement ID within that board. */ measurementKey: string; /** Short label shown in the legend (e.g. "U", "V", "W"). */ label: string; @@ -48,7 +51,7 @@ const MultiSeriesChart = memo(({ title, series, unit = "" }: MultiSeriesChartPro const values = useStore( // The selector is stable because `series` is a module-level constant. useShallow((s) => - series.map(({ measurementKey }) => s.telemetry[measurementKey] as number | undefined), + series.map(({ board, measurementKey }) => s.telemetry[board]?.[measurementKey] as number | undefined), ), ); diff --git a/frontend/competition-view/src/pages/Charts/components/TelemetryChart.tsx b/frontend/competition-view/src/pages/Charts/components/TelemetryChart.tsx index 22b4fcdf8..ce757c196 100644 --- a/frontend/competition-view/src/pages/Charts/components/TelemetryChart.tsx +++ b/frontend/competition-view/src/pages/Charts/components/TelemetryChart.tsx @@ -13,7 +13,9 @@ import useMeasurement from "../../../hooks/useMeasurement"; interface TelemetryChartProps { /** Human-readable label shown in the card header. */ title: string; - /** Backend telemetry key to track (e.g. "PCU/encoder_speed_km_h"). */ + /** Board name (must match backend, use BOARDS constants). */ + board: string; + /** Measurement ID within that board. */ measurementKey: string; /** Unit appended to the y-axis label. */ unit?: string; @@ -30,6 +32,7 @@ interface TelemetryChartProps { */ const TelemetryChart = memo(({ title, + board, measurementKey, unit = "", colorIndex = 0, @@ -41,7 +44,7 @@ const TelemetryChart = memo(({ const yRef = useRef([]); const counterRef = useRef(0); - const value = useMeasurement(measurementKey); + const value = useMeasurement(board, measurementKey); const color = CHART_COLORS[colorIndex % CHART_COLORS.length]; // ── Initialise uplot ──────────────────────────────────────────────────── diff --git a/frontend/competition-view/src/pages/Overview/Overview.tsx b/frontend/competition-view/src/pages/Overview/Overview.tsx index ba769bf61..c1c48f71b 100644 --- a/frontend/competition-view/src/pages/Overview/Overview.tsx +++ b/frontend/competition-view/src/pages/Overview/Overview.tsx @@ -1,11 +1,10 @@ -import { Button, Separator } from "@workspace/ui/components"; +import { Button } from "@workspace/ui/components"; import { ChevronUp } from "@workspace/ui/icons"; import { useEffect, useRef, useState } from "react"; import { - BCU, BLCU, + BOARDS, HVBMS, - HVBMS_CABINET, LCU, LVBMS, PCU, @@ -23,39 +22,185 @@ import MessageItem from "../Messages/components/MessageItem"; /* ─── Stable series configs ─────────────────────────────────────────────── */ const DLIM_SERIES: SeriesConfig[] = [ - { measurementKey: PCU.motorCurrentU, label: "U", colorIndex: 0 }, - { measurementKey: PCU.motorCurrentV, label: "V", colorIndex: 1 }, - { measurementKey: PCU.motorCurrentW, label: "W", colorIndex: 2 }, + { board: BOARDS.PCU, measurementKey: PCU.motorCurrentU, label: "U", colorIndex: 0 }, + { board: BOARDS.PCU, measurementKey: PCU.motorCurrentV, label: "V", colorIndex: 1 }, + { board: BOARDS.PCU, measurementKey: PCU.motorCurrentW, label: "W", colorIndex: 2 }, ]; -const LSM_SERIES: SeriesConfig[] = [ - { measurementKey: BCU.averageCurrentU, label: "U", colorIndex: 0 }, - { measurementKey: BCU.averageCurrentV, label: "V", colorIndex: 1 }, - { measurementKey: BCU.averageCurrentW, label: "W", colorIndex: 2 }, +const VERT_AIRGAP_SERIES: SeriesConfig[] = [ + { board: BOARDS.LCU, measurementKey: LCU.verticalAirgap1, label: "1", colorIndex: 0 }, + { board: BOARDS.LCU, measurementKey: LCU.verticalAirgap2, label: "2", colorIndex: 1 }, + { board: BOARDS.LCU, measurementKey: LCU.verticalAirgap3, label: "3", colorIndex: 2 }, + { board: BOARDS.LCU, measurementKey: LCU.verticalAirgap4, label: "4", colorIndex: 3 }, ]; -/* ─── Compact metric tile ───────────────────────────────────────────────── */ +const LAT_AIRGAP_SERIES: SeriesConfig[] = [ + { board: BOARDS.LCU, measurementKey: LCU.horizontalAirgap1, label: "1", colorIndex: 0 }, + { board: BOARDS.LCU, measurementKey: LCU.horizontalAirgap2, label: "2", colorIndex: 1 }, + { board: BOARDS.LCU, measurementKey: LCU.horizontalAirgap3, label: "3", colorIndex: 2 }, + { board: BOARDS.LCU, measurementKey: LCU.horizontalAirgap4, label: "4", colorIndex: 3 }, +]; + +const HEMS_SERIES: SeriesConfig[] = [ + { board: BOARDS.LCU, measurementKey: LCU.coilCurrentHEMS1, label: "H1", colorIndex: 0 }, + { board: BOARDS.LCU, measurementKey: LCU.coilCurrentHEMS2, label: "H2", colorIndex: 1 }, + { board: BOARDS.LCU, measurementKey: LCU.coilCurrentHEMS3, label: "H3", colorIndex: 2 }, + { board: BOARDS.LCU, measurementKey: LCU.coilCurrentHEMS4, label: "H4", colorIndex: 3 }, +]; + +const EMS_SERIES: SeriesConfig[] = [ + { board: BOARDS.LCU, measurementKey: LCU.coilCurrentEMS1, label: "E1", colorIndex: 0 }, + { board: BOARDS.LCU, measurementKey: LCU.coilCurrentEMS2, label: "E2", colorIndex: 1 }, + { board: BOARDS.LCU, measurementKey: LCU.coilCurrentEMS3, label: "E3", colorIndex: 2 }, + { board: BOARDS.LCU, measurementKey: LCU.coilCurrentEMS4, label: "E4", colorIndex: 3 }, + { board: BOARDS.LCU, measurementKey: LCU.coilCurrentEMS5, label: "E5", colorIndex: 4 }, + { board: BOARDS.LCU, measurementKey: LCU.coilCurrentEMS6, label: "E6", colorIndex: 5 }, +]; + +/* ─── Helpers ───────────────────────────────────────────────────────────── */ + +const fmtNum = (v: number | boolean | string | undefined, decimals = 1) => + typeof v === "number" ? v.toFixed(decimals) : undefined; -interface MetricTileProps { +/* ─── Battery card ──────────────────────────────────────────────────────── */ + +interface BatteryRow { label: string; value: string | undefined; unit?: string; - valueClassName?: string; + warn?: boolean; +} + +interface BatteryCardProps { + title: string; + soc: number | undefined; + rows: BatteryRow[]; } -const MetricTile = ({ label, value, unit, valueClassName = "" }: MetricTileProps) => ( -
- - {label} - - - {value ?? "—"} - {value !== undefined && unit && ( - {unit} - )} - -
-); +const BatteryCard = ({ title, soc, rows }: BatteryCardProps) => { + const socPct = typeof soc === "number" ? Math.min(100, Math.max(0, soc)) : 0; + const barColor = typeof soc !== "number" + ? "bg-muted-foreground/20" + : soc < 20 ? "bg-red-500" : soc < 50 ? "bg-yellow-500" : "bg-green-500"; + + return ( +
+
+ {title} + + {typeof soc === "number" ? soc.toFixed(0) : "—"} + % + +
+ +
+
+
+ +
+ {rows.map(({ label, value, unit, warn }) => ( +
+ {label} + + {value ?? "—"} + {value !== undefined && unit && ( + {unit} + )} + +
+ ))} +
+
+ ); +}; + +/* ─── Kinematics card ───────────────────────────────────────────────────── */ + +const KinematicsCard = () => { + const speed = useMeasurement(BOARDS.PCU, PCU.speed); + const position = useMeasurement(BOARDS.PCU, PCU.position); + const acceleration = useMeasurement(BOARDS.PCU, PCU.acceleration); + const highPsi = useMeasurement(BOARDS.VCU, VCU.highPressure); + const lowPsi = useMeasurement(BOARDS.VCU, VCU.lowPressure); + + const rows = [ + { label: "Position", value: fmtNum(position), unit: "m" }, + { label: "Acceleration", value: fmtNum(acceleration, 2), unit: "m/s²" }, + { label: "High pres.", value: fmtNum(highPsi), unit: "bar" }, + { label: "Low pres.", value: fmtNum(lowPsi), unit: "bar" }, + ]; + + return ( +
+ Kinematics +
+ {fmtNum(speed, 0) ?? "—"} + km/h +
+
+ {rows.map(({ label, value, unit }) => ( +
+ {label} + + {value ?? "—"} + {value !== undefined && unit && ( + {unit} + )} + +
+ ))} +
+
+ ); +}; + +/* ─── Safety card ───────────────────────────────────────────────────────── */ + +interface SafeRow { label: string; text: string; color: string } + +const SafetyCard = () => { + const sdcStatus = useMeasurement(BOARDS.HVBMS, HVBMS.sdcStatus); + const contactors = useMeasurement(BOARDS.VCU, VCU.contactorsClosed); + const imd = useMeasurement(BOARDS.HVBMS, HVBMS.imdOk); + const hvBmsState = useMeasurement(BOARDS.HVBMS, HVBMS.operationalState); + + const rows: SafeRow[] = [ + { + label: "SDC", + text: sdcStatus === undefined ? "—" : String(sdcStatus), + color: sdcStatus === "ENGAGED" ? "text-green-500" : sdcStatus === "DISENGAGED" ? "text-red-500" : "text-muted-foreground", + }, + { + label: "Contactors", + text: contactors === true ? "CLOSED" : contactors === false ? "OPEN" : "—", + color: contactors === true ? "text-green-500" : contactors === false ? "text-amber-500" : "text-muted-foreground", + }, + { + label: "IMD", + text: imd === true ? "OK" : imd === false ? "FAULT" : "—", + color: imd === true ? "text-green-500" : imd === false ? "text-red-500" : "text-muted-foreground", + }, + { + label: "HV BMS", + text: hvBmsState === undefined ? "—" : String(hvBmsState), + color: hvBmsState === "OPERATIONAL" ? "text-green-500" : "text-foreground", + }, + ]; + + return ( +
+ Safety +
+ {rows.map(({ label, text, color }) => ( +
+ {label} + {text} +
+ ))} +
+
+ ); +}; /* ─── Messages panel ────────────────────────────────────────────────────── */ @@ -98,7 +243,6 @@ const MessagesPanel = () => { ))}
{filtered.length}/{messages.length} - - - - sendOrder(EMERGENCY_STOP_ORDERS)} /> +
+ {loading ? ( +
+ {Array.from({ length: 3 }).map((_, i) => ( + + ))} +
+ ) : boards.length === 0 ? ( +

+ No orders available +

+ ) : ( +
+ {boards.map((boardName) => { + const board = commandsCatalog[boardName]; + if (!board) return null; + return ( + + ); + })} +
+ )}
); }; -/** - * Emergency Stop requires two clicks within 2 s to prevent accidents. - * After the first click the button enters an "armed" state. - */ -const EmergencyStopButton = ({ onConfirm }: { onConfirm: () => void }) => { - const [armed, setArmed] = useState(false); - - const handleClick = () => { - if (armed) { - onConfirm(); - setArmed(false); - return; - } - setArmed(true); - setTimeout(() => setArmed(false), 2000); - }; - - return ( - - ); -}; - export default OrdersPanel; From 27c18e58b7754266f6edfc2816ca5664e5525ed4 Mon Sep 17 00:00:00 2001 From: lcastel Date: Mon, 13 Jul 2026 21:44:52 +0200 Subject: [PATCH 07/29] feat(competition-view): changes made at ehw --- frontend/competition-view/src/App.tsx | 2 + frontend/competition-view/src/assets/pod.svg | 228 ++++++++++++++++++ .../src/components/header/ConnectionBadge.tsx | 37 +-- .../components/header/DashboardStatusBar.tsx | 180 +++++++------- .../src/components/header/Header.tsx | 35 +-- .../src/components/header/HvalIndicator.tsx | 49 ++++ .../src/constants/chartConfig.ts | 37 ++- .../src/constants/measurements.ts | 68 +++--- .../competition-view/src/constants/orders.ts | 32 ++- .../competition-view/src/constants/track.ts | 2 + .../src/hooks/useKeyboardShortcuts.ts | 10 +- .../competition-view/src/layout/AppLayout.tsx | 2 +- .../competition-view/src/lib/stateColor.ts | 65 +++++ .../Batteries/components/BatteryPackCard.tsx | 123 ++++++---- .../Batteries/components/HvBatterySection.tsx | 34 +-- .../src/pages/Boards/Boards.tsx | 70 ------ .../src/pages/Boards/components/BoardCard.tsx | 89 ------- .../pages/Boards/components/LcuAirgapCard.tsx | 128 ---------- .../src/pages/Booster/Booster.tsx | 42 +++- .../src/pages/Charts/Charts.tsx | 2 +- .../Charts/components/MultiSeriesChart.tsx | 29 ++- .../Charts/components/TelemetryChart.tsx | 29 ++- .../src/pages/Overview/Overview.tsx | 156 +++++------- .../components/BoardsOverviewCard.tsx | 125 ++++++++++ .../pages/Overview/components/OrdersPanel.tsx | 2 +- .../Overview/components/TrackProgress.tsx | 72 ++++++ .../components/VehicleStateBanner.tsx | 14 +- 27 files changed, 1028 insertions(+), 634 deletions(-) create mode 100644 frontend/competition-view/src/assets/pod.svg create mode 100644 frontend/competition-view/src/components/header/HvalIndicator.tsx create mode 100644 frontend/competition-view/src/constants/track.ts create mode 100644 frontend/competition-view/src/lib/stateColor.ts delete mode 100644 frontend/competition-view/src/pages/Boards/Boards.tsx delete mode 100644 frontend/competition-view/src/pages/Boards/components/BoardCard.tsx delete mode 100644 frontend/competition-view/src/pages/Boards/components/LcuAirgapCard.tsx create mode 100644 frontend/competition-view/src/pages/Overview/components/BoardsOverviewCard.tsx create mode 100644 frontend/competition-view/src/pages/Overview/components/TrackProgress.tsx diff --git a/frontend/competition-view/src/App.tsx b/frontend/competition-view/src/App.tsx index a0c29829b..24f3eede0 100644 --- a/frontend/competition-view/src/App.tsx +++ b/frontend/competition-view/src/App.tsx @@ -6,6 +6,7 @@ import KeyboardShortcutsHelp from "./components/KeyboardShortcutsHelp"; import { BRAKE_ORDERS, EMERGENCY_STOP_ORDERS, + FAULT_ORDERS, OPEN_CONTACTORS_ORDERS, } from "./constants/orders"; import useKeyboardShortcuts from "./hooks/useKeyboardShortcuts"; @@ -41,6 +42,7 @@ const App = () => { onBrake: () => sendOrder(BRAKE_ORDERS), onOpenContactors: () => sendOrder(OPEN_CONTACTORS_ORDERS), onEmergencyStop: () => sendOrder(EMERGENCY_STOP_ORDERS), + onFault: () => sendOrder(FAULT_ORDERS), onToggleHelp: () => setHelpOpen((v) => !v), }); diff --git a/frontend/competition-view/src/assets/pod.svg b/frontend/competition-view/src/assets/pod.svg new file mode 100644 index 000000000..1eeac4a5f --- /dev/null +++ b/frontend/competition-view/src/assets/pod.svg @@ -0,0 +1,228 @@ + + + + + + + + + + + + + + + + + + diff --git a/frontend/competition-view/src/components/header/ConnectionBadge.tsx b/frontend/competition-view/src/components/header/ConnectionBadge.tsx index 88c2cb42e..619e9bf2e 100644 --- a/frontend/competition-view/src/components/header/ConnectionBadge.tsx +++ b/frontend/competition-view/src/components/header/ConnectionBadge.tsx @@ -1,25 +1,32 @@ import { Badge } from "@workspace/ui/components"; interface ConnectionBadgeProps { + /** Clear label identifying what this badge reports on, e.g. "Backend" or "VCU". */ + label: string; connected: boolean; } -const ConnectionBadge = ({ connected }: ConnectionBadgeProps) => ( - - ( + // Fixed-width slot: "Connected" vs "Disconnected" differ in length, so without + // this the badge (and anything after it) would shift as connection state changes. + // Wide enough for the longest case, e.g. "Backend: Disconnected". +
+ - {connected ? "Connected" : "Disconnected"} - + > + + {label}: {connected ? "Connected" : "Disconnected"} + +
); export default ConnectionBadge; diff --git a/frontend/competition-view/src/components/header/DashboardStatusBar.tsx b/frontend/competition-view/src/components/header/DashboardStatusBar.tsx index 16c831147..02db6fa03 100644 --- a/frontend/competition-view/src/components/header/DashboardStatusBar.tsx +++ b/frontend/competition-view/src/components/header/DashboardStatusBar.tsx @@ -1,6 +1,7 @@ -import { Button, Separator } from "@workspace/ui/components"; -import { useState } from "react"; -import { BOARDS, VCU } from "../../constants/measurements"; +import { Button, Separator, Tooltip, TooltipContent, TooltipTrigger } from "@workspace/ui/components"; +import { AlertTriangle, Square, Unplug } from "@workspace/ui/icons"; +import { formatAxisValue } from "../../constants/chartConfig"; +import { BOARDS, HVAL_THRESHOLD_V, HVBMS, VCU } from "../../constants/measurements"; import { BRAKE_ORDERS, EMERGENCY_STOP_ORDERS, @@ -8,60 +9,57 @@ import { } from "../../constants/orders"; import useMeasurement from "../../hooks/useMeasurement"; import useSendOrder from "../../hooks/useSendOrder"; +import { stateBadgeClass } from "../../lib/stateColor"; -/* ─── State colour helper ────────────────────────────────────────────────── */ +/* ─── Icon-only order button (half the footprint of a labelled button) ──── */ -const stateColor = (state: string | number | boolean | undefined): string => { - if (state === undefined) return "text-muted-foreground"; - const s = String(state).toUpperCase(); - if (s.includes("EMERGENCY") || s.includes("FAULT") || s.includes("ERROR")) return "text-red-500"; - if (s.includes("RUN") || s.includes("NOMINAL") || s.includes("ACCELERAT")) return "text-green-500"; - if (s.includes("BRAKE") || s.includes("DECELER") || s.includes("WARN")) return "text-amber-500"; - return "text-muted-foreground"; -}; - -/* ─── Emergency stop button (requires two clicks within 2 s) ────────────── */ - -const EStopButton = ({ onConfirm }: { onConfirm: () => void }) => { - const [armed, setArmed] = useState(false); +const OrderIconButton = ({ + label, + className, + onClick, + children, +}: { + label: string; + className?: string; + onClick: () => void; + children: React.ReactNode; +}) => ( + + + + + {label} + +); - const handleClick = () => { - if (armed) { onConfirm(); setArmed(false); return; } - setArmed(true); - setTimeout(() => setArmed(false), 2000); - }; +/* ─── Shared vertical rhythm for the status blocks ───────────────────────── */ - return ( - - ); -}; +const VDivider = () => ; -/* ─── Compact status label ───────────────────────────────────────────────── */ +const StatBlock = ({ heading, children }: { heading: string; children: React.ReactNode }) => ( +
+ + {heading} + + {/* Fixed-height value row so a bare text value and a bordered badge line up identically. */} +
{children}
+
+); -const StatusLabel = ({ - heading, +const StatValue = ({ value, valueClass = "text-foreground", + width, }: { - heading: string; value: string; valueClass?: string; + /** Fixed width so a longer/shorter value doesn't shift the elements after it. */ + width?: string; }) => ( -
- - {heading} - - {value} +
+ {value}
); @@ -70,9 +68,14 @@ const StatusLabel = ({ const DashboardStatusBar = () => { const sendOrder = useSendOrder(); - const generalState = useMeasurement(BOARDS.VCU, VCU.generalState); - const operationalState = useMeasurement(BOARDS.VCU, VCU.operationalState); - const brakeRaw = useMeasurement(BOARDS.VCU, VCU.activeBrakes); + const state = useMeasurement(BOARDS.VCU, VCU.state); + const brakeRaw = useMeasurement(BOARDS.VCU, VCU.activeBrakes); + const dcLinkV = useMeasurement(BOARDS.HVBMS, HVBMS.voltageReading) as number | undefined; + + const dcLinkActive = dcLinkV !== undefined && dcLinkV > HVAL_THRESHOLD_V; + const dcLinkClass = dcLinkV === undefined + ? "text-muted-foreground" + : dcLinkActive ? "text-red-500" : "text-green-500"; const brakeLabel = brakeRaw === undefined ? "—" : brakeRaw ? "BRAKED" : "UNBRAKED"; const brakeClass = @@ -82,43 +85,50 @@ const DashboardStatusBar = () => { return (
- - - - - - - - - - - - - - - - - sendOrder(EMERGENCY_STOP_ORDERS)} /> + + + + + + + + {/* Fixed-width slot so a longer/shorter state string doesn't shift the elements after it. */} +
+ + {state !== undefined ? String(state) : "—"} + +
+
+ + + + + + + + + +
+ sendOrder(BRAKE_ORDERS)}> + + + + sendOrder(OPEN_CONTACTORS_ORDERS)} + > + + + + sendOrder(EMERGENCY_STOP_ORDERS)} + > + + +
); }; diff --git a/frontend/competition-view/src/components/header/Header.tsx b/frontend/competition-view/src/components/header/Header.tsx index 33bd644a5..98b44f977 100644 --- a/frontend/competition-view/src/components/header/Header.tsx +++ b/frontend/competition-view/src/components/header/Header.tsx @@ -1,9 +1,12 @@ import { Button, Separator, SidebarTrigger, Tooltip, TooltipContent, TooltipTrigger } from "@workspace/ui/components"; import { Keyboard } from "@workspace/ui/icons"; import { useLocation } from "react-router"; +import { BOARDS } from "../../constants/measurements"; import { PAGES } from "../../constants/pages"; +import useConnections from "../../hooks/useConnections"; import ConnectionBadge from "./ConnectionBadge"; import DashboardStatusBar from "./DashboardStatusBar"; +import HvalIndicator from "./HvalIndicator"; interface HeaderProps { backendConnected: boolean; @@ -14,31 +17,33 @@ const Header = ({ backendConnected, onShowShortcuts }: HeaderProps) => { const location = useLocation(); const page = PAGES[location.pathname as keyof typeof PAGES]; const pageTitle = page?.title ?? "Competition View"; - const isDashboard = location.pathname === "/"; + + const connections = useConnections(); + const vcuConnected = connections[BOARDS.VCU]?.isConnected ?? false; return ( -
- - -

{pageTitle}

- - {isDashboard && ( -
- -
- )} - -
+
+
+ + +

{pageTitle}

+ +
+ + + +
Keyboard shortcuts (?) - + +
); diff --git a/frontend/competition-view/src/components/header/HvalIndicator.tsx b/frontend/competition-view/src/components/header/HvalIndicator.tsx new file mode 100644 index 000000000..37987e1a0 --- /dev/null +++ b/frontend/competition-view/src/components/header/HvalIndicator.tsx @@ -0,0 +1,49 @@ +import { Badge } from "@workspace/ui/components"; +import { useEffect, useState } from "react"; +import { BOARDS, HVAL_THRESHOLD_V, HVBMS } from "../../constants/measurements"; +import useMeasurement from "../../hooks/useMeasurement"; + +const FLASH_INTERVAL_MS = 500; + +const HvalIndicator = () => { + const voltage = useMeasurement(BOARDS.HVBMS, HVBMS.voltageReading) as number | undefined; + const active = voltage !== undefined && voltage > HVAL_THRESHOLD_V; + + // Blinks the badge between a solid red fill and its normal outlined look, + // mimicking a physical HVAL warning light rather than a smooth fade. + const [solid, setSolid] = useState(false); + useEffect(() => { + if (!active) { + setSolid(false); + return; + } + const id = setInterval(() => setSolid((s) => !s), FLASH_INTERVAL_MS); + return () => clearInterval(id); + }, [active]); + + return ( + // Fixed-width slot: "Active" vs "Inactive" differ in length, so without this + // the badge would resize as HVAL toggles. +
+ + + HVAL: {active ? "Active" : "Inactive"} + +
+ ); +}; + +export default HvalIndicator; diff --git a/frontend/competition-view/src/constants/chartConfig.ts b/frontend/competition-view/src/constants/chartConfig.ts index 0707e3ed6..7c76c50b3 100644 --- a/frontend/competition-view/src/constants/chartConfig.ts +++ b/frontend/competition-view/src/constants/chartConfig.ts @@ -1,5 +1,8 @@ -/** Maximum number of data points kept per chart series. */ -export const CHART_MAX_POINTS = 500; +/** Maximum number of data points kept per chart series (memory safety cap). */ +export const CHART_MAX_POINTS = 2000; + +/** Width, in seconds, of the rolling time window shown by real-time charts. */ +export const CHART_WINDOW_SECONDS = 10; /** Default rendered height of a chart in pixels. */ export const CHART_HEIGHT = 220; @@ -18,3 +21,33 @@ export const CHART_COLORS = [ "#ef4444", // red "#8b5cf6", // purple ] as const; + +const compactFormatter = new Intl.NumberFormat("en", { notation: "compact", maximumFractionDigits: 2 }); + +/** + * Formats an axis tick value, keeping the label short so uPlot never drops + * it for lack of space. `Intl`'s compact notation only abbreviates up to + * "T" (1e12) and prints the full digit string beyond that, so magnitudes + * past 1e6 fall back to exponential notation instead. + */ +export const formatAxisValue = (value: number): string => { + if (!Number.isFinite(value)) return ""; + const abs = Math.abs(value); + if (abs !== 0 && (abs >= 1e6 || abs < 1e-3)) return value.toExponential(1); + return compactFormatter.format(value); +}; + +/** + * "Nice" 1/2/5 × 10^n increments spanning the full float64 exponent range. + * uPlot's built-in table tops out around 1e32, so a garbage/misdecoded + * telemetry sample (float64 reinterpreted from bad bytes can reach ~1e308) + * leaves `findIncr` with no usable increment, which silently renders the + * axis with zero ticks — this table ensures one is always found. + */ +export const CHART_AXIS_INCRS: number[] = (() => { + const incrs: number[] = []; + for (let exp = -24; exp <= 308; exp++) { + for (const m of [1, 2, 5]) incrs.push(m * Math.pow(10, exp)); + } + return incrs; +})(); diff --git a/frontend/competition-view/src/constants/measurements.ts b/frontend/competition-view/src/constants/measurements.ts index b1ffdd224..245bda0a6 100644 --- a/frontend/competition-view/src/constants/measurements.ts +++ b/frontend/competition-view/src/constants/measurements.ts @@ -1,6 +1,6 @@ /** * Backend telemetry measurement IDs and board names sourced from the ADJ - * repository (branch: astra-wip4). + * repository (branch: Astra). * * IDs are the raw values sent by the backend inside `measurementUpdates`. * The BOARDS map provides the board name needed for the two-level lookup @@ -9,47 +9,50 @@ /** Board names as reported by the backend (from the ADJ). */ export const BOARDS = { - VCU: "VCU", - PCU: "PCU", - LCU: "LCU", - HVBMS: "HVBMS", + VCU: "VCU", + PCU: "PCU", + LCU: "LCU", + HVBMS: "HVBMS", + HVSCU_CABINET: "HVSCU-Cabinet", } as const; export const VCU = { - generalState: "general_state", - operationalState: "operational_state", - highPressure: "high_pressure", - lowPressure: "low_pressure", - sdcClosed: "sdc_closed", - contactorsClosed: "contactors_closed", - activeBrakes: "active_brakes", - brakeFault: "brake_fault_detected", - // Sub-system states as orchestrated by the VCU - hvbmsState: "hvbms_state", - pcuState: "pcu_state", - lcuVerticalState: "lcu_vertical_state", - lcuHorizontalState: "lcu_horizontal_state", + state: "state", + highPressure: "high_pressure", + lowPressure: "low_pressure", + pressureRegulatorFdbk: "pressure_regulator_feedback", + sdcClosed: "sdc_closed", + activeBrakes: "active_brakes", + brakeFault: "brake_fault_detected", + electrovalveEnabled: "electrovalve_enabled", + // Sub-board connectivity as reported by the VCU + hvbmsConnected: "hvbms_connected", + pcuConnected: "pcu_connected", + lcuConnected: "lcu_connected", + propulsionTargetSpeed: "propulsion_target_speed", + propulsionMaxCurrent: "propulsion_max_current", + levitationTargetHeight: "levitation_target_height", } as const; export const PCU = { - speed: "encoder_speed_km_h", - position: "encoder_position", - acceleration: "encoder_acceleration", + speed: "imu_speed_km_h", + position: "imu_position_m", motorCurrentU: "current_sensor_u_a", motorCurrentV: "current_sensor_v_a", motorCurrentW: "current_sensor_w_a", } as const; export const PCU_BOARD = { - generalState: "general_state_machine", - operatingState: "operational_state_machine", - peakCurrent: "current_Peak", - frequency: "frequency", + state: "state", + peakCurrent: "current_Peak", + frequency: "frequency", } as const; +/** DC link voltage (HVBMS.voltageReading) threshold above which HVAL is considered active. */ +export const HVAL_THRESHOLD_V = 60; + /** HVBMS — high-voltage battery management system. */ export const HVBMS = { - minimumSoc: "minimum_soc", soc: "soc", voltageReading: "voltage_reading", batteriesVoltage: "batteries_voltage_reading", @@ -62,7 +65,7 @@ export const HVBMS = { imdStatus: "imd_status", imdResistance: "imd_resistance", sdcStatus: "sdc_status", - operationalState: "gsm_status", + operationalState: "sm_status", contactorPrecharge: "contactor_precharge", contactorDischarge: "contactor_discharge", contactorHigh: "contactor_high", @@ -70,14 +73,19 @@ export const HVBMS = { contactorCommonHigh: "contactor_common_high", } as const; -/** Per-group indices are 1-based (1–8). Each group has 12 cells. */ +/** Per-group indices are 1-based (1–8). Each group has 12 cells and 4 temp sensors. */ export const hvbmsPack = (n: number) => ({ voltage: `battery${n}_total_voltage`, - tempMax: `battery${n}_max_temp`, - tempMin: `battery${n}_min_temp`, + temps: Array.from({ length: 4 }, (_, i) => `battery${n}_temp${i + 1}`), cells: Array.from({ length: 12 }, (_, i) => `battery${n}_cell${i + 1}`), }); +/** HVSCU-Cabinet — booster supercapacitor cabinet. */ +export const HVSCU_CABINET = { + // DC bus voltage feeding the PCU inverter, i.e. the DC link voltage. + dcLinkVoltage: "HVSCU-Cabinet_bus_voltage", +} as const; + /** LCU — levitation control unit. */ export const LCU = { // Airgaps 1–4: vertical, 5–8: lateral/horizontal diff --git a/frontend/competition-view/src/constants/orders.ts b/frontend/competition-view/src/constants/orders.ts index c07eb2390..3f6ec7771 100644 --- a/frontend/competition-view/src/constants/orders.ts +++ b/frontend/competition-view/src/constants/orders.ts @@ -1,6 +1,12 @@ /** - * Hardcoded order IDs used during competition. + * Hardcoded order IDs used during competition, sourced from the ADJ + * repository (branch: Astra). * Each ID maps to a backend command understood by the VCU/HV system. + * + * The running backend currently only has the VCU board enabled + * (see backend/cmd/config.toml `[vehicle].boards`), so these quick + * actions are scoped to VCU-native orders — PCU/HVBMS order IDs won't + * route anywhere until those boards are added back to the config. */ export interface OrderFieldValue { @@ -14,23 +20,29 @@ export interface Order { fields: Record; } -/** Engages the brakes. */ +/** Engages the brakes (VCU "Brake"). */ export const BRAKE_ORDERS: Order[] = [ - { id: 215, fields: {} }, + { id: 51, fields: {} }, ]; -/** Opens the HV contactors to cut power. */ +/** Opens the contactors to cut power (VCU "Open Contactors"). */ export const OPEN_CONTACTORS_ORDERS: Order[] = [ - { id: 902, fields: {} }, + { id: 42, fields: {} }, ]; /** * Full emergency stop sequence: - * triggers emergency brake + disables propulsion + cuts HV power. + * triggers emergency brake + brings the vehicle to a full stop + * + opens contactors to cut power + forces FAULT (all VCU orders). */ export const EMERGENCY_STOP_ORDERS: Order[] = [ - { id: 55, fields: {} }, - { id: 1799, fields: {} }, - { id: 1698, fields: {} }, - { id: 0, fields: {} }, + { id: 51, fields: {} }, + { id: 30, fields: {} }, + { id: 42, fields: {} }, + { id: 0, fields: {} }, +]; + +/** Forces a FAULT state (id 0 is shared by the FAULT order on every board). */ +export const FAULT_ORDERS: Order[] = [ + { id: 0, fields: {} }, ]; diff --git a/frontend/competition-view/src/constants/track.ts b/frontend/competition-view/src/constants/track.ts new file mode 100644 index 000000000..050e79d8b --- /dev/null +++ b/frontend/competition-view/src/constants/track.ts @@ -0,0 +1,2 @@ +/** Total physical length of the competition track, in meters. */ +export const TRACK_LENGTH_M = 48; diff --git a/frontend/competition-view/src/hooks/useKeyboardShortcuts.ts b/frontend/competition-view/src/hooks/useKeyboardShortcuts.ts index 81b7274e6..034e5b75c 100644 --- a/frontend/competition-view/src/hooks/useKeyboardShortcuts.ts +++ b/frontend/competition-view/src/hooks/useKeyboardShortcuts.ts @@ -18,6 +18,7 @@ export const SHORTCUT_DEFS: ShortcutDef[] = [ { key: "b", label: "B", description: "Brake" }, { key: "o", label: "O", description: "Open Contactors" }, { key: "e", label: "E → E", description: "Emergency Stop (press twice in 2 s)" }, + { key: "space", label: "Space", description: "Fault" }, { key: "shift+/", label: "?", description: "Toggle keyboard shortcuts reference" }, ]; @@ -27,6 +28,7 @@ interface Options { onBrake: () => void; onOpenContactors: () => void; onEmergencyStop: () => void; + onFault: () => void; onToggleHelp: () => void; } @@ -46,6 +48,7 @@ const useKeyboardShortcuts = ({ onBrake, onOpenContactors, onEmergencyStop, + onFault, onToggleHelp, }: Options): Result => { const [estopArmed, setEstopArmed] = useState(false); @@ -93,9 +96,14 @@ const useKeyboardShortcuts = ({ e.preventDefault(); onToggleHelp(); break; + + case " ": + e.preventDefault(); + onFault(); + break; } }, - [enabled, estopArmed, onBrake, onOpenContactors, armEstop, confirmEstop, onToggleHelp], + [enabled, estopArmed, onBrake, onOpenContactors, armEstop, confirmEstop, onFault, onToggleHelp], ); useEffect(() => { diff --git a/frontend/competition-view/src/layout/AppLayout.tsx b/frontend/competition-view/src/layout/AppLayout.tsx index 0469d821f..09f1167e7 100644 --- a/frontend/competition-view/src/layout/AppLayout.tsx +++ b/frontend/competition-view/src/layout/AppLayout.tsx @@ -19,7 +19,7 @@ const AppLayout = ({ children, backendConnected, onShowShortcuts }: AppLayoutPro }, [isDarkMode]); return ( -
+
diff --git a/frontend/competition-view/src/lib/stateColor.ts b/frontend/competition-view/src/lib/stateColor.ts new file mode 100644 index 000000000..b27786cd5 --- /dev/null +++ b/frontend/competition-view/src/lib/stateColor.ts @@ -0,0 +1,65 @@ +/** + * Shared colour classification for board/state telemetry strings. + * + * Board state enums aren't fixed in the ADJ (they're free-form strings sent + * by each board's firmware), so this classifies by keyword instead of an + * exhaustive list. Order matters: danger is checked first so e.g. + * "DISCONNECTED" (danger) isn't mistaken for "CONNECTED" (nominal). + */ +type StateLevel = "danger" | "caution" | "info" | "nominal" | "neutral"; + +const DANGER_KEYWORDS = [ + "FAULT", "ERROR", "EMERGENCY", "DISENGAG", "DISCONNECT", "TIMEOUT", "ABORT", +]; + +const CAUTION_KEYWORDS = [ + "CONNECTING", "PRECHARG", "DISCHARG", "CALIBRAT", "WARN", + "BRAKE", "DECELER", "ENERGIZ", "HVACTIVE", "PENDING", +]; + +/** Subsystem-specific operating modes, called out in blue rather than generic green. */ +const INFO_KEYWORDS = [ + "LEVITAT", "PROPULSION", +]; + +const NOMINAL_KEYWORDS = [ + "RUN", "NOMINAL", "ACCELERAT", "CONNECTED", + "CLOSED", "ENGAGED", "OPERATIONAL", "READY", "OK", +]; + +const stateLevel = (state: string | number | boolean | undefined): StateLevel => { + if (state === undefined) return "neutral"; + const s = String(state).toUpperCase(); + if (DANGER_KEYWORDS.some((k) => s.includes(k))) return "danger"; + if (CAUTION_KEYWORDS.some((k) => s.includes(k))) return "caution"; + if (INFO_KEYWORDS.some((k) => s.includes(k))) return "info"; + if (NOMINAL_KEYWORDS.some((k) => s.includes(k))) return "nominal"; + return "neutral"; +}; + +/** Text-only colour for a state string (e.g. inline labels). */ +export const stateTextClass = (state: string | number | boolean | undefined): string => { + switch (stateLevel(state)) { + case "danger": return "text-red-500"; + case "caution": return "text-amber-500"; + case "info": return "text-blue-500"; + case "nominal": return "text-green-500"; + case "neutral": return "text-muted-foreground"; + } +}; + +/** Same classification as {@link stateTextClass}, but as a full badge (border + tint + text). */ +export const stateBadgeClass = (state: string | number | boolean | undefined): string => { + switch (stateLevel(state)) { + case "danger": + return "border-red-500 bg-red-500/10 text-red-600 dark:text-red-400"; + case "caution": + return "border-amber-500 bg-amber-500/10 text-amber-600 dark:text-amber-400"; + case "info": + return "border-blue-500 bg-blue-500/10 text-blue-600 dark:text-blue-400"; + case "nominal": + return "border-green-500 bg-green-500/10 text-green-600 dark:text-green-400"; + case "neutral": + return "border-muted-foreground/30 text-muted-foreground"; + } +}; diff --git a/frontend/competition-view/src/pages/Batteries/components/BatteryPackCard.tsx b/frontend/competition-view/src/pages/Batteries/components/BatteryPackCard.tsx index 62e2de6ca..994fcc81b 100644 --- a/frontend/competition-view/src/pages/Batteries/components/BatteryPackCard.tsx +++ b/frontend/competition-view/src/pages/Batteries/components/BatteryPackCard.tsx @@ -1,6 +1,9 @@ import { Card, CardContent, CardHeader, CardTitle } from "@workspace/ui/components"; +import { useShallow } from "zustand/react/shallow"; +import { formatAxisValue } from "../../../constants/chartConfig"; import { BOARDS, hvbmsPack } from "../../../constants/measurements"; import useMeasurement from "../../../hooks/useMeasurement"; +import { useStore } from "../../../store/store"; interface BatteryPackCardProps { packNumber: number; @@ -11,43 +14,50 @@ const CELL_MAX = 4.2; const CELL_WARN_LOW = 3.1; const CELL_WARN_HIGH = 4.15; -const fmt = (v: number | boolean | string | undefined, decimals = 1) => - typeof v === "number" ? v.toFixed(decimals) : "—"; +// formatAxisValue keeps the label short (falls back to exponential notation) +// so a garbage/out-of-range sample never blows out a tile's fixed width. +const fmt = (v: number | boolean | string | undefined) => + typeof v === "number" ? formatAxisValue(v) : "—"; + +type CellStatus = "low" | "high" | "ok"; + +const cellStatus = (v: number | null): CellStatus => + v === null ? "ok" : v < CELL_WARN_LOW ? "low" : v > CELL_WARN_HIGH ? "high" : "ok"; /* ─── Individual cell tile ───────────────────────────────────────────────── */ -const CellTile = ({ cellNum, measurementKey }: { cellNum: number; measurementKey: string }) => { - const raw = useMeasurement(BOARDS.HVBMS, measurementKey); - const v = typeof raw === "number" ? raw : null; - const isLow = v !== null && v < CELL_WARN_LOW; - const isHigh = v !== null && v > CELL_WARN_HIGH; +const CellTile = ({ cellNum, value }: { cellNum: number; value: number | undefined }) => { + const v = typeof value === "number" ? value : null; + const status = cellStatus(v); const fill = v !== null ? Math.min(100, Math.max(0, ((v - CELL_MIN) / (CELL_MAX - CELL_MIN)) * 100)) : 0; return (
- {cellNum} -
-
-
+ Cell {cellNum} - {v !== null ? v.toFixed(2) : "—"} - V + {v !== null ? formatAxisValue(v) : "—"} + V +
+
+
); }; @@ -58,25 +68,57 @@ const BatteryPackCard = ({ packNumber }: BatteryPackCardProps) => { const keys = hvbmsPack(packNumber); const voltage = useMeasurement(BOARDS.HVBMS, keys.voltage); - const temp = useMeasurement(BOARDS.HVBMS, keys.tempMax); + const temp1 = useMeasurement(BOARDS.HVBMS, keys.temps[0]); + const temp2 = useMeasurement(BOARDS.HVBMS, keys.temps[1]); + const temp3 = useMeasurement(BOARDS.HVBMS, keys.temps[2]); + const temp4 = useMeasurement(BOARDS.HVBMS, keys.temps[3]); + const numericTemps = [temp1, temp2, temp3, temp4].filter((t): t is number => typeof t === "number"); + const tempMax = numericTemps.length > 0 ? Math.max(...numericTemps) : undefined; + + // Read every cell once here (not inside each tile) so the pack-level + // health accent and the tiles themselves share a single subscription. + const cellValues = useStore( + useShallow((s) => keys.cells.map((key) => s.telemetry[BOARDS.HVBMS]?.[key] as number | undefined)), + ); + const statuses = cellValues.map((v) => cellStatus(typeof v === "number" ? v : null)); + const packStatus: CellStatus = statuses.includes("low") ? "low" : statuses.includes("high") ? "high" : "ok"; return ( - - - Group {packNumber} - - - - {/* Summary stats */} -
- - + + + {/* Summary rail */} +
+ + Group {packNumber} + + +
+ Voltage + + {fmt(voltage)} + V + +
+ +
+ Temp max + + {fmt(tempMax)} + °C + +
- {/* Cell grid */} -
+ {/* Cell grid: 4 cols × 3 rows */} +
{keys.cells.map((key, i) => ( - + ))}
@@ -84,13 +126,4 @@ const BatteryPackCard = ({ packNumber }: BatteryPackCardProps) => { ); }; -const Stat = ({ label, value, unit }: { label: string; value: string; unit: string }) => ( -
- {label} - - {value} {unit} - -
-); - export default BatteryPackCard; diff --git a/frontend/competition-view/src/pages/Batteries/components/HvBatterySection.tsx b/frontend/competition-view/src/pages/Batteries/components/HvBatterySection.tsx index e8d11674a..ceddd5bda 100644 --- a/frontend/competition-view/src/pages/Batteries/components/HvBatterySection.tsx +++ b/frontend/competition-view/src/pages/Batteries/components/HvBatterySection.tsx @@ -1,5 +1,5 @@ import { Separator } from "@workspace/ui/components"; -import { BOARDS, HVBMS, VCU } from "../../../constants/measurements"; +import { BOARDS, HVBMS } from "../../../constants/measurements"; import useMeasurement from "../../../hooks/useMeasurement"; import BatteryPackCard from "./BatteryPackCard"; @@ -10,13 +10,17 @@ const fmt = (v: number | boolean | string | undefined, decimals = 1) => typeof v === "number" ? v.toFixed(decimals) : "—"; const HvBatterySection = () => { - const totalVoltage = useMeasurement(BOARDS.HVBMS, HVBMS.batteriesVoltage); - const voltageMax = useMeasurement(BOARDS.HVBMS, HVBMS.voltageMax); - const voltageMin = useMeasurement(BOARDS.HVBMS, HVBMS.voltageMin); - const tempMax = useMeasurement(BOARDS.HVBMS, HVBMS.tempMax); - const tempMin = useMeasurement(BOARDS.HVBMS, HVBMS.tempMin); - const soc = useMeasurement(BOARDS.HVBMS, HVBMS.minimumSoc); - const contactors = useMeasurement(BOARDS.VCU, VCU.contactorsClosed); + const totalVoltage = useMeasurement(BOARDS.HVBMS, HVBMS.batteriesVoltage); + const voltageMax = useMeasurement(BOARDS.HVBMS, HVBMS.voltageMax); + const voltageMin = useMeasurement(BOARDS.HVBMS, HVBMS.voltageMin); + const tempMax = useMeasurement(BOARDS.HVBMS, HVBMS.tempMax); + const tempMin = useMeasurement(BOARDS.HVBMS, HVBMS.tempMin); + const soc = useMeasurement(BOARDS.HVBMS, HVBMS.soc); + const contactorHigh = useMeasurement(BOARDS.HVBMS, HVBMS.contactorHigh); + const contactorLow = useMeasurement(BOARDS.HVBMS, HVBMS.contactorLow); + const contactors = contactorHigh === undefined || contactorLow === undefined + ? undefined + : contactorHigh === true && contactorLow === true; return (
@@ -41,17 +45,17 @@ const HvBatterySection = () => {
{[ { label: "Total V", value: fmt(totalVoltage), unit: "V" }, - { label: "Min SOC", value: fmt(soc, 0), unit: "%" }, + { label: "SOC", value: fmt(soc, 0), unit: "%" }, { label: "V max", value: fmt(voltageMax, 3), unit: "V" }, { label: "V min", value: fmt(voltageMin, 3), unit: "V" }, { label: "T max", value: fmt(tempMax), unit: "°C" }, { label: "T min", value: fmt(tempMin), unit: "°C" }, ].map(({ label, value, unit }) => ( -
- {label} - +
+ {label} + {value} - + {unit} @@ -61,8 +65,8 @@ const HvBatterySection = () => { - {/* Group grid: 2 rows of 4 */} -
+ {/* Group grid */} +
{PACK_NUMBERS.map((n) => ( ))} diff --git a/frontend/competition-view/src/pages/Boards/Boards.tsx b/frontend/competition-view/src/pages/Boards/Boards.tsx deleted file mode 100644 index c3468c427..000000000 --- a/frontend/competition-view/src/pages/Boards/Boards.tsx +++ /dev/null @@ -1,70 +0,0 @@ -import { BOARDS, HVBMS, LCU, PCU_BOARD, VCU } from "../../constants/measurements"; -import BoardCard from "./components/BoardCard"; -import LcuAirgapCard from "./components/LcuAirgapCard"; - -const Boards = () => ( -
-
-

Board States

- -
- - - - - - - - -
-
- - {/* LCU levitation detail */} -
-

Levitation

- -
-
-); - -export default Boards; diff --git a/frontend/competition-view/src/pages/Boards/components/BoardCard.tsx b/frontend/competition-view/src/pages/Boards/components/BoardCard.tsx deleted file mode 100644 index 9f8689c29..000000000 --- a/frontend/competition-view/src/pages/Boards/components/BoardCard.tsx +++ /dev/null @@ -1,89 +0,0 @@ -import { - Badge, - Card, - CardContent, - CardHeader, - CardTitle, -} from "@workspace/ui/components"; -import useMeasurement from "../../../hooks/useMeasurement"; - -interface Stat { - label: string; - measurementKey: string; - unit?: string; - decimals?: number; -} - -interface BoardCardProps { - /** Board name used to scope the telemetry lookup (must match backend). */ - board: string; - /** Display name shown in the card header. */ - name: string; - /** Measurement key for the board's general/operational state string. */ - stateMeasurementKey: string; - /** Additional stats shown in a compact grid below the state. */ - stats?: Stat[]; -} - -/** - * Generic board status card. - * Shows a state badge and an optional grid of secondary measurements. - */ -const BoardCard = ({ board, name, stateMeasurementKey, stats = [] }: BoardCardProps) => { - const state = useMeasurement(board, stateMeasurementKey); - const hasData = state !== undefined; - - return ( - - - - {name} - - {hasData ? String(state) : "—"} - - - - - {stats.length > 0 && ( - -
- {stats.map((s) => ( - - ))} -
-
- )} -
- ); -}; - -const StatRow = ({ board, stat }: { board: string; stat: Stat }) => { - const raw = useMeasurement(board, stat.measurementKey); - const display = - typeof raw === "number" - ? raw.toFixed(stat.decimals ?? 1) - : raw !== undefined - ? String(raw) - : "—"; - - return ( -
- {stat.label} - - {display} - {raw !== undefined && stat.unit && ( - {stat.unit} - )} - -
- ); -}; - -export default BoardCard; diff --git a/frontend/competition-view/src/pages/Boards/components/LcuAirgapCard.tsx b/frontend/competition-view/src/pages/Boards/components/LcuAirgapCard.tsx deleted file mode 100644 index e1828d0cc..000000000 --- a/frontend/competition-view/src/pages/Boards/components/LcuAirgapCard.tsx +++ /dev/null @@ -1,128 +0,0 @@ -import React from "react"; -import { - Card, - CardContent, - CardHeader, - CardTitle, -} from "@workspace/ui/components"; -import { BOARDS, LCU } from "../../../constants/measurements"; -import useMeasurement from "../../../hooks/useMeasurement"; - -/** Airgap warning threshold in mm. */ -const AIRGAP_WARN_MM = 5; - -const fmt = (v: number | boolean | string | undefined, decimals = 1) => - typeof v === "number" ? v.toFixed(decimals) : "—"; - -/* ─── Shared row component ─────────────────────────────────────────────── */ - -interface MeasurementRowProps { - label: string; - measurementKey: string; - unit: string; - decimals?: number; - warnBelow?: number; -} - -const MeasurementRow = ({ - label, - measurementKey, - unit, - decimals = 1, - warnBelow, -}: MeasurementRowProps) => { - const value = useMeasurement(BOARDS.LCU, measurementKey); - const isWarning = - warnBelow !== undefined && - typeof value === "number" && - value < warnBelow; - - return ( -
- {label} - - {fmt(value, decimals)} - {unit} - -
- ); -}; - -const SectionLabel = ({ children }: { children: React.ReactNode }) => ( -

- {children} -

-); - -/* ─── Airgap sections ──────────────────────────────────────────────────── */ - -const VerticalAirgapsSection = () => ( -
- Vertical Airgaps -
- - - - -
-
-); - -const HorizontalAirgapsSection = () => ( -
- Lateral Airgaps -
- - - - -
-
-); - -const HemsCurrentsSection = () => ( -
- HEMS Currents -
- - - - -
-
-); - -const EmsCurrentsSection = () => ( -
- EMS Currents -
- - - - - - -
-
-); - -/* ─── Main card ─────────────────────────────────────────────────────────── */ - -const LcuAirgapCard = () => ( - - - LCU — Levitation - - - -
- - - - -
-
-
-); - -export default LcuAirgapCard; diff --git a/frontend/competition-view/src/pages/Booster/Booster.tsx b/frontend/competition-view/src/pages/Booster/Booster.tsx index 755c93138..2fdc737c7 100644 --- a/frontend/competition-view/src/pages/Booster/Booster.tsx +++ b/frontend/competition-view/src/pages/Booster/Booster.tsx @@ -1,4 +1,4 @@ -import { BOARDS, HVBMS, VCU } from "../../constants/measurements"; +import { BOARDS, HVBMS, HVSCU_CABINET, VCU } from "../../constants/measurements"; import useMeasurement from "../../hooks/useMeasurement"; /* ─── Helpers ───────────────────────────────────────────────────────────── */ @@ -54,19 +54,44 @@ const EnumRow = ({ label, board, measurementKey, goodValues = [] }: { ); }; +/* ─── Value row ──────────────────────────────────────────────────────────── */ + +const ValueRow = ({ label, board, measurementKey, unit, digits = 1 }: { + label: string; board: string; measurementKey: string; unit: string; digits?: number +}) => { + const v = useMeasurement(board, measurementKey); + const text = typeof v === "number" ? v.toFixed(digits) : "—"; + + return ( +
+ {label} + + {text} {unit} + +
+ ); +}; + /* ─── Booster page ───────────────────────────────────────────────────────── */ const Booster = () => (
- {/* ── VCU Subsystem States ──────────────────────────────────────────── */} + {/* ── VCU Subsystem Connectivity ────────────────────────────────────── */} +
+

VCU — Subsystem Connectivity

+
+ + + +
+
+ + {/* ── HVSCU Cabinet ────────────────────────────────────────────────── */}
-

VCU — Subsystem States

-
- - - - +

HVSCU Cabinet

+
+
@@ -76,7 +101,6 @@ const Booster = () => (
- diff --git a/frontend/competition-view/src/pages/Charts/Charts.tsx b/frontend/competition-view/src/pages/Charts/Charts.tsx index 2e44fb0b7..59ea1149f 100644 --- a/frontend/competition-view/src/pages/Charts/Charts.tsx +++ b/frontend/competition-view/src/pages/Charts/Charts.tsx @@ -56,7 +56,7 @@ const Charts = () => ( {/* Row 2 — Electrical */} - + {/* Row 3 — DLIM motor currents */} diff --git a/frontend/competition-view/src/pages/Charts/components/MultiSeriesChart.tsx b/frontend/competition-view/src/pages/Charts/components/MultiSeriesChart.tsx index f0d334c86..641e667a4 100644 --- a/frontend/competition-view/src/pages/Charts/components/MultiSeriesChart.tsx +++ b/frontend/competition-view/src/pages/Charts/components/MultiSeriesChart.tsx @@ -3,11 +3,14 @@ import uPlot from "uplot"; import "uplot/dist/uPlot.min.css"; import { useShallow } from "zustand/react/shallow"; import { + CHART_AXIS_INCRS, CHART_COLORS, CHART_HEIGHT, CHART_LINE_WIDTH, CHART_MAX_POINTS, CHART_POINT_SIZE, + CHART_WINDOW_SECONDS, + formatAxisValue, } from "../../../constants/chartConfig"; import { useStore } from "../../../store/store"; @@ -35,8 +38,12 @@ interface MultiSeriesChartProps { * Series configs must be a stable reference (defined at module level or * memoized) so the Zustand selector and uPlot init only run once. * + * The x-axis is wall-clock time (seconds) and the visible range is + * pinned to a rolling window ending at the latest sample, so the chart + * keeps advancing even under bursty, high-frequency packet rates. + * * A compact colour-dot legend is rendered in the card header. - * Double-click resets the zoom. + * Double-click resets a manual zoom. */ const MultiSeriesChart = memo(({ title, series, unit = "" }: MultiSeriesChartProps) => { const wrapperRef = useRef(null); // flex-1 div sized by CSS layout @@ -44,7 +51,7 @@ const MultiSeriesChart = memo(({ title, series, unit = "" }: MultiSeriesChartPro const uplotRef = useRef(null); const xRef = useRef([]); const yRefs = useRef(series.map(() => [])); - const counterRef = useRef(0); + const startRef = useRef(performance.now()); // Subscribe to all series values at once; useShallow prevents re-renders // when the values haven't actually changed. @@ -81,7 +88,13 @@ const MultiSeriesChart = memo(({ title, series, unit = "" }: MultiSeriesChartPro legend: { show: false }, padding: [16, 8, 4, 12], scales: { - x: { time: false }, + x: { + time: false, + range: (_, __, dataMax) => + dataMax == null + ? [0, CHART_WINDOW_SECONDS] + : [dataMax - CHART_WINDOW_SECONDS, dataMax], + }, y: { range: (_, min, max) => { if (min === max) return [min - 1, max + 1]; @@ -97,15 +110,17 @@ const MultiSeriesChart = memo(({ title, series, unit = "" }: MultiSeriesChartPro stroke: getVar("--muted-foreground"), grid: { show: false }, font: "10px Archivo", - size: 20, + size: 24, + values: (_, ticks) => ticks.map(formatAxisValue), }, { side: 1, stroke: getVar("--muted-foreground"), grid: { stroke: getVar("--border") }, font: "10px Archivo", - size: unit ? 48 : 36, - label: unit, + size: 36, + incrs: CHART_AXIS_INCRS, + values: (_, ticks) => ticks.map(formatAxisValue), }, ], cursor: { drag: { setScale: true, x: true, y: true } }, @@ -137,7 +152,7 @@ const MultiSeriesChart = memo(({ title, series, unit = "" }: MultiSeriesChartPro // arrive in the same telemetry packet so this is normally always true). if (!values.every((v) => typeof v === "number")) return; - xRef.current.push(counterRef.current++); + xRef.current.push((performance.now() - startRef.current) / 1000); (values as number[]).forEach((v, i) => { yRefs.current[i].push(v); }); diff --git a/frontend/competition-view/src/pages/Charts/components/TelemetryChart.tsx b/frontend/competition-view/src/pages/Charts/components/TelemetryChart.tsx index ce757c196..31b314eee 100644 --- a/frontend/competition-view/src/pages/Charts/components/TelemetryChart.tsx +++ b/frontend/competition-view/src/pages/Charts/components/TelemetryChart.tsx @@ -2,11 +2,14 @@ import { memo, useEffect, useRef } from "react"; import uPlot from "uplot"; import "uplot/dist/uPlot.min.css"; import { + CHART_AXIS_INCRS, CHART_COLORS, CHART_HEIGHT, CHART_LINE_WIDTH, CHART_MAX_POINTS, CHART_POINT_SIZE, + CHART_WINDOW_SECONDS, + formatAxisValue, } from "../../../constants/chartConfig"; import useMeasurement from "../../../hooks/useMeasurement"; @@ -27,8 +30,10 @@ interface TelemetryChartProps { * Fixed single-series real-time chart for competition telemetry. * * History is accumulated in a local ref (no store involvement) so the - * component stays lightweight. The x-axis is a monotonic counter driven - * by incoming telemetry packets. Double-click resets the zoom. + * component stays lightweight. The x-axis is wall-clock time (seconds) + * and the visible range is pinned to a rolling window ending at the + * latest sample, so the chart keeps advancing even under bursty, + * high-frequency packet rates. Double-click resets a manual zoom. */ const TelemetryChart = memo(({ title, @@ -42,7 +47,7 @@ const TelemetryChart = memo(({ const uplotRef = useRef(null); const xRef = useRef([]); const yRef = useRef([]); - const counterRef = useRef(0); + const startRef = useRef(performance.now()); const value = useMeasurement(board, measurementKey); const color = CHART_COLORS[colorIndex % CHART_COLORS.length]; @@ -60,7 +65,13 @@ const TelemetryChart = memo(({ legend: { show: false }, padding: [16, 8, 4, 12], scales: { - x: { time: false }, + x: { + time: false, + range: (_, __, dataMax) => + dataMax == null + ? [0, CHART_WINDOW_SECONDS] + : [dataMax - CHART_WINDOW_SECONDS, dataMax], + }, y: { range: (_, min, max) => { if (min === max) return [min - 1, max + 1]; @@ -84,15 +95,17 @@ const TelemetryChart = memo(({ stroke: getVar("--muted-foreground"), grid: { show: false }, font: "10px Archivo", - size: 20, + size: 24, + values: (_, ticks) => ticks.map(formatAxisValue), }, { side: 1, stroke: getVar("--muted-foreground"), grid: { stroke: getVar("--border") }, font: "10px Archivo", - size: unit ? 48 : 36, - label: unit, + size: 36, + incrs: CHART_AXIS_INCRS, + values: (_, ticks) => ticks.map(formatAxisValue), }, ], cursor: { drag: { setScale: true, x: true, y: true } }, @@ -116,7 +129,7 @@ const TelemetryChart = memo(({ useEffect(() => { if (typeof value !== "number" || !uplotRef.current) return; - xRef.current.push(counterRef.current++); + xRef.current.push((performance.now() - startRef.current) / 1000); yRef.current.push(value); if (xRef.current.length > CHART_MAX_POINTS) { diff --git a/frontend/competition-view/src/pages/Overview/Overview.tsx b/frontend/competition-view/src/pages/Overview/Overview.tsx index 55b18f4b1..2f511b7df 100644 --- a/frontend/competition-view/src/pages/Overview/Overview.tsx +++ b/frontend/competition-view/src/pages/Overview/Overview.tsx @@ -6,17 +6,17 @@ import { HVBMS, LCU, PCU, - PCU_BOARD, VCU, } from "../../constants/measurements"; import useMeasurement from "../../hooks/useMeasurement"; import { useStore } from "../../store/store"; import type { MessageKind } from "../../types/message"; -import BoardCard from "../Boards/components/BoardCard"; import MultiSeriesChart, { type SeriesConfig } from "../Charts/components/MultiSeriesChart"; import TelemetryChart from "../Charts/components/TelemetryChart"; import MessageItem from "../Messages/components/MessageItem"; +import BoardsOverviewCard from "./components/BoardsOverviewCard"; import OrdersPanel from "./components/OrdersPanel"; +import TrackProgress from "./components/TrackProgress"; /* ─── Stable series configs ─────────────────────────────────────────────── */ @@ -78,15 +78,13 @@ interface BatteryCardProps { const BatteryCard = ({ title, soc, rows }: BatteryCardProps) => { const socPct = typeof soc === "number" ? Math.min(100, Math.max(0, soc)) : 0; - const barColor = typeof soc !== "number" - ? "bg-muted-foreground/20" - : soc < 20 ? "bg-red-500" : soc < 50 ? "bg-yellow-500" : "bg-green-500"; + const barColor = typeof soc !== "number" ? "bg-muted-foreground/20" : "bg-yellow-500"; return (
{title} - + {typeof soc === "number" ? soc.toFixed(0) : "—"} % @@ -100,10 +98,10 @@ const BatteryCard = ({ title, soc, rows }: BatteryCardProps) => { {rows.map(({ label, value, unit, warn }) => (
{label} - + {value ?? "—"} {value !== undefined && unit && ( - {unit} + {unit} )}
@@ -116,15 +114,13 @@ const BatteryCard = ({ title, soc, rows }: BatteryCardProps) => { /* ─── Kinematics card ───────────────────────────────────────────────────── */ const KinematicsCard = () => { - const speed = useMeasurement(BOARDS.PCU, PCU.speed); - const position = useMeasurement(BOARDS.PCU, PCU.position); - const acceleration = useMeasurement(BOARDS.PCU, PCU.acceleration); - const highPsi = useMeasurement(BOARDS.VCU, VCU.highPressure); - const lowPsi = useMeasurement(BOARDS.VCU, VCU.lowPressure); + const speed = useMeasurement(BOARDS.PCU, PCU.speed); + const position = useMeasurement(BOARDS.PCU, PCU.position); + const highPsi = useMeasurement(BOARDS.VCU, VCU.highPressure); + const lowPsi = useMeasurement(BOARDS.VCU, VCU.lowPressure); const rows = [ { label: "Position", value: fmtNum(position), unit: "m" }, - { label: "Acceleration", value: fmtNum(acceleration, 2), unit: "m/s²" }, { label: "High pres.", value: fmtNum(highPsi), unit: "bar" }, { label: "Low pres.", value: fmtNum(lowPsi), unit: "bar" }, ]; @@ -133,17 +129,17 @@ const KinematicsCard = () => {
Kinematics
- {fmtNum(speed, 0) ?? "—"} + {fmtNum(speed, 0) ?? "—"} km/h
{rows.map(({ label, value, unit }) => (
{label} - + {value ?? "—"} {value !== undefined && unit && ( - {unit} + {unit} )}
@@ -158,8 +154,12 @@ const KinematicsCard = () => { interface SafeRow { label: string; text: string; color: string } const SafetyCard = () => { - const sdcStatus = useMeasurement(BOARDS.HVBMS, HVBMS.sdcStatus); - const contactors = useMeasurement(BOARDS.VCU, VCU.contactorsClosed); + const sdcStatus = useMeasurement(BOARDS.HVBMS, HVBMS.sdcStatus); + const contactorHigh = useMeasurement(BOARDS.HVBMS, HVBMS.contactorHigh); + const contactorLow = useMeasurement(BOARDS.HVBMS, HVBMS.contactorLow); + const contactors = contactorHigh === undefined || contactorLow === undefined + ? undefined + : contactorHigh === true && contactorLow === true; const imd = useMeasurement(BOARDS.HVBMS, HVBMS.imdOk); const hvBmsState = useMeasurement(BOARDS.HVBMS, HVBMS.operationalState); @@ -193,7 +193,7 @@ const SafetyCard = () => { {rows.map(({ label, text, color }) => (
{label} - {text} + {text}
))}
@@ -274,92 +274,58 @@ const MessagesPanel = () => { /* ─── Dashboard ─────────────────────────────────────────────────────────── */ const Dashboard = () => { - const hvSoc = useMeasurement(BOARDS.HVBMS, HVBMS.minimumSoc); + const hvSoc = useMeasurement(BOARDS.HVBMS, HVBMS.soc); const hvVoltage = useMeasurement(BOARDS.HVBMS, HVBMS.batteriesVoltage); const hvCurrent = useMeasurement(BOARDS.HVBMS, HVBMS.currentReading); const hvVSensor = useMeasurement(BOARDS.HVBMS, HVBMS.voltageReading); return ( -
- - {/* ── Left column ──────────────────────────────────────────────── */} -
- - {/* Summary cards row */} -
- - - -
+
+ + + +
+ + {/* ── Left column ────────────────────────────────────────────── */} +
+ + {/* Summary cards row */} +
+ + + +
+ + {/* Charts — 2 cols × 3 rows */} +
+ + + + + + +
- {/* Charts — 2 cols × 3 rows */} -
- - - - - -
-
+ {/* ── Right column ───────────────────────────────────────────── */} +
- {/* ── Right column ─────────────────────────────────────────────── */} -
- -
- - - - -
+ - + - + + +
diff --git a/frontend/competition-view/src/pages/Overview/components/BoardsOverviewCard.tsx b/frontend/competition-view/src/pages/Overview/components/BoardsOverviewCard.tsx new file mode 100644 index 000000000..9a69d58ea --- /dev/null +++ b/frontend/competition-view/src/pages/Overview/components/BoardsOverviewCard.tsx @@ -0,0 +1,125 @@ +import { Card, CardContent, CardHeader, CardTitle } from "@workspace/ui/components"; +import { BOARDS, HVBMS, LCU, PCU_BOARD, VCU } from "../../../constants/measurements"; +import useMeasurement from "../../../hooks/useMeasurement"; +import { stateBadgeClass } from "../../../lib/stateColor"; + +interface Stat { + label: string; + measurementKey: string; + unit?: string; + decimals?: number; + /** Labels for [true, false] — makes boolean measurements read as words instead of "true"/"false". */ + boolLabels?: [string, string]; +} + +interface BoardRow { + board: string; + name: string; + stateMeasurementKey: string; + stats: Stat[]; +} + +const ROWS: BoardRow[] = [ + { + board: BOARDS.VCU, + name: "VCU", + stateMeasurementKey: VCU.state, + stats: [ + { label: "High pres.", measurementKey: VCU.highPressure, unit: "bar" }, + { label: "SDC", measurementKey: VCU.sdcClosed, boolLabels: ["Closed", "Open"] }, + { label: "Brakes", measurementKey: VCU.activeBrakes, boolLabels: ["Braked", "Unbraked"] }, + ], + }, + { + board: BOARDS.HVBMS, + name: "HVBMS", + stateMeasurementKey: HVBMS.operationalState, + stats: [ + { label: "SOC", measurementKey: HVBMS.soc, unit: "%" }, + { label: "Pack V", measurementKey: HVBMS.batteriesVoltage, unit: "V" }, + { label: "Current", measurementKey: HVBMS.currentReading, unit: "A" }, + ], + }, + { + board: BOARDS.PCU, + name: "PCU", + stateMeasurementKey: PCU_BOARD.state, + stats: [ + { label: "Peak I", measurementKey: PCU_BOARD.peakCurrent, unit: "A" }, + { label: "Freq", measurementKey: PCU_BOARD.frequency, unit: "Hz" }, + ], + }, + { + board: BOARDS.LCU, + name: "LCU", + stateMeasurementKey: LCU.masterState, + stats: [ + { label: "Slave SM", measurementKey: LCU.slaveState }, + ], + }, +]; + +const StatItem = ({ board, stat }: { board: string; stat: Stat }) => { + const raw = useMeasurement(board, stat.measurementKey); + const display = + typeof raw === "number" + ? raw.toFixed(stat.decimals ?? 1) + : typeof raw === "boolean" && stat.boolLabels + ? stat.boolLabels[raw ? 0 : 1] + : raw !== undefined + ? String(raw) + : "—"; + + return ( +
+ {stat.label}: + + {display} + {raw !== undefined && stat.unit && ( + {stat.unit} + )} + +
+ ); +}; + +const BoardRowItem = ({ row }: { row: BoardRow }) => { + const state = useMeasurement(row.board, row.stateMeasurementKey); + + return ( +
+ + {row.name} + + + {/* Fixed-width slot so a longer/shorter state string doesn't shift the stats after it. */} +
+ + {state !== undefined ? String(state) : "—"} + +
+ +
+ {row.stats.map((stat) => ( + + ))} +
+
+ ); +}; + +/** Single consolidated card showing every board's state and key measurements. */ +const BoardsOverviewCard = () => ( + + + Board States + + + {ROWS.map((row) => ( + + ))} + + +); + +export default BoardsOverviewCard; diff --git a/frontend/competition-view/src/pages/Overview/components/OrdersPanel.tsx b/frontend/competition-view/src/pages/Overview/components/OrdersPanel.tsx index 72e8a778d..482b11da3 100644 --- a/frontend/competition-view/src/pages/Overview/components/OrdersPanel.tsx +++ b/frontend/competition-view/src/pages/Overview/components/OrdersPanel.tsx @@ -14,7 +14,7 @@ const OrdersPanel = () => { const { loading } = useOrdersCatalog(isConnected); return ( -
+
Orders diff --git a/frontend/competition-view/src/pages/Overview/components/TrackProgress.tsx b/frontend/competition-view/src/pages/Overview/components/TrackProgress.tsx new file mode 100644 index 000000000..b61275141 --- /dev/null +++ b/frontend/competition-view/src/pages/Overview/components/TrackProgress.tsx @@ -0,0 +1,72 @@ +import { Card, CardContent } from "@workspace/ui/components"; +import { useEffect, useRef, useState } from "react"; +import podIcon from "../../../assets/pod.svg"; +import { BOARDS, PCU } from "../../../constants/measurements"; +import { TRACK_LENGTH_M } from "../../../constants/track"; +import useMeasurement from "../../../hooks/useMeasurement"; + +/** Number of gaps between distance labels below the track (5 labels total). */ +const TICK_COUNT = 4; +const TICKS = Array.from({ length: TICK_COUNT + 1 }, (_, i) => (TRACK_LENGTH_M / TICK_COUNT) * i); + +/** Rendered pod icon width in px (h-14 = 56px tall, at the SVG's ~1.76:1 aspect ratio). */ +const ICON_WIDTH_PX = 99; + +/** Track-position visualizer: the pod icon slides along an empty bordered track. */ +const TrackProgress = () => { + const position = useMeasurement(BOARDS.PCU, PCU.position) as number | undefined; + const clamped = typeof position === "number" ? Math.min(TRACK_LENGTH_M, Math.max(0, position)) : 0; + const pct = (clamped / TRACK_LENGTH_M) * 100; + + const trackRef = useRef(null); + const [trackWidth, setTrackWidth] = useState(0); + + useEffect(() => { + if (!trackRef.current) return; + const observer = new ResizeObserver((entries) => { + for (const entry of entries) setTrackWidth(entry.contentRect.width); + }); + observer.observe(trackRef.current); + return () => observer.disconnect(); + }, []); + + // Keep the icon's own edges within the track by only sliding its center + // across the range [iconWidth/2, trackWidth - iconWidth/2]. + const availableWidth = Math.max(trackWidth - ICON_WIDTH_PX, 0); + const podCenterPx = ICON_WIDTH_PX / 2 + (pct / 100) * availableWidth; + + return ( + + +
+ + Track Position + + + {typeof position === "number" ? position.toFixed(1) : "—"} + / {TRACK_LENGTH_M} m + +
+ +
+ Pod +
+ +
+ {TICKS.map((t) => ( + + {t.toFixed(0)} m + + ))} +
+
+
+ ); +}; + +export default TrackProgress; diff --git a/frontend/competition-view/src/pages/Overview/components/VehicleStateBanner.tsx b/frontend/competition-view/src/pages/Overview/components/VehicleStateBanner.tsx index 9d85d716c..1e02fa5e5 100644 --- a/frontend/competition-view/src/pages/Overview/components/VehicleStateBanner.tsx +++ b/frontend/competition-view/src/pages/Overview/components/VehicleStateBanner.tsx @@ -51,14 +51,14 @@ interface VehicleStateBannerProps { } /** - * Banner showing the vehicle's general and operational states. + * Banner showing the vehicle's state and active-brakes status. * Background and text colour change based on the detected state category. */ const VehicleStateBanner = ({ compact = false }: VehicleStateBannerProps) => { - const generalState = useMeasurement(BOARDS.VCU, VCU.generalState); - const operationalState = useMeasurement(BOARDS.VCU, VCU.operationalState); + const state = useMeasurement(BOARDS.VCU, VCU.state); + const activeBrakes = useMeasurement(BOARDS.VCU, VCU.activeBrakes); - const category = categorise(generalState); + const category = categorise(state); const { banner, valueText, badgeClass } = STATE_STYLES[category]; return ( @@ -68,19 +68,19 @@ const VehicleStateBanner = ({ compact = false }: VehicleStateBannerProps) => { Vehicle State
- {generalState !== undefined ? String(generalState) : "—"} + {state !== undefined ? String(state) : "—"}
- Operational State + Brakes - {operationalState !== undefined ? String(operationalState) : "—"} + {activeBrakes === undefined ? "—" : activeBrakes ? "BRAKED" : "UNBRAKED"}
From 687daccddcaff5639e4938f835225e25e8657416 Mon Sep 17 00:00:00 2001 From: Javier Ribal del Rio Date: Tue, 14 Jul 2026 02:08:14 +0200 Subject: [PATCH 08/29] feat: changes --- .../src/constants/chartConfig.ts | 7 ++ .../competition-view/src/layout/AppLayout.tsx | 2 +- .../competition-view/src/pages/Boards.tsx | 1 - .../Charts/components/MultiSeriesChart.tsx | 94 +++++++++++++------ .../Charts/components/TelemetryChart.tsx | 73 +++++++++++--- .../src/pages/Overview/Overview.tsx | 70 +++++++++----- .../components/BoardsOverviewCard.tsx | 47 +++++----- .../Overview/components/TrackProgress.tsx | 49 +++++----- .../src/store/slices/telemetrySlice.ts | 8 +- 9 files changed, 231 insertions(+), 120 deletions(-) delete mode 100644 frontend/competition-view/src/pages/Boards.tsx diff --git a/frontend/competition-view/src/constants/chartConfig.ts b/frontend/competition-view/src/constants/chartConfig.ts index 7c76c50b3..e54490a57 100644 --- a/frontend/competition-view/src/constants/chartConfig.ts +++ b/frontend/competition-view/src/constants/chartConfig.ts @@ -1,6 +1,13 @@ /** Maximum number of data points kept per chart series (memory safety cap). */ export const CHART_MAX_POINTS = 2000; +/** + * Extra points allowed past CHART_MAX_POINTS before a series is trimmed. + * Trimming reallocates every series array, so doing it in batches keeps + * the per-update cost allocation-free once the cap is reached. + */ +export const CHART_TRIM_SLACK = 500; + /** Width, in seconds, of the rolling time window shown by real-time charts. */ export const CHART_WINDOW_SECONDS = 10; diff --git a/frontend/competition-view/src/layout/AppLayout.tsx b/frontend/competition-view/src/layout/AppLayout.tsx index 09f1167e7..0469d821f 100644 --- a/frontend/competition-view/src/layout/AppLayout.tsx +++ b/frontend/competition-view/src/layout/AppLayout.tsx @@ -19,7 +19,7 @@ const AppLayout = ({ children, backendConnected, onShowShortcuts }: AppLayoutPro }, [isDarkMode]); return ( -
+
diff --git a/frontend/competition-view/src/pages/Boards.tsx b/frontend/competition-view/src/pages/Boards.tsx deleted file mode 100644 index 6c6085f94..000000000 --- a/frontend/competition-view/src/pages/Boards.tsx +++ /dev/null @@ -1 +0,0 @@ -export { default } from "./Boards/Boards"; diff --git a/frontend/competition-view/src/pages/Charts/components/MultiSeriesChart.tsx b/frontend/competition-view/src/pages/Charts/components/MultiSeriesChart.tsx index 641e667a4..eba7a2907 100644 --- a/frontend/competition-view/src/pages/Charts/components/MultiSeriesChart.tsx +++ b/frontend/competition-view/src/pages/Charts/components/MultiSeriesChart.tsx @@ -1,7 +1,6 @@ import { memo, useEffect, useRef } from "react"; import uPlot from "uplot"; import "uplot/dist/uPlot.min.css"; -import { useShallow } from "zustand/react/shallow"; import { CHART_AXIS_INCRS, CHART_COLORS, @@ -9,6 +8,7 @@ import { CHART_LINE_WIDTH, CHART_MAX_POINTS, CHART_POINT_SIZE, + CHART_TRIM_SLACK, CHART_WINDOW_SECONDS, formatAxisValue, } from "../../../constants/chartConfig"; @@ -42,6 +42,10 @@ interface MultiSeriesChartProps { * pinned to a rolling window ending at the latest sample, so the chart * keeps advancing even under bursty, high-frequency packet rates. * + * Telemetry is consumed through a transient store subscription that feeds + * uPlot directly, so data updates never re-render the React component. + * Redraws are batched to animation frames. + * * A compact colour-dot legend is rendered in the card header. * Double-click resets a manual zoom. */ @@ -53,15 +57,6 @@ const MultiSeriesChart = memo(({ title, series, unit = "" }: MultiSeriesChartPro const yRefs = useRef(series.map(() => [])); const startRef = useRef(performance.now()); - // Subscribe to all series values at once; useShallow prevents re-renders - // when the values haven't actually changed. - const values = useStore( - // The selector is stable because `series` is a module-level constant. - useShallow((s) => - series.map(({ board, measurementKey }) => s.telemetry[board]?.[measurementKey] as number | undefined), - ), - ); - // ── Initialise uPlot ──────────────────────────────────────────────────── useEffect(() => { if (!wrapperRef.current || !containerRef.current) return; @@ -105,9 +100,11 @@ const MultiSeriesChart = memo(({ title, series, unit = "" }: MultiSeriesChartPro }, }, series: uplotSeries, + // Stroke callbacks re-read the CSS variables on every draw so the + // axes/grid follow light/dark theme switches (see redraw observer below). axes: [ { - stroke: getVar("--muted-foreground"), + stroke: () => getVar("--muted-foreground"), grid: { show: false }, font: "10px Archivo", size: 24, @@ -115,8 +112,8 @@ const MultiSeriesChart = memo(({ title, series, unit = "" }: MultiSeriesChartPro }, { side: 1, - stroke: getVar("--muted-foreground"), - grid: { stroke: getVar("--border") }, + stroke: () => getVar("--muted-foreground"), + grid: { stroke: () => getVar("--border") }, font: "10px Archivo", size: 36, incrs: CHART_AXIS_INCRS, @@ -145,25 +142,55 @@ const MultiSeriesChart = memo(({ title, series, unit = "" }: MultiSeriesChartPro // eslint-disable-next-line react-hooks/exhaustive-deps }, []); - // ── Feed new data points ───────────────────────────────────────────────── + // ── Feed new data points (transient subscription, no React re-renders) ── useEffect(() => { - if (!uplotRef.current) return; - // Only push a point when every series has a numeric value (all phases - // arrive in the same telemetry packet so this is normally always true). - if (!values.every((v) => typeof v === "number")) return; - - xRef.current.push((performance.now() - startRef.current) / 1000); - (values as number[]).forEach((v, i) => { - yRefs.current[i].push(v); - }); + let rafId = 0; + let lastValues: (number | boolean | string | undefined)[] | null = null; + + // Coalesce redraws to one per animation frame (and none while hidden). + const flush = () => { + rafId = 0; + uplotRef.current?.setData([xRef.current, ...yRefs.current]); + }; + + const ingest = (telemetry: ReturnType["telemetry"]) => { + const values = series.map(({ board, measurementKey }) => telemetry[board]?.[measurementKey]); - if (xRef.current.length > CHART_MAX_POINTS) { - xRef.current = xRef.current.slice(-CHART_MAX_POINTS); - yRefs.current = yRefs.current.map((y) => y.slice(-CHART_MAX_POINTS)); - } + // Skip when nothing this chart plots has changed. + if (lastValues && values.every((v, i) => v === lastValues![i])) return; + lastValues = values; - uplotRef.current.setData([xRef.current, ...yRefs.current]); - }, [values]); + // Only push a point when every series has a numeric value (all phases + // arrive in the same telemetry packet so this is normally always true). + if (!values.every((v) => typeof v === "number")) return; + + xRef.current.push((performance.now() - startRef.current) / 1000); + (values as number[]).forEach((v, i) => { + yRefs.current[i].push(v); + }); + + // Trim with slack so the slice allocation is amortised instead of + // happening on every single update once the cap is reached. + if (xRef.current.length > CHART_MAX_POINTS + CHART_TRIM_SLACK) { + xRef.current = xRef.current.slice(-CHART_MAX_POINTS); + yRefs.current = yRefs.current.map((y) => y.slice(-CHART_MAX_POINTS)); + } + + if (!rafId) rafId = requestAnimationFrame(flush); + }; + + ingest(useStore.getState().telemetry); + const unsubscribe = useStore.subscribe((state, prevState) => { + if (state.telemetry !== prevState.telemetry) ingest(state.telemetry); + }); + + return () => { + unsubscribe(); + if (rafId) cancelAnimationFrame(rafId); + }; + // Intentionally runs once on mount — series config is stable. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); // ── Resize to wrapper ─────────────────────────────────────────────────── useEffect(() => { @@ -180,6 +207,15 @@ const MultiSeriesChart = memo(({ title, series, unit = "" }: MultiSeriesChartPro return () => observer.disconnect(); }, []); + // ── Repaint on theme switch ───────────────────────────────────────────── + // AppLayout toggles the `dark` class on ; redrawing re-runs the + // axis/grid stroke callbacks so the chart picks up the new theme colours. + useEffect(() => { + const observer = new MutationObserver(() => uplotRef.current?.redraw()); + observer.observe(document.documentElement, { attributes: true, attributeFilter: ["class"] }); + return () => observer.disconnect(); + }, []); + return (
diff --git a/frontend/competition-view/src/pages/Charts/components/TelemetryChart.tsx b/frontend/competition-view/src/pages/Charts/components/TelemetryChart.tsx index 31b314eee..34fe96abd 100644 --- a/frontend/competition-view/src/pages/Charts/components/TelemetryChart.tsx +++ b/frontend/competition-view/src/pages/Charts/components/TelemetryChart.tsx @@ -8,10 +8,11 @@ import { CHART_LINE_WIDTH, CHART_MAX_POINTS, CHART_POINT_SIZE, + CHART_TRIM_SLACK, CHART_WINDOW_SECONDS, formatAxisValue, } from "../../../constants/chartConfig"; -import useMeasurement from "../../../hooks/useMeasurement"; +import { useStore } from "../../../store/store"; interface TelemetryChartProps { /** Human-readable label shown in the card header. */ @@ -34,6 +35,10 @@ interface TelemetryChartProps { * and the visible range is pinned to a rolling window ending at the * latest sample, so the chart keeps advancing even under bursty, * high-frequency packet rates. Double-click resets a manual zoom. + * + * Telemetry is consumed through a transient store subscription that feeds + * uPlot directly, so data updates never re-render the React component. + * Redraws are batched to animation frames. */ const TelemetryChart = memo(({ title, @@ -49,7 +54,6 @@ const TelemetryChart = memo(({ const yRef = useRef([]); const startRef = useRef(performance.now()); - const value = useMeasurement(board, measurementKey); const color = CHART_COLORS[colorIndex % CHART_COLORS.length]; // ── Initialise uplot ──────────────────────────────────────────────────── @@ -90,9 +94,11 @@ const TelemetryChart = memo(({ points: { show: true, size: CHART_POINT_SIZE, fill: color, width: 0 }, }, ], + // Stroke callbacks re-read the CSS variables on every draw so the + // axes/grid follow light/dark theme switches (see redraw observer below). axes: [ { - stroke: getVar("--muted-foreground"), + stroke: () => getVar("--muted-foreground"), grid: { show: false }, font: "10px Archivo", size: 24, @@ -100,8 +106,8 @@ const TelemetryChart = memo(({ }, { side: 1, - stroke: getVar("--muted-foreground"), - grid: { stroke: getVar("--border") }, + stroke: () => getVar("--muted-foreground"), + grid: { stroke: () => getVar("--border") }, font: "10px Archivo", size: 36, incrs: CHART_AXIS_INCRS, @@ -125,20 +131,48 @@ const TelemetryChart = memo(({ // eslint-disable-next-line react-hooks/exhaustive-deps }, []); - // ── Feed new data points ──────────────────────────────────────────────── + // ── Feed new data points (transient subscription, no React re-renders) ── useEffect(() => { - if (typeof value !== "number" || !uplotRef.current) return; + let rafId = 0; + let lastValue: number | boolean | string | undefined; + + // Coalesce redraws to one per animation frame (and none while hidden). + const flush = () => { + rafId = 0; + uplotRef.current?.setData([xRef.current, yRef.current]); + }; + + const ingest = (telemetry: ReturnType["telemetry"]) => { + const value = telemetry[board]?.[measurementKey]; + if (value === lastValue) return; + lastValue = value; + if (typeof value !== "number") return; - xRef.current.push((performance.now() - startRef.current) / 1000); - yRef.current.push(value); + xRef.current.push((performance.now() - startRef.current) / 1000); + yRef.current.push(value); + + // Trim with slack so the slice allocation is amortised instead of + // happening on every single update once the cap is reached. + if (xRef.current.length > CHART_MAX_POINTS + CHART_TRIM_SLACK) { + xRef.current = xRef.current.slice(-CHART_MAX_POINTS); + yRef.current = yRef.current.slice(-CHART_MAX_POINTS); + } + + if (!rafId) rafId = requestAnimationFrame(flush); + }; - if (xRef.current.length > CHART_MAX_POINTS) { - xRef.current = xRef.current.slice(-CHART_MAX_POINTS); - yRef.current = yRef.current.slice(-CHART_MAX_POINTS); - } + ingest(useStore.getState().telemetry); + const unsubscribe = useStore.subscribe((state, prevState) => { + if (state.telemetry !== prevState.telemetry) ingest(state.telemetry); + }); - uplotRef.current.setData([xRef.current, yRef.current]); - }, [value]); + return () => { + unsubscribe(); + if (rafId) cancelAnimationFrame(rafId); + }; + // Intentionally runs once on mount — board/measurement props are stable. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); // ── Resize to wrapper ─────────────────────────────────────────────────── useEffect(() => { @@ -157,6 +191,15 @@ const TelemetryChart = memo(({ return () => observer.disconnect(); }, []); + // ── Repaint on theme switch ───────────────────────────────────────────── + // AppLayout toggles the `dark` class on ; redrawing re-runs the + // axis/grid stroke callbacks so the chart picks up the new theme colours. + useEffect(() => { + const observer = new MutationObserver(() => uplotRef.current?.redraw()); + observer.observe(document.documentElement, { attributes: true, attributeFilter: ["class"] }); + return () => observer.disconnect(); + }, []); + return (
diff --git a/frontend/competition-view/src/pages/Overview/Overview.tsx b/frontend/competition-view/src/pages/Overview/Overview.tsx index 2f511b7df..d1c90b628 100644 --- a/frontend/competition-view/src/pages/Overview/Overview.tsx +++ b/frontend/competition-view/src/pages/Overview/Overview.tsx @@ -76,28 +76,35 @@ interface BatteryCardProps { rows: BatteryRow[]; } +/** SOC-level colouring: red below 20 %, amber below 40 %, green otherwise. */ +const socColors = (soc: number | undefined) => + typeof soc !== "number" ? { bar: "bg-muted-foreground/20", text: "" } + : soc < 20 ? { bar: "bg-red-500", text: "text-red-500" } + : soc < 40 ? { bar: "bg-amber-500", text: "text-amber-500" } + : { bar: "bg-green-500", text: "" }; + const BatteryCard = ({ title, soc, rows }: BatteryCardProps) => { - const socPct = typeof soc === "number" ? Math.min(100, Math.max(0, soc)) : 0; - const barColor = typeof soc !== "number" ? "bg-muted-foreground/20" : "bg-yellow-500"; + const socPct = typeof soc === "number" ? Math.min(100, Math.max(0, soc)) : 0; + const { bar, text } = socColors(soc); return ( -
+
{title} - + {typeof soc === "number" ? soc.toFixed(0) : "—"} %
-
+
{rows.map(({ label, value, unit, warn }) => (
- {label} + {label} {value ?? "—"} {value !== undefined && unit && ( @@ -126,7 +133,7 @@ const KinematicsCard = () => { ]; return ( -
+
Kinematics
{fmtNum(speed, 0) ?? "—"} @@ -135,7 +142,7 @@ const KinematicsCard = () => {
{rows.map(({ label, value, unit }) => (
- {label} + {label} {value ?? "—"} {value !== undefined && unit && ( @@ -187,12 +194,12 @@ const SafetyCard = () => { ]; return ( -
+
Safety
{rows.map(({ label, text, color }) => (
- {label} + {label} {text}
))} @@ -271,35 +278,48 @@ const MessagesPanel = () => { ); }; -/* ─── Dashboard ─────────────────────────────────────────────────────────── */ +/* ─── HV battery card ───────────────────────────────────────────────────── */ -const Dashboard = () => { +/** + * Leaf component so the HVBMS subscriptions re-render only this card — + * keeping them in Dashboard would re-render the whole page tree on every + * telemetry packet. + */ +const HvBatteryCard = () => { const hvSoc = useMeasurement(BOARDS.HVBMS, HVBMS.soc); const hvVoltage = useMeasurement(BOARDS.HVBMS, HVBMS.batteriesVoltage); const hvCurrent = useMeasurement(BOARDS.HVBMS, HVBMS.currentReading); const hvVSensor = useMeasurement(BOARDS.HVBMS, HVBMS.voltageReading); return ( -
+ + ); +}; + +/* ─── Dashboard ─────────────────────────────────────────────────────────── */ + +const Dashboard = () => { + return ( +
-
+
{/* ── Left column ────────────────────────────────────────────── */} -
+
{/* Summary cards row */}
- +
@@ -317,7 +337,7 @@ const Dashboard = () => {
{/* ── Right column ───────────────────────────────────────────── */} -
+
diff --git a/frontend/competition-view/src/pages/Overview/components/BoardsOverviewCard.tsx b/frontend/competition-view/src/pages/Overview/components/BoardsOverviewCard.tsx index 9a69d58ea..08a9312ab 100644 --- a/frontend/competition-view/src/pages/Overview/components/BoardsOverviewCard.tsx +++ b/frontend/competition-view/src/pages/Overview/components/BoardsOverviewCard.tsx @@ -1,4 +1,3 @@ -import { Card, CardContent, CardHeader, CardTitle } from "@workspace/ui/components"; import { BOARDS, HVBMS, LCU, PCU_BOARD, VCU } from "../../../constants/measurements"; import useMeasurement from "../../../hooks/useMeasurement"; import { stateBadgeClass } from "../../../lib/stateColor"; @@ -71,35 +70,33 @@ const StatItem = ({ board, stat }: { board: string; stat: Stat }) => { : "—"; return ( -
- {stat.label}: - +
+ {stat.label} + {display} {raw !== undefined && stat.unit && ( - {stat.unit} + {stat.unit} )}
); }; -const BoardRowItem = ({ row }: { row: BoardRow }) => { +const BoardTile = ({ row }: { row: BoardRow }) => { const state = useMeasurement(row.board, row.stateMeasurementKey); return ( -
- - {row.name} - - - {/* Fixed-width slot so a longer/shorter state string doesn't shift the stats after it. */} -
- +
+
+ + {row.name} + + {state !== undefined ? String(state) : "—"}
-
+
{row.stats.map((stat) => ( ))} @@ -108,18 +105,16 @@ const BoardRowItem = ({ row }: { row: BoardRow }) => { ); }; -/** Single consolidated card showing every board's state and key measurements. */ +/** + * 2×2 grid of per-board state tiles. The tiles are the card surface + * themselves — no wrapper card or title, to keep the column compact. + */ const BoardsOverviewCard = () => ( - - - Board States - - - {ROWS.map((row) => ( - - ))} - - +
+ {ROWS.map((row) => ( + + ))} +
); export default BoardsOverviewCard; diff --git a/frontend/competition-view/src/pages/Overview/components/TrackProgress.tsx b/frontend/competition-view/src/pages/Overview/components/TrackProgress.tsx index b61275141..ec9c25cb2 100644 --- a/frontend/competition-view/src/pages/Overview/components/TrackProgress.tsx +++ b/frontend/competition-view/src/pages/Overview/components/TrackProgress.tsx @@ -9,8 +9,8 @@ import useMeasurement from "../../../hooks/useMeasurement"; const TICK_COUNT = 4; const TICKS = Array.from({ length: TICK_COUNT + 1 }, (_, i) => (TRACK_LENGTH_M / TICK_COUNT) * i); -/** Rendered pod icon width in px (h-14 = 56px tall, at the SVG's ~1.76:1 aspect ratio). */ -const ICON_WIDTH_PX = 99; +/** Rendered pod icon width in px (h-12 = 48px tall, at the SVG's ~1.76:1 aspect ratio). */ +const ICON_WIDTH_PX = 85; /** Track-position visualizer: the pod icon slides along an empty bordered track. */ const TrackProgress = () => { @@ -37,33 +37,40 @@ const TrackProgress = () => { return ( - -
- - Track Position - - - {typeof position === "number" ? position.toFixed(1) : "—"} - / {TRACK_LENGTH_M} m - -
+ {/* Single-row layout: label · track · value, to keep the banner short. */} + + + Track Position + + +
+ {/* Distance-covered fill behind the pod (icon overflows the track, so no overflow clipping) */} +
+ + {/* Distance ticks live inside the track so they cost no extra height */} +
+ {TICKS.map((t) => ( + + {t.toFixed(0)} m + + ))} +
-
Pod
-
- {TICKS.map((t) => ( - - {t.toFixed(0)} m - - ))} -
+ + {typeof position === "number" ? position.toFixed(1) : "—"} + / {TRACK_LENGTH_M} m + ); diff --git a/frontend/competition-view/src/store/slices/telemetrySlice.ts b/frontend/competition-view/src/store/slices/telemetrySlice.ts index c64c8ffe6..5c97a25c0 100644 --- a/frontend/competition-view/src/store/slices/telemetrySlice.ts +++ b/frontend/competition-view/src/store/slices/telemetrySlice.ts @@ -41,10 +41,14 @@ export const createTelemetrySlice: StateCreator< } } + // Don't wake subscribers when no packet mapped to a known board. + const boards = Object.keys(updates); + if (boards.length === 0) return; + set((state) => { const newTelemetry = { ...state.telemetry }; - for (const [board, measurements] of Object.entries(updates)) { - newTelemetry[board] = { ...newTelemetry[board], ...measurements }; + for (const board of boards) { + newTelemetry[board] = { ...newTelemetry[board], ...updates[board] }; } return { telemetry: newTelemetry }; }); From 323f242255f319046b0845fb043dcdd0a08b230f Mon Sep 17 00:00:00 2001 From: Javier Ribal del Rio Date: Tue, 14 Jul 2026 02:45:43 +0200 Subject: [PATCH 09/29] feat: enhance batteries --- .../src/constants/measurements.ts | 6 ++ .../src/pages/Batteries/Batteries.tsx | 2 +- .../Batteries/components/BatteryPackCard.tsx | 98 +++++++++---------- .../Batteries/components/HvBatterySection.tsx | 54 ++++++---- .../src/pages/Overview/Overview.tsx | 58 ++++------- 5 files changed, 108 insertions(+), 110 deletions(-) diff --git a/frontend/competition-view/src/constants/measurements.ts b/frontend/competition-view/src/constants/measurements.ts index 245bda0a6..c5f2e1761 100644 --- a/frontend/competition-view/src/constants/measurements.ts +++ b/frontend/competition-view/src/constants/measurements.ts @@ -51,6 +51,12 @@ export const PCU_BOARD = { /** DC link voltage (HVBMS.voltageReading) threshold above which HVAL is considered active. */ export const HVAL_THRESHOLD_V = 60; +/** Cell voltage display range and warning thresholds (V), shared by the battery views. */ +export const CELL_V_MIN = 3.0; +export const CELL_V_MAX = 4.2; +export const CELL_V_WARN_LOW = 3.1; +export const CELL_V_WARN_HIGH = 4.15; + /** HVBMS — high-voltage battery management system. */ export const HVBMS = { soc: "soc", diff --git a/frontend/competition-view/src/pages/Batteries/Batteries.tsx b/frontend/competition-view/src/pages/Batteries/Batteries.tsx index 9b47cf03e..cde3fdc24 100644 --- a/frontend/competition-view/src/pages/Batteries/Batteries.tsx +++ b/frontend/competition-view/src/pages/Batteries/Batteries.tsx @@ -1,7 +1,7 @@ import HvBatterySection from "./components/HvBatterySection"; const Batteries = () => ( -
+
); diff --git a/frontend/competition-view/src/pages/Batteries/components/BatteryPackCard.tsx b/frontend/competition-view/src/pages/Batteries/components/BatteryPackCard.tsx index 994fcc81b..cd33f0b47 100644 --- a/frontend/competition-view/src/pages/Batteries/components/BatteryPackCard.tsx +++ b/frontend/competition-view/src/pages/Batteries/components/BatteryPackCard.tsx @@ -1,7 +1,13 @@ -import { Card, CardContent, CardHeader, CardTitle } from "@workspace/ui/components"; import { useShallow } from "zustand/react/shallow"; import { formatAxisValue } from "../../../constants/chartConfig"; -import { BOARDS, hvbmsPack } from "../../../constants/measurements"; +import { + BOARDS, + CELL_V_MAX, + CELL_V_MIN, + CELL_V_WARN_HIGH, + CELL_V_WARN_LOW, + hvbmsPack, +} from "../../../constants/measurements"; import useMeasurement from "../../../hooks/useMeasurement"; import { useStore } from "../../../store/store"; @@ -9,11 +15,6 @@ interface BatteryPackCardProps { packNumber: number; } -const CELL_MIN = 3.0; -const CELL_MAX = 4.2; -const CELL_WARN_LOW = 3.1; -const CELL_WARN_HIGH = 4.15; - // formatAxisValue keeps the label short (falls back to exponential notation) // so a garbage/out-of-range sample never blows out a tile's fixed width. const fmt = (v: number | boolean | string | undefined) => @@ -22,7 +23,7 @@ const fmt = (v: number | boolean | string | undefined) => type CellStatus = "low" | "high" | "ok"; const cellStatus = (v: number | null): CellStatus => - v === null ? "ok" : v < CELL_WARN_LOW ? "low" : v > CELL_WARN_HIGH ? "high" : "ok"; + v === null ? "ok" : v < CELL_V_WARN_LOW ? "low" : v > CELL_V_WARN_HIGH ? "high" : "ok"; /* ─── Individual cell tile ───────────────────────────────────────────────── */ @@ -30,26 +31,27 @@ const CellTile = ({ cellNum, value }: { cellNum: number; value: number | undefin const v = typeof value === "number" ? value : null; const status = cellStatus(v); const fill = v !== null - ? Math.min(100, Math.max(0, ((v - CELL_MIN) / (CELL_MAX - CELL_MIN)) * 100)) + ? Math.min(100, Math.max(0, ((v - CELL_V_MIN) / (CELL_V_MAX - CELL_V_MIN)) * 100)) : 0; return (
- Cell {cellNum} - - {v !== null ? formatAxisValue(v) : "—"} - V - +
+ C{cellNum} + + {v !== null ? formatAxisValue(v) : "—"} + +
{ const keys = hvbmsPack(packNumber); @@ -84,45 +91,30 @@ const BatteryPackCard = ({ packNumber }: BatteryPackCardProps) => { const packStatus: CellStatus = statuses.includes("low") ? "low" : statuses.includes("high") ? "high" : "ok"; return ( - - - {/* Summary rail */} -
- - Group {packNumber} - - -
- Voltage - - {fmt(voltage)} - V - -
- -
- Temp max - - {fmt(tempMax)} - °C - -
-
+ {/* Summary rail */} +
+ Group {packNumber} + + {fmt(voltage)} V + {" · "} + {fmt(tempMax)} °C + +
- {/* Cell grid: 4 cols × 3 rows */} -
- {keys.cells.map((key, i) => ( - - ))} -
-
-
+ {/* All 12 cells in a single row */} +
+ {keys.cells.map((key, i) => ( + + ))} +
+
); }; diff --git a/frontend/competition-view/src/pages/Batteries/components/HvBatterySection.tsx b/frontend/competition-view/src/pages/Batteries/components/HvBatterySection.tsx index ceddd5bda..2e75cf5ad 100644 --- a/frontend/competition-view/src/pages/Batteries/components/HvBatterySection.tsx +++ b/frontend/competition-view/src/pages/Batteries/components/HvBatterySection.tsx @@ -1,13 +1,22 @@ -import { Separator } from "@workspace/ui/components"; -import { BOARDS, HVBMS } from "../../../constants/measurements"; +import { formatAxisValue } from "../../../constants/chartConfig"; +import { + BOARDS, + CELL_V_WARN_HIGH, + CELL_V_WARN_LOW, + HVBMS, +} from "../../../constants/measurements"; import useMeasurement from "../../../hooks/useMeasurement"; import BatteryPackCard from "./BatteryPackCard"; const PACK_COUNT = 8; const PACK_NUMBERS = Array.from({ length: PACK_COUNT }, (_, i) => i + 1); +// Sane magnitudes get fixed decimals; garbage/misdecoded samples fall back +// to compact/exponential notation so they never blow out the tile width. const fmt = (v: number | boolean | string | undefined, decimals = 1) => - typeof v === "number" ? v.toFixed(decimals) : "—"; + typeof v !== "number" ? "—" + : Math.abs(v) < 1e4 ? v.toFixed(decimals) + : formatAxisValue(v); const HvBatterySection = () => { const totalVoltage = useMeasurement(BOARDS.HVBMS, HVBMS.batteriesVoltage); @@ -23,9 +32,9 @@ const HvBatterySection = () => { : contactorHigh === true && contactorLow === true; return ( -
+
{/* Section header */} -
+

High Voltage

8 groups · 12 cells each {contactors !== undefined && ( @@ -41,21 +50,30 @@ const HvBatterySection = () => { )}
- {/* Summary stats */} -
+ {/* Summary stats — SOC / cell extremes reuse the cell warning thresholds */} +
{[ { label: "Total V", value: fmt(totalVoltage), unit: "V" }, - { label: "SOC", value: fmt(soc, 0), unit: "%" }, - { label: "V max", value: fmt(voltageMax, 3), unit: "V" }, - { label: "V min", value: fmt(voltageMin, 3), unit: "V" }, + { + label: "SOC", value: fmt(soc, 0), unit: "%", + valueClass: typeof soc !== "number" ? "" : soc < 20 ? "text-red-500" : soc < 40 ? "text-amber-500" : "", + }, + { + label: "V max", value: fmt(voltageMax, 3), unit: "V", + valueClass: typeof voltageMax === "number" && voltageMax > CELL_V_WARN_HIGH ? "text-amber-500" : "", + }, + { + label: "V min", value: fmt(voltageMin, 3), unit: "V", + valueClass: typeof voltageMin === "number" && voltageMin < CELL_V_WARN_LOW ? "text-red-500" : "", + }, { label: "T max", value: fmt(tempMax), unit: "°C" }, { label: "T min", value: fmt(tempMin), unit: "°C" }, - ].map(({ label, value, unit }) => ( -
- {label} - + ].map(({ label, value, unit, valueClass }) => ( +
+ {label} + {value} - + {unit} @@ -63,10 +81,8 @@ const HvBatterySection = () => { ))}
- - - {/* Group grid */} -
+ {/* Group strips — one row per group, stacked to fill the viewport without scrolling */} +
{PACK_NUMBERS.map((n) => ( ))} diff --git a/frontend/competition-view/src/pages/Overview/Overview.tsx b/frontend/competition-view/src/pages/Overview/Overview.tsx index d1c90b628..f2bd311de 100644 --- a/frontend/competition-view/src/pages/Overview/Overview.tsx +++ b/frontend/competition-view/src/pages/Overview/Overview.tsx @@ -10,7 +10,6 @@ import { } from "../../constants/measurements"; import useMeasurement from "../../hooks/useMeasurement"; import { useStore } from "../../store/store"; -import type { MessageKind } from "../../types/message"; import MultiSeriesChart, { type SeriesConfig } from "../Charts/components/MultiSeriesChart"; import TelemetryChart from "../Charts/components/TelemetryChart"; import MessageItem from "../Messages/components/MessageItem"; @@ -134,10 +133,13 @@ const KinematicsCard = () => { return (
- Kinematics -
- {fmtNum(speed, 0) ?? "—"} - km/h + {/* Speed sits beside the title (like the battery card's SOC) to keep the card short. */} +
+ Kinematics + + {fmtNum(speed, 0) ?? "—"} + km/h +
{rows.map(({ label, value, unit }) => ( @@ -210,50 +212,32 @@ const SafetyCard = () => { /* ─── Messages panel ────────────────────────────────────────────────────── */ -const ALL_KINDS: MessageKind[] = ["info", "warning", "error", "debug"]; -const KIND_LABEL: Record = { info: "Info", warning: "Warn", error: "Err", debug: "Dbg" }; - const MessagesPanel = () => { const messages = useStore((s) => s.messages); const clearMessages = useStore((s) => s.clearMessages); - const [activeKinds, setActiveKinds] = useState>(new Set(ALL_KINDS)); const [scrolledAway, setScrolledAway] = useState(false); const scrollRef = useRef(null); const prevLenRef = useRef(0); - const filtered = messages.filter((m) => activeKinds.has(m.kind)); - - const toggleKind = (kind: MessageKind) => - setActiveKinds((prev) => { - const next = new Set(prev); - next.has(kind) ? next.delete(kind) : next.add(kind); - return next; - }); - useEffect(() => { - if (filtered.length > prevLenRef.current && !scrolledAway) { + if (messages.length > prevLenRef.current && !scrolledAway) { scrollRef.current?.scrollTo({ top: 0, behavior: "smooth" }); } - prevLenRef.current = filtered.length; - }, [filtered.length, scrolledAway]); + prevLenRef.current = messages.length; + }, [messages.length, scrolledAway]); return (
-
- {ALL_KINDS.map((kind) => ( - - ))} -
- {filtered.length}/{messages.length} - -
+
+ + Messages + + {messages.length} +
{ onScroll={(e) => setScrolledAway(e.currentTarget.scrollTop > 60)} className="relative min-h-0 flex-1 overflow-y-auto" > - {filtered.length === 0 ? ( + {messages.length === 0 ? (

No messages

) : ( - filtered.map((msg) => ) + messages.map((msg) => ) )} {scrolledAway && (
); -}; +}); + +CellTile.displayName = "CellTile"; /* ─── Pack strip ─────────────────────────────────────────────────────────── */ @@ -71,22 +77,32 @@ const CellTile = ({ cellNum, value }: { cellNum: number; value: number | undefin * 12 cells laid out in a single row so every cell stays wide and legible. * Eight strips stacked fill the page without scrolling. */ -const BatteryPackCard = ({ packNumber }: BatteryPackCardProps) => { - const keys = hvbmsPack(packNumber); - - const voltage = useMeasurement(BOARDS.HVBMS, keys.voltage); - const temp1 = useMeasurement(BOARDS.HVBMS, keys.temps[0]); - const temp2 = useMeasurement(BOARDS.HVBMS, keys.temps[1]); - const temp3 = useMeasurement(BOARDS.HVBMS, keys.temps[2]); - const temp4 = useMeasurement(BOARDS.HVBMS, keys.temps[3]); - const numericTemps = [temp1, temp2, temp3, temp4].filter((t): t is number => typeof t === "number"); - const tempMax = numericTemps.length > 0 ? Math.max(...numericTemps) : undefined; +const BatteryPackCard = memo(({ packNumber }: BatteryPackCardProps) => { + const keys = useMemo(() => hvbmsPack(packNumber), [packNumber]); + // Same order as the values selector below: voltage, temps 1-4, cells 1-12. + const ids = useMemo(() => [keys.voltage, ...keys.temps, ...keys.cells], [keys]); - // Read every cell once here (not inside each tile) so the pack-level - // health accent and the tiles themselves share a single subscription. - const cellValues = useStore( - useShallow((s) => keys.cells.map((key) => s.telemetry[BOARDS.HVBMS]?.[key] as number | undefined)), + // Single consolidated subscription for everything this strip displays + // (voltage + 4 temps + 12 cells) instead of one subscription per value. + const values = useStore( + useShallow((s) => { + const board = s.telemetry[BOARDS.HVBMS]; + return ids.map((id) => board?.[id]) as (number | undefined)[]; + }), ); + const staleFlags = useStaleFlags(BOARDS.HVBMS, ids); + + const voltage = values[0]; + const temps = values.slice(1, 5); + const cellValues = values.slice(5); + + const voltageStale = staleFlags[0]; + const tempStale = staleFlags.slice(1, 5).some(Boolean); + const cellStale = staleFlags.slice(5); + + const numericTemps = temps.filter((t): t is number => typeof t === "number"); + const tempMax = numericTemps.length > 0 ? Math.max(...numericTemps) : undefined; + const statuses = cellValues.map((v) => cellStatus(typeof v === "number" ? v : null)); const packStatus: CellStatus = statuses.includes("low") ? "low" : statuses.includes("high") ? "high" : "ok"; @@ -102,20 +118,21 @@ const BatteryPackCard = ({ packNumber }: BatteryPackCardProps) => {
Group {packNumber} - {fmt(voltage)} V + {fmt(voltage)} V {" · "} - {fmt(tempMax)} °C + {fmt(tempMax)} °C
{/* All 12 cells in a single row */}
{keys.cells.map((key, i) => ( - + ))}
); -}; +}); +BatteryPackCard.displayName = "BatteryPackCard"; export default BatteryPackCard; diff --git a/frontend/competition-view/src/pages/Batteries/components/HvBatterySection.tsx b/frontend/competition-view/src/pages/Batteries/components/HvBatterySection.tsx index 2e75cf5ad..f1d254c43 100644 --- a/frontend/competition-view/src/pages/Batteries/components/HvBatterySection.tsx +++ b/frontend/competition-view/src/pages/Batteries/components/HvBatterySection.tsx @@ -5,9 +5,21 @@ import { CELL_V_WARN_LOW, HVBMS, } from "../../../constants/measurements"; +import { useStaleFlags } from "../../../hooks/useIsStale"; import useMeasurement from "../../../hooks/useMeasurement"; +import { STALE_TEXT_CLASS } from "../../../lib/freshness"; import BatteryPackCard from "./BatteryPackCard"; +/** Measurement ids backing the summary tiles, in display order (stable for useStaleFlags). */ +const SUMMARY_STALE_IDS = [ + HVBMS.batteriesVoltage, + HVBMS.soc, + HVBMS.voltageMax, + HVBMS.voltageMin, + HVBMS.tempMax, + HVBMS.tempMin, +] as const; + const PACK_COUNT = 8; const PACK_NUMBERS = Array.from({ length: PACK_COUNT }, (_, i) => i + 1); @@ -31,6 +43,8 @@ const HvBatterySection = () => { ? undefined : contactorHigh === true && contactorLow === true; + const staleFlags = useStaleFlags(BOARDS.HVBMS, SUMMARY_STALE_IDS); + return (
{/* Section header */} @@ -68,10 +82,10 @@ const HvBatterySection = () => { }, { label: "T max", value: fmt(tempMax), unit: "°C" }, { label: "T min", value: fmt(tempMin), unit: "°C" }, - ].map(({ label, value, unit, valueClass }) => ( + ].map(({ label, value, unit, valueClass }, i) => (
{label} - + {value} {unit} diff --git a/frontend/competition-view/src/pages/Charts/components/MultiSeriesChart.tsx b/frontend/competition-view/src/pages/Charts/components/MultiSeriesChart.tsx index eba7a2907..4016e4410 100644 --- a/frontend/competition-view/src/pages/Charts/components/MultiSeriesChart.tsx +++ b/frontend/competition-view/src/pages/Charts/components/MultiSeriesChart.tsx @@ -12,6 +12,7 @@ import { CHART_WINDOW_SECONDS, formatAxisValue, } from "../../../constants/chartConfig"; +import { useAllStale } from "../../../hooks/useIsStale"; import { useStore } from "../../../store/store"; export interface SeriesConfig { @@ -57,6 +58,9 @@ const MultiSeriesChart = memo(({ title, series, unit = "" }: MultiSeriesChartPro const yRefs = useRef(series.map(() => [])); const startRef = useRef(performance.now()); + // Subtle yellow tint when the whole data stream stopped arriving. + const stale = useAllStale(series); + // ── Initialise uPlot ──────────────────────────────────────────────────── useEffect(() => { if (!wrapperRef.current || !containerRef.current) return; @@ -217,7 +221,9 @@ const MultiSeriesChart = memo(({ title, series, unit = "" }: MultiSeriesChartPro }, []); return ( -
+
{title} diff --git a/frontend/competition-view/src/pages/Charts/components/TelemetryChart.tsx b/frontend/competition-view/src/pages/Charts/components/TelemetryChart.tsx index 34fe96abd..31a753f44 100644 --- a/frontend/competition-view/src/pages/Charts/components/TelemetryChart.tsx +++ b/frontend/competition-view/src/pages/Charts/components/TelemetryChart.tsx @@ -12,6 +12,7 @@ import { CHART_WINDOW_SECONDS, formatAxisValue, } from "../../../constants/chartConfig"; +import { useIsStale } from "../../../hooks/useIsStale"; import { useStore } from "../../../store/store"; interface TelemetryChartProps { @@ -56,6 +57,9 @@ const TelemetryChart = memo(({ const color = CHART_COLORS[colorIndex % CHART_COLORS.length]; + // Subtle yellow tint when the data stream stopped arriving. + const stale = useIsStale(board, measurementKey); + // ── Initialise uplot ──────────────────────────────────────────────────── useEffect(() => { if (!wrapperRef.current || !containerRef.current) return; @@ -201,7 +205,9 @@ const TelemetryChart = memo(({ }, []); return ( -
+
{title} {unit && ( diff --git a/frontend/competition-view/src/pages/Messages/components/MessageItem.tsx b/frontend/competition-view/src/pages/Messages/components/MessageItem.tsx index 8c2fd0666..ccb69e73b 100644 --- a/frontend/competition-view/src/pages/Messages/components/MessageItem.tsx +++ b/frontend/competition-view/src/pages/Messages/components/MessageItem.tsx @@ -1,4 +1,5 @@ import { Badge } from "@workspace/ui/components"; +import { memo } from "react"; import type { Message, MessageKind } from "../../../types/message"; const KIND_BADGE_CLASS: Record = { @@ -19,7 +20,9 @@ interface MessageItemProps { message: Message; } -const MessageItem = ({ message }: MessageItemProps) => { +// Memoised: message objects are immutable, so once rendered a row never +// changes — this keeps a new message from re-rendering the whole list. +const MessageItem = memo(({ message }: MessageItemProps) => { const time = new Date(message.timestamp).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", @@ -42,6 +45,7 @@ const MessageItem = ({ message }: MessageItemProps) => {
); -}; +}); +MessageItem.displayName = "MessageItem"; export default MessageItem; diff --git a/frontend/competition-view/src/pages/Overview/Overview.tsx b/frontend/competition-view/src/pages/Overview/Overview.tsx index f2bd311de..56a8940fc 100644 --- a/frontend/competition-view/src/pages/Overview/Overview.tsx +++ b/frontend/competition-view/src/pages/Overview/Overview.tsx @@ -9,6 +9,8 @@ import { VCU, } from "../../constants/measurements"; import useMeasurement from "../../hooks/useMeasurement"; +import { useIsStale, useStaleFlags } from "../../hooks/useIsStale"; +import { STALE_TEXT_CLASS } from "../../lib/freshness"; import { useStore } from "../../store/store"; import MultiSeriesChart, { type SeriesConfig } from "../Charts/components/MultiSeriesChart"; import TelemetryChart from "../Charts/components/TelemetryChart"; @@ -67,11 +69,14 @@ interface BatteryRow { value: string | undefined; unit?: string; warn?: boolean; + /** Data stopped arriving — value is rendered in yellow. */ + stale?: boolean; } interface BatteryCardProps { title: string; soc: number | undefined; + socStale?: boolean; rows: BatteryRow[]; } @@ -82,7 +87,7 @@ const socColors = (soc: number | undefined) => : soc < 40 ? { bar: "bg-amber-500", text: "text-amber-500" } : { bar: "bg-green-500", text: "" }; -const BatteryCard = ({ title, soc, rows }: BatteryCardProps) => { +const BatteryCard = ({ title, soc, socStale, rows }: BatteryCardProps) => { const socPct = typeof soc === "number" ? Math.min(100, Math.max(0, soc)) : 0; const { bar, text } = socColors(soc); @@ -90,7 +95,7 @@ const BatteryCard = ({ title, soc, rows }: BatteryCardProps) => {
{title} - + {typeof soc === "number" ? soc.toFixed(0) : "—"} % @@ -101,10 +106,10 @@ const BatteryCard = ({ title, soc, rows }: BatteryCardProps) => {
- {rows.map(({ label, value, unit, warn }) => ( + {rows.map(({ label, value, unit, warn, stale }) => (
{label} - + {value ?? "—"} {value !== undefined && unit && ( {unit} @@ -125,10 +130,15 @@ const KinematicsCard = () => { const highPsi = useMeasurement(BOARDS.VCU, VCU.highPressure); const lowPsi = useMeasurement(BOARDS.VCU, VCU.lowPressure); + const speedStale = useIsStale(BOARDS.PCU, PCU.speed); + const posStale = useIsStale(BOARDS.PCU, PCU.position); + const highStale = useIsStale(BOARDS.VCU, VCU.highPressure); + const lowStale = useIsStale(BOARDS.VCU, VCU.lowPressure); + const rows = [ - { label: "Position", value: fmtNum(position), unit: "m" }, - { label: "High pres.", value: fmtNum(highPsi), unit: "bar" }, - { label: "Low pres.", value: fmtNum(lowPsi), unit: "bar" }, + { label: "Position", value: fmtNum(position), unit: "m", stale: posStale }, + { label: "High pres.", value: fmtNum(highPsi), unit: "bar", stale: highStale }, + { label: "Low pres.", value: fmtNum(lowPsi), unit: "bar", stale: lowStale }, ]; return ( @@ -136,16 +146,16 @@ const KinematicsCard = () => { {/* Speed sits beside the title (like the battery card's SOC) to keep the card short. */}
Kinematics - + {fmtNum(speed, 0) ?? "—"} km/h
- {rows.map(({ label, value, unit }) => ( + {rows.map(({ label, value, unit, stale }) => (
{label} - + {value ?? "—"} {value !== undefined && unit && ( {unit} @@ -162,6 +172,15 @@ const KinematicsCard = () => { interface SafeRow { label: string; text: string; color: string } +/** Measurement ids backing the Safety card rows (stable for useStaleFlags). */ +const SAFETY_STALE_IDS = [ + HVBMS.sdcStatus, + HVBMS.contactorHigh, + HVBMS.contactorLow, + HVBMS.imdOk, + HVBMS.operationalState, +] as const; + const SafetyCard = () => { const sdcStatus = useMeasurement(BOARDS.HVBMS, HVBMS.sdcStatus); const contactorHigh = useMeasurement(BOARDS.HVBMS, HVBMS.contactorHigh); @@ -172,26 +191,29 @@ const SafetyCard = () => { const imd = useMeasurement(BOARDS.HVBMS, HVBMS.imdOk); const hvBmsState = useMeasurement(BOARDS.HVBMS, HVBMS.operationalState); + const [sdcStale, ctHighStale, ctLowStale, imdStale, stateStale] = + useStaleFlags(BOARDS.HVBMS, SAFETY_STALE_IDS); + const rows: SafeRow[] = [ { label: "SDC", text: sdcStatus === undefined ? "—" : String(sdcStatus), - color: sdcStatus === "ENGAGED" ? "text-green-500" : sdcStatus === "DISENGAGED" ? "text-red-500" : "text-muted-foreground", + color: sdcStale ? STALE_TEXT_CLASS : sdcStatus === "ENGAGED" ? "text-green-500" : sdcStatus === "DISENGAGED" ? "text-red-500" : "text-muted-foreground", }, { label: "Contactors", text: contactors === true ? "CLOSED" : contactors === false ? "OPEN" : "—", - color: contactors === true ? "text-green-500" : contactors === false ? "text-amber-500" : "text-muted-foreground", + color: ctHighStale || ctLowStale ? STALE_TEXT_CLASS : contactors === true ? "text-green-500" : contactors === false ? "text-amber-500" : "text-muted-foreground", }, { label: "IMD", text: imd === true ? "OK" : imd === false ? "FAULT" : "—", - color: imd === true ? "text-green-500" : imd === false ? "text-red-500" : "text-muted-foreground", + color: imdStale ? STALE_TEXT_CLASS : imd === true ? "text-green-500" : imd === false ? "text-red-500" : "text-muted-foreground", }, { label: "HV BMS", text: hvBmsState === undefined ? "—" : String(hvBmsState), - color: hvBmsState === "OPERATIONAL" ? "text-green-500" : "text-foreground", + color: stateStale ? STALE_TEXT_CLASS : hvBmsState === "OPERATIONAL" ? "text-green-500" : "text-foreground", }, ]; @@ -269,20 +291,31 @@ const MessagesPanel = () => { * keeping them in Dashboard would re-render the whole page tree on every * telemetry packet. */ +/** Measurement ids backing the HV battery rows (stable for useStaleFlags). */ +const HV_BATTERY_STALE_IDS = [ + HVBMS.soc, + HVBMS.batteriesVoltage, + HVBMS.currentReading, + HVBMS.voltageReading, +] as const; + const HvBatteryCard = () => { const hvSoc = useMeasurement(BOARDS.HVBMS, HVBMS.soc); const hvVoltage = useMeasurement(BOARDS.HVBMS, HVBMS.batteriesVoltage); const hvCurrent = useMeasurement(BOARDS.HVBMS, HVBMS.currentReading); const hvVSensor = useMeasurement(BOARDS.HVBMS, HVBMS.voltageReading); + const [socStale, vStale, iStale, dcStale] = useStaleFlags(BOARDS.HVBMS, HV_BATTERY_STALE_IDS); + return ( ); diff --git a/frontend/competition-view/src/pages/Overview/components/BoardsOverviewCard.tsx b/frontend/competition-view/src/pages/Overview/components/BoardsOverviewCard.tsx index 08a9312ab..26da5e8bf 100644 --- a/frontend/competition-view/src/pages/Overview/components/BoardsOverviewCard.tsx +++ b/frontend/competition-view/src/pages/Overview/components/BoardsOverviewCard.tsx @@ -1,5 +1,7 @@ import { BOARDS, HVBMS, LCU, PCU_BOARD, VCU } from "../../../constants/measurements"; +import { useIsStale } from "../../../hooks/useIsStale"; import useMeasurement from "../../../hooks/useMeasurement"; +import { STALE_BADGE_CLASS, STALE_TEXT_CLASS } from "../../../lib/freshness"; import { stateBadgeClass } from "../../../lib/stateColor"; interface Stat { @@ -59,7 +61,8 @@ const ROWS: BoardRow[] = [ ]; const StatItem = ({ board, stat }: { board: string; stat: Stat }) => { - const raw = useMeasurement(board, stat.measurementKey); + const raw = useMeasurement(board, stat.measurementKey); + const stale = useIsStale(board, stat.measurementKey); const display = typeof raw === "number" ? raw.toFixed(stat.decimals ?? 1) @@ -72,7 +75,7 @@ const StatItem = ({ board, stat }: { board: string; stat: Stat }) => { return (
{stat.label} - + {display} {raw !== undefined && stat.unit && ( {stat.unit} @@ -83,7 +86,8 @@ const StatItem = ({ board, stat }: { board: string; stat: Stat }) => { }; const BoardTile = ({ row }: { row: BoardRow }) => { - const state = useMeasurement(row.board, row.stateMeasurementKey); + const state = useMeasurement(row.board, row.stateMeasurementKey); + const stateStale = useIsStale(row.board, row.stateMeasurementKey); return (
@@ -91,7 +95,7 @@ const BoardTile = ({ row }: { row: BoardRow }) => { {row.name} - + {state !== undefined ? String(state) : "—"}
diff --git a/frontend/competition-view/src/pages/Overview/components/TrackProgress.tsx b/frontend/competition-view/src/pages/Overview/components/TrackProgress.tsx index ec9c25cb2..67f0813f9 100644 --- a/frontend/competition-view/src/pages/Overview/components/TrackProgress.tsx +++ b/frontend/competition-view/src/pages/Overview/components/TrackProgress.tsx @@ -3,7 +3,9 @@ import { useEffect, useRef, useState } from "react"; import podIcon from "../../../assets/pod.svg"; import { BOARDS, PCU } from "../../../constants/measurements"; import { TRACK_LENGTH_M } from "../../../constants/track"; +import { useIsStale } from "../../../hooks/useIsStale"; import useMeasurement from "../../../hooks/useMeasurement"; +import { STALE_TEXT_CLASS } from "../../../lib/freshness"; /** Number of gaps between distance labels below the track (5 labels total). */ const TICK_COUNT = 4; @@ -15,6 +17,7 @@ const ICON_WIDTH_PX = 85; /** Track-position visualizer: the pod icon slides along an empty bordered track. */ const TrackProgress = () => { const position = useMeasurement(BOARDS.PCU, PCU.position) as number | undefined; + const stale = useIsStale(BOARDS.PCU, PCU.position); const clamped = typeof position === "number" ? Math.min(TRACK_LENGTH_M, Math.max(0, position)) : 0; const pct = (clamped / TRACK_LENGTH_M) * 100; @@ -67,7 +70,7 @@ const TrackProgress = () => { />
- + {typeof position === "number" ? position.toFixed(1) : "—"} / {TRACK_LENGTH_M} m diff --git a/frontend/competition-view/src/store/slices/telemetrySlice.ts b/frontend/competition-view/src/store/slices/telemetrySlice.ts index 5c97a25c0..f16f24cba 100644 --- a/frontend/competition-view/src/store/slices/telemetrySlice.ts +++ b/frontend/competition-view/src/store/slices/telemetrySlice.ts @@ -1,4 +1,5 @@ import type { StateCreator } from "zustand"; +import { markFresh } from "../../lib/freshness"; import type { TelemetryData, TelemetryState } from "../../types/telemetry"; import type { Store } from "../store"; @@ -25,6 +26,7 @@ export const createTelemetrySlice: StateCreator< updateTelemetry: (packets) => { const boardMap = get().packetBoard; const updates: TelemetryState = {}; + const now = Date.now(); for (const [packetIdStr, packet] of Object.entries(packets)) { const boardName = boardMap[Number(packetIdStr)]; @@ -38,6 +40,7 @@ export const createTelemetrySlice: StateCreator< } else { updates[boardName][key] = value as number | boolean | string; } + markFresh(boardName, key, now); } } From f044ceb94bcb1948abfc4a27adeb05f5a85254f5 Mon Sep 17 00:00:00 2001 From: lcastel Date: Tue, 14 Jul 2026 10:18:02 +0200 Subject: [PATCH 11/29] feat(competition-view): changed graph behaviour, changed vehicle state labelling --- .../src/components/header/DashboardStatusBar.tsx | 2 +- .../src/pages/Charts/components/MultiSeriesChart.tsx | 12 ++---------- .../src/pages/Charts/components/TelemetryChart.tsx | 9 +++------ 3 files changed, 6 insertions(+), 17 deletions(-) diff --git a/frontend/competition-view/src/components/header/DashboardStatusBar.tsx b/frontend/competition-view/src/components/header/DashboardStatusBar.tsx index 3d609c5c3..127367fdf 100644 --- a/frontend/competition-view/src/components/header/DashboardStatusBar.tsx +++ b/frontend/competition-view/src/components/header/DashboardStatusBar.tsx @@ -100,7 +100,7 @@ const DashboardStatusBar = () => { - + {/* Fixed-width slot so a longer/shorter state string doesn't shift the elements after it. */}
diff --git a/frontend/competition-view/src/pages/Charts/components/MultiSeriesChart.tsx b/frontend/competition-view/src/pages/Charts/components/MultiSeriesChart.tsx index 4016e4410..0e2190448 100644 --- a/frontend/competition-view/src/pages/Charts/components/MultiSeriesChart.tsx +++ b/frontend/competition-view/src/pages/Charts/components/MultiSeriesChart.tsx @@ -48,7 +48,7 @@ interface MultiSeriesChartProps { * Redraws are batched to animation frames. * * A compact colour-dot legend is rendered in the card header. - * Double-click resets a manual zoom. + * Zoom is disabled; only hover crosshair interaction is active. */ const MultiSeriesChart = memo(({ title, series, unit = "" }: MultiSeriesChartProps) => { const wrapperRef = useRef(null); // flex-1 div sized by CSS layout @@ -124,23 +124,15 @@ const MultiSeriesChart = memo(({ title, series, unit = "" }: MultiSeriesChartPro values: (_, ticks) => ticks.map(formatAxisValue), }, ], - cursor: { drag: { setScale: true, x: true, y: true } }, + cursor: { drag: { setScale: false, x: false, y: false } }, }; const initialData: uPlot.AlignedData = [[], ...series.map(() => [] as number[])]; uplotRef.current = new uPlot(opts, initialData, containerRef.current); - const handleDblClick = () => - uplotRef.current?.setScale("x", { - min: null as unknown as number, - max: null as unknown as number, - }); - wrapperRef.current.addEventListener("dblclick", handleDblClick); - return () => { uplotRef.current?.destroy(); uplotRef.current = null; - wrapperRef.current?.removeEventListener("dblclick", handleDblClick); }; // Intentionally runs once on mount — series config is stable. // eslint-disable-next-line react-hooks/exhaustive-deps diff --git a/frontend/competition-view/src/pages/Charts/components/TelemetryChart.tsx b/frontend/competition-view/src/pages/Charts/components/TelemetryChart.tsx index 31a753f44..dd8166e00 100644 --- a/frontend/competition-view/src/pages/Charts/components/TelemetryChart.tsx +++ b/frontend/competition-view/src/pages/Charts/components/TelemetryChart.tsx @@ -35,7 +35,8 @@ interface TelemetryChartProps { * component stays lightweight. The x-axis is wall-clock time (seconds) * and the visible range is pinned to a rolling window ending at the * latest sample, so the chart keeps advancing even under bursty, - * high-frequency packet rates. Double-click resets a manual zoom. + * high-frequency packet rates. Zoom is disabled; only hover crosshair + * interaction is active. * * Telemetry is consumed through a transient store subscription that feeds * uPlot directly, so data updates never re-render the React component. @@ -118,18 +119,14 @@ const TelemetryChart = memo(({ values: (_, ticks) => ticks.map(formatAxisValue), }, ], - cursor: { drag: { setScale: true, x: true, y: true } }, + cursor: { drag: { setScale: false, x: false, y: false } }, }; uplotRef.current = new uPlot(opts, [[], []], containerRef.current); - const handleDblClick = () => uplotRef.current?.setScale("x", { min: null as unknown as number, max: null as unknown as number }); - wrapperRef.current.addEventListener("dblclick", handleDblClick); - return () => { uplotRef.current?.destroy(); uplotRef.current = null; - wrapperRef.current?.removeEventListener("dblclick", handleDblClick); }; // Intentionally runs once on mount — series config is stable. // eslint-disable-next-line react-hooks/exhaustive-deps From 74d52b33c462e9673ab421b1030d74e8e8a2a8f3 Mon Sep 17 00:00:00 2001 From: lcastel Date: Tue, 14 Jul 2026 15:16:45 +0200 Subject: [PATCH 12/29] feat(competition-view): changed orders panel, changed track icon, removed quick orders, and other changes made in EHW'26 --- .../competition-view/src/assets/pod-dark.svg | 170 ++++++++++++++++ .../src/assets/{pod.svg => pod-light.svg} | 0 .../components/header/DashboardStatusBar.tsx | 58 +----- .../src/components/header/Header.tsx | 2 +- .../src/constants/orderColors.ts | 46 +++++ .../src/constants/vcuStateMachine.ts | 54 +++++ frontend/competition-view/src/lib/message.ts | 33 +++ .../src/pages/Charts/Charts.tsx | 28 ++- .../Charts/components/MultiSeriesChart.tsx | 10 +- .../Charts/components/TelemetryChart.tsx | 9 +- .../src/pages/Messages/Messages.tsx | 6 +- .../pages/Messages/components/MessageItem.tsx | 17 +- .../src/pages/Orders/Orders.tsx | 43 +--- .../pages/Orders/components/BoardSection.tsx | 70 ------- .../pages/Orders/components/OrderButton.tsx | 63 ++++++ .../src/pages/Orders/components/OrderItem.tsx | 166 --------------- .../Orders/components/OrderParameters.tsx | 190 ------------------ .../pages/Orders/components/OrdersList.tsx | 88 ++++++++ .../src/pages/Overview/Overview.tsx | 45 +++-- .../components/BoardsOverviewCard.tsx | 9 +- .../pages/Overview/components/OrdersPanel.tsx | 60 ++---- .../Overview/components/RecentMessages.tsx | 71 ------- .../Overview/components/TrackProgress.tsx | 11 +- .../competition-view/src/types/message.ts | 44 +++- 24 files changed, 609 insertions(+), 684 deletions(-) create mode 100644 frontend/competition-view/src/assets/pod-dark.svg rename frontend/competition-view/src/assets/{pod.svg => pod-light.svg} (100%) create mode 100644 frontend/competition-view/src/constants/orderColors.ts create mode 100644 frontend/competition-view/src/constants/vcuStateMachine.ts create mode 100644 frontend/competition-view/src/lib/message.ts delete mode 100644 frontend/competition-view/src/pages/Orders/components/BoardSection.tsx create mode 100644 frontend/competition-view/src/pages/Orders/components/OrderButton.tsx delete mode 100644 frontend/competition-view/src/pages/Orders/components/OrderItem.tsx delete mode 100644 frontend/competition-view/src/pages/Orders/components/OrderParameters.tsx create mode 100644 frontend/competition-view/src/pages/Orders/components/OrdersList.tsx delete mode 100644 frontend/competition-view/src/pages/Overview/components/RecentMessages.tsx diff --git a/frontend/competition-view/src/assets/pod-dark.svg b/frontend/competition-view/src/assets/pod-dark.svg new file mode 100644 index 000000000..c23d16b84 --- /dev/null +++ b/frontend/competition-view/src/assets/pod-dark.svg @@ -0,0 +1,170 @@ + + + + + + + + + + + + + + + + + + diff --git a/frontend/competition-view/src/assets/pod.svg b/frontend/competition-view/src/assets/pod-light.svg similarity index 100% rename from frontend/competition-view/src/assets/pod.svg rename to frontend/competition-view/src/assets/pod-light.svg diff --git a/frontend/competition-view/src/components/header/DashboardStatusBar.tsx b/frontend/competition-view/src/components/header/DashboardStatusBar.tsx index 127367fdf..4bf6259d5 100644 --- a/frontend/competition-view/src/components/header/DashboardStatusBar.tsx +++ b/frontend/competition-view/src/components/header/DashboardStatusBar.tsx @@ -1,41 +1,11 @@ -import { Button, Separator, Tooltip, TooltipContent, TooltipTrigger } from "@workspace/ui/components"; -import { AlertTriangle, Square, Unplug } from "@workspace/ui/icons"; +import { Separator } from "@workspace/ui/components"; import { formatAxisValue } from "../../constants/chartConfig"; import { BOARDS, HVAL_THRESHOLD_V, HVBMS, VCU } from "../../constants/measurements"; -import { - BRAKE_ORDERS, - EMERGENCY_STOP_ORDERS, - OPEN_CONTACTORS_ORDERS, -} from "../../constants/orders"; import { useIsStale } from "../../hooks/useIsStale"; import useMeasurement from "../../hooks/useMeasurement"; -import useSendOrder from "../../hooks/useSendOrder"; import { STALE_BADGE_CLASS, STALE_TEXT_CLASS } from "../../lib/freshness"; import { stateBadgeClass } from "../../lib/stateColor"; -/* ─── Icon-only order button (half the footprint of a labelled button) ──── */ - -const OrderIconButton = ({ - label, - className, - onClick, - children, -}: { - label: string; - className?: string; - onClick: () => void; - children: React.ReactNode; -}) => ( - - - - - {label} - -); - /* ─── Shared vertical rhythm for the status blocks ───────────────────────── */ const VDivider = () => ; @@ -68,8 +38,6 @@ const StatValue = ({ /* ─── DashboardStatusBar ─────────────────────────────────────────────────── */ const DashboardStatusBar = () => { - const sendOrder = useSendOrder(); - const state = useMeasurement(BOARDS.VCU, VCU.state); const brakeRaw = useMeasurement(BOARDS.VCU, VCU.activeBrakes); const dcLinkV = useMeasurement(BOARDS.HVBMS, HVBMS.voltageReading) as number | undefined; @@ -114,30 +82,6 @@ const DashboardStatusBar = () => { - - - -
- sendOrder(BRAKE_ORDERS)}> - - - - sendOrder(OPEN_CONTACTORS_ORDERS)} - > - - - - sendOrder(EMERGENCY_STOP_ORDERS)} - > - - -
); }; diff --git a/frontend/competition-view/src/components/header/Header.tsx b/frontend/competition-view/src/components/header/Header.tsx index 98b44f977..109634bf9 100644 --- a/frontend/competition-view/src/components/header/Header.tsx +++ b/frontend/competition-view/src/components/header/Header.tsx @@ -43,7 +43,7 @@ const Header = ({ backendConnected, onShowShortcuts }: HeaderProps) => { - +
); diff --git a/frontend/competition-view/src/constants/orderColors.ts b/frontend/competition-view/src/constants/orderColors.ts new file mode 100644 index 000000000..eb8483155 --- /dev/null +++ b/frontend/competition-view/src/constants/orderColors.ts @@ -0,0 +1,46 @@ +/** + * Colour coding for VCU order buttons, echoing the same danger/caution/ + * info/nominal/neutral vocabulary used for vehicle state badges + * (see lib/stateColor.ts) so an order's colour hints at what it does to + * the vehicle's safety posture rather than just being decorative. + */ +export type OrderColorLevel = "danger" | "caution" | "nominal" | "info" | "neutral" | "special"; + +/** + * Level per gated VCU order id (see constants/vcuStateMachine.ts). + * - danger: FAULT — forces a fault state. Rendered as its own big button, not this grid. + * - caution: leaves a safe state or energises something (Stop, Precharge). + * - nominal: returns to a safer state (Brake). + * - info: active operating modes (Static/Dynamic Levitation, Propulsion). + * - neutral: administrative, no direct safety impact (Maintenance). + * - special: called out distinctly (Unbrake). + */ +export const VCU_ORDER_COLOR_LEVELS: Record = { + 0: "danger", // Fault + 30: "caution", // Stop + 40: "neutral", // Maintenance + 41: "caution", // Precharge + 50: "special", // Unbrake + 51: "nominal", // Brake + 60: "info", // Static Levitation + 61: "info", // Propulsion + 62: "info", // Dynamic Levitation +}; + +/** Solid button colour classes for a given level (text-on-fill, so contrast holds in both themes). */ +export const orderButtonColorClass = (level: OrderColorLevel): string => { + switch (level) { + case "danger": + return "bg-red-600 hover:bg-red-700 text-white border-red-600"; + case "caution": + return "bg-amber-500 hover:bg-amber-600 text-white border-amber-500"; + case "nominal": + return "bg-green-600 hover:bg-green-700 text-white border-green-600"; + case "info": + return "bg-blue-600 hover:bg-blue-700 text-white border-blue-600"; + case "neutral": + return "bg-muted hover:bg-muted/70 text-foreground border-border"; + case "special": + return "bg-purple-600 hover:bg-purple-700 text-white border-purple-600"; + } +}; diff --git a/frontend/competition-view/src/constants/vcuStateMachine.ts b/frontend/competition-view/src/constants/vcuStateMachine.ts new file mode 100644 index 000000000..9ddf7f196 --- /dev/null +++ b/frontend/competition-view/src/constants/vcuStateMachine.ts @@ -0,0 +1,54 @@ +export const VCU_STATES = [ + "Idle", + "Connected", + "Maintenance", + "Precharging", + "HVActive", + "Ready", + "Propulsion", + "Static Levitation", + "Dynamic Levitation", + "Fault", +] as const; + +export type VcuState = (typeof VCU_STATES)[number]; + +export const VCU_GATED_ORDER_IDS = [0, 30, 40, 41, 50, 51, 60, 61, 62] as const; + +const VCU_ORDER_STATE_MATRIX: readonly (0 | 1)[][] = [ + /* Idle */ [1, 0, 0, 0, 0, 0, 0, 0, 0], + /* Connected */ [1, 0, 1, 1, 0, 0, 0, 0, 0], + /* Maintenance */ [1, 1, 0, 0, 0, 0, 0, 0, 0], + /* Precharging */ [1, 1, 0, 0, 0, 0, 0, 0, 0], + /* HVActive */ [1, 1, 0, 0, 1, 0, 0, 0, 0], + /* Ready */ [1, 1, 0, 0, 0, 1, 1, 1, 0], + /* Propulsion */ [1, 1, 0, 0, 0, 1, 0, 0, 1], + /* Static Levitation */ [1, 1, 0, 0, 0, 1, 0, 0, 1], + /* Dynamic Levitation */ [1, 1, 0, 0, 0, 1, 0, 0, 0], + /* Fault */ [1, 0, 0, 0, 0, 0, 0, 0, 0], +]; + +export const isVcuOrderAllowedInState = (orderId: number, state: string | undefined): boolean => { + const stateIndex = VCU_STATES.findIndex((s) => s === state); + if (stateIndex === -1) return false; + + const orderIndex = VCU_GATED_ORDER_IDS.indexOf(orderId as (typeof VCU_GATED_ORDER_IDS)[number]); + if (orderIndex === -1) return true; + + return VCU_ORDER_STATE_MATRIX[stateIndex][orderIndex] === 1; +}; + +/** Column index of the FAULT order (always shown separately, not part of the grid). */ +const FAULT_COLUMN_INDEX = VCU_GATED_ORDER_IDS.indexOf(0); + +/** + * Largest number of non-FAULT orders enabled in any single state (currently + * Ready: Stop/Brake/Static Levitation/Propulsion = 4). Used to size the + * order button grid to the worst case so it doesn't grow/shrink as the + * vehicle transitions between states. + */ +export const VCU_MAX_ORDERS_PER_STATE = Math.max( + ...VCU_ORDER_STATE_MATRIX.map((row) => + row.reduce((sum, enabled, i) => (i === FAULT_COLUMN_INDEX ? sum : sum + enabled), 0), + ), +); diff --git a/frontend/competition-view/src/lib/message.ts b/frontend/competition-view/src/lib/message.ts new file mode 100644 index 000000000..1452f672d --- /dev/null +++ b/frontend/competition-view/src/lib/message.ts @@ -0,0 +1,33 @@ +import type { MessagePacket, MessageTimestamp } from "../types/message"; + +/** `HH:MM:SS` straight from the backend RTC fields — no Date/timezone conversion. */ +export const formatMessageTimestamp = (ts: MessageTimestamp | undefined): string => { + if (!ts) return "--:--:--"; + const pad = (n: number) => n.toString().padStart(2, "0"); + return `${pad(ts.hour)}:${pad(ts.minute)}:${pad(ts.second)}`; +}; + +/** Human-readable text for a message's payload, whether it's a plain string or a detailed protection object. */ +export const messageContent = (payload: MessagePacket["payload"]): string => { + if (typeof payload === "string") return payload; + if (!payload || typeof payload !== "object") return "No detail available"; + + const { kind, data } = payload; + if (typeof data === "string") return data; + + switch (kind) { + case "OUT_OF_BOUNDS": + return `Value: ${data.value} (Bounds: [${data.bounds[0]}, ${data.bounds[1]}])`; + case "UPPER_BOUND": + case "LOWER_BOUND": + return `Value: ${data.value} (Limit: ${data.bound})`; + case "EQUALS": + return `Value: ${data.value}`; + case "NOT_EQUALS": + return `Value: ${data.value} (Expected: ${data.want})`; + case "TIME_ACCUMULATION": + return `Value: ${data.value} for ${data.timelimit}s (Limit: ${data.bound})`; + default: + return JSON.stringify(data); + } +}; diff --git a/frontend/competition-view/src/pages/Charts/Charts.tsx b/frontend/competition-view/src/pages/Charts/Charts.tsx index 59ea1149f..52b8d4282 100644 --- a/frontend/competition-view/src/pages/Charts/Charts.tsx +++ b/frontend/competition-view/src/pages/Charts/Charts.tsx @@ -1,3 +1,11 @@ +import { + BatteryFull, + Gauge, + MapPin, + MoveHorizontal, + MoveVertical, + Zap, +} from "lucide-react"; import { BOARDS, HVBMS, LCU, PCU, VCU } from "../../constants/measurements"; import MultiSeriesChart, { type SeriesConfig } from "./components/MultiSeriesChart"; import TelemetryChart from "./components/TelemetryChart"; @@ -52,24 +60,24 @@ const Charts = () => (
{/* Row 1 — Kinematic */} - - + + {/* Row 2 — Electrical */} - - + + {/* Row 3 — DLIM motor currents */} - - + + {/* Row 4 — Airgaps */} - - + + {/* Row 5 — Levitation currents */} - - + +
); diff --git a/frontend/competition-view/src/pages/Charts/components/MultiSeriesChart.tsx b/frontend/competition-view/src/pages/Charts/components/MultiSeriesChart.tsx index 0e2190448..b0386a726 100644 --- a/frontend/competition-view/src/pages/Charts/components/MultiSeriesChart.tsx +++ b/frontend/competition-view/src/pages/Charts/components/MultiSeriesChart.tsx @@ -1,4 +1,5 @@ import { memo, useEffect, useRef } from "react"; +import type { LucideIcon } from "lucide-react"; import uPlot from "uplot"; import "uplot/dist/uPlot.min.css"; import { @@ -28,6 +29,8 @@ export interface SeriesConfig { interface MultiSeriesChartProps { title: string; + /** Icon shown before the title, e.g. from lucide-react. */ + icon?: LucideIcon; series: SeriesConfig[]; unit?: string; } @@ -50,7 +53,7 @@ interface MultiSeriesChartProps { * A compact colour-dot legend is rendered in the card header. * Zoom is disabled; only hover crosshair interaction is active. */ -const MultiSeriesChart = memo(({ title, series, unit = "" }: MultiSeriesChartProps) => { +const MultiSeriesChart = memo(({ title, icon: Icon, series, unit = "" }: MultiSeriesChartProps) => { const wrapperRef = useRef(null); // flex-1 div sized by CSS layout const containerRef = useRef(null); // uPlot mounting point const uplotRef = useRef(null); @@ -218,7 +221,10 @@ const MultiSeriesChart = memo(({ title, series, unit = "" }: MultiSeriesChartPro }`}>
- {title} + + {Icon && } + {title} + {series.map(({ label, colorIndex }, i) => { const color = CHART_COLORS[(colorIndex ?? i) % CHART_COLORS.length]; return ( diff --git a/frontend/competition-view/src/pages/Charts/components/TelemetryChart.tsx b/frontend/competition-view/src/pages/Charts/components/TelemetryChart.tsx index dd8166e00..7fd438465 100644 --- a/frontend/competition-view/src/pages/Charts/components/TelemetryChart.tsx +++ b/frontend/competition-view/src/pages/Charts/components/TelemetryChart.tsx @@ -1,4 +1,5 @@ import { memo, useEffect, useRef } from "react"; +import type { LucideIcon } from "lucide-react"; import uPlot from "uplot"; import "uplot/dist/uPlot.min.css"; import { @@ -18,6 +19,8 @@ import { useStore } from "../../../store/store"; interface TelemetryChartProps { /** Human-readable label shown in the card header. */ title: string; + /** Icon shown before the title, e.g. from lucide-react. */ + icon?: LucideIcon; /** Board name (must match backend, use BOARDS constants). */ board: string; /** Measurement ID within that board. */ @@ -44,6 +47,7 @@ interface TelemetryChartProps { */ const TelemetryChart = memo(({ title, + icon: Icon, board, measurementKey, unit = "", @@ -206,7 +210,10 @@ const TelemetryChart = memo(({ stale ? "border-yellow-500/40 bg-yellow-500/10" : "bg-card" }`}>
- {title} + + {Icon && } + {title} + {unit && ( {unit} )} diff --git a/frontend/competition-view/src/pages/Messages/Messages.tsx b/frontend/competition-view/src/pages/Messages/Messages.tsx index 19ed27301..cee32a58a 100644 --- a/frontend/competition-view/src/pages/Messages/Messages.tsx +++ b/frontend/competition-view/src/pages/Messages/Messages.tsx @@ -5,13 +5,13 @@ import { useStore } from "../../store/store"; import type { MessageKind } from "../../types/message"; import MessageItem from "./components/MessageItem"; -const ALL_KINDS: MessageKind[] = ["info", "warning", "error", "debug"]; +const ALL_KINDS: MessageKind[] = ["info", "warning", "fault", "ok"]; const KIND_LABEL: Record = { info: "Info", warning: "Warning", - error: "Error", - debug: "Debug", + fault: "Fault", + ok: "Ok", }; /** diff --git a/frontend/competition-view/src/pages/Messages/components/MessageItem.tsx b/frontend/competition-view/src/pages/Messages/components/MessageItem.tsx index ccb69e73b..0521e4088 100644 --- a/frontend/competition-view/src/pages/Messages/components/MessageItem.tsx +++ b/frontend/competition-view/src/pages/Messages/components/MessageItem.tsx @@ -1,19 +1,20 @@ import { Badge } from "@workspace/ui/components"; import { memo } from "react"; +import { formatMessageTimestamp, messageContent } from "../../../lib/message"; import type { Message, MessageKind } from "../../../types/message"; const KIND_BADGE_CLASS: Record = { info: "border-blue-300 bg-blue-50 text-blue-700 dark:border-blue-700 dark:bg-blue-900/20 dark:text-blue-400", warning: "border-amber-300 bg-amber-50 text-amber-700 dark:border-amber-700 dark:bg-amber-900/20 dark:text-amber-400", - error: "border-red-300 bg-red-50 text-red-700 dark:border-red-700 dark:bg-red-900/20 dark:text-red-400", - debug: "border-border bg-muted text-muted-foreground", + fault: "border-red-300 bg-red-50 text-red-700 dark:border-red-700 dark:bg-red-900/20 dark:text-red-400", + ok: "border-green-300 bg-green-50 text-green-700 dark:border-green-700 dark:bg-green-900/20 dark:text-green-400", }; const KIND_ROW_CLASS: Record = { info: "", warning: "bg-amber-50/40 dark:bg-amber-900/10", - error: "bg-red-50/40 dark:bg-red-900/10", - debug: "", + fault: "bg-red-50/40 dark:bg-red-900/10", + ok: "", }; interface MessageItemProps { @@ -23,11 +24,7 @@ interface MessageItemProps { // Memoised: message objects are immutable, so once rendered a row never // changes — this keeps a new message from re-rendering the whole list. const MessageItem = memo(({ message }: MessageItemProps) => { - const time = new Date(message.timestamp).toLocaleTimeString([], { - hour: "2-digit", - minute: "2-digit", - second: "2-digit", - }); + const time = formatMessageTimestamp(message.timestamp); return (
@@ -41,7 +38,7 @@ const MessageItem = memo(({ message }: MessageItemProps) => { {message.kind} - {message.content} + {messageContent(message.payload)}
); diff --git a/frontend/competition-view/src/pages/Orders/Orders.tsx b/frontend/competition-view/src/pages/Orders/Orders.tsx index 56a8f6c09..cf432b6e8 100644 --- a/frontend/competition-view/src/pages/Orders/Orders.tsx +++ b/frontend/competition-view/src/pages/Orders/Orders.tsx @@ -1,59 +1,30 @@ -import { InputGroup, InputGroupInput, Skeleton } from "@workspace/ui/components"; +import { Skeleton } from "@workspace/ui/components"; import { useWebSocket } from "@workspace/ui/hooks"; -import { useState } from "react"; import useOrdersCatalog from "../../hooks/useOrdersCatalog"; import { useStore } from "../../store/store"; -import BoardSection from "./components/BoardSection"; +import OrdersList from "./components/OrdersList"; /** - * Dynamic orders catalog page. + * VCU order action panel page. * * Owns its own catalog fetch so loading state is scoped here. - * Layout: - * - Search input to filter across all boards and order labels/IDs - * - Skeleton while the catalog is loading - * - One collapsible BoardSection per board - * - Empty state if the catalog loaded but returned no boards + * Renders only the orders defined by the vehicle state machine + * (see constants/vcuStateMachine.ts), filtered to what the current + * vehicle state allows, plus an always-available FAULT button. */ const Orders = () => { const { isConnected } = useWebSocket(); - const boards = useStore((s) => s.boards); const commandsCatalog = useStore((s) => s.commandsCatalog); - const [filter, setFilter] = useState(""); // Fetch catalog here; refetches on every WS reconnect const { loading } = useOrdersCatalog(isConnected); return (
- {/* Search */} - - setFilter(e.target.value)} - /> - - - {/* Board sections */} {loading ? ( - ) : boards.length === 0 ? ( -
-

- No orders available — check the backend connection. -

-
) : ( -
- {boards.map((boardName) => { - const board = commandsCatalog[boardName]; - if (!board) return null; - return ( - - ); - })} -
+ )}
); diff --git a/frontend/competition-view/src/pages/Orders/components/BoardSection.tsx b/frontend/competition-view/src/pages/Orders/components/BoardSection.tsx deleted file mode 100644 index f556d4adb..000000000 --- a/frontend/competition-view/src/pages/Orders/components/BoardSection.tsx +++ /dev/null @@ -1,70 +0,0 @@ -import { - Badge, - Collapsible, - CollapsibleContent, - CollapsibleTrigger, -} from "@workspace/ui/components"; -import { ChevronDown } from "@workspace/ui/icons"; -import { useState } from "react"; -import type { BoardOrdersData, CommandCatalogItem } from "../../../types/catalog"; -import OrderItem from "./OrderItem"; - -interface BoardSectionProps { - board: BoardOrdersData; - /** If provided, only orders whose label matches are rendered. */ - filter: string; - isConnected: boolean; -} - -const matchesFilter = (item: CommandCatalogItem, filter: string) => { - if (!filter) return true; - const q = filter.toLowerCase(); - return ( - item.name.toLowerCase().includes(q) || - String(item.id).includes(q) - ); -}; - -/** - * Collapsible section for a single board's orders. - * Starts collapsed; auto-expands when a search filter is active. - */ -const BoardSection = ({ board, filter, isConnected }: BoardSectionProps) => { - const [open, setOpen] = useState(false); - - const visibleOrders = board.orders.filter((o) => matchesFilter(o, filter)); - if (visibleOrders.length === 0) return null; - - // Auto-expand when the user is searching - const isOpen = open || filter.length > 0; - - return ( - - -
- - {board.name} - - - {visibleOrders.length} - -
- -
- - -
- {visibleOrders.map((order) => ( - - ))} -
-
-
- ); -}; - -export default BoardSection; diff --git a/frontend/competition-view/src/pages/Orders/components/OrderButton.tsx b/frontend/competition-view/src/pages/Orders/components/OrderButton.tsx new file mode 100644 index 000000000..a90c25223 --- /dev/null +++ b/frontend/competition-view/src/pages/Orders/components/OrderButton.tsx @@ -0,0 +1,63 @@ +import { Button, Tooltip, TooltipContent, TooltipTrigger } from "@workspace/ui/components"; +import { AlertTriangle, Check, Send } from "@workspace/ui/icons"; +import { useState } from "react"; +import useSendOrder from "../../../hooks/useSendOrder"; +import { orderButtonColorClass, type OrderColorLevel } from "../../../constants/orderColors"; + +interface OrderButtonProps { + id: number; + name: string; + level: OrderColorLevel; + isConnected: boolean; + /** Renders full-width with larger text — used for the FAULT button. */ + prominent?: boolean; +} + +/** + * A single colour-coded order button. Sends a field-less VCU order (all + * orders in the state-machine gated set take no parameters) and briefly + * shows a checkmark on dispatch. + */ +const OrderButton = ({ id, name, level, isConnected, prominent = false }: OrderButtonProps) => { + const sendOrder = useSendOrder(); + const [sent, setSent] = useState(false); + + const canSend = isConnected && !sent; + + const handleSend = () => { + if (!canSend) return; + sendOrder([{ id, fields: {} }]); + setSent(true); + setTimeout(() => setSent(false), 1500); + }; + + const button = ( + + ); + + if (isConnected) return button; + + return ( + + {button} + Not connected to backend + + ); +}; + +export default OrderButton; diff --git a/frontend/competition-view/src/pages/Orders/components/OrderItem.tsx b/frontend/competition-view/src/pages/Orders/components/OrderItem.tsx deleted file mode 100644 index 73ca1357f..000000000 --- a/frontend/competition-view/src/pages/Orders/components/OrderItem.tsx +++ /dev/null @@ -1,166 +0,0 @@ -import { - Badge, - Button, - Collapsible, - CollapsibleContent, - CollapsibleTrigger, - Tooltip, - TooltipContent, - TooltipTrigger, -} from "@workspace/ui/components"; -import { Check, ChevronDown, Send } from "@workspace/ui/icons"; -import { useState } from "react"; -import useSendOrder from "../../../hooks/useSendOrder"; -import type { CommandCatalogItem, ParameterValues } from "../../../types/catalog"; -import type { OrderFieldValue } from "../../../constants/orders"; -import OrderParameters from "./OrderParameters"; - -interface OrderItemProps { - item: CommandCatalogItem; - isConnected: boolean; -} - -const getDefaultValues = (item: CommandCatalogItem): ParameterValues => { - const defaults: ParameterValues = {}; - for (const [key, param] of Object.entries(item.fields)) { - if (param.kind === "numeric") defaults[key] = ""; - else if (param.kind === "enum") defaults[key] = param.options[0] ?? ""; - else if (param.kind === "boolean") defaults[key] = false; - } - return defaults; -}; - -/** - * A single order row with an inline send button. - * Orders with parameters expand to show a compact form. - * - * - Send is disabled while the WS is disconnected or any required numeric field is empty/invalid. - * - After a successful dispatch the button briefly shows a checkmark. - */ -const OrderItem = ({ item, isConnected }: OrderItemProps) => { - const sendOrder = useSendOrder(); - - const [values, setValues] = useState(() => getDefaultValues(item)); - const [open, setOpen] = useState(false); - const [sent, setSent] = useState(false); - - const hasParams = Object.keys(item.fields).length > 0; - - const hasInvalidNumeric = Object.entries(item.fields).some( - ([key, param]) => - param.kind === "numeric" && - !Number.isFinite(parseFloat(String(values[key]))), - ); - - const canSend = isConnected && !hasInvalidNumeric && !sent; - - const handleSend = () => { - if (!canSend) return; - - const fields = Object.entries(item.fields).reduce>( - (acc, [key, param]) => { - acc[key] = { - value: param.kind === "numeric" ? parseFloat(String(values[key])) : values[key], - isEnabled: true, - type: param.type, - }; - return acc; - }, - {}, - ); - - sendOrder([{ id: item.id, fields }]); - - // Brief confirmation state - setSent(true); - setTimeout(() => setSent(false), 1500); - }; - - const handleParamChange = (key: string, value: string | number | boolean) => - setValues((prev) => ({ ...prev, [key]: value })); - - const sendButton = ( - - ); - - // Wrap with tooltip explaining why the button is disabled - const sendButtonWithTooltip = !isConnected ? ( - - - {sendButton} - - Not connected to backend - - ) : hasInvalidNumeric ? ( - - - {sendButton} - - Fill in all required fields first - - ) : ( - sendButton - ); - - if (!hasParams) { - return ( -
-
- {item.name} - {item.id} -
- {sendButtonWithTooltip} -
- ); - } - - return ( - - -
- {item.name} - {item.id} - - {Object.keys(item.fields).length} params - -
-
- {sendButtonWithTooltip} - -
-
- - -
- -
-
-
- ); -}; - -export default OrderItem; diff --git a/frontend/competition-view/src/pages/Orders/components/OrderParameters.tsx b/frontend/competition-view/src/pages/Orders/components/OrderParameters.tsx deleted file mode 100644 index 84e7d4ca5..000000000 --- a/frontend/competition-view/src/pages/Orders/components/OrderParameters.tsx +++ /dev/null @@ -1,190 +0,0 @@ -import { - Checkbox, - Input, - Label, - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@workspace/ui/components"; -import type { - BooleanParameter, - CommandParameter, - EnumParameter, - NumericParameter, - ParameterValues, -} from "../../../types/catalog"; - -interface OrderParametersProps { - fields: Record; - values: ParameterValues; - onChange: (id: string, value: string | number | boolean) => void; -} - -/** - * Renders a form field for each command parameter. - * Numeric → Input with range hint, Enum → Select, Boolean → Checkbox. - * All input components come from @workspace/ui. - */ -const OrderParameters = ({ fields, values, onChange }: OrderParametersProps) => ( -
- {Object.entries(fields).map(([key, param]) => ( - - ))} -
-); - -/* ─── Helpers ───────────────────────────────────────────────────────────── */ - -/** Returns false if `val` is outside the [min, max] range (null = unbounded). */ -const inRange = (val: number, range: (number | null)[]): boolean => { - const [min, max] = range; - if (min !== null && val < min) return false; - if (max !== null && val > max) return false; - return true; -}; - -const formatBound = (v: number | null, fallback: string) => - v === null ? fallback : String(v); - -/* ─── Per-type field renderers ─────────────────────────────────────────── */ - -interface FieldProps { - fieldKey: string; - param: CommandParameter; - value: ParameterValues[string]; - onChange: (id: string, value: string | number | boolean) => void; -} - -const ParameterField = ({ fieldKey, param, value, onChange }: FieldProps) => { - switch (param.kind) { - case "numeric": - return ; - case "enum": - return ; - case "boolean": - return ; - } -}; - -const NumericField = ({ - fieldKey, - param, - value, - onChange, -}: { - fieldKey: string; - param: NumericParameter; - value: string; - onChange: FieldProps["onChange"]; -}) => { - const n = parseFloat(value); - const hasValue = value !== "" && Number.isFinite(n); - const outOfSafe = hasValue && param.safeRange.length >= 2 && !inRange(n, param.safeRange); - const outOfWarning = hasValue && param.warningRange.length >= 2 && !inRange(n, param.warningRange); - - // Visual state: red > amber > default - const borderClass = outOfSafe - ? "border-red-500 focus-visible:ring-red-500" - : outOfWarning - ? "border-amber-500 focus-visible:ring-amber-500" - : ""; - - // Build hint text from safeRange when present - const hasRange = param.safeRange.length >= 2 && - (param.safeRange[0] !== null || param.safeRange[1] !== null); - const rangeHint = hasRange - ? `Range: ${formatBound(param.safeRange[0], "−∞")} – ${formatBound(param.safeRange[1], "+∞")}` - : null; - - return ( -
- - onChange(fieldKey, e.target.value)} - className={`h-8 text-xs ${borderClass}`} - /> - {/* Range validation feedback */} - {outOfSafe && ( -

- Value is outside the safe range ({formatBound(param.safeRange[0], "−∞")} – {formatBound(param.safeRange[1], "+∞")}) -

- )} - {!outOfSafe && outOfWarning && ( -

- Value is outside the recommended range ({formatBound(param.warningRange[0], "−∞")} – {formatBound(param.warningRange[1], "+∞")}) -

- )} - {!outOfSafe && !outOfWarning && rangeHint && ( -

{rangeHint}

- )} -
- ); -}; - -const EnumField = ({ - fieldKey, - param, - value, - onChange, -}: { - fieldKey: string; - param: EnumParameter; - value: string; - onChange: FieldProps["onChange"]; -}) => ( -
- - -
-); - -const BooleanField = ({ - fieldKey, - param, - value, - onChange, -}: { - fieldKey: string; - param: BooleanParameter; - value: boolean; - onChange: FieldProps["onChange"]; -}) => ( -
- onChange(fieldKey, !!checked)} - /> - -
-); - -export default OrderParameters; diff --git a/frontend/competition-view/src/pages/Orders/components/OrdersList.tsx b/frontend/competition-view/src/pages/Orders/components/OrdersList.tsx new file mode 100644 index 000000000..e49d1c1d1 --- /dev/null +++ b/frontend/competition-view/src/pages/Orders/components/OrdersList.tsx @@ -0,0 +1,88 @@ +import { Button } from "@workspace/ui/components"; +import type { BoardOrdersData } from "../../../types/catalog"; +import { BOARDS, VCU } from "../../../constants/measurements"; +import { + isVcuOrderAllowedInState, + VCU_GATED_ORDER_IDS, + VCU_MAX_ORDERS_PER_STATE, +} from "../../../constants/vcuStateMachine"; +import { VCU_ORDER_COLOR_LEVELS } from "../../../constants/orderColors"; +import useMeasurement from "../../../hooks/useMeasurement"; +import OrderButton from "./OrderButton"; + +const FAULT_ORDER_ID = 0; + +interface OrdersListProps { + /** Full catalog, keyed by board name (as received from the backend). */ + commandsCatalog: Record; + isConnected: boolean; +} + +/** + * VCU order action panel, built from the vehicle state machine (see + * constants/vcuStateMachine.ts): only orders valid in the current state + * are shown (rather than shown-but-disabled), each colour-coded by what + * it does to the vehicle's safety posture (constants/orderColors.ts). + * FAULT is always available and pinned as a big red button at the bottom, + * regardless of state. + * + * The grid always reserves VCU_MAX_ORDERS_PER_STATE slots (filled with + * invisible placeholders past the current state's count) so the panel's + * height is exactly what the worst-case state needs — no more, and no + * growing/shrinking as the vehicle transitions between states. + */ +const OrdersList = ({ commandsCatalog, isConnected }: OrdersListProps) => { + const vcuState = useMeasurement(BOARDS.VCU, VCU.state) as string | undefined; + const vcuOrders = commandsCatalog[BOARDS.VCU]?.orders ?? []; + + // Name comes from the catalog when available, but the button itself never + // depends on the catalog having loaded — FAULT (id 0) is always sendable. + const faultName = vcuOrders.find((o) => o.id === FAULT_ORDER_ID)?.name ?? "Fault"; + + const availableOrders = VCU_GATED_ORDER_IDS + .filter((id) => id !== FAULT_ORDER_ID) + .map((id) => vcuOrders.find((o) => o.id === id)) + .filter((o): o is NonNullable => o !== undefined) + .filter((o) => isVcuOrderAllowedInState(o.id, vcuState)); + + const placeholderCount = Math.max(0, VCU_MAX_ORDERS_PER_STATE - availableOrders.length); + + return ( +
+
+
+ {availableOrders.map((order) => ( + + ))} + {Array.from({ length: placeholderCount }).map((_, i) => ( + + ))} +
+ + {availableOrders.length === 0 && ( +

+ {vcuState ? `No orders available in state "${vcuState}".` : "Waiting for vehicle state…"} +

+ )} +
+ + +
+ ); +}; + +export default OrdersList; diff --git a/frontend/competition-view/src/pages/Overview/Overview.tsx b/frontend/competition-view/src/pages/Overview/Overview.tsx index 56a8940fc..f0fd211a6 100644 --- a/frontend/competition-view/src/pages/Overview/Overview.tsx +++ b/frontend/competition-view/src/pages/Overview/Overview.tsx @@ -1,5 +1,14 @@ import { Button } from "@workspace/ui/components"; -import { ChevronUp } from "@workspace/ui/icons"; +import { ChevronUp, MessageSquare } from "@workspace/ui/icons"; +import { + BatteryFull, + Gauge, + type LucideIcon, + MoveHorizontal, + MoveVertical, + Shield, + Zap, +} from "lucide-react"; import { useEffect, useRef, useState } from "react"; import { BOARDS, @@ -75,6 +84,7 @@ interface BatteryRow { interface BatteryCardProps { title: string; + icon: LucideIcon; soc: number | undefined; socStale?: boolean; rows: BatteryRow[]; @@ -87,14 +97,17 @@ const socColors = (soc: number | undefined) => : soc < 40 ? { bar: "bg-amber-500", text: "text-amber-500" } : { bar: "bg-green-500", text: "" }; -const BatteryCard = ({ title, soc, socStale, rows }: BatteryCardProps) => { +const BatteryCard = ({ title, icon: Icon, soc, socStale, rows }: BatteryCardProps) => { const socPct = typeof soc === "number" ? Math.min(100, Math.max(0, soc)) : 0; const { bar, text } = socColors(soc); return (
- {title} + + + {title} + {typeof soc === "number" ? soc.toFixed(0) : "—"} % @@ -145,7 +158,10 @@ const KinematicsCard = () => {
{/* Speed sits beside the title (like the battery card's SOC) to keep the card short. */}
- Kinematics + + + Kinematics + {fmtNum(speed, 0) ?? "—"} km/h @@ -219,7 +235,10 @@ const SafetyCard = () => { return (
- Safety + + + Safety +
{rows.map(({ label, text, color }) => (
@@ -252,7 +271,8 @@ const MessagesPanel = () => { return (
- + + Messages {messages.length} @@ -310,6 +330,7 @@ const HvBatteryCard = () => { return ( { {/* Charts — 2 cols × 3 rows */}
- - - - - - + + + + + +
diff --git a/frontend/competition-view/src/pages/Overview/components/BoardsOverviewCard.tsx b/frontend/competition-view/src/pages/Overview/components/BoardsOverviewCard.tsx index 26da5e8bf..84a832a99 100644 --- a/frontend/competition-view/src/pages/Overview/components/BoardsOverviewCard.tsx +++ b/frontend/competition-view/src/pages/Overview/components/BoardsOverviewCard.tsx @@ -1,3 +1,4 @@ +import { BatteryFull, Cpu, type LucideIcon, Waves, Zap } from "lucide-react"; import { BOARDS, HVBMS, LCU, PCU_BOARD, VCU } from "../../../constants/measurements"; import { useIsStale } from "../../../hooks/useIsStale"; import useMeasurement from "../../../hooks/useMeasurement"; @@ -16,6 +17,7 @@ interface Stat { interface BoardRow { board: string; name: string; + icon: LucideIcon; stateMeasurementKey: string; stats: Stat[]; } @@ -24,6 +26,7 @@ const ROWS: BoardRow[] = [ { board: BOARDS.VCU, name: "VCU", + icon: Cpu, stateMeasurementKey: VCU.state, stats: [ { label: "High pres.", measurementKey: VCU.highPressure, unit: "bar" }, @@ -34,6 +37,7 @@ const ROWS: BoardRow[] = [ { board: BOARDS.HVBMS, name: "HVBMS", + icon: BatteryFull, stateMeasurementKey: HVBMS.operationalState, stats: [ { label: "SOC", measurementKey: HVBMS.soc, unit: "%" }, @@ -44,6 +48,7 @@ const ROWS: BoardRow[] = [ { board: BOARDS.PCU, name: "PCU", + icon: Zap, stateMeasurementKey: PCU_BOARD.state, stats: [ { label: "Peak I", measurementKey: PCU_BOARD.peakCurrent, unit: "A" }, @@ -53,6 +58,7 @@ const ROWS: BoardRow[] = [ { board: BOARDS.LCU, name: "LCU", + icon: Waves, stateMeasurementKey: LCU.masterState, stats: [ { label: "Slave SM", measurementKey: LCU.slaveState }, @@ -92,7 +98,8 @@ const BoardTile = ({ row }: { row: BoardRow }) => { return (
- + + {row.name} diff --git a/frontend/competition-view/src/pages/Overview/components/OrdersPanel.tsx b/frontend/competition-view/src/pages/Overview/components/OrdersPanel.tsx index 482b11da3..e00453186 100644 --- a/frontend/competition-view/src/pages/Overview/components/OrdersPanel.tsx +++ b/frontend/competition-view/src/pages/Overview/components/OrdersPanel.tsx @@ -1,62 +1,36 @@ -import { InputGroup, InputGroupInput, Skeleton } from "@workspace/ui/components"; +import { Skeleton } from "@workspace/ui/components"; +import { Send } from "@workspace/ui/icons"; import { useWebSocket } from "@workspace/ui/hooks"; -import { useState } from "react"; import useOrdersCatalog from "../../../hooks/useOrdersCatalog"; import { useStore } from "../../../store/store"; -import BoardSection from "../../Orders/components/BoardSection"; +import OrdersList from "../../Orders/components/OrdersList"; const OrdersPanel = () => { const { isConnected } = useWebSocket(); - const boards = useStore((s) => s.boards); const commandsCatalog = useStore((s) => s.commandsCatalog); - const [filter, setFilter] = useState(""); const { loading } = useOrdersCatalog(isConnected); return ( -
+
- + + Orders - - setFilter(e.target.value)} - className="h-6 py-0 text-xs" - /> -
-
- {loading ? ( -
- {Array.from({ length: 3 }).map((_, i) => ( - - ))} -
- ) : boards.length === 0 ? ( -

- No orders available -

- ) : ( -
- {boards.map((boardName) => { - const board = commandsCatalog[boardName]; - if (!board) return null; - return ( - - ); - })} -
- )} -
+ {loading ? ( +
+ {Array.from({ length: 3 }).map((_, i) => ( + + ))} +
+ ) : ( +
+ +
+ )}
); }; diff --git a/frontend/competition-view/src/pages/Overview/components/RecentMessages.tsx b/frontend/competition-view/src/pages/Overview/components/RecentMessages.tsx deleted file mode 100644 index f70e95ff1..000000000 --- a/frontend/competition-view/src/pages/Overview/components/RecentMessages.tsx +++ /dev/null @@ -1,71 +0,0 @@ -import { Badge, Separator } from "@workspace/ui/components"; -import { useStore } from "../../../store/store"; -import type { Message, MessageKind } from "../../../types/message"; - -const KIND_BADGE_CLASS: Record = { - info: "border-blue-300 text-blue-700 dark:border-blue-700 dark:text-blue-400", - warning: "border-amber-300 text-amber-700 dark:border-amber-700 dark:text-amber-400", - error: "border-red-300 text-red-700 dark:border-red-700 dark:text-red-400", - debug: "text-muted-foreground", -}; - -const MAX_VISIBLE = 6; - -interface MessageRowProps { - message: Message; -} - -const MessageRow = ({ message }: MessageRowProps) => { - const time = new Date(message.timestamp).toLocaleTimeString([], { - hour: "2-digit", - minute: "2-digit", - second: "2-digit", - }); - - return ( -
- - {time} - - - {message.kind} - - {message.content} -
- ); -}; - -/** - * Shows the most recent system messages in a compact panel. - * Full message history is available on the Messages page. - */ -const RecentMessages = () => { - const messages = useStore((s) => s.messages); - const recent = messages.slice(0, MAX_VISIBLE); - - return ( -
-
- - Recent Messages - - {messages.length} total -
- -
- {recent.length === 0 ? ( -

- No messages yet -

- ) : ( - recent.map((msg) => ) - )} -
-
- ); -}; - -export default RecentMessages; diff --git a/frontend/competition-view/src/pages/Overview/components/TrackProgress.tsx b/frontend/competition-view/src/pages/Overview/components/TrackProgress.tsx index 67f0813f9..0f4cc910b 100644 --- a/frontend/competition-view/src/pages/Overview/components/TrackProgress.tsx +++ b/frontend/competition-view/src/pages/Overview/components/TrackProgress.tsx @@ -1,11 +1,14 @@ import { Card, CardContent } from "@workspace/ui/components"; +import { MapPin } from "lucide-react"; import { useEffect, useRef, useState } from "react"; -import podIcon from "../../../assets/pod.svg"; +import podIconDark from "../../../assets/pod-dark.svg"; +import podIconLight from "../../../assets/pod-light.svg"; import { BOARDS, PCU } from "../../../constants/measurements"; import { TRACK_LENGTH_M } from "../../../constants/track"; import { useIsStale } from "../../../hooks/useIsStale"; import useMeasurement from "../../../hooks/useMeasurement"; import { STALE_TEXT_CLASS } from "../../../lib/freshness"; +import { useStore } from "../../../store/store"; /** Number of gaps between distance labels below the track (5 labels total). */ const TICK_COUNT = 4; @@ -16,6 +19,9 @@ const ICON_WIDTH_PX = 85; /** Track-position visualizer: the pod icon slides along an empty bordered track. */ const TrackProgress = () => { + const isDarkMode = useStore((s) => s.isDarkMode); + const podIcon = isDarkMode ? podIconDark : podIconLight; + const position = useMeasurement(BOARDS.PCU, PCU.position) as number | undefined; const stale = useIsStale(BOARDS.PCU, PCU.position); const clamped = typeof position === "number" ? Math.min(TRACK_LENGTH_M, Math.max(0, position)) : 0; @@ -42,7 +48,8 @@ const TrackProgress = () => { {/* Single-row layout: label · track · value, to keep the banner short. */} - + + Track Position diff --git a/frontend/competition-view/src/types/message.ts b/frontend/competition-view/src/types/message.ts index 84e9abbb9..e6a360edd 100644 --- a/frontend/competition-view/src/types/message.ts +++ b/frontend/competition-view/src/types/message.ts @@ -1,15 +1,41 @@ -export type MessageKind = "info" | "warning" | "error" | "debug"; +/** Severities sent by the backend (see backend/pkg/transport/packet/protection Severity, plus the hardcoded "info" used for order-sent notices). */ +export type MessageKind = "info" | "warning" | "fault" | "ok"; -export interface Message { - id: string; - content: string; - kind: MessageKind; - timestamp: number; +/** + * Wall-clock timestamp as sent by the backend RTC (protection.Timestamp). + * There's no timezone concept here — it's raw RTC fields, so display them + * as-is rather than routing through `Date`/UTC conversion. + */ +export interface MessageTimestamp { + counter: number; + second: number; + minute: number; + hour: number; + day: number; + month: number; + year: number; } -/** Raw packet shape sent by the backend over the WebSocket. */ +/** Detailed protection payload for warning/fault/ok messages. */ +export type MessageDetailedPayload = + | { kind: "LOWER_BOUND" | "UPPER_BOUND"; data: { bound: number; value: number } } + | { kind: "OUT_OF_BOUNDS"; data: { bounds: [number, number]; value: number } } + | { kind: "EQUALS"; data: { value: number } } + | { kind: "NOT_EQUALS"; data: { want: number; value: number } } + | { kind: "TIME_ACCUMULATION"; data: { value: number; bound: number; timelimit: number } } + | { kind: "ERROR_HANDLER" | "WARNING"; data: string }; + +/** Raw packet shape sent by the backend over the `message/update` WebSocket topic. */ export interface MessagePacket { - content: string; kind: MessageKind; - timestamp: number; + /** A plain string for "info" (order-sent) messages; a detailed object for warning/fault/ok. */ + payload: string | MessageDetailedPayload; + board: string; + name: string; + timestamp: MessageTimestamp; +} + +/** Message definition on the frontend — the wire packet plus a client-generated id. */ +export interface Message extends MessagePacket { + id: string; } From 46a0e7f2aefdfd1196f8bdb788cc5e0bc8324d58 Mon Sep 17 00:00:00 2001 From: Javier Ribal del Rio Date: Wed, 15 Jul 2026 01:08:07 +0200 Subject: [PATCH 13/29] fix(competition-view): remove HVAL light --- .../competition-view/src/components/header/HvalIndicator.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/competition-view/src/components/header/HvalIndicator.tsx b/frontend/competition-view/src/components/header/HvalIndicator.tsx index 37987e1a0..13443f853 100644 --- a/frontend/competition-view/src/components/header/HvalIndicator.tsx +++ b/frontend/competition-view/src/components/header/HvalIndicator.tsx @@ -40,7 +40,7 @@ const HvalIndicator = () => { active ? (solid ? "bg-white" : "bg-red-500") : "bg-green-500" }`} /> - HVAL: {active ? "Active" : "Inactive"} + HVAL
); From 55bd8ec43ab0c8c0ff980279c6cecd304f9fd10f Mon Sep 17 00:00:00 2001 From: Javier Ribal del Rio Date: Wed, 15 Jul 2026 01:19:14 +0200 Subject: [PATCH 14/29] fix(competition view): icons --- frontend/competition-view/src/constants/pages.ts | 4 ++-- frontend/frontend-kit/ui/src/icons/design.ts | 3 ++- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/frontend/competition-view/src/constants/pages.ts b/frontend/competition-view/src/constants/pages.ts index 9573e0c7e..65aee673f 100644 --- a/frontend/competition-view/src/constants/pages.ts +++ b/frontend/competition-view/src/constants/pages.ts @@ -1,8 +1,8 @@ -import { Layout, Wrench } from "@workspace/ui/icons"; +import { Battery, Layout } from "@workspace/ui/icons"; export const PAGES = { "/": { title: "Dashboard", icon: Layout }, - "/batteries": { title: "Batteries", icon: Wrench }, + "/batteries": { title: "Batteries", icon: Battery }, } as const; export const PAGES_ARRAY = Object.entries(PAGES).map( diff --git a/frontend/frontend-kit/ui/src/icons/design.ts b/frontend/frontend-kit/ui/src/icons/design.ts index 414f2b03f..74faef01d 100644 --- a/frontend/frontend-kit/ui/src/icons/design.ts +++ b/frontend/frontend-kit/ui/src/icons/design.ts @@ -1 +1,2 @@ -export { Pencil, Palette, Layout, LayoutGrid } from "lucide-react"; +export { Battery, Layout, LayoutGrid, Palette, Pencil } from "lucide-react"; + From 7d7ad35334f215cac073ac71f44cbefa47fcfbb7 Mon Sep 17 00:00:00 2001 From: Javier Ribal del Rio Date: Wed, 15 Jul 2026 01:40:36 +0200 Subject: [PATCH 15/29] feat(competition-view): about section --- frontend/competition-view/src/App.tsx | 11 +-- .../src/components/sidebar/AboutItem.tsx | 68 +++++++++++++++++++ .../src/components/sidebar/AppSidebar.tsx | 2 + .../competition-view/src/constants/orders.ts | 12 ---- .../src/hooks/useKeyboardShortcuts.ts | 44 +----------- 5 files changed, 74 insertions(+), 63 deletions(-) create mode 100644 frontend/competition-view/src/components/sidebar/AboutItem.tsx diff --git a/frontend/competition-view/src/App.tsx b/frontend/competition-view/src/App.tsx index 24f3eede0..ff901e1e2 100644 --- a/frontend/competition-view/src/App.tsx +++ b/frontend/competition-view/src/App.tsx @@ -5,7 +5,6 @@ import ErrorBoundary from "./components/ErrorBoundary"; import KeyboardShortcutsHelp from "./components/KeyboardShortcutsHelp"; import { BRAKE_ORDERS, - EMERGENCY_STOP_ORDERS, FAULT_ORDERS, OPEN_CONTACTORS_ORDERS, } from "./constants/orders"; @@ -37,11 +36,10 @@ const App = () => { // Global keyboard shortcuts for competition quick-actions. // Disabled while the help dialog is open so its keys don't accidentally fire. - const { estopArmed } = useKeyboardShortcuts({ + useKeyboardShortcuts({ enabled: !helpOpen, onBrake: () => sendOrder(BRAKE_ORDERS), onOpenContactors: () => sendOrder(OPEN_CONTACTORS_ORDERS), - onEmergencyStop: () => sendOrder(EMERGENCY_STOP_ORDERS), onFault: () => sendOrder(FAULT_ORDERS), onToggleHelp: () => setHelpOpen((v) => !v), }); @@ -75,13 +73,6 @@ const App = () => { {/* Keyboard shortcuts reference dialog */} - - {/* ESTOP armed banner — pulses for 2 s after the first E press */} - {estopArmed && ( -
- ⚠ ESTOP ARMED — Press E again to confirm -
- )} ); }; diff --git a/frontend/competition-view/src/components/sidebar/AboutItem.tsx b/frontend/competition-view/src/components/sidebar/AboutItem.tsx new file mode 100644 index 000000000..ede7e29fd --- /dev/null +++ b/frontend/competition-view/src/components/sidebar/AboutItem.tsx @@ -0,0 +1,68 @@ +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + Separator, + SidebarMenuButton, +} from "@workspace/ui/components"; +import { Info } from "lucide-react"; +import { useState } from "react"; +import logo from "../../assets/logo.svg"; + +/** + * Sidebar footer item that opens the About dialog with team credits. + */ +const AboutItem = () => { + const [open, setOpen] = useState(false); + + return ( + <> + setOpen(true)}> + + About + + + + + + + + About + + + + + +
+ Hyperloop UPV +
+

Hyperloop UPV

+

Competition View

+
+

+ We are Hyperloop UPV, the hyperloop team of the Universitat + Politècnica de València. +

+ + hyperloopupv.com + +
+ + + +

+ Made by the Software Subsystem +

+
+
+ + ); +}; + +export default AboutItem; diff --git a/frontend/competition-view/src/components/sidebar/AppSidebar.tsx b/frontend/competition-view/src/components/sidebar/AppSidebar.tsx index 1e83cdf69..8937a2bf7 100644 --- a/frontend/competition-view/src/components/sidebar/AppSidebar.tsx +++ b/frontend/competition-view/src/components/sidebar/AppSidebar.tsx @@ -6,6 +6,7 @@ import { SidebarRail, } from "@workspace/ui/components"; import { PAGES_ARRAY } from "../../constants/pages"; +import AboutItem from "./AboutItem"; import ConnectionStatusGroup from "./ConnectionStatusGroup"; import Logo from "./Logo"; import NavigationGroup from "./NavigationGroup"; @@ -29,6 +30,7 @@ const AppSidebar = ({ backendConnected, ...props }: AppSidebarProps) => (
+ diff --git a/frontend/competition-view/src/constants/orders.ts b/frontend/competition-view/src/constants/orders.ts index 3f6ec7771..f5c1275f0 100644 --- a/frontend/competition-view/src/constants/orders.ts +++ b/frontend/competition-view/src/constants/orders.ts @@ -30,18 +30,6 @@ export const OPEN_CONTACTORS_ORDERS: Order[] = [ { id: 42, fields: {} }, ]; -/** - * Full emergency stop sequence: - * triggers emergency brake + brings the vehicle to a full stop - * + opens contactors to cut power + forces FAULT (all VCU orders). - */ -export const EMERGENCY_STOP_ORDERS: Order[] = [ - { id: 51, fields: {} }, - { id: 30, fields: {} }, - { id: 42, fields: {} }, - { id: 0, fields: {} }, -]; - /** Forces a FAULT state (id 0 is shared by the FAULT order on every board). */ export const FAULT_ORDERS: Order[] = [ { id: 0, fields: {} }, diff --git a/frontend/competition-view/src/hooks/useKeyboardShortcuts.ts b/frontend/competition-view/src/hooks/useKeyboardShortcuts.ts index 034e5b75c..d7948efd6 100644 --- a/frontend/competition-view/src/hooks/useKeyboardShortcuts.ts +++ b/frontend/competition-view/src/hooks/useKeyboardShortcuts.ts @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useRef, useState } from "react"; +import { useCallback, useEffect } from "react"; /** Returns true when focus is inside a text-input element. */ const isTypingInInput = () => { @@ -17,7 +17,6 @@ export interface ShortcutDef { export const SHORTCUT_DEFS: ShortcutDef[] = [ { key: "b", label: "B", description: "Brake" }, { key: "o", label: "O", description: "Open Contactors" }, - { key: "e", label: "E → E", description: "Emergency Stop (press twice in 2 s)" }, { key: "space", label: "Space", description: "Fault" }, { key: "shift+/", label: "?", description: "Toggle keyboard shortcuts reference" }, ]; @@ -27,45 +26,22 @@ interface Options { enabled: boolean; onBrake: () => void; onOpenContactors: () => void; - onEmergencyStop: () => void; onFault: () => void; onToggleHelp: () => void; } -interface Result { - /** True for up to 2 s after the first E press — use to show an armed indicator. */ - estopArmed: boolean; -} - /** * Registers global keydown handlers for competition quick-actions. * * Shortcuts fire only when the user is NOT typing in a form field. - * ESTOP requires two E presses within 2 seconds to prevent accidents. */ const useKeyboardShortcuts = ({ enabled, onBrake, onOpenContactors, - onEmergencyStop, onFault, onToggleHelp, -}: Options): Result => { - const [estopArmed, setEstopArmed] = useState(false); - const timerRef = useRef | null>(null); - - const armEstop = useCallback(() => { - setEstopArmed(true); - if (timerRef.current) clearTimeout(timerRef.current); - timerRef.current = setTimeout(() => setEstopArmed(false), 2000); - }, []); - - const confirmEstop = useCallback(() => { - if (timerRef.current) clearTimeout(timerRef.current); - setEstopArmed(false); - onEmergencyStop(); - }, [onEmergencyStop]); - +}: Options): void => { const handleKeyDown = useCallback( (e: KeyboardEvent) => { if (!enabled || isTypingInInput()) return; @@ -83,15 +59,6 @@ const useKeyboardShortcuts = ({ onOpenContactors(); break; - case "e": - e.preventDefault(); - if (estopArmed) { - confirmEstop(); - } else { - armEstop(); - } - break; - case "?": e.preventDefault(); onToggleHelp(); @@ -103,18 +70,13 @@ const useKeyboardShortcuts = ({ break; } }, - [enabled, estopArmed, onBrake, onOpenContactors, armEstop, confirmEstop, onFault, onToggleHelp], + [enabled, onBrake, onOpenContactors, onFault, onToggleHelp], ); useEffect(() => { window.addEventListener("keydown", handleKeyDown); return () => window.removeEventListener("keydown", handleKeyDown); }, [handleKeyDown]); - - // Clean up the arm timer on unmount - useEffect(() => () => { if (timerRef.current) clearTimeout(timerRef.current); }, []); - - return { estopArmed }; }; export default useKeyboardShortcuts; From bc9e879b0a3458a41cf0ddf8aadcfaa1fc00f095 Mon Sep 17 00:00:00 2001 From: Javier Ribal del Rio Date: Wed, 15 Jul 2026 01:51:50 +0200 Subject: [PATCH 16/29] fix(competition-view): move shortcut helper --- .../src/components/header/ConnectionBadge.tsx | 33 ++++++++----------- .../src/components/header/Header.tsx | 15 ++------- .../src/components/sidebar/AppSidebar.tsx | 5 ++- .../src/components/sidebar/ShortcutsItem.tsx | 16 +++++++++ .../competition-view/src/layout/AppLayout.tsx | 4 +-- 5 files changed, 38 insertions(+), 35 deletions(-) create mode 100644 frontend/competition-view/src/components/sidebar/ShortcutsItem.tsx diff --git a/frontend/competition-view/src/components/header/ConnectionBadge.tsx b/frontend/competition-view/src/components/header/ConnectionBadge.tsx index 619e9bf2e..97f564253 100644 --- a/frontend/competition-view/src/components/header/ConnectionBadge.tsx +++ b/frontend/competition-view/src/components/header/ConnectionBadge.tsx @@ -7,26 +7,21 @@ interface ConnectionBadgeProps { } const ConnectionBadge = ({ label, connected }: ConnectionBadgeProps) => ( - // Fixed-width slot: "Connected" vs "Disconnected" differ in length, so without - // this the badge (and anything after it) would shift as connection state changes. - // Wide enough for the longest case, e.g. "Backend: Disconnected". -
- + - - {label}: {connected ? "Connected" : "Disconnected"} - -
+ /> + {label} + ); export default ConnectionBadge; diff --git a/frontend/competition-view/src/components/header/Header.tsx b/frontend/competition-view/src/components/header/Header.tsx index 109634bf9..2a92ca497 100644 --- a/frontend/competition-view/src/components/header/Header.tsx +++ b/frontend/competition-view/src/components/header/Header.tsx @@ -1,5 +1,4 @@ -import { Button, Separator, SidebarTrigger, Tooltip, TooltipContent, TooltipTrigger } from "@workspace/ui/components"; -import { Keyboard } from "@workspace/ui/icons"; +import { Separator, SidebarTrigger } from "@workspace/ui/components"; import { useLocation } from "react-router"; import { BOARDS } from "../../constants/measurements"; import { PAGES } from "../../constants/pages"; @@ -10,10 +9,9 @@ import HvalIndicator from "./HvalIndicator"; interface HeaderProps { backendConnected: boolean; - onShowShortcuts: () => void; } -const Header = ({ backendConnected, onShowShortcuts }: HeaderProps) => { +const Header = ({ backendConnected }: HeaderProps) => { const location = useLocation(); const page = PAGES[location.pathname as keyof typeof PAGES]; const pageTitle = page?.title ?? "Competition View"; @@ -33,15 +31,6 @@ const Header = ({ backendConnected, onShowShortcuts }: HeaderProps) => {
- - - - - Keyboard shortcuts (?) - -
diff --git a/frontend/competition-view/src/components/sidebar/AppSidebar.tsx b/frontend/competition-view/src/components/sidebar/AppSidebar.tsx index 8937a2bf7..d6eae2d7e 100644 --- a/frontend/competition-view/src/components/sidebar/AppSidebar.tsx +++ b/frontend/competition-view/src/components/sidebar/AppSidebar.tsx @@ -10,13 +10,15 @@ import AboutItem from "./AboutItem"; import ConnectionStatusGroup from "./ConnectionStatusGroup"; import Logo from "./Logo"; import NavigationGroup from "./NavigationGroup"; +import ShortcutsItem from "./ShortcutsItem"; import ThemeToggleItem from "./ThemeToggleItem"; interface AppSidebarProps extends React.ComponentProps { backendConnected: boolean; + onShowShortcuts: () => void; } -const AppSidebar = ({ backendConnected, ...props }: AppSidebarProps) => ( +const AppSidebar = ({ backendConnected, onShowShortcuts, ...props }: AppSidebarProps) => ( @@ -30,6 +32,7 @@ const AppSidebar = ({ backendConnected, ...props }: AppSidebarProps) => (
+ diff --git a/frontend/competition-view/src/components/sidebar/ShortcutsItem.tsx b/frontend/competition-view/src/components/sidebar/ShortcutsItem.tsx new file mode 100644 index 000000000..6ec99f0c3 --- /dev/null +++ b/frontend/competition-view/src/components/sidebar/ShortcutsItem.tsx @@ -0,0 +1,16 @@ +import { SidebarMenuButton } from "@workspace/ui/components"; +import { Keyboard } from "@workspace/ui/icons"; + +interface ShortcutsItemProps { + onShowShortcuts: () => void; +} + +/** Sidebar footer item that opens the keyboard shortcuts reference dialog. */ +const ShortcutsItem = ({ onShowShortcuts }: ShortcutsItemProps) => ( + + + Keyboard Shortcuts + +); + +export default ShortcutsItem; diff --git a/frontend/competition-view/src/layout/AppLayout.tsx b/frontend/competition-view/src/layout/AppLayout.tsx index 0469d821f..f9d3b8d39 100644 --- a/frontend/competition-view/src/layout/AppLayout.tsx +++ b/frontend/competition-view/src/layout/AppLayout.tsx @@ -22,9 +22,9 @@ const AppLayout = ({ children, backendConnected, onShowShortcuts }: AppLayoutPro
- + -
+
{children}
From 1b8a52c0586b9dcaac6e175fe7d469a782aa76b1 Mon Sep 17 00:00:00 2001 From: Javier Ribal del Rio Date: Wed, 15 Jul 2026 02:05:14 +0200 Subject: [PATCH 17/29] fix(competition-view): enhance topbar --- .../competition-view/src/components/header/Header.tsx | 9 ++++++--- frontend/competition-view/src/constants/pages.ts | 2 +- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/frontend/competition-view/src/components/header/Header.tsx b/frontend/competition-view/src/components/header/Header.tsx index 2a92ca497..3e280d95a 100644 --- a/frontend/competition-view/src/components/header/Header.tsx +++ b/frontend/competition-view/src/components/header/Header.tsx @@ -20,11 +20,14 @@ const Header = ({ backendConnected }: HeaderProps) => { const vcuConnected = connections[BOARDS.VCU]?.isConnected ?? false; return ( -
-
+
+
-

{pageTitle}

+
+

{pageTitle}

+

Hyperloop UPV

+
diff --git a/frontend/competition-view/src/constants/pages.ts b/frontend/competition-view/src/constants/pages.ts index 65aee673f..840dc1a88 100644 --- a/frontend/competition-view/src/constants/pages.ts +++ b/frontend/competition-view/src/constants/pages.ts @@ -1,7 +1,7 @@ import { Battery, Layout } from "@workspace/ui/icons"; export const PAGES = { - "/": { title: "Dashboard", icon: Layout }, + "/": { title: "Competition View", icon: Layout }, "/batteries": { title: "Batteries", icon: Battery }, } as const; From 2208019689beb3e658ef3dd1cde672daf60512b9 Mon Sep 17 00:00:00 2001 From: Javier Ribal del Rio Date: Wed, 15 Jul 2026 02:29:38 +0200 Subject: [PATCH 18/29] fix(competition-view): Maxim's yellow --- .../competition-view/src/hooks/useIsStale.ts | 62 +++++++++++++----- .../competition-view/src/lib/freshness.ts | Bin 2200 -> 2501 bytes 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/frontend/competition-view/src/hooks/useIsStale.ts b/frontend/competition-view/src/hooks/useIsStale.ts index f1a5c4a82..70bdd967f 100644 --- a/frontend/competition-view/src/hooks/useIsStale.ts +++ b/frontend/competition-view/src/hooks/useIsStale.ts @@ -1,13 +1,26 @@ -import { useRef, useSyncExternalStore } from "react"; +import { useEffect, useState } from "react"; import { isStale, subscribeTick } from "../lib/freshness"; /** - * True when the measurement stopped arriving (see STALE_THRESHOLD_MS). - * Driven by the shared freshness ticker; the component only re-renders - * when the flag actually flips. + * These hooks poll the freshness map on the shared ticker and mirror the + * result into component state. A plain setState is used (rather than + * useSyncExternalStore) so a flip can never be missed: memoised chart + * components never re-render on data updates, so a single dropped + * notification would leave them tinted yellow forever. */ -export const useIsStale = (board: string, id: string): boolean => - useSyncExternalStore(subscribeTick, () => isStale(board, id)); + +/** True when the measurement stopped arriving (see STALE_THRESHOLD_MS). */ +export const useIsStale = (board: string, id: string): boolean => { + const [stale, setStale] = useState(() => isStale(board, id)); + + useEffect(() => { + const update = () => setStale(isStale(board, id)); + update(); + return subscribeTick(update); + }, [board, id]); + + return stale; +}; /** * True when EVERY listed measurement is stale — used by charts to tint the @@ -16,23 +29,40 @@ export const useIsStale = (board: string, id: string): boolean => */ export const useAllStale = ( pairs: readonly { board: string; measurementKey: string }[], -): boolean => - useSyncExternalStore(subscribeTick, () => +): boolean => { + const [stale, setStale] = useState(() => pairs.every(({ board, measurementKey }) => isStale(board, measurementKey)), ); + useEffect(() => { + const update = () => + setStale(pairs.every(({ board, measurementKey }) => isStale(board, measurementKey))); + update(); + return subscribeTick(update); + }, [pairs]); + + return stale; +}; + /** * Stale flags for several measurements of one board in a single * subscription. Returns a stable array reference while no flag changes. * `ids` should be a stable reference (module constant or memoised). */ export const useStaleFlags = (board: string, ids: readonly string[]): boolean[] => { - const cacheRef = useRef([]); - return useSyncExternalStore(subscribeTick, () => { - const next = ids.map((id) => isStale(board, id)); - const prev = cacheRef.current; - if (next.length === prev.length && next.every((v, i) => v === prev[i])) return prev; - cacheRef.current = next; - return next; - }); + const [flags, setFlags] = useState(() => ids.map((id) => isStale(board, id))); + + useEffect(() => { + const update = () => + setFlags((prev) => { + const next = ids.map((id) => isStale(board, id)); + return next.length === prev.length && next.every((v, i) => v === prev[i]) + ? prev + : next; + }); + update(); + return subscribeTick(update); + }, [board, ids]); + + return flags; }; diff --git a/frontend/competition-view/src/lib/freshness.ts b/frontend/competition-view/src/lib/freshness.ts index 9b4a483a9d79c93bbbccfb3405bfa500c0ad2a38..56a765138deaddb8f47e2378c3683c55e69019c8 100644 GIT binary patch delta 370 zcmYk2F-`+95Jk&KbchRlrNJwqqz8pQH79VKan{1v(#&L6BwFPVG#r7FmIH7Ou7bT} zQQX)X|M~CFykCFce16PM%O%#5qg6r-qLhe1%$Ws}krP2%&V5Kb$bKV^!=@;lO}7eB z%Uu=F70M}AOrprXmheR7nLr)!D>S%gn7$3JMW6R^3(rIcI#p@lY844S+MP6u(M`ik z0j(gU>!ie;jm9)sJ>oe213oj1%c&q^g--dU`Ii7%;8Q~l8BOzW?gs{VXSGB!W#r2) qp!()Wi*XlbUeZdNNaSyl2?cLMr0xCut}gA;`OFx`dwBbqef(k delta 45 zcmX>qJVS893iink98$75nZ+fkd8tLkdTIGZu8GMR8X7s83bu9%IU1UplX*CI0sv{( B4w(P| From e1313b89b75ec42e8aa2417bdf3ddb98ee635bef Mon Sep 17 00:00:00 2001 From: Javier Ribal del Rio Date: Wed, 15 Jul 2026 09:45:11 +0200 Subject: [PATCH 19/29] wip(competition-view): safety range --- .../src/constants/measurements.ts | 13 +++++++++++ .../Batteries/components/HvBatterySection.tsx | 16 +++++++++++-- .../src/pages/Overview/Overview.tsx | 23 ++++++++++++++----- 3 files changed, 44 insertions(+), 8 deletions(-) diff --git a/frontend/competition-view/src/constants/measurements.ts b/frontend/competition-view/src/constants/measurements.ts index c5f2e1761..3b961c31b 100644 --- a/frontend/competition-view/src/constants/measurements.ts +++ b/frontend/competition-view/src/constants/measurements.ts @@ -51,6 +51,19 @@ export const PCU_BOARD = { /** DC link voltage (HVBMS.voltageReading) threshold above which HVAL is considered active. */ export const HVAL_THRESHOLD_V = 60; +/* ─── Expected operating intervals, displayed next to the live values ────── */ + +/** Cell voltage interval (V). */ +export const CELL_V_RANGE: readonly [number, number] = [2.7, 4.2]; +/** Total battery (pack) voltage interval (V). */ +export const PACK_V_RANGE: readonly [number, number] = [260, 400]; +/** DC bus / DC link voltage interval (V). */ +export const DC_BUS_V_RANGE: readonly [number, number] = [0, 400]; +/** Levitation coil current interval (A). */ +export const LEV_CURRENT_RANGE: readonly [number, number] = [-55, 55]; +/** Propulsion phase current interval (A). */ +export const PROP_CURRENT_RANGE: readonly [number, number] = [0, 120]; + /** Cell voltage display range and warning thresholds (V), shared by the battery views. */ export const CELL_V_MIN = 3.0; export const CELL_V_MAX = 4.2; diff --git a/frontend/competition-view/src/pages/Batteries/components/HvBatterySection.tsx b/frontend/competition-view/src/pages/Batteries/components/HvBatterySection.tsx index f1d254c43..68f5aafe7 100644 --- a/frontend/competition-view/src/pages/Batteries/components/HvBatterySection.tsx +++ b/frontend/competition-view/src/pages/Batteries/components/HvBatterySection.tsx @@ -1,9 +1,11 @@ import { formatAxisValue } from "../../../constants/chartConfig"; import { BOARDS, + CELL_V_RANGE, CELL_V_WARN_HIGH, CELL_V_WARN_LOW, HVBMS, + PACK_V_RANGE, } from "../../../constants/measurements"; import { useStaleFlags } from "../../../hooks/useIsStale"; import useMeasurement from "../../../hooks/useMeasurement"; @@ -67,22 +69,27 @@ const HvBatterySection = () => { {/* Summary stats — SOC / cell extremes reuse the cell warning thresholds */}
{[ - { label: "Total V", value: fmt(totalVoltage), unit: "V" }, + { + label: "Total V", value: fmt(totalVoltage), unit: "V", + range: PACK_V_RANGE, + }, { label: "SOC", value: fmt(soc, 0), unit: "%", valueClass: typeof soc !== "number" ? "" : soc < 20 ? "text-red-500" : soc < 40 ? "text-amber-500" : "", }, { label: "V max", value: fmt(voltageMax, 3), unit: "V", + range: CELL_V_RANGE, valueClass: typeof voltageMax === "number" && voltageMax > CELL_V_WARN_HIGH ? "text-amber-500" : "", }, { label: "V min", value: fmt(voltageMin, 3), unit: "V", + range: CELL_V_RANGE, valueClass: typeof voltageMin === "number" && voltageMin < CELL_V_WARN_LOW ? "text-red-500" : "", }, { label: "T max", value: fmt(tempMax), unit: "°C" }, { label: "T min", value: fmt(tempMin), unit: "°C" }, - ].map(({ label, value, unit, valueClass }, i) => ( + ].map(({ label, value, unit, valueClass, range }, i) => (
{label} @@ -90,6 +97,11 @@ const HvBatterySection = () => { {unit} + {range && ( + + [{range[0]}–{range[1]}] + + )}
))} diff --git a/frontend/competition-view/src/pages/Overview/Overview.tsx b/frontend/competition-view/src/pages/Overview/Overview.tsx index f0fd211a6..978f55fce 100644 --- a/frontend/competition-view/src/pages/Overview/Overview.tsx +++ b/frontend/competition-view/src/pages/Overview/Overview.tsx @@ -12,9 +12,13 @@ import { import { useEffect, useRef, useState } from "react"; import { BOARDS, + DC_BUS_V_RANGE, HVBMS, LCU, + LEV_CURRENT_RANGE, + PACK_V_RANGE, PCU, + PROP_CURRENT_RANGE, VCU, } from "../../constants/measurements"; import useMeasurement from "../../hooks/useMeasurement"; @@ -80,6 +84,8 @@ interface BatteryRow { warn?: boolean; /** Data stopped arriving — value is rendered in yellow. */ stale?: boolean; + /** Expected operating interval [min, max], shown muted next to the value. */ + range?: readonly [number, number]; } interface BatteryCardProps { @@ -119,7 +125,7 @@ const BatteryCard = ({ title, icon: Icon, soc, socStale, rows }: BatteryCardProp
- {rows.map(({ label, value, unit, warn, stale }) => ( + {rows.map(({ label, value, unit, warn, stale, range }) => (
{label} @@ -127,6 +133,11 @@ const BatteryCard = ({ title, icon: Icon, soc, socStale, rows }: BatteryCardProp {value !== undefined && unit && ( {unit} )} + {range && ( + + [{range[0]}–{range[1]}] + + )}
))} @@ -334,9 +345,9 @@ const HvBatteryCard = () => { soc={typeof hvSoc === "number" ? hvSoc : undefined} socStale={socStale} rows={[ - { label: "Pack V", value: fmtNum(hvVoltage), unit: "V", stale: vStale }, + { label: "Pack V", value: fmtNum(hvVoltage), unit: "V", stale: vStale, range: PACK_V_RANGE }, { label: "Current", value: fmtNum(hvCurrent), unit: "A", stale: iStale }, - { label: "DC Link", value: fmtNum(hvVSensor), unit: "V", stale: dcStale }, + { label: "DC Link", value: fmtNum(hvVSensor), unit: "V", stale: dcStale, range: DC_BUS_V_RANGE }, ]} /> ); @@ -364,12 +375,12 @@ const Dashboard = () => { {/* Charts — 2 cols × 3 rows */}
- + - - + +
From 6ceaed94dd38031c6cb25e78082c3144b4d90177 Mon Sep 17 00:00:00 2001 From: Javier Ribal del Rio Date: Wed, 15 Jul 2026 10:21:57 +0200 Subject: [PATCH 20/29] feat(competition-view): logger view --- .../src/components/sidebar/AppSidebar.tsx | 2 + .../src/components/sidebar/LoggerItem.tsx | 41 +++++++++++ .../src/constants/loggerControlConfig.tsx | 38 ++++++++++ .../competition-view/src/hooks/useLogger.ts | 70 +++++++++++++++++++ frontend/competition-view/src/types/logger.ts | 4 ++ 5 files changed, 155 insertions(+) create mode 100644 frontend/competition-view/src/components/sidebar/LoggerItem.tsx create mode 100644 frontend/competition-view/src/constants/loggerControlConfig.tsx create mode 100644 frontend/competition-view/src/hooks/useLogger.ts create mode 100644 frontend/competition-view/src/types/logger.ts diff --git a/frontend/competition-view/src/components/sidebar/AppSidebar.tsx b/frontend/competition-view/src/components/sidebar/AppSidebar.tsx index d6eae2d7e..e6c79531b 100644 --- a/frontend/competition-view/src/components/sidebar/AppSidebar.tsx +++ b/frontend/competition-view/src/components/sidebar/AppSidebar.tsx @@ -8,6 +8,7 @@ import { import { PAGES_ARRAY } from "../../constants/pages"; import AboutItem from "./AboutItem"; import ConnectionStatusGroup from "./ConnectionStatusGroup"; +import LoggerItem from "./LoggerItem"; import Logo from "./Logo"; import NavigationGroup from "./NavigationGroup"; import ShortcutsItem from "./ShortcutsItem"; @@ -31,6 +32,7 @@ const AppSidebar = ({ backendConnected, onShowShortcuts, ...props }: AppSidebarP
+ diff --git a/frontend/competition-view/src/components/sidebar/LoggerItem.tsx b/frontend/competition-view/src/components/sidebar/LoggerItem.tsx new file mode 100644 index 000000000..5f6894133 --- /dev/null +++ b/frontend/competition-view/src/components/sidebar/LoggerItem.tsx @@ -0,0 +1,41 @@ +import { SidebarMenuButton } from "@workspace/ui/components"; +import { cn } from "@workspace/ui/lib"; +import { LOGGER_CONTROL_CONFIG } from "../../constants/loggerControlConfig"; +import { useLogger } from "../../hooks/useLogger"; + +interface LoggerItemProps { + disabled: boolean; +} + +const LoggerItem = ({ disabled }: LoggerItemProps) => { + const { status, toggleLogging } = useLogger(); + const config = LOGGER_CONTROL_CONFIG[status]; + const label = `Logger: ${config.text}`; + + return ( + + {config.icon} + + {label} + + {status === "recording" && ( + + )} + + + + + ); +}; + +export default LoggerItem; diff --git a/frontend/competition-view/src/constants/loggerControlConfig.tsx b/frontend/competition-view/src/constants/loggerControlConfig.tsx new file mode 100644 index 000000000..02b9ea10a --- /dev/null +++ b/frontend/competition-view/src/constants/loggerControlConfig.tsx @@ -0,0 +1,38 @@ +import { AlertCircle, Loader2, Play, Square } from "@workspace/ui/icons"; +import type { LoggerStatus } from "../types/logger"; + +interface LoggerControlOption { + color: string; + text: string; + icon: React.ReactNode; + className: string; +} + +/** Appearance configuration for the logger control state. */ +export const LOGGER_CONTROL_CONFIG: Record = + { + standby: { + color: "bg-slate-300", + text: "Standby", + icon: , + className: "text-green-500", + }, + recording: { + color: "bg-red-500", + text: "Recording", + icon: , + className: "text-red-500", + }, + loading: { + color: "bg-amber-400", + text: "Loading...", + icon: , + className: "text-amber-500", + }, + error: { + color: "bg-amber-800", + text: "Time out", + icon: , + className: "text-amber-800", + }, + }; diff --git a/frontend/competition-view/src/hooks/useLogger.ts b/frontend/competition-view/src/hooks/useLogger.ts new file mode 100644 index 000000000..96ba34c9a --- /dev/null +++ b/frontend/competition-view/src/hooks/useLogger.ts @@ -0,0 +1,70 @@ +import { logger, socketService } from "@workspace/core"; +import { useTopic } from "@workspace/ui/hooks"; +import { useEffect, useState } from "react"; +import type { LoggerStatus } from "../types/logger"; + +/** Time to wait for a `logger/response` before flagging an error (ms). */ +const LOGGER_RESPONSE_TIMEOUT = 2000; + +// Shared singleton state across all useLogger instances +let sharedStatus: LoggerStatus = "standby"; +let sharedTimeout: ReturnType | null = null; +const listeners = new Set<(status: LoggerStatus) => void>(); + +const updateStatus = (status: LoggerStatus) => { + sharedStatus = status; + listeners.forEach((l) => l(status)); +}; + +export const getLoggerStatus = () => sharedStatus; + +export function useLogger() { + const [status, setStatus] = useState(sharedStatus); + + useEffect(() => { + listeners.add(setStatus); + return () => { + listeners.delete(setStatus); + }; + }, []); + + const log = (enable: boolean) => { + if (sharedTimeout) clearTimeout(sharedTimeout); + + updateStatus("loading"); + + socketService.post("logger/enable", enable); + + sharedTimeout = setTimeout(() => { + updateStatus("error"); + sharedTimeout = null; + }, LOGGER_RESPONSE_TIMEOUT); + }; + + useTopic("logger/response", (isLogging) => { + if (sharedTimeout) { + clearTimeout(sharedTimeout); + sharedTimeout = null; + } + updateStatus(isLogging ? "recording" : "standby"); + }); + + // Unlike testing-view, competition-view has no variable filter UI, so no + // `logger/variables` is posted and the backend logs every variable. + const startLogging = () => { + if (sharedStatus === "recording" || sharedStatus === "loading") return; + logger.competitionView.log("Starting logger..."); + log(true); + }; + + const stopLogging = () => { + if (sharedStatus !== "recording") return; + logger.competitionView.log("Stopping logger..."); + log(false); + }; + + const toggleLogging = () => + sharedStatus === "recording" ? stopLogging() : startLogging(); + + return { status, startLogging, stopLogging, toggleLogging }; +} diff --git a/frontend/competition-view/src/types/logger.ts b/frontend/competition-view/src/types/logger.ts new file mode 100644 index 000000000..0c364c272 --- /dev/null +++ b/frontend/competition-view/src/types/logger.ts @@ -0,0 +1,4 @@ +/** + * Status of the logger. + */ +export type LoggerStatus = "standby" | "recording" | "loading" | "error"; From d35437f1aa0bcf949ae7e24d210f6ee12d59512d Mon Sep 17 00:00:00 2001 From: lcastel Date: Wed, 15 Jul 2026 10:39:51 +0200 Subject: [PATCH 21/29] fix(competition-view): fixed message timestamp --- frontend/competition-view/src/lib/message.ts | 13 ++++++++----- .../src/pages/Overview/Overview.tsx | 18 +++++++++++++++--- .../Overview/components/BoardsOverviewCard.tsx | 7 ++++++- .../Overview/components/TrackProgress.tsx | 7 ++++++- 4 files changed, 35 insertions(+), 10 deletions(-) diff --git a/frontend/competition-view/src/lib/message.ts b/frontend/competition-view/src/lib/message.ts index 1452f672d..673ded83f 100644 --- a/frontend/competition-view/src/lib/message.ts +++ b/frontend/competition-view/src/lib/message.ts @@ -7,6 +7,9 @@ export const formatMessageTimestamp = (ts: MessageTimestamp | undefined): string return `${pad(ts.hour)}:${pad(ts.minute)}:${pad(ts.second)}`; }; +/** Caps a raw telemetry float to 2 decimal places for display. */ +const fmt = (n: number): string => (Number.isFinite(n) ? n.toFixed(2) : String(n)); + /** Human-readable text for a message's payload, whether it's a plain string or a detailed protection object. */ export const messageContent = (payload: MessagePacket["payload"]): string => { if (typeof payload === "string") return payload; @@ -17,16 +20,16 @@ export const messageContent = (payload: MessagePacket["payload"]): string => { switch (kind) { case "OUT_OF_BOUNDS": - return `Value: ${data.value} (Bounds: [${data.bounds[0]}, ${data.bounds[1]}])`; + return `Value: ${fmt(data.value)} (Bounds: [${fmt(data.bounds[0])}, ${fmt(data.bounds[1])}])`; case "UPPER_BOUND": case "LOWER_BOUND": - return `Value: ${data.value} (Limit: ${data.bound})`; + return `Value: ${fmt(data.value)} (Limit: ${fmt(data.bound)})`; case "EQUALS": - return `Value: ${data.value}`; + return `Value: ${fmt(data.value)}`; case "NOT_EQUALS": - return `Value: ${data.value} (Expected: ${data.want})`; + return `Value: ${fmt(data.value)} (Expected: ${fmt(data.want)})`; case "TIME_ACCUMULATION": - return `Value: ${data.value} for ${data.timelimit}s (Limit: ${data.bound})`; + return `Value: ${fmt(data.value)} for ${fmt(data.timelimit)}s (Limit: ${fmt(data.bound)})`; default: return JSON.stringify(data); } diff --git a/frontend/competition-view/src/pages/Overview/Overview.tsx b/frontend/competition-view/src/pages/Overview/Overview.tsx index 978f55fce..1083c78f3 100644 --- a/frontend/competition-view/src/pages/Overview/Overview.tsx +++ b/frontend/competition-view/src/pages/Overview/Overview.tsx @@ -10,6 +10,7 @@ import { Zap, } from "lucide-react"; import { useEffect, useRef, useState } from "react"; +import { formatAxisValue } from "../../constants/chartConfig"; import { BOARDS, DC_BUS_V_RANGE, @@ -72,8 +73,19 @@ const EMS_SERIES: SeriesConfig[] = [ /* ─── Helpers ───────────────────────────────────────────────────────────── */ -const fmtNum = (v: number | boolean | string | undefined, decimals = 1) => - typeof v === "number" ? v.toFixed(decimals) : undefined; +/** + * Formats a telemetry number for display, capped to `decimals` places. + * Garbage/misdecoded samples can come through as huge magnitudes (a + * float64 reinterpreted from bad bytes can reach ~1e308) — those fall + * back to the same compact/exponential formatting used on chart axes + * instead of printing dozens of digits. + */ +const fmtNum = (v: number | boolean | string | undefined, decimals = 1) => { + if (typeof v !== "number" || !Number.isFinite(v)) return undefined; + const abs = Math.abs(v); + if (abs !== 0 && (abs >= 1e6 || abs < 1e-3)) return formatAxisValue(v); + return v.toFixed(decimals); +}; /* ─── Battery card ──────────────────────────────────────────────────────── */ @@ -115,7 +127,7 @@ const BatteryCard = ({ title, icon: Icon, soc, socStale, rows }: BatteryCardProp {title} - {typeof soc === "number" ? soc.toFixed(0) : "—"} + {fmtNum(soc, 0) ?? "—"} %
diff --git a/frontend/competition-view/src/pages/Overview/components/BoardsOverviewCard.tsx b/frontend/competition-view/src/pages/Overview/components/BoardsOverviewCard.tsx index 84a832a99..3e54b53ad 100644 --- a/frontend/competition-view/src/pages/Overview/components/BoardsOverviewCard.tsx +++ b/frontend/competition-view/src/pages/Overview/components/BoardsOverviewCard.tsx @@ -1,4 +1,5 @@ import { BatteryFull, Cpu, type LucideIcon, Waves, Zap } from "lucide-react"; +import { formatAxisValue } from "../../../constants/chartConfig"; import { BOARDS, HVBMS, LCU, PCU_BOARD, VCU } from "../../../constants/measurements"; import { useIsStale } from "../../../hooks/useIsStale"; import useMeasurement from "../../../hooks/useMeasurement"; @@ -69,9 +70,13 @@ const ROWS: BoardRow[] = [ const StatItem = ({ board, stat }: { board: string; stat: Stat }) => { const raw = useMeasurement(board, stat.measurementKey); const stale = useIsStale(board, stat.measurementKey); + // Garbage/misdecoded samples can come through as huge magnitudes; fall + // back to compact/exponential formatting instead of a long digit string. const display = typeof raw === "number" - ? raw.toFixed(stat.decimals ?? 1) + ? Number.isFinite(raw) && Math.abs(raw) !== 0 && (Math.abs(raw) >= 1e6 || Math.abs(raw) < 1e-3) + ? formatAxisValue(raw) + : raw.toFixed(stat.decimals ?? 1) : typeof raw === "boolean" && stat.boolLabels ? stat.boolLabels[raw ? 0 : 1] : raw !== undefined diff --git a/frontend/competition-view/src/pages/Overview/components/TrackProgress.tsx b/frontend/competition-view/src/pages/Overview/components/TrackProgress.tsx index 0f4cc910b..f7a14c346 100644 --- a/frontend/competition-view/src/pages/Overview/components/TrackProgress.tsx +++ b/frontend/competition-view/src/pages/Overview/components/TrackProgress.tsx @@ -3,6 +3,7 @@ import { MapPin } from "lucide-react"; import { useEffect, useRef, useState } from "react"; import podIconDark from "../../../assets/pod-dark.svg"; import podIconLight from "../../../assets/pod-light.svg"; +import { formatAxisValue } from "../../../constants/chartConfig"; import { BOARDS, PCU } from "../../../constants/measurements"; import { TRACK_LENGTH_M } from "../../../constants/track"; import { useIsStale } from "../../../hooks/useIsStale"; @@ -78,7 +79,11 @@ const TrackProgress = () => {
- {typeof position === "number" ? position.toFixed(1) : "—"} + {typeof position === "number" && Number.isFinite(position) + ? Math.abs(position) >= 1e6 + ? formatAxisValue(position) + : position.toFixed(1) + : "—"} / {TRACK_LENGTH_M} m From ee9992f0aa6ce8db013de520adbe6528dc871a49 Mon Sep 17 00:00:00 2001 From: Javier Ribal del Rio Date: Wed, 15 Jul 2026 18:45:28 +0200 Subject: [PATCH 22/29] fix(connection): send fault if frontend disconnect --- backend/cmd/setup_vehicle.go | 9 +++++++ backend/pkg/websocket/client.go | 14 +++++++++++ backend/pkg/websocket/pool.go | 7 ++++++ frontend/frontend-kit/core/src/websocket.ts | 26 +++++++++++++++++++++ 4 files changed, 56 insertions(+) diff --git a/backend/cmd/setup_vehicle.go b/backend/cmd/setup_vehicle.go index 19ce8765b..64408a66c 100644 --- a/backend/cmd/setup_vehicle.go +++ b/backend/cmd/setup_vehicle.go @@ -64,6 +64,15 @@ func configureBroker(subloggers abstraction.SubloggersMap, loggerHandler *logger pool := websocket.NewPool(connections, trace.Logger) pool.SetOnDisconnect(func(count int) { if count == 0 { + // Losing the last control-station GUI (closed, crashed or hung: + // heartbeats stop and the read deadline expires) must put the + // vehicle in a safe state, as required by competition rules. + trace.Warn().Msg("no clients connected, sending FAULT order") + faultOrder := &order_topic.Order{Id: 0, Fields: map[string]order_topic.Field{}} + if err := broker.UserPush(faultOrder); err != nil { + trace.Error().Err(err).Msg("failed to send fault order on client disconnect") + } + trace.Info().Msg("no clients connected, stopping logger") loggerHandler.Stop() if err := loggerTopic.NotifyStopped(); err != nil { diff --git a/backend/pkg/websocket/client.go b/backend/pkg/websocket/client.go index f69235fdf..7b2c82b2c 100644 --- a/backend/pkg/websocket/client.go +++ b/backend/pkg/websocket/client.go @@ -9,6 +9,15 @@ import ( ws "github.com/gorilla/websocket" ) +// HeartbeatTopic is the liveness message every frontend sends once per +// second. It is consumed by the websocket layer and never reaches the broker. +const HeartbeatTopic = "connection/heartbeat" + +// heartbeatTimeout bounds how long a dead or frozen frontend goes undetected: +// if no message (the heartbeat guarantees one per second) arrives within this +// window, Read fails and the client is treated as disconnected. +const heartbeatTimeout = 3 * time.Second + type Client struct { readMx *sync.Mutex writeMx *sync.Mutex @@ -26,6 +35,8 @@ func NewClient(conn *ws.Conn) *Client { onClose: func() {}, } + conn.SetReadDeadline(time.Now().Add(heartbeatTimeout)) + return client } @@ -46,6 +57,9 @@ func (client *Client) Read() (Message, error) { var message Message err := client.conn.ReadJSON(&message) + if err == nil { + client.conn.SetReadDeadline(time.Now().Add(heartbeatTimeout)) + } return message, err } diff --git a/backend/pkg/websocket/pool.go b/backend/pkg/websocket/pool.go index 41736015e..d53ae89b9 100644 --- a/backend/pkg/websocket/pool.go +++ b/backend/pkg/websocket/pool.go @@ -84,6 +84,13 @@ func (pool *Pool) handle(id ClientId, client *Client) { } clientLogger.Trace().Msg("read") + + // Heartbeats only refresh the read deadline; they carry no payload + // for the broker. + if string(message.Topic) == HeartbeatTopic { + continue + } + pool.onMessage(id, &message) } } diff --git a/frontend/frontend-kit/core/src/websocket.ts b/frontend/frontend-kit/core/src/websocket.ts index 649818f1f..03e291a6a 100644 --- a/frontend/frontend-kit/core/src/websocket.ts +++ b/frontend/frontend-kit/core/src/websocket.ts @@ -64,6 +64,11 @@ class SocketService { */ private ws: WebSocketSubject | null = null; + /** + * Handle of the interval that emits the liveness heartbeat while connected. + */ + private heartbeatInterval: ReturnType | null = null; + /** * Connects to the WebSocket server by creating a new connection object and pushing it to `socketSource$`. * @param port - the port to connect to. Defaults to 4000. @@ -81,6 +86,7 @@ class SocketService { next: () => { this.status$.next("connected"); logger.core.log("WebSocket connected"); + this.startHeartbeat(); }, }, }); @@ -102,10 +108,30 @@ class SocketService { this.socketSource$.next(this.ws); } + /** + * Starts the periodic heartbeat that lets the backend detect a dead or + * frozen GUI. The backend expects one at least every 3 seconds and treats + * a missing heartbeat as a disconnection. + */ + private startHeartbeat() { + this.stopHeartbeat(); + this.heartbeatInterval = setInterval(() => { + this.ws?.next({ topic: "connection/heartbeat", payload: {} }); + }, 1000); + } + + private stopHeartbeat() { + if (this.heartbeatInterval !== null) { + clearInterval(this.heartbeatInterval); + this.heartbeatInterval = null; + } + } + /** * Cleans up the WebSocket connection by setting the connection object to null and updating the status to "disconnected". */ private cleanup() { + this.stopHeartbeat(); this.ws = null; this.status$.next("disconnected"); } From 467dbbf22702afbcff38b625d32dd35f7423527f Mon Sep 17 00:00:00 2001 From: lcastel Date: Wed, 15 Jul 2026 20:05:29 +0200 Subject: [PATCH 23/29] feat(competition-view): side panel with full orders functionality --- .../src/components/header/Header.tsx | 2 + .../src/components/header/OrdersSheet.tsx | 74 +++++++++++++++ .../src/pages/Orders/components/OrderRow.tsx | 94 +++++++++++++++++++ .../Orders/components/OrdersCatalogList.tsx | 53 +++++++++++ 4 files changed, 223 insertions(+) create mode 100644 frontend/competition-view/src/components/header/OrdersSheet.tsx create mode 100644 frontend/competition-view/src/pages/Orders/components/OrderRow.tsx create mode 100644 frontend/competition-view/src/pages/Orders/components/OrdersCatalogList.tsx diff --git a/frontend/competition-view/src/components/header/Header.tsx b/frontend/competition-view/src/components/header/Header.tsx index 3e280d95a..ac107e9d7 100644 --- a/frontend/competition-view/src/components/header/Header.tsx +++ b/frontend/competition-view/src/components/header/Header.tsx @@ -6,6 +6,7 @@ import useConnections from "../../hooks/useConnections"; import ConnectionBadge from "./ConnectionBadge"; import DashboardStatusBar from "./DashboardStatusBar"; import HvalIndicator from "./HvalIndicator"; +import OrdersSheet from "./OrdersSheet"; interface HeaderProps { backendConnected: boolean; @@ -36,6 +37,7 @@ const Header = ({ backendConnected }: HeaderProps) => {
+
); diff --git a/frontend/competition-view/src/components/header/OrdersSheet.tsx b/frontend/competition-view/src/components/header/OrdersSheet.tsx new file mode 100644 index 000000000..6b85490b5 --- /dev/null +++ b/frontend/competition-view/src/components/header/OrdersSheet.tsx @@ -0,0 +1,74 @@ +import { + Button, + InputGroup, + InputGroupInput, + Sheet, + SheetContent, + SheetHeader, + SheetTitle, + SheetTrigger, + Skeleton, +} from "@workspace/ui/components"; +import { Send } from "@workspace/ui/icons"; +import { useWebSocket } from "@workspace/ui/hooks"; +import { useState } from "react"; +import useOrdersCatalog from "../../hooks/useOrdersCatalog"; +import { useStore } from "../../store/store"; +import OrdersCatalogList from "../../pages/Orders/components/OrdersCatalogList"; + +/** + * Side sheet with the classic catalog-style order list (search + outline + * Send buttons), opened via a header button. The always-visible dashboard + * Orders panel (OrdersPanel.tsx) keeps the colour-coded grid — this sheet + * is an additional, searchable view over the same state-machine order set. + */ +const OrdersSheet = () => { + const { isConnected } = useWebSocket(); + const commandsCatalog = useStore((s) => s.commandsCatalog); + const [filter, setFilter] = useState(""); + + const { loading } = useOrdersCatalog(isConnected); + + return ( + + + + + + + + + Orders + + + +
+ + setFilter(e.target.value)} + /> + + +
+ {loading ? ( +
+ {Array.from({ length: 4 }).map((_, i) => ( + + ))} +
+ ) : ( + + )} +
+
+
+
+ ); +}; + +export default OrdersSheet; diff --git a/frontend/competition-view/src/pages/Orders/components/OrderRow.tsx b/frontend/competition-view/src/pages/Orders/components/OrderRow.tsx new file mode 100644 index 000000000..0aaed86b1 --- /dev/null +++ b/frontend/competition-view/src/pages/Orders/components/OrderRow.tsx @@ -0,0 +1,94 @@ +import { + Badge, + Button, + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@workspace/ui/components"; +import { Check, Send } from "@workspace/ui/icons"; +import { useState } from "react"; +import useSendOrder from "../../../hooks/useSendOrder"; +import useMeasurement from "../../../hooks/useMeasurement"; +import type { CommandCatalogItem } from "../../../types/catalog"; +import { BOARDS, VCU } from "../../../constants/measurements"; +import { isVcuOrderAllowedInState } from "../../../constants/vcuStateMachine"; + +interface OrderRowProps { + item: CommandCatalogItem; + /** Name of the board this order belongs to, as reported by the catalog. */ + board: string; + isConnected: boolean; +} + +/** + * Classic catalog-style order row: name + id badge, with an outline + * Send button (used in the Orders side sheet — the always-visible + * dashboard Orders panel uses the colour-coded grid instead, see + * OrdersList.tsx/OrderButton.tsx). + * + * - Send is disabled while the WS is disconnected, or (for VCU orders) the + * current vehicle state doesn't allow it — either way a tooltip explains why. + * - After a successful dispatch the button briefly shows a checkmark. + */ +const OrderRow = ({ item, board, isConnected }: OrderRowProps) => { + const sendOrder = useSendOrder(); + const vcuState = useMeasurement(BOARDS.VCU, VCU.state); + + const [sent, setSent] = useState(false); + + // Only VCU orders are gated by the vehicle state machine. + const stateAllowsOrder = + board !== BOARDS.VCU || isVcuOrderAllowedInState(item.id, vcuState as string | undefined); + + const canSend = isConnected && !sent && stateAllowsOrder; + + const handleSend = () => { + if (!canSend) return; + sendOrder([{ id: item.id, fields: {} }]); + setSent(true); + setTimeout(() => setSent(false), 1500); + }; + + const sendButton = ( + + ); + + const sendButtonWithTooltip = !isConnected ? ( + + + {sendButton} + + Not connected to backend + + ) : !stateAllowsOrder ? ( + + + {sendButton} + + Not available in current vehicle state{vcuState ? ` (${vcuState})` : ""} + + ) : ( + sendButton + ); + + return ( +
+
+ {item.name} + {item.id} +
+ {sendButtonWithTooltip} +
+ ); +}; + +export default OrderRow; diff --git a/frontend/competition-view/src/pages/Orders/components/OrdersCatalogList.tsx b/frontend/competition-view/src/pages/Orders/components/OrdersCatalogList.tsx new file mode 100644 index 000000000..a8b07f571 --- /dev/null +++ b/frontend/competition-view/src/pages/Orders/components/OrdersCatalogList.tsx @@ -0,0 +1,53 @@ +import type { BoardOrdersData, CommandCatalogItem } from "../../../types/catalog"; +import { BOARDS } from "../../../constants/measurements"; +import { VCU_GATED_ORDER_IDS } from "../../../constants/vcuStateMachine"; +import OrderRow from "./OrderRow"; + +interface OrdersCatalogListProps { + /** Full catalog, keyed by board name (as received from the backend). */ + commandsCatalog: Record; + filter: string; + isConnected: boolean; +} + +const matchesFilter = (item: CommandCatalogItem, filter: string) => { + if (!filter) return true; + const q = filter.toLowerCase(); + return item.name.toLowerCase().includes(q) || String(item.id).includes(q); +}; + +/** + * Classic flat catalog list (name + id badge + outline Send button per row, + * search filtering) — used in the Orders side sheet. Restricted to the + * orders defined by the vehicle state machine (constants/vcuStateMachine.ts): + * no per-board grouping, and orders outside that set (other boards, or VCU + * orders not part of the diagram) aren't rendered at all. Every order in the + * set is always shown; OrderRow disables + tooltips the ones the current + * vehicle state doesn't allow. + */ +const OrdersCatalogList = ({ commandsCatalog, filter, isConnected }: OrdersCatalogListProps) => { + const vcuOrders = commandsCatalog[BOARDS.VCU]?.orders ?? []; + + const items = VCU_GATED_ORDER_IDS + .map((id) => vcuOrders.find((o) => o.id === id)) + .filter((o): o is CommandCatalogItem => o !== undefined) + .filter((o) => matchesFilter(o, filter)); + + if (items.length === 0) { + return ( +

+ {filter ? "No orders match your search." : "No orders available — check the backend connection."} +

+ ); + } + + return ( +
+ {items.map((order) => ( + + ))} +
+ ); +}; + +export default OrdersCatalogList; From 1e97ada07bc16e8c47a45e902c849b0c892bd0c8 Mon Sep 17 00:00:00 2001 From: lcastel Date: Wed, 15 Jul 2026 20:18:31 +0200 Subject: [PATCH 24/29] fix(competition-view): add parametrized orders into order side panel --- .../src/constants/vcuStateMachine.ts | 9 ++ .../Orders/components/OrderParameters.tsx | 63 +++++++++ .../src/pages/Orders/components/OrderRow.tsx | 133 +++++++++++++++--- .../Orders/components/OrdersCatalogList.tsx | 19 +-- 4 files changed, 197 insertions(+), 27 deletions(-) create mode 100644 frontend/competition-view/src/pages/Orders/components/OrderParameters.tsx diff --git a/frontend/competition-view/src/constants/vcuStateMachine.ts b/frontend/competition-view/src/constants/vcuStateMachine.ts index 9ddf7f196..01a082b2a 100644 --- a/frontend/competition-view/src/constants/vcuStateMachine.ts +++ b/frontend/competition-view/src/constants/vcuStateMachine.ts @@ -15,6 +15,15 @@ export type VcuState = (typeof VCU_STATES)[number]; export const VCU_GATED_ORDER_IDS = [0, 30, 40, 41, 50, 51, 60, 61, 62] as const; +/** + * Parameterized variants of the propulsion/levitation orders (they take + * fields — target speed/current/height — so aren't shown as plain buttons + * in the state-machine diagram/grid). Not part of VCU_ORDER_STATE_MATRIX, + * so isVcuOrderAllowedInState treats them as always allowed regardless of + * vehicle state. Shown only in the Orders side sheet's classic catalog list. + */ +export const VCU_PARAMETERIZED_ORDER_IDS = [100, 101, 102] as const; + const VCU_ORDER_STATE_MATRIX: readonly (0 | 1)[][] = [ /* Idle */ [1, 0, 0, 0, 0, 0, 0, 0, 0], /* Connected */ [1, 0, 1, 1, 0, 0, 0, 0, 0], diff --git a/frontend/competition-view/src/pages/Orders/components/OrderParameters.tsx b/frontend/competition-view/src/pages/Orders/components/OrderParameters.tsx new file mode 100644 index 000000000..fc31572ae --- /dev/null +++ b/frontend/competition-view/src/pages/Orders/components/OrderParameters.tsx @@ -0,0 +1,63 @@ +import { + Checkbox, + Field, + FieldLabel, + Input, + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@workspace/ui/components"; +import type { CommandParameter, ParameterValues } from "../../../types/catalog"; + +interface OrderParametersProps { + fields: Record; + values: ParameterValues; + onChange: (key: string, value: string | number | boolean) => void; +} + +/** Compact parameter form for orders that take fields (e.g. Propulsion Parameterized). */ +const OrderParameters = ({ fields, values, onChange }: OrderParametersProps) => ( +
+ {Object.entries(fields).map(([key, param]) => ( + + {param.name} + {param.kind === "numeric" && ( + onChange(key, e.target.value)} + placeholder={ + param.safeRange[0] != null && param.safeRange[1] != null + ? `${param.safeRange[0]}–${param.safeRange[1]}` + : undefined + } + /> + )} + {param.kind === "enum" && ( + + )} + {param.kind === "boolean" && ( + onChange(key, checked === true)} + /> + )} + + ))} +
+); + +export default OrderParameters; diff --git a/frontend/competition-view/src/pages/Orders/components/OrderRow.tsx b/frontend/competition-view/src/pages/Orders/components/OrderRow.tsx index 0aaed86b1..7353847ce 100644 --- a/frontend/competition-view/src/pages/Orders/components/OrderRow.tsx +++ b/frontend/competition-view/src/pages/Orders/components/OrderRow.tsx @@ -1,17 +1,22 @@ import { Badge, Button, + Collapsible, + CollapsibleContent, + CollapsibleTrigger, Tooltip, TooltipContent, TooltipTrigger, } from "@workspace/ui/components"; -import { Check, Send } from "@workspace/ui/icons"; +import { Check, ChevronDown, Send } from "@workspace/ui/icons"; import { useState } from "react"; import useSendOrder from "../../../hooks/useSendOrder"; import useMeasurement from "../../../hooks/useMeasurement"; -import type { CommandCatalogItem } from "../../../types/catalog"; +import type { CommandCatalogItem, ParameterValues } from "../../../types/catalog"; +import type { OrderFieldValue } from "../../../constants/orders"; import { BOARDS, VCU } from "../../../constants/measurements"; import { isVcuOrderAllowedInState } from "../../../constants/vcuStateMachine"; +import OrderParameters from "./OrderParameters"; interface OrderRowProps { item: CommandCatalogItem; @@ -20,48 +25,97 @@ interface OrderRowProps { isConnected: boolean; } +const getDefaultValues = (item: CommandCatalogItem): ParameterValues => { + const defaults: ParameterValues = {}; + for (const [key, param] of Object.entries(item.fields)) { + if (param.kind === "numeric") defaults[key] = ""; + else if (param.kind === "enum") defaults[key] = param.options[0] ?? ""; + else if (param.kind === "boolean") defaults[key] = false; + } + return defaults; +}; + /** * Classic catalog-style order row: name + id badge, with an outline - * Send button (used in the Orders side sheet — the always-visible - * dashboard Orders panel uses the colour-coded grid instead, see - * OrdersList.tsx/OrderButton.tsx). + * Send button, and an expandable parameter form for orders that take + * fields (e.g. the Parameterized propulsion/levitation orders). Used + * in the Orders side sheet — the always-visible dashboard Orders panel + * uses the colour-coded grid instead (see OrdersList.tsx/OrderButton.tsx), + * which only covers the field-less orders from the state machine diagram. * - * - Send is disabled while the WS is disconnected, or (for VCU orders) the - * current vehicle state doesn't allow it — either way a tooltip explains why. + * - Send is disabled while the WS is disconnected, any required numeric + * field is empty/invalid, or (for VCU orders) the current vehicle state + * doesn't allow it — either way a tooltip explains why. * - After a successful dispatch the button briefly shows a checkmark. */ const OrderRow = ({ item, board, isConnected }: OrderRowProps) => { const sendOrder = useSendOrder(); const vcuState = useMeasurement(BOARDS.VCU, VCU.state); - const [sent, setSent] = useState(false); + const [values, setValues] = useState(() => getDefaultValues(item)); + const [open, setOpen] = useState(false); + const [sent, setSent] = useState(false); + + const hasParams = Object.keys(item.fields).length > 0; + + const hasInvalidNumeric = Object.entries(item.fields).some( + ([key, param]) => + param.kind === "numeric" && + !Number.isFinite(parseFloat(String(values[key]))), + ); // Only VCU orders are gated by the vehicle state machine. const stateAllowsOrder = board !== BOARDS.VCU || isVcuOrderAllowedInState(item.id, vcuState as string | undefined); - const canSend = isConnected && !sent && stateAllowsOrder; + const canSend = isConnected && !hasInvalidNumeric && !sent && stateAllowsOrder; const handleSend = () => { if (!canSend) return; - sendOrder([{ id: item.id, fields: {} }]); + + const fields = Object.entries(item.fields).reduce>( + (acc, [key, param]) => { + acc[key] = { + value: param.kind === "numeric" ? parseFloat(String(values[key])) : values[key], + isEnabled: true, + type: param.type, + }; + return acc; + }, + {}, + ); + + sendOrder([{ id: item.id, fields }]); + + // Brief confirmation state setSent(true); setTimeout(() => setSent(false), 1500); }; + const handleParamChange = (key: string, value: string | number | boolean) => + setValues((prev) => ({ ...prev, [key]: value })); + const sendButton = ( ); + // Wrap with tooltip explaining why the button is disabled const sendButtonWithTooltip = !isConnected ? ( @@ -76,18 +130,59 @@ const OrderRow = ({ item, board, isConnected }: OrderRowProps) => { Not available in current vehicle state{vcuState ? ` (${vcuState})` : ""} + ) : hasInvalidNumeric ? ( + + + {sendButton} + + Fill in all required fields first + ) : ( sendButton ); - return ( -
-
- {item.name} - {item.id} + if (!hasParams) { + return ( +
+
+ {item.name} + {item.id} +
+ {sendButtonWithTooltip}
- {sendButtonWithTooltip} -
+ ); + } + + return ( + + +
+ {item.name} + {item.id} + + {Object.keys(item.fields).length} params + +
+
+ {sendButtonWithTooltip} + +
+
+ + +
+ +
+
+
); }; diff --git a/frontend/competition-view/src/pages/Orders/components/OrdersCatalogList.tsx b/frontend/competition-view/src/pages/Orders/components/OrdersCatalogList.tsx index a8b07f571..3a7bb1be3 100644 --- a/frontend/competition-view/src/pages/Orders/components/OrdersCatalogList.tsx +++ b/frontend/competition-view/src/pages/Orders/components/OrdersCatalogList.tsx @@ -1,8 +1,11 @@ import type { BoardOrdersData, CommandCatalogItem } from "../../../types/catalog"; import { BOARDS } from "../../../constants/measurements"; -import { VCU_GATED_ORDER_IDS } from "../../../constants/vcuStateMachine"; +import { VCU_GATED_ORDER_IDS, VCU_PARAMETERIZED_ORDER_IDS } from "../../../constants/vcuStateMachine"; import OrderRow from "./OrderRow"; +/** State-machine orders plus their parameterized variants (Propulsion/Static/Dynamic Levitation Parameterized). */ +const CATALOG_ORDER_IDS: readonly number[] = [...VCU_GATED_ORDER_IDS, ...VCU_PARAMETERIZED_ORDER_IDS]; + interface OrdersCatalogListProps { /** Full catalog, keyed by board name (as received from the backend). */ commandsCatalog: Record; @@ -18,17 +21,17 @@ const matchesFilter = (item: CommandCatalogItem, filter: string) => { /** * Classic flat catalog list (name + id badge + outline Send button per row, - * search filtering) — used in the Orders side sheet. Restricted to the - * orders defined by the vehicle state machine (constants/vcuStateMachine.ts): - * no per-board grouping, and orders outside that set (other boards, or VCU - * orders not part of the diagram) aren't rendered at all. Every order in the - * set is always shown; OrderRow disables + tooltips the ones the current - * vehicle state doesn't allow. + * search filtering, expandable parameter forms) — used in the Orders side + * sheet. Restricted to the orders defined by the vehicle state machine + * (constants/vcuStateMachine.ts) plus their parameterized variants: no + * per-board grouping, and orders outside that set (other boards) aren't + * rendered at all. Every order in the set is always shown; OrderRow + * disables + tooltips the ones the current vehicle state doesn't allow. */ const OrdersCatalogList = ({ commandsCatalog, filter, isConnected }: OrdersCatalogListProps) => { const vcuOrders = commandsCatalog[BOARDS.VCU]?.orders ?? []; - const items = VCU_GATED_ORDER_IDS + const items = CATALOG_ORDER_IDS .map((id) => vcuOrders.find((o) => o.id === id)) .filter((o): o is CommandCatalogItem => o !== undefined) .filter((o) => matchesFilter(o, filter)); From f1bf1e599ed04b90ff517f9e9503647380f5be79 Mon Sep 17 00:00:00 2001 From: Javier Ribal del Rio Date: Thu, 16 Jul 2026 16:06:03 +0200 Subject: [PATCH 25/29] fix(competition-view): add interval errors --- .../src/constants/measurements.ts | 6 +++++- .../Batteries/components/HvBatterySection.tsx | 8 ++++---- .../src/pages/Overview/Overview.tsx | 20 ++++++++++--------- 3 files changed, 20 insertions(+), 14 deletions(-) diff --git a/frontend/competition-view/src/constants/measurements.ts b/frontend/competition-view/src/constants/measurements.ts index 3b961c31b..92b5a28e0 100644 --- a/frontend/competition-view/src/constants/measurements.ts +++ b/frontend/competition-view/src/constants/measurements.ts @@ -59,10 +59,14 @@ export const CELL_V_RANGE: readonly [number, number] = [2.7, 4.2]; export const PACK_V_RANGE: readonly [number, number] = [260, 400]; /** DC bus / DC link voltage interval (V). */ export const DC_BUS_V_RANGE: readonly [number, number] = [0, 400]; +/** HV battery current interval (A). */ +export const HV_CURRENT_RANGE: readonly [number, number] = [-10, 120]; /** Levitation coil current interval (A). */ export const LEV_CURRENT_RANGE: readonly [number, number] = [-55, 55]; /** Propulsion phase current interval (A). */ -export const PROP_CURRENT_RANGE: readonly [number, number] = [0, 120]; +export const PROP_CURRENT_RANGE: readonly [number, number] = [-20, 100]; +/** Propulsion velocity interval (km/h). */ +export const SPEED_RANGE: readonly [number, number] = [0, 120]; /** Cell voltage display range and warning thresholds (V), shared by the battery views. */ export const CELL_V_MIN = 3.0; diff --git a/frontend/competition-view/src/pages/Batteries/components/HvBatterySection.tsx b/frontend/competition-view/src/pages/Batteries/components/HvBatterySection.tsx index 68f5aafe7..ebd46e4d2 100644 --- a/frontend/competition-view/src/pages/Batteries/components/HvBatterySection.tsx +++ b/frontend/competition-view/src/pages/Batteries/components/HvBatterySection.tsx @@ -94,14 +94,14 @@ const HvBatterySection = () => { {label} {value} - - {unit} - {range && ( - [{range[0]}–{range[1]}] + [{range[0]}, {range[1]}] )} + + {unit} +
))} diff --git a/frontend/competition-view/src/pages/Overview/Overview.tsx b/frontend/competition-view/src/pages/Overview/Overview.tsx index 1083c78f3..45423b81c 100644 --- a/frontend/competition-view/src/pages/Overview/Overview.tsx +++ b/frontend/competition-view/src/pages/Overview/Overview.tsx @@ -14,12 +14,14 @@ import { formatAxisValue } from "../../constants/chartConfig"; import { BOARDS, DC_BUS_V_RANGE, + HV_CURRENT_RANGE, HVBMS, LCU, LEV_CURRENT_RANGE, PACK_V_RANGE, PCU, PROP_CURRENT_RANGE, + SPEED_RANGE, VCU, } from "../../constants/measurements"; import useMeasurement from "../../hooks/useMeasurement"; @@ -142,14 +144,14 @@ const BatteryCard = ({ title, icon: Icon, soc, socStale, rows }: BatteryCardProp {label} {value ?? "—"} - {value !== undefined && unit && ( - {unit} - )} {range && ( - [{range[0]}–{range[1]}] + [{range[0]}, {range[1]}] )} + {(value !== undefined || range) && unit && ( + {unit} + )}
))} @@ -358,7 +360,7 @@ const HvBatteryCard = () => { socStale={socStale} rows={[ { label: "Pack V", value: fmtNum(hvVoltage), unit: "V", stale: vStale, range: PACK_V_RANGE }, - { label: "Current", value: fmtNum(hvCurrent), unit: "A", stale: iStale }, + { label: "Current", value: fmtNum(hvCurrent), unit: "A", stale: iStale, range: HV_CURRENT_RANGE }, { label: "DC Link", value: fmtNum(hvVSensor), unit: "V", stale: dcStale, range: DC_BUS_V_RANGE }, ]} /> @@ -387,12 +389,12 @@ const Dashboard = () => { {/* Charts — 2 cols × 3 rows */}
- - + + - - + +
From de0d0c68af17500d12b4fd8e69890acc17883075 Mon Sep 17 00:00:00 2001 From: Javier Ribal del Rio Date: Thu, 16 Jul 2026 16:10:27 +0200 Subject: [PATCH 26/29] feat():sdf --- backend/cmd/config.toml | 4 +- backend/cmd/setup_transport.go | 27 ++-- backend/internal/config/config_types.go | 12 +- backend/pkg/transport/keepalive.go | 72 +++++++++ backend/pkg/transport/network/tcp/config.go | 20 +-- backend/scripts/analyze_trace.py | 158 ++++++++++++++++++++ 6 files changed, 257 insertions(+), 36 deletions(-) create mode 100644 backend/pkg/transport/keepalive.go create mode 100644 backend/scripts/analyze_trace.py diff --git a/backend/cmd/config.toml b/backend/cmd/config.toml index fde463245..353d3e644 100644 --- a/backend/cmd/config.toml +++ b/backend/cmd/config.toml @@ -27,8 +27,8 @@ backoff_min_ms = 100 # Minimum backoff duration in milliseconds backoff_max_ms = 5000 # Maximum backoff duration in milliseconds backoff_multiplier = 1.5 # Exponential backoff multiplier (e.g., 1.5 means each retry waits 1.5x longer) max_retries = 0 # Maximum retries before cycling (0 = infinite retries, recommended for persistent reconnection) -connection_timeout_ms = 3000 # Connection timeout in milliseconds -keep_alive_ms = 1000 # Keep-alive interval in milliseconds +keep_alive_interval_ms = 50 + # UDP Configuration # These settings control the UDP server's buffer sizes and performance characteristics diff --git a/backend/cmd/setup_transport.go b/backend/cmd/setup_transport.go index 9611b7cc1..eabef6a93 100644 --- a/backend/cmd/setup_transport.go +++ b/backend/cmd/setup_transport.go @@ -1,7 +1,6 @@ package main import ( - "context" "encoding/binary" "fmt" "net" @@ -52,6 +51,8 @@ func configureTransport( // Start handling network packets using UDP server configureUDPServerTransport(adj, transp, config) + // Start the application-level TCP keep-alive (empty order with id 1) + go transp.HandleKeepAlive(time.Duration(config.TCP.KeepAliveIntervalMs) * time.Millisecond) } func configureTCPClientTransport( @@ -86,11 +87,6 @@ func configureTCPClientTransport( clientConfig.Timeout = time.Duration(config.TCP.ConnectionTimeout) * time.Millisecond } - // Apply custom keep-alive if specified - if config.TCP.KeepAlive > 0 { - clientConfig.KeepAlive = time.Duration(config.TCP.KeepAlive) * time.Millisecond - } - // Apply custom backoff parameters if config.TCP.BackoffMinMs > 0 || config.TCP.BackoffMaxMs > 0 || config.TCP.BackoffMultiplier > 0 { minBackoff := 100 * time.Millisecond // default @@ -118,18 +114,12 @@ func configureTCPClientTransport( } } -// configureTCPServerTransport starts the TCP server handler using a ListenConfig with KeepAlive. +// configureTCPServerTransport starts the TCP server handler. func configureTCPServerTransport( adj adj_module.ADJ, transp *transport.Transport, ) { - go transp.HandleServer(tcp.ServerConfig{ - ListenConfig: net.ListenConfig{ - KeepAlive: time.Second, - }, - Context: context.TODO(), - }, fmt.Sprintf("%s:%d", adj.Info.Addresses[BACKEND], adj.Info.Ports[TcpServer])) - + go transp.HandleServer(tcp.NewServerConfig(), fmt.Sprintf("%s:%d", adj.Info.Addresses[BACKEND], adj.Info.Ports[TcpServer])) } // configureUDPServerTransport creates and starts the UDP server then delegates handling to transport. @@ -242,6 +232,15 @@ func getTransportDecEnc(info adj_module.Info, podData pod_data.PodData) (*presen encoder.SetPacketEncoder(id, dataEncoder) } + // The TCP keep-alive order is an empty packet, so give it an empty + // descriptor unless the ADJ already defines packet id 1 + if !common.Contains(ids, transport.KeepAliveId) { + dataDecoder.SetDescriptor(transport.KeepAliveId, data.Descriptor{}) + dataEncoder.SetDescriptor(transport.KeepAliveId, data.Descriptor{}) + decoder.SetPacketDecoder(transport.KeepAliveId, dataDecoder) + encoder.SetPacketEncoder(transport.KeepAliveId, dataEncoder) + } + // TODO Solve this foking mess, I have tried... stateOrdersDecoder := order.NewDecoder(binary.LittleEndian) stateOrdersDecoder.SetActionId(abstraction.PacketId(info.MessageIds[AddStateOrder]), stateOrdersDecoder.DecodeAdd) diff --git a/backend/internal/config/config_types.go b/backend/internal/config/config_types.go index b0957b0ef..75e5a03db 100644 --- a/backend/internal/config/config_types.go +++ b/backend/internal/config/config_types.go @@ -16,12 +16,12 @@ type Transport struct { } type TCP struct { - BackoffMinMs int `toml:"backoff_min_ms"` - BackoffMaxMs int `toml:"backoff_max_ms"` - BackoffMultiplier float64 `toml:"backoff_multiplier"` - MaxRetries int `toml:"max_retries"` - ConnectionTimeout int `toml:"connection_timeout_ms"` - KeepAlive int `toml:"keep_alive_ms"` + BackoffMinMs int `toml:"backoff_min_ms"` + BackoffMaxMs int `toml:"backoff_max_ms"` + BackoffMultiplier float64 `toml:"backoff_multiplier"` + MaxRetries int `toml:"max_retries"` + ConnectionTimeout int `toml:"connection_timeout_ms"` + KeepAliveIntervalMs int `toml:"keep_alive_interval_ms"` } type UDP struct { diff --git a/backend/pkg/transport/keepalive.go b/backend/pkg/transport/keepalive.go new file mode 100644 index 000000000..6b7672101 --- /dev/null +++ b/backend/pkg/transport/keepalive.go @@ -0,0 +1,72 @@ +package transport + +import ( + "errors" + "fmt" + "net" + "time" + + "github.com/HyperloopUPV-H8/h9-backend/pkg/abstraction" + "github.com/HyperloopUPV-H8/h9-backend/pkg/transport/packet/data" +) + +// KeepAliveId is the packet id of the empty order used as the +// application-level TCP keep-alive. +const KeepAliveId abstraction.PacketId = 1 + +// DefaultKeepAliveInterval is how often the keep-alive order is sent to each +// board when no interval is configured. +const DefaultKeepAliveInterval = 50 * time.Millisecond + +// HandleKeepAlive broadcasts an empty order with id KeepAliveId to every +// connected board every interval. Write errors are forwarded to the +// connection handler by the connection wrapper, which tears the connection +// down. This method blocks. +func (transport *Transport) HandleKeepAlive(interval time.Duration) { + if interval <= 0 { + interval = DefaultKeepAliveInterval + } + // The keep-alive frame never changes, so encode it once up front + buf, err := transport.encoder.Encode(data.NewPacket(KeepAliveId)) + if err != nil { + transport.logger.Error().Stack().Err(err).Msg("encode keep-alive") + transport.errChan <- err + return + } + frame := make([]byte, buf.Len()) + copy(frame, buf.Bytes()) + transport.encoder.ReleaseBuffer(buf) + + ticker := time.NewTicker(interval) + defer ticker.Stop() + for range ticker.C { + transport.broadcastKeepAlive(frame, interval) + } +} + +// broadcastKeepAlive writes the keep-alive frame to all active connections. +func (transport *Transport) broadcastKeepAlive(frame []byte, interval time.Duration) { + transport.connectionsMx.RLock() + defer transport.connectionsMx.RUnlock() + + for target, conn := range transport.connections { + // Bound the write so a dead peer with a full send buffer cannot hold + // the connections lock past the next tick + conn.SetWriteDeadline(time.Now().Add(interval)) + totalWritten := 0 + for totalWritten < len(frame) { + n, err := conn.Write(frame[totalWritten:]) + totalWritten += n + if err != nil { + // net.ErrClosed means the connection was closed on purpose + // and its handler already reported the reason + if !errors.Is(err, net.ErrClosed) { + transport.logger.Error().Stack().Err(err).Str("target", string(target)).Msg("keep-alive write") + transport.errChan <- fmt.Errorf("TCP keep-alive to board %s failed: %w", target, err) + } + break + } + } + conn.SetWriteDeadline(time.Time{}) + } +} diff --git a/backend/pkg/transport/network/tcp/config.go b/backend/pkg/transport/network/tcp/config.go index 976b42c39..7de139e98 100644 --- a/backend/pkg/transport/network/tcp/config.go +++ b/backend/pkg/transport/network/tcp/config.go @@ -23,13 +23,9 @@ func NewClientConfig(laddr net.Addr) ClientConfig { Dialer: net.Dialer{ Timeout: time.Second, LocalAddr: laddr, - KeepAlive: -1, // managed via KeepAliveConfig - KeepAliveConfig: net.KeepAliveConfig{ - Enable: true, - Idle: time.Second, - Interval: time.Second, - Count: 3, - }, + // Kernel keep-alive disabled: liveness is handled by the + // application-level keep-alive order (transport.HandleKeepAlive) + KeepAlive: -1, }, Context: context.TODO(), @@ -73,13 +69,9 @@ type ServerConfig struct { func NewServerConfig() ServerConfig { return ServerConfig{ ListenConfig: net.ListenConfig{ - KeepAlive: -1, // managed via KeepAliveConfig - KeepAliveConfig: net.KeepAliveConfig{ - Enable: true, - Idle: time.Second, - Interval: time.Second, - Count: 3, - }, + // Kernel keep-alive disabled: liveness is handled by the + // application-level keep-alive order (transport.HandleKeepAlive) + KeepAlive: -1, }, Context: context.TODO(), } diff --git a/backend/scripts/analyze_trace.py b/backend/scripts/analyze_trace.py new file mode 100644 index 000000000..acc7b58d0 --- /dev/null +++ b/backend/scripts/analyze_trace.py @@ -0,0 +1,158 @@ +#!/usr/bin/env python3 +"""Analyze a backend trace-*.jsonl file. + +Finds the two fault events logged by the transport layer: + - discarded packets -> "Ring buffer full, overwriting oldest UDP packet" + - time limit exceeded -> "packet interval exceeded, propagating fault" + +Produces a timeline plot (PNG + interactive window) and a .txt summary +next to the trace file. + +Usage: + python3 analyze_trace.py [trace.jsonl | logger-date-dir] +If no argument is given, the oldest date directory under cmd/logger is used. +""" + +import json +import sys +from collections import Counter +from datetime import datetime +from pathlib import Path + +import matplotlib.pyplot as plt + +DISCARD_MSG = "Ring buffer full" +INTERVAL_MSG = "packet interval exceeded" + +COLOR_DISCARD = "#c5373c" # red-ish: packets lost +COLOR_INTERVAL = "#e0a020" # amber: timing fault + + +def find_trace(arg: str | None) -> Path: + logger_dir = Path(__file__).resolve().parents[1] / "cmd" / "logger" + if arg is None: + dirs = sorted(d for d in logger_dir.iterdir() if d.is_dir()) + if not dirs: + sys.exit(f"no date directories under {logger_dir}") + target = dirs[0] + else: + target = Path(arg) + if target.is_file(): + return target + traces = sorted(target.rglob("trace-*.jsonl")) + if not traces: + sys.exit(f"no trace-*.jsonl found under {target}") + return traces[0] + + +def parse(trace: Path): + discards, intervals = [], [] + with trace.open() as f: + for line in f: + try: + rec = json.loads(line) + except json.JSONDecodeError: + continue + msg = rec.get("message", "") + t = rec.get("time") + if t is None: + continue + ts = t / 1e9 # ns -> s epoch + if DISCARD_MSG in msg: + discards.append((ts, rec)) + elif INTERVAL_MSG in msg: + intervals.append((ts, rec)) + return discards, intervals + + +def fmt(ts: float) -> str: + return datetime.fromtimestamp(ts).strftime("%H:%M:%S.%f")[:-3] + + +def write_summary(out: Path, trace: Path, discards, intervals): + lines = [f"Trace analysis: {trace.name}", "=" * 60, ""] + + for name, events in (("DISCARDED PACKETS (ring buffer full)", discards), + ("TIME LIMIT EXCEEDED (packet interval)", intervals)): + lines += [name, "-" * len(name)] + if not events: + lines += [" no events", ""] + continue + t0, t1 = events[0][0], events[-1][0] + lines += [ + f" total events : {len(events)}", + f" first : {fmt(t0)}", + f" last : {fmt(t1)}", + f" span : {t1 - t0:.1f} s", + ] + ids = Counter(r.get("id") for _, r in events if r.get("id") is not None) + if ids: + lines.append(" top packet ids:") + for pid, n in ids.most_common(10): + lines.append(f" id {pid}: {n} events") + ivals = [r["interval"] for _, r in events if "interval" in r] + if ivals: + lines.append(f" interval (s) : min {min(ivals):.3f} " + f"max {max(ivals):.3f} " + f"avg {sum(ivals) / len(ivals):.3f} " + f"(threshold {events[0][1].get('threshold', '?')})") + lines.append("") + + lines += ["EVENT LOG (per second)", "-" * 22] + per_sec = Counter() + for ts, _ in discards: + per_sec[(int(ts), "discarded")] += 1 + for ts, _ in intervals: + per_sec[(int(ts), "time limit exceeded")] += 1 + for (sec, kind) in sorted(per_sec): + n = per_sec[(sec, kind)] + lines.append(f" {fmt(sec)} {kind:<20} x{n}") + if not per_sec: + lines.append(" (none)") + + out.write_text("\n".join(lines) + "\n") + + +def plot(trace: Path, discards, intervals, png: Path): + fig, ax = plt.subplots(figsize=(11, 4.5)) + all_ts = [ts for ts, _ in discards] + [ts for ts, _ in intervals] + if not all_ts: + ax.text(0.5, 0.5, "No discard / time-limit events in this trace", + ha="center", va="center", transform=ax.transAxes) + else: + t0 = min(all_ts) + for events, color, label in ( + (intervals, COLOR_INTERVAL, "time limit exceeded"), + (discards, COLOR_DISCARD, "packet discarded"), + ): + if not events: + continue + per_sec = Counter(int(ts - t0) for ts, _ in events) + xs = sorted(per_sec) + ax.plot(xs, [per_sec[x] for x in xs], color=color, lw=2, + label=f"{label} ({len(events)})") + ax.set_xlabel(f"seconds since {fmt(t0)}") + ax.set_ylabel("events / second") + ax.legend(frameon=False) + ax.set_title(f"Packet faults — {trace.name}") + ax.spines[["top", "right"]].set_visible(False) + ax.grid(axis="y", color="#dddddd", lw=0.5) + fig.tight_layout() + fig.savefig(png, dpi=150) + print(f"plot -> {png}") + plt.show() + + +def main(): + trace = find_trace(sys.argv[1] if len(sys.argv) > 1 else None) + print(f"trace -> {trace}") + discards, intervals = parse(trace) + print(f"found -> {len(discards)} discarded, {len(intervals)} time-limit exceeded") + txt = trace.with_name(trace.stem + "-resume.txt") + write_summary(txt, trace, discards, intervals) + print(f"resume -> {txt}") + plot(trace, discards, intervals, trace.with_name(trace.stem + "-faults.png")) + + +if __name__ == "__main__": + main() From 16abed238d0329a28da90fb494e8c0ac64a36c68 Mon Sep 17 00:00:00 2001 From: Javier Ribal del Rio Date: Thu, 16 Jul 2026 18:37:43 +0200 Subject: [PATCH 27/29] fix(competition-view): modify safe ranges --- frontend/competition-view/src/constants/measurements.ts | 4 +--- frontend/competition-view/src/pages/Overview/Overview.tsx | 3 +-- 2 files changed, 2 insertions(+), 5 deletions(-) diff --git a/frontend/competition-view/src/constants/measurements.ts b/frontend/competition-view/src/constants/measurements.ts index 92b5a28e0..8c968862d 100644 --- a/frontend/competition-view/src/constants/measurements.ts +++ b/frontend/competition-view/src/constants/measurements.ts @@ -60,13 +60,11 @@ export const PACK_V_RANGE: readonly [number, number] = [260, 400]; /** DC bus / DC link voltage interval (V). */ export const DC_BUS_V_RANGE: readonly [number, number] = [0, 400]; /** HV battery current interval (A). */ -export const HV_CURRENT_RANGE: readonly [number, number] = [-10, 120]; +export const HV_CURRENT_RANGE: readonly [number, number] = [0, 120]; /** Levitation coil current interval (A). */ export const LEV_CURRENT_RANGE: readonly [number, number] = [-55, 55]; /** Propulsion phase current interval (A). */ export const PROP_CURRENT_RANGE: readonly [number, number] = [-20, 100]; -/** Propulsion velocity interval (km/h). */ -export const SPEED_RANGE: readonly [number, number] = [0, 120]; /** Cell voltage display range and warning thresholds (V), shared by the battery views. */ export const CELL_V_MIN = 3.0; diff --git a/frontend/competition-view/src/pages/Overview/Overview.tsx b/frontend/competition-view/src/pages/Overview/Overview.tsx index 45423b81c..2505a117b 100644 --- a/frontend/competition-view/src/pages/Overview/Overview.tsx +++ b/frontend/competition-view/src/pages/Overview/Overview.tsx @@ -21,7 +21,6 @@ import { PACK_V_RANGE, PCU, PROP_CURRENT_RANGE, - SPEED_RANGE, VCU, } from "../../constants/measurements"; import useMeasurement from "../../hooks/useMeasurement"; @@ -390,7 +389,7 @@ const Dashboard = () => { {/* Charts — 2 cols × 3 rows */}
- + From d3af87b715d3e79329be38a02fc48634900c1de7 Mon Sep 17 00:00:00 2001 From: Javier Ribal del Rio Date: Thu, 16 Jul 2026 18:49:51 +0200 Subject: [PATCH 28/29] fix(competition-view): change range --- frontend/competition-view/src/constants/measurements.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/competition-view/src/constants/measurements.ts b/frontend/competition-view/src/constants/measurements.ts index 8c968862d..df6b35fa2 100644 --- a/frontend/competition-view/src/constants/measurements.ts +++ b/frontend/competition-view/src/constants/measurements.ts @@ -64,7 +64,7 @@ export const HV_CURRENT_RANGE: readonly [number, number] = [0, 120]; /** Levitation coil current interval (A). */ export const LEV_CURRENT_RANGE: readonly [number, number] = [-55, 55]; /** Propulsion phase current interval (A). */ -export const PROP_CURRENT_RANGE: readonly [number, number] = [-20, 100]; +export const PROP_CURRENT_RANGE: readonly [number, number] = [0, 120]; /** Cell voltage display range and warning thresholds (V), shared by the battery views. */ export const CELL_V_MIN = 3.0; From efcc827d785cd1b1f6207c1ec040bef97dd6bf9b Mon Sep 17 00:00:00 2001 From: Javier Ribal del Rio Date: Thu, 16 Jul 2026 19:06:43 +0200 Subject: [PATCH 29/29] fix(backend): update dev-config to match new keep alive --- backend/cmd/dev-config.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/cmd/dev-config.toml b/backend/cmd/dev-config.toml index c3eea59f0..c450b3636 100644 --- a/backend/cmd/dev-config.toml +++ b/backend/cmd/dev-config.toml @@ -30,7 +30,7 @@ backoff_max_ms = 999999 # Maximum backoff duration in milliseconds backoff_multiplier = 1 # Exponential backoff multiplier (e.g., 1.5 means each retry waits 1.5x longer) max_retries = 0 # Maximum retries before cycling (0 = infinite retries, recommended for persistent reconnection) connection_timeout_ms = 999999 # Timeout for the initial connection attempt -keep_alive_ms = 0 # Keep-alive interval in milliseconds (0 to disable) +keep_alive_interval_ms = 0 # Keep-alive interval in milliseconds (0 to disable) # UDP Configuration # These settings control the UDP server's buffer sizes and performance characteristics