Skip to main content

panelspec/
lib.rs

1//! A panel spec (`config/panels/*.toml`) and the driver-chip library it names
2//! (`config/chips/*.toml`). Nothing here knows a receiver card's record or
3//! image layout; `rcvbp` turns a spec into Colorlight's formats.
4
5pub mod chips;
6
7pub use chips::{ChipLibrary, ScanPatch};
8
9/// The chip libraries and panel specs under `config/`, embedded at build
10/// time as `(path, text)` pairs.
11///
12/// The path is relative to the repository root. `status = "verified"` files
13/// first, then the rest, each alphabetical.
14pub mod embedded {
15    use anyhow::Context as _;
16
17    include!(concat!(env!("OUT_DIR"), "/libraries.rs"));
18
19    /// The chip library at `path` (`config/chips/...`).
20    #[must_use]
21    pub fn chip(path: &str) -> Option<&'static str> {
22        CHIPS.iter().find(|(p, _)| *p == path).map(|(_, t)| *t)
23    }
24
25    /// The panel spec at `path` (`config/panels/...`).
26    #[must_use]
27    pub fn panel(path: &str) -> Option<&'static str> {
28        PANELS.iter().find(|(p, _)| *p == path).map(|(_, t)| *t)
29    }
30
31    /// Every embedded panel spec, parsed, as `(path, spec)` in embedding
32    /// order.
33    ///
34    /// # Errors
35    /// Fails on a spec that does not parse; the crate's tests keep that from
36    /// being embedded.
37    pub fn specs() -> anyhow::Result<Vec<(&'static str, crate::PanelSpec)>> {
38        PANELS
39            .iter()
40            .map(|&(path, text)| {
41                let spec = crate::PanelSpec::parse(text)
42                    .with_context(|| format!("parse {path}"))?;
43                Ok((path, spec))
44            })
45            .collect()
46    }
47
48    /// The embedded chip library for a chip family id, as `(path, text)`:
49    /// a library an embedded panel spec names wins over one none does
50    /// (the SM16269S family has three), then embedding order.
51    #[must_use]
52    pub fn chip_by_family(family_id: u16) -> Option<(&'static str, &'static str)> {
53        let named: Vec<String> = specs()
54            .unwrap_or_default()
55            .into_iter()
56            .map(|(_, spec)| spec.chip.library)
57            .collect();
58        let has_id = |&&(_, text): &&(&str, &str)| {
59            crate::ChipLibrary::parse(text).is_ok_and(|c| c.family_id == family_id)
60        };
61        CHIPS
62            .iter()
63            .filter(|(path, _)| named.iter().any(|n| n == path))
64            .chain(CHIPS.iter())
65            .find(has_id)
66            .copied()
67    }
68
69    #[cfg(test)]
70    mod tests {
71        use super::*;
72
73        #[test]
74        fn every_embedded_spec_parses_and_carries_meta() {
75            let specs = specs().unwrap();
76            assert_eq!(specs.len(), PANELS.len());
77            for (path, text) in PANELS {
78                let table: toml::Table = text.parse().unwrap();
79                assert!(table.contains_key("meta"), "{path}: no [meta] table");
80            }
81            let (path, bench) = &specs[0];
82            assert_eq!(*path, "config/panels/p25-128x64-sm16269s.toml");
83            assert_eq!(bench.meta.status, crate::Status::Verified);
84            assert_eq!(bench.meta.pitch_mm, Some(2.5));
85            for (path, spec) in &specs[1..] {
86                assert_eq!(spec.meta.status, crate::Status::Derived, "{path}");
87                assert!(spec.meta.sources > 0, "{path}");
88                assert!(!spec.meta.examples.is_empty(), "{path}");
89            }
90        }
91
92        #[test]
93        fn a_chip_id_finds_the_library_the_shipped_specs_use() {
94            // Three libraries carry 0x14C; the bench spec's is the one chosen.
95            let (path, text) = chip_by_family(0x14C).unwrap();
96            assert_eq!(path, "config/chips/sm16269s.toml");
97            assert_eq!(crate::ChipLibrary::parse(text).unwrap().family_id, 0x14C);
98            assert_eq!(chip_by_family(0x85).unwrap().0, "config/chips/icn2053.toml");
99            assert!(chip_by_family(0xFFFF).is_none());
100        }
101
102        #[test]
103        fn the_verified_files_are_embedded_before_the_derived_ones() {
104            assert!(chip("config/chips/sm16269s.toml").is_some());
105            assert!(chip("config/chips/icn2053.toml").is_some());
106            assert!(chip("config/chips/x.toml").is_none());
107            assert_eq!(PANELS[0].0, "config/panels/p25-128x64-sm16269s.toml");
108            assert_eq!(CHIPS[0].0, "config/chips/sm16269s.toml");
109            assert!(panel(PANELS[0].0).is_some());
110            let verified = |text: &str| text.lines().any(|l| l.trim() == r#"status = "verified""#);
111            let sorted = |xs: &[(&str, &str)]| {
112                let plain: Vec<&str> = xs.iter().filter(|(_, t)| verified(t)).map(|(p, _)| *p).collect();
113                let rest: Vec<&str> = xs.iter().filter(|(_, t)| !verified(t)).map(|(p, _)| *p).collect();
114                xs.iter().take(plain.len()).all(|(_, t)| verified(t))
115                    && plain.windows(2).all(|w| w[0] < w[1])
116                    && rest.windows(2).all(|w| w[0] < w[1])
117            };
118            assert!(sorted(CHIPS) && sorted(PANELS));
119            for (p, text) in PANELS {
120                assert!(crate::PanelSpec::parse(text).is_ok(), "{p}");
121            }
122            for (p, text) in CHIPS {
123                assert!(crate::ChipLibrary::parse(text).is_ok(), "{p}");
124            }
125        }
126    }
127}
128
129use anyhow::{bail, Context, Result};
130use serde::{Deserialize, Serialize, Serializer};
131use std::collections::BTreeMap;
132use std::path::Path;
133
134/// Payload length of record 0x01, the bound on `record01_overrides` keys.
135pub const RECORD01_LEN: usize = 764;
136
137/// Maps a spec's `[chip].library` path to the library's TOML text: the
138/// filesystem for the CLI, an embedded set in the browser.
139pub type Loader<'a> = &'a dyn Fn(&str) -> Result<String>;
140
141/// The filesystem loader: the file at the path, relative to the working
142/// directory.
143///
144/// # Errors
145/// Fails if the file cannot be read.
146pub fn read_library(path: &str) -> Result<String> {
147    std::fs::read_to_string(path).with_context(|| format!("read {path}"))
148}
149
150#[derive(Debug, Clone, Deserialize, Serialize)]
151#[serde(deny_unknown_fields)]
152pub struct PanelSpec {
153    /// Name used for output files.
154    pub name: String,
155    /// Where the values came from and how far they are trusted.
156    #[serde(default)]
157    pub meta: Meta,
158    pub module: Module,
159    pub screen: Screen,
160    pub chip: Chip,
161    #[serde(default)]
162    pub color: Color,
163    #[serde(default)]
164    pub current: Current,
165    #[serde(default)]
166    pub timing: Timing,
167    #[serde(default)]
168    pub mapping: Mapping,
169    #[serde(default)]
170    pub boot: Boot,
171    /// Raw record 0x01 byte overrides (`"0x043" = 0x20`), applied last. The
172    /// bench spec's `+0x02F = 1` lives here; nothing displays without it.
173    #[serde(
174        default,
175        deserialize_with = "chips::record01_offsets",
176        serialize_with = "chips::hex_offsets",
177        skip_serializing_if = "BTreeMap::is_empty"
178    )]
179    pub record01_overrides: BTreeMap<usize, u8>,
180}
181
182/// An `f32` written as its shortest decimal (`2.8`, not the f64 expansion
183/// of the nearest binary value); it parses back to the same `f32`.
184struct Short(f32);
185
186impl Serialize for Short {
187    fn serialize<S: Serializer>(&self, s: S) -> std::result::Result<S::Ok, S::Error> {
188        let text = self.0.to_string();
189        s.serialize_f64(text.parse().unwrap_or_else(|_| f64::from(self.0)))
190    }
191}
192
193// serde's `serialize_with` passes the field by reference.
194#[allow(clippy::trivially_copy_pass_by_ref)]
195fn short<S: Serializer>(v: &f32, s: S) -> std::result::Result<S::Ok, S::Error> {
196    Short(*v).serialize(s)
197}
198
199fn shorts<S: Serializer>(v: &[f32], s: S) -> std::result::Result<S::Ok, S::Error> {
200    s.collect_seq(v.iter().map(|&x| Short(x)))
201}
202
203/// The `[meta]` table: the evidence behind the values and how far they are
204/// trusted. Every field has a default, so a spec without the table counts as
205/// derived from no file at all.
206#[derive(Debug, Clone, Default, PartialEq, Deserialize, Serialize)]
207#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
208#[serde(default, deny_unknown_fields)]
209pub struct Meta {
210    /// Pixel pitch in millimetres, when the spec describes one physical module.
211    #[serde(skip_serializing_if = "Option::is_none")]
212    #[cfg_attr(feature = "ts", ts(optional))]
213    pub pitch_mm: Option<f32>,
214    pub status: Status,
215    /// Vendor files the values were taken from.
216    pub sources: u32,
217    /// Share (0..1) of the files for this module class that agree with the
218    /// values; absent when nothing was counted.
219    #[serde(skip_serializing_if = "Option::is_none")]
220    #[cfg_attr(feature = "ts", ts(optional))]
221    pub agreement: Option<f32>,
222    /// A few of the source files by name.
223    pub examples: Vec<String>,
224    /// Control-system vendors whose config files the sources are (the
225    /// format the values were taken from), not who makes the panel.
226    pub vendors: Vec<String>,
227    #[serde(skip_serializing_if = "Option::is_none")]
228    #[cfg_attr(feature = "ts", ts(optional))]
229    pub notes: Option<String>,
230    /// Who makes the panel.
231    #[serde(default, skip_serializing_if = "Option::is_none")]
232    #[cfg_attr(feature = "ts", ts(optional))]
233    pub maker: Option<String>,
234    /// The maker's model name.
235    #[serde(default, skip_serializing_if = "Option::is_none")]
236    #[cfg_attr(feature = "ts", ts(optional))]
237    pub product: Option<String>,
238    /// Product page.
239    #[serde(default, skip_serializing_if = "Option::is_none")]
240    #[cfg_attr(feature = "ts", ts(optional))]
241    pub url: Option<String>,
242    /// Specification sheet.
243    #[serde(default, skip_serializing_if = "Option::is_none")]
244    #[cfg_attr(feature = "ts", ts(optional))]
245    pub datasheet: Option<String>,
246    /// Photo.
247    #[serde(default, skip_serializing_if = "Option::is_none")]
248    #[cfg_attr(feature = "ts", ts(optional))]
249    pub image: Option<String>,
250    #[serde(default, skip_serializing_if = "Option::is_none")]
251    #[cfg_attr(feature = "ts", ts(optional))]
252    pub image_source: Option<String>,
253}
254
255/// The evidence behind a spec or a chip library.
256#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize, Serialize)]
257#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
258#[serde(rename_all = "lowercase")]
259pub enum Status {
260    /// Driven on a bench; the notes say on which card and firmware.
261    Verified,
262    /// Values taken from vendor configuration files; the notes say how many
263    /// agreed.
264    #[default]
265    Derived,
266    /// A placeholder that is not expected to work.
267    Stub,
268}
269
270#[derive(Debug, Clone, Deserialize, Serialize)]
271#[serde(deny_unknown_fields)]
272pub struct Module {
273    /// Pixels across one module.
274    pub width: u16,
275    /// Pixels down one module. The record stores half of this.
276    pub height: u16,
277    /// Scan denominator, e.g. 16 for 1/16.
278    pub scan: u8,
279    /// Serial (data) clock setting, the vendor's SetSerialClockFrequency unit;
280    /// the chip library's default when absent.
281    #[serde(skip_serializing_if = "Option::is_none")]
282    pub serial_clock: Option<u16>,
283    /// Grayscale depth override; derived from the chip registers when absent.
284    #[serde(skip_serializing_if = "Option::is_none")]
285    pub gray_bits: Option<u8>,
286    /// Data line direction: 0/1 vertical, 2/3 horizontal (vendor GetLineDir).
287    #[serde(default)]
288    pub line_dir: u8,
289    /// Data-group / output code (record +0x044 low nibble).
290    #[serde(default = "default_data_groups")]
291    pub data_groups: u8,
292}
293
294#[derive(Debug, Clone, Deserialize, Serialize)]
295#[serde(deny_unknown_fields)]
296pub struct Screen {
297    /// Whole screen this card drives, in pixels (MaxWidth/MaxHeight).
298    pub width: u16,
299    pub height: u16,
300}
301
302#[derive(Debug, Clone, Deserialize, Serialize)]
303#[serde(deny_unknown_fields)]
304pub struct Chip {
305    /// Chip library (`config/chips/*.toml`): ids, register defaults, chip control.
306    pub library: String,
307}
308
309#[derive(Debug, Clone, Deserialize, Serialize)]
310#[serde(deny_unknown_fields)]
311pub struct Color {
312    /// Colour-swap index (record +0x02B).
313    pub swap: u8,
314    /// R/G/B source indices (record +0x02C..0x02E); (2,1,0) = no exchange.
315    pub source: [u8; 3],
316}
317
318impl Default for Color {
319    fn default() -> Self {
320        Self {
321            swap: 3,
322            source: [2, 1, 0],
323        }
324    }
325}
326
327#[derive(Debug, Clone, Deserialize, Serialize)]
328#[serde(deny_unknown_fields)]
329pub struct Current {
330    /// Red, green, blue, virtual-red current gain, 0-63.
331    pub gains: [u8; 4],
332    /// Per-channel current percent (record +0x0B4/B8/BC, f32).
333    #[serde(serialize_with = "shorts")]
334    pub percent: [f32; 3],
335}
336
337impl Default for Current {
338    fn default() -> Self {
339        Self {
340            gains: [43; 4],
341            percent: [0.1; 3],
342        }
343    }
344}
345
346#[derive(Debug, Clone, Deserialize, Serialize)]
347#[serde(deny_unknown_fields)]
348pub struct Timing {
349    #[serde(serialize_with = "short")]
350    pub gamma: f32,
351    #[serde(serialize_with = "short")]
352    pub refresh_hz: f32,
353    /// GCLK setting (record +0x031); vendor default 0x14.
354    pub gclock: u8,
355    /// Minimum OE time (record +0x0AE); the PWM bit-time solver's floor.
356    #[serde(serialize_with = "short")]
357    pub min_oe: f32,
358    /// Luminance level (record +0x026), split across the colour percents.
359    pub luminance_level: u16,
360    /// 8 ns OE enable (record +0x050 bit 0).
361    pub oe_8ns: bool,
362}
363
364impl Default for Timing {
365    fn default() -> Self {
366        Self {
367            gamma: 2.8,
368            refresh_hz: 60.0,
369            gclock: 0x14,
370            min_oe: 1e-4,
371            luminance_level: 188,
372            oe_8ns: true,
373        }
374    }
375}
376
377/// How the module's pixels are wired into the card's scan-line buffer
378/// (record 0x03). The vendor corpus shows two knobs beyond geometry.
379#[derive(Debug, Clone, Deserialize, Serialize)]
380#[serde(deny_unknown_fields)]
381pub struct Mapping {
382    /// Data groups (`stored height / scan` of them) in reverse order in the
383    /// buffer, the vendor default (234 of 241 two-group configs).
384    pub reversed_groups: bool,
385    /// Scan lines addressed bottom-up (`scan-1-row`) instead of top-down.
386    pub reversed_lines: bool,
387    /// Columns per run of the shift chain before it switches data group:
388    /// `[lower 0..b][upper 0..b][lower b..2b]...`. Default (module width) gives
389    /// each group one contiguous half; the bench panel's own file uses 64.
390    #[serde(default, skip_serializing_if = "Option::is_none")]
391    pub block: Option<u16>,
392    /// Displace line positions `width..2*width` off the chain via the void-line
393    /// column table; otherwise the card drives them with a fixed pattern that
394    /// shows as a floor at black (docs/rendering.md). Off reproduces the factory image.
395    #[serde(default = "default_true")]
396    pub gate_phantom_positions: bool,
397}
398
399const fn default_true() -> bool {
400    true
401}
402
403impl Default for Mapping {
404    fn default() -> Self {
405        Self {
406            reversed_groups: true,
407            reversed_lines: false,
408            block: None,
409            gate_phantom_positions: true,
410        }
411    }
412}
413
414#[derive(Debug, Clone, Default, Deserialize, Serialize)]
415#[serde(deny_unknown_fields)]
416pub struct Boot {
417    /// Install the chip-register page so the card arms the drivers at
418    /// power-on. Until the config boots dark this rails the supply.
419    pub arm_at_boot: bool,
420}
421
422const fn default_data_groups() -> u8 {
423    1
424}
425
426impl PanelSpec {
427    /// Read a spec from a TOML file.
428    ///
429    /// # Errors
430    /// Fails on a missing or malformed file.
431    pub fn load(path: impl AsRef<Path>) -> Result<Self> {
432        let path = path.as_ref();
433        let text =
434            std::fs::read_to_string(path).with_context(|| format!("read {}", path.display()))?;
435        Self::parse(&text).with_context(|| format!("parse {}", path.display()))
436    }
437
438    /// Read a spec from TOML text.
439    ///
440    /// # Errors
441    /// Fails on malformed TOML or an unknown field.
442    pub fn parse(text: &str) -> Result<Self> {
443        Ok(toml::from_str(text)?)
444    }
445
446    /// The spec as TOML, tables in the order `config/panels/*.toml` use;
447    /// `parse` reads it back to the same values.
448    ///
449    /// # Errors
450    /// Fails when a value cannot be written as TOML.
451    pub fn to_toml(&self) -> Result<String> {
452        toml::to_string(self).context("write spec")
453    }
454
455    /// The spec's chip library, with `load` mapping `[chip].library` to TOML
456    /// text.
457    ///
458    /// # Errors
459    /// Fails when `load` does, or on a malformed library.
460    pub fn chip_library(&self, load: Loader) -> Result<ChipLibrary> {
461        let path = &self.chip.library;
462        let text = load(path)?;
463        ChipLibrary::parse(&text).with_context(|| format!("parse {path}"))
464    }
465
466    /// Check the spec against what the record can express.
467    ///
468    /// # Errors
469    /// Rejects geometry the record cannot hold.
470    pub fn validate(&self) -> Result<()> {
471        if !self.module.height.is_multiple_of(2) {
472            bail!("module height must be even (the record stores height/2)");
473        }
474        if self.module.width > 255 || self.module.height / 2 > 255 {
475            bail!("module dimensions exceed the record's byte fields");
476        }
477        if !self.screen.width.is_multiple_of(self.module.width)
478            || !self.screen.height.is_multiple_of(self.module.height)
479        {
480            bail!("screen size must be a whole number of modules");
481        }
482        if self.module.scan == 0 || u16::from(self.module.scan) > self.module.height {
483            bail!("scan denominator must be 1..=module height");
484        }
485        if !(self.module.height / 2).is_multiple_of(u16::from(self.module.scan)) {
486            bail!("stored module height (height/2) must be a whole number of scan groups");
487        }
488        Ok(())
489    }
490
491    /// The serial clock (record +0x021): the spec's, else the chip's default.
492    #[must_use]
493    pub fn serial_clock(&self, chip: &ChipLibrary) -> u16 {
494        self.module.serial_clock.unwrap_or(chip.serial_clock)
495    }
496
497    /// Grayscale depth: the spec's override, else the vendor's derivation
498    /// from the chip registers.
499    ///
500    /// # Errors
501    /// Fails if the library lacks the registers the derivation reads.
502    pub fn gray_bits(&self, chip: &ChipLibrary) -> Result<u8> {
503        match self.module.gray_bits {
504            Some(g) => Ok(g),
505            None => chip.gray_bits(),
506        }
507    }
508
509    /// The screen extent along the data-line direction (vendor
510    /// `GetMaxInLineDir`, before void adjustments).
511    #[must_use]
512    pub fn screen_extent_in_line_dir(&self) -> u16 {
513        if self.module.line_dir >= 2 {
514            self.screen.height
515        } else {
516            self.screen.width
517        }
518    }
519
520    /// Vendor `GetModuleInputCount`: the 16-pixel grid unit over the module
521    /// dimension along the line direction, at least 1.
522    #[must_use]
523    pub fn module_input_count(&self) -> u8 {
524        let unit = 16u16;
525        let dim = if self.module.line_dir >= 2 {
526            self.module.width
527        } else {
528            self.module.height / 2
529        };
530        (unit / dim.max(1)).max(1) as u8
531    }
532
533    /// Modules chained along the data-line direction (vendor
534    /// `GetModuleCountInLineDir`): screen extent / module extent on that axis.
535    #[must_use]
536    pub fn modules_in_line_dir(&self) -> u16 {
537        if self.module.line_dir >= 2 {
538            self.screen.height.div_ceil(self.module.height)
539        } else {
540            self.screen.width.div_ceil(self.module.width)
541        }
542    }
543
544    /// Clocks in one scan line (vendor `GetOneScanLen`): W x stored H / scan.
545    #[must_use]
546    pub fn one_scan_len(&self) -> u16 {
547        let v = u32::from(self.module.width) * u32::from(self.module.height / 2)
548            / u32::from(self.module.scan);
549        v.max(1) as u16
550    }
551
552    /// Clocks in one card scan line (vendor `GetCardScanLen`): OneScanLen
553    /// scaled by the modules along the line direction.
554    #[must_use]
555    pub fn card_scan_len(&self) -> u16 {
556        self.one_scan_len() * self.modules_in_line_dir()
557    }
558}
559
560#[cfg(test)]
561mod tests {
562    use super::*;
563
564    fn spec() -> PanelSpec {
565        PanelSpec::parse(
566            r#"
567            name = "t"
568            [module]
569            width = 128
570            height = 64
571            scan = 16
572            [screen]
573            width = 256
574            height = 64
575            [chip]
576            library = "x.toml"
577            "#,
578        )
579        .unwrap()
580    }
581
582    #[test]
583    fn geometry_helpers_follow_the_vendor_formulas() {
584        let s = spec();
585        assert!(s.validate().is_ok());
586        assert_eq!(s.modules_in_line_dir(), 2);
587        assert_eq!(s.one_scan_len(), 256);
588        assert_eq!(s.card_scan_len(), 512);
589        assert_eq!(s.screen_extent_in_line_dir(), 256);
590        assert_eq!(s.module_input_count(), 1);
591    }
592
593    #[test]
594    fn a_scan_that_does_not_divide_the_module_is_refused() {
595        let mut s = spec();
596        s.module.scan = 12;
597        assert!(s.validate().is_err());
598    }
599
600    #[test]
601    fn unknown_fields_are_refused() {
602        assert!(PanelSpec::parse("name = \"t\"\nextra = 1\n").is_err());
603    }
604
605    #[test]
606    fn a_spec_written_as_toml_reads_back_to_the_same_values() {
607        let text = std::fs::read_to_string(concat!(
608            env!("CARGO_MANIFEST_DIR"),
609            "/config/panels/p25-128x64-sm16269s.toml"
610        ))
611        .unwrap();
612        let spec = PanelSpec::parse(&text).unwrap();
613        let out = spec.to_toml().unwrap();
614        assert!(out.starts_with("name = \"p25-128x64-sm16269s\"\n\n[meta]\n"), "{out}");
615        assert!(out.contains("\n[record01_overrides]\n0x02F = 1\n"), "{out}");
616        assert!(out.contains("gamma = 2.8\n") && out.contains("min_oe = 0.0001\n"), "{out}");
617        let back = PanelSpec::parse(&out).unwrap();
618        assert_eq!(back.to_toml().unwrap(), out);
619        assert_eq!(back.record01_overrides, spec.record01_overrides);
620        assert_eq!(back.timing.min_oe.to_bits(), spec.timing.min_oe.to_bits());
621        assert_eq!(back.module.serial_clock, Some(8));
622
623        let mut bare = spec;
624        bare.record01_overrides.clear();
625        bare.mapping.block = None;
626        let out = bare.to_toml().unwrap();
627        assert!(!out.contains("record01_overrides") && !out.contains("block"), "{out}");
628    }
629}