diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..b59c47e --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,22 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + +jobs: + ts-bindings: + name: TypeScript bindings up to date + runs-on: ubuntu-latest + defaults: + run: + working-directory: software/pc + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + - uses: Swatinem/rust-cache@v2 + with: + workspaces: software/pc + - uses: taiki-e/install-action@just + - run: just check-types diff --git a/software/pc/.cargo/config.toml b/software/pc/.cargo/config.toml new file mode 100644 index 0000000..03a01c2 --- /dev/null +++ b/software/pc/.cargo/config.toml @@ -0,0 +1,3 @@ +[env] +# Output directory for ts-rs generated TypeScript bindings +TS_RS_EXPORT_DIR = { value = "frontend/src/lib", relative = true } diff --git a/software/pc/Cargo.lock b/software/pc/Cargo.lock index fd79393..9c2072c 100644 --- a/software/pc/Cargo.lock +++ b/software/pc/Cargo.lock @@ -157,6 +157,7 @@ dependencies = [ "tower-http", "tracing", "tracing-subscriber", + "ts-rs", ] [[package]] @@ -1176,6 +1177,15 @@ version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +[[package]] +name = "termcolor" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755" +dependencies = [ + "winapi-util", +] + [[package]] name = "thiserror" version = "2.0.18" @@ -1388,6 +1398,28 @@ dependencies = [ "tracing-log", ] +[[package]] +name = "ts-rs" +version = "12.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "756050066659291d47a554a9f558125db17428b073c5ffce1daf5dcb0f7231d8" +dependencies = [ + "thiserror", + "ts-rs-macros", +] + +[[package]] +name = "ts-rs-macros" +version = "12.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38d90eea51bc7988ef9e674bf80a85ba6804739e535e9cab48e4bb34a8b652aa" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "termcolor", +] + [[package]] name = "tungstenite" version = "0.28.0" diff --git a/software/pc/Cargo.toml b/software/pc/Cargo.toml index a38b6dc..7d86ba5 100644 --- a/software/pc/Cargo.toml +++ b/software/pc/Cargo.toml @@ -48,5 +48,8 @@ heapless = "0.9.2" # SHA-256 for OTA firmware verification sha2 = "0.10" +# Generate TypeScript bindings for WebSocket types (see `just gen-types`) +ts-rs = "12" + [dev-dependencies] tokio = { version = "1", features = ["full", "test-util"] } diff --git a/software/pc/frontend/.prettierignore b/software/pc/frontend/.prettierignore index b1cefd3..c615336 100644 --- a/software/pc/frontend/.prettierignore +++ b/software/pc/frontend/.prettierignore @@ -10,3 +10,6 @@ static/** pnpm-lock.yaml package-lock.json yarn.lock + +# Generated by ts-rs (just gen-types) +src/lib/bindings.ts diff --git a/software/pc/frontend/eslint.config.js b/software/pc/frontend/eslint.config.js index 9aa25d1..7399f66 100644 --- a/software/pc/frontend/eslint.config.js +++ b/software/pc/frontend/eslint.config.js @@ -43,6 +43,6 @@ export default ts.config( } }, { - ignores: ['.svelte-kit/**', 'build/**', 'node_modules/**'] + ignores: ['.svelte-kit/**', 'build/**', 'node_modules/**', 'src/lib/bindings.ts'] } ); diff --git a/software/pc/frontend/pnpm-workspace.yaml b/software/pc/frontend/pnpm-workspace.yaml new file mode 100644 index 0000000..5ed0b5a --- /dev/null +++ b/software/pc/frontend/pnpm-workspace.yaml @@ -0,0 +1,2 @@ +allowBuilds: + esbuild: true diff --git a/software/pc/frontend/src/app.css b/software/pc/frontend/src/app.css new file mode 100644 index 0000000..8184eda --- /dev/null +++ b/software/pc/frontend/src/app.css @@ -0,0 +1,68 @@ +/* Theme tokens. Dark is the default; light follows the OS preference. */ +:root { + color-scheme: dark; + + --bg: #111; + --surface: #1a1a1a; + --surface-2: #222; + --surface-hover: #333; + --border: #444; + --border-strong: #555; + --border-focus: #777; + + --text: #eee; + --text-muted: #aaa; + --text-dim: #888; + + --plot-grid: #2a2a2a; + --plot-axis: #333; + + --danger: #e53935; + --danger-banner: #d32f2f; + --danger-hover: #2a1010; + --warning: #e65100; + --success: #4caf50; + --jog: #ffa726; + + --trace-0: #fff; + --trace-1: #4fc3f7; + --trace-2: #ff8a65; + --trace-3: #81c784; + --trace-4: #ce93d8; + --trace-5: #fff176; +} + +@media (prefers-color-scheme: light) { + :root { + color-scheme: light; + + --bg: #f4f4f5; + --surface: #fff; + --surface-2: #fafafa; + --surface-hover: #ebebeb; + --border: #d4d4d8; + --border-strong: #bdbdc2; + --border-focus: #8e8e93; + + --text: #1c1c1e; + --text-muted: #52525b; + --text-dim: #8e8e93; + + --plot-grid: #e4e4e7; + --plot-axis: #c8c8cc; + + --danger: #d32f2f; + --danger-banner: #d32f2f; + --danger-hover: #fdecea; + --warning: #e65100; + --success: #2e7d32; + --jog: #ef6c00; + + --trace-0: #212121; + --trace-1: #0288d1; + --trace-2: #e64a19; + --trace-3: #2e7d32; + --trace-4: #7b1fa2; + --trace-5: #f9a825; + } +} diff --git a/software/pc/frontend/src/lib/bindings.ts b/software/pc/frontend/src/lib/bindings.ts new file mode 100644 index 0000000..99ee7fe --- /dev/null +++ b/software/pc/frontend/src/lib/bindings.ts @@ -0,0 +1,92 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * A single measurement data point. + */ +export type DataPoint = { +/** + * Turntable angle in degrees. + */ +angle_deg: number, +/** + * Averaged RSSI in dBm (negative; closer to 0 = stronger). + */ +rssi_dbm: number, +/** + * Number of raw RSSI samples averaged into this reading. + */ +sample_count: number, }; + +/** + * Serial port information returned by the list_ports endpoint. + */ +export type PortInfo = { +/** + * OS device path (e.g. `/dev/ttyUSB0` or `COM3`). + */ +name: string, +/** + * Human-readable description from the USB descriptor. + */ +description: string, }; + +/** + * Sweep configuration sent from UI to backend. + */ +export type SweepConfig = { start_deg: number, stop_deg: number, step_deg: number, samples_per_angle: number, }; + +/** + * Current system status broadcast to all WebSocket clients on state changes. + */ +export type SystemStatus = { +/** + * Whether an antenna pattern sweep is in progress. + */ +sweeping: boolean, +/** + * Whether the TX ESP32 is paired via ESP-NOW. + */ +tx_connected: boolean, +/** + * Whether the RX ESP32 is paired via ESP-NOW. + */ +rx_connected: boolean, +/** + * Whether the turntable ESP32 is paired via ESP-NOW. + */ +turntable_connected: boolean, +/** + * Whether the PC is connected to the Bridge over serial/TCP. + */ +serial_connected: boolean, +/** + * Number of data points stored on the backend. + */ +data_points: number, +/** + * Active RF measurement backend name (e.g. "espnow", "dect-nr+"). + */ +backend_name: string, +/** + * Carrier frequency in MHz, if applicable. + */ +backend_freq_mhz: number | null, +/** + * Motor-to-turntable gear ratio (motor_deg = turntable_deg × ratio). + */ +transmission_ratio: number, }; + +/** + * TX configuration. + */ +export type TxConfig = { channel: number, tx_power_dbm: number, packet_rate_hz: number, }; + +/** + * Commands received from the frontend over WebSocket. + */ +export type WsCommand = { "type": "StartSweep" } & SweepConfig | { "type": "ConfigureTx" } & TxConfig | { "type": "Stop" } | { "type": "QueryStatus" } | { "type": "ListPorts" } | { "type": "Connect", port: string, } | { "type": "Disconnect" } | { "type": "ExportCsv" } | { "type": "ReturnHome" } | { "type": "Jog", delta_deg: number, } | { "type": "OtaUpload", target: string, firmware_path: string, } | { "type": "SetTransmissionRatio", ratio: number, }; + +/** + * Events pushed to the frontend over WebSocket. + */ +export type WsEvent = { "type": "DataPoint" } & DataPoint | { "type": "SweepComplete" } | { "type": "HomeComplete" } | { "type": "JogComplete", angle_deg: number, } | { "type": "Status" } & SystemStatus | { "type": "OtaProgress", chunks_sent: number, total_chunks: number, } | { "type": "OtaFinished" } | { "type": "Error", message: string, } | { "type": "Log", message: string, }; diff --git a/software/pc/frontend/src/lib/ws.ts b/software/pc/frontend/src/lib/ws.ts index b219845..99f7e9a 100644 --- a/software/pc/frontend/src/lib/ws.ts +++ b/software/pc/frontend/src/lib/ws.ts @@ -1,105 +1,14 @@ /** WebSocket client for communicating with the Axum backend. */ -/** A single RSSI measurement at a given turntable angle. */ -export type DataPoint = { - /** Turntable angle in degrees. */ - angle_deg: number; - /** Averaged RSSI reading in dBm (negative values; closer to 0 = stronger). */ - rssi_dbm: number; - /** Number of raw RSSI samples averaged into this reading. */ - sample_count: number; -}; - -/** Snapshot of the system's connection and sweep state, sent periodically by the backend. */ -export type SystemStatus = { - /** Whether a sweep is currently in progress. */ - sweeping: boolean; - /** Whether the TX (transmitter) ESP32 is paired via ESP-NOW. */ - tx_connected: boolean; - /** Whether the RX (receiver) ESP32 is paired via ESP-NOW. */ - rx_connected: boolean; - /** Whether the turntable ESP32 is paired via ESP-NOW. */ - turntable_connected: boolean; - /** Whether the PC is connected to the Bridge over serial. */ - serial_connected: boolean; - /** Total data points collected in the current session. */ - data_points: number; - /** Active RF measurement backend name (e.g. "espnow", "dect-nr+"). */ - backend_name: string; - /** Carrier frequency in MHz, if applicable. */ - backend_freq_mhz: number | null; - /** Motor-to-turntable gear ratio (motor_deg = turntable_deg × ratio). */ - transmission_ratio: number; -}; - -/** A serial port available on the host machine. */ -export type PortInfo = { - /** OS device path (e.g. `/dev/ttyUSB0` or `COM3`). */ - name: string; - /** Human-readable description from the USB descriptor. */ - description: string; -}; - -/** - * Server-to-client WebSocket events. - * - * The backend pushes these as JSON over the `/ws` endpoint. - */ -export type WsEvent = - | { - type: 'DataPoint'; - angle_deg: number; - rssi_dbm: number; - sample_count: number; - } - | { type: 'SweepComplete' } - | { type: 'HomeComplete' } - | { type: 'JogComplete'; angle_deg: number } - | { - type: 'Status'; - sweeping: boolean; - tx_connected: boolean; - rx_connected: boolean; - turntable_connected: boolean; - serial_connected: boolean; - data_points: number; - backend_name: string; - backend_freq_mhz: number | null; - transmission_ratio: number; - } - | { type: 'OtaProgress'; chunks_sent: number; total_chunks: number } - | { type: 'OtaFinished' } - | { type: 'Error'; message: string } - | { type: 'Log'; message: string }; - -/** - * Client-to-server WebSocket commands. - * - * Sent as JSON to the `/ws` endpoint to control the measurement system. - */ -export type WsCommand = - | { - type: 'StartSweep'; - start_deg: number; - stop_deg: number; - step_deg: number; - samples_per_angle: number; - } - | { - type: 'ConfigureTx'; - channel: number; - tx_power_dbm: number; - packet_rate_hz: number; - } - | { type: 'Stop' } - | { type: 'QueryStatus' } - | { type: 'ListPorts' } - | { type: 'Connect'; port: string } - | { type: 'Disconnect' } - | { type: 'ExportCsv' } - | { type: 'ReturnHome' } - | { type: 'Jog'; delta_deg: number } - | { type: 'SetTransmissionRatio'; ratio: number }; +// Types are generated from the Rust definitions in src/lib.rs (`just gen-types`). +import type { WsCommand, WsEvent } from './bindings'; +export type { + DataPoint, + PortInfo, + SystemStatus, + WsCommand, + WsEvent +} from './bindings'; export function createWsConnection( onEvent: (event: WsEvent) => void, diff --git a/software/pc/frontend/src/routes/+layout.svelte b/software/pc/frontend/src/routes/+layout.svelte index 02a579e..69be405 100644 --- a/software/pc/frontend/src/routes/+layout.svelte +++ b/software/pc/frontend/src/routes/+layout.svelte @@ -1,4 +1,5 @@ @@ -423,7 +463,9 @@

