diff --git a/electron-app/src/app/initialization.js b/electron-app/src/app/initialization.js index 3e6195ca0..433c7fddc 100644 --- a/electron-app/src/app/initialization.js +++ b/electron-app/src/app/initialization.js @@ -3,19 +3,25 @@ * @description Application initialization: config setup and cleanup. */ +import { Menu } from "electron"; import { getConfigManager } from "../config/configInstance.js"; import { setupIpcHandlers } from "../ipc/handlers.js"; +import { createMenu } from "../menu/menu.js"; import { logger } from "../utils/logger.js"; import { cleanupLeftoverBackendProcesses } from "./cleanup.js"; /** * Initializes the application: + * - Sets the application menu shared by all windows * - Sets up IPC handlers * - Initializes configuration * - Cleans up leftover processes * @returns {Promise} */ async function initializeApp() { + // Application-wide menu so shortcuts (F11 fullscreen, F12 DevTools, ...) work in every window + Menu.setApplicationMenu(createMenu()); + // Setup IPC handlers for renderer process communication setupIpcHandlers(); diff --git a/electron-app/src/ipc/handlers.js b/electron-app/src/ipc/handlers.js index 4e774ea72..fd3a646a3 100644 --- a/electron-app/src/ipc/handlers.js +++ b/electron-app/src/ipc/handlers.js @@ -68,8 +68,9 @@ function setupIpcHandlers() { * @event setup-kernel * @async * @description Configures kernel parameters needed by the backend - * (disables the TCP invalid-packet rate limit). Linux only; on other - * platforms it shows a warning dialog. + * (disables the TCP invalid-packet rate limit and lowers the TCP + * retransmission limit). Linux only; on other platforms it shows a + * warning dialog. * @returns {Promise<{success: boolean, message: string}>} */ ipcMain.handle("setup-kernel", async () => { @@ -88,6 +89,7 @@ function setupIpcHandlers() { "sysctl", "-w", "net.ipv4.tcp_invalid_ratelimit=0", + "net.ipv4.tcp_retries2=3", ]); logger.electron.info(`Kernel setup completed: ${stdout.trim()}`); return { success: true, message: "Kernel set up successfully" }; diff --git a/electron-app/src/menu/README.md b/electron-app/src/menu/README.md index 8f3b95918..9b124b27a 100644 --- a/electron-app/src/menu/README.md +++ b/electron-app/src/menu/README.md @@ -4,7 +4,7 @@ Application menu system for the Electron application. Provides the native menu b ## Overview -Creates and manages the native application menu bar with view switching, window controls, developer tools, and utility applications. +Creates the native application menu bar shared by all windows. Menu actions operate on the currently focused window, so shortcuts like fullscreen and DevTools work in the main window, the mode selector, and the log window alike. ## Files @@ -14,19 +14,17 @@ Creates and manages the native application menu bar with view switching, window ### File Menu -- **Reload** (`CmdOrCtrl+R`) - Reloads the current window +- **Reload** (`CmdOrCtrl+R`) - Reloads the focused window +- **Return to Selector** (`CmdOrCtrl+Shift+S`) - Stops services and returns to the mode selector - **Exit** (`CmdOrCtrl+Q`) - Quits the application -### View Menu +### Tools Menu -- **Control Station** (`CmdOrCtrl+1`) - Switches to Competition View -- **Ethernet View** (`CmdOrCtrl+2`) - Switches to Testing View -- **Toggle DevTools** (`F12`) - Opens/closes Chrome DevTools - -### Tools Menu (Developing) - -- **Start Packet Sender** - Launches packet sender utility (validates binary exists) -- **Stop Packet Sender** - Stops the running packet sender process +- **Zoom In** (`CmdOrCtrl+Plus`) - Increases zoom of the focused window +- **Zoom Out** (`CmdOrCtrl+-`) - Decreases zoom of the focused window +- **Reset Zoom** (`CmdOrCtrl+0`) - Restores the focused window to 100% zoom +- **Toggle Full Screen** (`F11`) - Toggles fullscreen on the focused window +- **Toggle DevTools** (`F12`) - Opens/closes Chrome DevTools for the focused window ### Help Menu @@ -34,28 +32,24 @@ Creates and manages the native application menu bar with view switching, window ## Functions -### `createMenu(mainWindow)` +### `createMenu()` -Creates and sets the application menu bar. Takes the main window instance as parameter. +Builds and returns the application menu. Set it globally with `Menu.setApplicationMenu(createMenu())` so it applies to every window. ## Dependencies -- `../utils/paths.js` - For resolving binary paths -- `electron` - For Menu, dialog, and app APIs +- `electron` - For Menu, BrowserWindow, dialog, and app APIs ## Used By -- **`windows/mainWindow.js`** - Creates the menu when initializing the main window +- **`app/initialization.js`** - Sets the application-wide menu during app initialization ## Notes - Keyboard shortcuts automatically use `Cmd` on macOS and `Ctrl` on Windows/Linux - Menu appears in system menu bar on macOS, in window on Windows/Linux -- Packet sender binary existence is validated before starting -- View switching requires `loadView` to be imported from windows module +- Menu items resolve their target window at click time (focused window), so no window reference is needed when building the menu ## See Also -- [../windows/README.md](../windows/README.md) - Window management (used for view switching) -- [../processes/README.md](../processes/README.md) - Process management (packet sender) -- [../utils/README.md](../utils/README.md) - Utility functions (path resolution) +- [../windows/README.md](../windows/README.md) - Window management diff --git a/electron-app/src/menu/menu.js b/electron-app/src/menu/menu.js index a7cdc27e7..f9cf5926f 100644 --- a/electron-app/src/menu/menu.js +++ b/electron-app/src/menu/menu.js @@ -4,18 +4,19 @@ * Defines menu structure with File, Tools, and Help sections with keyboard shortcuts and actions. */ -import { Menu, app, dialog } from "electron"; +import { BrowserWindow, Menu, app, dialog } from "electron"; /** - * Creates and sets the application menu with File, Tools, and Help sections. - * Includes menu items for reloading, exiting, toggling DevTools, and app information. + * Creates the application menu with File, Tools, and Help sections. + * Includes menu items for reloading, exiting, toggling fullscreen/DevTools, and app information. + * Menu actions operate on the currently focused window, so the menu can be + * shared by all windows via Menu.setApplicationMenu. * View switching is no longer available since the mode is selected at startup. - * @param {import("electron").BrowserWindow} mainWindow - The main browser window instance to attach menu actions to. - * @returns {void} + * @returns {Menu} The built application menu. * @example - * createMenu(mainWindow); + * Menu.setApplicationMenu(createMenu()); */ -function createMenu(mainWindow) { +function createMenu() { const template = [ { label: "File", @@ -45,6 +46,19 @@ function createMenu(mainWindow) { { label: "Tools", submenu: [ + { role: "zoomIn", label: "Zoom In" }, + { role: "zoomOut", label: "Zoom Out" }, + { role: "resetZoom", label: "Reset Zoom" }, + { type: "separator" }, + { + label: "Toggle Full Screen", + accelerator: "F11", + click: (_, browserWindow) => { + if (browserWindow) { + browserWindow.setFullScreen(!browserWindow.isFullScreen()); + } + }, + }, { label: "Toggle DevTools", accelerator: "F12", @@ -62,7 +76,7 @@ function createMenu(mainWindow) { { label: "About", click: () => { - dialog.showMessageBox(mainWindow, { + dialog.showMessageBox(BrowserWindow.getFocusedWindow() ?? undefined, { type: "info", title: "About", message: "Hyperloop UPV Control Station", diff --git a/electron-app/src/windows/mainWindow.js b/electron-app/src/windows/mainWindow.js index 0a963e325..b10f5a4cd 100644 --- a/electron-app/src/windows/mainWindow.js +++ b/electron-app/src/windows/mainWindow.js @@ -7,7 +7,6 @@ import { BrowserWindow, app, dialog } from "electron"; import fs from "fs"; import path from "path"; -import { createMenu } from "../menu/menu.js"; import { getAppPath } from "../utils/paths.js"; // Get the application root path @@ -57,10 +56,6 @@ function createWindow(screenWidth, screenHeight, initialView) { loadView(currentView); } - // Create application menu - const menu = createMenu(mainWindow); - mainWindow.setMenu(menu); - // Open DevTools in development mode (skip in test env to keep window order predictable) if (!app.isPackaged && process.env.NODE_ENV !== "test") { mainWindow.webContents.openDevTools(); diff --git a/frontend/competition-view/src/components/header/DashboardStatusBar.tsx b/frontend/competition-view/src/components/header/DashboardStatusBar.tsx index 4bf6259d5..ab4b3f1e8 100644 --- a/frontend/competition-view/src/components/header/DashboardStatusBar.tsx +++ b/frontend/competition-view/src/components/header/DashboardStatusBar.tsx @@ -39,11 +39,11 @@ const StatValue = ({ const DashboardStatusBar = () => { const state = useMeasurement(BOARDS.VCU, VCU.state); - const brakeRaw = useMeasurement(BOARDS.VCU, VCU.activeBrakes); + const brakeRaw = useMeasurement(BOARDS.VCU, VCU.brakesStatus); 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 brakeStale = useIsStale(BOARDS.VCU, VCU.brakesStatus); const dcLinkStale = useIsStale(BOARDS.HVBMS, HVBMS.voltageReading); const dcLinkActive = dcLinkV !== undefined && dcLinkV > HVAL_THRESHOLD_V; @@ -53,11 +53,11 @@ const DashboardStatusBar = () => { ? "text-muted-foreground" : dcLinkActive ? "text-red-500" : "text-green-500"; - const brakeLabel = brakeRaw === undefined ? "—" : brakeRaw ? "BRAKED" : "UNBRAKED"; + const brakeLabel = brakeRaw === undefined ? "—" : String(brakeRaw); const brakeClass = - brakeStale ? STALE_TEXT_CLASS : - brakeRaw === true ? "text-red-500" : - brakeRaw === false ? "text-blue-500" : + brakeStale ? STALE_TEXT_CLASS : + brakeRaw === "BRAKED" ? "text-red-500" : + brakeRaw === "UNBRAKED" ? "text-blue-500" : "text-muted-foreground"; return ( diff --git a/frontend/competition-view/src/constants/measurements.ts b/frontend/competition-view/src/constants/measurements.ts index df6b35fa2..b22128fe5 100644 --- a/frontend/competition-view/src/constants/measurements.ts +++ b/frontend/competition-view/src/constants/measurements.ts @@ -22,7 +22,8 @@ export const VCU = { lowPressure: "low_pressure", pressureRegulatorFdbk: "pressure_regulator_feedback", sdcClosed: "sdc_closed", - activeBrakes: "active_brakes", + // Enum: "BRAKED" | "UNBRAKED" + brakesStatus: "brakes_status", brakeFault: "brake_fault_detected", electrovalveEnabled: "electrovalve_enabled", // Sub-board connectivity as reported by the VCU @@ -65,6 +66,12 @@ export const HV_CURRENT_RANGE: readonly [number, number] = [0, 120]; 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]; +/** Battery temperature safety interval (°C). */ +export const TEMP_RANGE: readonly [number, number] = [0, 60]; +/** Propulsion velocity safety interval (km/h). */ +export const SPEED_RANGE: readonly [number, number] = [0, 50]; +/** Vertical airgap safety interval (mm). */ +export const VERT_AIRGAP_RANGE: readonly [number, number] = [10, 25]; /** Cell voltage display range and warning thresholds (V), shared by the battery views. */ export const CELL_V_MIN = 3.0; diff --git a/frontend/competition-view/src/constants/orderLimits.ts b/frontend/competition-view/src/constants/orderLimits.ts new file mode 100644 index 000000000..f8f4749a3 --- /dev/null +++ b/frontend/competition-view/src/constants/orderLimits.ts @@ -0,0 +1,50 @@ +import type { CommandCatalogItem, NumericParameter } from "../types/catalog"; + +/** + * Per-order overrides for the numeric input limits reported by the backend + * catalog. Keyed by field key (the keys of `CommandCatalogItem.fields`); + * a `min`/`max` set here replaces the corresponding bound of the field's + * safeRange, so the input min/max, placeholder, out-of-range validation and + * error message all pick it up. `null` removes the bound; an omitted key + * keeps the catalog value. + */ +export interface FieldLimitChanges { + min?: number | null; + max?: number | null; +} + +export type OrderLimitOverride = [orderId: number, changes: Record]; + +export const ORDER_LIMIT_OVERRIDES: readonly OrderLimitOverride[] = [ + [100, { propulsion_current_reference: { min: 0, max: 120 } }], + [101, { levitation_target_height: { min: 5, max: 25 } }], + [102, { + propulsion_current_reference: { min: 0, max: 120 }, + levitation_target_height: { min: 5, max: 25 }, + }], +]; + +const overridesById = new Map(ORDER_LIMIT_OVERRIDES); + +const overrideSafeRange = (param: NumericParameter, changes: FieldLimitChanges): NumericParameter => ({ + ...param, + safeRange: [ + "min" in changes ? (changes.min ?? null) : param.safeRange[0], + "max" in changes ? (changes.max ?? null) : param.safeRange[1], + ], +}); + +/** Returns the item with ORDER_LIMIT_OVERRIDES applied to its numeric fields (the item itself if it has none). */ +export const applyOrderLimitOverrides = (item: CommandCatalogItem): CommandCatalogItem => { + const changes = overridesById.get(item.id); + if (!changes) return item; + + const fields = Object.fromEntries( + Object.entries(item.fields).map(([key, param]) => [ + key, + param.kind === "numeric" && changes[key] ? overrideSafeRange(param, changes[key]) : param, + ]), + ); + + return { ...item, fields }; +}; diff --git a/frontend/competition-view/src/pages/Batteries/components/HvBatterySection.tsx b/frontend/competition-view/src/pages/Batteries/components/HvBatterySection.tsx index ebd46e4d2..cd057377e 100644 --- a/frontend/competition-view/src/pages/Batteries/components/HvBatterySection.tsx +++ b/frontend/competition-view/src/pages/Batteries/components/HvBatterySection.tsx @@ -6,6 +6,7 @@ import { CELL_V_WARN_LOW, HVBMS, PACK_V_RANGE, + TEMP_RANGE, } from "../../../constants/measurements"; import { useStaleFlags } from "../../../hooks/useIsStale"; import useMeasurement from "../../../hooks/useMeasurement"; @@ -87,8 +88,8 @@ const HvBatterySection = () => { 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" }, + { label: "T max", value: fmt(tempMax), unit: "°C", range: TEMP_RANGE }, + { label: "T min", value: fmt(tempMin), unit: "°C", range: TEMP_RANGE }, ].map(({ label, value, unit, valueClass, range }, i) => (
{label} diff --git a/frontend/competition-view/src/pages/Booster/Booster.tsx b/frontend/competition-view/src/pages/Booster/Booster.tsx index 2fdc737c7..ceea7ef48 100644 --- a/frontend/competition-view/src/pages/Booster/Booster.tsx +++ b/frontend/competition-view/src/pages/Booster/Booster.tsx @@ -101,7 +101,7 @@ const Booster = () => (
- + diff --git a/frontend/competition-view/src/pages/Charts/Charts.tsx b/frontend/competition-view/src/pages/Charts/Charts.tsx index 52b8d4282..d4a59c784 100644 --- a/frontend/competition-view/src/pages/Charts/Charts.tsx +++ b/frontend/competition-view/src/pages/Charts/Charts.tsx @@ -6,7 +6,7 @@ import { MoveVertical, Zap, } from "lucide-react"; -import { BOARDS, HVBMS, LCU, PCU, VCU } from "../../constants/measurements"; +import { BOARDS, HVBMS, LCU, PCU, SPEED_RANGE, VERT_AIRGAP_RANGE } from "../../constants/measurements"; import MultiSeriesChart, { type SeriesConfig } from "./components/MultiSeriesChart"; import TelemetryChart from "./components/TelemetryChart"; @@ -60,7 +60,7 @@ const Charts = () => (
{/* Row 1 — Kinematic */} - + {/* Row 2 — Electrical */} @@ -69,10 +69,9 @@ const Charts = () => ( {/* Row 3 — DLIM motor currents */} - {/* Row 4 — Airgaps */} - + {/* Row 5 — Levitation currents */} diff --git a/frontend/competition-view/src/pages/Orders/components/OrderParameters.tsx b/frontend/competition-view/src/pages/Orders/components/OrderParameters.tsx index fc31572ae..967e1ef7d 100644 --- a/frontend/competition-view/src/pages/Orders/components/OrderParameters.tsx +++ b/frontend/competition-view/src/pages/Orders/components/OrderParameters.tsx @@ -9,7 +9,21 @@ import { SelectTrigger, SelectValue, } from "@workspace/ui/components"; -import type { CommandParameter, ParameterValues } from "../../../types/catalog"; +import type { CommandParameter, NumericParameter, ParameterValues } from "../../../types/catalog"; + +/** True when the field holds a number outside the parameter's safeRange (empty/NaN doesn't count). */ +export const isNumericValueOutOfRange = (param: NumericParameter, raw: unknown): boolean => { + const value = parseFloat(String(raw)); + if (!Number.isFinite(value)) return false; + const [min, max] = param.safeRange; + return (min != null && value < min) || (max != null && value > max); +}; + +const rangeLabel = (min: number | null, max: number | null): string => { + if (min != null && max != null) return `between ${min} and ${max}`; + if (min != null) return `at least ${min}`; + return `at most ${max}`; +}; interface OrderParametersProps { fields: Record; @@ -24,17 +38,27 @@ const OrderParameters = ({ fields, values, onChange }: OrderParametersProps) => {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 - } - /> + <> + onChange(key, e.target.value)} + aria-invalid={isNumericValueOutOfRange(param, values[key]) || undefined} + placeholder={ + param.safeRange[0] != null && param.safeRange[1] != null + ? `${param.safeRange[0]}–${param.safeRange[1]}` + : undefined + } + /> + {isNumericValueOutOfRange(param, values[key]) && ( +

+ Must be {rangeLabel(param.safeRange[0], param.safeRange[1])}. +

+ )} + )} {param.kind === "enum" && (