Skip to main content

nucleus_db/
pack.rs

1//! Parser for ST's open pin data (CubeMX XML) into the normalized
2//! pin ↔ AF ↔ peripheral model, plus the patch table for known anomalies.
3//!
4//! This module is deliberately self-contained (no `crate::` types) because it
5//! is also included by `build.rs` via `#[path]` to generate the compiled-in
6//! database at build time. Sources live in `packdata/` (see its README).
7
8/// One raw mapping parsed from the GPIO modes XML.
9#[derive(Debug, Clone, PartialEq, Eq)]
10pub struct RawMapping {
11    /// Pin name, e.g. `"PA7"`.
12    pub pin: String,
13    /// Alternate function number, 0–15.
14    pub af: u8,
15    /// Peripheral instance, e.g. `"SPI1"`.
16    pub peripheral: String,
17    /// Signal on that peripheral, e.g. `"MOSI"`.
18    pub signal: String,
19}
20
21/// Error parsing vendored pack data.
22#[derive(Debug, Clone, PartialEq, Eq)]
23pub enum PackError {
24    /// The XML structure was not what we expect from ST's pin data files.
25    Malformed(String),
26}
27
28impl core::fmt::Display for PackError {
29    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
30        match self {
31            PackError::Malformed(what) => write!(f, "malformed pack data: {what}"),
32        }
33    }
34}
35
36impl std::error::Error for PackError {}
37
38/// A recorded correction to upstream pack data. Every deviation from the
39/// vendored XML lives here so it is traceable (Phase 1 exit criteria).
40#[derive(Debug, Clone, Copy)]
41pub enum Patch {
42    /// Drop an upstream mapping that is wrong for this device.
43    Remove {
44        pin: &'static str,
45        af: u8,
46        peripheral: &'static str,
47        signal: &'static str,
48        reason: &'static str,
49    },
50    /// Add a mapping missing from upstream data.
51    Add {
52        pin: &'static str,
53        af: u8,
54        peripheral: &'static str,
55        signal: &'static str,
56        reason: &'static str,
57    },
58}
59
60/// Known anomalies in the vendored STM32F4 pin data (shared across families;
61/// each patch is keyed by exact `(pin, af, peripheral, signal)` so there is no
62/// cross-family collision risk).
63///
64/// No entry-level corrections are needed yet; the generated table is
65/// cross-validated against a hand-verified datasheet seed (see
66/// `generated_db_agrees_with_hand_verified_seed`). Record any future
67/// deviation here, never by editing `packdata/`.
68///
69/// Anomalies found so far are *structural* and handled (with tests) in
70/// [`parse_gpio_modes`] rather than as per-entry patches:
71/// - `"CEC"`: HDMI-CEC's signal name has no `PERIPH_SIGNAL` form; it
72///   normalizes to peripheral `CEC`, signal `CEC`.
73/// - `"PDR_ON"`: the modes file lists this power ball as a `GPIO_Pin`; any
74///   entry that is not a `P<port><n>` pin is skipped.
75/// - `"PI8"`: present in the family-wide modes file but not on the LQFP64
76///   package; dropped by the package-pin filter in [`generate_table`].
77pub const PATCHES: &[Patch] = &[];
78
79/// Parse the GPIO IP modes XML (`GPIO-*_Modes.xml`) into raw mappings.
80pub fn parse_gpio_modes(xml: &str) -> Result<Vec<RawMapping>, PackError> {
81    let doc = roxmltree::Document::parse(xml)
82        .map_err(|e| PackError::Malformed(format!("gpio modes xml: {e}")))?;
83
84    let mut mappings = Vec::new();
85    for gpio_pin in elements_named(&doc, "GPIO_Pin") {
86        let pin = required_attr(&gpio_pin, "Name")?;
87        // Known anomaly: the modes file also lists non-GPIO entries (the
88        // F446's PDR_ON ball); skip anything that isn't a P<port><n> pin.
89        let Some(pin) = normalize_pin_name(pin) else {
90            continue;
91        };
92
93        for pin_signal in gpio_pin
94            .children()
95            .filter(|n| n.is_element() && n.tag_name().name() == "PinSignal")
96        {
97            let signal_name = required_attr(&pin_signal, "Name")?;
98            let Some(af) = gpio_af_value(&pin_signal) else {
99                // PinSignal without a GPIO_AF parameter: not an AF mux entry.
100                continue;
101            };
102            let af = af
103                .strip_prefix("GPIO_AF")
104                .and_then(|s| s.split('_').next())
105                .and_then(|s| s.parse::<u8>().ok())
106                .filter(|af| *af <= 15)
107                .ok_or_else(|| PackError::Malformed(format!("bad GPIO_AF value {af:?}")))?;
108
109            // "SPI1_MOSI" → ("SPI1", "MOSI"); "SYS_JTMS-SWDIO" → ("SYS", "JTMS-SWDIO").
110            // Known anomaly: peripheral-only names (the F446's HDMI-CEC is
111            // just "CEC") normalize to peripheral == signal.
112            let (peripheral, signal) = signal_name
113                .split_once('_')
114                .unwrap_or((signal_name, signal_name));
115
116            mappings.push(RawMapping {
117                pin: pin.to_string(),
118                af,
119                peripheral: peripheral.to_string(),
120                signal: signal.to_string(),
121            });
122        }
123    }
124    Ok(mappings)
125}
126
127/// Parse the MCU package XML and return the GPIO pins that physically exist
128/// on the package, normalized (e.g. `"PC14-OSC32_IN"` → `"PC14"`).
129pub fn parse_package_pins(xml: &str) -> Result<Vec<String>, PackError> {
130    let doc = roxmltree::Document::parse(xml)
131        .map_err(|e| PackError::Malformed(format!("mcu xml: {e}")))?;
132
133    Ok(elements_named(&doc, "Pin")
134        .filter_map(|pin| {
135            let name = pin.attribute("Name")?;
136            // Power/reset/boot pins (VBAT, NRST, VSS...) are not GPIOs.
137            normalize_pin_name(name).map(str::to_string)
138        })
139        .collect())
140}
141
142/// Apply the patch table to parsed mappings.
143pub fn apply_patches(mappings: &mut Vec<RawMapping>, patches: &[Patch]) {
144    for patch in patches {
145        match *patch {
146            Patch::Remove {
147                pin,
148                af,
149                peripheral,
150                signal,
151                reason: _,
152            } => mappings.retain(|m| {
153                !(m.pin == pin && m.af == af && m.peripheral == peripheral && m.signal == signal)
154            }),
155            Patch::Add {
156                pin,
157                af,
158                peripheral,
159                signal,
160                reason: _,
161            } => mappings.push(RawMapping {
162                pin: pin.to_string(),
163                af,
164                peripheral: peripheral.to_string(),
165                signal: signal.to_string(),
166            }),
167        }
168    }
169}
170
171/// Render mappings (filtered to `package_pins`, sorted, deduplicated) as Rust
172/// source for inclusion by `data.rs`. Output is byte-deterministic.
173pub fn generate_table(
174    mappings: &[RawMapping],
175    package_pins: &[String],
176    const_name: &str,
177) -> String {
178    let mut rows: Vec<(char, u8, &RawMapping)> = mappings
179        .iter()
180        .filter(|m| package_pins.contains(&m.pin))
181        .filter_map(|m| {
182            let (port, number) = pin_sort_key(&m.pin)?;
183            Some((port, number, m))
184        })
185        .collect();
186    rows.sort_by_key(|(port, number, m)| {
187        (*port, *number, m.af, m.peripheral.clone(), m.signal.clone())
188    });
189    rows.dedup_by(|a, b| a.2 == b.2);
190
191    let mut out = format!(
192        "// @generated by build.rs from packdata/ — do not edit.\n\
193         pub(crate) const {const_name}: &[AfMapping] = &[\n",
194    );
195    for (port, number, m) in rows {
196        out.push_str(&format!(
197            "    map(Port::{port}, {number}, {af}, \"{peripheral}\", \"{signal}\"),\n",
198            af = m.af,
199            peripheral = m.peripheral,
200            signal = m.signal,
201        ));
202    }
203    out.push_str("];\n");
204    out
205}
206
207/// `"PC14-OSC32_IN"` → `Some("PC14")`; `"VBAT"`/`"NRST"` → `None`.
208fn normalize_pin_name(name: &str) -> Option<&str> {
209    let base = name.split(['-', '/', ' ']).next().unwrap_or(name);
210    pin_sort_key(base).map(|_| base)
211}
212
213/// `"PA7"` → `Some(('A', 7))` for sorting and validation.
214fn pin_sort_key(pin: &str) -> Option<(char, u8)> {
215    let rest = pin.strip_prefix('P')?;
216    let port = rest.chars().next().filter(char::is_ascii_uppercase)?;
217    let number: u8 = rest[1..].parse().ok().filter(|n| *n <= 15)?;
218    Some((port, number))
219}
220
221/// All descendant elements with local name `name` (the files use a default
222/// namespace, so match on local names).
223fn elements_named<'a>(
224    doc: &'a roxmltree::Document<'a>,
225    name: &'static str,
226) -> impl Iterator<Item = roxmltree::Node<'a, 'a>> {
227    doc.descendants()
228        .filter(move |n| n.is_element() && n.tag_name().name() == name)
229}
230
231fn required_attr<'a>(
232    node: &roxmltree::Node<'a, '_>,
233    attr: &'static str,
234) -> Result<&'a str, PackError> {
235    node.attribute(attr).ok_or_else(|| {
236        PackError::Malformed(format!(
237            "<{}> missing {attr} attribute",
238            node.tag_name().name()
239        ))
240    })
241}
242
243/// The `GPIO_AF` PossibleValue text under a PinSignal, if present.
244fn gpio_af_value<'a>(pin_signal: &roxmltree::Node<'a, '_>) -> Option<&'a str> {
245    pin_signal
246        .children()
247        .find(|n| {
248            n.is_element()
249                && n.tag_name().name() == "SpecificParameter"
250                && n.attribute("Name") == Some("GPIO_AF")
251        })?
252        .children()
253        .find(|n| n.is_element() && n.tag_name().name() == "PossibleValue")?
254        .text()
255}