diff --git a/backend/cmd/config.toml b/backend/cmd/config.toml index eee3c8c95..edf7f746b 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/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 diff --git a/backend/cmd/setup_transport.go b/backend/cmd/setup_transport.go index dba1f1111..f885dc749 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. @@ -246,6 +236,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/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/internal/config/config_types.go b/backend/internal/config/config_types.go index e8fbdb648..2594560df 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/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/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() diff --git a/electron-app/src/windows/mainWindow.js b/electron-app/src/windows/mainWindow.js index 3b14d8afb..0a963e325 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. @@ -102,10 +102,12 @@ function loadView(view) { // Update window title based on view type const titles = { "competition-view": "Competition View", - "flashing-view": "Flashing View", "testing-view": "Testing View", + "flashing-view": "Flashing View", }; - mainWindow.setTitle(`Hyperloop Control Station - ${titles[view] ?? "Testing View"}`); + mainWindow.setTitle( + `Hyperloop Control Station - ${titles[view] ?? view}`, + ); } else { // Log error and show dialog if view not found console.error(`View not found: ${viewPath}`); diff --git a/frontend/competition-view/src/App.tsx b/frontend/competition-view/src/App.tsx index e9a210de3..ff901e1e2 100644 --- a/frontend/competition-view/src/App.tsx +++ b/frontend/competition-view/src/App.tsx @@ -5,18 +5,14 @@ import ErrorBoundary from "./components/ErrorBoundary"; import KeyboardShortcutsHelp from "./components/KeyboardShortcutsHelp"; import { BRAKE_ORDERS, - EMERGENCY_STOP_ORDERS, + FAULT_ORDERS, 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"; -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"; import type { Connection } from "./types/connection"; @@ -32,16 +28,19 @@ 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); // 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), }); @@ -67,25 +66,13 @@ const App = () => { onShowShortcuts={() => setHelpOpen(true)} > - } /> - } /> - } /> - } /> - } /> - } /> - } /> + } /> + } /> {/* 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/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-light.svg b/frontend/competition-view/src/assets/pod-light.svg new file mode 100644 index 000000000..1eeac4a5f --- /dev/null +++ b/frontend/competition-view/src/assets/pod-light.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..97f564253 100644 --- a/frontend/competition-view/src/components/header/ConnectionBadge.tsx +++ b/frontend/competition-view/src/components/header/ConnectionBadge.tsx @@ -1,24 +1,26 @@ 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) => ( +const ConnectionBadge = ({ label, connected }: ConnectionBadgeProps) => ( - {connected ? "Connected" : "Disconnected"} + {label} ); 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..4bf6259d5 --- /dev/null +++ b/frontend/competition-view/src/components/header/DashboardStatusBar.tsx @@ -0,0 +1,89 @@ +import { Separator } from "@workspace/ui/components"; +import { formatAxisValue } from "../../constants/chartConfig"; +import { BOARDS, HVAL_THRESHOLD_V, HVBMS, 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"; + +/* ─── Shared vertical rhythm for the status blocks ───────────────────────── */ + +const VDivider = () => ; + +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 StatValue = ({ + value, + valueClass = "text-foreground", + width, +}: { + value: string; + valueClass?: string; + /** Fixed width so a longer/shorter value doesn't shift the elements after it. */ + width?: string; +}) => ( +
+ {value} +
+); + +/* ─── DashboardStatusBar ─────────────────────────────────────────────────── */ + +const DashboardStatusBar = () => { + 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 stateStale = useIsStale(BOARDS.VCU, VCU.state); + const brakeStale = useIsStale(BOARDS.VCU, VCU.activeBrakes); + const dcLinkStale = useIsStale(BOARDS.HVBMS, HVBMS.voltageReading); + + const dcLinkActive = dcLinkV !== undefined && dcLinkV > HVAL_THRESHOLD_V; + const dcLinkClass = dcLinkStale + ? STALE_TEXT_CLASS + : dcLinkV === undefined + ? "text-muted-foreground" + : dcLinkActive ? "text-red-500" : "text-green-500"; + + const brakeLabel = brakeRaw === undefined ? "—" : brakeRaw ? "BRAKED" : "UNBRAKED"; + const brakeClass = + brakeStale ? STALE_TEXT_CLASS : + brakeRaw === true ? "text-red-500" : + brakeRaw === false ? "text-blue-500" : + "text-muted-foreground"; + + return ( +
+ + + + + + + + {/* Fixed-width slot so a longer/shorter state string doesn't shift the elements after it. */} +
+ + {state !== undefined ? String(state) : "—"} + +
+
+ + + + + + +
+ ); +}; + +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..ac107e9d7 100644 --- a/frontend/competition-view/src/components/header/Header.tsx +++ b/frontend/competition-view/src/components/header/Header.tsx @@ -1,44 +1,43 @@ -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"; +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; - 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"; + const connections = useConnections(); + const vcuConnected = connections[BOARDS.VCU]?.isConnected ?? false; + return ( -
- - -

{pageTitle}

+
+
+ + +
+

{pageTitle}

+

Hyperloop UPV

+
+ +
-
- - - - - 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..13443f853 --- /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 + +
+ ); +}; + +export default HvalIndicator; 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/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..e6c79531b 100644 --- a/frontend/competition-view/src/components/sidebar/AppSidebar.tsx +++ b/frontend/competition-view/src/components/sidebar/AppSidebar.tsx @@ -6,16 +6,20 @@ import { SidebarRail, } from "@workspace/ui/components"; 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"; import ThemeToggleItem from "./ThemeToggleItem"; interface AppSidebarProps extends React.ComponentProps { backendConnected: boolean; + onShowShortcuts: () => void; } -const AppSidebar = ({ backendConnected, ...props }: AppSidebarProps) => ( +const AppSidebar = ({ backendConnected, onShowShortcuts, ...props }: AppSidebarProps) => ( @@ -28,7 +32,10 @@ const AppSidebar = ({ backendConnected, ...props }: AppSidebarProps) => (
+ + + 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/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/constants/chartConfig.ts b/frontend/competition-view/src/constants/chartConfig.ts index 0707e3ed6..e54490a57 100644 --- a/frontend/competition-view/src/constants/chartConfig.ts +++ b/frontend/competition-view/src/constants/chartConfig.ts @@ -1,5 +1,15 @@ -/** 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; + +/** + * 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; /** Default rendered height of a chart in pixels. */ export const CHART_HEIGHT = 220; @@ -18,3 +28,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/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/constants/measurements.ts b/frontend/competition-view/src/constants/measurements.ts index 5e63acbe5..df6b35fa2 100644 --- a/frontend/competition-view/src/constants/measurements.ts +++ b/frontend/competition-view/src/constants/measurements.ts @@ -1,116 +1,134 @@ /** - * 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). + * + * 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", + HVSCU_CABINET: "HVSCU-Cabinet", +} 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", + 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: "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: "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: "PCU/general_state_machine", - operatingState: "PCU/operational_state_machine", - peakCurrent: "PCU/current_Peak", - frequency: "PCU/frequency", + state: "state", + peakCurrent: "current_Peak", + frequency: "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", -} as const; +/** 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]; +/** HV battery current interval (A). */ +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] = [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; +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 = { - 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", + 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: "sm_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 and 4 temp sensors. */ 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`, + temps: Array.from({ length: 4 }, (_, i) => `battery${n}_temp${i + 1}`), + 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", -} as const; - -/** BLCU — bootloader control unit (firmware flashing). */ -export const BLCU = { - state: "BLCU/state", +/** 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 — 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/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/orders.ts b/frontend/competition-view/src/constants/orders.ts index c07eb2390..f5c1275f0 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,17 @@ 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. - */ -export const EMERGENCY_STOP_ORDERS: Order[] = [ - { id: 55, fields: {} }, - { id: 1799, fields: {} }, - { id: 1698, 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/pages.ts b/frontend/competition-view/src/constants/pages.ts index b422cc086..840dc1a88 100644 --- a/frontend/competition-view/src/constants/pages.ts +++ b/frontend/competition-view/src/constants/pages.ts @@ -1,14 +1,8 @@ -import { Activity, Layout, ScrollText, Send, Terminal, Wrench } from "@workspace/ui/icons"; -import { Zap } from "lucide-react"; +import { Battery, Layout } 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: "Competition View", icon: Layout }, + "/batteries": { title: "Batteries", icon: Battery }, } as const; export const PAGES_ARRAY = Object.entries(PAGES).map( 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/constants/vcuStateMachine.ts b/frontend/competition-view/src/constants/vcuStateMachine.ts new file mode 100644 index 000000000..01a082b2a --- /dev/null +++ b/frontend/competition-view/src/constants/vcuStateMachine.ts @@ -0,0 +1,63 @@ +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; + +/** + * 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], + /* 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/hooks/useIsStale.ts b/frontend/competition-view/src/hooks/useIsStale.ts new file mode 100644 index 000000000..70bdd967f --- /dev/null +++ b/frontend/competition-view/src/hooks/useIsStale.ts @@ -0,0 +1,68 @@ +import { useEffect, useState } from "react"; +import { isStale, subscribeTick } from "../lib/freshness"; + +/** + * 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. + */ + +/** 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 + * whole card when its data stream stopped. `pairs` should be a stable + * reference (module constant or memoised). + */ +export const useAllStale = ( + pairs: readonly { board: string; measurementKey: string }[], +): 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 [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/hooks/useKeyboardShortcuts.ts b/frontend/competition-view/src/hooks/useKeyboardShortcuts.ts index 81b7274e6..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,7 @@ 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" }, ]; @@ -26,43 +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; @@ -80,33 +59,24 @@ const useKeyboardShortcuts = ({ onOpenContactors(); break; - case "e": + case "?": e.preventDefault(); - if (estopArmed) { - confirmEstop(); - } else { - armEstop(); - } + onToggleHelp(); break; - case "?": + case " ": e.preventDefault(); - onToggleHelp(); + onFault(); break; } }, - [enabled, estopArmed, onBrake, onOpenContactors, armEstop, confirmEstop, 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; 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/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/layout/AppLayout.tsx b/frontend/competition-view/src/layout/AppLayout.tsx index 73ad4321a..f9d3b8d39 100644 --- a/frontend/competition-view/src/layout/AppLayout.tsx +++ b/frontend/competition-view/src/layout/AppLayout.tsx @@ -22,10 +22,10 @@ const AppLayout = ({ children, backendConnected, onShowShortcuts }: AppLayoutPro
- + -
-
{children}
+
+
{children}
diff --git a/frontend/competition-view/src/lib/freshness.ts b/frontend/competition-view/src/lib/freshness.ts new file mode 100644 index 000000000..56a765138 Binary files /dev/null and b/frontend/competition-view/src/lib/freshness.ts differ diff --git a/frontend/competition-view/src/lib/message.ts b/frontend/competition-view/src/lib/message.ts new file mode 100644 index 000000000..673ded83f --- /dev/null +++ b/frontend/competition-view/src/lib/message.ts @@ -0,0 +1,36 @@ +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)}`; +}; + +/** 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; + 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: ${fmt(data.value)} (Bounds: [${fmt(data.bounds[0])}, ${fmt(data.bounds[1])}])`; + case "UPPER_BOUND": + case "LOWER_BOUND": + return `Value: ${fmt(data.value)} (Limit: ${fmt(data.bound)})`; + case "EQUALS": + return `Value: ${fmt(data.value)}`; + case "NOT_EQUALS": + return `Value: ${fmt(data.value)} (Expected: ${fmt(data.want)})`; + case "TIME_ACCUMULATION": + 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/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/Batteries.tsx b/frontend/competition-view/src/pages/Batteries/Batteries.tsx index 0aea4f4f8..cde3fdc24 100644 --- a/frontend/competition-view/src/pages/Batteries/Batteries.tsx +++ b/frontend/competition-view/src/pages/Batteries/Batteries.tsx @@ -1,20 +1,8 @@ -import { Separator } from "@workspace/ui/components"; import HvBatterySection from "./components/HvBatterySection"; -import LvBatterySection from "./components/LvBatterySection"; -/** - * Batteries monitoring page. - * - * Layout: - * - High-voltage section (HVSCU summary + 18 pack cards) - * - Separator - * - Low-voltage section (BMSL summary + 6 cell cards) - */ 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 8137fe196..9f078c268 100644 --- a/frontend/competition-view/src/pages/Batteries/components/BatteryPackCard.tsx +++ b/frontend/competition-view/src/pages/Batteries/components/BatteryPackCard.tsx @@ -1,94 +1,138 @@ +import { memo, useMemo } from "react"; +import { useShallow } from "zustand/react/shallow"; +import { formatAxisValue } from "../../../constants/chartConfig"; import { - Card, - CardContent, - CardHeader, - CardTitle, -} from "@workspace/ui/components"; -import { hvbmsPack } from "../../../constants/measurements"; -import useMeasurement from "../../../hooks/useMeasurement"; + BOARDS, + CELL_V_MAX, + CELL_V_MIN, + CELL_V_WARN_HIGH, + CELL_V_WARN_LOW, + hvbmsPack, +} from "../../../constants/measurements"; +import { useStaleFlags } from "../../../hooks/useIsStale"; +import { STALE_TEXT_CLASS } from "../../../lib/freshness"; +import { useStore } from "../../../store/store"; interface BatteryPackCardProps { - /** Pack number, 1-based (1–18). */ packNumber: number; } -const fmt = (v: number | boolean | string | undefined, decimals = 1) => - typeof v === "number" ? v.toFixed(decimals) : "—"; - -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 socColor = - socNum === null ? "bg-muted" : - socNum < 15 ? "bg-red-500" : - socNum < 30 ? "bg-amber-500" : - "bg-green-500"; +// 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_V_WARN_LOW ? "low" : v > CELL_V_WARN_HIGH ? "high" : "ok"; + +/* ─── Individual cell tile ───────────────────────────────────────────────── */ + +// Memoised so only the cells whose value actually changed re-render. +const CellTile = memo(({ cellNum, value, stale }: { cellNum: number; value: number | undefined; stale: boolean }) => { + const v = typeof value === "number" ? value : null; + const status = cellStatus(v); + const fill = v !== null + ? Math.min(100, Math.max(0, ((v - CELL_V_MIN) / (CELL_V_MAX - CELL_V_MIN)) * 100)) + : 0; return ( - - - - Pack {packNumber} - - - - - {/* SOC bar */} -
-
-
-
- - {fmt(soc, 0)}% - -
- - {/* Key values */} -
- - - - -
- - +
+
+ C{cellNum} + + {v !== null ? formatAxisValue(v) : "—"} + +
+
+
+
+
+ ); +}); + +CellTile.displayName = "CellTile"; + +/* ─── Pack strip ─────────────────────────────────────────────────────────── */ + +/** + * One horizontal strip per battery group: summary rail on the left, its + * 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 = 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]); + + // 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"; + + return ( +
+ {/* Summary rail */} +
+ Group {packNumber} + + {fmt(voltage)} V + {" · "} + {fmt(tempMax)} °C + +
+ + {/* All 12 cells in a single row */} +
+ {keys.cells.map((key, i) => ( + + ))} +
+
); -}; - -const Stat = ({ - label, - value, - unit, -}: { - label: string; - value: string; - unit: string; -}) => ( -
- {label} - - {value} {unit} - -
-); +}); +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 1909aabf4..ebd46e4d2 100644 --- a/frontend/competition-view/src/pages/Batteries/components/HvBatterySection.tsx +++ b/frontend/competition-view/src/pages/Batteries/components/HvBatterySection.tsx @@ -1,58 +1,105 @@ -import { Separator } from "@workspace/ui/components"; -import { HVBMS, HVBMS_CABINET } from "../../../constants/measurements"; +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"; +import { STALE_TEXT_CLASS } from "../../../lib/freshness"; import BatteryPackCard from "./BatteryPackCard"; -const PACK_COUNT = 18; +/** 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); +// 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(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.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; + + const staleFlags = useStaleFlags(BOARDS.HVBMS, SUMMARY_STALE_IDS); return ( -
+
{/* Section header */} -
-

