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/.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
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).
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..446db43 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,199 @@ 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);
+ // 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);
- async function toggle() {
- // Unknown power state → no defensible target to send; leave it to telemetry.
- if (!clientId || powered === undefined) return;
+ 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 && 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);
+ 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;
+ }
+ // 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) {
- 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 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. */}
+
+
+ rail
+
+ {onOff(aux?.runcamPowered, "on", "off")}
+
+
+
+ auto-stop in
+ {countdown}
+
+
+
+ {canCommand && (
+ <>
+
+ setAutostop(e.target.value)}
+ bg="bg.canvas"
+ borderColor="border.default"
+ fontFamily="mono"
+ flex="1"
+ minW="70px"
+ placeholder="FMC default"
+ aria-label="RunCam auto-stop seconds (blank = FMC persisted default, 0 = no timer)"
+ />
+
+
+
+
+ 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),
+ },
+ {
+ 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..23ad830 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,134 @@ 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, "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 +731,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..d297a27 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,101 @@ 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
+ // 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;
+
+ 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 +427,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";