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
122 changes: 122 additions & 0 deletions crates/fbuild-config/src/board/mcu_vid.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
//! Offline MCU-family → USB VID heuristic, embedded at build time.
//!
//! Boards whose PlatformIO definition carries no `build.vid`/`build.pid`
//! (native-USB SoCs like ESP32-C6, LPC845, UNO R4 / RA4M1) previously had a
//! VID only via the `online-data` `mcu_to_vid` pipeline — resolved over the
//! network at runtime. That "online fallback" made offline / air-gapped / CI
//! resolution impossible for those boards (FastLED/fbuild#959, PR #925).
//!
//! This module bakes the exact same curated MCU-family → VID table
//! (`online-data-tools/seed_mcu_to_vid.json`, the seed the online pipeline
//! publishes from) into the binary via `include_str!`, and applies the same
//! matching rule the pipeline's SQLite view uses:
//!
//! ```sql
//! JOIN mcu_to_vid m ON m.mcu_family = b.mcu OR b.mcu LIKE m.mcu_family || '%'
//! ```
//!
//! i.e. a family matches when it equals the board MCU or is a prefix of it,
//! and the highest-`score` match wins. Single source of truth — the same JSON
//! feeds both the online publish and this embedded copy.

use std::sync::OnceLock;

use serde::Deserialize;

/// The curated seed, embedded at compile time (repo-root path so the online
/// publish and the embedded copy never drift).
const SEED_JSON: &str = include_str!("../../../../online-data-tools/seed_mcu_to_vid.json");

#[derive(Deserialize)]
struct SeedRow {
mcu_family: String,
vid: String,
score: f64,
// `reason` is documentation-only; ignored here.
}

struct McuVid {
/// Uppercased family key, e.g. `ESP32C6`, `LPC8`, `RA4M1`.
family_upper: String,
/// Lowercased 4-hex VID (no `0x`), e.g. `303a`.
vid: String,
score: f64,
}

fn table() -> &'static [McuVid] {
static TABLE: OnceLock<Vec<McuVid>> = OnceLock::new();
TABLE.get_or_init(|| {
let rows: Vec<SeedRow> = serde_json::from_str(SEED_JSON)
.expect("embedded seed_mcu_to_vid.json is valid JSON at build time");
rows.into_iter()
.map(|r| McuVid {
family_upper: r.mcu_family.to_ascii_uppercase(),
vid: r.vid.to_ascii_lowercase(),
score: r.score,
})
.collect()
})
}

/// Best-guess USB VID (lowercase 4-hex, no `0x`) for an MCU string, using the
/// same `family == mcu OR mcu LIKE family%` + highest-score rule as the
/// online `mcu_to_vid` pipeline. `None` when no family matches.
///
/// Purely a resolution/display heuristic — it deliberately does NOT feed the
/// `-DUSB_VID` compile define (that stays keyed on an explicit `build.vid`),
/// so adding this cannot change any board's compiled output.
pub fn vid_for_mcu(mcu: &str) -> Option<&'static str> {
if mcu.is_empty() {
return None;
}
let mcu_upper = mcu.to_ascii_uppercase();
table()
.iter()
.filter(|r| mcu_upper == r.family_upper || mcu_upper.starts_with(&r.family_upper))
.max_by(|a, b| {
a.score
.partial_cmp(&b.score)
// Tie-break deterministically on the longer (more specific)
// family so `ESP32C6` beats a same-score `ESP32` prefix.
.unwrap_or(std::cmp::Ordering::Equal)
.then_with(|| a.family_upper.len().cmp(&b.family_upper.len()))
})
.map(|r| r.vid.as_str())
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn embedded_seed_is_nonempty() {
assert!(!table().is_empty(), "embedded mcu_to_vid seed should parse");
}

#[test]
fn native_usb_socs_resolve_a_vid_offline() {
// The families behind the six null-vid boards #959 targets.
assert_eq!(vid_for_mcu("ESP32C6"), Some("303a"));
assert_eq!(vid_for_mcu("ESP32C3"), Some("303a"));
assert_eq!(vid_for_mcu("ESP32P4"), Some("303a"));
assert_eq!(vid_for_mcu("RA4M1"), Some("2341"));
// LPC845 matches the `LPC8` family prefix.
assert_eq!(vid_for_mcu("LPC845"), Some("1fc9"));
// Plain ESP32 (esp32doit-devkit-v1) → CP210x bridge.
assert_eq!(vid_for_mcu("ESP32"), Some("10c4"));
}

