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
11 changes: 11 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
81 changes: 81 additions & 0 deletions docs/adr/2026-07-31-board-display-metadata-split.md
Original file line number Diff line number Diff line change
@@ -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/<vendor>/<product>.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/<vendor>/<product>.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).
1 change: 1 addition & 0 deletions docs/adr/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
23 changes: 23 additions & 0 deletions lp-app/lpa-boards/Cargo.toml
Original file line number Diff line number Diff line change
@@ -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
23 changes: 23 additions & 0 deletions lp-app/lpa-boards/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
# lpa-boards

Board display metadata: the catalog/drawing layer for supported boards.

This crate owns the `boards/<vendor>/<product>.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.
93 changes: 93 additions & 0 deletions lp-app/lpa-boards/src/catalog.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
//! The checked-in board catalog, embedded so wasm consumers need no fs.
//!
//! Sources live next to the runtime manifests:
//! `lp-core/lpc-hardware/boards/<vendor>/<product>.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<Vec<BoardDisplayFile>> = 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
);
}
}
}
Loading
Loading