Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions agents/docs/commands-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <port>]` | Open the daemon-served Serial Plotter web page (`GET /plotter`) in the default browser: a self-contained, dependency-free `<canvas>` 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

Expand Down
11 changes: 11 additions & 0 deletions crates/fbuild-cli/src/cli/args.rs
Original file line number Diff line number Diff line change
Expand Up @@ -524,6 +524,16 @@ pub enum Commands {
#[command(subcommand)]
action: Option<IdeAction>,
},
/// 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<String>,
},
/// Build firmware and run it in an emulator for testing
TestEmu {
project_dir: Option<String>,
Expand Down Expand Up @@ -997,6 +1007,7 @@ pub const KNOWN_SUBCOMMANDS: &[&str] = &[
"iwyu",
"clangd-config",
"ide",
"plotter",
"clang-query",
"test-emu",
"lib-select",
Expand Down
2 changes: 2 additions & 0 deletions crates/fbuild-cli/src/cli/dispatch.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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,
Expand Down
16 changes: 14 additions & 2 deletions crates/fbuild-cli/src/cli/ide.rs
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,12 @@ fn build_fbuild_tasks(env_name: &str, debug_chip: Option<&str>) -> Vec<ZedTask>
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",
Expand Down Expand Up @@ -683,14 +689,15 @@ 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)",
"fbuild: Deploy",
"fbuild: Deploy + Monitor",
"fbuild: Monitor",
"fbuild: Reset",
"fbuild: Serial Plotter",
"fbuild: Select environment",
] {
assert!(
Expand All @@ -705,14 +712,19 @@ 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")));
}

#[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)")
Expand Down
1 change: 1 addition & 0 deletions crates/fbuild-cli/src/cli/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
97 changes: 97 additions & 0 deletions crates/fbuild-cli/src/cli/plotter.rs
Original file line number Diff line number Diff line change
@@ -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=<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 <port>]`
pub async fn run_plotter(port: Option<String>) -> 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.~");
}
}
20 changes: 20 additions & 0 deletions crates/fbuild-cli/src/cli/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
1 change: 1 addition & 0 deletions crates/fbuild-daemon/src/handlers/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,4 +6,5 @@ pub mod emulator;
pub mod health;
pub mod locks;
pub mod operations;
pub mod plotter;
pub mod websockets;
64 changes: 64 additions & 0 deletions crates/fbuild-daemon/src/handlers/plotter.rs
Original file line number Diff line number Diff line change
@@ -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"
);
}
}
5 changes: 4 additions & 1 deletion crates/fbuild-daemon/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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))
Expand Down
3 changes: 3 additions & 0 deletions crates/fbuild-daemon/tests/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
63 changes: 63 additions & 0 deletions crates/fbuild-daemon/tests/test_plotter_route.rs
Original file line number Diff line number Diff line change
@@ -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("<title>fbuild Serial Plotter</title>"));
}
Loading
Loading