1use 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
17pub const ARCHIVE: &str = "third-party/firmware";
19
20pub const AUTO: &str = "auto";
23
24pub 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#[derive(Debug)]
37pub struct Resolved {
38 pub path: PathBuf,
39 pub image: Option<&'static Image>,
40}
41
42pub 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#[derive(Debug)]
82pub struct Loaded {
83 pub path: String,
85 pub bytes: Vec<u8>,
86 pub verified: bool,
88}
89
90pub 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#[must_use]
116pub const fn checked(l: &Loaded) -> &'static str {
117 if l.verified {
118 "sha256 verified"
119 } else {
120 "unverified"
121 }
122}
123
124fn 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
136pub 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
161pub 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 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
216const TESTED: u32 = 1000;
220const CHIP_EXACT: u32 = 500;
222const CHIP_FAMILY: u32 = 400;
224const KIND: u32 = 100;
226
227#[derive(Debug)]
229pub struct Candidate {
230 pub image: &'static Image,
231 pub score: u32,
232 pub reasons: Vec<String>,
233}
234
235impl Candidate {
236 #[must_use]
238 pub fn why(&self) -> String {
239 self.reasons.join("; ")
240 }
241
242 const fn decided(&self) -> bool {
244 self.score >= CHIP_FAMILY
245 }
246
247 fn tie_break(&self) -> (receivers::Version, u32) {
249 (self.image.version, built(&self.image.name).unwrap_or(0))
250 }
251}
252
253#[derive(Debug, Clone, Copy, PartialEq, Eq)]
257enum Class {
258 Spwm,
260 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
288fn part(name: &str) -> &str {
291 name.split('(')
292 .next()
293 .unwrap_or(name)
294 .split_whitespace()
295 .next()
296 .unwrap_or(name)
297}
298
299fn 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#[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
317fn 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
326fn 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
348fn 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 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
366fn 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#[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 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
457pub 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
487pub fn pick(spec: &PanelSpec, card: &CardModel) -> Result<&'static Image> {
493 Ok(chosen(&select(spec, card), &chip_name(spec))?.image)
494}
495
496pub 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 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 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 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 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 assert!(ranked[0].image.kind.eq_ignore_ascii_case("PWM"));
645 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 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 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 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 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}