Skip to main content

receivers/
lib.rs

1//! Receiving-card models as data. Each `config/cards/<name>.toml` describes
2//! one card: how discovery identifies it, what it can drive, where its flash
3//! holds firmware and parameters, and how far it has been tested. The files
4//! are embedded at build time; nothing here reads the filesystem.
5
6pub mod firmware;
7
8use serde::Deserialize;
9use std::fmt;
10use std::ops::Range;
11use std::sync::OnceLock;
12
13mod embedded {
14    include!(concat!(env!("OUT_DIR"), "/cards.rs"));
15}
16
17/// One receiving card.
18#[derive(Debug, Clone, Deserialize)]
19#[serde(deny_unknown_fields)]
20pub struct CardModel {
21    /// The name `--card` takes, matched without regard to case.
22    pub name: String,
23    pub vendor: String,
24    /// The protocol family: `colorlight` is the only one implemented.
25    pub family: String,
26    /// The card-type byte in the discovery reply. Absent when no card of the
27    /// model has been seen and no source states it: `by_id` then never
28    /// returns the model, and a reply carrying an unlisted byte is reported
29    /// as an unknown model rather than resolved to some other file.
30    #[serde(default)]
31    pub id: Option<u8>,
32    pub status: Status,
33    #[serde(default)]
34    pub notes: String,
35    /// A photo for the web app, relative to `web/static` (`cards/e120.jpg`).
36    #[serde(default)]
37    pub image: Option<String>,
38    /// Where the photo came from.
39    #[serde(default)]
40    pub image_source: Option<String>,
41    /// Specification sheet.
42    #[serde(default)]
43    pub datasheet: Option<String>,
44    /// Panels driven on a bench with this card, one entry per measurement.
45    #[serde(default)]
46    pub tested: Vec<Tested>,
47    pub limits: Limits,
48    pub memory: Memory,
49    pub firmware: Firmware,
50}
51
52/// How far the model has been exercised.
53#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Deserialize)]
54#[serde(rename_all = "lowercase")]
55pub enum Status {
56    /// Driven on a bench.
57    Tested,
58    /// Configurations generate; never driven.
59    Generates,
60    Unsupported,
61}
62
63impl fmt::Display for Status {
64    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
65        f.write_str(match self {
66            Self::Tested => "tested",
67            Self::Generates => "generates",
68            Self::Unsupported => "unsupported",
69        })
70    }
71}
72
73/// One panel driven on a bench with the card.
74#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
75#[serde(deny_unknown_fields)]
76pub struct Tested {
77    /// The spec it was driven with, relative to the repository root.
78    pub panel: String,
79    /// The firmware the card ran: an image name from `config/firmware.toml`.
80    pub firmware: String,
81}
82
83impl Tested {
84    /// The manifest entry `firmware` names.
85    #[must_use]
86    pub fn image(&self) -> Option<&'static firmware::Image> {
87        firmware::image(&self.firmware)
88    }
89
90    /// The version the card reported, from the manifest.
91    #[must_use]
92    pub fn version(&self) -> Option<Version> {
93        self.image().map(|i| i.version)
94    }
95}
96
97/// What the card can drive, as its specification states it.
98#[derive(Debug, Clone, Deserialize)]
99#[serde(deny_unknown_fields)]
100pub struct Limits {
101    pub max_width: u16,
102    pub max_height: u16,
103    pub hub_ports: u8,
104    /// Cards on one chain; absent when the specification does not say.
105    pub chain: Option<u16>,
106}
107
108/// The flash map: banks by address, the parameter block by index.
109#[derive(Debug, Clone, Deserialize)]
110#[serde(deny_unknown_fields)]
111pub struct Memory {
112    pub block_bytes: u32,
113    pub primary_bank: u32,
114    pub bank_bytes: u32,
115    pub golden_bank: u32,
116    pub parameter_block: u8,
117    pub eeprom_mirror: u32,
118    #[serde(default)]
119    pub guarded: Vec<Guard>,
120    pub boot_image: BootImage,
121}
122
123impl Memory {
124    /// Blocks of the primary bank.
125    #[must_use]
126    pub fn primary_blocks(&self) -> Range<u8> {
127        let first = (self.primary_bank / self.block_bytes) as u8;
128        first..first + self.bank_blocks()
129    }
130
131    /// Blocks in one bank.
132    #[must_use]
133    pub fn bank_blocks(&self) -> u8 {
134        self.bank_bytes.div_ceil(self.block_bytes) as u8
135    }
136
137    /// First block of the golden bank.
138    #[must_use]
139    pub fn golden_block(&self) -> u8 {
140        (self.golden_bank / self.block_bytes) as u8
141    }
142
143    /// Blocks `version` write-protects from the host page-write path; empty
144    /// when no range lists it.
145    #[must_use]
146    pub fn guarded_blocks(&self, version: Version) -> &[u8] {
147        self.guarded
148            .iter()
149            .find(|g| g.covers(version))
150            .map_or(&[], |g| g.blocks.as_slice())
151    }
152
153    /// The 256-byte page index of the embedded `.rcvbp`: the parameter
154    /// block's first page plus the region offset.
155    #[must_use]
156    pub fn config_page(&self) -> u16 {
157        (u16::from(self.parameter_block) << 8) | (self.boot_image.rcvbp / 0x100) as u16
158    }
159}
160
161/// Blocks a firmware version range guards from the host path.
162#[derive(Debug, Clone, Deserialize)]
163#[serde(deny_unknown_fields)]
164pub struct Guard {
165    pub from: Version,
166    /// Inclusive; open-ended when absent.
167    pub to: Option<Version>,
168    pub blocks: Vec<u8>,
169}
170
171impl Guard {
172    fn covers(&self, v: Version) -> bool {
173        self.from <= v && self.to.is_none_or(|to| v <= to)
174    }
175}
176
177/// A firmware version as the card reports it, `major.minor`.
178#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
179pub struct Version(pub u8, pub u8);
180
181impl fmt::Display for Version {
182    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
183        write!(f, "{}.{}", self.0, self.1)
184    }
185}
186
187impl std::str::FromStr for Version {
188    type Err = String;
189
190    fn from_str(s: &str) -> Result<Self, String> {
191        let (a, b) = s
192            .split_once('.')
193            .ok_or_else(|| format!("version {s:?} is not major.minor"))?;
194        let n = |v: &str| v.parse::<u8>().map_err(|e| format!("version {s:?}: {e}"));
195        Ok(Self(n(a)?, n(b)?))
196    }
197}
198
199impl<'de> Deserialize<'de> for Version {
200    fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
201        let s = String::deserialize(d)?;
202        s.parse().map_err(serde::de::Error::custom)
203    }
204}
205
206/// Region offsets inside the parameter block, and the two limits the
207/// vendor applies there.
208#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
209#[serde(deny_unknown_fields)]
210pub struct BootImage {
211    pub basic_pack: usize,
212    pub data_swap: usize,
213    pub module_positions: usize,
214    pub chip_page: usize,
215    pub void_line: usize,
216    pub void_line_columns: usize,
217    pub anti_void: usize,
218    pub mapping: usize,
219    pub scan_table: usize,
220    pub rcvbp: usize,
221    /// Pixel-map entries the mapping region holds.
222    pub map_entries: usize,
223    /// Largest embedded `.rcvbp` the card accepts.
224    pub rcvbp_max: usize,
225}
226
227impl BootImage {
228    /// Bytes of the mapping region: three per entry.
229    #[must_use]
230    pub const fn mapping_len(&self) -> usize {
231        self.map_entries * 3
232    }
233}
234
235/// How firmware images are named and installed.
236#[derive(Debug, Clone, Deserialize)]
237#[serde(deny_unknown_fields)]
238pub struct Firmware {
239    /// Vendor image names, `{version}` standing for `major.minor`.
240    pub image_pattern: String,
241    /// The card stages an image in SDRAM and programs itself.
242    pub sdram_staging: bool,
243}
244
245impl Firmware {
246    /// The version in an image file name, read after the pattern's last
247    /// `_`-separated token before `{version}` (`FPGA` in `E320_PWM_FPGA16.53_...`).
248    #[must_use]
249    pub fn version_in_name(&self, path: &str) -> Option<Version> {
250        let name = std::path::Path::new(path).file_name()?.to_str()?;
251        let prefix = self.image_pattern.split("{version}").next()?;
252        let marker = prefix.rsplit('_').next().filter(|m| !m.is_empty())?;
253        let rest = &name[name.find(marker)? + marker.len()..];
254        let end = rest
255            .find(|c: char| !(c.is_ascii_digit() || c == '.'))
256            .unwrap_or(rest.len());
257        rest[..end].parse().ok()
258    }
259}
260
261fn parse_all() -> Vec<CardModel> {
262    let mut models: Vec<CardModel> = embedded::FILES
263        .iter()
264        .map(|(file, text)| {
265            toml::from_str(text).unwrap_or_else(|e| panic!("config/cards/{file}: {e}"))
266        })
267        .collect();
268    models.sort_by(|a, b| a.status.cmp(&b.status).then_with(|| a.name.cmp(&b.name)));
269    models
270}
271
272/// Every embedded model: tested first, then by name.
273pub fn models() -> &'static [CardModel] {
274    static MODELS: OnceLock<Vec<CardModel>> = OnceLock::new();
275    MODELS.get_or_init(parse_all)
276}
277
278/// The model whose discovery id byte is `id`; `None` when no file carries
279/// it. A model file without an `id` matches nothing.
280#[must_use]
281pub fn by_id(id: u8) -> Option<&'static CardModel> {
282    models().iter().find(|m| m.id == Some(id))
283}
284
285/// The model called `name`, case-insensitively.
286#[must_use]
287pub fn by_name(name: &str) -> Option<&'static CardModel> {
288    models().iter().find(|m| m.name.eq_ignore_ascii_case(name))
289}
290
291/// The first tested model: what offline generation targets when no card is
292/// named.
293#[must_use]
294pub fn default_model() -> &'static CardModel {
295    &models()[0]
296}
297
298#[cfg(test)]
299mod tests {
300    use super::*;
301
302    #[test]
303    fn every_embedded_file_parses_and_ids_are_unique() {
304        let all = models();
305        assert!(!all.is_empty());
306        for (i, m) in all.iter().enumerate() {
307            assert!(
308                m.id.is_none() || all[..i].iter().all(|o| o.id != m.id),
309                "{}: id shared",
310                m.name
311            );
312            assert!(
313                all[..i]
314                    .iter()
315                    .all(|o| !o.name.eq_ignore_ascii_case(&m.name)),
316                "{}: name shared",
317                m.name
318            );
319            assert!(
320                m.memory
321                    .primary_blocks()
322                    .contains(&m.memory.parameter_block),
323                "{}: parameter block outside the bank",
324                m.name
325            );
326            assert!(
327                !m.memory.primary_blocks().contains(&m.memory.golden_block()),
328                "{}: golden bank inside the primary",
329                m.name
330            );
331        }
332        assert_eq!(all[0].status, Status::Tested);
333        for m in all {
334            assert_eq!(m.status == Status::Tested, !m.tested.is_empty(), "{}: status and tested disagree", m.name);
335            for t in &m.tested {
336                assert!(t.image().is_some(), "{}: tested firmware {} is not in config/firmware.toml", m.name, t.firmware);
337            }
338        }
339    }
340
341    /// Every model file says where its numbers came from, and only a model
342    /// driven on a bench carries `[[tested]]`.
343    #[test]
344    fn every_model_cites_a_source_and_only_tested_models_list_panels() {
345        for m in models() {
346            assert!(!m.notes.trim().is_empty(), "{}: no source note", m.name);
347            assert!(
348                m.notes.contains("http") || m.datasheet.is_some(),
349                "{}: the note names no source URL and there is no datasheet",
350                m.name
351            );
352            assert_eq!(
353                m.status == Status::Tested,
354                !m.tested.is_empty(),
355                "{}: only a tested model carries [[tested]]",
356                m.name
357            );
358        }
359    }
360
361    /// An id byte no file carries resolves to nothing rather than to the
362    /// first model.
363    #[test]
364    fn an_unlisted_id_resolves_to_no_model() {
365        assert!(by_id(0x03).is_none());
366        for m in models() {
367            if let Some(id) = m.id {
368                assert!(std::ptr::eq(by_id(id).unwrap(), m), "{}: id lookup", m.name);
369            }
370        }
371    }
372
373    #[test]
374    fn the_e120_is_found_by_id_and_by_name() {
375        let m = by_id(0x64).expect("E120 by id");
376        assert_eq!(m.name, "E120");
377        assert_eq!(
378            m.tested,
379            [Tested {
380                panel: "config/panels/p25-128x64-sm16269s.toml".into(),
381                firmware: "E320_PWM_FPGA16.53_20231227_SM16386S_SM16269SH.hex".into()
382            }]
383        );
384        assert_eq!(m.tested[0].version(), Some(Version(16, 53)));
385        assert!(std::ptr::eq(by_name("e120").unwrap(), m));
386        assert!(std::ptr::eq(default_model(), m));
387        assert!(by_id(0x03).is_none());
388        assert!(by_name("e121").is_none());
389    }
390
391    #[test]
392    fn the_e120_map_reads_as_blocks() {
393        let m = by_name("E120").unwrap().memory.clone();
394        assert_eq!(m.primary_blocks(), 0x00..0x0b);
395        assert_eq!(m.bank_blocks(), 11);
396        assert_eq!(m.golden_block(), 0x20);
397        assert_eq!(m.config_page(), 0x0780);
398        assert_eq!(m.guarded_blocks(Version(16, 53)), &[0, 1, 2, 8]);
399        assert_eq!(m.guarded_blocks(Version(17, 0)), &[0, 1, 2, 8]);
400        assert!(m.guarded_blocks(Version(10, 81)).is_empty());
401        assert_eq!(m.boot_image.mapping_len(), 0x3000);
402    }
403
404    #[test]
405    fn versions_order_numerically_and_round_trip() {
406        assert!(Version(16, 53) < Version(17, 0));
407        assert!(Version(9, 53) < Version(16, 53));
408        assert_eq!("16.53".parse::<Version>(), Ok(Version(16, 53)));
409        assert_eq!(Version(16, 53).to_string(), "16.53");
410        assert!("16".parse::<Version>().is_err());
411    }
412
413    #[test]
414    fn the_version_is_read_from_the_image_name() {
415        let fw = &by_name("E120").unwrap().firmware;
416        assert_eq!(
417            fw.version_in_name(
418                "third-party/firmware/E320_PWM_FPGA16.53_20231227_SM16386S_SM16269SH.hex"
419            ),
420            Some(Version(16, 53))
421        );
422        assert_eq!(
423            fw.version_in_name("E320_PCB6.0_PWM_FPGA10.81_20230907.hex"),
424            Some(Version(10, 81))
425        );
426        assert_eq!(fw.version_in_name("image.hex"), None);
427    }
428}