#[test]
fn prefix_specificity_beats_shorter_family() {
// `ESP32C6` (0.90) must win over the `ESP32` (0.70) prefix match.
assert_eq!(vid_for_mcu("ESP32C6"), Some("303a"));
// Case-insensitive.
assert_eq!(vid_for_mcu("esp32c6"), Some("303a"));
}

#[test]
fn unknown_mcu_returns_none() {
assert_eq!(vid_for_mcu("TOTALLY_FAKE_MCU"), None);
assert_eq!(vid_for_mcu(""), None);
}
}
24 changes: 24 additions & 0 deletions crates/fbuild-config/src/board/methods.rs
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,30 @@ impl BoardConfig {
self.chip_variant.as_deref().unwrap_or(&self.mcu)
}

/// Resolve this board's USB VID **offline**: the explicit `build.vid`
/// when the board JSON ships one, otherwise the embedded MCU-family
/// heuristic ([`super::mcu_vid::vid_for_mcu`]).
///
/// Returns a lowercase 4-hex string without the `0x` prefix (e.g.
/// `"303a"`), or `None` when neither source knows a VID.
///
/// This is the build-time replacement for the old runtime `mcu_to_vid`
/// online fallback (FastLED/fbuild#959): boards whose JSON has null
/// `build.vid`/`build.pid` — ESP32-C6, LPC845-BRK, UNO R4, etc. — now
/// resolve a VID with no network access. It is a resolution/display
/// helper only and does **not** change the `-DUSB_VID` compile define,
/// which stays keyed on an explicit `build.vid`.
pub fn resolved_vid(&self) -> Option<String> {
if let Some(vid) = &self.vid {
let hex = vid
.strip_prefix("0x")
.or_else(|| vid.strip_prefix("0X"))
.unwrap_or(vid);
return Some(hex.to_ascii_lowercase());
}
super::mcu_vid::vid_for_mcu(&self.mcu).map(str::to_string)
}

/// Check whether this board supports a specific emulator tool.
pub fn has_emulator(&self, tool_name: &str) -> bool {
self.debug_tools
Expand Down
1 change: 1 addition & 0 deletions crates/fbuild-config/src/board/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@

mod db;
mod loaders;
pub mod mcu_vid;
mod methods;
mod types;

Expand Down
55 changes: 55 additions & 0 deletions crates/fbuild-config/src/board/tests_common_board_vidpid.rs
Original file line number Diff line number Diff line change
Expand Up @@ -332,3 +332,58 @@ fn issue_740_first_party_vid_pid_rows_all_resolve() {
failures.join("\n")
);
}

/// FastLED/fbuild#959 — the six boards whose JSON has null `build.vid`/`build.pid`
/// used to resolve a VID only via the runtime `online-data` `mcu_to_vid` fetch.
/// They must now resolve one **offline** through the compile-time-embedded
/// MCU-family heuristic ([`BoardConfig::resolved_vid`]). No network here.
#[test]
fn issue_959_null_vid_boards_resolve_via_embedded_mcu_heuristic() {
// (board_id, expected heuristic VID) — the same VIDs the online-data
// pipeline would have published for each MCU family.
const NULL_VID_BOARDS: &[(&str, &str)] = &[
("esp32-c3-devkitm-1", "303a"),
("esp32-c6-devkitc-1", "303a"),
("esp32-p4-evboard", "303a"),
("esp32doit-devkit-v1", "10c4"),
("uno_r4_wifi", "2341"),
("lpc845brk", "1fc9"),
];

let mut failures = Vec::new();
for (board_id, expected_vid) in NULL_VID_BOARDS {
let config = match BoardConfig::from_board_id(board_id, &HashMap::new()) {
Ok(c) => c,
Err(e) => {
failures.push(format!("{board_id}: failed to load: {e}"));
continue;
}
};
// Precondition: these boards genuinely lack an explicit build.vid, so
// the resolution is exercising the embedded heuristic, not JSON.
if config.vid.is_some() {
failures.push(format!(
"{board_id}: now ships an explicit build.vid ({:?}); move it to \
FIRST_PARTY_VID_PID_ROWS instead",
config.vid
));
continue;
}
match config.resolved_vid() {
Some(got) if norm_hex(&got) == norm_hex(expected_vid) => {}
Some(got) => failures.push(format!(
"{board_id}: heuristic VID {got} != expected {expected_vid}"
)),
None => failures.push(format!(
"{board_id}: resolved_vid() returned None — online-fallback \
regression, the MCU heuristic did not embed/resolve"
)),
}
}

assert!(
failures.is_empty(),
"FastLED/fbuild#959 offline MCU→VID embedding regressed:\n{}",
failures.join("\n")
);
}
Loading