Skip to main content

ops/
model.rs

1//! Which card is on the link: `--card` or the daemon's setting names a
2//! model, otherwise the discovery reply's id byte picks one from
3//! `config/cards/`.
4
5use crate::capture::discover_one;
6use crate::{protocol, Ctx, Progress};
7use anyhow::{bail, Context, Result};
8use panelspec::{embedded, ChipLibrary, PanelSpec};
9use receivers::{by_id, by_name, models, CardModel, Status};
10use std::collections::BTreeMap;
11use std::fmt::Write as _;
12
13/// The model for a discovered card.
14///
15/// # Errors
16/// Fails when no model file carries the card's id byte.
17pub fn for_card(info: &protocol::DiscoveryInfo) -> Result<&'static CardModel> {
18    by_id(info.card_id).with_context(|| {
19        format!(
20            "card id 0x{:02x} matches no model in config/cards (pass --card NAME to override)",
21            info.card_id
22        )
23    })
24}
25
26/// The model named `name`, for `--card` and the daemon's setting.
27///
28/// # Errors
29/// Fails on an unknown name, listing the known ones.
30pub fn named(name: &str) -> Result<&'static CardModel> {
31    by_name(name).with_context(|| {
32        let known: Vec<&str> = models().iter().map(|m| m.name.as_str()).collect();
33        format!("unknown card model {name:?}; known: {}", known.join(", "))
34    })
35}
36
37/// The context's model, or the one discovery returns within `wait` seconds.
38///
39/// # Errors
40/// Fails when no card answers or its id has no model.
41pub fn resolve(ctx: &Ctx, wait: u64) -> Result<&'static CardModel> {
42    if let Some(m) = ctx.model {
43        return Ok(m);
44    }
45    let Some(info) = discover_one(ctx, wait)? else {
46        bail!("no response on {} within {wait}s", ctx.iface);
47    };
48    for_card(&info)
49}
50
51/// The colorlight allowlists for a model.
52#[must_use]
53pub fn flash_map(m: &CardModel) -> protocol::FlashMap {
54    protocol::FlashMap {
55        param_block: m.memory.parameter_block,
56        firmware_blocks: m.memory.primary_blocks(),
57        golden_block: m.memory.golden_block(),
58        screen_record_addr: m.memory.eeprom_mirror,
59    }
60}
61
62/// Bytes in the primary firmware bank.
63#[must_use]
64pub fn bank_bytes(m: &CardModel) -> usize {
65    m.memory.bank_bytes as usize
66}
67
68/// `rxp card models`: one line per model, tested first.
69pub fn list(p: &mut dyn Progress) {
70    for m in models() {
71        // A model file with no sourced id byte prints `id=?`; discovery
72        // cannot pick it, `--card NAME` is the only way in.
73        let id = m.id.map_or_else(|| "?   ".to_owned(), |b| format!("0x{b:02x}"));
74        // `Status`'s Display writes straight to the formatter, so pad the
75        // string rather than the value.
76        p.out(&format!(
77            "{:<8} id={id}  {:<11} {}",
78            m.name,
79            m.status.to_string(),
80            m.vendor
81        ));
82    }
83}
84
85/// The matrix cell for a status.
86const fn symbol(s: Status) -> &'static str {
87    match s {
88        Status::Tested => "✅",
89        Status::Generates => "⚠️",
90        Status::Unsupported => "❌",
91    }
92}
93
94/// Columns the model files do not carry: the rest of Colorlight's classic
95/// family shares the E320 gateware line and the same firmware archive but
96/// has no model file, and no other vendor's protocol is implemented.
97const OTHER_COLUMNS: [(&str, Status); 2] = [
98    ("Colorlight 5A-75B · 5A-75E", Status::Generates),
99    ("Linsn · Novastar · Huidu", Status::Unsupported),
100];
101
102/// Rows the derived set does not carry: chips whose register record is not
103/// decoded, and module wirings the pixel-map generator does not produce.
104const UNSUPPORTED_ROWS: [&str; 2] = [
105    "SM16369S · ICND2263 (register record not decoded)",
106    "snake-wired outdoor modules (1/2, 1/4, 1/5, 1/10 scan)",
107];
108
109/// A chip library's name without its parenthetical tag, as
110/// `SM16269 (default parameters)` carries one.
111fn chip_name(lib: &ChipLibrary) -> String {
112    lib.name.split(" (").next().unwrap_or(&lib.name).to_owned()
113}
114
115/// The driver-chip family of a chip name: its leading letters, `ICND`
116/// folded into `ICN`.
117fn family(chip: &str) -> String {
118    let letters: String = chip.chars().take_while(char::is_ascii_alphabetic).collect();
119    if letters == "ICND" { "ICN".to_owned() } else { letters }
120}
121
122fn embedded_chip(path: &str) -> Result<ChipLibrary> {
123    let text = embedded::chip(path).with_context(|| format!("{path}: not embedded"))?;
124    ChipLibrary::parse(text).with_context(|| format!("parse {path}"))
125}
126
127/// `rxp card models --markdown`: the README's Tested matrix. One column per
128/// model file, one row per panel a model was driven with, then one row per
129/// driver-chip family among the derived module classes.
130///
131/// # Errors
132/// Fails when a model names a panel or chip library that is not embedded.
133pub fn matrix_markdown() -> Result<String> {
134    let mut s = String::from("✅ driven on the bench · ⚠️ configuration generates, never driven · ❌ not supported
135
136");
137    let _ = write!(s, "| panel (driver chip) |");
138    for m in models() {
139        let _ = write!(s, " {} {} |", m.vendor, m.name);
140    }
141    for (name, _) in OTHER_COLUMNS {
142        let _ = write!(s, " {name} |");
143    }
144    s.push_str("
145|---|");
146    for _ in 0..models().len() + OTHER_COLUMNS.len() {
147        s.push_str(":---:|");
148    }
149    s.push('\n');
150    let row = |s: &mut String, label: &str, cell: &dyn Fn(&CardModel) -> Status, other: &dyn Fn(Status) -> Status| {
151        let _ = write!(s, "| {label} |");
152        for m in models() {
153            let _ = write!(s, " {} |", symbol(cell(m)));
154        }
155        for (_, status) in OTHER_COLUMNS {
156            let _ = write!(s, " {} |", symbol(other(status)));
157        }
158        s.push('\n');
159    };
160
161    let mut driven: Vec<&str> = Vec::new();
162    for t in models().iter().flat_map(|m| &m.tested) {
163        if !driven.contains(&t.panel.as_str()) {
164            driven.push(&t.panel);
165        }
166    }
167    for path in driven {
168        let text = embedded::panel(path).with_context(|| format!("{path}: not embedded"))?;
169        let spec = PanelSpec::parse(text).with_context(|| format!("parse {path}"))?;
170        let chip = chip_name(&embedded_chip(&spec.chip.library)?);
171        let label = format!(
172            "{}x{} 1/{}, {chip} (`{path}`)",
173            spec.module.width, spec.module.height, spec.module.scan
174        );
175        let cell = |m: &CardModel| {
176            if m.tested.iter().any(|t| t.panel == path) {
177                Status::Tested
178            } else {
179                m.status.max(Status::Generates)
180            }
181        };
182        row(&mut s, &label, &cell, &|o| o);
183    }
184
185    // Derived module classes, grouped by the family of the chip each names.
186    let mut families: BTreeMap<String, Vec<String>> = BTreeMap::new();
187    let mut classes = 0usize;
188    for (path, text) in embedded::PANELS {
189        let spec = PanelSpec::parse(text).with_context(|| format!("parse {path}"))?;
190        if spec.meta.status == panelspec::Status::Verified {
191            continue;
192        }
193        classes += 1;
194        let chip = chip_name(&embedded_chip(&spec.chip.library)?);
195        let chips = families.entry(family(&chip)).or_default();
196        if !chips.contains(&chip) {
197            chips.push(chip);
198        }
199    }
200    let generates = |m: &CardModel| m.status.max(Status::Generates);
201    for chips in families.values_mut() {
202        chips.sort();
203        row(&mut s, &chips.join(" · "), &generates, &|o| o);
204    }
205    for label in UNSUPPORTED_ROWS {
206        row(&mut s, label, &|_| Status::Unsupported, &|_| Status::Unsupported);
207    }
208    let _ = write!(
209        s,
210        "
211The ⚠️ chip rows are the {classes} derived module classes in `config/panels/`, grouped by driver-chip family."
212    );
213    Ok(s)
214}
215
216#[cfg(test)]
217mod tests {
218    use super::*;
219
220    /// The README's matrix is what `--markdown` prints.
221    #[test]
222    fn the_readme_matrix_is_generated() {
223        let readme = std::fs::read_to_string(concat!(env!("CARGO_MANIFEST_DIR"), "/../../README.md")).unwrap();
224        let (_, rest) = readme.split_once("<!-- tested -->").expect("<!-- tested --> marker");
225        let (table, _) = rest.split_once("<!-- /tested -->").expect("<!-- /tested --> marker");
226        assert_eq!(table.trim(), matrix_markdown().unwrap().trim(), "regenerate with: rxp card models --markdown");
227    }
228
229    #[test]
230    fn the_matrix_has_one_column_per_model_and_the_tested_panel_first() {
231        let s = matrix_markdown().unwrap();
232        let lines: Vec<&str> = s.lines().collect();
233        assert_eq!(lines[2], "| panel (driver chip) | Colorlight E120 | Colorlight E320 | Colorlight E80 | Colorlight 5A-75B · 5A-75E | Linsn · Novastar · Huidu |");
234        assert_eq!(lines[3], "|---|:---:|:---:|:---:|:---:|:---:|");
235        // Only the E120 is ✅: the other model files generate, never driven.
236        assert_eq!(lines[4], "| 128x64 1/16, SM16269S (`config/panels/p25-128x64-sm16269s.toml`) | ✅ | ⚠️ | ⚠️ | ⚠️ | ❌ |");
237        assert!(lines[5].starts_with("| DP5525 | ⚠️ |"), "{}", lines[5]);
238        assert!(s.contains("| ICN2038S · ICN2053 · ICN2055 · ICN2065 · ICND2163 | ⚠️ | ⚠️ | ⚠️ | ⚠️ | ❌ |"), "{s}");
239        assert_eq!(family("ICND2163"), "ICN");
240        assert_eq!(family("SM16380"), "SM");
241    }
242
243    #[test]
244    fn the_e120_model_gives_the_pinned_allowlists() {
245        assert_eq!(flash_map(named("e120").unwrap()), protocol::E120);
246        assert_eq!(bank_bytes(named("E120").unwrap()), 11 * 0x10000);
247    }
248
249    #[test]
250    fn an_unknown_name_lists_the_models() {
251        let e = named("x").unwrap_err().to_string();
252        assert!(e.contains("E120"), "{e}");
253    }
254}