diff --git a/agents/docs/commands-reference.md b/agents/docs/commands-reference.md index a7843986b..0ada14258 100644 --- a/agents/docs/commands-reference.md +++ b/agents/docs/commands-reference.md @@ -37,6 +37,7 @@ help text). | `fbuild clangd-config` | Emit `.clangd` / `.vscode/settings.json` for the default env. | `fbuild help clangd-config` | | `fbuild ide` / `fbuild ide select` | You want to open a project as an IDE workspace on stock Zed: installs declared deps, refreshes the compile DB, emits `.clangd` + `.zed/settings.json` + `.zed/tasks.json`, and — for probe-rs-supported boards only (RP2040/RP2350, a small ARM Cortex-M set) — `.zed/debug.json` plus a `probe-rs dap-server` task, then launches Zed. Unsupported targets (ESP32, AVR) get a one-line "not supported" note, not a failure. `ide select` interactively (or via `-e`) switches the persisted environment and regenerates. | `fbuild help ide`, FastLED/fbuild#1076 Phase 1 & Phase 3 milestone 1, `docs/reference/cli.md#fbuild-ide` | | `fbuild lib-select` | Drive the LDF-style library-selection resolver and print the selected library set. Use this when debugging "library not found" without a full build. | FastLED/fbuild#202 / #204 | +| `fbuild plotter [-p ]` | Open the daemon-served Serial Plotter web page (`GET /plotter`) in the default browser: a self-contained, dependency-free `` chart over the existing `/ws/serial-monitor` WebSocket, port list from `/api/devices/list`. `fbuild ide` wires this up as the `"fbuild: Serial Plotter"` Zed task. | `fbuild help plotter`, FastLED/fbuild#1076 Phase 2, `docs/reference/cli.md#fbuild-plotter` | ## Daemon & cache diff --git a/crates/fbuild-cli/src/cli/args.rs b/crates/fbuild-cli/src/cli/args.rs index 627cdc2e2..4adfb075f 100644 --- a/crates/fbuild-cli/src/cli/args.rs +++ b/crates/fbuild-cli/src/cli/args.rs @@ -524,6 +524,16 @@ pub enum Commands { #[command(subcommand)] action: Option, }, + /// Open the daemon-served Serial Plotter web page in the default + /// browser (FastLED/fbuild#1076 Phase 2): a live scrolling chart of + /// numeric values parsed from serial output, over the existing + /// `/ws/serial-monitor` WebSocket + Plotter { + /// Pin the page to this serial port (e.g. "COM3", "/dev/ttyUSB0") + /// instead of requiring a manual pick in the page's port selector. + #[arg(short = 'p', long)] + port: Option, + }, /// Build firmware and run it in an emulator for testing TestEmu { project_dir: Option, @@ -997,6 +1007,7 @@ pub const KNOWN_SUBCOMMANDS: &[&str] = &[ "iwyu", "clangd-config", "ide", + "plotter", "clang-query", "test-emu", "lib-select", diff --git a/crates/fbuild-cli/src/cli/dispatch.rs b/crates/fbuild-cli/src/cli/dispatch.rs index 0ca96327a..afb00487b 100644 --- a/crates/fbuild-cli/src/cli/dispatch.rs +++ b/crates/fbuild-cli/src/cli/dispatch.rs @@ -25,6 +25,7 @@ use super::ide::{run_ide, run_ide_select}; use super::lnk::run_lnk; use super::monitor_parse::parse_monitor_flags; use super::pio::{pio_build, pio_deploy, pio_monitor}; +use super::plotter::run_plotter; use super::port_scan::run_port; use super::purge::{run_purge, run_purge_gc}; use super::reset::run_reset; @@ -479,6 +480,7 @@ pub async fn async_main() { run_ide(project_dir, environment, no_launch).await } }, + Some(Commands::Plotter { port }) => run_plotter(port).await, Some(Commands::TestEmu { project_dir, environment, diff --git a/crates/fbuild-cli/src/cli/ide.rs b/crates/fbuild-cli/src/cli/ide.rs index f94c7e23b..e2c15622b 100644 --- a/crates/fbuild-cli/src/cli/ide.rs +++ b/crates/fbuild-cli/src/cli/ide.rs @@ -153,6 +153,12 @@ fn build_fbuild_tasks(env_name: &str, debug_chip: Option<&str>) -> Vec str_args(vec!["monitor", "-e", env_name]), ), task("Reset", "fbuild", str_args(vec!["reset", "-e", env_name])), + // Opens the daemon-served Serial Plotter page in the default + // browser (FastLED/fbuild#1076 Phase 2). No port pinned here -- + // the page's own port selector (populated from + // /api/devices/list) is how the user picks a port, so this task + // works regardless of which environment/port is active. + task("Serial Plotter", "fbuild", str_args(vec!["plotter"])), task( "Select environment", "fbuild", @@ -683,7 +689,7 @@ mod tests { #[test] fn build_fbuild_tasks_pins_environment_in_args() { let tasks = build_fbuild_tasks("esp32dev", None); - assert_eq!(tasks.len(), 7); + assert_eq!(tasks.len(), 8); for label in [ "fbuild: Build", "fbuild: Build (clean)", @@ -691,6 +697,7 @@ mod tests { "fbuild: Deploy + Monitor", "fbuild: Monitor", "fbuild: Reset", + "fbuild: Serial Plotter", "fbuild: Select environment", ] { assert!( @@ -705,6 +712,11 @@ mod tests { .find(|t| t.label == "fbuild: Select environment") .unwrap(); assert_eq!(select.args, vec!["ide", "select"]); + let plotter = tasks + .iter() + .find(|t| t.label == "fbuild: Serial Plotter") + .unwrap(); + assert_eq!(plotter.args, vec!["plotter"]); // No debug-chip resolved -> no debug-server task. assert!(!tasks.iter().any(|t| t.label.contains("Debug server"))); } @@ -712,7 +724,7 @@ mod tests { #[test] fn build_fbuild_tasks_adds_debug_server_task_when_chip_resolved() { let tasks = build_fbuild_tasks("rpipico", Some("RP2040")); - assert_eq!(tasks.len(), 8); + assert_eq!(tasks.len(), 9); let debug = tasks .iter() .find(|t| t.label == "fbuild: Debug server (probe-rs)") diff --git a/crates/fbuild-cli/src/cli/mod.rs b/crates/fbuild-cli/src/cli/mod.rs index 14f5f6afe..52767ad7d 100644 --- a/crates/fbuild-cli/src/cli/mod.rs +++ b/crates/fbuild-cli/src/cli/mod.rs @@ -28,6 +28,7 @@ pub mod ide_debug; pub mod lnk; pub mod monitor_parse; pub mod pio; +pub mod plotter; pub mod port_scan; pub mod purge; pub mod reset; diff --git a/crates/fbuild-cli/src/cli/plotter.rs b/crates/fbuild-cli/src/cli/plotter.rs new file mode 100644 index 000000000..28872b969 --- /dev/null +++ b/crates/fbuild-cli/src/cli/plotter.rs @@ -0,0 +1,97 @@ +//! `fbuild plotter`: open the daemon-served Serial Plotter web page +//! (FastLED/fbuild#1076 Phase 2) in the default browser. +//! +//! The plotter itself is a self-contained page served by fbuild-daemon at +//! `GET /plotter` that connects to the existing `/ws/serial-monitor` +//! WebSocket and populates its own port selector from `/api/devices/list` +//! (`crates/fbuild-daemon/web/plotter/index.html`). So this command's only +//! job is: make sure the daemon is running, build the URL (optionally +//! pinning `?port=` so the page auto-attaches instead of requiring a +//! manual pick), and hand it to the OS's default browser via +//! [`super::build::open_in_browser`] — the same helper the avr8js emulator +//! path (`cli::deploy`) already uses. +//! +//! This is deliberately a standalone tiny command rather than encoding an +//! OS-specific "open a URL" invocation inside `.zed/tasks.json`: the +//! generated Zed task (`fbuild ide`'s `build_fbuild_tasks`) just runs +//! `fbuild plotter`, and the same command works for anyone not using Zed. + +use crate::daemon_client; +use crate::output; + +use super::build::open_in_browser; + +/// Build the `/plotter` URL for the daemon at `base_url`, optionally +/// pinning a port via the `?port=` query param. Pure — no I/O — so it's +/// directly testable without a running daemon. +pub fn plotter_url(base_url: &str, port: Option<&str>) -> String { + match port { + Some(p) => format!("{base_url}/plotter?port={}", percent_encode_query(p)), + None => format!("{base_url}/plotter"), + } +} + +/// Minimal percent-encoding for the handful of characters that show up in +/// a serial port name (`COM3`, `/dev/ttyUSB0`, ...) but aren't safe unescaped +/// in a URL query value. Deliberately small — not a general-purpose encoder. +fn percent_encode_query(s: &str) -> String { + let mut out = String::with_capacity(s.len()); + for b in s.bytes() { + match b { + b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => { + out.push(b as char); + } + _ => out.push_str(&format!("%{:02X}", b)), + } + } + out +} + +/// `fbuild plotter [--port ]` +pub async fn run_plotter(port: Option) -> fbuild_core::Result<()> { + daemon_client::ensure_daemon_running().await?; + let base_url = fbuild_paths::get_daemon_url(); + let url = plotter_url(&base_url, port.as_deref()); + + output::progress(format!("Opening Serial Plotter: {}", url)); + if let Err(e) = open_in_browser(&url).await { + output::warn(format!("failed to open browser: {}", e)); + output::warn(format!("open this URL manually: {}", url)); + } + output::result(url); + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn plotter_url_without_port() { + assert_eq!( + plotter_url("http://127.0.0.1:49200", None), + "http://127.0.0.1:49200/plotter" + ); + } + + #[test] + fn plotter_url_with_com_port_is_unescaped() { + assert_eq!( + plotter_url("http://127.0.0.1:49200", Some("COM3")), + "http://127.0.0.1:49200/plotter?port=COM3" + ); + } + + #[test] + fn plotter_url_with_unix_device_path_is_percent_encoded() { + assert_eq!( + plotter_url("http://127.0.0.1:49200", Some("/dev/ttyUSB0")), + "http://127.0.0.1:49200/plotter?port=%2Fdev%2FttyUSB0" + ); + } + + #[test] + fn percent_encode_query_leaves_safe_chars_alone() { + assert_eq!(percent_encode_query("abc-DEF_123.~"), "abc-DEF_123.~"); + } +} diff --git a/crates/fbuild-cli/src/cli/tests.rs b/crates/fbuild-cli/src/cli/tests.rs index 0039da9c9..802f28848 100644 --- a/crates/fbuild-cli/src/cli/tests.rs +++ b/crates/fbuild-cli/src/cli/tests.rs @@ -72,6 +72,26 @@ fn ide_flags_parse() { } } +// ---------- `fbuild plotter` CLI shape ---------- + +#[test] +fn plotter_with_no_args_has_no_port() { + let cli = Cli::try_parse_from(["fbuild", "plotter"]).expect("parse"); + match cli.command { + Some(Commands::Plotter { port }) => assert_eq!(port, None), + _ => panic!("expected Commands::Plotter"), + } +} + +#[test] +fn plotter_port_flag_parses() { + let cli = Cli::try_parse_from(["fbuild", "plotter", "--port", "COM3"]).expect("parse"); + match cli.command { + Some(Commands::Plotter { port }) => assert_eq!(port, Some("COM3".to_string())), + _ => panic!("expected Commands::Plotter"), + } +} + #[test] fn ide_select_with_no_project_dir_parses_as_select_action() { let cli = Cli::try_parse_from(["fbuild", "ide", "select"]).expect("parse"); diff --git a/crates/fbuild-daemon/src/handlers/mod.rs b/crates/fbuild-daemon/src/handlers/mod.rs index b8a9aeac5..f79213b16 100644 --- a/crates/fbuild-daemon/src/handlers/mod.rs +++ b/crates/fbuild-daemon/src/handlers/mod.rs @@ -6,4 +6,5 @@ pub mod emulator; pub mod health; pub mod locks; pub mod operations; +pub mod plotter; pub mod websockets; diff --git a/crates/fbuild-daemon/src/handlers/plotter.rs b/crates/fbuild-daemon/src/handlers/plotter.rs new file mode 100644 index 000000000..ac64fcf6f --- /dev/null +++ b/crates/fbuild-daemon/src/handlers/plotter.rs @@ -0,0 +1,64 @@ +//! Serial Plotter web page (FastLED/fbuild#1076 Phase 2): a daemon-served, +//! self-contained HTML page that connects to the existing +//! `/ws/serial-monitor` WebSocket and renders a live scrolling line chart +//! of numeric values parsed from serial output, Arduino-Serial-Plotter +//! style. +//! +//! Unlike the avr8js emulator pages (`super::emulator::avr8js_web`), the +//! plotter has no server-side session state — it only needs a serial port +//! that's already open or openable, which `/ws/serial-monitor`'s existing +//! `Attach { open_if_needed: true, .. }` handshake already provides, and a +//! port list, which `POST /api/devices/list` already provides. All parsing +//! and rendering is client-side JS; the page has no build step and no +//! external dependencies (CSP-safe, no CDN — same embedding pattern as +//! `avr8js_web::AVR8JS_APP_JS`). + +use axum::response::{Html, IntoResponse}; + +const PLOTTER_PAGE_HTML: &str = include_str!("../../web/plotter/index.html"); + +/// GET /plotter — serve the self-contained Serial Plotter page. +pub async fn plotter_page() -> impl IntoResponse { + Html(PLOTTER_PAGE_HTML) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn plotter_page_serves_html_containing_ws_endpoint_and_no_external_deps() { + let response = plotter_page().await.into_response(); + assert_eq!(response.status(), axum::http::StatusCode::OK); + let content_type = response + .headers() + .get(axum::http::header::CONTENT_TYPE) + .and_then(|v| v.to_str().ok()) + .unwrap_or_default(); + assert!( + content_type.starts_with("text/html"), + "expected text/html content type, got {content_type}" + ); + + let body = axum::body::to_bytes(response.into_body(), usize::MAX) + .await + .expect("body should be readable"); + let html = String::from_utf8(body.to_vec()).expect("body should be utf-8"); + + assert!( + html.contains("/ws/serial-monitor"), + "plotter page must connect to the existing serial monitor websocket" + ); + assert!( + html.contains("/api/devices/list"), + "plotter page must populate its port selector from the devices endpoint" + ); + assert!( + !html.contains("cdn.") + && !html.contains("unpkg.com") + && !html.contains("jsdelivr.net") + && !html.contains("googleapis.com"), + "plotter page must be self-contained with no CDN dependencies" + ); + } +} diff --git a/crates/fbuild-daemon/src/main.rs b/crates/fbuild-daemon/src/main.rs index 0414a4478..1a655c622 100644 --- a/crates/fbuild-daemon/src/main.rs +++ b/crates/fbuild-daemon/src/main.rs @@ -8,7 +8,9 @@ use fbuild_build::compile_backend::CompileBackend; use fbuild_daemon::context::{ BroadcastHub, DaemonContext, IDLE_TIMEOUT, STALE_LOCK_CHECK_INTERVAL, self_eviction_timeout, }; -use fbuild_daemon::handlers::{cache, devices, emulator, health, locks, operations, websockets}; +use fbuild_daemon::handlers::{ + cache, devices, emulator, health, locks, operations, plotter, websockets, +}; use fbuild_daemon::log_layer::BroadcastLogLayer; use std::sync::Arc; use tracing_subscriber::layer::SubscriberExt; @@ -203,6 +205,7 @@ async fn main() { ) .route("/emulator/avr8js/app.js", get(emulator::avr8js_app_js)) .route("/emulator/avr8js/:session_id", get(emulator::avr8js_page)) + .route("/plotter", get(plotter::plotter_page)) .route("/ws/serial-monitor", get(websockets::ws_serial_monitor)) .route("/ws/status", get(websockets::ws_status)) .route("/ws/logs", get(websockets::ws_logs)) diff --git a/crates/fbuild-daemon/tests/README.md b/crates/fbuild-daemon/tests/README.md index cc94198f1..d71056cf4 100644 --- a/crates/fbuild-daemon/tests/README.md +++ b/crates/fbuild-daemon/tests/README.md @@ -19,3 +19,6 @@ cargo test --release -p fbuild-daemon -- --ignored a daemon with an open client connection, a fresh daemon must still be able to bind the same port. The test is `#[ignore]` because it leaves port state lingering and depends on `taskkill`/`kill` being on PATH. +- `test_plotter_route.rs` — asserts `GET /plotter` (FastLED/fbuild#1076 + Phase 2) is registered and serves the self-contained Serial Plotter + page that attaches to the existing `/ws/serial-monitor` WebSocket. diff --git a/crates/fbuild-daemon/tests/test_plotter_route.rs b/crates/fbuild-daemon/tests/test_plotter_route.rs new file mode 100644 index 000000000..09279d01b --- /dev/null +++ b/crates/fbuild-daemon/tests/test_plotter_route.rs @@ -0,0 +1,63 @@ +//! Integration test for `GET /plotter` (FastLED/fbuild#1076 Phase 2). +//! +//! Mirrors the `test_emu_endpoint.rs` pattern: build a minimal `Router` +//! wired exactly like `main.rs`, spawn it on an ephemeral port, and assert +//! the route round-trips with the expected content — catching route- +//! registration regressions without needing the full production binary. + +use axum::Router; +use axum::routing::get; +use fbuild_daemon::handlers::plotter; +use std::net::SocketAddr; +use std::time::Duration; + +fn build_test_app() -> Router { + Router::new().route("/plotter", get(plotter::plotter_page)) +} + +async fn spawn_test_server() -> SocketAddr { + let app = build_test_app(); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("bind ephemeral port"); + let addr = listener.local_addr().expect("local_addr"); + tokio::spawn(async move { + axum::serve(listener, app) + .await + .expect("axum::serve should not fail in test"); + }); + addr +} + +/// The `/plotter` route is registered and serves a self-contained HTML +/// page that attaches to the existing `/ws/serial-monitor` WebSocket. +#[tokio::test] +async fn plotter_route_serves_html_page() { + let addr = spawn_test_server().await; + + let resp = fbuild_core::http::client_with_timeout(Duration::from_secs(10)) + .get(format!("http://{}/plotter", addr)) + .timeout(Duration::from_secs(5)) + .send() + .await + .expect("GET /plotter should not drop the connection"); + + assert_eq!(resp.status(), reqwest::StatusCode::OK); + let content_type = resp + .headers() + .get(reqwest::header::CONTENT_TYPE) + .and_then(|v| v.to_str().ok()) + .unwrap_or_default() + .to_string(); + assert!( + content_type.starts_with("text/html"), + "expected text/html, got {content_type}" + ); + + let body = resp.text().await.expect("body should be readable"); + assert!( + body.contains("/ws/serial-monitor"), + "must use the existing serial monitor websocket" + ); + assert!(body.contains("fbuild Serial Plotter")); +} diff --git a/crates/fbuild-daemon/web/plotter/README.md b/crates/fbuild-daemon/web/plotter/README.md new file mode 100644 index 000000000..2ddf8a214 --- /dev/null +++ b/crates/fbuild-daemon/web/plotter/README.md @@ -0,0 +1,18 @@ +# Serial Plotter Web Assets + +`index.html` is the self-contained Serial Plotter page served by +`fbuild-daemon` at `GET /plotter` (FastLED/fbuild#1076 Phase 2, +`crates/fbuild-daemon/src/handlers/plotter.rs`, embedded via +`include_str!` following the same pattern as `../avr8js/app.js`). + +The page has no build step and no external dependencies: all CSS and JS +are inline, and the chart is hand-rolled `` drawing (no charting +library). It connects to the daemon's existing `/ws/serial-monitor` +WebSocket to receive serial data and to `POST /api/devices/list` to +populate its port selector — both endpoints already exist and are used +unmodified by other fbuild-daemon clients (the CLI's `fbuild monitor` +and `fbuild device` commands). + +Numeric series are parsed from incoming serial lines Arduino-Serial- +Plotter style: whitespace/comma-separated numbers, with an optional +`name:value` label per token. diff --git a/crates/fbuild-daemon/web/plotter/index.html b/crates/fbuild-daemon/web/plotter/index.html new file mode 100644 index 000000000..397a2ab8e --- /dev/null +++ b/crates/fbuild-daemon/web/plotter/index.html @@ -0,0 +1,629 @@ + + + + + + fbuild Serial Plotter + + + +
+
+
fbuild Serial Plotter
+ + + + + + + +
+
Not connected
+
+ +
+
+
+