Beambench

{#if !wsConnected} -
Server connection lost — reconnecting…
+
+ Server connection lost — reconnecting… +
{/if} {#if errorMessage} @@ -481,7 +523,8 @@ {#if status.backend_name}

- Backend: {status.backend_name}{#if status.backend_freq_mhz} + Backend: {status.backend_name}{#if status.backend_freq_mhz}  ({status.backend_freq_mhz} MHz){/if}

{/if} @@ -491,31 +534,49 @@

Turntable

Position: {turntableAngle.toFixed(1)}° - {#if jogging} (moving...){/if} + {#if jogging} + (moving...){/if}

@@ -523,7 +584,11 @@ @@ -541,15 +606,20 @@

Active: 1:{status.transmission_ratio} - — e.g. 10° turntable = {(10 * status.transmission_ratio).toFixed(1)}° motor + — e.g. 10° turntable = {( + 10 * status.transmission_ratio + ).toFixed(1)}° motor

@@ -589,7 +659,10 @@
-
startResize(e, 'x', () => sidebarWidth, (v) => { sidebarWidth = v; }, 200, 500)} - >
+ onpointerdown={(e) => + startResize( + e, + 'x', + () => sidebarWidth, + (v) => { + sidebarWidth = v; + }, + 200, + 500 + )}> + -
+
startResize(e, 'y', () => logHeight, (v) => { logHeight = v; }, 60, 600, true)} - >
+ onpointerdown={(e) => + startResize( + e, + 'y', + () => logHeight, + (v) => { + logHeight = v; + }, + 60, + 600, + true + )}> +

Log

@@ -702,8 +800,8 @@ system-ui, -apple-system, sans-serif; - background: #111; - color: #eee; + background: var(--bg); + color: var(--text); } main { @@ -714,20 +812,20 @@ margin: 0 0 1.25rem; font-size: 1.4rem; font-weight: 600; - color: #fff; + color: var(--text); } h2 { margin: 0 0 0.75rem; font-size: 0.75rem; font-weight: 600; - color: #aaa; + color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.08em; } .ws-disconnected { - background: #e65100; + background: var(--warning); color: white; padding: 0.5rem 1rem; border-radius: 4px; @@ -737,7 +835,7 @@ } .error { - background: #d32f2f; + background: var(--danger-banner); color: white; padding: 0.5rem 1rem; border-radius: 4px; @@ -770,7 +868,7 @@ .resize-handle-h:hover, .resize-handle-h:active { - background: #444; + background: var(--border); border-radius: 3px; } @@ -785,8 +883,8 @@ } section { - background: #1a1a1a; - border: 1.5px solid #3a3a3a; + background: var(--surface); + border: 1.5px solid var(--border); border-radius: 6px; padding: 1rem; } @@ -798,7 +896,7 @@ .field label { display: block; font-size: 0.8rem; - color: #aaa; + color: var(--text-muted); margin-bottom: 0.25rem; } @@ -806,17 +904,17 @@ select { width: 100%; padding: 0.45rem 0.5rem; - border: 1.5px solid #444; + border: 1.5px solid var(--border); border-radius: 4px; - background: #222; - color: #eee; + background: var(--surface-2); + color: var(--text); font-size: 0.85rem; box-sizing: border-box; } input:focus { outline: none; - border-color: #666; + border-color: var(--border-focus); } .connect-row { @@ -831,18 +929,18 @@ button { padding: 0.5rem 1rem; - border: 1.5px solid #555; + border: 1.5px solid var(--border-strong); border-radius: 4px; - background: #222; - color: #eee; + background: var(--surface-2); + color: var(--text); cursor: pointer; font-size: 0.85rem; width: 100%; } button:hover:not(:disabled) { - background: #333; - border-color: #777; + background: var(--surface-hover); + border-color: var(--border-focus); } button:disabled { @@ -851,18 +949,18 @@ } button.secondary { - color: #bbb; - border-color: #444; + color: var(--text-muted); + border-color: var(--border); } button.danger { - background: #222; - color: #e53935; - border-color: #e53935; + background: var(--surface-2); + color: var(--danger); + border-color: var(--danger); } button.danger:hover { - background: #2a1010; + background: var(--danger-hover); } .button-row { @@ -879,32 +977,32 @@ font-size: 0.7rem; padding: 0.2rem 0.5rem; border-radius: 3px; - background: #222; - border: 1.5px solid #444; - color: #777; + background: var(--surface-2); + border: 1.5px solid var(--border); + color: var(--text-dim); } .indicator.active { - background: #111; - border-color: #4caf50; - color: #4caf50; + background: var(--bg); + border-color: var(--success); + color: var(--success); } .backend-info { font-size: 0.75rem; - color: #888; + color: var(--text-dim); margin: 0.5rem 0 0; } .validation-error { font-size: 0.8rem; - color: #e53935; + color: var(--danger); margin: 0.25rem 0; } .info { font-size: 0.8rem; - color: #999; + color: var(--text-dim); margin: 0.5rem 0 0; } @@ -920,8 +1018,8 @@ min-height: 0; min-width: 0; overflow: hidden; - background: #1a1a1a; - border: 1.5px solid #3a3a3a; + background: var(--surface); + border: 1.5px solid var(--border); border-radius: 6px; } @@ -932,7 +1030,7 @@ .resize-handle-v:hover, .resize-handle-v:active { - background: #444; + background: var(--border); border-radius: 3px; } @@ -953,7 +1051,7 @@ font-family: monospace; font-size: 0.75rem; line-height: 1.5; - color: #bbb; + color: var(--text-muted); } .log-entry { @@ -962,7 +1060,7 @@ } .log-ts { - color: #777; + color: var(--text-dim); } .measurements-header { @@ -993,7 +1091,7 @@ align-items: center; gap: 0.4rem; font-size: 0.8rem; - color: #ddd; + color: var(--text); cursor: pointer; } @@ -1021,13 +1119,13 @@ font-size: 1rem; width: auto; line-height: 1; - color: #888; + color: var(--text-dim); border: none; background: transparent; } .icon-btn:hover { - color: #e53935; + color: var(--danger); background: transparent; } @@ -1059,8 +1157,7 @@ } .jog-indicator { - color: #ffa726; + color: var(--jog); font-style: italic; } - diff --git a/software/pc/justfile b/software/pc/justfile index 06b3cd0..fc7fffa 100644 --- a/software/pc/justfile +++ b/software/pc/justfile @@ -37,6 +37,14 @@ check: test: cargo test --lib +# Generate TypeScript bindings (frontend/src/lib/bindings.ts) from Rust types +gen-types: + cargo test --lib export_bindings + +# Fail if committed TypeScript bindings are out of date +check-types: gen-types + git diff --exit-code frontend/src/lib/bindings.ts + # Run integration tests (requires sim feature) test-integration: cargo test --test integration --features sim diff --git a/software/pc/src/lib.rs b/software/pc/src/lib.rs index 1e24b40..d940aee 100644 --- a/software/pc/src/lib.rs +++ b/software/pc/src/lib.rs @@ -10,17 +10,23 @@ pub mod sim; pub mod sweep; use serde::{Deserialize, Serialize}; +use ts_rs::TS; /// A single measurement data point. -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)] +#[ts(export, export_to = "bindings.ts")] pub struct DataPoint { + /// Turntable angle in degrees. pub angle_deg: f32, + /// Averaged RSSI in dBm (negative; closer to 0 = stronger). pub rssi_dbm: f32, + /// Number of raw RSSI samples averaged into this reading. pub sample_count: u16, } /// Sweep configuration sent from UI to backend. -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)] +#[ts(export, export_to = "bindings.ts")] pub struct SweepConfig { pub start_deg: f32, pub stop_deg: f32, @@ -29,7 +35,8 @@ pub struct SweepConfig { } /// TX configuration. -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)] +#[ts(export, export_to = "bindings.ts")] pub struct TxConfig { pub channel: u8, pub tx_power_dbm: i8, @@ -45,7 +52,8 @@ pub struct TxConfig { pub const DEFAULT_TRANSMISSION_RATIO: f32 = 3.0; /// Current system status broadcast to all WebSocket clients on state changes. -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)] +#[ts(export, export_to = "bindings.ts")] pub struct SystemStatus { /// Whether an antenna pattern sweep is in progress. pub sweeping: bool, @@ -68,14 +76,18 @@ pub struct SystemStatus { } /// Serial port information returned by the list_ports endpoint. -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)] +#[ts(export, export_to = "bindings.ts")] pub struct PortInfo { + /// OS device path (e.g. `/dev/ttyUSB0` or `COM3`). pub name: String, + /// Human-readable description from the USB descriptor. pub description: String, } /// Events pushed to the frontend over WebSocket. -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)] +#[ts(export, export_to = "bindings.ts")] #[serde(tag = "type")] pub enum WsEvent { /// A new measurement data point arrived during a sweep. @@ -99,7 +111,8 @@ pub enum WsEvent { } /// Commands received from the frontend over WebSocket. -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)] +#[ts(export, export_to = "bindings.ts")] #[serde(tag = "type")] pub enum WsCommand { /// Start a new antenna pattern sweep with the given configuration.