use std::path::Path;
use serde::{Deserialize, Serialize};
use crate::bindings::{BatteryGaugeBinding, GnssBinding, ImuBinding, SensorHubBinding};
use crate::host_facts::probe_host_facts;
use crate::sensor_hub::{SensorHubPresence, UsbTty};
pub fn sidecar_path() -> std::path::PathBuf {
match std::env::var_os("ADOS_RUN_DIR") {
Some(dir) => std::path::PathBuf::from(dir).join("board.json"),
None => std::path::PathBuf::from("/run/ados/board.json"),
}
}
pub const BOARD_SIDECAR_VERSION: u16 = 1;
#[derive(Debug, Clone, Default, Deserialize)]
pub struct ComputeSection {
#[serde(default)]
pub npu_tops: f64,
#[serde(default)]
pub local_inference: Option<String>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct DisplayBinding {
pub id: String,
}
#[derive(Debug, Clone, Default, Deserialize)]
pub struct DisplaysSection {
#[serde(default)]
pub supported: Vec<DisplayBinding>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub struct ButtonPins {
pub up: u32,
pub down: u32,
pub select: u32,
pub back: u32,
}
impl ButtonPins {
pub fn pins(&self) -> [u32; 4] {
[self.up, self.down, self.select, self.back]
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub struct EncoderPins {
pub a: u32,
pub b: u32,
pub sw: u32,
}
impl EncoderPins {
pub fn pins(&self) -> [u32; 3] {
[self.a, self.b, self.sw]
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
pub struct ReservedGpio {
pub gpio: u32,
}
#[derive(Debug, Clone, Default, Deserialize)]
pub struct VariantMatch {
#[serde(default)]
pub cpu_cores: Option<i64>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct BoardVariant {
pub id: String,
#[serde(default)]
pub when: VariantMatch,
#[serde(default)]
pub name: Option<String>,
#[serde(default)]
pub soc: Option<String>,
#[serde(default)]
pub default_tier: Option<i64>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct BoardProfile {
pub name: String,
#[serde(default = "unknown")]
pub vendor: String,
#[serde(default = "unknown")]
pub soc: String,
#[serde(default = "default_arch")]
pub arch: String,
#[serde(default)]
pub model_patterns: Vec<String>,
#[serde(default = "default_tier_field")]
pub default_tier: i64,
#[serde(default)]
pub hw_video_codecs: Vec<String>,
#[serde(default)]
pub compute: ComputeSection,
#[serde(default)]
pub displays: DisplaysSection,
#[serde(default)]
pub variants: Vec<BoardVariant>,
#[serde(default)]
pub buttons: Option<ButtonPins>,
#[serde(default)]
pub encoder: Option<EncoderPins>,
#[serde(default)]
pub imu: Option<ImuBinding>,
#[serde(default)]
pub gnss: Option<GnssBinding>,
#[serde(default)]
pub sensor_hub: Option<SensorHubBinding>,
#[serde(default)]
pub battery_gauge: Option<BatteryGaugeBinding>,
#[serde(default)]
pub reserved_gpio: Vec<ReservedGpio>,
#[serde(skip)]
pub stem: String,
}
impl BoardProfile {
pub fn reserved_lines(&self) -> Vec<u32> {
let mut lines: Vec<u32> = self.reserved_gpio.iter().map(|r| r.gpio).collect();
if let Some(pps) = self
.gnss
.as_ref()
.and_then(|g| g.pps_gpio)
.and_then(|p| u32::try_from(p).ok())
{
lines.push(pps);
}
lines.sort_unstable();
lines.dedup();
lines
}
pub fn panel_conflict(&self) -> Option<String> {
let mut declared: Vec<(&str, u32)> = Vec::new();
if let Some(buttons) = self.buttons {
declared.extend(buttons.pins().map(|p| ("button", p)));
}
if let Some(encoder) = self.encoder {
declared.extend(encoder.pins().map(|p| ("encoder", p)));
}
let reserved = self.reserved_lines();
let mut seen: Vec<u32> = Vec::new();
for (role, pin) in declared {
if seen.contains(&pin) {
return Some(format!("{role} gpio {pin} is declared twice"));
}
if reserved.contains(&pin) {
return Some(format!("{role} gpio {pin} is a reserved line"));
}
seen.push(pin);
}
None
}
}
fn unknown() -> String {
"unknown".to_string()
}
fn default_arch() -> String {
"aarch64".to_string()
}
fn default_tier_field() -> i64 {
2
}
pub const BOARD_PROFILE_YAML: &[(&str, &str)] = &[
("beagley-ai", include_str!("../boards/beagley-ai.yaml")),
("cm3", include_str!("../boards/cm3.yaml")),
("cm4", include_str!("../boards/cm4.yaml")),
("cm5", include_str!("../boards/cm5.yaml")),
("cubie-a7s", include_str!("../boards/cubie-a7s.yaml")),
("cubie-a7z", include_str!("../boards/cubie-a7z.yaml")),
(
"generic-arm64",
include_str!("../boards/generic-arm64.yaml"),
),
(
"generic-x86_64",
include_str!("../boards/generic-x86_64.yaml"),
),
("jetson-nano", include_str!("../boards/jetson-nano.yaml")),
(
"jetson-orin-nano",
include_str!("../boards/jetson-orin-nano.yaml"),
),
("orange-pi-5", include_str!("../boards/orange-pi-5.yaml")),
("rdk-x3", include_str!("../boards/rdk-x3.yaml")),
("rk3566", include_str!("../boards/rk3566.yaml")),
("rk3576", include_str!("../boards/rk3576.yaml")),
("rk3588s2", include_str!("../boards/rk3588s2.yaml")),
("rock-5b-plus", include_str!("../boards/rock-5b-plus.yaml")),
("rock-5c-lite", include_str!("../boards/rock-5c-lite.yaml")),
("rpi3", include_str!("../boards/rpi3.yaml")),
("rpi4b", include_str!("../boards/rpi4b.yaml")),
("rpi5", include_str!("../boards/rpi5.yaml")),
("rv1126b", include_str!("../boards/rv1126b.yaml")),
("zero2w", include_str!("../boards/zero2w.yaml")),
];
pub fn load_profiles() -> Vec<BoardProfile> {
let mut out = Vec::with_capacity(BOARD_PROFILE_YAML.len());
for (stem, body) in BOARD_PROFILE_YAML {
match parse_profile(body) {
Ok(mut p) => {
p.stem = (*stem).to_string();
out.push(p);
}
Err(e) => tracing::warn!(board = stem, error = %e, "board profile did not parse"),
}
}
out
}
pub fn parse_profile(body: &str) -> Result<BoardProfile, String> {
let profile = serde_norway::from_str::<BoardProfile>(body).map_err(|e| e.to_string())?;
match profile.panel_conflict() {
Some(conflict) => Err(conflict),
None => Ok(profile),
}
}
pub fn detect_tier(ram_mb: i64) -> i64 {
if ram_mb < 512 {
1
} else if ram_mb < 2048 {
2
} else if ram_mb <= 4096 {
3
} else {
4
}
}
pub fn match_profile<'a>(
profiles: &'a [BoardProfile],
model_string: &str,
) -> Option<&'a BoardProfile> {
if model_string.is_empty() {
return None;
}
let needle = model_string.to_ascii_lowercase();
profiles.iter().find(|p| {
p.model_patterns
.iter()
.any(|pattern| !pattern.is_empty() && needle.contains(&pattern.to_ascii_lowercase()))
})
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct BoardFingerprint {
pub version: u16,
pub name: String,
pub model: String,
#[serde(default)]
pub profile: String,
pub tier: i64,
pub ram_mb: i64,
pub cpu_cores: i64,
pub vendor: String,
pub soc: String,
pub arch: String,
pub hw_video_codecs: Vec<String>,
pub npu_tops: f64,
pub has_accelerator: bool,
pub local_inference: String,
pub has_local_inference: bool,
#[serde(default)]
pub buttons: Option<ButtonPins>,
#[serde(default)]
pub encoder: Option<EncoderPins>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub sensor_hub: Option<SensorHubPresence>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub battery_gauge: Option<BatteryGaugeBinding>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub reserved_gpio: Vec<u32>,
}
#[derive(Debug, Clone, Default)]
pub struct HostFacts {
pub model: String,
pub compatible: String,
pub cpuinfo_model: String,
pub ram_mb: i64,
pub cpu_cores: i64,
pub machine: String,
pub os: String,
pub board_override: String,
pub usb_ttys: Vec<UsbTty>,
}
#[derive(Debug, Clone)]
pub struct ProfileMatch<'a> {
pub profile: &'a BoardProfile,
pub model: String,
pub source: &'static str,
}
pub fn select_variant(profile: &BoardProfile, cpu_cores: i64) -> Option<&BoardVariant> {
profile.variants.iter().find(|v| match v.when.cpu_cores {
Some(n) => n == cpu_cores,
None => false,
})
}
pub fn resolve_profile<'a>(
profiles: &'a [BoardProfile],
facts: &HostFacts,
) -> Option<ProfileMatch<'a>> {
let detected = [
facts.model.as_str(),
facts.compatible.as_str(),
facts.cpuinfo_model.as_str(),
]
.into_iter()
.find(|s| !s.is_empty())
.unwrap_or("");
let token = facts.board_override.trim();
if !token.is_empty() {
match profiles
.iter()
.find(|p| p.stem.eq_ignore_ascii_case(token))
{
Some(p) => {
return Some(ProfileMatch {
profile: p,
model: if detected.is_empty() {
token.to_string()
} else {
detected.to_string()
},
source: "override",
})
}
None => tracing::warn!(
token,
"board_override names no board profile (expected a YAML filename stem, e.g. rock-5c-lite); auto-detecting"
),
}
}
if let Some(p) = match_profile(profiles, &facts.compatible) {
return Some(ProfileMatch {
profile: p,
model: if facts.model.is_empty() {
facts.compatible.clone()
} else {
facts.model.clone()
},
source: "compatible",
});
}
if let Some(p) = match_profile(profiles, &facts.model) {
return Some(ProfileMatch {
profile: p,
model: facts.model.clone(),
source: "model",
});
}
if let Some(p) = match_profile(profiles, &facts.cpuinfo_model) {
return Some(ProfileMatch {
profile: p,
model: facts.cpuinfo_model.clone(),
source: "cpuinfo",
});
}
None
}
fn from_profile(profile: &BoardProfile, model_string: &str, facts: &HostFacts) -> BoardFingerprint {
let local_inference = profile
.compute
.local_inference
.clone()
.unwrap_or_else(|| "none".to_string());
let variant = select_variant(profile, facts.cpu_cores);
let name = variant
.and_then(|v| v.name.clone())
.unwrap_or_else(|| profile.name.clone());
let soc = variant
.and_then(|v| v.soc.clone())
.unwrap_or_else(|| profile.soc.clone());
let tier = variant
.and_then(|v| v.default_tier)
.unwrap_or(profile.default_tier);
BoardFingerprint {
version: BOARD_SIDECAR_VERSION,
model: if model_string.is_empty() {
name.clone()
} else {
model_string.to_string()
},
name,
profile: profile.stem.clone(),
tier,
ram_mb: facts.ram_mb,
cpu_cores: facts.cpu_cores,
vendor: profile.vendor.clone(),
soc,
arch: profile.arch.clone(),
hw_video_codecs: profile.hw_video_codecs.clone(),
npu_tops: profile.compute.npu_tops,
has_accelerator: profile.compute.npu_tops > 0.0,
has_local_inference: !matches!(local_inference.as_str(), "" | "none"),
local_inference,
buttons: profile.buttons,
encoder: profile.encoder,
sensor_hub: crate::sensor_hub::detect(profile.sensor_hub.as_ref(), &facts.usb_ttys),
battery_gauge: profile.battery_gauge.clone(),
reserved_gpio: profile.reserved_lines(),
}
}
fn generic_identity(facts: &HostFacts) -> (&'static str, String) {
let stem = if facts.machine == "x86_64" || facts.machine == "AMD64" {
"generic-x86_64"
} else {
"generic-arm64"
};
let name = if facts.os == "macos" {
"macOS (dev)".to_string()
} else {
stem.to_string()
};
(stem, name)
}
pub fn resolve(profiles: &[BoardProfile], facts: &HostFacts) -> BoardFingerprint {
if let Some(m) = resolve_profile(profiles, facts) {
return from_profile(m.profile, &m.model, facts);
}
let (generic_stem, generic_name) = generic_identity(facts);
let detected_model = [
facts.model.as_str(),
facts.cpuinfo_model.as_str(),
facts.compatible.as_str(),
]
.into_iter()
.find(|s| !s.is_empty())
.unwrap_or("")
.to_string();
if let Some(p) = profiles.iter().find(|p| p.stem == generic_stem) {
let mut fp = from_profile(p, &detected_model, facts);
fp.name = generic_name;
if fp.model.is_empty() {
fp.model = fp.name.clone();
}
fp.tier = detect_tier(facts.ram_mb);
return fp;
}
BoardFingerprint {
version: BOARD_SIDECAR_VERSION,
model: if detected_model.is_empty() {
generic_name.clone()
} else {
detected_model
},
name: generic_name,
profile: String::new(),
tier: detect_tier(facts.ram_mb),
ram_mb: facts.ram_mb,
cpu_cores: facts.cpu_cores,
vendor: "unknown".to_string(),
soc: "unknown".to_string(),
arch: if facts.machine.is_empty() {
"aarch64".to_string()
} else {
facts.machine.clone()
},
hw_video_codecs: Vec::new(),
npu_tops: 0.0,
has_accelerator: false,
local_inference: "none".to_string(),
has_local_inference: false,
buttons: None,
encoder: None,
sensor_hub: crate::sensor_hub::detect(None, &facts.usb_ttys),
battery_gauge: None,
reserved_gpio: Vec::new(),
}
}
pub fn declared_display_ids() -> Vec<String> {
let profiles = load_profiles();
let facts = probe_host_facts();
match resolve_profile(&profiles, &facts) {
Some(m) => m
.profile
.displays
.supported
.iter()
.map(|d| d.id.clone())
.collect(),
None => Vec::new(),
}
}
pub fn write_sidecar(path: &Path, fp: &BoardFingerprint) -> std::io::Result<()> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
let body = serde_json::to_vec(fp).map_err(std::io::Error::other)?;
let tmp = path.with_extension("json.tmp");
std::fs::write(&tmp, &body)?;
std::fs::rename(&tmp, path)?;
Ok(())
}
pub fn read_sidecar(path: &Path) -> Option<BoardFingerprint> {
let raw = std::fs::read_to_string(path).ok()?;
serde_json::from_str(&raw).ok()
}
pub fn publish() -> std::io::Result<BoardFingerprint> {
let facts = probe_host_facts();
let fp = resolve(&load_profiles(), &facts);
write_sidecar(&sidecar_path(), &fp)?;
let hub_present = crate::sensor_hub::publish_presence(fp.sensor_hub.as_ref());
tracing::info!(
board = %fp.name,
tier = fp.tier,
npu_tops = fp.npu_tops,
ram_mb = fp.ram_mb,
sensor_hub = hub_present,
"board fingerprint published"
);
Ok(fp)
}
#[cfg(test)]
#[path = "board_sidecar_bindings_tests.rs"]
mod bindings_tests;
#[cfg(test)]
mod tests {
use super::*;
use crate::host_facts::{parse_cpu_online, parse_cpuinfo_model, parse_mem_total_mb};
fn facts(compatible: &str, model: &str) -> HostFacts {
HostFacts {
model: model.to_string(),
compatible: compatible.to_string(),
cpuinfo_model: String::new(),
ram_mb: 8192,
cpu_cores: 8,
machine: "aarch64".to_string(),
os: "linux".to_string(),
board_override: String::new(),
usb_ttys: Vec::new(),
}
}
fn yaml_files(dir: &std::path::Path) -> Vec<(String, Vec<u8>)> {
let mut out: Vec<(String, Vec<u8>)> = std::fs::read_dir(dir)
.expect("board profile directory must be readable")
.flatten()
.filter_map(|e| {
let name = e.file_name().to_string_lossy().to_string();
name.ends_with(".yaml")
.then(|| (name, std::fs::read(e.path()).expect("readable profile")))
})
.collect();
out.sort();
out
}
#[test]
fn every_board_profile_yaml_is_registered_and_parses() {
let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("boards");
let on_disk: Vec<String> = yaml_files(&dir)
.into_iter()
.map(|(name, _)| name.trim_end_matches(".yaml").to_string())
.collect();
let mut embedded: Vec<String> = BOARD_PROFILE_YAML
.iter()
.map(|(stem, _)| stem.to_string())
.collect();
embedded.sort();
assert_eq!(
embedded, on_disk,
"the embedded board table and boards/ have drifted"
);
assert_eq!(
load_profiles().len(),
BOARD_PROFILE_YAML.len(),
"a board profile failed to parse"
);
}
#[test]
fn bundled_boards_match_the_canonical_hal_profiles() {
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"));
let canonical = root.join("../../src/ados/hal/boards");
if !canonical.is_dir() {
return;
}
let bundled = yaml_files(&root.join("boards"));
let canonical = yaml_files(&canonical);
let names = |v: &[(String, Vec<u8>)]| v.iter().map(|(n, _)| n.clone()).collect::<Vec<_>>();
assert_eq!(
names(&bundled),
names(&canonical),
"crates/ados-hal-probe/boards/ and src/ados/hal/boards/ list different files"
);
for ((name, a), (_, b)) in bundled.iter().zip(&canonical) {
assert!(
a == b,
"boards/{name} differs from src/ados/hal/boards/{name}; copy it over"
);
}
}
#[test]
fn the_sidecar_version_matches_the_shared_contract_registry() {
assert_eq!(
BOARD_SIDECAR_VERSION,
ados_protocol::contracts::sidecar_version("board").unwrap()
);
}
#[test]
fn an_npu_board_reports_its_real_tops_and_accelerator_flag() {
let profiles = load_profiles();
let fp = resolve(&profiles, &facts("rockchip,rk3588s2", "Rockchip RK3588S2"));
assert!(fp.npu_tops > 0.0, "fingerprint: {fp:?}");
assert!(fp.has_accelerator);
assert_ne!(fp.name, "generic-arm64");
assert_eq!(fp.version, BOARD_SIDECAR_VERSION);
}
#[test]
fn the_compatible_token_disambiguates_two_boards_with_one_soc_model() {
let profiles = load_profiles();
let a7z = resolve(&profiles, &facts("radxa,cubie-a7z", "sun60iw2"));
let a7s = resolve(&profiles, &facts("radxa,cubie-a7s", "sun60iw2"));
assert_eq!(a7z.name, "Radxa Cubie A7Z");
assert_eq!(a7s.name, "Radxa Cubie A7S");
assert_ne!(a7z.tier, a7s.tier);
assert_eq!(a7z.model, "sun60iw2");
let ambiguous = resolve(&profiles, &facts("", "sun60iw2"));
assert_eq!(ambiguous.name, "generic-arm64", "{ambiguous:?}");
}
#[test]
fn an_unknown_board_falls_back_to_the_generic_profile_not_to_nothing() {
let profiles = load_profiles();
let fp = resolve(&profiles, &facts("acme,unheard-of-v9", "ACME Unheard Of"));
assert_eq!(fp.name, "generic-arm64");
assert_eq!(fp.model, "ACME Unheard Of");
assert_eq!(fp.tier, detect_tier(8192));
assert_eq!(fp.npu_tops, 0.0);
assert!(!fp.has_accelerator);
}
#[test]
fn an_unknown_x86_64_host_resolves_to_the_generic_x86_64_profile() {
let profiles = load_profiles();
let mut f = facts("", "");
f.machine = "x86_64".to_string();
let fp = resolve(&profiles, &f);
assert_eq!(fp.name, "generic-x86_64", "{fp:?}");
assert_eq!(fp.npu_tops, 0.0);
assert!(!fp.has_accelerator);
}
#[test]
fn the_cpuinfo_fallback_is_used_when_the_device_tree_says_nothing() {
let profiles = load_profiles();
let mut f = facts("", "");
f.cpuinfo_model = "Raspberry Pi 4 Model B Rev 1.4".to_string();
let fp = resolve(&profiles, &f);
assert!(fp.name.contains("Raspberry Pi 4"), "fingerprint: {fp:?}");
}
#[test]
fn tiers_follow_the_ram_bands() {
assert_eq!(detect_tier(256), 1);
assert_eq!(detect_tier(1024), 2);
assert_eq!(detect_tier(4096), 3);
assert_eq!(detect_tier(8192), 4);
}
#[test]
fn the_document_carries_the_python_writer_key_set_exactly() {
let profiles = load_profiles();
let fp = resolve(&profiles, &facts("raspberrypi,5-model-b", "Raspberry Pi 5"));
let value = serde_json::to_value(&fp).unwrap();
let mut keys: Vec<&str> = value
.as_object()
.unwrap()
.keys()
.map(|k| k.as_str())
.collect();
keys.sort();
assert_eq!(
keys,
vec![
"arch",
"buttons",
"cpu_cores",
"encoder",
"has_accelerator",
"has_local_inference",
"hw_video_codecs",
"local_inference",
"model",
"name",
"npu_tops",
"profile",
"ram_mb",
"soc",
"tier",
"vendor",
"version",
]
);
}
#[test]
fn the_sidecar_is_written_atomically_and_round_trips() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("run").join("board.json");
let profiles = load_profiles();
let fp = resolve(&profiles, &facts("raspberrypi,5-model-b", "Raspberry Pi 5"));
write_sidecar(&path, &fp).unwrap();
let back: BoardFingerprint =
serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
assert_eq!(back, fp);
assert!(!path.with_extension("json.tmp").exists());
}
#[test]
fn a_local_inference_board_reports_the_cpu_path() {
let profiles = load_profiles();
let cm5 = profiles
.iter()
.find(|p| p.name == "Raspberry Pi CM5")
.expect("cm5 profile");
let fp = from_profile(cm5, "Raspberry Pi CM5", &facts("", ""));
assert_eq!(fp.local_inference, "onnx");
assert!(fp.has_local_inference);
assert!(!fp.has_accelerator);
}
#[test]
fn the_sidecar_carries_the_profile_stem() {
let profiles = load_profiles();
let cm5 = profiles
.iter()
.find(|p| p.stem == "cm5")
.expect("cm5 profile");
let fp = from_profile(cm5, "Raspberry Pi CM5", &facts("", ""));
assert_eq!(fp.profile, "cm5");
assert_eq!(fp.name, "Raspberry Pi CM5");
let generic = resolve(&profiles, &facts("acme,unheard-of-v9", "ACME Unheard Of"));
assert_eq!(generic.profile, "generic-arm64");
}
#[test]
fn a_panel_board_publishes_its_button_pins() {
let profiles = load_profiles();
let rpi4 = profiles.iter().find(|p| p.stem == "rpi4b").expect("rpi4b");
let fp = from_profile(rpi4, "Raspberry Pi 4 Model B", &facts("", ""));
assert_eq!(
fp.buttons,
Some(ButtonPins {
up: 5,
down: 6,
select: 13,
back: 19
})
);
let cm5 = profiles.iter().find(|p| p.stem == "cm5").expect("cm5");
assert_eq!(from_profile(cm5, "", &facts("", "")).buttons, None);
let old = r#"{"version":1,"name":"x","model":"x","tier":2,"ram_mb":1,"cpu_cores":1,"vendor":"v","soc":"s","arch":"a","hw_video_codecs":[],"npu_tops":0.0,"has_accelerator":false,"local_inference":"none","has_local_inference":false}"#;
let fp: BoardFingerprint = serde_json::from_str(old).unwrap();
assert_eq!(fp.profile, "");
assert_eq!(fp.buttons, None);
assert_eq!(fp.encoder, None);
}
#[test]
fn host_fact_parsers_read_the_real_shapes() {
assert_eq!(parse_mem_total_mb("MemTotal: 8123456 kB\n"), 7933);
assert_eq!(parse_mem_total_mb("SwapTotal: 0 kB\n"), 0);
assert_eq!(parse_cpu_online("0-7\n"), 8);
assert_eq!(parse_cpu_online("0,2-3\n"), 3);
assert_eq!(parse_cpu_online("\n"), 0);
assert_eq!(
parse_cpuinfo_model("processor\t: 0\nHardware\t: BCM2835\n"),
"BCM2835"
);
assert_eq!(parse_cpuinfo_model("flags : none\n"), "");
}
#[test]
fn every_board_stem_is_a_resolvable_override_token() {
let profiles = load_profiles();
for (stem, _) in BOARD_PROFILE_YAML {
let mut f = facts("", "");
f.board_override = (*stem).to_string();
let fp = resolve(&profiles, &f);
let expected = profiles
.iter()
.find(|p| &p.stem == stem)
.expect("every embedded stem parses");
let expected_name = select_variant(expected, f.cpu_cores)
.and_then(|v| v.name.clone())
.unwrap_or_else(|| expected.name.clone());
assert_eq!(fp.name, expected_name, "override {stem} resolved wrong");
}
}
#[test]
fn the_override_stem_pins_the_a7s_against_its_shared_soc_family_string() {
let profiles = load_profiles();
let mut f = facts("", "sun60iw2");
f.board_override = "cubie-a7s".to_string();
let fp = resolve(&profiles, &f);
assert_eq!(fp.name, "Radxa Cubie A7S");
assert_eq!(fp.soc, "Allwinner A733");
assert_eq!(fp.model, "sun60iw2");
}
#[test]
fn an_override_naming_no_profile_falls_through_to_auto_detection() {
let profiles = load_profiles();
let mut f = facts("raspberrypi,5-model-b", "Raspberry Pi 5");
f.board_override = "Radxa ROCK 5C Lite (RK3582)".to_string();
let fp = resolve(&profiles, &f);
assert!(fp.name.contains("Raspberry Pi 5"), "fingerprint: {fp:?}");
}
#[test]
fn the_shared_rock_5c_device_tree_is_split_by_core_count() {
let profiles = load_profiles();
let mut lite = facts("radxa,rock-5c", "Radxa ROCK 5C ");
lite.cpu_cores = 6;
let lite = resolve(&profiles, &lite);
assert_eq!(lite.name, "Radxa ROCK 5C Lite (RK3582)");
assert_eq!(lite.soc, "RK3582");
let mut full = facts("radxa,rock-5c", "Radxa ROCK 5C ");
full.cpu_cores = 8;
let full = resolve(&profiles, &full);
assert_eq!(full.name, "Radxa ROCK 5C (RK3588S2)");
assert_eq!(full.soc, "RK3588S2");
assert_eq!(full.npu_tops, lite.npu_tops);
assert!(full.has_accelerator);
}
#[test]
fn real_device_tree_strings_resolve_to_exactly_one_board() {
let profiles = load_profiles();
let cases: &[(&str, &str, i64, &str)] = &[
("radxa,cubie-a7s", "sun60iw2", 8, "Radxa Cubie A7S"),
("radxa,cubie-a7z", "sun60iw2", 8, "Radxa Cubie A7Z"),
(
"radxa,rock-5c",
"Radxa ROCK 5C ",
6,
"Radxa ROCK 5C Lite (RK3582)",
),
(
"radxa,rock-5c",
"Radxa ROCK 5C ",
8,
"Radxa ROCK 5C (RK3588S2)",
),
("radxa,cm3", "Radxa CM3 IO Board", 4, "Radxa CM3 (RK3566)"),
("rockchip,rk3566", "RK3566 EVB", 4, "Radxa CM3 (RK3566)"),
("radxa,cm4", "Radxa CM4", 8, "Radxa CM4 (RK3588S2)"),
(
"radxa,rock-5b-plus",
"Radxa ROCK 5B+",
8,
"Radxa ROCK 5B+ (RK3588)",
),
(
"raspberrypi,4-model-b",
"Raspberry Pi 4 Model B Rev 1.4",
4,
"Raspberry Pi 4B",
),
(
"raspberrypi,3-model-b-plus",
"Raspberry Pi 3 Model B Plus Rev 1.3",
4,
"Raspberry Pi 3",
),
(
"raspberrypi,3-compute-module",
"Raspberry Pi Compute Module 3 Plus Rev 1.0",
4,
"Raspberry Pi Compute Module 3",
),
];
for (compatible, model, cores, expected) in cases {
let mut f = facts(compatible, model);
f.cpu_cores = *cores;
let fp = resolve(&profiles, &f);
assert_eq!(
fp.name, *expected,
"{compatible} / {model} / {cores} cores resolved to {}",
fp.name
);
}
let fp = resolve(&profiles, &facts("radxa,rock-5b", "Radxa ROCK 5B"));
assert_ne!(
fp.profile, "rock-5b-plus",
"ROCK 5B resolved to {}",
fp.name
);
}
#[test]
fn the_rk3566_board_keeps_its_npu_and_its_third_uart() {
let profiles = load_profiles();
let fp = resolve(&profiles, &facts("radxa,cm3", "Radxa CM3 IO Board"));
assert_eq!(fp.npu_tops, 0.8);
assert!(fp.has_accelerator);
assert!(fp.hw_video_codecs.iter().any(|c| c == "vp9_dec"));
}
#[test]
fn a_dev_mac_is_labelled_as_one_but_still_carries_the_generic_declarations() {
let profiles = load_profiles();
let mut f = facts("", "");
f.os = "macos".to_string();
f.machine = "aarch64".to_string();
let fp = resolve(&profiles, &f);
assert_eq!(fp.name, "macOS (dev)");
assert_eq!(fp.tier, detect_tier(8192));
assert_eq!(fp.vendor, "unknown");
}
#[test]
fn the_document_carries_the_hub_and_gauge_only_when_there_is_one() {
let mut rig: BoardProfile = serde_norway::from_str(
"name: Test Rig\nmodel_patterns: [test rig]\n\
imu: {bus: hub, model: ICM-42688-P, rate_hz: 1000}\n\
gnss: {transport: hub}\n\
sensor_hub: {board: matek_h743_slim_v3}\n\
battery_gauge: {type: ina226, i2c_bus: 1, addr: 64, shunt_ohm: 0.0005, max_current_a: 60.0}\n",
)
.unwrap();
rig.stem = "test-rig".into();
assert_eq!(rig.imu.as_ref().unwrap().rate_hz, Some(1000));
let fp = from_profile(&rig, "Test Rig", &facts("", ""));
let v = serde_json::to_value(&fp).unwrap();
assert_eq!(
v["sensor_hub"],
serde_json::json!({"present": false, "device": null, "vid_pid": "1209:ad05"})
);
assert_eq!(
v["battery_gauge"],
serde_json::json!({"type": "ina226", "i2c_bus": 1, "addr": 64,
"shunt_ohm": 0.0005, "max_current_a": 60.0})
);
let mut f = facts("", "");
f.usb_ttys = vec![UsbTty {
device: "/dev/ttyACM0".into(),
vid: 0x1209,
pid: 0xAD05,
}];
let fp = from_profile(&rig, "Test Rig", &f);
let hub = fp.sensor_hub.as_ref().unwrap();
assert!(hub.present);
assert_eq!(hub.device.as_deref(), Some("/dev/ttyACM0"));
let back: BoardFingerprint =
serde_json::from_str(&serde_json::to_string(&fp).unwrap()).unwrap();
assert_eq!(back, fp);
let profiles = load_profiles();
let pi5 = facts("raspberrypi,5-model-b", "Raspberry Pi 5");
let v = serde_json::to_value(resolve(&profiles, &pi5)).unwrap();
assert!(v.get("sensor_hub").is_none());
assert!(v.get("battery_gauge").is_none());
let mut pi5_hub = pi5;
pi5_hub.usb_ttys = f.usb_ttys;
let fp = resolve(&profiles, &pi5_hub);
assert!(fp.sensor_hub.unwrap().present);
}
}