From 32f109a6ced19acbe0b5c317f89cbae4a5a87816 Mon Sep 17 00:00:00 2001 From: Amelia Date: Tue, 18 Aug 2026 18:27:54 -0400 Subject: [PATCH 1/4] feat: add RadioConfigView component for displaying vehicle radio configuration - Implemented a new component `RadioConfigView` that provides a read-only view of the FMC-authoritative radio configuration record. - Added utility functions for formatting values such as frequency and time. - Integrated transaction badges to indicate the status of the radio configuration. - Updated the API to support sending and receiving radio configuration data. - Enhanced flight data parsing to include radio configuration state and details. - Updated the tools index and manifest to include the new radio tool. --- src/app/flight/page.tsx | 1 - src/app/tools/radio/page.tsx | 23 ++ src/app/tools/radio/tool.ts | 8 + src/components/devices/ChargerCard.tsx | 16 +- src/components/flight/FmcAuxCard.tsx | 346 +++++++++++++++++------ src/components/tools/RadioConfigView.tsx | 222 +++++++++++++++ src/components/tools/index.ts | 1 + src/lib/api/direct.ts | 72 ++++- src/lib/flight/index.ts | 171 ++++++++++- src/lib/flight/types.ts | 138 +++++++-- src/lib/tools/manifest.ts | 1 + 11 files changed, 868 insertions(+), 131 deletions(-) create mode 100644 src/app/tools/radio/page.tsx create mode 100644 src/app/tools/radio/tool.ts create mode 100644 src/components/tools/RadioConfigView.tsx diff --git a/src/app/flight/page.tsx b/src/app/flight/page.tsx index 8b60f79..eb89bea 100644 --- a/src/app/flight/page.tsx +++ b/src/app/flight/page.tsx @@ -229,7 +229,6 @@ export default function FlightPage() { diff --git a/src/app/tools/radio/page.tsx b/src/app/tools/radio/page.tsx new file mode 100644 index 0000000..52dcc74 --- /dev/null +++ b/src/app/tools/radio/page.tsx @@ -0,0 +1,23 @@ +"use client"; + +import { Box, Button } from "@chakra-ui/react"; +import Link from "next/link"; +import { PageHeader } from "@/components/shell"; +import { RadioConfigView } from "@/components/tools"; + +export default function RadioConfigPage() { + return ( + + + ← Back to Tools + + } + /> + + + ); +} diff --git a/src/app/tools/radio/tool.ts b/src/app/tools/radio/tool.ts new file mode 100644 index 0000000..96fb657 --- /dev/null +++ b/src/app/tools/radio/tool.ts @@ -0,0 +1,8 @@ +import { registerTool } from "@/lib/tools/registry"; + +registerTool({ + slug: "radio", + label: "Vehicle Radio", + description: "STM32WL config record — LoRa parameters, pressure streams, RF chain timings.", + icon: "settings_input_antenna", +}); diff --git a/src/components/devices/ChargerCard.tsx b/src/components/devices/ChargerCard.tsx index 19562cd..fc7d141 100644 --- a/src/components/devices/ChargerCard.tsx +++ b/src/components/devices/ChargerCard.tsx @@ -134,7 +134,13 @@ export function ChargerCard({ boardKey, charger, chgCfg, stale = false }: Charge Firmware limits - {chgCfg.vlimit === true && voltage cutoff active} + + {chgCfg.vlimit === true && voltage cutoff active} + {/* The LTC gate state cannot be proven, so no reading here is trustworthy. */} + {chgCfg.controlUnknown === true && control state unknown} + {/* Flash write failed: the running state may not survive a reset. */} + {chgCfg.persistError === true && persist error} + @@ -152,6 +158,14 @@ export function ChargerCard({ boardKey, charger, chgCfg, stale = false }: Charge label="cells" value={chgCfg.cells != null ? String(chgCfg.cells) : "—"} /> + + diff --git a/src/components/flight/FmcAuxCard.tsx b/src/components/flight/FmcAuxCard.tsx index 6993415..fe005e2 100644 --- a/src/components/flight/FmcAuxCard.tsx +++ b/src/components/flight/FmcAuxCard.tsx @@ -1,33 +1,42 @@ "use client"; import { useState } from "react"; -import { Box, Button, Flex, Text } from "@chakra-ui/react"; -import { Card, Mono } from "@/components/primitives"; +import { Box, Button, Flex, Input, Text } from "@chakra-ui/react"; +import { Card, Chip, Mono } from "@/components/primitives"; +import type { Status } from "@/components/primitives"; import { useNovaStore } from "@/lib/store/store"; import { sel } from "@/lib/store/selectors"; -import { sendFasAux, sendFasRf } from "@/lib/api/direct"; -import type { FmcAuxStatus, FmcRfStatus, FmcStatus } from "@/lib/flight/types"; +import { sendFasAux, sendFasRuncamRecord, withBackendSupport } from "@/lib/api/direct"; +import type { FmcAuxStatus, FmcStatus } from "@/lib/flight/types"; export interface FmcAuxCardProps { fmc: FmcStatus | undefined; aux?: FmcAuxStatus; - rf?: FmcRfStatus; - /** Node addressed by the aux / RF commands. */ + /** Node addressed by the aux / recording commands. */ node?: string; } +/** Firmware clamp on the RunCam auto-stop timer (12 h). */ +const RUNCAM_AUTOSTOP_MAX_S = 43200; + interface Cell { label: string; value: string; + /** Colour for the value — used to make faults and dropped frames stand out. */ + status?: Status; } function num(v: number | undefined, suffix = "", digits = 1): string { return v !== undefined ? `${v.toFixed(digits)}${suffix}` : "—"; } +function onOff(v: boolean | undefined, on = "on", off = "off"): string { + return v === undefined ? "—" : v ? on : off; +} + function group(title: string, cells: Cell[]) { return ( - + ( {c.label} - {c.value} + {c.value} ))} @@ -50,34 +59,106 @@ function group(title: string, cells: Cell[]) { } // --------------------------------------------------------------------------- -// RF rate / power mode +// STM32WL radio rows // -// The mode is persisted on the FMC and echoed ~1 Hz. This control is display-only -// until the operator picks a mode — it must never push a default on mount, which -// would clobber the FMC's stored choice. +// `powered` is the rail's own read-back and `powerRequested` is what the +// operator asked for: they can disagree, and that disagreement is the state +// worth showing loudest after an outright fault. // --------------------------------------------------------------------------- -const RF_MODES: Array<{ mode: 0 | 1 | 2; label: string; hint: string }> = [ - { mode: 0, label: "low", hint: "power-saving (default)" }, - { mode: 1, label: "normal", hint: "normal rate" }, - { mode: 2, label: "high", hint: "bench / close-range" }, -]; +function radioCells(radio: FmcStatus["radio"]): Cell[] { + const powerValue = + radio?.powered === undefined && radio?.powerRequested === undefined + ? "—" + : radio?.powered + ? "on" + : radio?.powerRequested + ? "requested, not on" + : "off"; + + const configValue = + radio?.configValid === undefined + ? "—" + : !radio.configValid + ? "invalid" + : radio.readbackMatches === false + ? "valid / MISMATCH" + : radio.readbackMatches + ? "valid / matches" + : "valid"; -function RfModeControl({ rf, node }: { rf: FmcRfStatus | undefined; node: string }) { + const dropped = radio?.txDropped; + + return [ + { + label: "fault", + value: radio?.fault === undefined ? "—" : radio.fault ? "FAULT" : "none", + status: radio?.fault ? "error" : undefined, + }, + { + label: "ready", + value: onOff(radio?.ready, "yes", "no"), + status: radio?.ready === false ? "warn" : undefined, + }, + { + label: "power", + value: powerValue, + status: powerValue === "requested, not on" ? "warn" : undefined, + }, + { + label: "config", + value: configValue, + status: configValue === "invalid" || configValue === "valid / MISMATCH" ? "warn" : undefined, + }, + { label: "clock", value: onOff(radio?.clockCalibrated, "calibrated", "uncalibrated") }, + { label: "queue", value: num(radio?.queueDepth, "", 0) }, + { + label: "tx", + value: + radio?.txAccepted === undefined && dropped === undefined + ? "—" + : `${num(radio?.txAccepted, "", 0)} ok / ${num(dropped, "", 0)} drop`, + status: dropped !== undefined && dropped > 0 ? "warn" : undefined, + }, + ]; +} + +// --------------------------------------------------------------------------- +// Aux rail power +// +// The vehicle modem rail (`radio`) is an FMC-local load switch. The RunCam and +// RF amplifier rails are EPB load switches driven through the actuator path, so +// they carry the same physical-lockout gate as the relay/servo tools. +// --------------------------------------------------------------------------- + +function PowerToggle({ + label, device, powered, node, gated, +}: { + label: string; + device: "radio" | "runcam" | "rf_pa"; + powered: boolean | undefined; + node: string; + /** True for the EPB actuator rails, which the physical lockout blocks. */ + gated: boolean; +}) { const clientId = useNovaStore(sel.clientId); const canCommand = useNovaStore(sel.canCommand); - const [busy, setBusy] = useState(false); - const [error, setError] = useState(null); + const isLocked = useNovaStore(sel.isLocked); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); - const current = rf?.rateMode; - const disabled = !clientId || !canCommand || busy; + const lockBlocked = gated && isLocked; + // Unknown power state → no defensible target to send; leave it to telemetry. + const disabled = !clientId || !canCommand || powered === undefined || lockBlocked; - async function pick(mode: 0 | 1 | 2) { - if (!clientId || mode === current) return; + async function toggle() { + if (!clientId || powered === undefined || lockBlocked) return; setBusy(true); setError(null); try { - await sendFasRf({ node, mode }, clientId); + await withBackendSupport(label, () => + sendFasAux({ node, device, enable: !powered }, clientId), + ); } catch (e) { setError(e instanceof Error ? e.message : "Error"); } finally { @@ -86,112 +167,197 @@ function RfModeControl({ rf, node }: { rf: FmcRfStatus | undefined; node: string } return ( - - - RF rate / power - - - mode - - {rf?.rateName ?? (current !== undefined ? RF_MODES[current]?.label ?? String(current) : "—")} - - - - {RF_MODES.map((m) => ( - - ))} - - - low = power-saving (default) - + + + {lockBlocked && Nova lockout active} {error && {error}} ); } // --------------------------------------------------------------------------- -// RFD / RunCam load-switch power +// RunCam recording +// +// Separate from RunCam *power*: with `recOnPower` set on the radio config +// record (the firmware default), energising the camera rail starts a recording +// by itself, so the two controls can disagree without anyone having pressed +// anything here. // --------------------------------------------------------------------------- -function PowerToggle({ - label, device, powered, node, -}: { - label: string; - device: "rfd" | "runcam"; - powered: boolean | undefined; - node: string; -}) { +function RuncamRecordControl({ aux, node }: { aux: FmcAuxStatus | undefined; node: string }) { const clientId = useNovaStore(sel.clientId); const canCommand = useNovaStore(sel.canCommand); - const [busy, setBusy] = useState(false); + const [autostop, setAutostop] = useState("0"); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); - async function toggle() { - // Unknown power state → no defensible target to send; leave it to telemetry. - if (!clientId || powered === undefined) return; + const recording = aux?.runcamRecording; + const disabled = !clientId || !canCommand || busy; + + async function send(enable: boolean) { + if (!clientId) return; setBusy(true); + setError(null); try { - await sendFasAux({ node, device, enable: !powered }, clientId); + const body: Parameters[0] = { node, enable }; + if (enable) { + // The firmware clamps too, but a silent server-side clamp is a bad + // surprise — do it here so the operator sees the value that was sent. + const parsed = Number(autostop); + const seconds = Number.isFinite(parsed) ? Math.round(parsed) : 0; + const clamped = Math.min(Math.max(seconds, 0), RUNCAM_AUTOSTOP_MAX_S); + if (clamped !== seconds) setAutostop(String(clamped)); + body.autostop_s = clamped; + } + await withBackendSupport("RunCam recording", () => sendFasRuncamRecord(body, clientId)); } catch (e) { - console.error(e); + setError(e instanceof Error ? e.message : "Error"); } finally { setBusy(false); } } + const countdown = + aux?.runcamRecordS === undefined || aux.runcamRecordS === null + ? "—" + : `${aux.runcamRecordS} s`; + return ( - + + + RunCam recording + + + + + recording + + {onOff(recording, "yes", "no")} + + + + camera + + {onOff(aux?.runcamPresent, "present", "no answer")} + + + + auto-stop in + {countdown} + + + + {canCommand && ( + <> + + setAutostop(e.target.value)} + bg="bg.canvas" + borderColor="border.default" + fontFamily="mono" + flex="1" + minW="70px" + aria-label="RunCam auto-stop seconds" + /> + + + + + auto-stop seconds, 0 = until stopped (max {RUNCAM_AUTOSTOP_MAX_S}) + + + )} + + + With rec-on-power set, powering the camera rail starts a recording by itself. + + {error && {error}} + ); } -export function FmcAuxCard({ fmc, aux, rf, node = "FMC_0" }: FmcAuxCardProps) { +export function FmcAuxCard({ fmc, aux, node = "FMC_0" }: FmcAuxCardProps) { const temp = fmc?.temp; const radio = fmc?.radio; const canCommand = useNovaStore(sel.canCommand); return ( + {radio?.fault === true && ( + + + + radio fault{radio.lastFault ? ` · code ${radio.lastFault}` : ""} + + + + )} + {group("board temps", [ { label: "near H7", value: num(temp?.h7, " °C") }, { label: "power stage", value: num(temp?.pwr, " °C") }, ])} - {group("RFD900x radio", [ - { label: "power", value: radio?.powered === undefined ? "—" : radio.powered ? "on" : "off" }, - { label: "mirror", value: radio?.enabled === undefined ? "—" : radio.enabled ? "on" : "off" }, - { label: "frames", value: num(radio?.txFrames, "", 0) }, - ])} + {group("STM32WL radio", radioCells(radio))} {group("peripherals", [ - { label: "RunCam", value: aux?.runcamPowered === undefined ? "—" : aux.runcamPowered ? "on" : "off" }, + { + label: "RunCam", + value: onOff(aux?.runcamPowered), + status: aux?.runcamPowered === false && aux?.runcamRecording ? "warn" : undefined, + }, + { + label: "RF amp", + value: + aux?.rfPaOn === undefined && aux?.rfPaRequested === undefined + ? "—" + : aux?.rfPaOn + ? aux?.rfPaCycling ? "on (cycling)" : "on" + : aux?.rfPaInhibited + ? "inhibited" + : aux?.rfPaRequested + ? "requested, not on" + : "off", + status: + aux?.rfPaInhibited || (aux?.rfPaRequested && aux?.rfPaOn === false) ? "warn" : undefined, + }, { label: "GNSS PPS", value: aux?.ppsPresent === undefined ? "—" : aux.ppsPresent ? "lock" : "no" }, { label: "PPS age", value: aux?.ppsAgeMs === undefined ? "—" : `${(aux.ppsAgeMs / 1000).toFixed(1)} s` }, ])} - + + {aux?.rfPaInhibited && ( + + RF amplifier inhibited — the firmware is holding the PA off because the modem is not + ready. This is not a failed command. + + )} + {canCommand && ( - - - + + + + )} diff --git a/src/components/tools/RadioConfigView.tsx b/src/components/tools/RadioConfigView.tsx new file mode 100644 index 0000000..0cdd604 --- /dev/null +++ b/src/components/tools/RadioConfigView.tsx @@ -0,0 +1,222 @@ +"use client"; + +import { Box, Flex, Text } from "@chakra-ui/react"; +import { Card, Chip, Mono } from "@/components/primitives"; +import { useNovaStore } from "@/lib/store/store"; +import { sel } from "@/lib/store/selectors"; +import type { RadioConfigState } from "@/lib/flight/types"; + +// --------------------------------------------------------------------------- +// Read-only view of the FMC-authoritative radio configuration record +// (`fas_radio_cfg`, FMC_RADIO_CONFIG 0x3D). +// +// The record is the FMC's own copy: everything here is a read-back, not an +// intent. Editing is deliberately not implemented yet — the backend +// `POST /api/fas/radio_config` endpoint does not exist. `sendFasRadioConfig` +// in `lib/api/direct` is the hook for when it does. +// --------------------------------------------------------------------------- + +function Row({ label, value }: { label: string; value: string }) { + return ( + + {label} + {value} + + ); +} + +function Section({ title, children }: { title: string; children: React.ReactNode }) { + return ( + + + {title} + + {children} + + ); +} + +function hz(v: number | undefined): string { + if (v === undefined) return "—"; + if (v >= 1_000_000) return `${(v / 1_000_000).toFixed(3)} MHz`; + if (v >= 1_000) return `${(v / 1_000).toFixed(1)} kHz`; + return `${v} Hz`; +} + +function ms(v: number | undefined): string { + return v === undefined ? "—" : `${v} ms`; +} + +/** Peripheral bindings are `null` when the board/channel is unfitted. */ +function binding(board: number | null | undefined, channel: number | null | undefined): string { + if (board == null || channel == null) return "unfitted"; + return `EPB ${board} · ch ${channel}`; +} + +function flag(v: boolean | undefined): string { + return v === undefined ? "—" : v ? "yes" : "no"; +} + +function TransactionBadges({ state }: { state: RadioConfigState }) { + return ( + + {state.statusName && ( + + {state.statusName} + + )} + + {state.persisted ? "persisted" : "not persisted"} + + + {state.linkReady ? "link ready" : "link not ready"} + + + + {state.readbackMatches === false + ? "readback MISMATCH" + : state.readbackMatches + ? "readback matches" + : "readback unknown"} + + + {state.placeholderId && ( + placeholder callsign + )} + {state.validationError !== undefined && state.validationError !== 0 && ( + validation error {state.validationError} + )} + + ); +} + +export function RadioConfigView() { + const telemetry = useNovaStore(sel.flightData); + const state = telemetry?.radioConfig; + + if (!state) { + return ( + + + No fas_radio_cfg block in the current telemetry. The FMC publishes the + record on request; a backend that predates the STM32WL wave never sends it at all. + + + ); + } + + if (state.decodeError) { + return ( + + + decode failed + {state.decodeError} + + The 88-byte record could not be unpacked. Nothing below can be trusted, so nothing is + shown — check the firmware/bridge protocol versions. + + + ); + } + + const cfg = state.config; + if (!cfg) { + return ( + + + + A transaction is in flight but no config record has been read back yet. + + + ); + } + + const { lora, rfChain } = cfg; + + return ( + + + + + {state.placeholderId && ( + + Callsign is still the XXXXXX placeholder. This is a warning only — + operation is deliberately never gated on it. + + )} + + +
+ + + + + + {state.generation !== undefined && ( + + )} + {state.transactionId !== undefined && ( + + )} +
+ +
+ + + + + + +
+ +
+ {cfg.pressureChannels.length === 0 ? ( + none selected + ) : ( + cfg.pressureChannels.map((c, i) => ( + + )) + )} +
+
+
+ + + +
+ + +
+ +
+ + + + + + +
+ +
+ + + + +
+
+ + {rfChain.recOnPower && ( + + Record-on-power is set: energising the camera rail starts a recording on its own. + + )} +
+
+ ); +} diff --git a/src/components/tools/index.ts b/src/components/tools/index.ts index c281096..24a9bc0 100644 --- a/src/components/tools/index.ts +++ b/src/components/tools/index.ts @@ -1,3 +1,4 @@ export { ServoTester } from "./ServoTester"; export { RelayTester } from "./RelayTester"; export { SoundboardTool } from "./SoundboardTool"; +export { RadioConfigView } from "./RadioConfigView"; diff --git a/src/lib/api/direct.ts b/src/lib/api/direct.ts index 3d621b0..7a13a6d 100644 --- a/src/lib/api/direct.ts +++ b/src/lib/api/direct.ts @@ -1,6 +1,24 @@ import { novaFetch } from "./novaFetch"; import type { SourceTarget } from "../types"; +/** + * Run a call against an endpoint that the backend may not have yet (the + * STM32WL wave landed on the frontend first). A 404/405 becomes a clear + * "not supported yet" message rather than a bare API error; everything else + * propagates unchanged. + */ +export async function withBackendSupport(label: string, call: () => Promise): Promise { + try { + return await call(); + } catch (e) { + const message = e instanceof Error ? e.message : String(e); + if (/\b(404|405)\b/.test(message) || /not found/i.test(message)) { + throw new Error(`${label}: backend does not support this yet`); + } + throw e; + } +} + export interface DirectRelayBody { target: SourceTarget; node?: string; @@ -88,10 +106,15 @@ export function sendFasRab(body: FasRabBody, clientId: string): Promise<{ publis ); } -/** POST /api/fas/aux — RFD900 / RunCam load-switch power. */ +/** + * POST /api/fas/aux — load-switch power for the FMC's auxiliary rails. + * + * `radio` is the STM32WL vehicle modem's own rail and is not actuator-gated. + * `runcam` and `rf_pa` are EPB load switches: the actuator lock applies. + */ export interface FasAuxBody { node?: string | null; // default FMC_0 - device: "rfd" | "runcam"; + device: "radio" | "runcam" | "rf_pa"; enable: boolean; } @@ -103,15 +126,48 @@ export function sendFasAux(body: FasAuxBody, clientId: string): Promise<{ publis ); } -/** POST /api/fas/rf — set the FMC RF telemetry rate/power mode (persisted on the FMC). */ -export interface FasRfBody { - node?: string | null; // default FMC_0 - mode: 0 | 1 | 2; // 0 = low, 1 = normal, 2 = high +/** + * POST /api/fas/runcam_record — start/stop a RunCam recording. + * + * Distinct from {@link sendFasAux} with `device: "runcam"`, which only powers the + * camera rail. Note that with the config record's `recOnPower` set, powering the + * rail starts a recording on its own. + */ +export interface FasRuncamRecordBody { + node: string; + enable: boolean; + /** 0 = record until stopped. Max 43200. */ + autostop_s?: number; } -export function sendFasRf(body: FasRfBody, clientId: string): Promise<{ published_commands: unknown[] }> { +export function sendFasRuncamRecord( + body: FasRuncamRecordBody, + clientId: string, +): Promise<{ published_commands: unknown[] }> { + return novaFetch<{ published_commands: unknown[] }>( + "/api/fas/runcam_record", + { method: "POST", body: JSON.stringify(body) }, + clientId, + ); +} + +/** + * POST /api/fas/radio_config — patch the FMC-authoritative radio config record. + * + * `cfg` is a partial patch merged server-side against the stored record. + */ +export interface FasRadioConfigBody { + node: string; + cfg: Record; + target?: "vehicle" | "ground"; +} + +export function sendFasRadioConfig( + body: FasRadioConfigBody, + clientId: string, +): Promise<{ published_commands: unknown[] }> { return novaFetch<{ published_commands: unknown[] }>( - "/api/fas/rf", + "/api/fas/radio_config", { method: "POST", body: JSON.stringify(body) }, clientId, ); diff --git a/src/lib/flight/index.ts b/src/lib/flight/index.ts index c9825e4..255cb60 100644 --- a/src/lib/flight/index.ts +++ b/src/lib/flight/index.ts @@ -10,12 +10,16 @@ import type { FlightMilestones, FlightTelemetry, FmcAuxStatus, - FmcRfStatus, FmcStatus, GpsData, ImcStatus, PmbStatus, RabStatus, + RadioConfig, + RadioConfigState, + RadioLoraProfile, + RadioPressureChannel, + RadioRfChain, SoundClip, SoundStatus, SoundboardStatus, @@ -33,12 +37,16 @@ export type { FlightMilestones, FlightTelemetry, FmcAuxStatus, - FmcRfStatus, FmcStatus, GpsData, ImcStatus, PmbStatus, RabStatus, + RadioConfig, + RadioConfigState, + RadioLoraProfile, + RadioPressureChannel, + RadioRfChain, SoundClip, SoundStatus, SoundboardStatus, @@ -135,6 +143,10 @@ function bool(v: unknown): boolean | undefined { return undefined; } +function str(v: unknown): string | undefined { + return typeof v === "string" ? v : undefined; +} + function obj(v: unknown): Record | undefined { return typeof v === "object" && v !== null && !Array.isArray(v) ? (v as Record) @@ -312,6 +324,10 @@ function parsePmb(raw: unknown): Record | undefined { put(block, "vSetting", num(chgCfg["v_setting"])); put(block, "cells", num(chgCfg["cells"])); put(block, "vlimit", bool(chgCfg["vlimit"])); + put(block, "enabledIntent", bool(chgCfg["enabled_intent"])); + put(block, "persistError", bool(chgCfg["persist_error"])); + put(block, "targetsOk", bool(chgCfg["targets_ok"])); + put(block, "controlUnknown", bool(chgCfg["control_unknown"])); status.chgCfg = block; } @@ -362,11 +378,21 @@ function parseFmc(raw: unknown): Record | undefined { const radio = obj(o["radio"]); if (radio) { const block: NonNullable = {}; - put(block, "powered", bool(radio["powered"])); - put(block, "enabled", bool(radio["enabled"])); - put(block, "everyN", num(radio["every_n"])); - put(block, "txFrames", num(radio["tx_frames"])); - put(block, "txBytes", num(radio["tx_bytes"])); + put(block, "flags", num(radio["flags"])); + put(block, "state", num(radio["state"])); + put(block, "stateName", str(radio["state_name"])); + put(block, "lastFault", num(radio["last_fault"])); + put(block, "queueDepth", num(radio["queue_depth"])); + put(block, "txAccepted", num(radio["tx_accepted"])); + put(block, "txDropped", num(radio["tx_dropped"])); + put(block, "powerRequested", bool(radio["power_requested"])); + put(block, "powered", bool(radio["powered"])); + put(block, "ready", bool(radio["ready"])); + put(block, "configValid", bool(radio["config_valid"])); + put(block, "readbackMatches", bool(radio["readback_matches"])); + put(block, "txActive", bool(radio["tx_active"])); + put(block, "clockCalibrated", bool(radio["clock_calibrated"])); + put(block, "fault", bool(radio["fault"])); status.radio = block; } @@ -457,19 +483,136 @@ function parseAux(raw: unknown): FmcAuxStatus | undefined { runcamPowered: bool(o["runcam_powered"]) ?? false, ppsPresent: bool(o["pps_present"]) ?? false, }; + put(aux, "flags", num(o["aux_flags"])); + put(aux, "runcamPresent", bool(o["runcam_present"])); + put(aux, "runcamRecording", bool(o["runcam_recording"])); + put(aux, "runcamAutostop", bool(o["runcam_autostop"])); + // null means "not applicable" (0xFFFF on the wire), which is not 0 seconds — + // preserve it so the UI renders a dash rather than "stopping now". + if (o["runcam_record_s"] !== undefined) { + aux.runcamRecordS = o["runcam_record_s"] === null ? null : num(o["runcam_record_s"]) ?? null; + } + put(aux, "rfPaRequested", bool(o["rf_pa_requested"])); + put(aux, "rfPaOn", bool(o["rf_pa_on"])); + put(aux, "rfPaCycling", bool(o["rf_pa_cycling"])); + put(aux, "rfPaInhibited", bool(o["rf_pa_inhibited"])); put(aux, "ppsCount", num(o["pps_count"])); put(aux, "ppsAgeMs", num(o["pps_age_ms"])); return aux; } -function parseRf(raw: unknown): FmcRfStatus | undefined { +// --------------------------------------------------------------------------- +// Vehicle-radio configuration (`fas_radio_cfg`) +// --------------------------------------------------------------------------- + +/** null = unfitted peripheral binding; undefined = field absent. Both are preserved. */ +function nullableNum(v: unknown): number | null | undefined { + if (v === undefined) return undefined; + if (v === null) return null; + return num(v) ?? null; +} + +function parseLora(raw: unknown): RadioLoraProfile | undefined { const o = obj(raw); if (!o) return undefined; - const mode = num(o["rate_mode"]); - if (mode === undefined) return undefined; - const rf: FmcRfStatus = { rateMode: mode }; - put(rf, "rateName", typeof o["rate_name"] === "string" ? (o["rate_name"] as string) : undefined); - return rf; + return { + frequencyHz: num(o["frequency_hz"]) ?? 0, + bandwidthHz: num(o["bandwidth_hz"]) ?? 0, + powerDbm: num(o["power_dbm"]) ?? 0, + spreadingFactor: num(o["spreading_factor"]) ?? 0, + codingRate: str(o["coding_rate"]) ?? "", + preambleSymbols: num(o["preamble_symbols"]) ?? 0, + }; +} + +function parsePressureChannels(raw: unknown): RadioPressureChannel[] | undefined { + if (!Array.isArray(raw)) return undefined; + const out: RadioPressureChannel[] = []; + for (const entry of raw) { + const o = obj(entry); + if (!o) continue; + const boardId = num(o["board_id"]); + const channel = num(o["channel"]); + if (boardId === undefined || channel === undefined) continue; + out.push({ boardId, channel }); + } + return out; +} + +function parseRfChain(raw: unknown): RadioRfChain | undefined { + const o = obj(raw); + if (!o) return undefined; + const chain: RadioRfChain = { + cyclePeriodMs: num(o["cycle_period_ms"]) ?? 0, + warmupMs: num(o["warmup_ms"]) ?? 0, + tailMs: num(o["tail_ms"]) ?? 0, + maxOnMs: num(o["max_on_ms"]) ?? 0, + minOffMs: num(o["min_off_ms"]) ?? 0, + runcamAutostopS: num(o["runcam_autostop_s"]) ?? 0, + dutyCycle: bool(o["duty_cycle"]) ?? false, + runcamAutostop: bool(o["runcam_autostop"]) ?? false, + bootSound: bool(o["boot_sound"]) ?? false, + recOnPower: bool(o["rec_on_power"]) ?? false, + }; + // Peripheral bindings are null when the board/channel is unfitted. + const paBoard = nullableNum(o["pa_board_id"]); + if (paBoard !== undefined) chain.paBoardId = paBoard; + const paCh = nullableNum(o["pa_channel"]); + if (paCh !== undefined) chain.paChannel = paCh; + const rcBoard = nullableNum(o["runcam_board_id"]); + if (rcBoard !== undefined) chain.runcamBoardId = rcBoard; + const rcCh = nullableNum(o["runcam_channel"]); + if (rcCh !== undefined) chain.runcamChannel = rcCh; + return chain; +} + +function parseRadioConfigRecord(raw: unknown): RadioConfig | undefined { + const o = obj(raw); + if (!o) return undefined; + const lora = parseLora(o["lora"]); + const rfChain = parseRfChain(o["rf_chain"]); + if (!lora || !rfChain) return undefined; + return { + callsign: str(o["callsign"]) ?? "", + networkId: num(o["network_id"]) ?? 0, + vehicleNodeId: num(o["vehicle_node_id"]) ?? 0, + allocationLowHz: num(o["allocation_low_hz"]) ?? 0, + allocationHighHz: num(o["allocation_high_hz"]) ?? 0, + lora, + pressureChannels: parsePressureChannels(o["pressure_channels"]) ?? [], + rfChain, + }; +} + +/** + * `fas_radio_cfg` — the FMC-authoritative radio config plus its transaction + * envelope. A malformed 88-byte record arrives as + * `{ config_decode_error, raw_hex }`; that path leaves `config` undefined so the + * UI can show the decode failure rather than a half-parsed record. + */ +function parseRadioConfig(raw: unknown): RadioConfigState | undefined { + const o = obj(raw); + if (!o) return undefined; + const state: RadioConfigState = {}; + + const decodeError = str(o["config_decode_error"]); + if (decodeError !== undefined) { + state.decodeError = decodeError; + } else { + put(state, "config", parseRadioConfigRecord(o["config"])); + } + + put(state, "status", num(o["status"])); + put(state, "statusName", str(o["status_name"])); + put(state, "transactionId", num(o["transaction_id"])); + put(state, "generation", num(o["generation"])); + put(state, "persisted", bool(o["persisted"])); + put(state, "linkReady", bool(o["link_ready"])); + put(state, "readbackMatches", bool(o["readback_matches"])); + put(state, "placeholderId", bool(o["placeholder_id"])); + put(state, "validationError", num(o["validation_error"])); + + return Object.keys(state).length > 0 ? state : undefined; } function parseSound(raw: unknown): SoundboardStatus | undefined { @@ -590,7 +733,7 @@ export function adaptFlightData(raw: Record): FlightTelemetry { put(out, "imc", parseImc(raw["fas_imc"])); put(out, "rab", parseRab(raw["fas_rab"])); put(out, "aux", parseAux(raw["fas_aux"])); - put(out, "rf", parseRf(raw["fas_rf"])); + put(out, "radioConfig", parseRadioConfig(raw["fas_radio_cfg"])); put(out, "sound", parseSound(raw["fas_sound"])); put(out, "link", parseFasLink(raw["fas_link"])); diff --git a/src/lib/flight/types.ts b/src/lib/flight/types.ts index 744e76b..fb791b7 100644 --- a/src/lib/flight/types.ts +++ b/src/lib/flight/types.ts @@ -111,7 +111,11 @@ export interface PmbStatus { iSetting?: number; // charge-current DAC code (0..31) vSetting?: number; // charge-voltage DAC code (0..31) cells?: number; - vlimit?: boolean; // firmware charge-voltage cutoff currently holding + vlimit?: boolean; // firmware charge-voltage cutoff currently holding + enabledIntent?: boolean; // persisted automatic-charge intent + persistError?: boolean; // runtime state may differ from flash — warn + targetsOk?: boolean; // direct I/V targets verified by read-back + controlUnknown?: boolean; // LTC gate state cannot be proven — warn }; } @@ -139,12 +143,31 @@ export interface FmcSdStatus { rateDiv?: number | null; } +/** STM32WL vehicle-radio state (`fas_fmc[key].radio`), echoed ~1 Hz. */ export interface FmcRadioStatus { - powered?: boolean; - enabled?: boolean; - everyN?: number; - txFrames?: number; - txBytes?: number; + /** Raw flag byte, kept for debugging/console display. */ + flags?: number; + /** Modem state machine code, and its decoded name when the backend supplies one. */ + state?: number; + stateName?: string; + /** Last fault code latched by the modem driver; 0 = none. */ + lastFault?: number; + /** Frames waiting in the FMC's transmit queue. */ + queueDepth?: number; + /** Frames handed to the modem since boot. */ + txAccepted?: number; + /** Frames dropped because the queue was full — the number that matters on the pad. */ + txDropped?: number; + + // Decoded flag bits (see RADIO_STATUS_FLAG_* in gs/protocol.py). + powerRequested?: boolean; // operator asked for the rail + powered?: boolean; // rail is actually on + ready?: boolean; // modem answered and is usable + configValid?: boolean; // a valid config record is loaded + readbackMatches?: boolean; // modem read-back agrees with the stored record + txActive?: boolean; // a transmission is in flight right now + clockCalibrated?: boolean; // TCXO/clock calibration completed + fault?: boolean; // latched fault — surface prominently } export interface FmcTemps { @@ -191,18 +214,99 @@ export interface RabStatus { flags?: number; } -/** FMC auxiliary status (`fas_aux`): RunCam power + GNSS time-pulse. */ +/** + * FMC auxiliary status (`fas_aux`): camera, RF amplifier, GNSS time-pulse. + * + * `runcamPowered` / `rfPaOn` are the EPB's own ACTUATOR_STATE echo as the FMC + * saw it — *not* the FMC's intent. A board that never answered reads `false` + * rather than a guess, so each is paired with its `*Requested` sibling in the + * UI: "asked for on, reading off" is its own distinct, alarming state. + */ export interface FmcAuxStatus { - runcamPowered: boolean; - ppsPresent: boolean; // GNSS PPS rising edge seen within ~2 s - ppsCount?: number; // rising-edge counter (wraps) - ppsAgeMs?: number; // ms since last edge (undefined if never) + /** Raw aux flag byte. */ + flags?: number; + + runcamPowered: boolean; // EPB echo — the camera rail is on + runcamPresent?: boolean; // camera answered RCDP GET_DEVICE_INFO + runcamRecording?: boolean; // a START_RECORDING is in effect + runcamAutostop?: boolean; // the auto-stop timer is armed + /** Seconds remaining before auto-stop; `null` when the field is not applicable. */ + runcamRecordS?: number | null; + + rfPaRequested?: boolean; // operator master enable is set + rfPaOn?: boolean; // EPB echo — the amplifier rail is on + rfPaCycling?: boolean; // the duty-cycle scheduler is running + rfPaInhibited?: boolean; // held off because the modem is not ready + + ppsPresent: boolean; // GNSS PPS rising edge seen within ~2 s + ppsCount?: number; // rising-edge counter (wraps) + ppsAgeMs?: number; // ms since last edge (undefined if never) +} + +// --------------------------------------------------------------------------- +// Vehicle-radio configuration (`fas_radio_cfg`, FMC_RADIO_CONFIG 0x3D) +// --------------------------------------------------------------------------- + +/** One EPB ADC channel selected for the 200 Hz RF pressure stream. */ +export interface RadioPressureChannel { + boardId: number; // EPB 0..7 + channel: number; // 0..1 +} + +/** LoRa link parameters (the vehicle has exactly one mode). */ +export interface RadioLoraProfile { + frequencyHz: number; + bandwidthHz: number; + powerDbm: number; + spreadingFactor: number; + codingRate: string; // "4/5" … "4/8" + preambleSymbols: number; +} + +/** RF amplifier duty cycle + EPB peripheral bindings. */ +export interface RadioRfChain { + paBoardId?: number | null; // null = unfitted + paChannel?: number | null; + runcamBoardId?: number | null; + runcamChannel?: number | null; + cyclePeriodMs: number; // 1000..60000 + warmupMs: number; // 0..2000 + tailMs: number; // 0..2000 + maxOnMs: number; // 1..30000 + minOffMs: number; // 0..60000 + runcamAutostopS: number; // 0..43200 + dutyCycle: boolean; + runcamAutostop: boolean; + bootSound: boolean; + recOnPower: boolean; +} + +/** The FMC-authoritative vehicle-radio config (`fas_radio_cfg`). */ +export interface RadioConfig { + callsign: string; + networkId: number; + vehicleNodeId: number; + allocationLowHz: number; + allocationHighHz: number; + lora: RadioLoraProfile; + pressureChannels: RadioPressureChannel[]; + rfChain: RadioRfChain; } -/** FMC RF telemetry rate/power mode (`fas_rf`). Persisted on the FMC; echoed ~1 Hz. */ -export interface FmcRfStatus { - rateMode: number; // 0 = low, 1 = normal, 2 = high - rateName?: string; // "low" | "normal" | "high" +/** Transaction envelope around a config read-back. */ +export interface RadioConfigState { + config?: RadioConfig; + status?: number; + statusName?: string; // request|accepted|applied|invalid|store_error|link_error|busy + transactionId?: number; + generation?: number; + persisted?: boolean; + linkReady?: boolean; + readbackMatches?: boolean; + placeholderId?: boolean; // callsign is still XXXXXX + validationError?: number; + /** Set when the payload could not be decoded at all. */ + decodeError?: string; } /** Soundboard status (`fas_sound.status`). */ @@ -321,8 +425,8 @@ export interface FlightTelemetry { rab?: Record; /** FMC auxiliary status (`fas_aux`). */ aux?: FmcAuxStatus; - /** FMC RF telemetry rate/power mode (`fas_rf`). */ - rf?: FmcRfStatus; + /** FMC-authoritative vehicle-radio configuration (`fas_radio_cfg`). */ + radioConfig?: RadioConfigState; /** Soundboard status + clip directory (`fas_sound`). */ sound?: SoundboardStatus; /** Flight state machine (`fas_fsm`). */ diff --git a/src/lib/tools/manifest.ts b/src/lib/tools/manifest.ts index a08fd7b..a676aaf 100644 --- a/src/lib/tools/manifest.ts +++ b/src/lib/tools/manifest.ts @@ -19,3 +19,4 @@ import "@/app/tools/servo/tool"; import "@/app/tools/relay/tool"; import "@/app/tools/soundboard/tool"; import "@/app/tools/analysis/tool"; +import "@/app/tools/radio/tool"; From 09ef1ca8029ea8f9cdbc278532301f6d09c9063c Mon Sep 17 00:00:00 2001 From: Nova-Server-UTAT Date: Tue, 18 Aug 2026 23:41:27 -0400 Subject: [PATCH 2/4] gs: show the RunCam rail instead of camera state the FMC cannot see aux_flags bits 1 and 2 are permanently reserved and never emitted, so the present/recording rows always rendered a hard-coded "no" next to real telemetry. Show the rail as the EPB echoed it and the FMC's own countdown. A blank auto-stop now omits the field, so the FMC applies its persisted default rather than being told "never stop"; 0 still means no timer. --- src/components/flight/FmcAuxCard.tsx | 36 +++++++++++++++------------- src/lib/flight/index.ts | 2 -- src/lib/flight/types.ts | 6 +++-- 3 files changed, 23 insertions(+), 21 deletions(-) diff --git a/src/components/flight/FmcAuxCard.tsx b/src/components/flight/FmcAuxCard.tsx index fe005e2..446db43 100644 --- a/src/components/flight/FmcAuxCard.tsx +++ b/src/components/flight/FmcAuxCard.tsx @@ -198,11 +198,13 @@ function PowerToggle({ function RuncamRecordControl({ aux, node }: { aux: FmcAuxStatus | undefined; node: string }) { const clientId = useNovaStore(sel.clientId); const canCommand = useNovaStore(sel.canCommand); - const [autostop, setAutostop] = useState("0"); + // Blank = send no autostop_s, which the FMC reads as "use my persisted + // runcam_autostop_s". 0 is NOT that: 0 is an explicit "no timer at all", so + // it must not be the default an operator gets without asking for it. + const [autostop, setAutostop] = useState(""); const [busy, setBusy] = useState(false); const [error, setError] = useState(null); - const recording = aux?.runcamRecording; const disabled = !clientId || !canCommand || busy; async function send(enable: boolean) { @@ -211,7 +213,7 @@ function RuncamRecordControl({ aux, node }: { aux: FmcAuxStatus | undefined; nod setError(null); try { const body: Parameters[0] = { node, enable }; - if (enable) { + if (enable && autostop.trim() !== "") { // The firmware clamps too, but a silent server-side clamp is a bad // surprise — do it here so the operator sees the value that was sent. const parsed = Number(autostop); @@ -220,6 +222,8 @@ function RuncamRecordControl({ aux, node }: { aux: FmcAuxStatus | undefined; nod if (clamped !== seconds) setAutostop(String(clamped)); body.autostop_s = clamped; } + // Left blank the field is omitted entirely, so the FMC applies its own + // persisted default instead of being told "never stop". await withBackendSupport("RunCam recording", () => sendFasRuncamRecord(body, clientId)); } catch (e) { setError(e instanceof Error ? e.message : "Error"); @@ -236,20 +240,18 @@ function RuncamRecordControl({ aux, node }: { aux: FmcAuxStatus | undefined; nod return ( - RunCam recording + RunCam rail + {/* Raising the 8V4 rail is what starts a recording and dropping it is what + ends one — there is no record command and no way to ask the camera + anything. So this shows the rail as the EPB echoed it and the FMC's own + timer, and claims nothing about the camera itself. */} - recording - - {onOff(recording, "yes", "no")} - - - - camera - - {onOff(aux?.runcamPresent, "present", "no answer")} + rail + + {onOff(aux?.runcamPowered, "on", "off")} @@ -273,13 +275,14 @@ function RuncamRecordControl({ aux, node }: { aux: FmcAuxStatus | undefined; nod fontFamily="mono" flex="1" minW="70px" - aria-label="RunCam auto-stop seconds" + placeholder="FMC default" + aria-label="RunCam auto-stop seconds (blank = FMC persisted default, 0 = no timer)" /> @@ -323,7 +326,6 @@ export function FmcAuxCard({ fmc, aux, node = "FMC_0" }: FmcAuxCardProps) { { label: "RunCam", value: onOff(aux?.runcamPowered), - status: aux?.runcamPowered === false && aux?.runcamRecording ? "warn" : undefined, }, { label: "RF amp", diff --git a/src/lib/flight/index.ts b/src/lib/flight/index.ts index 255cb60..23ad830 100644 --- a/src/lib/flight/index.ts +++ b/src/lib/flight/index.ts @@ -484,8 +484,6 @@ function parseAux(raw: unknown): FmcAuxStatus | undefined { ppsPresent: bool(o["pps_present"]) ?? false, }; put(aux, "flags", num(o["aux_flags"])); - put(aux, "runcamPresent", bool(o["runcam_present"])); - put(aux, "runcamRecording", bool(o["runcam_recording"])); put(aux, "runcamAutostop", bool(o["runcam_autostop"])); // null means "not applicable" (0xFFFF on the wire), which is not 0 seconds — // preserve it so the UI renders a dash rather than "stopping now". diff --git a/src/lib/flight/types.ts b/src/lib/flight/types.ts index fb791b7..d297a27 100644 --- a/src/lib/flight/types.ts +++ b/src/lib/flight/types.ts @@ -227,8 +227,10 @@ export interface FmcAuxStatus { flags?: number; runcamPowered: boolean; // EPB echo — the camera rail is on - runcamPresent?: boolean; // camera answered RCDP GET_DEVICE_INFO - runcamRecording?: boolean; // a START_RECORDING is in effect + // No runcamPresent / runcamRecording: nothing in this system can observe the + // camera. The FMC has only its supply, no signal path, so aux_flags bits 1 + // and 2 are permanently reserved and never emitted. A field here would only + // ever render a confident "no", which is worse than showing nothing. runcamAutostop?: boolean; // the auto-stop timer is armed /** Seconds remaining before auto-stop; `null` when the field is not applicable. */ runcamRecordS?: number | null; From 09e64d79d1473b628898a7b10caee78d435ef364 Mon Sep 17 00:00:00 2001 From: Nova-Server-UTAT Date: Wed, 19 Aug 2026 03:10:09 -0400 Subject: [PATCH 3/4] gs: ignore generated files (.env.production, next-env.d.ts) ops/Nova.ps1 regenerates .env.production from ops/nova.config.ps1 for whichever environment is being launched, and next build emits next-env.d.ts; both are output, not source, and kept every checkout permanently dirty. --- .gitignore | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/.gitignore b/.gitignore index 5e10cc7..21c59e6 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,4 @@ -# dependencies +# dependencies /node_modules /.pnp .pnp.js @@ -31,4 +31,7 @@ yarn-error.log* # typescript *.tsbuildinfo -next-env.d.ts \ No newline at end of file + +# generated by ops/Nova.ps1 and next build +.env.production +next-env.d.ts From b80a658e49bac7b751565fc65322f51e3647f180 Mon Sep 17 00:00:00 2001 From: Awesome-Ace22 Date: Wed, 30 Sep 2026 00:03:01 -0400 Subject: [PATCH 4/4] chore!: add CODEOWNERS, pull request template, and contributing guidelines --- .github/CODEOWNERS | 8 +++ .github/pull_request_template.md | 37 +++++++++++++ .github/workflows/pr-checks.yml | 89 ++++++++++++++++++++++++++++++++ CONTRIBUTING.md | 41 +++++++++++++++ 4 files changed, 175 insertions(+) create mode 100644 .github/CODEOWNERS create mode 100644 .github/pull_request_template.md create mode 100644 .github/workflows/pr-checks.yml create mode 100644 CONTRIBUTING.md diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 0000000..1802871 --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1,8 @@ +# Code owners must approve PRs that touch these paths (branch protection: +# "require review from code owners"). Everything else needs any one approval. +# Listed paths are the ones that can move hardware or run the station - see +# CONTRIBUTING.md in UTATRocketry/Nova-Collected. +# +# Replace the owner with a team once one exists, e.g. @UTATRocketry/nova-leads + +/.github/ @Awesome-Ace22 diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000..12b9223 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,37 @@ + + +## What and why + + + +## How was this tested? + + + +- [ ] 1 Unit / Automated Test - CI +- [ ] 2 Simulator Test - testing procedure with the hardware simulators +- [ ] 3 Bench Test - testing procedure on the dev stack, with some real hardware +- [ ] 4 Integration Test - full engine test procedure, real hardware, no propellants + + + +## Hardware impact + +- [ ] None - UI, docs or tooling only +- [ ] Changes how a sensor is read, converted or shown +- [ ] Changes how an actuator is commanded, or safety rules, interlocks, timeouts or sequencing → add the `hardware-risk` label; a lead must approve +- [ ] Changes a message format, MQTT topic or FAS protocol that another repo depends on → title needs `!`, link the matching PRs: + + + +## Checklist + +- [ ] Targets `dev` (or `main` for a release/hotfix, or with a reason given above) +- [ ] Branch is up to date ("Update branch" button) +- [ ] No station config/calibrations, data files, secrets, `.env` files or build output committed +- [ ] README/docs updated if behaviour changed diff --git a/.github/workflows/pr-checks.yml b/.github/workflows/pr-checks.yml new file mode 100644 index 0000000..c5cb0fd --- /dev/null +++ b/.github/workflows/pr-checks.yml @@ -0,0 +1,89 @@ +name: PR checks + +on: + pull_request: + # `edited` so fixing the PR title re-runs the checks. + types: [opened, edited, reopened, synchronize] + +permissions: + contents: read + +concurrency: + group: pr-checks-${{ github.event.pull_request.number }} + cancel-in-progress: true + +jobs: + # Branch names, PR direction and PR title. See CONTRIBUTING.md in + # UTATRocketry/Nova-Collected: work goes into dev; main takes releases and + # hotfixes (anything else into main is allowed but flagged). + rules: + name: rules + runs-on: ubuntu-latest + steps: + - name: Branch name and target + env: + HEAD_REF: ${{ github.head_ref }} + BASE_REF: ${{ github.base_ref }} + run: | + words='[a-z0-9]+(-[a-z0-9]+)*' + work="^(feature|bugfix|chore)/${words}$" + hotfix="^hotfix/${words}$" + release='^release/v[0-9]{4}\.[0-9]{2}\.[0-9]{2}(\.[0-9]+)?$' + github_made='^(revert-[0-9]+-|dependabot/)' + fail() { echo "::error::$1"; exit 1; } + + if [[ ! ( "$HEAD_REF" =~ $work || "$HEAD_REF" =~ $hotfix || "$HEAD_REF" =~ $release \ + || "$HEAD_REF" == "main" || "$HEAD_REF" =~ $github_made ) ]]; then + fail "Branch '$HEAD_REF' must be named feature/..., bugfix/..., chore/... (lower case, words joined by single hyphens), e.g. bugfix/fas-port-reconnect. Rename it on GitHub (Branches page, pencil icon) - that keeps this PR." + fi + + case "$BASE_REF" in + dev) + if [[ "$HEAD_REF" =~ $release ]]; then + fail "Release branches go into main, not dev." + fi ;; + main) + if [[ ! ( "$HEAD_REF" =~ $release || "$HEAD_REF" =~ $hotfix ) ]]; then + echo "::warning::'$HEAD_REF' -> main is not the normal path (main takes release/* and hotfix/* branches; work goes into dev). If this is deliberate, say why in the PR description." + fi ;; + hotfix/*) ;; + *) + fail "PRs go into dev (or main for releases and hotfixes), not '$BASE_REF'. Change the base branch at the top of the PR." ;; + esac + echo "Branch '$HEAD_REF' -> '$BASE_REF' is fine." + + - name: PR title + env: + TITLE: ${{ github.event.pull_request.title }} + run: | + conventional='^(feat|fix|refactor|docs|test|chore)(\([a-z0-9._/-]+\))?!?: .{3,}$' + github_revert='^Revert "' + if [[ ! "$TITLE" =~ $conventional && ! "$TITLE" =~ $github_revert ]]; then + echo "::error::PR title '$TITLE' must look like 'fix(fas): reopen the COM port after unplug' - type(optional scope): what changed. Edit the title; the checks re-run by themselves." + exit 1 + fi + if (( ${#TITLE} > 72 )); then + echo "::error::PR title is ${#TITLE} characters; keep it to 72 or fewer." + exit 1 + fi + echo "Title is fine." + + build: + name: build + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + + - uses: actions/setup-node@v5 + with: + node-version: "20" + cache: npm + + - name: Install + run: npm ci + + - name: Type check + run: npm run typecheck + + - name: Build + run: npm run build diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..b0eeace --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,41 @@ +# Contributing to novaOPS-front + +**The rules for all Nova repos are in +[Nova-Collected/CONTRIBUTING.md](https://github.com/UTATRocketry/Nova-Collected/blob/main/CONTRIBUTING.md). +Read that first.** In short: + +- `dev` collects finished work for hardware testing; `main` is what prod runs. +- Branch from `dev` as `feature/...`, `bugfix/...` or `chore/...`, e.g. `bugfix/fas-port-reconnect`. +- Open a PR **into `dev`**, titled `type(scope): what changed`, and fill in the template. +- CI green + 1 approval (a lead's if it can move hardware), then **Squash and merge**. +- Leads bring tested releases into `main`; you never merge into `main` yourself. + +This file only covers what is specific to this repo. + +## Before you open a PR + +```bash +npm ci +npm run typecheck +npm run build +``` + +CI runs the same checks on every PR. + +## Simulator Test (level 2) + +Run the backend with its simulators (see novaOps-back `CONTRIBUTING.md`), then +`npm run dev`, and go through the testing procedure +(`docs/testing-procedure.md` in Nova-Collected). The UI talks to the backend +URL in `.env.local`; copy `.env.example` to start. + +## Scopes for PR titles + +`ui`, `plots`, `diagram`, `commands`, `config`, `api`, `ws` + +## Things to know + +- Controls that command actuators are hardware changes even though they're + "just UI": tick the actuator box in the PR template. +- Never commit `.env.local` or `.env.production` (`Nova.ps1` generates the + latter on the ground station).