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
+
+
+
+
+
+
+ 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
+
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 */}
-
-
- {/* 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 */}
-
-
-);
-
-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 = () => (
-
-);
-
-const RotationSection = () => (
-
-);
-
-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 ────────────────────────────────────────────────── */}
+
+
+ {/* ── 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 = (
+
+ {sent ? (
+
+ ) : prominent ? (
+
+ ) : (
+
+ )}
+ {sent ? "Sent" : name}
+
+ );
+
+ 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" && (
+ onChange(key, v)}>
+
+
+
+
+ {param.options.map((opt) => (
+ {opt}
+ ))}
+
+
+ )}
+ {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 (
-
-
{param.name}
-
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"];
-}) => (
-
- {param.name}
- onChange(fieldKey, v)}
- >
-
-
-
-
- {param.options.map((opt) => (
-
- {opt}
-
- ))}
-
-
-
-);
-
-const BooleanField = ({
- fieldKey,
- param,
- value,
- onChange,
-}: {
- fieldKey: string;
- param: BooleanParameter;
- value: boolean;
- onChange: FieldProps["onChange"];
-}) => (
-
- onChange(fieldKey, !!checked)}
- />
-
- {param.name}
-
-
-);
-
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) => (
+
+ placeholder
+
+ ))}
+
+
+ {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}
+
+ Clear
+
+
+
+
setScrolledAway(e.currentTarget.scrollTop > 60)}
+ className="relative min-h-0 flex-1 overflow-y-auto"
+ >
+ {messages.length === 0 ? (
+
No messages
+ ) : (
+ messages.map((msg) =>
)
+ )}
+ {scrolledAway && (
+
scrollRef.current?.scrollTo({ top: 0, behavior: "smooth" })}
+ className="sticky bottom-3 left-1/2 z-10 -translate-x-1/2 gap-1 px-2 py-1 text-xs shadow">
+ Latest
+
+ )}
+
+
+ );
+};
+
+/* ─── 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(BRAKE_ORDERS)}
- title="Engage brakes (ID 215)"
- >
- Brake
-
-
- sendOrder(OPEN_CONTACTORS_ORDERS)}
- title="Cut HV power (ID 902)"
- >
- Open Contactors
-
- 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 (
-
- {armed ? "⚠ Click again to confirm" : "⚠ Emergency Stop"}
-
- );
-};
-
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
+
+ ))}
+
+
+
+
+
+
+ {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";
+