From 38a22ba47bfdf75707fed26df98b3d5d25f700fd Mon Sep 17 00:00:00 2001 From: Yona Appletree Date: Fri, 31 Jul 2026 12:36:09 -0700 Subject: [PATCH 1/4] feat(lpa-boards): board display sidecars, schema, drift gate, six boards New app-side lpa-boards crate owning boards//.display.json catalog sidecars (identity/commerce + drawing block) next to the runtime manifests, which stay firmware-lean and untouched. Adds runtime+display profiles for the C6/S3 DevKitC-1 (datasheet-derived, uncalibrated, USB and strap pins reserved per policy) and display-only sidecars for DevKitC v4 + QuinLED Dig-Uno (no classic-ESP32 target yet - allowlisted in the drift tests). board-display.schema.json joins the generated schema set; the manifest store now skips .display.json sidecars. Plan: 2026-07-31-1002-hardware-board-selection (M1) Co-Authored-By: Claude Fable 5 --- Cargo.lock | 11 + Cargo.toml | 2 + lp-app/lpa-boards/Cargo.toml | 23 + lp-app/lpa-boards/src/catalog.rs | 87 ++++ lp-app/lpa-boards/src/display_manifest.rs | 398 ++++++++++++++++ lp-app/lpa-boards/src/lib.rs | 21 + lp-app/lpa-boards/tests/manifest_drift.rs | 209 +++++++++ lp-cli/Cargo.toml | 1 + .../hardware/manifest/board_manifest_store.rs | 9 + lp-cli/src/commands/schema/generate.rs | 14 + .../espressif/esp32-c6-devkitc-1.display.json | 59 +++ .../boards/espressif/esp32-c6-devkitc-1.json | 192 ++++++++ .../espressif/esp32-devkitc-v4.display.json | 65 +++ .../espressif/esp32-s3-devkitc-1.display.json | 73 +++ .../boards/espressif/esp32-s3-devkitc-1.json | 238 ++++++++++ .../boards/quinled/dig-uno.display.json | 39 ++ .../boards/seeed/xiao-esp32-c6.display.json | 39 ++ .../seeed/xiao-esp32-s3-plus.display.json | 41 ++ schemas/board-display.schema.json | 424 ++++++++++++++++++ 19 files changed, 1945 insertions(+) create mode 100644 lp-app/lpa-boards/Cargo.toml create mode 100644 lp-app/lpa-boards/src/catalog.rs create mode 100644 lp-app/lpa-boards/src/display_manifest.rs create mode 100644 lp-app/lpa-boards/src/lib.rs create mode 100644 lp-app/lpa-boards/tests/manifest_drift.rs create mode 100644 lp-core/lpc-hardware/boards/espressif/esp32-c6-devkitc-1.display.json create mode 100644 lp-core/lpc-hardware/boards/espressif/esp32-c6-devkitc-1.json create mode 100644 lp-core/lpc-hardware/boards/espressif/esp32-devkitc-v4.display.json create mode 100644 lp-core/lpc-hardware/boards/espressif/esp32-s3-devkitc-1.display.json create mode 100644 lp-core/lpc-hardware/boards/espressif/esp32-s3-devkitc-1.json create mode 100644 lp-core/lpc-hardware/boards/quinled/dig-uno.display.json create mode 100644 lp-core/lpc-hardware/boards/seeed/xiao-esp32-c6.display.json create mode 100644 lp-core/lpc-hardware/boards/seeed/xiao-esp32-s3-plus.display.json create mode 100644 schemas/board-display.schema.json diff --git a/Cargo.lock b/Cargo.lock index 46655c39d..177a70639 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5290,6 +5290,7 @@ dependencies = [ "lp-riscv-emu", "lp-riscv-inst", "lp-shader", + "lpa-boards", "lpa-client", "lpa-link", "lpa-server", @@ -5557,6 +5558,16 @@ dependencies = [ "web-sys", ] +[[package]] +name = "lpa-boards" +version = "40.0.0" +dependencies = [ + "lpc-hardware", + "schemars", + "serde", + "serde_json", +] + [[package]] name = "lpa-client" version = "40.0.0" diff --git a/Cargo.toml b/Cargo.toml index 7f7f9121b..77c061175 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -16,6 +16,7 @@ members = [ "lp-app/lpa-client", "lp-app/lpa-link", "lp-app/lpa-mapping-editor", + "lp-app/lpa-boards", "lp-app/lpa-studio-core", "lp-app/lpa-studio-web", "lp-app/lpa-studio-web-story-macros", @@ -105,6 +106,7 @@ default-members = [ "lp-app/lpa-client", "lp-app/lpa-link", "lp-app/lpa-mapping-editor", + "lp-app/lpa-boards", "lp-app/lpa-studio-core", "lp-fw/fw-esp32-common", "lp-fw/fw-core", diff --git a/lp-app/lpa-boards/Cargo.toml b/lp-app/lpa-boards/Cargo.toml new file mode 100644 index 000000000..abff60f1f --- /dev/null +++ b/lp-app/lpa-boards/Cargo.toml @@ -0,0 +1,23 @@ +[package] +name = "lpa-boards" +version.workspace = true +authors.workspace = true +edition.workspace = true +license.workspace = true +rust-version.workspace = true + +[features] +default = [] +# JSON Schema derives for the board display sidecar types (host-side tooling). +schema-gen = ["dep:schemars"] + +[dependencies] +serde = { workspace = true, features = ["derive"] } +serde_json = { workspace = true } +schemars = { workspace = true, optional = true } + +[dev-dependencies] +lpc-hardware = { path = "../../lp-core/lpc-hardware" } + +[lints] +workspace = true diff --git a/lp-app/lpa-boards/src/catalog.rs b/lp-app/lpa-boards/src/catalog.rs new file mode 100644 index 000000000..31de7fcf4 --- /dev/null +++ b/lp-app/lpa-boards/src/catalog.rs @@ -0,0 +1,87 @@ +//! The checked-in board catalog, embedded so wasm consumers need no fs. +//! +//! Sources live next to the runtime manifests: +//! `lp-core/lpc-hardware/boards//.display.json`. +//! Adding a board = adding the file there and listing it here; the drift +//! tests fail if this list and the directory disagree. + +use std::sync::OnceLock; + +use crate::display_manifest::BoardDisplayFile; + +/// `(board_id, json_source)` for every checked-in display sidecar. +pub const DISPLAY_MANIFEST_SOURCES: &[(&str, &str)] = &[ + ( + "espressif/esp32-c6-devkitc-1", + include_str!("../../../lp-core/lpc-hardware/boards/espressif/esp32-c6-devkitc-1.display.json"), + ), + ( + "espressif/esp32-s3-devkitc-1", + include_str!("../../../lp-core/lpc-hardware/boards/espressif/esp32-s3-devkitc-1.display.json"), + ), + ( + "espressif/esp32-devkitc-v4", + include_str!("../../../lp-core/lpc-hardware/boards/espressif/esp32-devkitc-v4.display.json"), + ), + ( + "seeed/xiao-esp32-c6", + include_str!("../../../lp-core/lpc-hardware/boards/seeed/xiao-esp32-c6.display.json"), + ), + ( + "seeed/xiao-esp32-s3-plus", + include_str!("../../../lp-core/lpc-hardware/boards/seeed/xiao-esp32-s3-plus.display.json"), + ), + ( + "quinled/dig-uno", + include_str!("../../../lp-core/lpc-hardware/boards/quinled/dig-uno.display.json"), + ), +]; + +/// Every checked-in board, parsed once. Panics on malformed embedded data — +/// the tests and the schema gate keep that impossible at HEAD. +pub fn all_boards() -> &'static [BoardDisplayFile] { + static BOARDS: OnceLock> = OnceLock::new(); + BOARDS.get_or_init(|| { + DISPLAY_MANIFEST_SOURCES + .iter() + .map(|(id, source)| { + let board = BoardDisplayFile::read_json(source) + .unwrap_or_else(|error| panic!("embedded display manifest {id}: {error}")); + assert_eq!( + &board.board_id, id, + "embedded display manifest listed under the wrong id" + ); + board + }) + .collect() + }) +} + +pub fn board_by_id(board_id: &str) -> Option<&'static BoardDisplayFile> { + all_boards().iter().find(|board| board.board_id == board_id) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn all_embedded_boards_parse_and_validate() { + let boards = all_boards(); + assert_eq!(boards.len(), DISPLAY_MANIFEST_SOURCES.len()); + } + + #[test] + fn board_ids_are_unique() { + let boards = all_boards(); + for board in boards { + assert_eq!( + boards + .iter() + .filter(|other| other.board_id == board.board_id) + .count(), + 1 + ); + } + } +} diff --git a/lp-app/lpa-boards/src/display_manifest.rs b/lp-app/lpa-boards/src/display_manifest.rs new file mode 100644 index 000000000..fd859bdd2 --- /dev/null +++ b/lp-app/lpa-boards/src/display_manifest.rs @@ -0,0 +1,398 @@ +//! Serde types for `boards//.display.json` sidecars. +//! +//! A display sidecar carries everything the boards catalog, provisioning +//! picker, and diagram renderer need that the runtime manifest must not: +//! commerce/identity fields and the drawing block. Pin facts here are +//! presentation-level; the runtime manifest stays the authority on claimable +//! resources, and `tests/manifest_drift.rs` keeps the two consistent. + +use std::fmt; + +use serde::{Deserialize, Serialize}; + +/// One board's display sidecar (`/.display.json`). +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[cfg_attr(feature = "schema-gen", derive(schemars::JsonSchema))] +pub struct BoardDisplayFile { + /// Must equal the sibling runtime manifest id and the `vendor/product` + /// file path stem. + pub board_id: String, + pub display_name: String, + pub manufacturer: String, + /// Human SoC name, e.g. "ESP32-C6". + pub soc: String, + /// Filter family for the provisioning picker: "esp32", "esp32s3", + /// "esp32c6". Kept a plain string so display data never gates on firmware + /// target enums. + pub family: String, + pub flash: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub psram: Option, + /// Approximate street price, USD, for catalog sorting. + pub price_usd: f64, + pub tier: SupportTier, + /// Catalog capability chips ("Wi-Fi 6", "level shifter", ...). + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub capabilities: Vec, + pub blurb: String, + /// Honest support caveat shown with the tier, e.g. "runs when classic + /// ESP32 firmware lands". + #[serde(default, skip_serializing_if = "Option::is_none")] + pub support_note: Option, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub purchase_urls: Vec, + pub hw: BoardDrawing, +} + +/// Support tier shown on the catalog. Definitions live in +/// `lp-core/lpc-hardware/boards/README.md`. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[cfg_attr(feature = "schema-gen", derive(schemars::JsonSchema))] +#[serde(rename_all = "kebab-case")] +pub enum SupportTier { + /// First-class: tested every release. + Gold, + /// Supported: tested occasionally. + Silver, + /// Community-verified: should work. + Bronze, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[cfg_attr(feature = "schema-gen", derive(schemars::JsonSchema))] +pub struct PurchaseUrl { + pub label: String, + pub href: String, +} + +/// The drawing block the row-engine diagram renderer consumes. +/// +/// Geometry is in board-local units; the renderer derives every other size +/// from the pin pitch (`u`). See `spikes/hardware-boards/DESIGN-LANGUAGE.md` +/// (moving to `docs/design/board-diagrams.md` with the renderer milestone). +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[cfg_attr(feature = "schema-gen", derive(schemars::JsonSchema))] +pub struct BoardDrawing { + /// Board width in drawing units. Height derives from pin counts. + pub width: f32, + pub module: DrawnModule, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub usb: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub buttons: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub rgb: Option, + /// Top-edge screw terminals (band rows + leader lines). + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub terminals: Vec, + pub left: Vec, + pub right: Vec, +} + +/// The SoC module / can outline. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[cfg_attr(feature = "schema-gen", derive(schemars::JsonSchema))] +pub struct DrawnModule { + pub x: f32, + pub y: f32, + pub w: f32, + pub h: f32, + pub label: String, + #[serde(default)] + pub antenna: bool, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[cfg_attr(feature = "schema-gen", derive(schemars::JsonSchema))] +pub struct DrawnUsb { + pub x: f32, + pub label: String, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[cfg_attr(feature = "schema-gen", derive(schemars::JsonSchema))] +pub struct DrawnButton { + pub x: f32, + /// Negative y = offset from the board's bottom edge. + pub y: f32, + pub label: String, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[cfg_attr(feature = "schema-gen", derive(schemars::JsonSchema))] +pub struct DrawnRgb { + pub x: f32, + pub y: f32, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub gpio: Option, +} + +/// A top-edge screw terminal. Terminal rows keep a leading name cell because +/// there is no inside-the-board space for their labels. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[cfg_attr(feature = "schema-gen", derive(schemars::JsonSchema))] +pub struct DrawnTerminal { + pub label: String, + pub role: PinRole, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub gpio: Option, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub caps: Vec, +} + +/// One header pin on a left/right rail. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[cfg_attr(feature = "schema-gen", derive(schemars::JsonSchema))] +pub struct DrawnPin { + /// Silkscreen label ("4", "3V3", "D10", "RST"). + pub label: String, + pub role: PinRole, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub gpio: Option, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub caps: Vec, +} + +/// Pin role: drives pad/label color and output eligibility. +/// +/// Output-eligible for LED discovery: `io` and `strap` only. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[cfg_attr(feature = "schema-gen", derive(schemars::JsonSchema))] +#[serde(rename_all = "kebab-case")] +pub enum PinRole { + Pwr5, + Pwr3, + Gnd, + Io, + /// Input-only (classic ESP32 34-39). + IoIn, + /// Boot-strap pin: usable, with care. + Strap, + /// USB D+/D- — driving it drops the link. + Usb, + /// EN / RST. + Ctl, + Nc, + /// Connected but reserved (in-package flash/PSRAM pins on boards that + /// expose them). Never claimable, never discovery-eligible. + Rsvd, +} + +impl PinRole { + /// Whether LED discovery may drive this pin. + pub fn output_eligible(self) -> bool { + matches!(self, Self::Io | Self::Strap) + } +} + +/// A capability cell in the pin's row ("ADC1_3", "Touch4", "U0_TXD"). +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[cfg_attr(feature = "schema-gen", derive(schemars::JsonSchema))] +pub struct PinCap { + pub text: String, + pub kind: CapKind, +} + +/// Cell type: drives the capability cell's color family. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[cfg_attr(feature = "schema-gen", derive(schemars::JsonSchema))] +#[serde(rename_all = "kebab-case")] +pub enum CapKind { + Adc, + Dac, + Touch, + Spi, + I2c, + Uart, + Usb, + Strap, + Pwr, + /// Caution: input-only, XTAL, PSRAM-reserved, JTAG, onboard LED. + Warn, + Note, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum BoardDisplayError { + Parse { message: String }, + Invalid { message: String }, +} + +impl fmt::Display for BoardDisplayError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Parse { message } => write!(f, "display manifest parse error: {message}"), + Self::Invalid { message } => write!(f, "invalid display manifest: {message}"), + } + } +} + +impl std::error::Error for BoardDisplayError {} + +impl BoardDisplayFile { + pub fn read_json(json_text: &str) -> Result { + let file: Self = serde_json::from_str(json_text).map_err(|error| { + BoardDisplayError::Parse { + message: error.to_string(), + } + })?; + file.validate()?; + Ok(file) + } + + /// Structural checks that don't need the runtime manifest. Cross-file + /// consistency (board_id vs path, runtime agreement) lives in the drift + /// tests, which see the filesystem. + pub fn validate(&self) -> Result<(), BoardDisplayError> { + let invalid = |message: String| Err(BoardDisplayError::Invalid { message }); + if self.board_id.split('/').count() != 2 + || self.board_id.split('/').any(|part| part.trim().is_empty()) + { + return invalid(format!( + "board_id must be vendor/product, got {:?}", + self.board_id + )); + } + if self.display_name.trim().is_empty() { + return invalid("display_name must not be empty".into()); + } + if !self.price_usd.is_finite() || self.price_usd < 0.0 { + return invalid(format!("price_usd must be non-negative: {}", self.price_usd)); + } + for url in &self.purchase_urls { + if !(url.href.starts_with("https://") || url.href.starts_with("http://")) { + return invalid(format!("purchase url must be http(s): {}", url.href)); + } + } + let mut seen = Vec::new(); + for pin in self.pins() { + if pin.label.trim().is_empty() { + return invalid("pin label must not be empty".into()); + } + if let Some(gpio) = pin.gpio { + if seen.contains(&gpio) { + return invalid(format!("duplicate gpio {gpio} in pin tables")); + } + seen.push(gpio); + } + // Numeric silkscreen labels ARE the gpio number; a mismatch (or a + // missing gpio) is a physical-damage class of authoring mistake. + // Named pins (D4, LED1) may omit gpio when unverified. + if let Ok(numeric) = pin.label.parse::() { + if pin.gpio != Some(numeric) { + return invalid(format!( + "pin labeled {:?} must carry gpio {numeric}, got {:?}", + pin.label, pin.gpio + )); + } + } + } + for terminal in &self.hw.terminals { + if let Some(gpio) = terminal.gpio { + if seen.contains(&gpio) { + return invalid(format!("duplicate gpio {gpio} (terminal {})", terminal.label)); + } + seen.push(gpio); + } + } + Ok(()) + } + + /// All rail pins, left then right (terminals excluded). + pub fn pins(&self) -> impl Iterator { + self.hw.left.iter().chain(self.hw.right.iter()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn minimal() -> BoardDisplayFile { + BoardDisplayFile { + board_id: "vendor/product".into(), + display_name: "Board".into(), + manufacturer: "Vendor".into(), + soc: "ESP32-C6".into(), + family: "esp32c6".into(), + flash: "8 MB".into(), + psram: None, + price_usd: 9.0, + tier: SupportTier::Bronze, + capabilities: vec![], + blurb: "A board.".into(), + support_note: None, + purchase_urls: vec![], + hw: BoardDrawing { + width: 100.0, + module: DrawnModule { + x: 10.0, + y: 8.0, + w: 80.0, + h: 60.0, + label: "SOC".into(), + antenna: true, + }, + usb: vec![], + buttons: vec![], + rgb: None, + terminals: vec![], + left: vec![DrawnPin { + label: "4".into(), + role: PinRole::Io, + gpio: Some(4), + caps: vec![], + }], + right: vec![], + }, + } + } + + #[test] + fn accepts_minimal() { + minimal().validate().unwrap(); + } + + #[test] + fn rejects_duplicate_gpio() { + let mut file = minimal(); + file.hw.right = file.hw.left.clone(); + assert!(matches!( + file.validate(), + Err(BoardDisplayError::Invalid { .. }) + )); + } + + #[test] + fn rejects_bad_board_id() { + let mut file = minimal(); + file.board_id = "no-slash".into(); + assert!(file.validate().is_err()); + } + + #[test] + fn output_eligibility_is_io_and_strap_only() { + assert!(PinRole::Io.output_eligible()); + assert!(PinRole::Strap.output_eligible()); + for role in [ + PinRole::Pwr5, + PinRole::Pwr3, + PinRole::Gnd, + PinRole::IoIn, + PinRole::Usb, + PinRole::Ctl, + PinRole::Nc, + PinRole::Rsvd, + ] { + assert!(!role.output_eligible(), "{role:?} must not be eligible"); + } + } + + #[test] + fn round_trips_json() { + let file = minimal(); + let json = serde_json::to_string_pretty(&file).unwrap(); + let back = BoardDisplayFile::read_json(&json).unwrap(); + assert_eq!(file, back); + } +} diff --git a/lp-app/lpa-boards/src/lib.rs b/lp-app/lpa-boards/src/lib.rs new file mode 100644 index 000000000..d97094344 --- /dev/null +++ b/lp-app/lpa-boards/src/lib.rs @@ -0,0 +1,21 @@ +//! Board display metadata: the catalog/drawing layer for supported boards. +//! +//! Boundary: display data in, nothing out. This crate owns the +//! `boards//.display.json` sidecar format — catalog identity +//! (tier, price, purchase links), and the drawing block the board diagram +//! renderer consumes. It knows nothing about projects, devices, routes, or the +//! runtime [`lpc-hardware`] manifest types; the two stay consistent through +//! the drift tests in `tests/manifest_drift.rs`, not through code coupling. +//! +//! The runtime board manifest (`boards//.json`) is compiled +//! into firmware and stays flash-lean; everything presentation-only lives +//! here, app-side. See `docs/adr/2026-07-31-board-display-metadata-split.md`. + +mod catalog; +mod display_manifest; + +pub use catalog::{all_boards, board_by_id, DISPLAY_MANIFEST_SOURCES}; +pub use display_manifest::{ + BoardDisplayError, BoardDisplayFile, BoardDrawing, CapKind, DrawnButton, DrawnModule, + DrawnPin, DrawnRgb, DrawnTerminal, DrawnUsb, PinCap, PinRole, PurchaseUrl, SupportTier, +}; diff --git a/lp-app/lpa-boards/tests/manifest_drift.rs b/lp-app/lpa-boards/tests/manifest_drift.rs new file mode 100644 index 000000000..a47b08fbf --- /dev/null +++ b/lp-app/lpa-boards/tests/manifest_drift.rs @@ -0,0 +1,209 @@ +//! Drift gate between display sidecars and runtime board manifests. +//! +//! The runtime manifest (`/.json`, compiled into firmware) is +//! the authority on claimable resources; the display sidecar +//! (`/.display.json`) is presentation. These tests keep the +//! two from silently disagreeing — a wrong GPIO in either file is a +//! physical-damage class of mistake (see boards/README.md). + +use std::collections::BTreeMap; +use std::fs; +use std::path::PathBuf; + +use lpa_boards::{BoardDisplayFile, PinRole}; +use lpc_hardware::manifest::hw_manifest_file::{HardwareBoardLabelFile, HardwareResourceFile}; +use lpc_hardware::{HardwareBoardLabelStatus, HardwareManifestFile}; + +/// Boards with display sidecars but deliberately no runtime manifest, with +/// the reason. Shrink this list as firmware targets land. +const DISPLAY_ONLY: &[(&str, &str)] = &[ + ( + "espressif/esp32-devkitc-v4", + "classic ESP32 (v3) has no HardwareTarget yet", + ), + ( + "quinled/dig-uno", + "classic ESP32 (v3) has no HardwareTarget yet", + ), +]; + +fn boards_dir() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../lp-core/lpc-hardware/boards") +} + +/// `board_id -> (display?, runtime?)` walked from the boards directory. +fn manifest_pairs() -> BTreeMap, Option)> { + let mut pairs: BTreeMap, Option)> = + BTreeMap::new(); + for vendor_entry in fs::read_dir(boards_dir()).expect("boards dir") { + let vendor_dir = vendor_entry.expect("vendor entry").path(); + if !vendor_dir.is_dir() { + continue; + } + let vendor = vendor_dir.file_name().unwrap().to_string_lossy().to_string(); + for file_entry in fs::read_dir(&vendor_dir).expect("vendor dir") { + let path = file_entry.expect("file entry").path(); + let name = path.file_name().unwrap().to_string_lossy().to_string(); + let text = fs::read_to_string(&path).expect("read manifest"); + if let Some(stem) = name.strip_suffix(".display.json") { + let display = BoardDisplayFile::read_json(&text) + .unwrap_or_else(|error| panic!("{vendor}/{name}: {error}")); + assert_eq!( + display.board_id, + format!("{vendor}/{stem}"), + "{vendor}/{name}: board_id must match its path" + ); + let key = display.board_id.clone(); + pairs.entry(key).or_default().0 = Some(display); + } else if let Some(stem) = name.strip_suffix(".json") { + let runtime = HardwareManifestFile::read_json(&text) + .unwrap_or_else(|error| panic!("{vendor}/{name}: {error}")); + runtime + .validate() + .unwrap_or_else(|error| panic!("{vendor}/{name}: {error}")); + assert_eq!( + runtime.id, + format!("{vendor}/{stem}"), + "{vendor}/{name}: id must match its path" + ); + let key = runtime.id.clone(); + pairs.entry(key).or_default().1 = Some(runtime); + } + } + } + pairs +} + +#[test] +fn every_board_has_both_files_or_a_recorded_reason() { + for (board_id, (display, runtime)) in manifest_pairs() { + assert!( + display.is_some(), + "{board_id}: runtime manifest has no display sidecar — the catalog can't show it" + ); + let allowlisted = DISPLAY_ONLY.iter().any(|(id, _)| *id == board_id); + match (&runtime, allowlisted) { + (None, false) => panic!( + "{board_id}: display sidecar has no runtime manifest and no DISPLAY_ONLY reason" + ), + (Some(_), true) => panic!( + "{board_id}: has a runtime manifest — remove it from DISPLAY_ONLY" + ), + _ => {} + } + } +} + +#[test] +fn embedded_catalog_matches_the_directory() { + let pairs = manifest_pairs(); + let embedded: Vec<&str> = lpa_boards::DISPLAY_MANIFEST_SOURCES + .iter() + .map(|(id, _)| *id) + .collect(); + for board_id in pairs.keys() { + assert!( + embedded.contains(&board_id.as_str()), + "{board_id}: display sidecar exists on disk but is not embedded in lpa_boards::catalog" + ); + } + assert_eq!( + embedded.len(), + pairs.len(), + "embedded catalog lists a board with no on-disk display sidecar" + ); + // And the embedded bytes are the on-disk bytes (include_str! path typos). + for (board_id, source) in lpa_boards::DISPLAY_MANIFEST_SOURCES { + let path = boards_dir().join(format!("{board_id}.display.json")); + let on_disk = fs::read_to_string(&path) + .unwrap_or_else(|error| panic!("{board_id}: reading {path:?}: {error}")); + assert_eq!( + *source, on_disk, + "{board_id}: embedded source differs from the on-disk sidecar" + ); + } +} + +#[test] +fn display_pins_agree_with_runtime_manifests() { + for (board_id, (display, runtime)) in manifest_pairs() { + let (Some(display), Some(runtime)) = (display, runtime) else { + continue; + }; + let label_map: BTreeMap<&str, &HardwareBoardLabelFile> = runtime + .board_label + .iter() + .map(|entry| (entry.label.as_str(), entry)) + .collect(); + let gpio_addresses: BTreeMap = runtime + .gpio + .iter() + .filter_map(|resource| { + resource + .address + .strip_prefix("/gpio/") + .and_then(|n| n.parse().ok()) + .map(|n| (n, resource)) + }) + .collect(); + + let terminals = display.hw.terminals.iter().map(|t| { + (t.label.as_str(), t.role, t.gpio, Vec::new()) + }); + let pins = display.pins().map(|p| { + ( + p.label.as_str(), + p.role, + p.gpio, + p.caps.iter().map(|c| c.kind).collect::>(), + ) + }); + + for (label, role, gpio, cap_kinds) in pins.chain(terminals) { + // Rule 1: a runtime board_label with the same silkscreen label is + // authoritative for the gpio mapping (or its absence). + if let Some(entry) = label_map.get(label) { + match entry.status { + Some(HardwareBoardLabelStatus::NotFound) => assert!( + gpio.is_none(), + "{board_id}: pin {label} claims gpio {gpio:?} but calibration recorded not-found" + ), + _ => { + if let Some(mapped) = entry + .gpio + .as_deref() + .and_then(|address| address.strip_prefix("/gpio/")) + .and_then(|n| n.parse::().ok()) + { + assert_eq!( + gpio, + Some(mapped), + "{board_id}: pin {label} disagrees with the runtime board_label mapping" + ); + } + } + } + } + let Some(gpio) = gpio else { continue }; + match gpio_addresses.get(&u32::from(gpio)) { + // Rule 2: a display pin whose gpio the runtime deliberately + // omits must present as non-claimable. + None => assert!( + !role.output_eligible(), + "{board_id}: pin {label} (gpio {gpio}) is {role:?} but the runtime manifest \ + omits /gpio/{gpio} — either add the resource or mark the pin non-eligible" + ), + // Rule 3: a runtime-reserved gpio must not read as a plain io + // pin in the catalog. + Some(resource) if resource.reserved_reason.is_some() => assert!( + role != PinRole::Io + || cap_kinds.contains(&lpa_boards::CapKind::Warn), + "{board_id}: pin {label} (gpio {gpio}) is reserved in the runtime manifest \ + ({:?}) but displays as plain io with no warn cap", + resource.reserved_reason + ), + Some(_) => {} + } + } + } +} diff --git a/lp-cli/Cargo.toml b/lp-cli/Cargo.toml index 415cf03b2..527435f6a 100644 --- a/lp-cli/Cargo.toml +++ b/lp-cli/Cargo.toml @@ -30,6 +30,7 @@ lpa-server = { path = "../lp-app/lpa-server" } lp-gfx-lpvm = { path = "../lp-gfx/lp-gfx-lpvm" } lpa-link = { path = "../lp-app/lpa-link", features = ["host-process", "host-serial-esp32"] } lpc-hardware = { path = "../lp-core/lpc-hardware", features = ["schema-gen"] } +lpa-boards = { path = "../lp-app/lpa-boards", features = ["schema-gen"] } schemars = { workspace = true } lpc-shared = { path = "../lp-core/lpc-shared" } lpfs = { path = "../lp-base/lpfs", features = ["std"] } diff --git a/lp-cli/src/commands/hardware/manifest/board_manifest_store.rs b/lp-cli/src/commands/hardware/manifest/board_manifest_store.rs index 81c7d163e..ecfa41a18 100644 --- a/lp-cli/src/commands/hardware/manifest/board_manifest_store.rs +++ b/lp-cli/src/commands/hardware/manifest/board_manifest_store.rs @@ -40,6 +40,15 @@ impl BoardManifestStore { if path.extension().and_then(|ext| ext.to_str()) != Some("json") { continue; } + // `.display.json` sidecars are catalog metadata owned + // by lpa-boards, not runtime manifests. + if path + .file_name() + .and_then(|name| name.to_str()) + .is_some_and(|name| name.ends_with(".display.json")) + { + continue; + } let manifest = self.load_path(&path)?; out.push(BoardManifestSummary { id: manifest.id, diff --git a/lp-cli/src/commands/schema/generate.rs b/lp-cli/src/commands/schema/generate.rs index 1f77ddb9f..45adf761a 100644 --- a/lp-cli/src/commands/schema/generate.rs +++ b/lp-cli/src/commands/schema/generate.rs @@ -12,6 +12,8 @@ //! registered node kind, discriminated by top-level `kind`. //! - `hardware.schema.json` — the plain-serde board manifest //! ([`lpc_hardware::HardwareManifestFile`]) via `schemars`. +//! - `board-display.schema.json` — the board display sidecar +//! ([`lpa_boards::BoardDisplayFile`], `*.display.json`) via `schemars`. //! - `shapes/.json` — the serialized [`SlotShape`] for each //! registered static shape (the source-of-truth dump a future format //! upgrader consumes), plus `shapes/_index.json` mapping registry shape @@ -79,6 +81,10 @@ fn generate_outputs() -> Result> { String::from("hardware.schema.json"), render_schema(hardware_schema()?, "hardware.schema.json")?, ); + outputs.insert( + String::from("board-display.schema.json"), + render_schema(board_display_schema()?, "board-display.schema.json")?, + ); let mut index = Map::new(); for id in lpc_model::slot_shapes::static_slot_shape_ids() { @@ -202,6 +208,14 @@ fn hardware_schema() -> Result { serde_json::to_value(&schema).context("serializing hardware manifest schema") } +/// Schema for `*.display.json` board display sidecars. Plain serde like the +/// hardware manifest; the types live app-side in `lpa-boards` so the runtime +/// manifest (compiled into firmware) never carries display data. +fn board_display_schema() -> Result { + let schema = schemars::schema_for!(lpa_boards::BoardDisplayFile); + serde_json::to_value(&schema).context("serializing board display schema") +} + /// File stem for a shape dump: the registry name with Rust path separators /// flattened (`::` → `.`) so the checked-in file names stay portable. fn shape_file_stem(name: &str) -> String { diff --git a/lp-core/lpc-hardware/boards/espressif/esp32-c6-devkitc-1.display.json b/lp-core/lpc-hardware/boards/espressif/esp32-c6-devkitc-1.display.json new file mode 100644 index 000000000..57f152106 --- /dev/null +++ b/lp-core/lpc-hardware/boards/espressif/esp32-c6-devkitc-1.display.json @@ -0,0 +1,59 @@ +{ + "board_id": "espressif/esp32-c6-devkitc-1", + "display_name": "ESP32-C6-DevKitC-1", + "manufacturer": "Espressif", + "soc": "ESP32-C6", + "family": "esp32c6", + "flash": "8 MB", + "price_usd": 9.0, + "tier": "gold", + "capabilities": ["Wi-Fi 6", "BLE 5", "802.15.4", "onboard RGB pixel"], + "blurb": "The reference C6 devkit and our best-tested board. RISC-V, Wi-Fi 6, cheap — the easiest way to start.", + "purchase_urls": [ + { "label": "Espressif", "href": "https://www.espressif.com/en/products/devkits/esp32-c6-devkitc-1" }, + { "label": "DigiKey", "href": "https://www.digikey.com/en/products/detail/espressif-systems/ESP32-C6-DEVKITC-1-N8/17728861" } + ], + "hw": { + "width": 112, + "module": { "x": 16, "y": 8, "w": 80, "h": 66, "label": "ESP32-C6", "antenna": true }, + "usb": [ { "x": 30, "label": "USB" }, { "x": 76, "label": "UART" } ], + "buttons": [ { "x": 24, "y": -20, "label": "RST" }, { "x": 88, "y": -20, "label": "BOOT" } ], + "rgb": { "x": 56, "y": 100, "gpio": 8 }, + "left": [ + { "label": "3V3", "role": "pwr3" }, + { "label": "RST", "role": "ctl" }, + { "label": "4", "role": "io", "gpio": 4, "caps": [ { "text": "ADC1_4", "kind": "adc" } ] }, + { "label": "5", "role": "io", "gpio": 5, "caps": [ { "text": "ADC1_5", "kind": "adc" } ] }, + { "label": "6", "role": "io", "gpio": 6, "caps": [ { "text": "ADC1_6", "kind": "adc" } ] }, + { "label": "7", "role": "io", "gpio": 7 }, + { "label": "0", "role": "io", "gpio": 0, "caps": [ { "text": "ADC1_0", "kind": "adc" } ] }, + { "label": "1", "role": "io", "gpio": 1, "caps": [ { "text": "ADC1_1", "kind": "adc" } ] }, + { "label": "8", "role": "strap", "gpio": 8, "caps": [ { "text": "RGB LED", "kind": "warn" }, { "text": "strap", "kind": "strap" } ] }, + { "label": "10", "role": "io", "gpio": 10 }, + { "label": "11", "role": "io", "gpio": 11 }, + { "label": "2", "role": "io", "gpio": 2, "caps": [ { "text": "ADC1_2", "kind": "adc" } ] }, + { "label": "3", "role": "io", "gpio": 3, "caps": [ { "text": "ADC1_3", "kind": "adc" } ] }, + { "label": "5V", "role": "pwr5" }, + { "label": "GND", "role": "gnd" }, + { "label": "NC", "role": "nc" } + ], + "right": [ + { "label": "GND", "role": "gnd" }, + { "label": "TX", "role": "io", "gpio": 16, "caps": [ { "text": "U0_TXD", "kind": "uart" } ] }, + { "label": "RX", "role": "io", "gpio": 17, "caps": [ { "text": "U0_RXD", "kind": "uart" } ] }, + { "label": "15", "role": "io", "gpio": 15 }, + { "label": "23", "role": "io", "gpio": 23 }, + { "label": "22", "role": "io", "gpio": 22 }, + { "label": "21", "role": "io", "gpio": 21 }, + { "label": "20", "role": "io", "gpio": 20 }, + { "label": "19", "role": "io", "gpio": 19 }, + { "label": "18", "role": "io", "gpio": 18 }, + { "label": "9", "role": "strap", "gpio": 9, "caps": [ { "text": "BOOT", "kind": "strap" } ] }, + { "label": "13", "role": "usb", "gpio": 13, "caps": [ { "text": "USB_D+", "kind": "usb" } ] }, + { "label": "12", "role": "usb", "gpio": 12, "caps": [ { "text": "USB_D-", "kind": "usb" } ] }, + { "label": "GND", "role": "gnd" }, + { "label": "NC", "role": "nc" }, + { "label": "NC", "role": "nc" } + ] + } +} diff --git a/lp-core/lpc-hardware/boards/espressif/esp32-c6-devkitc-1.json b/lp-core/lpc-hardware/boards/espressif/esp32-c6-devkitc-1.json new file mode 100644 index 000000000..ec24bf39a --- /dev/null +++ b/lp-core/lpc-hardware/boards/espressif/esp32-c6-devkitc-1.json @@ -0,0 +1,192 @@ +{ + "id": "espressif/esp32-c6-devkitc-1", + "target": "esp32c6", + "vendor": "espressif", + "product": "ESP32-C6-DevKitC-1", + "description": "Espressif ESP32-C6-DevKitC-1 board profile (v1.2 pin layout). Header silkscreen labels are the GPIO numbers themselves; TX/RX are GPIO16/17. Sourced from Espressif's board documentation, not yet hardware-calibrated.", + "url": "https://docs.espressif.com/projects/esp-dev-kits/en/latest/esp32c6/esp32-c6-devkitc-1/index.html", + "board_label": [ + { + "label": "TX", + "gpio": "/gpio/16", + "status": "assigned", + "note": "UART0 TXD per the v1.2 pin layout; not yet hardware-verified" + }, + { + "label": "RX", + "gpio": "/gpio/17", + "status": "assigned", + "note": "UART0 RXD per the v1.2 pin layout; not yet hardware-verified" + }, + { + "label": "RGB", + "gpio": "/gpio/8", + "status": "assigned", + "note": "onboard WS2812 per Espressif docs; also a boot-strap pin" + }, + { + "label": "BOOT", + "gpio": "/gpio/9", + "status": "assigned", + "note": "BOOT button; boot-strap pin" + } + ], + "gpio": [ + { + "address": "/gpio/0", + "display_label": "GPIO0", + "capabilities": ["gpio-output", "gpio-input"], + "aliases": ["IO0"] + }, + { + "address": "/gpio/1", + "display_label": "GPIO1", + "capabilities": ["gpio-output", "gpio-input"], + "aliases": ["IO1"] + }, + { + "address": "/gpio/2", + "display_label": "GPIO2", + "capabilities": ["gpio-output", "gpio-input"], + "aliases": ["IO2"] + }, + { + "address": "/gpio/3", + "display_label": "GPIO3", + "capabilities": ["gpio-output", "gpio-input"], + "aliases": ["IO3"] + }, + { + "address": "/gpio/4", + "display_label": "GPIO4", + "capabilities": ["gpio-output", "gpio-input"], + "aliases": ["IO4"] + }, + { + "address": "/gpio/5", + "display_label": "GPIO5", + "capabilities": ["gpio-output", "gpio-input"], + "aliases": ["IO5"] + }, + { + "address": "/gpio/6", + "display_label": "GPIO6", + "capabilities": ["gpio-output", "gpio-input"], + "aliases": ["IO6"] + }, + { + "address": "/gpio/7", + "display_label": "GPIO7", + "capabilities": ["gpio-output", "gpio-input"], + "aliases": ["IO7"] + }, + { + "address": "/gpio/8", + "display_label": "GPIO8", + "capabilities": ["gpio-output", "gpio-input"], + "aliases": ["IO8"], + "location": "onboard WS2812 RGB LED; boot-strap pin" + }, + { + "address": "/gpio/9", + "display_label": "GPIO9", + "capabilities": ["gpio-output", "gpio-input"], + "aliases": ["IO9"], + "location": "BOOT button; boot-strap pin" + }, + { + "address": "/gpio/10", + "display_label": "GPIO10", + "capabilities": ["gpio-output", "gpio-input"], + "aliases": ["IO10"] + }, + { + "address": "/gpio/11", + "display_label": "GPIO11", + "capabilities": ["gpio-output", "gpio-input"], + "aliases": ["IO11"] + }, + { + "address": "/gpio/12", + "display_label": "GPIO12", + "capabilities": ["gpio-output", "gpio-input"], + "aliases": ["IO12"], + "reserved_reason": "USB-Serial-JTAG D- — driving it drops the link the board is flashed over" + }, + { + "address": "/gpio/13", + "display_label": "GPIO13", + "capabilities": ["gpio-output", "gpio-input"], + "aliases": ["IO13"], + "reserved_reason": "USB-Serial-JTAG D+ — driving it drops the link the board is flashed over" + }, + { + "address": "/gpio/15", + "display_label": "GPIO15", + "capabilities": ["gpio-output", "gpio-input"], + "aliases": ["IO15"] + }, + { + "address": "/gpio/16", + "display_label": "TX", + "capabilities": ["gpio-output", "gpio-input"], + "aliases": ["IO16", "GPIO16"], + "location": "UART0 TXD to the UART bridge" + }, + { + "address": "/gpio/17", + "display_label": "RX", + "capabilities": ["gpio-output", "gpio-input"], + "aliases": ["IO17", "GPIO17"], + "location": "UART0 RXD to the UART bridge" + }, + { + "address": "/gpio/18", + "display_label": "GPIO18", + "capabilities": ["gpio-output", "gpio-input"], + "aliases": ["IO18"] + }, + { + "address": "/gpio/19", + "display_label": "GPIO19", + "capabilities": ["gpio-output", "gpio-input"], + "aliases": ["IO19"] + }, + { + "address": "/gpio/20", + "display_label": "GPIO20", + "capabilities": ["gpio-output", "gpio-input"], + "aliases": ["IO20"] + }, + { + "address": "/gpio/21", + "display_label": "GPIO21", + "capabilities": ["gpio-output", "gpio-input"], + "aliases": ["IO21"] + }, + { + "address": "/gpio/22", + "display_label": "GPIO22", + "capabilities": ["gpio-output", "gpio-input"], + "aliases": ["IO22"] + }, + { + "address": "/gpio/23", + "display_label": "GPIO23", + "capabilities": ["gpio-output", "gpio-input"], + "aliases": ["IO23"] + } + ], + "resource": [ + { + "address": "/rmt/ws281x0", + "display_label": "RMT WS281x 0", + "capabilities": ["rmt", "ws281x-output"] + }, + { + "address": "/radio/0", + "display_label": "ESP-NOW Radio 0", + "capabilities": ["radio"] + } + ] +} diff --git a/lp-core/lpc-hardware/boards/espressif/esp32-devkitc-v4.display.json b/lp-core/lpc-hardware/boards/espressif/esp32-devkitc-v4.display.json new file mode 100644 index 000000000..7d1011481 --- /dev/null +++ b/lp-core/lpc-hardware/boards/espressif/esp32-devkitc-v4.display.json @@ -0,0 +1,65 @@ +{ + "board_id": "espressif/esp32-devkitc-v4", + "display_name": "ESP32 DevKitC v4", + "manufacturer": "Espressif (and clones)", + "soc": "ESP32", + "family": "esp32", + "flash": "4 MB", + "price_usd": 10.0, + "tier": "bronze", + "capabilities": ["Wi-Fi", "BT classic", "everywhere"], + "blurb": "The classic WROOM-32 board. Millions exist; clones vary. The board classic-ESP32 support will target.", + "support_note": "Runs when classic ESP32 (v3) firmware lands — in progress.", + "purchase_urls": [ + { "label": "Espressif", "href": "https://www.espressif.com/en/products/devkits/esp32-devkitc" }, + { "label": "AliExpress", "href": "https://www.aliexpress.com/w/wholesale-esp32-devkitc.html" } + ], + "hw": { + "width": 116, + "module": { "x": 18, "y": 8, "w": 80, "h": 70, "label": "WROOM-32", "antenna": true }, + "usb": [ { "x": 42, "label": "USB" } ], + "buttons": [ { "x": 26, "y": -20, "label": "EN" }, { "x": 90, "y": -20, "label": "BOOT" } ], + "left": [ + { "label": "3V3", "role": "pwr3" }, + { "label": "EN", "role": "ctl" }, + { "label": "36", "role": "io-in", "gpio": 36, "caps": [ { "text": "ADC1_0", "kind": "adc" }, { "text": "SensVP", "kind": "adc" }, { "text": "in-only", "kind": "warn" } ] }, + { "label": "39", "role": "io-in", "gpio": 39, "caps": [ { "text": "ADC1_3", "kind": "adc" }, { "text": "SensVN", "kind": "adc" }, { "text": "in-only", "kind": "warn" } ] }, + { "label": "34", "role": "io-in", "gpio": 34, "caps": [ { "text": "ADC1_6", "kind": "adc" }, { "text": "in-only", "kind": "warn" } ] }, + { "label": "35", "role": "io-in", "gpio": 35, "caps": [ { "text": "ADC1_7", "kind": "adc" }, { "text": "in-only", "kind": "warn" } ] }, + { "label": "32", "role": "io", "gpio": 32, "caps": [ { "text": "ADC1_4", "kind": "adc" }, { "text": "Touch9", "kind": "touch" }, { "text": "XTAL32", "kind": "warn" } ] }, + { "label": "33", "role": "io", "gpio": 33, "caps": [ { "text": "ADC1_5", "kind": "adc" }, { "text": "Touch8", "kind": "touch" }, { "text": "XTAL32", "kind": "warn" } ] }, + { "label": "25", "role": "io", "gpio": 25, "caps": [ { "text": "DAC1", "kind": "dac" }, { "text": "ADC2_8", "kind": "adc" } ] }, + { "label": "26", "role": "io", "gpio": 26, "caps": [ { "text": "DAC2", "kind": "dac" }, { "text": "ADC2_9", "kind": "adc" } ] }, + { "label": "27", "role": "io", "gpio": 27, "caps": [ { "text": "ADC2_7", "kind": "adc" }, { "text": "Touch7", "kind": "touch" } ] }, + { "label": "14", "role": "io", "gpio": 14, "caps": [ { "text": "ADC2_6", "kind": "adc" }, { "text": "Touch6", "kind": "touch" }, { "text": "HSPI_CLK", "kind": "spi" } ] }, + { "label": "12", "role": "strap", "gpio": 12, "caps": [ { "text": "ADC2_5", "kind": "adc" }, { "text": "Touch5", "kind": "touch" }, { "text": "HSPI_MISO", "kind": "spi" } ] }, + { "label": "GND", "role": "gnd" }, + { "label": "13", "role": "io", "gpio": 13, "caps": [ { "text": "ADC2_4", "kind": "adc" }, { "text": "Touch4", "kind": "touch" }, { "text": "HSPI_MOSI", "kind": "spi" } ] }, + { "label": "SD2", "role": "rsvd", "gpio": 9, "caps": [ { "text": "flash", "kind": "warn" } ] }, + { "label": "SD3", "role": "rsvd", "gpio": 10, "caps": [ { "text": "flash", "kind": "warn" } ] }, + { "label": "CMD", "role": "rsvd", "gpio": 11, "caps": [ { "text": "flash", "kind": "warn" } ] }, + { "label": "5V", "role": "pwr5" } + ], + "right": [ + { "label": "GND", "role": "gnd" }, + { "label": "23", "role": "io", "gpio": 23, "caps": [ { "text": "VSPI_MOSI", "kind": "spi" } ] }, + { "label": "22", "role": "io", "gpio": 22, "caps": [ { "text": "SCL", "kind": "i2c" } ] }, + { "label": "TX0", "role": "io", "gpio": 1, "caps": [ { "text": "U0_TXD", "kind": "uart" } ] }, + { "label": "RX0", "role": "io", "gpio": 3, "caps": [ { "text": "U0_RXD", "kind": "uart" } ] }, + { "label": "21", "role": "io", "gpio": 21, "caps": [ { "text": "SDA", "kind": "i2c" } ] }, + { "label": "GND", "role": "gnd" }, + { "label": "19", "role": "io", "gpio": 19, "caps": [ { "text": "VSPI_MISO", "kind": "spi" } ] }, + { "label": "18", "role": "io", "gpio": 18, "caps": [ { "text": "VSPI_CLK", "kind": "spi" } ] }, + { "label": "5", "role": "strap", "gpio": 5, "caps": [ { "text": "VSPI_CS", "kind": "spi" }, { "text": "strap", "kind": "strap" } ] }, + { "label": "17", "role": "io", "gpio": 17, "caps": [ { "text": "U2_TXD", "kind": "uart" } ] }, + { "label": "16", "role": "io", "gpio": 16, "caps": [ { "text": "U2_RXD", "kind": "uart" } ] }, + { "label": "4", "role": "io", "gpio": 4, "caps": [ { "text": "ADC2_0", "kind": "adc" }, { "text": "Touch0", "kind": "touch" } ] }, + { "label": "0", "role": "strap", "gpio": 0, "caps": [ { "text": "ADC2_1", "kind": "adc" }, { "text": "Touch1", "kind": "touch" }, { "text": "BOOT", "kind": "strap" } ] }, + { "label": "2", "role": "strap", "gpio": 2, "caps": [ { "text": "ADC2_2", "kind": "adc" }, { "text": "Touch2", "kind": "touch" }, { "text": "strap", "kind": "strap" } ] }, + { "label": "15", "role": "strap", "gpio": 15, "caps": [ { "text": "ADC2_3", "kind": "adc" }, { "text": "Touch3", "kind": "touch" }, { "text": "HSPI_CS", "kind": "spi" } ] }, + { "label": "SD1", "role": "rsvd", "gpio": 8, "caps": [ { "text": "flash", "kind": "warn" } ] }, + { "label": "SD0", "role": "rsvd", "gpio": 7, "caps": [ { "text": "flash", "kind": "warn" } ] }, + { "label": "CLK", "role": "rsvd", "gpio": 6, "caps": [ { "text": "flash", "kind": "warn" } ] } + ] + } +} diff --git a/lp-core/lpc-hardware/boards/espressif/esp32-s3-devkitc-1.display.json b/lp-core/lpc-hardware/boards/espressif/esp32-s3-devkitc-1.display.json new file mode 100644 index 000000000..70cbab913 --- /dev/null +++ b/lp-core/lpc-hardware/boards/espressif/esp32-s3-devkitc-1.display.json @@ -0,0 +1,73 @@ +{ + "board_id": "espressif/esp32-s3-devkitc-1", + "display_name": "ESP32-S3-DevKitC-1", + "manufacturer": "Espressif", + "soc": "ESP32-S3", + "family": "esp32s3", + "flash": "8 MB", + "psram": "8 MB", + "price_usd": 15.0, + "tier": "silver", + "capabilities": ["Wi-Fi", "BLE 5", "USB-OTG", "onboard RGB pixel"], + "blurb": "The reference S3 devkit. Big pin headers and plenty of GPIO; the board our S3 support is brought up on.", + "support_note": "S3 support is new — gold once it joins the every-release test loop.", + "purchase_urls": [ + { "label": "Espressif", "href": "https://www.espressif.com/en/products/devkits/esp32-s3-devkitc-1" }, + { "label": "Adafruit", "href": "https://www.adafruit.com/product/5312" } + ], + "hw": { + "width": 120, + "module": { "x": 18, "y": 8, "w": 84, "h": 74, "label": "ESP32-S3", "antenna": true }, + "usb": [ { "x": 32, "label": "USB" }, { "x": 82, "label": "UART" } ], + "buttons": [ { "x": 26, "y": -20, "label": "RST" }, { "x": 94, "y": -20, "label": "BOOT" } ], + "rgb": { "x": 60, "y": 130, "gpio": 48 }, + "left": [ + { "label": "3V3", "role": "pwr3" }, + { "label": "3V3", "role": "pwr3" }, + { "label": "RST", "role": "ctl" }, + { "label": "4", "role": "io", "gpio": 4, "caps": [ { "text": "ADC1_3", "kind": "adc" }, { "text": "Touch4", "kind": "touch" } ] }, + { "label": "5", "role": "io", "gpio": 5, "caps": [ { "text": "ADC1_4", "kind": "adc" }, { "text": "Touch5", "kind": "touch" } ] }, + { "label": "6", "role": "io", "gpio": 6, "caps": [ { "text": "ADC1_5", "kind": "adc" }, { "text": "Touch6", "kind": "touch" } ] }, + { "label": "7", "role": "io", "gpio": 7, "caps": [ { "text": "ADC1_6", "kind": "adc" }, { "text": "Touch7", "kind": "touch" } ] }, + { "label": "15", "role": "io", "gpio": 15, "caps": [ { "text": "ADC2_4", "kind": "adc" }, { "text": "XTAL32P", "kind": "warn" } ] }, + { "label": "16", "role": "io", "gpio": 16, "caps": [ { "text": "ADC2_5", "kind": "adc" }, { "text": "XTAL32N", "kind": "warn" } ] }, + { "label": "17", "role": "io", "gpio": 17, "caps": [ { "text": "ADC2_6", "kind": "adc" } ] }, + { "label": "18", "role": "io", "gpio": 18, "caps": [ { "text": "ADC2_7", "kind": "adc" } ] }, + { "label": "8", "role": "io", "gpio": 8, "caps": [ { "text": "ADC1_7", "kind": "adc" }, { "text": "Touch8", "kind": "touch" } ] }, + { "label": "3", "role": "strap", "gpio": 3, "caps": [ { "text": "JTAG sel", "kind": "strap" } ] }, + { "label": "46", "role": "strap", "gpio": 46, "caps": [ { "text": "strap", "kind": "strap" } ] }, + { "label": "9", "role": "io", "gpio": 9, "caps": [ { "text": "ADC1_8", "kind": "adc" }, { "text": "Touch9", "kind": "touch" } ] }, + { "label": "10", "role": "io", "gpio": 10, "caps": [ { "text": "ADC1_9", "kind": "adc" }, { "text": "Touch10", "kind": "touch" } ] }, + { "label": "11", "role": "io", "gpio": 11, "caps": [ { "text": "ADC2_0", "kind": "adc" }, { "text": "Touch11", "kind": "touch" } ] }, + { "label": "12", "role": "io", "gpio": 12, "caps": [ { "text": "ADC2_1", "kind": "adc" }, { "text": "Touch12", "kind": "touch" } ] }, + { "label": "13", "role": "io", "gpio": 13, "caps": [ { "text": "ADC2_2", "kind": "adc" }, { "text": "Touch13", "kind": "touch" } ] }, + { "label": "14", "role": "io", "gpio": 14, "caps": [ { "text": "ADC2_3", "kind": "adc" }, { "text": "Touch14", "kind": "touch" } ] }, + { "label": "5V", "role": "pwr5" }, + { "label": "GND", "role": "gnd" } + ], + "right": [ + { "label": "GND", "role": "gnd" }, + { "label": "43", "role": "io", "gpio": 43, "caps": [ { "text": "U0_TXD", "kind": "uart" } ] }, + { "label": "44", "role": "io", "gpio": 44, "caps": [ { "text": "U0_RXD", "kind": "uart" } ] }, + { "label": "1", "role": "io", "gpio": 1, "caps": [ { "text": "ADC1_0", "kind": "adc" }, { "text": "Touch1", "kind": "touch" } ] }, + { "label": "2", "role": "io", "gpio": 2, "caps": [ { "text": "ADC1_1", "kind": "adc" }, { "text": "Touch2", "kind": "touch" } ] }, + { "label": "42", "role": "io", "gpio": 42, "caps": [ { "text": "MTMS", "kind": "warn" } ] }, + { "label": "41", "role": "io", "gpio": 41, "caps": [ { "text": "MTDI", "kind": "warn" } ] }, + { "label": "40", "role": "io", "gpio": 40, "caps": [ { "text": "MTDO", "kind": "warn" } ] }, + { "label": "39", "role": "io", "gpio": 39, "caps": [ { "text": "MTCK", "kind": "warn" } ] }, + { "label": "38", "role": "io", "gpio": 38 }, + { "label": "37", "role": "rsvd", "gpio": 37, "caps": [ { "text": "PSRAM", "kind": "warn" } ] }, + { "label": "36", "role": "rsvd", "gpio": 36, "caps": [ { "text": "PSRAM", "kind": "warn" } ] }, + { "label": "35", "role": "rsvd", "gpio": 35, "caps": [ { "text": "PSRAM", "kind": "warn" } ] }, + { "label": "0", "role": "strap", "gpio": 0, "caps": [ { "text": "BOOT", "kind": "strap" } ] }, + { "label": "45", "role": "strap", "gpio": 45, "caps": [ { "text": "strap", "kind": "strap" } ] }, + { "label": "48", "role": "io", "gpio": 48, "caps": [ { "text": "RGB v1.1", "kind": "warn" } ] }, + { "label": "47", "role": "io", "gpio": 47 }, + { "label": "21", "role": "io", "gpio": 21 }, + { "label": "20", "role": "usb", "gpio": 20, "caps": [ { "text": "USB_D+", "kind": "usb" } ] }, + { "label": "19", "role": "usb", "gpio": 19, "caps": [ { "text": "USB_D-", "kind": "usb" } ] }, + { "label": "GND", "role": "gnd" }, + { "label": "GND", "role": "gnd" } + ] + } +} diff --git a/lp-core/lpc-hardware/boards/espressif/esp32-s3-devkitc-1.json b/lp-core/lpc-hardware/boards/espressif/esp32-s3-devkitc-1.json new file mode 100644 index 000000000..f1eb797a9 --- /dev/null +++ b/lp-core/lpc-hardware/boards/espressif/esp32-s3-devkitc-1.json @@ -0,0 +1,238 @@ +{ + "id": "espressif/esp32-s3-devkitc-1", + "target": "esp32s3", + "vendor": "espressif", + "product": "ESP32-S3-DevKitC-1", + "description": "Espressif ESP32-S3-DevKitC-1 board profile. Header silkscreen labels are the GPIO numbers; sourced from Espressif's board documentation, not yet hardware-calibrated. GPIO35-37 are deliberately absent: on octal-PSRAM modules (N8R8, N16R8V) they are in-package and never claimable, and the board ships with both quad and octal modules. The onboard RGB LED is recorded as a label only: GPIO48 on v1.1 boards but GPIO38 on v1.0, so no gpio resource claims it.", + "url": "https://docs.espressif.com/projects/esp-dev-kits/en/latest/esp32s3/esp32-s3-devkitc-1/index.html", + "board_label": [ + { + "label": "RGB", + "status": "unassigned", + "note": "GPIO48 on v1.1 boards, GPIO38 on v1.0 — contested across revisions; verify per unit before claiming" + }, + { + "label": "BOOT", + "gpio": "/gpio/0", + "status": "assigned", + "note": "BOOT button; boot-strap pin" + } + ], + "gpio": [ + { + "address": "/gpio/0", + "display_label": "GPIO0", + "capabilities": ["gpio-output", "gpio-input"], + "aliases": ["IO0"], + "reserved_reason": "BOOT strap — grounding it at reset enters the download bootloader" + }, + { + "address": "/gpio/1", + "display_label": "GPIO1", + "capabilities": ["gpio-output", "gpio-input"], + "aliases": ["IO1"] + }, + { + "address": "/gpio/2", + "display_label": "GPIO2", + "capabilities": ["gpio-output", "gpio-input"], + "aliases": ["IO2"] + }, + { + "address": "/gpio/3", + "display_label": "GPIO3", + "capabilities": ["gpio-output", "gpio-input"], + "aliases": ["IO3"] + }, + { + "address": "/gpio/4", + "display_label": "GPIO4", + "capabilities": ["gpio-output", "gpio-input"], + "aliases": ["IO4"] + }, + { + "address": "/gpio/5", + "display_label": "GPIO5", + "capabilities": ["gpio-output", "gpio-input"], + "aliases": ["IO5"] + }, + { + "address": "/gpio/6", + "display_label": "GPIO6", + "capabilities": ["gpio-output", "gpio-input"], + "aliases": ["IO6"] + }, + { + "address": "/gpio/7", + "display_label": "GPIO7", + "capabilities": ["gpio-output", "gpio-input"], + "aliases": ["IO7"] + }, + { + "address": "/gpio/8", + "display_label": "GPIO8", + "capabilities": ["gpio-output", "gpio-input"], + "aliases": ["IO8"] + }, + { + "address": "/gpio/9", + "display_label": "GPIO9", + "capabilities": ["gpio-output", "gpio-input"], + "aliases": ["IO9"] + }, + { + "address": "/gpio/10", + "display_label": "GPIO10", + "capabilities": ["gpio-output", "gpio-input"], + "aliases": ["IO10"] + }, + { + "address": "/gpio/11", + "display_label": "GPIO11", + "capabilities": ["gpio-output", "gpio-input"], + "aliases": ["IO11"] + }, + { + "address": "/gpio/12", + "display_label": "GPIO12", + "capabilities": ["gpio-output", "gpio-input"], + "aliases": ["IO12"] + }, + { + "address": "/gpio/13", + "display_label": "GPIO13", + "capabilities": ["gpio-output", "gpio-input"], + "aliases": ["IO13"] + }, + { + "address": "/gpio/14", + "display_label": "GPIO14", + "capabilities": ["gpio-output", "gpio-input"], + "aliases": ["IO14"] + }, + { + "address": "/gpio/15", + "display_label": "GPIO15", + "capabilities": ["gpio-output", "gpio-input"], + "aliases": ["IO15"] + }, + { + "address": "/gpio/16", + "display_label": "GPIO16", + "capabilities": ["gpio-output", "gpio-input"], + "aliases": ["IO16"] + }, + { + "address": "/gpio/17", + "display_label": "GPIO17", + "capabilities": ["gpio-output", "gpio-input"], + "aliases": ["IO17"] + }, + { + "address": "/gpio/18", + "display_label": "GPIO18", + "capabilities": ["gpio-output", "gpio-input"], + "aliases": ["IO18"] + }, + { + "address": "/gpio/19", + "display_label": "GPIO19", + "capabilities": ["gpio-output", "gpio-input"], + "aliases": ["IO19"], + "reserved_reason": "USB D- — driving it drops the native USB link" + }, + { + "address": "/gpio/20", + "display_label": "GPIO20", + "capabilities": ["gpio-output", "gpio-input"], + "aliases": ["IO20"], + "reserved_reason": "USB D+ — driving it drops the native USB link" + }, + { + "address": "/gpio/21", + "display_label": "GPIO21", + "capabilities": ["gpio-output", "gpio-input"], + "aliases": ["IO21"] + }, + { + "address": "/gpio/38", + "display_label": "GPIO38", + "capabilities": ["gpio-output", "gpio-input"], + "aliases": ["IO38"] + }, + { + "address": "/gpio/39", + "display_label": "GPIO39", + "capabilities": ["gpio-output", "gpio-input"], + "aliases": ["IO39"], + "location": "JTAG MTCK" + }, + { + "address": "/gpio/40", + "display_label": "GPIO40", + "capabilities": ["gpio-output", "gpio-input"], + "aliases": ["IO40"], + "location": "JTAG MTDO" + }, + { + "address": "/gpio/41", + "display_label": "GPIO41", + "capabilities": ["gpio-output", "gpio-input"], + "aliases": ["IO41"], + "location": "JTAG MTDI" + }, + { + "address": "/gpio/42", + "display_label": "GPIO42", + "capabilities": ["gpio-output", "gpio-input"], + "aliases": ["IO42"], + "location": "JTAG MTMS" + }, + { + "address": "/gpio/43", + "display_label": "GPIO43", + "capabilities": ["gpio-output", "gpio-input"], + "aliases": ["IO43"], + "location": "UART0 TXD to the UART bridge" + }, + { + "address": "/gpio/44", + "display_label": "GPIO44", + "capabilities": ["gpio-output", "gpio-input"], + "aliases": ["IO44"], + "location": "UART0 RXD to the UART bridge" + }, + { + "address": "/gpio/45", + "display_label": "GPIO45", + "capabilities": ["gpio-output", "gpio-input"], + "aliases": ["IO45"] + }, + { + "address": "/gpio/46", + "display_label": "GPIO46", + "capabilities": ["gpio-output", "gpio-input"], + "aliases": ["IO46"] + }, + { + "address": "/gpio/47", + "display_label": "GPIO47", + "capabilities": ["gpio-output", "gpio-input"], + "aliases": ["IO47"] + }, + { + "address": "/gpio/48", + "display_label": "GPIO48", + "capabilities": ["gpio-output", "gpio-input"], + "aliases": ["IO48"], + "location": "onboard RGB LED on v1.1 boards (see RGB board label)" + } + ], + "resource": [ + { + "address": "/rmt/ws281x0", + "display_label": "RMT WS281x 0", + "capabilities": ["rmt", "ws281x-output"] + } + ] +} diff --git a/lp-core/lpc-hardware/boards/quinled/dig-uno.display.json b/lp-core/lpc-hardware/boards/quinled/dig-uno.display.json new file mode 100644 index 000000000..744e12ed1 --- /dev/null +++ b/lp-core/lpc-hardware/boards/quinled/dig-uno.display.json @@ -0,0 +1,39 @@ +{ + "board_id": "quinled/dig-uno", + "display_name": "QuinLED-Dig-Uno", + "manufacturer": "QuinLED", + "soc": "ESP32", + "family": "esp32", + "flash": "4 MB", + "price_usd": 32.0, + "tier": "bronze", + "capabilities": ["level shifter", "fuse", "screw terminals", "2 LED ports", "5-24 V input"], + "blurb": "Purpose-built LED controller: fused, level-shifted, screw terminals. The clean permanent-install choice.", + "support_note": "Runs when classic ESP32 (v3) firmware lands — in progress.", + "purchase_urls": [ + { "label": "quinled.info", "href": "https://quinled.info/pre-assembled-quinled-dig-uno/" } + ], + "hw": { + "width": 150, + "module": { "x": 44, "y": 10, "w": 62, "h": 58, "label": "ESP32", "antenna": true }, + "usb": [ { "x": 64, "label": "USB" } ], + "terminals": [ + { "label": "V+", "role": "pwr5", "caps": [ { "text": "5-24V", "kind": "pwr" } ] }, + { "label": "GND", "role": "gnd" }, + { "label": "LED1", "role": "io", "gpio": 16, "caps": [ { "text": "level-shift", "kind": "note" } ] }, + { "label": "LED2", "role": "io", "gpio": 3, "caps": [ { "text": "level-shift", "kind": "note" } ] } + ], + "left": [ + { "label": "Q1", "role": "io", "gpio": 1 }, + { "label": "Q2", "role": "io", "gpio": 2 }, + { "label": "Q4", "role": "io", "gpio": 4, "caps": [ { "text": "Touch0", "kind": "touch" } ] }, + { "label": "GND", "role": "gnd" } + ], + "right": [ + { "label": "3V3", "role": "pwr3" }, + { "label": "15", "role": "io", "gpio": 15, "caps": [ { "text": "ADC2_3", "kind": "adc" } ] }, + { "label": "12", "role": "strap", "gpio": 12, "caps": [ { "text": "strap", "kind": "strap" } ] }, + { "label": "GND", "role": "gnd" } + ] + } +} diff --git a/lp-core/lpc-hardware/boards/seeed/xiao-esp32-c6.display.json b/lp-core/lpc-hardware/boards/seeed/xiao-esp32-c6.display.json new file mode 100644 index 000000000..5c5ce3e23 --- /dev/null +++ b/lp-core/lpc-hardware/boards/seeed/xiao-esp32-c6.display.json @@ -0,0 +1,39 @@ +{ + "board_id": "seeed/xiao-esp32-c6", + "display_name": "XIAO ESP32-C6", + "manufacturer": "Seeed Studio", + "soc": "ESP32-C6", + "family": "esp32c6", + "flash": "4 MB", + "price_usd": 5.2, + "tier": "silver", + "capabilities": ["Wi-Fi 6", "BLE 5", "802.15.4", "thumb-sized"], + "blurb": "Tiny C6 for wearables and props where the controller must disappear. Calibrated profile in the repo.", + "purchase_urls": [ + { "label": "Seeed", "href": "https://www.seeedstudio.com/Seeed-Studio-XIAO-ESP32C6-p-5884.html" } + ], + "hw": { + "width": 76, + "module": { "x": 14, "y": 6, "w": 48, "h": 30, "label": "C6", "antenna": false }, + "usb": [ { "x": 24, "label": "USB" } ], + "buttons": [ { "x": 64, "y": -14, "label": "B" } ], + "left": [ + { "label": "D0", "role": "io", "gpio": 0, "caps": [ { "text": "ADC1_0", "kind": "adc" } ] }, + { "label": "D1", "role": "io", "gpio": 1, "caps": [ { "text": "ADC1_1", "kind": "adc" } ] }, + { "label": "D2", "role": "io", "gpio": 2, "caps": [ { "text": "ADC1_2", "kind": "adc" } ] }, + { "label": "D3", "role": "io", "gpio": 21 }, + { "label": "D4", "role": "io", "caps": [ { "text": "not found", "kind": "warn" } ] }, + { "label": "D5", "role": "io", "caps": [ { "text": "not found", "kind": "warn" } ] }, + { "label": "D6", "role": "io", "gpio": 16, "caps": [ { "text": "U0_TXD", "kind": "uart" } ] } + ], + "right": [ + { "label": "5V", "role": "pwr5" }, + { "label": "GND", "role": "gnd" }, + { "label": "3V3", "role": "pwr3" }, + { "label": "D10", "role": "io", "gpio": 18, "caps": [ { "text": "WS281x hdr", "kind": "note" } ] }, + { "label": "D9", "role": "io", "gpio": 20 }, + { "label": "D8", "role": "io", "gpio": 19 }, + { "label": "D7", "role": "io", "gpio": 17, "caps": [ { "text": "U0_RXD", "kind": "uart" } ] } + ] + } +} diff --git a/lp-core/lpc-hardware/boards/seeed/xiao-esp32-s3-plus.display.json b/lp-core/lpc-hardware/boards/seeed/xiao-esp32-s3-plus.display.json new file mode 100644 index 000000000..f565dfb7b --- /dev/null +++ b/lp-core/lpc-hardware/boards/seeed/xiao-esp32-s3-plus.display.json @@ -0,0 +1,41 @@ +{ + "board_id": "seeed/xiao-esp32-s3-plus", + "display_name": "XIAO ESP32-S3 Plus", + "manufacturer": "Seeed Studio", + "soc": "ESP32-S3", + "family": "esp32s3", + "flash": "16 MB", + "psram": "8 MB", + "price_usd": 9.9, + "tier": "silver", + "capabilities": ["Wi-Fi", "BLE 5", "thumb-sized", "camera header"], + "blurb": "Tiny S3 with real memory headroom. Calibrated profile in the repo; the castellated pads are deliberately unlisted.", + "support_note": "S3 support is new — tested as the S3 track matures.", + "purchase_urls": [ + { "label": "Seeed", "href": "https://www.seeedstudio.com/Seeed-Studio-XIAO-ESP32S3-Plus-p-6361.html" } + ], + "hw": { + "width": 76, + "module": { "x": 14, "y": 6, "w": 48, "h": 30, "label": "S3", "antenna": false }, + "usb": [ { "x": 24, "label": "USB" } ], + "buttons": [ { "x": 64, "y": -14, "label": "B" } ], + "left": [ + { "label": "D0", "role": "io", "gpio": 1, "caps": [ { "text": "ADC1_0", "kind": "adc" }, { "text": "Touch1", "kind": "touch" } ] }, + { "label": "D1", "role": "io", "gpio": 2, "caps": [ { "text": "ADC1_1", "kind": "adc" }, { "text": "Touch2", "kind": "touch" } ] }, + { "label": "D2", "role": "io", "gpio": 3, "caps": [ { "text": "ADC1_2", "kind": "adc" }, { "text": "Touch3", "kind": "touch" } ] }, + { "label": "D3", "role": "io", "gpio": 4, "caps": [ { "text": "ADC1_3", "kind": "adc" }, { "text": "Touch4", "kind": "touch" } ] }, + { "label": "D4", "role": "io", "gpio": 5, "caps": [ { "text": "ADC1_4", "kind": "adc" }, { "text": "Touch5", "kind": "touch" } ] }, + { "label": "D5", "role": "io", "gpio": 6, "caps": [ { "text": "ADC1_5", "kind": "adc" }, { "text": "Touch6", "kind": "touch" } ] }, + { "label": "D6", "role": "io", "gpio": 43, "caps": [ { "text": "U0_TXD", "kind": "uart" } ] } + ], + "right": [ + { "label": "5V", "role": "pwr5" }, + { "label": "GND", "role": "gnd" }, + { "label": "3V3", "role": "pwr3" }, + { "label": "D10", "role": "io", "gpio": 9, "caps": [ { "text": "ADC1_8", "kind": "adc" }, { "text": "Touch9", "kind": "touch" } ] }, + { "label": "D9", "role": "io", "gpio": 8, "caps": [ { "text": "ADC1_7", "kind": "adc" }, { "text": "Touch8", "kind": "touch" } ] }, + { "label": "D8", "role": "io", "gpio": 7, "caps": [ { "text": "ADC1_6", "kind": "adc" }, { "text": "Touch7", "kind": "touch" } ] }, + { "label": "D7", "role": "io", "gpio": 44, "caps": [ { "text": "U0_RXD", "kind": "uart" } ] } + ] + } +} diff --git a/schemas/board-display.schema.json b/schemas/board-display.schema.json new file mode 100644 index 000000000..e0ecad1ce --- /dev/null +++ b/schemas/board-display.schema.json @@ -0,0 +1,424 @@ +{ + "$defs": { + "BoardDrawing": { + "description": "The drawing block the row-engine diagram renderer consumes.\n\nGeometry is in board-local units; the renderer derives every other size\nfrom the pin pitch (`u`). See `spikes/hardware-boards/DESIGN-LANGUAGE.md`\n(moving to `docs/design/board-diagrams.md` with the renderer milestone).", + "properties": { + "buttons": { + "items": { + "$ref": "#/$defs/DrawnButton" + }, + "type": "array" + }, + "left": { + "items": { + "$ref": "#/$defs/DrawnPin" + }, + "type": "array" + }, + "module": { + "$ref": "#/$defs/DrawnModule" + }, + "rgb": { + "anyOf": [ + { + "$ref": "#/$defs/DrawnRgb" + }, + { + "type": "null" + } + ] + }, + "right": { + "items": { + "$ref": "#/$defs/DrawnPin" + }, + "type": "array" + }, + "terminals": { + "description": "Top-edge screw terminals (band rows + leader lines).", + "items": { + "$ref": "#/$defs/DrawnTerminal" + }, + "type": "array" + }, + "usb": { + "items": { + "$ref": "#/$defs/DrawnUsb" + }, + "type": "array" + }, + "width": { + "description": "Board width in drawing units. Height derives from pin counts.", + "format": "float", + "type": "number" + } + }, + "required": [ + "width", + "module", + "left", + "right" + ], + "type": "object" + }, + "CapKind": { + "description": "Cell type: drives the capability cell's color family.", + "oneOf": [ + { + "enum": [ + "adc", + "dac", + "touch", + "spi", + "i2c", + "uart", + "usb", + "strap", + "pwr", + "note" + ], + "type": "string" + }, + { + "const": "warn", + "description": "Caution: input-only, XTAL, PSRAM-reserved, JTAG, onboard LED.", + "type": "string" + } + ] + }, + "DrawnButton": { + "properties": { + "label": { + "type": "string" + }, + "x": { + "format": "float", + "type": "number" + }, + "y": { + "description": "Negative y = offset from the board's bottom edge.", + "format": "float", + "type": "number" + } + }, + "required": [ + "x", + "y", + "label" + ], + "type": "object" + }, + "DrawnModule": { + "description": "The SoC module / can outline.", + "properties": { + "antenna": { + "default": false, + "type": "boolean" + }, + "h": { + "format": "float", + "type": "number" + }, + "label": { + "type": "string" + }, + "w": { + "format": "float", + "type": "number" + }, + "x": { + "format": "float", + "type": "number" + }, + "y": { + "format": "float", + "type": "number" + } + }, + "required": [ + "x", + "y", + "w", + "h", + "label" + ], + "type": "object" + }, + "DrawnPin": { + "description": "One header pin on a left/right rail.", + "properties": { + "caps": { + "items": { + "$ref": "#/$defs/PinCap" + }, + "type": "array" + }, + "gpio": { + "format": "uint8", + "maximum": 255, + "minimum": 0, + "type": [ + "integer", + "null" + ] + }, + "label": { + "description": "Silkscreen label (\"4\", \"3V3\", \"D10\", \"RST\").", + "type": "string" + }, + "role": { + "$ref": "#/$defs/PinRole" + } + }, + "required": [ + "label", + "role" + ], + "type": "object" + }, + "DrawnRgb": { + "properties": { + "gpio": { + "format": "uint8", + "maximum": 255, + "minimum": 0, + "type": [ + "integer", + "null" + ] + }, + "x": { + "format": "float", + "type": "number" + }, + "y": { + "format": "float", + "type": "number" + } + }, + "required": [ + "x", + "y" + ], + "type": "object" + }, + "DrawnTerminal": { + "description": "A top-edge screw terminal. Terminal rows keep a leading name cell because\nthere is no inside-the-board space for their labels.", + "properties": { + "caps": { + "items": { + "$ref": "#/$defs/PinCap" + }, + "type": "array" + }, + "gpio": { + "format": "uint8", + "maximum": 255, + "minimum": 0, + "type": [ + "integer", + "null" + ] + }, + "label": { + "type": "string" + }, + "role": { + "$ref": "#/$defs/PinRole" + } + }, + "required": [ + "label", + "role" + ], + "type": "object" + }, + "DrawnUsb": { + "properties": { + "label": { + "type": "string" + }, + "x": { + "format": "float", + "type": "number" + } + }, + "required": [ + "x", + "label" + ], + "type": "object" + }, + "PinCap": { + "description": "A capability cell in the pin's row (\"ADC1_3\", \"Touch4\", \"U0_TXD\").", + "properties": { + "kind": { + "$ref": "#/$defs/CapKind" + }, + "text": { + "type": "string" + } + }, + "required": [ + "text", + "kind" + ], + "type": "object" + }, + "PinRole": { + "description": "Pin role: drives pad/label color and output eligibility.\n\nOutput-eligible for LED discovery: `io` and `strap` only.", + "oneOf": [ + { + "enum": [ + "pwr5", + "pwr3", + "gnd", + "io", + "nc" + ], + "type": "string" + }, + { + "const": "io-in", + "description": "Input-only (classic ESP32 34-39).", + "type": "string" + }, + { + "const": "strap", + "description": "Boot-strap pin: usable, with care.", + "type": "string" + }, + { + "const": "usb", + "description": "USB D+/D- — driving it drops the link.", + "type": "string" + }, + { + "const": "ctl", + "description": "EN / RST.", + "type": "string" + }, + { + "const": "rsvd", + "description": "Connected but reserved (in-package flash/PSRAM pins on boards that\nexpose them). Never claimable, never discovery-eligible.", + "type": "string" + } + ] + }, + "PurchaseUrl": { + "properties": { + "href": { + "type": "string" + }, + "label": { + "type": "string" + } + }, + "required": [ + "label", + "href" + ], + "type": "object" + }, + "SupportTier": { + "description": "Support tier shown on the catalog. Definitions live in\n`lp-core/lpc-hardware/boards/README.md`.", + "oneOf": [ + { + "const": "gold", + "description": "First-class: tested every release.", + "type": "string" + }, + { + "const": "silver", + "description": "Supported: tested occasionally.", + "type": "string" + }, + { + "const": "bronze", + "description": "Community-verified: should work.", + "type": "string" + } + ] + } + }, + "$id": "https://lightplayer.dev/schemas/board-display.schema.json", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "description": "One board's display sidecar (`/.display.json`).", + "properties": { + "blurb": { + "type": "string" + }, + "board_id": { + "description": "Must equal the sibling runtime manifest id and the `vendor/product`\nfile path stem.", + "type": "string" + }, + "capabilities": { + "description": "Catalog capability chips (\"Wi-Fi 6\", \"level shifter\", ...).", + "items": { + "type": "string" + }, + "type": "array" + }, + "display_name": { + "type": "string" + }, + "family": { + "description": "Filter family for the provisioning picker: \"esp32\", \"esp32s3\",\n\"esp32c6\". Kept a plain string so display data never gates on firmware\ntarget enums.", + "type": "string" + }, + "flash": { + "type": "string" + }, + "hw": { + "$ref": "#/$defs/BoardDrawing" + }, + "manufacturer": { + "type": "string" + }, + "price_usd": { + "description": "Approximate street price, USD, for catalog sorting.", + "format": "double", + "type": "number" + }, + "psram": { + "type": [ + "string", + "null" + ] + }, + "purchase_urls": { + "items": { + "$ref": "#/$defs/PurchaseUrl" + }, + "type": "array" + }, + "soc": { + "description": "Human SoC name, e.g. \"ESP32-C6\".", + "type": "string" + }, + "support_note": { + "description": "Honest support caveat shown with the tier, e.g. \"runs when classic\nESP32 firmware lands\".", + "type": [ + "string", + "null" + ] + }, + "tier": { + "$ref": "#/$defs/SupportTier" + } + }, + "required": [ + "board_id", + "display_name", + "manufacturer", + "soc", + "family", + "flash", + "price_usd", + "tier", + "blurb", + "hw" + ], + "title": "BoardDisplayFile", + "type": "object" +} From d2c01d67b3cdc82831eee28e7c238bd48ca37846 Mon Sep 17 00:00:00 2001 From: Yona Appletree Date: Fri, 31 Jul 2026 12:37:50 -0700 Subject: [PATCH 2/4] docs(lpa-boards): display-sidecar authoring guide, crate README, ADR boards/README.md gains the display-sidecar section and support-tier definitions; ADR records the runtime/display split and the drift-gate contract; ADR index gains the display-only-allowlist follow-up row. ADR: docs/adr/2026-07-31-board-display-metadata-split.md Plan: 2026-07-31-1002-hardware-board-selection (M1) Co-Authored-By: Claude Fable 5 --- ...2026-07-31-board-display-metadata-split.md | 81 +++++++++++++++++++ docs/adr/README.md | 1 + lp-app/lpa-boards/README.md | 23 ++++++ lp-core/lpc-hardware/boards/README.md | 36 +++++++++ 4 files changed, 141 insertions(+) create mode 100644 docs/adr/2026-07-31-board-display-metadata-split.md create mode 100644 lp-app/lpa-boards/README.md diff --git a/docs/adr/2026-07-31-board-display-metadata-split.md b/docs/adr/2026-07-31-board-display-metadata-split.md new file mode 100644 index 000000000..fdfaa3342 --- /dev/null +++ b/docs/adr/2026-07-31-board-display-metadata-split.md @@ -0,0 +1,81 @@ +# Board display metadata lives in app-side sidecars, not the runtime manifest + +- Status: accepted +- Date: 2026-07-31 +- Deciders: Yona, Claude + +## Context + +Board selection is becoming a first-class feature (boards catalog page, +provisioning picker, device-card hardware pane, pin discovery — see the +`spikes/hardware-boards/` spike and the 2026-07-31 hardware/board-selection +roadmap). Those surfaces need catalog data the runtime board manifest does not +carry: support tier, approximate price, purchase URLs, capability chips, and a +drawing block (per-pin roles/capability cells, module/USB/button/terminal +geometry) for the metadata-driven board diagrams. + +The runtime manifest (`boards//.json`, +`lpc_hardware::HardwareManifestFile`) is compiled into firmware via +`include_str!`, and measurement has shown the serde surface is the dominant +flash lever on the C6. It is also safety-sensitive: it is the authority on +claimable resources, with a calibration workflow and an "omit what you cannot +verify" policy, because a wrong GPIO is a physical-damage class of mistake. + +## Decision + +Display/catalog metadata lives in a **sidecar file per board** +(`boards//.display.json`), owned by the new app-side +`lp-app/lpa-boards` crate. The runtime manifest types and firmware serde +surface are untouched. + +- The sidecar carries identity/commerce fields and the drawing block; its + schema (`schemas/board-display.schema.json`) is generated alongside the + existing schemas by `lp-cli schema gen`. +- `lpa-boards` embeds every checked-in sidecar so wasm consumers (catalog + page, studio) need no filesystem. +- **Drift tests** in `lpa-boards` are the consistency contract between the two + files: display↔runtime pairing both directions, `board_id` = path = runtime + id, silkscreen-label→GPIO agreement with `board_label` entries (calibration + `not-found` forces the display gpio absent), runtime-omitted GPIOs must + present as non-claimable roles, and runtime-reserved GPIOs may not display + as plain io without a warning cap. +- A board may be **display-only** (catalog presence without a runtime + manifest) only while its SoC has no `HardwareTarget`; such boards sit on an + explicit allowlist in the drift tests with the reason recorded (today: + classic-ESP32 boards, pending the v3 firmware target). +- Support tiers are display data: gold (tested every release), silver + (tested occasionally), bronze (community-verified; the ceiling for boards + whose firmware target does not exist yet, with a `support_note`). + +## Consequences + +- Firmware flash cost of board data is unchanged, now and as catalog fields + grow. +- Pin facts exist in two files with different stakes; the drift gate — not + discipline — keeps them agreeing, and the runtime manifest stays + authoritative for anything claimable. +- The catalog can list boards we cannot run yet, honestly labeled, which the + boards page needs as a shop window. +- Datasheet-derived (uncalibrated) runtime profiles are marked in their + `board_label` notes and descriptions; the calibration workflow upgrades them + in place. + +## Alternatives considered + +- **Extend `HardwareManifestFile` with optional display fields.** Rejected: + every field is firmware serde surface and flash; display churn would ride + the safety-sensitive file; build-time stripping adds machinery for no + boundary gain. +- **A separate registry/config tree elsewhere in the repo.** Rejected: the + sidecar-next-to-manifest layout keeps a board one directory entry, makes the + pairing obvious, and lets the drift tests walk a single tree. +- **Deriving display pins from the runtime manifest.** Rejected: the drawing + needs pins the runtime manifest deliberately omits (power/ground/NC, + reserved, in-package), plus geometry and roles it will never carry. + +## Follow-ups + +- M2 (row-engine renderer) consumes the drawing block and moves the design + language into `docs/design/board-diagrams.md`. +- The display-only allowlist shrinks as firmware targets land (classic ESP32 + first). diff --git a/docs/adr/README.md b/docs/adr/README.md index 6136513c8..e9cfaf0e6 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -97,6 +97,7 @@ holds the full context. | Worker offload for probe evaluation (interp fuel now bounds infinite-loop shaders — `lpir::InterpLimits` + `lps-probe` `MAX_OPS_PER_EVAL` — so this is purely about main-thread jank) | `2026-07-25-shader-probe-experiment-api`; `2026-07-25-studio-shader-agent-architecture` | Live walks show probe-eval jank | | lps-glsl as a linter pass over the probe unit (better spans than the naga oracle) | `2026-07-25-shader-probe-experiment-api` | Span quality on probe/agent diagnostics becomes a complaint | | Frontend dialect gap: engine frontends accept bare uniforms, the probe oracle (naga) requires `layout(binding=N)` — a shader can render on-engine yet fail health | `2026-07-25-shader-probe-experiment-api` | A live gate or demo shader hits it; align or lint in the agent path | +| Display-only board allowlist shrinks (DevKitC v4, QuinLED Dig-Uno gain runtime manifests) | `2026-07-31-board-display-metadata-split` | A classic-ESP32 (v3) `HardwareTarget` lands | | Probe/agent-activity visualization (render probe domains/results on the preview) | `2026-07-25-studio-shader-agent-architecture` | M6 capture lands (the node-UX pass landed 2026-07-26 as `node-card-faces` without it); likely its own plan | | Live-sim binding push (agent binding overrides reach only the probe oracle today; transient push needs visible indication + auto-clear) | `2026-07-25-studio-shader-agent-architecture` | Outward-from-GLSL capability work begins | | In-web local model provider (the reason `ModelProvider` exists) | `2026-07-25-studio-shader-agent-architecture` | A credible in-browser model is worth serving | diff --git a/lp-app/lpa-boards/README.md b/lp-app/lpa-boards/README.md new file mode 100644 index 000000000..dfbd8aa50 --- /dev/null +++ b/lp-app/lpa-boards/README.md @@ -0,0 +1,23 @@ +# lpa-boards + +Board display metadata: the catalog/drawing layer for supported boards. + +This crate owns the `boards//.display.json` sidecar format — +catalog identity (support tier, price, purchase URLs, capability chips, blurb) +and the drawing block the board diagram renderer consumes (module outline, +USB/buttons/terminals, per-pin roles and capability cells). Sources live next +to the runtime manifests in `lp-core/lpc-hardware/boards/` and are embedded +here (`all_boards()`), so wasm consumers need no filesystem. + +Boundary: display data in, nothing out. No knowledge of projects, devices, +routes, or the runtime manifest types — consistency with the runtime manifests +is enforced by `tests/manifest_drift.rs`, not code coupling. Rationale: +`docs/adr/2026-07-31-board-display-metadata-split.md`. + +Authoring guide and tier definitions: `lp-core/lpc-hardware/boards/README.md`. +Schema: `schemas/board-display.schema.json` (generated by `just schema-gen`). +Drawing vocabulary: `spikes/hardware-boards/DESIGN-LANGUAGE.md` until the +renderer milestone moves it into `docs/design/`. + +Coming milestones add the row-engine `BoardDiagram` component and the +`#/boards` catalog page here. diff --git a/lp-core/lpc-hardware/boards/README.md b/lp-core/lpc-hardware/boards/README.md index 2f9818100..9c86ede34 100644 --- a/lp-core/lpc-hardware/boards/README.md +++ b/lp-core/lpc-hardware/boards/README.md @@ -177,6 +177,42 @@ refuted against Espressif's primary GPIO reference while writing its profile. When a vendor page and a primary source disagree, the primary source wins and the refutation belongs in the profile's `note`. +## Display sidecars (`*.display.json`) + +Each board may carry a catalog sidecar next to its runtime manifest: + +```text +boards/ + vendor/ + product.json # runtime manifest (this document) — compiled into firmware + product.display.json # catalog/drawing metadata — app-side only +``` + +The sidecar holds everything the boards catalog, provisioning picker, and +diagram renderer need that the runtime manifest must not carry (the runtime +manifest is `include_str!`'d into firmware, where every byte of serde surface +costs flash): display name, support tier, approximate price, purchase URLs, +capability chips, and the drawing block (module outline, USB/buttons/terminals, +per-pin roles and capability cells). + +The types, embedded catalog, and JSON schema +(`schemas/board-display.schema.json`) live in `lp-app/lpa-boards`. The drift +tests there (`lpa-boards/tests/manifest_drift.rs`) keep sidecar and runtime +manifest consistent: silkscreen-label→GPIO mappings must agree with +`board_label` entries (including calibration `not-found`), GPIOs the runtime +manifest deliberately omits must not present as claimable pins, and +runtime-reserved GPIOs may not display as plain io. A board may be +display-only (no runtime manifest) only while its SoC has no +`HardwareTarget`; those live on an explicit allowlist in the drift tests with +the reason recorded. + +### Support tiers + +- **gold** — first-class: tested every release. +- **silver** — supported: tested occasionally. +- **bronze** — community-verified: should work. Boards whose firmware target + does not exist yet are at most bronze, with a `support_note` saying so. + ## Validation Before committing a manifest change, run: From 635bf2fc31b5ac3cfc06721080cc0b67a87bf461 Mon Sep 17 00:00:00 2001 From: Yona Appletree Date: Fri, 31 Jul 2026 12:41:51 -0700 Subject: [PATCH 3/4] style: rustfmt lpa-boards Co-Authored-By: Claude Fable 5 --- lp-app/lpa-boards/src/catalog.rs | 12 +++++++++--- lp-app/lpa-boards/src/display_manifest.rs | 17 +++++++++++------ lp-app/lpa-boards/src/lib.rs | 6 +++--- lp-app/lpa-boards/tests/manifest_drift.rs | 23 ++++++++++++++--------- 4 files changed, 37 insertions(+), 21 deletions(-) diff --git a/lp-app/lpa-boards/src/catalog.rs b/lp-app/lpa-boards/src/catalog.rs index 31de7fcf4..736833ed8 100644 --- a/lp-app/lpa-boards/src/catalog.rs +++ b/lp-app/lpa-boards/src/catalog.rs @@ -13,15 +13,21 @@ use crate::display_manifest::BoardDisplayFile; pub const DISPLAY_MANIFEST_SOURCES: &[(&str, &str)] = &[ ( "espressif/esp32-c6-devkitc-1", - include_str!("../../../lp-core/lpc-hardware/boards/espressif/esp32-c6-devkitc-1.display.json"), + include_str!( + "../../../lp-core/lpc-hardware/boards/espressif/esp32-c6-devkitc-1.display.json" + ), ), ( "espressif/esp32-s3-devkitc-1", - include_str!("../../../lp-core/lpc-hardware/boards/espressif/esp32-s3-devkitc-1.display.json"), + include_str!( + "../../../lp-core/lpc-hardware/boards/espressif/esp32-s3-devkitc-1.display.json" + ), ), ( "espressif/esp32-devkitc-v4", - include_str!("../../../lp-core/lpc-hardware/boards/espressif/esp32-devkitc-v4.display.json"), + include_str!( + "../../../lp-core/lpc-hardware/boards/espressif/esp32-devkitc-v4.display.json" + ), ), ( "seeed/xiao-esp32-c6", diff --git a/lp-app/lpa-boards/src/display_manifest.rs b/lp-app/lpa-boards/src/display_manifest.rs index fd859bdd2..bf298a034 100644 --- a/lp-app/lpa-boards/src/display_manifest.rs +++ b/lp-app/lpa-boards/src/display_manifest.rs @@ -231,11 +231,10 @@ impl std::error::Error for BoardDisplayError {} impl BoardDisplayFile { pub fn read_json(json_text: &str) -> Result { - let file: Self = serde_json::from_str(json_text).map_err(|error| { - BoardDisplayError::Parse { + let file: Self = + serde_json::from_str(json_text).map_err(|error| BoardDisplayError::Parse { message: error.to_string(), - } - })?; + })?; file.validate()?; Ok(file) } @@ -257,7 +256,10 @@ impl BoardDisplayFile { return invalid("display_name must not be empty".into()); } if !self.price_usd.is_finite() || self.price_usd < 0.0 { - return invalid(format!("price_usd must be non-negative: {}", self.price_usd)); + return invalid(format!( + "price_usd must be non-negative: {}", + self.price_usd + )); } for url in &self.purchase_urls { if !(url.href.starts_with("https://") || url.href.starts_with("http://")) { @@ -290,7 +292,10 @@ impl BoardDisplayFile { for terminal in &self.hw.terminals { if let Some(gpio) = terminal.gpio { if seen.contains(&gpio) { - return invalid(format!("duplicate gpio {gpio} (terminal {})", terminal.label)); + return invalid(format!( + "duplicate gpio {gpio} (terminal {})", + terminal.label + )); } seen.push(gpio); } diff --git a/lp-app/lpa-boards/src/lib.rs b/lp-app/lpa-boards/src/lib.rs index d97094344..d8e770226 100644 --- a/lp-app/lpa-boards/src/lib.rs +++ b/lp-app/lpa-boards/src/lib.rs @@ -14,8 +14,8 @@ mod catalog; mod display_manifest; -pub use catalog::{all_boards, board_by_id, DISPLAY_MANIFEST_SOURCES}; +pub use catalog::{DISPLAY_MANIFEST_SOURCES, all_boards, board_by_id}; pub use display_manifest::{ - BoardDisplayError, BoardDisplayFile, BoardDrawing, CapKind, DrawnButton, DrawnModule, - DrawnPin, DrawnRgb, DrawnTerminal, DrawnUsb, PinCap, PinRole, PurchaseUrl, SupportTier, + BoardDisplayError, BoardDisplayFile, BoardDrawing, CapKind, DrawnButton, DrawnModule, DrawnPin, + DrawnRgb, DrawnTerminal, DrawnUsb, PinCap, PinRole, PurchaseUrl, SupportTier, }; diff --git a/lp-app/lpa-boards/tests/manifest_drift.rs b/lp-app/lpa-boards/tests/manifest_drift.rs index a47b08fbf..fe83de07f 100644 --- a/lp-app/lpa-boards/tests/manifest_drift.rs +++ b/lp-app/lpa-boards/tests/manifest_drift.rs @@ -40,7 +40,11 @@ fn manifest_pairs() -> BTreeMap, Option panic!( "{board_id}: display sidecar has no runtime manifest and no DISPLAY_ONLY reason" ), - (Some(_), true) => panic!( - "{board_id}: has a runtime manifest — remove it from DISPLAY_ONLY" - ), + (Some(_), true) => { + panic!("{board_id}: has a runtime manifest — remove it from DISPLAY_ONLY") + } _ => {} } } @@ -147,9 +151,11 @@ fn display_pins_agree_with_runtime_manifests() { }) .collect(); - let terminals = display.hw.terminals.iter().map(|t| { - (t.label.as_str(), t.role, t.gpio, Vec::new()) - }); + let terminals = display + .hw + .terminals + .iter() + .map(|t| (t.label.as_str(), t.role, t.gpio, Vec::new())); let pins = display.pins().map(|p| { ( p.label.as_str(), @@ -196,8 +202,7 @@ fn display_pins_agree_with_runtime_manifests() { // Rule 3: a runtime-reserved gpio must not read as a plain io // pin in the catalog. Some(resource) if resource.reserved_reason.is_some() => assert!( - role != PinRole::Io - || cap_kinds.contains(&lpa_boards::CapKind::Warn), + role != PinRole::Io || cap_kinds.contains(&lpa_boards::CapKind::Warn), "{board_id}: pin {label} (gpio {gpio}) is reserved in the runtime manifest \ ({:?}) but displays as plain io with no warn cap", resource.reserved_reason From 241041be59d5de5ae30229406241bbbe33b28bbd Mon Sep 17 00:00:00 2001 From: Yona Appletree Date: Fri, 31 Jul 2026 12:56:05 -0700 Subject: [PATCH 4/4] fix(lp-cli): schema-conformance validates display sidecars against their own schema The hardware conformance walk was checking .display.json sidecars against hardware.schema.json. Route them to board-display.schema.json (with a vacuity guard) so both formats stay schema-gated. Plan: 2026-07-31-1002-hardware-board-selection (M1) Co-Authored-By: Claude Fable 5 --- lp-cli/tests/schema_conformance.rs | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/lp-cli/tests/schema_conformance.rs b/lp-cli/tests/schema_conformance.rs index 7e8ed18f2..a341ee4ed 100644 --- a/lp-cli/tests/schema_conformance.rs +++ b/lp-cli/tests/schema_conformance.rs @@ -94,9 +94,11 @@ fn authored_artifacts_conform_to_checked_in_schemas() -> Result<()> { #[test] fn hardware_manifests_conform_to_checked_in_schema() -> Result<()> { let workspace = workspace_dir(); - let validator = load_validator(&workspace, "schemas/hardware.schema.json")?; + let manifest_validator = load_validator(&workspace, "schemas/hardware.schema.json")?; + let display_validator = load_validator(&workspace, "schemas/board-display.schema.json")?; let mut failures = Vec::new(); + let mut display_count = 0usize; for root in HARDWARE_ROOTS { let files = walk_json_files(&workspace, root)?; assert!( @@ -108,10 +110,22 @@ fn hardware_manifests_conform_to_checked_in_schema() -> Result<()> { if SKIP_JSON.contains(&rel.as_str()) { continue; } - validate_file(&validator, file, &rel, &mut failures)?; + // `.display.json` sidecars are catalog metadata + // (lpa-boards) with their own schema. + if rel.ends_with(".display.json") { + display_count += 1; + validate_file(&display_validator, file, &rel, &mut failures)?; + } else { + validate_file(&manifest_validator, file, &rel, &mut failures)?; + } } println!("{root}: validated {} hardware manifests", files.len()); } + // Vacuity guard for the sidecar branch too. + assert!( + display_count > 0, + "no display sidecars found under the hardware roots — walk is vacuous" + ); if !failures.is_empty() { bail!(