- High Voltage -

+
+

High Voltage

+ 8 groups · 12 cells each {contactors !== undefined && ( - Contactors {contactors} + Contactors {contactors === true ? "Closed" : "Open"} )}
- {/* Summary stats */} -
+ {/* Summary stats — SOC / cell extremes reuse the cell warning thresholds */} +
{[ - { label: "Total V", value: fmt(totalVoltage), unit: "V" }, - { label: "Min 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: "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 }) => ( -
+ ].map(({ label, value, unit, valueClass, range }, i) => ( +
{label} - + {value} - + {range && ( + + [{range[0]}, {range[1]}] + + )} + {unit} @@ -60,10 +107,8 @@ const HvBatterySection = () => { ))}
- - - {/* Pack 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/Batteries/components/LvBatterySection.tsx b/frontend/competition-view/src/pages/Batteries/components/LvBatterySection.tsx deleted file mode 100644 index f235dd70b..000000000 --- a/frontend/competition-view/src/pages/Batteries/components/LvBatterySection.tsx +++ /dev/null @@ -1,123 +0,0 @@ -import { Separator } from "@workspace/ui/components"; -import { LVBMS } from "../../../constants/measurements"; -import useMeasurement from "../../../hooks/useMeasurement"; - -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. - */ -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); - - return ( -
- {/* Section header */} -
-

- Low Voltage (LVBMS) -

- {state !== undefined && ( - - {String(state)} - - )} -
- - {/* 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} - - -
- ))} -
- - - - {/* Individual cell voltages */} -
- {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; 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/Boards/Boards.tsx b/frontend/competition-view/src/pages/Boards/Boards.tsx deleted file mode 100644 index 11eb623e1..000000000 --- a/frontend/competition-view/src/pages/Boards/Boards.tsx +++ /dev/null @@ -1,100 +0,0 @@ -import { BCU, BLCU, HVBMS, HVBMS_CABINET, LCU, LVBMS, 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 69f8ba297..000000000 --- a/frontend/competition-view/src/pages/Boards/components/BoardCard.tsx +++ /dev/null @@ -1,87 +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 { - /** 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 = ({ name, stateMeasurementKey, stats = [] }: BoardCardProps) => { - const state = useMeasurement(stateMeasurementKey); - const hasData = state !== undefined; - - return ( - - - - {name} - - {hasData ? String(state) : "—"} - - - - - {stats.length > 0 && ( - -
- {stats.map((s) => ( - - ))} -
-
- )} -
- ); -}; - -const StatRow = ({ stat }: { stat: Stat }) => { - const raw = useMeasurement(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 4104826b9..000000000 --- a/frontend/competition-view/src/pages/Boards/components/LcuAirgapCard.tsx +++ /dev/null @@ -1,133 +0,0 @@ -import { - Card, - CardContent, - CardHeader, - CardTitle, -} from "@workspace/ui/components"; -import { 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 components ────────────────────────────────────────────── */ - -interface MeasurementRowProps { - label: string; - measurementKey: string; - unit: string; - decimals?: number; - warnBelow?: number; -} - -const MeasurementRow = ({ - label, - measurementKey, - unit, - decimals = 1, - warnBelow, -}: MeasurementRowProps) => { - const value = useMeasurement(measurementKey); - const isWarning = - warnBelow !== undefined && - typeof value === "number" && - value < warnBelow; - - return ( -
- {label} - - {fmt(value, decimals)} - {unit} - -
- ); -}; - -/* ─── Section components ───────────────────────────────────────────────── */ - -const SectionLabel = ({ children }: { children: React.ReactNode }) => ( -

- {children} -

-); - -const PositionSection = () => ( -
- Position -
- - -
-
-); - -const RotationSection = () => ( -
- Rotation -
- - - -
-
-); - -const VerticalAirgapsSection = () => ( -
- Vertical Airgaps -
- - - - -
-
-); - -const HorizontalAirgapsSection = () => ( -
- Horizontal Airgaps -
- - - - -
-
-); - -/* ─── 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 = () => ( - - - 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 1113fe616..2fdc737c7 100644 --- a/frontend/competition-view/src/pages/Booster/Booster.tsx +++ b/frontend/competition-view/src/pages/Booster/Booster.tsx @@ -1,62 +1,125 @@ -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, HVSCU_CABINET, 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} +
+ ); +}; + +/* ─── 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 = () => (
- {/* ── BCU ─────────────────────────────────────────────────────────── */} + + {/* ── VCU Subsystem Connectivity ────────────────────────────────────── */} +
+

VCU — Subsystem Connectivity

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

HVSCU Cabinet

+
+ +
+
+ + {/* ── 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..52b8d4282 100644 --- a/frontend/competition-view/src/pages/Charts/Charts.tsx +++ b/frontend/competition-view/src/pages/Charts/Charts.tsx @@ -1,75 +1,83 @@ -import { BCU, HVBMS, PCU, VCU } from "../../constants/measurements"; +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"; /** * 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 843514af7..b0386a726 100644 --- a/frontend/competition-view/src/pages/Charts/components/MultiSeriesChart.tsx +++ b/frontend/competition-view/src/pages/Charts/components/MultiSeriesChart.tsx @@ -1,17 +1,25 @@ import { memo, useEffect, useRef } from "react"; +import type { LucideIcon } from "lucide-react"; 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_TRIM_SLACK, + CHART_WINDOW_SECONDS, + formatAxisValue, } from "../../../constants/chartConfig"; +import { useAllStale } from "../../../hooks/useIsStale"; 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; @@ -21,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; } @@ -32,29 +42,31 @@ 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. + * + * 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 the zoom. + * Zoom is disabled; only hover crosshair interaction is active. */ -const MultiSeriesChart = memo(({ title, series, unit = "" }: MultiSeriesChartProps) => { - const containerRef = useRef(null); +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); const xRef = useRef([]); - // One data array per series, initialised lazily on first render. const yRefs = useRef(series.map(() => [])); - const counterRef = useRef(0); - - // 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(({ measurementKey }) => s.telemetry[measurementKey] as number | undefined), - ), - ); + const startRef = useRef(performance.now()); + + // Subtle yellow tint when the whole data stream stopped arriving. + const stale = useAllStale(series); // ── Initialise uPlot ──────────────────────────────────────────────────── useEffect(() => { - if (!containerRef.current) return; + if (!wrapperRef.current || !containerRef.current) return; const getVar = (name: string) => getComputedStyle(document.documentElement).getPropertyValue(name).trim(); @@ -73,12 +85,18 @@ 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: { - 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]; @@ -89,103 +107,139 @@ 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: 20, + size: 24, + values: (_, ticks) => ticks.map(formatAxisValue), }, { side: 1, - stroke: getVar("--muted-foreground"), - grid: { stroke: getVar("--border") }, + 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 } }, + 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, - }); - containerRef.current.addEventListener("dblclick", handleDblClick); - return () => { uplotRef.current?.destroy(); uplotRef.current = null; - containerRef.current?.removeEventListener("dblclick", handleDblClick); }; // Intentionally runs once on mount — series config is stable. // 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(counterRef.current++); - (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]); + + // Skip when nothing this chart plots has changed. + if (lastValues && values.every((v, i) => v === lastValues![i])) return; + lastValues = 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); + }); - 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)); - } + // 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); + }; - uplotRef.current.setData([xRef.current, ...yRefs.current]); - }, [values]); + ingest(useStore.getState().telemetry); + const unsubscribe = useStore.subscribe((state, prevState) => { + if (state.telemetry !== prevState.telemetry) ingest(state.telemetry); + }); - // ── Resize to container ────────────────────────────────────────────────── + 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(() => { - 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(); + }, []); + + // ── 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 ( -
-
- {/* Title + inline phase legend */} +
+
- {title} + + {Icon && } + {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..7fd438465 100644 --- a/frontend/competition-view/src/pages/Charts/components/TelemetryChart.tsx +++ b/frontend/competition-view/src/pages/Charts/components/TelemetryChart.tsx @@ -1,19 +1,29 @@ import { memo, useEffect, useRef } from "react"; +import type { LucideIcon } from "lucide-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_TRIM_SLACK, + CHART_WINDOW_SECONDS, + formatAxisValue, } from "../../../constants/chartConfig"; -import useMeasurement from "../../../hooks/useMeasurement"; +import { useIsStale } from "../../../hooks/useIsStale"; +import { useStore } from "../../../store/store"; interface TelemetryChartProps { /** Human-readable label shown in the card header. */ title: string; - /** Backend telemetry key to track (e.g. "PCU/encoder_speed_km_h"). */ + /** 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. */ measurementKey: string; /** Unit appended to the y-axis label. */ unit?: string; @@ -25,38 +35,56 @@ 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. 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. + * Redraws are batched to animation frames. */ const TelemetryChart = memo(({ title, + icon: Icon, + board, measurementKey, 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([]); - const counterRef = useRef(0); + const startRef = useRef(performance.now()); - const value = useMeasurement(measurementKey); 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 (!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: { - 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]; @@ -75,81 +103,124 @@ 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: 20, + size: 24, + values: (_, ticks) => ticks.map(formatAxisValue), }, { side: 1, - stroke: getVar("--muted-foreground"), - grid: { stroke: getVar("--border") }, + 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 } }, + cursor: { drag: { setScale: false, x: false, y: false } }, }; 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); - return () => { uplotRef.current?.destroy(); uplotRef.current = null; - containerRef.current?.removeEventListener("dblclick", handleDblClick); }; // Intentionally runs once on mount — series config is stable. // 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); + + // 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); + } - xRef.current.push(counterRef.current++); - yRef.current.push(value); + 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 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(); + }, []); + + // ── 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 ( -
-
- {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 8c2fd0666..0521e4088 100644 --- a/frontend/competition-view/src/pages/Messages/components/MessageItem.tsx +++ b/frontend/competition-view/src/pages/Messages/components/MessageItem.tsx @@ -1,30 +1,30 @@ 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 { message: Message; } -const MessageItem = ({ message }: MessageItemProps) => { - const time = new Date(message.timestamp).toLocaleTimeString([], { - hour: "2-digit", - minute: "2-digit", - second: "2-digit", - }); +// 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 = formatMessageTimestamp(message.timestamp); return (
@@ -38,10 +38,11 @@ const MessageItem = ({ message }: MessageItemProps) => { {message.kind} - {message.content} + {messageContent(message.payload)}
); -}; +}); +MessageItem.displayName = "MessageItem"; export default MessageItem; 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/OrderParameters.tsx b/frontend/competition-view/src/pages/Orders/components/OrderParameters.tsx index 84e7d4ca5..fc31572ae 100644 --- a/frontend/competition-view/src/pages/Orders/components/OrderParameters.tsx +++ b/frontend/competition-view/src/pages/Orders/components/OrderParameters.tsx @@ -1,190 +1,63 @@ import { Checkbox, + Field, + FieldLabel, Input, - Label, Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "@workspace/ui/components"; -import type { - BooleanParameter, - CommandParameter, - EnumParameter, - NumericParameter, - ParameterValues, -} from "../../../types/catalog"; +import type { CommandParameter, ParameterValues } from "../../../types/catalog"; interface OrderParametersProps { fields: Record; values: ParameterValues; - onChange: (id: string, value: string | number | boolean) => void; + onChange: (key: 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. - */ +/** 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)} + /> + )} + ))}
); -/* ─── 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/OrderItem.tsx b/frontend/competition-view/src/pages/Orders/components/OrderRow.tsx similarity index 74% rename from frontend/competition-view/src/pages/Orders/components/OrderItem.tsx rename to frontend/competition-view/src/pages/Orders/components/OrderRow.tsx index 73ca1357f..7353847ce 100644 --- a/frontend/competition-view/src/pages/Orders/components/OrderItem.tsx +++ b/frontend/competition-view/src/pages/Orders/components/OrderRow.tsx @@ -11,12 +11,17 @@ import { 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, 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 OrderItemProps { +interface OrderRowProps { item: CommandCatalogItem; + /** Name of the board this order belongs to, as reported by the catalog. */ + board: string; isConnected: boolean; } @@ -31,14 +36,21 @@ const getDefaultValues = (item: CommandCatalogItem): ParameterValues => { }; /** - * A single order row with an inline send button. - * Orders with parameters expand to show a compact form. + * Classic catalog-style order row: name + id badge, with an outline + * 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 any required numeric field is empty/invalid. + * - 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 OrderItem = ({ item, isConnected }: OrderItemProps) => { +const OrderRow = ({ item, board, isConnected }: OrderRowProps) => { const sendOrder = useSendOrder(); + const vcuState = useMeasurement(BOARDS.VCU, VCU.state); const [values, setValues] = useState(() => getDefaultValues(item)); const [open, setOpen] = useState(false); @@ -52,7 +64,11 @@ const OrderItem = ({ item, isConnected }: OrderItemProps) => { !Number.isFinite(parseFloat(String(values[key]))), ); - const canSend = isConnected && !hasInvalidNumeric && !sent; + // 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 && !hasInvalidNumeric && !sent && stateAllowsOrder; const handleSend = () => { if (!canSend) return; @@ -107,6 +123,13 @@ const OrderItem = ({ item, isConnected }: OrderItemProps) => { Not connected to backend + ) : !stateAllowsOrder ? ( + + + {sendButton} + + Not available in current vehicle state{vcuState ? ` (${vcuState})` : ""} + ) : hasInvalidNumeric ? ( @@ -163,4 +186,4 @@ const OrderItem = ({ item, isConnected }: OrderItemProps) => { ); }; -export default OrderItem; +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..3a7bb1be3 --- /dev/null +++ b/frontend/competition-view/src/pages/Orders/components/OrdersCatalogList.tsx @@ -0,0 +1,56 @@ +import type { BoardOrdersData, CommandCatalogItem } from "../../../types/catalog"; +import { BOARDS } from "../../../constants/measurements"; +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; + 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, 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 = CATALOG_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; 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 bf986aa8c..2505a117b 100644 --- a/frontend/competition-view/src/pages/Overview/Overview.tsx +++ b/frontend/competition-view/src/pages/Overview/Overview.tsx @@ -1,87 +1,418 @@ -import { HVBMS, PCU, VCU } from "../../constants/measurements"; +import { Button } from "@workspace/ui/components"; +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 { 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, + VCU, +} from "../../constants/measurements"; import useMeasurement from "../../hooks/useMeasurement"; -import BrakeIndicator from "./components/BrakeIndicator"; -import MetricCard from "./components/MetricCard"; +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"; +import MessageItem from "../Messages/components/MessageItem"; +import BoardsOverviewCard from "./components/BoardsOverviewCard"; import OrdersPanel from "./components/OrdersPanel"; -import RecentMessages from "./components/RecentMessages"; -import VehicleStateBanner from "./components/VehicleStateBanner"; +import TrackProgress from "./components/TrackProgress"; + +/* ─── Stable series configs ─────────────────────────────────────────────── */ + +const DLIM_SERIES: SeriesConfig[] = [ + { 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: "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 }, +]; + +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 ───────────────────────────────────────────────────────────── */ /** - * 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 + * 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 Overview = () => { - const speed = useMeasurement(PCU.speed); - const position = useMeasurement(PCU.position); - const acceleration = useMeasurement(PCU.acceleration); - const soc = useMeasurement(HVBMS.minimumSoc); - const hvVoltage = useMeasurement(HVBMS.voltageReading); - const hvCurrent = useMeasurement(HVBMS.currentReading); - 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 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 ──────────────────────────────────────────────────────── */ + +interface BatteryRow { + label: string; + value: string | undefined; + unit?: string; + 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 { + title: string; + icon: LucideIcon; + soc: number | undefined; + socStale?: boolean; + 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, 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 ( -
- {/* Vehicle state banner */} - - - {/* Row 1 — Brake indicator + kinematic metrics */} -
-
- -
- - - +
+
+ + + {title} + + + {fmtNum(soc, 0) ?? "—"} + % + +
+ +
+
+
+ +
+ {rows.map(({ label, value, unit, warn, stale, range }) => ( +
+ {label} + + {value ?? "—"} + {range && ( + + [{range[0]}, {range[1]}] + + )} + {(value !== undefined || range) && unit && ( + {unit} + )} + +
+ ))}
+
+ ); +}; + +/* ─── Kinematics card ───────────────────────────────────────────────────── */ + +const KinematicsCard = () => { + 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 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", stale: posStale }, + { label: "High pres.", value: fmtNum(highPsi), unit: "bar", stale: highStale }, + { label: "Low pres.", value: fmtNum(lowPsi), unit: "bar", stale: lowStale }, + ]; - {/* Row 2 — Electrical & thermal */} -
- - - 55 ? "text-red-500" : ""} - /> - + return ( +
+ {/* 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, stale }) => ( +
+ {label} + + {value ?? "—"} + {value !== undefined && unit && ( + {unit} + )} + +
+ ))}
+
+ ); +}; - {/* Row 3 — Pneumatics + acceleration */} -
- - - +/* ─── Safety card ───────────────────────────────────────────────────────── */ + +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); + 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); + + const [sdcStale, ctHighStale, ctLowStale, imdStale, stateStale] = + useStaleFlags(BOARDS.HVBMS, SAFETY_STALE_IDS); + + const rows: SafeRow[] = [ + { + label: "SDC", + text: sdcStatus === undefined ? "—" : String(sdcStatus), + 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: 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: 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: stateStale ? STALE_TEXT_CLASS : hvBmsState === "OPERATIONAL" ? "text-green-500" : "text-foreground", + }, + ]; + + return ( +
+ + + Safety + +
+ {rows.map(({ label, text, color }) => ( +
+ {label} + {text} +
+ ))}
+
+ ); +}; - {/* Quick orders */} - +/* ─── Messages panel ────────────────────────────────────────────────────── */ + +const MessagesPanel = () => { + const messages = useStore((s) => s.messages); + const clearMessages = useStore((s) => s.clearMessages); + + const [scrolledAway, setScrolledAway] = useState(false); + const scrollRef = useRef(null); + const prevLenRef = useRef(0); + + useEffect(() => { + if (messages.length > prevLenRef.current && !scrolledAway) { + scrollRef.current?.scrollTo({ top: 0, behavior: "smooth" }); + } + prevLenRef.current = messages.length; + }, [messages.length, scrolledAway]); + + return ( +
+
+ + + Messages + + {messages.length} + +
+ +
setScrolledAway(e.currentTarget.scrollTop > 60)} + className="relative min-h-0 flex-1 overflow-y-auto" + > + {messages.length === 0 ? ( +

No messages

+ ) : ( + messages.map((msg) => ) + )} + {scrolledAway && ( + + )} +
+
+ ); +}; + +/* ─── HV battery card ───────────────────────────────────────────────────── */ + +/** + * 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. + */ +/** 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 ( + + ); +}; + +/* ─── Dashboard ─────────────────────────────────────────────────────────── */ + +const Dashboard = () => { + return ( +
+ + + +
+ + {/* ── Left column ────────────────────────────────────────────── */} +
+ + {/* Summary cards row */} +
+ + + +
+ + {/* Charts — 2 cols × 3 rows */} +
+ + + + + + +
+ +
+ + {/* ── Right column ───────────────────────────────────────────── */} +
+ + + + + + + +
+ +
- {/* Recent messages */} -
); }; -export default Overview; +export default Dashboard; 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..3e54b53ad --- /dev/null +++ b/frontend/competition-view/src/pages/Overview/components/BoardsOverviewCard.tsx @@ -0,0 +1,136 @@ +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"; +import { STALE_BADGE_CLASS, STALE_TEXT_CLASS } from "../../../lib/freshness"; +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; + icon: LucideIcon; + stateMeasurementKey: string; + stats: Stat[]; +} + +const ROWS: BoardRow[] = [ + { + board: BOARDS.VCU, + name: "VCU", + icon: Cpu, + 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", + icon: BatteryFull, + 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", + icon: Zap, + 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", + icon: Waves, + 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 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" + ? 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 + ? String(raw) + : "—"; + + return ( +
+ {stat.label} + + {display} + {raw !== undefined && stat.unit && ( + {stat.unit} + )} + +
+ ); +}; + +const BoardTile = ({ row }: { row: BoardRow }) => { + const state = useMeasurement(row.board, row.stateMeasurementKey); + const stateStale = useIsStale(row.board, row.stateMeasurementKey); + + return ( +
+
+ + + {row.name} + + + {state !== undefined ? String(state) : "—"} + +
+ +
+ {row.stats.map((stat) => ( + + ))} +
+
+ ); +}; + +/** + * 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 = () => ( +
+ {ROWS.map((row) => ( + + ))} +
+); + +export default BoardsOverviewCard; diff --git a/frontend/competition-view/src/pages/Overview/components/BrakeIndicator.tsx b/frontend/competition-view/src/pages/Overview/components/BrakeIndicator.tsx index 5b37972b7..2077404d8 100644 --- a/frontend/competition-view/src/pages/Overview/components/BrakeIndicator.tsx +++ b/frontend/competition-view/src/pages/Overview/components/BrakeIndicator.tsx @@ -1,4 +1,4 @@ -import { VCU } from "../../../constants/measurements"; +import { BOARDS, VCU } from "../../../constants/measurements"; import useMeasurement from "../../../hooks/useMeasurement"; type BrakeStatus = "braked" | "unbraked" | "unknown"; @@ -9,12 +9,17 @@ const STATUS_STYLES: Record { - const raw = useMeasurement(VCU.allReeds); +const BrakeIndicator = ({ compact = false }: BrakeIndicatorProps) => { + const raw = useMeasurement(BOARDS.VCU, VCU.activeBrakes); const status: BrakeStatus = raw === undefined ? "unknown" : raw ? "braked" : "unbraked"; @@ -23,12 +28,12 @@ const BrakeIndicator = () => { return (
- + Brake State - + {label}
diff --git a/frontend/competition-view/src/pages/Overview/components/OrdersPanel.tsx b/frontend/competition-view/src/pages/Overview/components/OrdersPanel.tsx index 27ced1db1..e00453186 100644 --- a/frontend/competition-view/src/pages/Overview/components/OrdersPanel.tsx +++ b/frontend/competition-view/src/pages/Overview/components/OrdersPanel.tsx @@ -1,84 +1,38 @@ -import { Button, Separator } from "@workspace/ui/components"; -import { useState } from "react"; -import { - BRAKE_ORDERS, - EMERGENCY_STOP_ORDERS, - OPEN_CONTACTORS_ORDERS, -} from "../../../constants/orders"; -import useSendOrder from "../../../hooks/useSendOrder"; +import { Skeleton } from "@workspace/ui/components"; +import { Send } from "@workspace/ui/icons"; +import { useWebSocket } from "@workspace/ui/hooks"; +import useOrdersCatalog from "../../../hooks/useOrdersCatalog"; +import { useStore } from "../../../store/store"; +import OrdersList from "../../Orders/components/OrdersList"; -/** - * Panel of hardcoded competition orders. - * - * The Emergency Stop button requires two consecutive clicks within 2 seconds - * to prevent accidental activation. All other buttons fire on single click. - */ const OrdersPanel = () => { - const sendOrder = useSendOrder(); + const { isConnected } = useWebSocket(); + const commandsCatalog = useStore((s) => s.commandsCatalog); + + const { loading } = useOrdersCatalog(isConnected); return ( -
-
- - Quick Orders +
+
+ + + Orders
- - -
- - - - sendOrder(EMERGENCY_STOP_ORDERS)} /> -
+ {loading ? ( +
+ {Array.from({ length: 3 }).map((_, i) => ( + + ))} +
+ ) : ( +
+ +
+ )}
); }; -/** - * 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; 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 new file mode 100644 index 000000000..f7a14c346 --- /dev/null +++ b/frontend/competition-view/src/pages/Overview/components/TrackProgress.tsx @@ -0,0 +1,94 @@ +import { Card, CardContent } from "@workspace/ui/components"; +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"; +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; +const TICKS = Array.from({ length: TICK_COUNT + 1 }, (_, i) => (TRACK_LENGTH_M / TICK_COUNT) * i); + +/** 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 = () => { + 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; + 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 ( + + {/* 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 +
+ + + {typeof position === "number" && Number.isFinite(position) + ? Math.abs(position) >= 1e6 + ? formatAxisValue(position) + : position.toFixed(1) + : "—"} + / {TRACK_LENGTH_M} 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 2eb42ace9..1e02fa5e5 100644 --- a/frontend/competition-view/src/pages/Overview/components/VehicleStateBanner.tsx +++ b/frontend/competition-view/src/pages/Overview/components/VehicleStateBanner.tsx @@ -1,5 +1,5 @@ import { Badge } from "@workspace/ui/components"; -import { VCU } from "../../../constants/measurements"; +import { BOARDS, VCU } from "../../../constants/measurements"; import useMeasurement from "../../../hooks/useMeasurement"; type StateCategory = "emergency" | "running" | "braking" | "idle" | "unknown"; @@ -45,41 +45,42 @@ 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 state and active-brakes status. + * Background and text colour change based on the detected state category. */ -const VehicleStateBanner = () => { - const generalState = useMeasurement(VCU.generalState); - const operationalState = useMeasurement(VCU.operationalState); +const VehicleStateBanner = ({ compact = false }: VehicleStateBannerProps) => { + 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 ( -
-
+
+
Vehicle State - - {generalState !== undefined ? String(generalState) : "—"} + + {state !== undefined ? String(state) : "—"}
-
+
- Operational State + Brakes - {operationalState !== undefined ? String(operationalState) : "—"} + {activeBrakes === undefined ? "—" : activeBrakes ? "BRAKED" : "UNBRAKED"}
diff --git a/frontend/competition-view/src/store/slices/telemetrySlice.ts b/frontend/competition-view/src/store/slices/telemetrySlice.ts index 9e2578134..f16f24cba 100644 --- a/frontend/competition-view/src/store/slices/telemetrySlice.ts +++ b/frontend/competition-view/src/store/slices/telemetrySlice.ts @@ -1,11 +1,15 @@ import type { StateCreator } from "zustand"; +import { markFresh } from "../../lib/freshness"; import type { TelemetryData, TelemetryState } from "../../types/telemetry"; import type { Store } from "../store"; export interface TelemetrySlice { telemetry: TelemetryState; - updateTelemetry: (packet: TelemetryData) => void; - getMeasurement: (id: string) => number | boolean | string | undefined; + /** Maps numeric packet ID → board name. Populated from podDataStructure on connect. */ + packetBoard: Record; + setPacketBoard: (map: Record) => void; + updateTelemetry: (packets: TelemetryData) => void; + getMeasurement: (board: string, id: string) => number | boolean | string | undefined; } export const createTelemetrySlice: StateCreator< @@ -15,20 +19,43 @@ export const createTelemetrySlice: StateCreator< TelemetrySlice > = (set, get) => ({ telemetry: {}, + packetBoard: {}, - updateTelemetry: (packet) => { - const flat: TelemetryState = {}; + setPacketBoard: (map) => set({ packetBoard: map }), - 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; + 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)]; + if (!boardName) continue; + + if (!updates[boardName]) updates[boardName] = {}; + + for (const [key, value] of Object.entries(packet.measurementUpdates)) { + if (typeof value === "object" && value !== null && "last" in value) { + updates[boardName][key] = value.last; + } else { + updates[boardName][key] = value as number | boolean | string; + } + markFresh(boardName, key, now); } } - set((state) => ({ telemetry: { ...state.telemetry, ...flat } })); + // 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 of boards) { + newTelemetry[board] = { ...newTelemetry[board], ...updates[board] }; + } + return { telemetry: newTelemetry }; + }); }, - getMeasurement: (id) => get().telemetry[id], + getMeasurement: (board, id) => get().telemetry[board]?.[id], }); 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"; 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; } diff --git a/frontend/competition-view/src/types/telemetry.ts b/frontend/competition-view/src/types/telemetry.ts index 9cbf175d0..2f8248553 100644 --- a/frontend/competition-view/src/types/telemetry.ts +++ b/frontend/competition-view/src/types/telemetry.ts @@ -1,25 +1,14 @@ -/** 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. - * The store keeps only the most recent value per measurement to avoid - * unbounded memory growth. + * Telemetry keyed first by board name then by measurement ID. + * Two-level lookup prevents collisions between boards that share + * measurement names (e.g. VCU and LCU both have "general_state"). */ -export type TelemetryState = Record; +export type TelemetryState = Record>; 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"); } 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"; +