Raw output

+

+    
+ +
+ + + + diff --git a/docs/reference/cli.md b/docs/reference/cli.md index c712ebbac..2a3b07840 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -150,6 +150,7 @@ known limitations. | `fbuild clangd-config [--editor vscode\|zed] [--refresh]` | Emit `.clangd` (editor-neutral) plus per-editor project config (`.vscode/*` or `.zed/*`). `--editor` selects the emitter (default `vscode`); `--refresh` forces `compile_commands.json` regeneration even if it already exists. | | `fbuild ide [project_dir] [-e ] [--no-launch]` | Open the project as an IDE workspace on stock Zed. See [`fbuild ide`](#fbuild-ide) below. | | `fbuild ide select [project_dir] [-e ]` | Interactively (or with `-e`) choose the environment used for the IDE config, persist it, and regenerate. | +| `fbuild plotter [-p ]` | Open the daemon-served Serial Plotter web page in the default browser. See [`fbuild plotter`](#fbuild-plotter) below. | | `fbuild clang-tidy` | Run clang-tidy against project sources. | | `fbuild iwyu` | Run include-what-you-use analysis. | | `fbuild clang-query` | Run a clang-query matcher. | @@ -190,8 +191,8 @@ Generated/updated files: `lsp.clangd.binary.arguments`); safe to commit. - `.zed/tasks.json` — merge-don't-clobber: fbuild only replaces tasks whose label starts with `"fbuild: "` (Build, Build (clean), Deploy, Deploy + - Monitor, Monitor, Reset, Select environment); any other task you've added - is left untouched. Safe to commit. + Monitor, Monitor, Reset, Serial Plotter, Select environment); any other + task you've added is left untouched. Safe to commit. - `.fbuild/ide_state.json` — the persisted environment choice. Local developer state; recommend `.gitignore`. - `.zed/debug.json` — merge-don't-clobber, **only written when the @@ -241,6 +242,31 @@ Port `50101` is fixed today (not yet configurable via a flag). The `fbuild build -e ` ELF output location, whether or not it exists yet — build once before attaching. +### `fbuild plotter` + +Open the daemon-served Serial Plotter web page in the default browser +(FastLED/fbuild#1076 Phase 2). The page (`GET /plotter` on the daemon, +`crates/fbuild-daemon/web/plotter/index.html`) is a single self-contained +HTML file with no external dependencies: it connects to the existing +`/ws/serial-monitor` WebSocket (the same one `fbuild monitor` uses), +populates its port selector from `POST /api/devices/list`, parses numeric +series out of incoming lines Arduino-Serial-Plotter style +(whitespace/comma-separated numbers, optional `name:value` labels), and +renders a live scrolling line chart on a `` — pause/resume, clear, +and a raw-output tail are all built in. + +```bash +fbuild plotter # opens the page; pick a port in the UI +fbuild plotter --port COM3 # pins the page to a port and auto-connects +``` + +`fbuild plotter` itself does no parsing or rendering — its only job is to +make sure the daemon is running and open `http://127.0.0.1:/plotter[?port=]` in the OS default browser. `fbuild ide` +generates a `"fbuild: Serial Plotter"` Zed task that just runs `fbuild +plotter` (see [`fbuild ide`](#fbuild-ide) above), so the same command works +whether or not you're using Zed. + ## Batch And CI Commands ### `fbuild compile-many`