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
21 changes: 17 additions & 4 deletions crates/fbuild-build/src/compile_many.rs
Original file line number Diff line number Diff line change
Expand Up @@ -328,8 +328,14 @@ fn resolve_env_for_board(project_dir: &Path, board: &str) -> Result<String> {
}

/// Determine the platform for a board id.
fn platform_for_board(board: &str) -> Result<Platform> {
let cfg = fbuild_config::BoardConfig::from_board_id(board, &HashMap::new())?;
///
/// `project_dir`, when provided, also allows `<project_dir>/boards/<board>.json`
/// to satisfy the lookup. This matches PlatformIO's auto-discovery of
/// project-local board manifests and is how compile_many ingests boards
/// shipped alongside a `platformio.ini` (FastLED/fbuild#515).
fn platform_for_board(board: &str, project_dir: Option<&std::path::Path>) -> Result<Platform> {
let cfg =
fbuild_config::BoardConfig::from_board_id_in_project(board, &HashMap::new(), project_dir)?;
cfg.platform().ok_or_else(|| {
FbuildError::ConfigError(format!(
"could not determine platform for board '{}' (mcu '{}')",
Expand Down Expand Up @@ -504,7 +510,14 @@ pub fn compile_many_with(
.unwrap_or_else(default_framework_jobs)
.max(1);
let sketch_jobs = req.sketch_jobs.unwrap_or_else(default_sketch_jobs).max(1);
let platform = platform_for_board(&req.board)?;
// Use the first sketch's project_dir as the project-local boards/
// search root. The convention is that `fbuild build <dir> -e <env>`
// uses <dir> as the project_dir (it holds platformio.ini), and any
// boards/*.json next to it should resolve. With multiple sketches in
// one call, they typically share a parent project; the first one is
// a good-enough default.
let project_dir_for_boards = req.sketches.first().map(|p| p.as_path());
let platform = platform_for_board(&req.board, project_dir_for_boards)?;

// Pre-resolve env names + assert each sketch dir exists. Doing this
// up front means we never half-build the batch and leave one worker
Expand Down Expand Up @@ -884,7 +897,7 @@ mod tests {

#[test]
fn platform_for_board_uno_is_avr() {
let p = platform_for_board("uno").unwrap();
let p = platform_for_board("uno", None).unwrap();
assert_eq!(p, Platform::AtmelAvr);
}

Expand Down
83 changes: 80 additions & 3 deletions crates/fbuild-config/src/board/db.rs
Original file line number Diff line number Diff line change
Expand Up @@ -106,34 +106,111 @@ pub(super) fn get_board_debug_tools(board_id: &str) -> Option<HashMap<String, De
Some(result)
}

pub(super) fn get_board_defaults(board_id: &str) -> Option<HashMap<String, String>> {
/// Resolve board defaults with an optional project-local fallback.
///
/// Lookup order:
/// 1. Built-in board database (enriched JSON baked into the binary).
/// 2. `<project_dir>/boards/<board_id>.json` (PlatformIO-style project-local
/// board manifest), when `project_dir` is provided.
///
/// This matches PlatformIO's behavior of auto-discovering project-local
/// board manifests so well-formed `platformio.ini` projects that ship a
/// `boards/` directory work without per-board upstream changes.
///
/// Precedence is fallback-only: built-in wins, project-local only fills
/// the gap. Project-local override of a bundled board is intentionally
/// not supported here (open a separate feature request if needed).
pub(super) fn get_board_defaults_with_project_dir(
board_id: &str,
project_dir: Option<&std::path::Path>,
) -> Option<HashMap<String, String>> {
// 1. Bundled DB lookup.
let db = get_board_db();
let resolved = resolve_board_alias(board_id);
let entry = db.get(board_id).or_else(|| db.get(resolved))?;
if let Some(entry) = db.get(board_id).or_else(|| db.get(resolved)) {
return Some(flatten_board_entry(entry, board_id));
}

// 2. Project-local fallback: <project_dir>/boards/<board_id>.json
// Use the original (non-aliased) board_id only: aliases are a
// bundled-DB convenience, not something users override locally.
if let Some(dir) = project_dir {
let path = dir.join("boards").join(format!("{}.json", board_id));
match std::fs::read_to_string(&path) {
Ok(contents) => match serde_json::from_str::<serde_json::Value>(&contents) {
Ok(value) => return Some(flatten_board_entry(&value, board_id)),
Err(e) => tracing::warn!(
"project-local board {} at {} failed to parse: {}",
board_id,
path.display(),
e
),
},
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
Err(e) => tracing::warn!(
"project-local board {} at {} unreadable: {}",
board_id,
path.display(),
e
),
}
}

None
}

/// Project a board JSON entry (enriched format OR raw PlatformIO format)
/// into the flat `HashMap<String, String>` consumed by [`BoardConfig::from_board_id`].
///
/// Enriched format has top-level `mcu`, `fcpu`, `ram`, `rom`, `platform`.
/// PlatformIO project-local boards typically only have `build.mcu`,
/// `build.f_cpu`, `upload.maximum_ram_size`, `upload.maximum_size`. Where
/// a top-level field is missing we fall back to the PIO-style nested
/// location so both shapes work uniformly.
fn flatten_board_entry(entry: &serde_json::Value, board_id: &str) -> HashMap<String, String> {
let mut d = HashMap::new();
let build = entry.get("build").and_then(|v| v.as_object());

if let Some(name) = entry.get("name").and_then(|v| v.as_str()) {
d.insert("name".into(), name.to_string());
}

// mcu: enriched top-level wins; fall back to build.mcu (PIO format).
let mcu = entry
.get("mcu")
.and_then(|v| v.as_str())
.or_else(|| build.and_then(|b| b.get("mcu")).and_then(|v| v.as_str()))
.unwrap_or("unknown")
.to_lowercase();
d.insert("mcu".into(), mcu.clone());

// f_cpu: enriched top-level `fcpu` (u64) wins; fall back to PIO
// `build.f_cpu` (string, may already carry the trailing "L").
if let Some(fcpu) = entry.get("fcpu").and_then(|v| v.as_u64()) {
d.insert("f_cpu".into(), format!("{}L", fcpu));
} else if let Some(f_cpu_str) = build.and_then(|b| b.get("f_cpu")).and_then(|v| v.as_str()) {
d.insert("f_cpu".into(), f_cpu_str.to_string());
}

// ram/rom: enriched top-level wins; fall back to PIO `upload.maximum_ram_size`
// and `upload.maximum_size` respectively.
let upload = entry.get("upload").and_then(|v| v.as_object());
if let Some(ram) = entry.get("ram").and_then(|v| v.as_u64()) {
d.insert("maximum_data_size".into(), ram.to_string());
} else if let Some(ram) = upload
.and_then(|u| u.get("maximum_ram_size"))
.and_then(|v| v.as_u64())
{
d.insert("maximum_data_size".into(), ram.to_string());
}

if let Some(rom) = entry.get("rom").and_then(|v| v.as_u64()) {
d.insert("maximum_size".into(), rom.to_string());
} else if let Some(rom) = upload
.and_then(|u| u.get("maximum_size"))
.and_then(|v| v.as_u64())
{
d.insert("maximum_size".into(), rom.to_string());
}

if let Some(platform) = entry.get("platform").and_then(|v| v.as_str()) {
Expand Down Expand Up @@ -249,5 +326,5 @@ pub(super) fn get_board_defaults(board_id: &str) -> Option<HashMap<String, Strin
d.entry("board".into())
.or_insert_with(|| board_id_to_board_define(board_id));

Some(d)
d
}
39 changes: 32 additions & 7 deletions crates/fbuild-config/src/board/loaders.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,9 @@
use std::collections::HashMap;
use std::path::Path;

use super::db::{board_id_to_board_define, get_board_debug_tools, get_board_defaults};
use super::db::{
board_id_to_board_define, get_board_debug_tools, get_board_defaults_with_project_dir,
};
use super::types::BoardConfig;

fn parse_flash_size_bytes(raw: &str) -> Option<u64> {
Expand Down Expand Up @@ -179,12 +181,35 @@ impl BoardConfig {
board_id: &str,
overrides: &HashMap<String, String>,
) -> fbuild_core::Result<Self> {
let defaults = get_board_defaults(board_id).ok_or_else(|| {
fbuild_core::FbuildError::ConfigError(format!(
"unknown board '{}' (no built-in defaults)",
board_id
))
})?;
Self::from_board_id_in_project(board_id, overrides, None)
}

/// Load board config from built-in defaults with a project-local fallback.
///
/// When the built-in board database has no entry for `board_id`, fall
/// back to `<project_dir>/boards/<board_id>.json` (PlatformIO-style
/// project-local board manifest). This matches PlatformIO's behavior
/// of auto-discovering project-local board manifests next to
/// `platformio.ini`.
///
/// Pass `None` for `project_dir` to disable the fallback (equivalent
/// to [`Self::from_board_id`]).
pub fn from_board_id_in_project(
board_id: &str,
overrides: &HashMap<String, String>,
project_dir: Option<&std::path::Path>,
) -> fbuild_core::Result<Self> {
let defaults =
get_board_defaults_with_project_dir(board_id, project_dir).ok_or_else(|| {
let suffix = match project_dir {
Some(d) => format!(" (also checked {}/boards/{}.json)", d.display(), board_id),
None => String::new(),
};
fbuild_core::FbuildError::ConfigError(format!(
"unknown board '{}' (no built-in defaults){}",
board_id, suffix
))
})?;

let get = |key: &str, default: &str| -> String {
overrides
Expand Down
3 changes: 3 additions & 0 deletions crates/fbuild-config/src/board/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,9 @@ mod types;
#[cfg(test)]
mod tests;

#[cfg(test)]
mod tests_project_local;

#[cfg(test)]
mod tests_usb_vid;

Expand Down
140 changes: 140 additions & 0 deletions crates/fbuild-config/src/board/tests_project_local.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
//! Tests for project-local `boards/<id>.json` resolution (FastLED/fbuild#515).
//!
//! Verifies that `BoardConfig::from_board_id_in_project` accepts a
//! PlatformIO-style project-local board manifest as a fallback when the
//! built-in board database has no entry for the requested board id, and
//! that the function still behaves identically to `from_board_id` when
//! no project_dir is provided.

use std::collections::HashMap;
use std::io::Write;

use tempfile::TempDir;

use super::BoardConfig;

/// Write a JSON file at `<dir>/boards/<id>.json` and return the directory.
fn write_project_board(dir: &TempDir, board_id: &str, json: &str) {
let boards_dir = dir.path().join("boards");
std::fs::create_dir_all(&boards_dir).unwrap();
let path = boards_dir.join(format!("{}.json", board_id));
let mut f = std::fs::File::create(&path).unwrap();
f.write_all(json.as_bytes()).unwrap();
f.flush().unwrap();
}

const LPC845BRK_PIO_JSON: &str = r#"{
"build": {
"core": "lpc8xx",
"cpu": "cortex-m0plus",
"extra_flags": "-DCPU_LPC845M301JBD48 -D__LPC845__ -DLPC845 -DARDUINO_LPC845BRK",
"f_cpu": "30000000L",
"mcu": "lpc845",
"variant": "lpc845brk"
},
"frameworks": ["arduino"],
"name": "NXP LPC845-BRK",
"upload": {
"maximum_ram_size": 16384,
"maximum_size": 65536,
"protocol": "cmsis-dap"
},
"vendor": "NXP"
}"#;

#[test]
fn project_local_board_resolves_when_bundled_db_misses() {
let dir = TempDir::new().unwrap();
write_project_board(&dir, "lpc845brk-test", LPC845BRK_PIO_JSON);

let cfg =
BoardConfig::from_board_id_in_project("lpc845brk-test", &HashMap::new(), Some(dir.path()))
.expect("project-local board should resolve");

assert_eq!(cfg.name, "NXP LPC845-BRK");
assert_eq!(cfg.mcu, "lpc845");
assert_eq!(cfg.f_cpu, "30000000L");
assert_eq!(cfg.core, "lpc8xx");
assert_eq!(cfg.variant, "lpc845brk");
assert_eq!(cfg.max_flash, Some(65_536));
assert_eq!(cfg.max_ram, Some(16_384));
assert_eq!(cfg.upload_protocol.as_deref(), Some("cmsis-dap"));
// Board-level extra_flags carry the Arduino board macro.
let defines = cfg.get_defines();
assert_eq!(defines.get("ARDUINO_LPC845BRK"), Some(&"1".to_string()));
assert_eq!(defines.get("CPU_LPC845M301JBD48"), Some(&"1".to_string()));
}

#[test]
fn project_local_board_not_consulted_without_project_dir() {
// With no project_dir, the lookup must fall straight through to the
// bundled DB; an unknown id stays unknown even if a file would have
// satisfied it.
let dir = TempDir::new().unwrap();
write_project_board(&dir, "lpc845brk-test", LPC845BRK_PIO_JSON);

let result = BoardConfig::from_board_id_in_project("lpc845brk-test", &HashMap::new(), None);
assert!(
result.is_err(),
"expected unknown-board error when project_dir is None"
);
let msg = result.unwrap_err().to_string();
assert!(msg.contains("unknown board"), "msg was: {}", msg);
}

#[test]
fn bundled_board_wins_over_project_local() {
// 'uno' is a well-known bundled board. A project-local file with a
// bogus mcu must NOT override the bundled defaults — project-local is
// a fallback only.
let dir = TempDir::new().unwrap();
let bogus_uno = r#"{
"build": {"mcu": "definitely-not-atmega328p", "core": "arduino", "variant": "standard"},
"name": "Project-Local Uno (should be ignored)"
}"#;
write_project_board(&dir, "uno", bogus_uno);

let cfg = BoardConfig::from_board_id_in_project("uno", &HashMap::new(), Some(dir.path()))
.expect("bundled 'uno' must resolve");
assert_eq!(
cfg.mcu, "atmega328p",
"bundled board defaults should win over project-local"
);
}

#[test]
fn project_local_missing_file_returns_unknown_board() {
let dir = TempDir::new().unwrap();
// No boards/ directory created.

let result =
BoardConfig::from_board_id_in_project("lpc845brk-test", &HashMap::new(), Some(dir.path()));
assert!(result.is_err(), "missing file should yield unknown-board");
}

#[test]
fn project_local_unparseable_file_returns_unknown_board() {
let dir = TempDir::new().unwrap();
write_project_board(&dir, "bad-json", "{ this is not json");

let result =
BoardConfig::from_board_id_in_project("bad-json", &HashMap::new(), Some(dir.path()));
assert!(
result.is_err(),
"malformed JSON should yield unknown-board (logged as a warning)"
);
}

#[test]
fn from_board_id_is_equivalent_to_in_project_with_none() {
let a = BoardConfig::from_board_id("uno", &HashMap::new()).unwrap();
let b = BoardConfig::from_board_id_in_project("uno", &HashMap::new(), None).unwrap();
assert_eq!(a.name, b.name);
assert_eq!(a.mcu, b.mcu);
assert_eq!(a.f_cpu, b.f_cpu);
assert_eq!(a.board, b.board);
assert_eq!(a.core, b.core);
assert_eq!(a.variant, b.variant);
assert_eq!(a.max_flash, b.max_flash);
assert_eq!(a.max_ram, b.max_ram);
}
Loading