Skip to main content

ops/
firmware.rs

1//! Firmware images by manifest name or path.
2//!
3//! `config/firmware.toml` (`receivers::firmware`) lists the archived images
4//! with their sha256; a name from it is looked for under
5//! `third-party/firmware` and then in the config directory's cache, and every
6//! image that is in the manifest is checked against it before a write.
7
8use crate::util::warn;
9use crate::Progress;
10use anyhow::{bail, Context, Result};
11use panelspec::{ChipLibrary, PanelSpec};
12use receivers::firmware::{image as entry, manifest, Image};
13use receivers::{CardModel, Tested};
14use std::fmt::Write as _;
15use std::path::{Path, PathBuf};
16
17/// The archived images, relative to the repository root.
18pub const ARCHIVE: &str = "third-party/firmware";
19
20/// The `provision --firmware` value that ranks the manifest instead of
21/// naming an image ([`pick`]).
22pub const AUTO: &str = "auto";
23
24/// Where `fetch` writes: `<config dir>/receiverproxy/firmware`.
25///
26/// # Errors
27/// When the OS names no configuration directory.
28pub fn cache_dir() -> Result<PathBuf> {
29    Ok(dirs::config_dir()
30        .context("no configuration directory for this user")?
31        .join("receiverproxy")
32        .join("firmware"))
33}
34
35/// An image argument resolved to a file, and its manifest entry when it has one.
36#[derive(Debug)]
37pub struct Resolved {
38    pub path: PathBuf,
39    pub image: Option<&'static Image>,
40}
41
42/// A manifest name to the archive or the cache; anything else is a path.
43///
44/// A path is matched to the manifest by file name. The archive is relative
45/// to the working directory, the repository root when the commands run as
46/// documented.
47///
48/// # Errors
49/// A manifest name whose file is in neither place.
50pub fn resolve(arg: &str) -> Result<Resolved> {
51    resolve_in(Path::new(""), arg)
52}
53
54fn resolve_in(root: &Path, arg: &str) -> Result<Resolved> {
55    if let Some(image) = entry(arg) {
56        let archive = root.join(ARCHIVE).join(arg);
57        if archive.is_file() {
58            return Ok(Resolved { path: archive, image: Some(image) });
59        }
60        let cached = cache_dir()?.join(arg);
61        if cached.is_file() {
62            return Ok(Resolved { path: cached, image: Some(image) });
63        }
64        let hint = if manifest().base_url.is_empty() {
65            ""
66        } else {
67            "; rxp firmware fetch downloads it"
68        };
69        bail!(
70            "{arg}: not at {} or {}{hint}",
71            archive.display(),
72            cached.display()
73        );
74    }
75    let path = PathBuf::from(arg);
76    let image = path.file_name().and_then(|n| n.to_str()).and_then(entry);
77    Ok(Resolved { path, image })
78}
79
80/// An image read from disk.
81#[derive(Debug)]
82pub struct Loaded {
83    /// The path read, as printed.
84    pub path: String,
85    pub bytes: Vec<u8>,
86    /// True when the manifest's sha256 was checked.
87    pub verified: bool,
88}
89
90/// Read `arg` and check it against the manifest when it is listed there;
91/// a file outside the manifest is used as is, with a warning.
92///
93/// # Errors
94/// The file cannot be read, or its size or sha256 disagrees with the manifest.
95pub fn load(arg: &str, p: &mut dyn Progress) -> Result<Loaded> {
96    let r = resolve(arg)?;
97    let path = r.path.display().to_string();
98    let bytes = std::fs::read(&r.path).with_context(|| format!("read {path}"))?;
99    let verified = match r.image {
100        Some(image) => {
101            image
102                .verify(&bytes)
103                .map_err(|e| anyhow::anyhow!("firmware: {e}; refusing to write it"))?;
104            true
105        }
106        None => {
107            warn(p, format!("{path} is not in config/firmware.toml; used as is, sha256 unchecked"));
108            false
109        }
110    };
111    Ok(Loaded { path, bytes, verified })
112}
113
114/// The word a plan line carries for a loaded image.
115#[must_use]
116pub const fn checked(l: &Loaded) -> &'static str {
117    if l.verified {
118        "sha256 verified"
119    } else {
120        "unverified"
121    }
122}
123
124/// Where a manifest entry's file is, for `list`.
125fn location(name: &str) -> String {
126    let archive = Path::new(ARCHIVE).join(name);
127    if archive.is_file() {
128        return ARCHIVE.to_string();
129    }
130    match cache_dir() {
131        Ok(d) if d.join(name).is_file() => d.display().to_string(),
132        _ => "absent".to_string(),
133    }
134}
135
136/// `rxp firmware list`: the manifest, one line per image, and where each is.
137pub fn list(p: &mut dyn Progress) {
138    let m = manifest();
139    p.out(&format!(
140        "base_url: {}",
141        if m.base_url.is_empty() { "(empty: local only)" } else { &m.base_url }
142    ));
143    p.out(&format!(
144        "{:<56} {:<7} {:<4} {:<9} {:<20} {:>7}  location",
145        "name", "version", "pcb", "kind", "chips", "bytes"
146    ));
147    for i in &m.image {
148        p.out(&format!(
149            "{:<56} {:<7} {:<4} {:<9} {:<20} {:>7}  {}",
150            i.name,
151            i.version.to_string(),
152            i.pcb.as_deref().unwrap_or("-"),
153            i.kind,
154            if i.chips.is_empty() { "-".to_string() } else { i.chips.join(",") },
155            m.size,
156            location(&i.name)
157        ));
158    }
159}
160
161/// `rxp firmware fetch NAME`: download `base_url/path` with `curl` into the
162/// cache after checking its sha256. With an empty `base_url` it reports
163/// where the image is expected instead.
164///
165/// # Errors
166/// An unknown name, a failed download, or a hash that disagrees with the
167/// manifest; the cache is left without the file on a mismatch.
168pub fn fetch(name: &str, p: &mut dyn Progress) -> Result<()> {
169    fetch_in(Path::new(""), name, p)
170}
171
172fn fetch_in(root: &Path, name: &str, p: &mut dyn Progress) -> Result<()> {
173    let image = entry(name).with_context(|| format!("{name}: not in config/firmware.toml (rxp firmware list)"))?;
174    let cache = cache_dir()?;
175    let dest = cache.join(name);
176    let m = manifest();
177    // A local copy that verifies is used before anything is downloaded.
178    let archive = root.join(ARCHIVE).join(name);
179    for path in [&archive, &dest] {
180        if path.is_file() {
181            let bytes = std::fs::read(path).with_context(|| format!("read {}", path.display()))?;
182            if image.verify(&bytes).is_ok() {
183                p.out(&path.display().to_string());
184                return Ok(());
185            }
186        }
187    }
188    if m.base_url.is_empty() {
189        bail!(
190            "base_url is empty in config/firmware.toml; {name} is expected at {} or {}",
191            archive.display(),
192            dest.display()
193        );
194    }
195    let url = format!("{}/{}", m.base_url.trim_end_matches('/'), m.path(image));
196    p.err(&format!("fetch: {url}"));
197    std::fs::create_dir_all(&cache).with_context(|| format!("create {}", cache.display()))?;
198    let tmp = cache.join(format!("{name}.part"));
199    let status = std::process::Command::new("curl")
200        .args(["-fsSL", "--max-filesize", &(m.size + 1).to_string(), "-o"])
201        .arg(&tmp)
202        .arg(&url)
203        .status()
204        .context("run curl")?;
205    anyhow::ensure!(status.success(), "fetch {url}: curl exited with {status}");
206    let bytes = std::fs::read(&tmp).with_context(|| format!("read {}", tmp.display()))?;
207    if let Err(e) = image.verify(&bytes) {
208        let _ = std::fs::remove_file(&tmp);
209        bail!("fetch: {e}; not cached");
210    }
211    std::fs::rename(&tmp, &dest).with_context(|| format!("rename to {}", dest.display()))?;
212    p.out(&dest.display().to_string());
213    Ok(())
214}
215
216// --- choosing an image for a panel ------------------------------------------
217
218/// Rule 1: the card model was driven with this image and this spec.
219const TESTED: u32 = 1000;
220/// Rule 2: the image's chip list names the spec's chip.
221const CHIP_EXACT: u32 = 500;
222/// Rule 2 through the vendor's suffix forms (`SM16269S` in `SM16269SH`).
223const CHIP_FAMILY: u32 = 400;
224/// Rule 3: the build kind suits the chip class.
225const KIND: u32 = 100;
226
227/// One image the ranking considered, and why it scored what it did.
228#[derive(Debug)]
229pub struct Candidate {
230    pub image: &'static Image,
231    pub score: u32,
232    pub reasons: Vec<String>,
233}
234
235impl Candidate {
236    /// The reasons as one line, for a table.
237    #[must_use]
238    pub fn why(&self) -> String {
239        self.reasons.join("; ")
240    }
241
242    /// Rule 1 or rule 2 decided this candidate; anything below is a guess.
243    const fn decided(&self) -> bool {
244        self.score >= CHIP_FAMILY
245    }
246
247    /// What orders equal scores: version, then the build date in the name.
248    fn tie_break(&self) -> (receivers::Version, u32) {
249        (self.image.version, built(&self.image.name).unwrap_or(0))
250    }
251}
252
253/// What the chip library says the part is. The vendor builds a `PWM`
254/// gateware for an S-PWM driver and a `Normal` one for a plain shift
255/// register; the library's shape says which the chip is, not its name.
256#[derive(Debug, Clone, Copy, PartialEq, Eq)]
257enum Class {
258    /// Addressed registers (`order`/`registers`) or a `chip_custom` block.
259    Spwm,
260    /// Neither: a plain shift register.
261    Plain,
262}
263
264impl Class {
265    fn of(lib: &ChipLibrary) -> Self {
266        if lib.order.is_empty() && lib.registers.is_empty() && lib.chip_custom.is_none() {
267            Self::Plain
268        } else {
269            Self::Spwm
270        }
271    }
272
273    const fn kind(self) -> &'static str {
274        match self {
275            Self::Spwm => "PWM",
276            Self::Plain => "Normal",
277        }
278    }
279
280    const fn what(self) -> &'static str {
281        match self {
282            Self::Spwm => "an S-PWM chip",
283            Self::Plain => "a plain shift register",
284        }
285    }
286}
287
288/// The part name in a chip library's `name`: `SM16269 (default parameters)`
289/// and `SM16269` are both the part before the parenthetical.
290fn part(name: &str) -> &str {
291    name.split('(')
292        .next()
293        .unwrap_or(name)
294        .split_whitespace()
295        .next()
296        .unwrap_or(name)
297}
298
299/// The spec's chip library: the embedded set, then the filesystem.
300fn library(spec: &PanelSpec) -> Option<ChipLibrary> {
301    let text = panelspec::embedded::chip(&spec.chip.library)
302        .map(str::to_owned)
303        .or_else(|| panelspec::read_library(&spec.chip.library).ok())?;
304    ChipLibrary::parse(&text).ok()
305}
306
307/// The part name a spec's chip library gives, or the library path when it
308/// cannot be read.
309#[must_use]
310pub fn chip_name(spec: &PanelSpec) -> String {
311    library(spec).map_or_else(
312        || spec.chip.library.clone(),
313        |lib| part(&lib.name).to_owned(),
314    )
315}
316
317/// `SM16269SH` as `("SM", "16269", "SH")`; `None` without a digit run.
318fn split_part(s: &str) -> Option<(&str, &str, &str)> {
319    let first = s.find(|c: char| c.is_ascii_digit())?;
320    let end = s[first..]
321        .find(|c: char| !c.is_ascii_digit())
322        .map_or(s.len(), |n| first + n);
323    Some((&s[..first], &s[first..end], &s[end..]))
324}
325
326/// The same part: equal names, or equal prefix and digits with one trailing
327/// letter group a prefix of the other (`SM16269S` in `SM16269SH`,
328/// `ICN2263` in `ICN2263ALL`). `ICND2263` is a different part.
329fn same_part(a: &str, b: &str) -> bool {
330    if a.eq_ignore_ascii_case(b) {
331        return true;
332    }
333    let (Some((pa, da, sa)), Some((pb, db, sb))) = (split_part(a), split_part(b)) else {
334        return false;
335    };
336    let alpha = |s: &str| s.chars().all(|c| c.is_ascii_alphabetic());
337    pa.eq_ignore_ascii_case(pb)
338        && da == db
339        && alpha(sa)
340        && alpha(sb)
341        && (starts(sa, sb) || starts(sb, sa))
342}
343
344fn starts(long: &str, short: &str) -> bool {
345    long.len() >= short.len() && long[..short.len()].eq_ignore_ascii_case(short)
346}
347
348/// The `yyyymmdd` in a vendor image name, when it carries one.
349fn built(name: &str) -> Option<u32> {
350    let b = name.as_bytes();
351    let digit = |j: usize| b.get(j).is_some_and(u8::is_ascii_digit);
352    for i in 0..b.len().saturating_sub(7) {
353        let run = &b[i..i + 8];
354        // A run of exactly eight digits, so a truncated date is not read as one.
355        if run.starts_with(b"20")
356            && run.iter().all(u8::is_ascii_digit)
357            && !(i > 0 && digit(i - 1))
358            && !digit(i + 8)
359        {
360            return name[i..i + 8].parse().ok();
361        }
362    }
363    None
364}
365
366/// True when a `[[tested]]` entry names this spec: the embedded spec at its
367/// path carries the same name, or the file stem does.
368fn tested_with(spec: &PanelSpec, t: &Tested) -> bool {
369    panelspec::embedded::panel(&t.panel)
370        .and_then(|text| PanelSpec::parse(text).ok())
371        .map_or_else(
372            || Path::new(&t.panel).file_stem().is_some_and(|s| s == spec.name.as_str()),
373            |s| s.name == spec.name,
374        )
375}
376
377/// The manifest ranked for a panel on a card, best first.
378///
379/// The rules, by weight: an image the card model records as tested with this
380/// spec; the image's chip list naming the spec's chip; a build kind that
381/// suits the chip class; then version and build date. `config/cards/*.toml`
382/// records no board revision, so nothing here matches `pcb`.
383///
384/// A build for one chip family (`LS0allDA`, `LS9937`, `DP3263`, `DS`) is a
385/// candidate only when its chip list matched.
386#[must_use]
387pub fn select(spec: &PanelSpec, card: &CardModel) -> Vec<Candidate> {
388    let lib = library(spec);
389    let chip = lib.as_ref().map(|l| part(&l.name));
390    let class = lib.as_ref().map(Class::of);
391    let mut out: Vec<Candidate> = manifest()
392        .image
393        .iter()
394        .filter_map(|image| candidate(image, spec, card, chip, class))
395        .collect();
396    out.sort_by(|a, b| {
397        b.score
398            .cmp(&a.score)
399            .then_with(|| b.tie_break().cmp(&a.tie_break()))
400            .then_with(|| a.image.name.cmp(&b.image.name))
401    });
402    out
403}
404
405fn candidate(
406    image: &'static Image,
407    spec: &PanelSpec,
408    card: &CardModel,
409    chip: Option<&str>,
410    class: Option<Class>,
411) -> Option<Candidate> {
412    let mut score = 0;
413    let mut reasons = Vec::new();
414
415    if card
416        .tested
417        .iter()
418        .any(|t| t.firmware == image.name && tested_with(spec, t))
419    {
420        score += TESTED;
421        reasons.push(format!("driven on the {} with this spec", card.name));
422    }
423
424    let hit = chip.and_then(|c| image.chips.iter().find(|listed| same_part(c, listed)));
425    match (chip, hit) {
426        (Some(c), Some(listed)) if listed.eq_ignore_ascii_case(c) => {
427            score += CHIP_EXACT;
428            reasons.push(format!("names {listed}"));
429        }
430        (Some(c), Some(listed)) => {
431            score += CHIP_FAMILY;
432            reasons.push(format!("names {listed}, the {c} family"));
433        }
434        _ => {}
435    }
436
437    // A kind built for one chip family says nothing about any other chip.
438    let general =
439        image.kind.eq_ignore_ascii_case("PWM") || image.kind.eq_ignore_ascii_case("Normal");
440    if !general && hit.is_none() {
441        return None;
442    }
443    if let Some(class) = class {
444        if image.kind.eq_ignore_ascii_case(class.kind()) {
445            score += KIND;
446            reasons.push(format!("{} suits {}", class.kind(), class.what()));
447        } else if general {
448            reasons.push(format!("{} does not suit {}", image.kind, class.what()));
449        }
450    }
451    if reasons.is_empty() {
452        reasons.push("nothing matched".to_owned());
453    }
454    Some(Candidate { image, score, reasons })
455}
456
457/// The one image a ranking chose: the top candidate, when rule 1 or rule 2
458/// decided it and nothing else ranks with it.
459///
460/// # Errors
461/// Names `chip` and the top five candidates with their reasons.
462pub fn chosen<'a>(ranked: &'a [Candidate], chip: &str) -> Result<&'a Candidate> {
463    let top = ranked.first().filter(|c| c.decided());
464    let tie = |a: &Candidate, b: &Candidate| a.score == b.score && a.tie_break() == b.tie_break();
465    match (top, ranked.get(1)) {
466        (Some(c), Some(next)) if tie(c, next) => Err(refusal(ranked, chip)),
467        (Some(c), _) => Ok(c),
468        (None, _) => Err(refusal(ranked, chip)),
469    }
470}
471
472fn refusal(ranked: &[Candidate], chip: &str) -> anyhow::Error {
473    let mut text = format!("no firmware chosen for {chip}: {} candidates", ranked.len());
474    for c in ranked.iter().take(5) {
475        let _ = write!(
476            text,
477            "\n  {} {} {}: {}",
478            c.image.name,
479            c.image.version,
480            c.image.kind,
481            c.why()
482        );
483    }
484    anyhow::anyhow!(text)
485}
486
487/// The image to install for a panel on a card.
488///
489/// # Errors
490/// When no candidate was decided by the chip or a tested entry, or two rank
491/// alike; the message is the ranking.
492pub fn pick(spec: &PanelSpec, card: &CardModel) -> Result<&'static Image> {
493    Ok(chosen(&select(spec, card), &chip_name(spec))?.image)
494}
495
496/// `rxp firmware pick`: the ranking, best first, and what it chose.
497pub fn print_pick(spec: &PanelSpec, card: &CardModel, top: usize, p: &mut dyn Progress) {
498    let chip = chip_name(spec);
499    let ranked = select(spec, card);
500    p.err(&format!(
501        "spec {}, chip {chip}, card {}: {} candidates",
502        spec.name,
503        card.name,
504        ranked.len()
505    ));
506    p.out(&format!(
507        "{:<62} {:<7} {:<7} {:<20} why",
508        "name", "version", "kind", "chips"
509    ));
510    for c in ranked.iter().take(top) {
511        p.out(&format!(
512            "{:<62} {:<7} {:<7} {:<20} {}",
513            c.image.name,
514            c.image.version.to_string(),
515            c.image.kind,
516            if c.image.chips.is_empty() { "-".to_owned() } else { c.image.chips.join(",") },
517            c.why()
518        ));
519    }
520    match chosen(&ranked, &chip) {
521        Ok(c) => p.out(&format!("pick: {}", c.image.name)),
522        Err(e) => p.err(&format!("pick: {e:#}")),
523    }
524}
525
526#[cfg(test)]
527mod tests {
528    use super::*;
529
530    const NAME: &str = "E320_PWM_FPGA16.53_20231227_SM16386S_SM16269SH.hex";
531
532    fn root() -> PathBuf {
533        Path::new(env!("CARGO_MANIFEST_DIR")).join("../..")
534    }
535
536    #[derive(Default)]
537    struct Lines {
538        out: Vec<String>,
539        err: Vec<String>,
540    }
541
542    impl Progress for Lines {
543        fn out(&mut self, line: &str) {
544            self.out.push(line.to_string());
545        }
546        fn err(&mut self, line: &str) {
547            self.err.push(line.to_string());
548        }
549    }
550
551    /// The archived image, when a local archive exists; the tests that need
552    /// the bytes skip without it (the images are not in the repository).
553    fn archived() -> Option<std::path::PathBuf> {
554        let p = root().join(ARCHIVE).join(NAME);
555        p.is_file().then_some(p)
556    }
557
558    #[test]
559    fn a_name_resolves_to_the_archive_and_a_path_to_its_entry() {
560        let Some(archived) = archived() else { return };
561        let r = resolve_in(&root(), NAME).unwrap();
562        assert_eq!(r.path, archived);
563        assert_eq!(r.image.map(|i| i.version), Some(receivers::Version(16, 53)));
564
565        let by_path = root().join(ARCHIVE).join(NAME);
566        let r = resolve(by_path.to_str().unwrap()).unwrap();
567        assert!(r.image.is_some());
568        let mut lines = Lines::default();
569        assert!(load(by_path.to_str().unwrap(), &mut lines).unwrap().verified);
570        assert!(lines.err.is_empty());
571
572        let r = resolve("build/other.hex").unwrap();
573        assert!(r.image.is_none());
574        assert_eq!(r.path, Path::new("build/other.hex"));
575        assert_eq!(resolve("").unwrap().path, Path::new(""));
576    }
577
578    #[test]
579    fn a_manifest_entry_with_the_wrong_bytes_is_refused() {
580        let dir = std::env::temp_dir().join(format!("rxp-firmware-test-{}", std::process::id()));
581        std::fs::create_dir_all(&dir).unwrap();
582        let bad = dir.join(NAME);
583        std::fs::write(&bad, b"not the image").unwrap();
584        let mut lines = Lines::default();
585        let e = load(bad.to_str().unwrap(), &mut lines).unwrap_err().to_string();
586        assert!(e.contains("size"), "{e}");
587        assert!(e.contains("refusing"), "{e}");
588
589        let other = dir.join("unlisted.hex");
590        std::fs::write(&other, b"anything").unwrap();
591        let l = load(other.to_str().unwrap(), &mut lines).unwrap();
592        assert!(!l.verified);
593        assert_eq!(l.bytes, b"anything");
594        assert!(lines.err.iter().any(|l| l.contains("not in config/firmware.toml")));
595        std::fs::remove_dir_all(&dir).unwrap();
596    }
597
598    fn bench_spec() -> PanelSpec {
599        PanelSpec::load(root().join("config/panels/p25-128x64-sm16269s.toml")).unwrap()
600    }
601
602    /// A spec whose only interesting field is the chip library it names.
603    fn spec_for(library: &str) -> PanelSpec {
604        let mut s = bench_spec();
605        s.name = "test".into();
606        s.chip.library = library.into();
607        s
608    }
609
610    fn e120() -> &'static receivers::CardModel {
611        receivers::by_name("E120").unwrap()
612    }
613
614    #[test]
615    fn the_bench_spec_picks_the_image_it_was_driven_with() {
616        let spec = bench_spec();
617        let card = e120();
618        assert_eq!(chip_name(&spec), "SM16269S");
619        let ranked = select(&spec, card);
620        assert_eq!(ranked[0].image.name, NAME);
621        assert_eq!(ranked[0].score, TESTED + CHIP_FAMILY + KIND);
622        assert!(ranked[0].reasons.iter().any(|r| r.contains("driven on the E120")));
623        assert_eq!(pick(&spec, card).unwrap().name, NAME);
624
625        // Rule 2 alone reaches the same image: it is the only one whose chip
626        // list names the SM16269S family.
627        let mut untested = card.clone();
628        untested.tested.clear();
629        let ranked = select(&spec, &untested);
630        assert_eq!(ranked[0].image.name, NAME);
631        assert_eq!(ranked[0].score, CHIP_FAMILY + KIND);
632        assert!(ranked[0].reasons.iter().any(|r| r.contains("SM16269SH")));
633        assert_eq!(pick(&spec, &untested).unwrap().name, NAME);
634    }
635
636    #[test]
637    fn an_spwm_chip_no_image_names_is_refused_with_the_ranking() {
638        // No image in config/firmware.toml lists ICN2053.
639        let spec = spec_for("config/chips/icn2053.toml");
640        let ranked = select(&spec, e120());
641        assert_eq!(chip_name(&spec), "ICN2053");
642        assert!(ranked.iter().all(|c| c.score <= KIND));
643        // Rule 3 still sorts the S-PWM builds first.
644        assert!(ranked[0].image.kind.eq_ignore_ascii_case("PWM"));
645        // A build for one chip family is not a candidate for another chip.
646        assert!(ranked.iter().all(|c| {
647            c.image.kind.eq_ignore_ascii_case("PWM") || c.image.kind.eq_ignore_ascii_case("Normal")
648        }));
649        let e = pick(&spec, e120()).unwrap_err().to_string();
650        assert!(e.starts_with("no firmware chosen for ICN2053: "), "{e}");
651        assert_eq!(e.lines().count(), 6, "{e}");
652        assert!(e.contains(&ranked[0].image.name), "{e}");
653    }
654
655    #[test]
656    fn a_plain_shift_register_prefers_a_normal_build() {
657        // No shipped chip library classes as plain: every one carries either a
658        // register table or a chip_custom block, the derived MBI5124 included.
659        // The rule is pinned here against a library of the shape a plain part
660        // would have.
661        let plain = ChipLibrary::parse(
662            "name = \"MBI5124\"\nfamily_id = 6\nserial_clock = 8\nchip_control = [0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0]\n",
663        )
664        .unwrap();
665        assert_eq!(Class::of(&plain), Class::Plain);
666        let derived = ChipLibrary::parse(
667            &std::fs::read_to_string(root().join("config/chips/mbi5124.toml")).unwrap(),
668        )
669        .unwrap();
670        assert_eq!(part(&derived.name), "MBI5124");
671        assert_eq!(Class::of(&derived), Class::Spwm);
672
673        // The manifest lists no MBI5124, so rule 3 alone orders the ranking.
674        let spec = spec_for("config/chips/mbi5124.toml");
675        let card = e120();
676        let scored = |class| -> Vec<&'static Image> {
677            manifest()
678                .image
679                .iter()
680                .filter_map(|i| candidate(i, &spec, card, Some("MBI5124"), Some(class)))
681                .filter(|c| c.score > 0)
682                .map(|c| c.image)
683                .collect()
684        };
685        let plain = scored(Class::Plain);
686        assert!(!plain.is_empty());
687        assert!(plain.iter().all(|i| i.kind.eq_ignore_ascii_case("Normal")));
688        assert!(scored(Class::Spwm).iter().all(|i| i.kind.eq_ignore_ascii_case("PWM")));
689        // The derived library's chip_custom block classes it S-PWM, so the
690        // shipped file ranks the PWM builds first.
691        assert!(select(&spec, card)[0].image.kind.eq_ignore_ascii_case("PWM"));
692    }
693
694    #[test]
695    fn part_names_match_the_vendors_suffix_forms() {
696        assert!(same_part("SM16269S", "SM16269SH"));
697        assert!(same_part("ICN2053", "icn2053"));
698        assert!(same_part("ICN2263", "ICN2263ALL"));
699        assert!(!same_part("ICN2263", "ICND2263"));
700        assert!(!same_part("SM16269S", "SM16289N"));
701        assert!(!same_part("MBI5124", "MBI5153"));
702        assert_eq!(part("SM16269S (0x0214) — NO VENDOR DATA; stub only"), "SM16269S");
703        assert_eq!(part("SM16269 (LEDSetting 2.2.6)"), "SM16269");
704        assert_eq!(built(NAME), Some(20_231_227));
705        assert_eq!(built("E320_PCB6.0_PWM_FPGA12.52_2024527_FDFP3.0.hex"), None);
706        assert_eq!(built("image.hex"), None);
707    }
708
709    #[test]
710    fn every_manifest_entry_is_ranked_for_a_chip_it_names() {
711        // The kinds beyond PWM/Normal are only reached through rule 2.
712        let spec = bench_spec();
713        let special = manifest()
714            .image
715            .iter()
716            .filter(|i| !i.kind.eq_ignore_ascii_case("PWM") && !i.kind.eq_ignore_ascii_case("Normal"))
717            .count();
718        assert!(special > 0);
719        assert_eq!(select(&spec, e120()).len() + special, manifest().image.len());
720    }
721
722    #[test]
723    fn fetch_prefers_a_verified_local_copy_and_names_unknown_images() {
724        let mut lines = Lines::default();
725        if let Some(archived) = archived() {
726            fetch_in(&root(), NAME, &mut lines).unwrap();
727            assert_eq!(lines.out, [archived.display().to_string()]);
728        }
729        let e = fetch("missing.hex", &mut lines).unwrap_err().to_string();
730        assert!(e.contains("not in config/firmware.toml"), "{e}");
731    }
732}