Skip to main content

taktora_ethercat_netcfg/
lib.rs

1//! Parser and in-memory IR for the `EtherCAT` network-config YAML.
2//!
3//! This crate is the **parse layer** of the `taktora-ethercat-netcfg` codegen
4//! toolchain (a build-host tool — `std` is fine). It turns a
5//! `network.yaml` document into the [`NetworkConfig`] IR via the single
6//! public entry point [`parse`].
7//!
8//! It performs *parsing only*. Validation, address assignment, multi-bus
9//! handling, and code generation are deliberately **out of scope** here —
10//! they are handled by later layers of the toolchain.
11
12#![warn(missing_docs)]
13
14use core::time::Duration;
15
16use serde::Deserialize;
17
18pub use taktora_fieldbus_od_core::Identity;
19
20/// The fully parsed network configuration — the IR root.
21#[derive(Debug, Clone, PartialEq, Eq)]
22pub struct NetworkConfig {
23    /// Schema version of the source document.
24    pub schema_version: u16,
25    /// Bus-wide configuration.
26    pub bus: BusConfig,
27    /// Device instances declared on the bus.
28    pub devices: Vec<DeviceInstance>,
29    /// Process-data channel bindings.
30    pub channels: Vec<ChannelBinding>,
31}
32
33/// Bus-wide configuration.
34#[derive(Debug, Clone, PartialEq, Eq)]
35pub struct BusConfig {
36    /// Cyclic process-data period.
37    pub cycle_time: Duration,
38    /// Whether distributed clocks are enabled.
39    pub distributed_clocks: bool,
40    /// Upper bound on the number of subdevices.
41    pub max_subdevices: usize,
42    /// Upper bound on the process-data-image size, in bytes.
43    pub max_pdi_bytes: usize,
44    /// Default NIC to bind the bus to, if any.
45    pub default_nic: Option<String>,
46    /// Fault-Tolerant Time Interval: the budget within which a detected
47    /// fault must reach the safe state (`AOU_0006` / `AFSR_0004`). The
48    /// per-device SM watchdog (`AOU_0016`) is bounded at FTTI/2 so a
49    /// silently-stopped master still drives outputs safe inside budget.
50    /// Defaults to 100 ms when the YAML omits `ftti_ms`.
51    pub ftti: Duration,
52}
53
54/// A single device instance declared on the bus.
55#[derive(Debug, Clone, PartialEq, Eq)]
56pub struct DeviceInstance {
57    /// Human-readable label, unique within the config.
58    pub label: String,
59    /// Where the device's PDO description comes from.
60    pub source: DeviceSource,
61    /// Optional expected identity for verification.
62    pub identity: Option<Identity>,
63    /// Optional configured station alias.
64    pub station_alias: Option<u16>,
65    /// Optional explicit configured-address override.
66    pub address_override: Option<u16>,
67    /// Optional per-device SM-watchdog timeout override. Absent → the
68    /// device inherits FTTI/2 (`REQ_0844`). YAML: `sm_watchdog_timeout_ms`.
69    pub sm_watchdog_timeout: Option<Duration>,
70    /// Explicit attestation that this output device's SM watchdog is (will
71    /// be) enabled (`REQ_0845`). Required for [`DeviceSource::Inline`] output
72    /// devices (no ESI control byte to read the enable bit from). Also
73    /// accepted for [`DeviceSource::Esi`] output devices whose ESI output SM
74    /// does not declare the watchdog trigger — the operator takes
75    /// responsibility; the driver programs `0x0400`/`0x0420` unconditionally
76    /// at bring-up (`AOU_0016`). YAML: `sm_watchdog_enabled`.
77    pub sm_watchdog_enabled: Option<bool>,
78    /// Resolved SM-watchdog register values for this device, present iff
79    /// the device carries output (rx) PDOs (`REQ_0844`). Codegen emits
80    /// these via `SubDeviceMap::with_sm_watchdog`; input-only devices
81    /// carry `None` and emit no watchdog.
82    pub sm_watchdog: Option<SmWatchdogRegisters>,
83    /// Operator-declared startup SDOs, in declaration order. Empty if none.
84    pub startup_sdos: Vec<StartupSdoSpec>,
85    /// Whether the device can accept `CoE` PDO-assignment SDO writes
86    /// (`0x1C12`/`0x1C13`). For an `esi:` device this reflects whether the ESI
87    /// declares a `CoE` mailbox; for an inline device it is `true` (the
88    /// integrator listed PDOs intending to assign them). Simple terminals
89    /// (e.g. EL1008/EL2004) have no mailbox, so writing a PDO assignment to
90    /// them fails with `NoReadMailbox` on the bus — codegen therefore emits an
91    /// EMPTY assignment for them (they use their fixed default mapping) while
92    /// still contributing their `expected_wkc`.
93    pub supports_coe: bool,
94}
95
96/// Resolved ESC SM-watchdog register values for one output device.
97///
98/// `divider` is register `0x0400` (the tick base) and `intervals` is
99/// register `0x0420` (the SM-watchdog time, in ticks). A tick is
100/// `40 ns × (divider + 2)`; netcfg always fixes the divider at
101/// [`DEFAULT_WATCHDOG_DIVIDER`] (a 100 µs tick) and varies only the tick
102/// count. These mirror the connector's
103/// `taktora_connector_ethercat::SmWatchdog` value (deliberately not a
104/// dependency — see [`sm_watchdog_intervals`]).
105#[derive(Debug, Clone, Copy, PartialEq, Eq)]
106pub struct SmWatchdogRegisters {
107    /// Watchdog divider register `0x0400`. Tick = `40 ns × (divider + 2)`.
108    pub divider: u16,
109    /// SM-watchdog time register `0x0420`, in ticks.
110    pub intervals: u16,
111}
112
113/// Watchdog divider that yields a 100 µs tick: `40 ns × (2498 + 2) = 100 µs`.
114///
115/// Mirrors `taktora_connector_ethercat::DEFAULT_DIVIDER` (the ESC power-up
116/// value); duplicated here so netcfg need not depend on the connector
117/// runtime (`REQ_0824`).
118pub const DEFAULT_WATCHDOG_DIVIDER: u16 = 2498;
119
120/// Quantize a timeout in microseconds to a whole number of 100 µs watchdog
121/// ticks: `intervals = ceil(timeout_us / 100)`, clamped to `1..=u16::MAX`.
122///
123/// This is a deliberate ~5-line duplicate of the connector's
124/// `taktora_connector_ethercat::SmWatchdog::from_timeout_us` arithmetic —
125/// a dependency on that heavy crate was rejected (`REQ_0824` /
126/// `crates/taktora-connector-ethercat/src/watchdog.rs`). Quantization is
127/// upward (ceil): a request that is not a whole multiple of 100 µs rounds
128/// **up** to the next tick, so the effective timeout is `≥ timeout_us`.
129/// Callers checking the FTTI/2 ceiling must compare against the QUANTIZED
130/// effective window, not the request. `0 µs` clamps to one tick, never the
131/// disabling 0-interval value.
132#[must_use]
133pub const fn sm_watchdog_intervals(timeout_us: u32) -> u16 {
134    let ticks = timeout_us.div_ceil(100);
135    if ticks < 1 {
136        1
137    } else if ticks > u16::MAX as u32 {
138        u16::MAX
139    } else {
140        // This branch only runs when `ticks <= u16::MAX`; `try_from` is
141        // not const, so cast. Provably lossless.
142        #[allow(clippy::cast_possible_truncation)]
143        {
144            ticks as u16
145        }
146    }
147}
148
149impl SmWatchdogRegisters {
150    /// Build the registers for a timeout in microseconds, fixing the
151    /// divider at [`DEFAULT_WATCHDOG_DIVIDER`] (100 µs ticks).
152    #[must_use]
153    pub const fn from_timeout_us(timeout_us: u32) -> Self {
154        Self {
155            divider: DEFAULT_WATCHDOG_DIVIDER,
156            intervals: sm_watchdog_intervals(timeout_us),
157        }
158    }
159
160    /// Effective (quantized) watchdog window in nanoseconds:
161    /// `40 ns × (divider + 2) × intervals`. Computed in `u64`; the worst
162    /// case (`2500 × 65535 × 40`) is far inside `u64`.
163    #[must_use]
164    pub const fn effective_timeout_ns(&self) -> u64 {
165        40 * (self.divider as u64 + 2) * self.intervals as u64
166    }
167}
168
169/// The origin of a device's PDO description.
170#[derive(Debug, Clone, PartialEq, Eq)]
171pub enum DeviceSource {
172    /// PDOs described inline in the network config.
173    Inline {
174        /// Receive (output) PDO entries.
175        rx: Vec<PdoEntry>,
176        /// Transmit (input) PDO entries.
177        tx: Vec<PdoEntry>,
178    },
179    /// PDOs resolved from a referenced ESI (`EtherCAT` Slave Information)
180    /// file at parse time (`REQ_0824`).
181    Esi {
182        /// The referenced ESI file, as named in the network config.
183        path: std::path::PathBuf,
184        /// Receive (output) PDO entries, resolved from the ESI file.
185        rx: Vec<PdoEntry>,
186        /// Transmit (input) PDO entries, resolved from the ESI file.
187        tx: Vec<PdoEntry>,
188    },
189}
190
191impl DeviceSource {
192    /// Receive (output) PDO entries, regardless of source variant.
193    #[must_use]
194    pub fn rx(&self) -> &[PdoEntry] {
195        match self {
196            Self::Inline { rx, .. } | Self::Esi { rx, .. } => rx,
197        }
198    }
199
200    /// Transmit (input) PDO entries, regardless of source variant.
201    #[must_use]
202    pub fn tx(&self) -> &[PdoEntry] {
203        match self {
204            Self::Inline { tx, .. } | Self::Esi { tx, .. } => tx,
205        }
206    }
207}
208
209/// A single PDO entry within an inline device description.
210#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
211pub struct PdoEntry {
212    /// PDO index.
213    pub index: u16,
214    /// Bit offset within the PDO.
215    pub bit_offset: u16,
216    /// Bit length of the entry.
217    pub bit_length: u16,
218}
219
220/// One operator-declared startup SDO, written in PRE-OP before PDO assignment.
221#[derive(Debug, Clone, PartialEq, Eq)]
222pub struct StartupSdoSpec {
223    /// SDO object-dictionary index.
224    pub index: u16,
225    /// SDO object-dictionary subindex.
226    pub subindex: u8,
227    /// Typed value to write.
228    pub value: SdoValueSpec,
229}
230
231/// A typed startup-SDO value. Mirrors `taktora_connector_ethercat::SdoValue`;
232/// codegen emits the matching connector variant as text.
233#[derive(Debug, Clone, Copy, PartialEq, Eq)]
234pub enum SdoValueSpec {
235    /// 8-bit unsigned.
236    U8(u8),
237    /// 16-bit unsigned.
238    U16(u16),
239    /// 32-bit unsigned.
240    U32(u32),
241    /// 8-bit signed.
242    I8(i8),
243    /// 16-bit signed.
244    I16(i16),
245    /// 32-bit signed.
246    I32(i32),
247}
248
249/// Direction of a process-data channel.
250#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
251#[serde(rename_all = "lowercase")]
252pub enum PdoDirection {
253    /// Receive (output, controller -> device).
254    Rx,
255    /// Transmit (input, device -> controller).
256    Tx,
257}
258
259/// A binding from a named channel to a slice of a device's process data.
260#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
261pub struct ChannelBinding {
262    /// Channel name (e.g. a topic).
263    pub name: String,
264    /// Label of the bound device.
265    pub device: String,
266    /// Direction of the channel.
267    pub direction: PdoDirection,
268    /// Bit offset within the device's process image.
269    pub bit_offset: u32,
270    /// Bit length of the channel.
271    pub bit_length: u16,
272    /// Primitive element type of the channel.
273    pub element_type: ElementType,
274    /// Whether overlapping bit ranges are permitted for this channel.
275    #[serde(default)]
276    pub allow_overlap: bool,
277}
278
279/// Primitive inline element types for a channel.
280#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
281#[serde(rename_all = "lowercase")]
282pub enum ElementType {
283    /// Unsigned 8-bit integer.
284    U8,
285    /// Unsigned 16-bit integer.
286    U16,
287    /// Unsigned 32-bit integer.
288    U32,
289    /// Unsigned 64-bit integer.
290    U64,
291}
292
293/// Errors that can occur while [`parse`]-ing a network config.
294#[derive(Debug, thiserror::Error)]
295pub enum NetcfgError {
296    /// The source document could not be deserialized from YAML.
297    #[error("failed to parse network config YAML: {0}")]
298    Yaml(#[from] serde_norway::Error),
299
300    /// The input is a multi-document YAML stream, but one `network.yaml`
301    /// describes exactly one bus (`REQ_0822` / `ADR_0096`).
302    #[error("found {count} YAML documents; one network.yaml describes exactly one bus")]
303    MultipleBuses {
304        /// Number of `---`-separated documents found in the stream.
305        count: usize,
306    },
307
308    /// A referenced ESI file could not be read from the filesystem.
309    #[error("failed to read referenced ESI file: {0}")]
310    Io(#[from] std::io::Error),
311
312    /// A referenced ESI file could not be parsed.
313    ///
314    /// `taktora_ethercat_esi::EsiError` is a `no_std` error that only implements
315    /// `Display` (not `std::error::Error`), so it is not threaded as a
316    /// `#[source]`; the message embeds its display form.
317    #[error("failed to parse referenced ESI file: {0}")]
318    Esi(taktora_ethercat_esi::EsiError),
319
320    /// A device declares both an `esi:` reference and inline `pdos:`, but the
321    /// ESI-resolved entries disagree with the inline entries.
322    ///
323    /// The ESI and the inline description are both available only after ESI
324    /// resolution, so the contradiction is detected here in `parse` rather
325    /// than in the downstream codegen validation pass.
326    #[error("device `{label}` declares an esi: reference that contradicts its inline pdos:")]
327    EsiContradiction {
328        /// Label of the offending device.
329        label: String,
330    },
331
332    /// A referenced ESI file describes more than one device, so the parser
333    /// cannot unambiguously pick one (multi-device selection is deferred).
334    #[error("ESI file {path:?} describes {count} devices; cannot select unambiguously")]
335    AmbiguousEsiDevice {
336        /// The referenced ESI file.
337        path: std::path::PathBuf,
338        /// Number of devices found in the ESI file.
339        count: usize,
340    },
341
342    /// `op_mode` named a mapping that does not exist on the device.
343    #[error("device `{label}` op_mode `{requested}` not found; available: {}", if available.is_empty() { String::from("(none)") } else { available.join(", ") })]
344    OpModeNotFound {
345        /// Offending device label.
346        label: String,
347        /// The requested mode name.
348        requested: String,
349        /// Mapping names the device declares.
350        available: Vec<String>,
351    },
352    /// `op_mode` was set on a device that has no selectable PDO mappings — either
353    /// because no `esi:` reference was given at all, or because the referenced
354    /// ESI declares no `AlternativeSmMapping`.
355    #[error(
356        "device `{label}` sets op_mode but has no selectable PDO mappings (no esi: reference, or the referenced ESI declares no AlternativeSmMapping)"
357    )]
358    OpModeOnFlatDevice {
359        /// Offending device label.
360        label: String,
361    },
362    /// `op_mode` omitted on a multi-mapping device with no Default=\"1\" mapping.
363    #[error("device `{label}` omits op_mode and its ESI declares no default PDO mapping")]
364    NoDefaultMapping {
365        /// Offending device label.
366        label: String,
367    },
368    /// A mapping references a PDO index present in neither rx nor tx PDOs.
369    #[error("device `{label}` mapping references unknown PDO index {index:#06x}")]
370    UnknownAssignmentPdo {
371        /// Offending device label.
372        label: String,
373        /// The dangling PDO index.
374        index: u16,
375    },
376
377    /// An `esi:` reference is a remote (`http://` / `https://`) URL. Builds
378    /// are hermetic and never fetch over the network at parse time
379    /// (`REQ_0834`); the ESI must be vendored locally and referenced as a
380    /// local file (or `file://` URL).
381    #[error(
382        "esi reference `{reference}` is a remote URL; vendor it locally with `netcfg fetch` and reference the vendored file"
383    )]
384    RemoteEsiNotVendored {
385        /// The offending remote reference, as named in the network config.
386        reference: String,
387    },
388
389    /// An output (rx-carrying) device's resolved SM-watchdog timeout, once
390    /// quantized to whole 100 µs ticks, exceeds FTTI/2 (`AOU_0016` /
391    /// `REQ_0845`). The bound is checked against the QUANTIZED effective
392    /// window, since `ceil` can push a value that was under the raw bound
393    /// over it.
394    #[error(
395        "device `{label}` SM-watchdog effective timeout {effective_us} µs exceeds the FTTI/2 bound of {bound_us} µs"
396    )]
397    SmWatchdogTimeoutTooLong {
398        /// Label of the offending output device.
399        label: String,
400        /// The quantized effective watchdog window, in microseconds.
401        effective_us: u64,
402        /// The FTTI/2 bound, in microseconds.
403        bound_us: u64,
404    },
405
406    /// An ESI-sourced output device's process-data (output) sync
407    /// manager(s) have the watchdog trigger DISABLED in the ESI control
408    /// byte (`AOU_0016` / `REQ_0845`). The watchdog is the sole mechanism
409    /// that drives outputs to their safe state on a silently-stopped
410    /// master, so a disabled trigger is a config error. Bypass by setting
411    /// `sm_watchdog_enabled: true` on the device (operator attestation).
412    #[error(
413        "device `{label}` is an output device but its ESI declares the SM watchdog trigger disabled on an output sync manager; set `sm_watchdog_enabled: true` to attest the watchdog is programmed at runtime"
414    )]
415    SmWatchdogDisabled {
416        /// Label of the offending output device.
417        label: String,
418    },
419
420    /// An [`DeviceSource::Inline`] output device did not attest that its
421    /// SM watchdog is enabled (`AOU_0016` / `REQ_0845`). Inline sources
422    /// carry no ESI control byte to read the enable bit from, so the
423    /// integrator must set `sm_watchdog_enabled: true` — or switch to an
424    /// ESI source whose output SM declares the trigger enabled.
425    #[error(
426        "device `{label}` is an inline output device without an SM-watchdog enable attestation; set `sm_watchdog_enabled: true` or source it from an ESI whose output SM enables the watchdog"
427    )]
428    SmWatchdogNotAttested {
429        /// Label of the offending output device.
430        label: String,
431    },
432
433    /// A startup-SDO `value` does not fit its declared `type`.
434    #[error(
435        "device `{label}` startup SDO {index:#06x}:{subindex:#04x} value {value} out of range for type {ty}"
436    )]
437    SdoValueOutOfRange {
438        /// Offending device label.
439        label: String,
440        /// SDO index.
441        index: u16,
442        /// SDO subindex.
443        subindex: u8,
444        /// Declared type name (e.g. "u16").
445        ty: String,
446        /// The out-of-range value as written.
447        value: i64,
448    },
449}
450
451impl From<taktora_ethercat_esi::EsiError> for NetcfgError {
452    fn from(e: taktora_ethercat_esi::EsiError) -> Self {
453        Self::Esi(e)
454    }
455}
456
457/// Parse a network-config YAML document into the [`NetworkConfig`] IR.
458pub fn parse(yaml: &str) -> Result<NetworkConfig, NetcfgError> {
459    // One-file-one-bus: a YAML stream may hold more than one
460    // `---`-separated document. `Deserializer::from_str` yields one
461    // `Deserializer` per document, so count them before deserializing.
462    let count = serde_norway::Deserializer::from_str(yaml).count();
463    if count > 1 {
464        return Err(NetcfgError::MultipleBuses { count });
465    }
466
467    let dto: dto::NetworkConfigDto = serde_norway::from_str(yaml)?;
468    dto.resolve()
469}
470
471/// Private deserialization DTOs.
472///
473/// These mirror the on-disk YAML shape (including serde defaults and the
474/// `cycle_time_ms` field) and convert into the public IR. Keeping them
475/// separate lets the IR stay free of serde concerns like the
476/// milliseconds-to-[`Duration`] conversion.
477mod dto {
478    use super::{
479        BusConfig, ChannelBinding, DeviceInstance, DeviceSource, Identity, NetcfgError,
480        NetworkConfig, PdoEntry, SdoValueSpec, SmWatchdogRegisters, StartupSdoSpec,
481        sm_watchdog_intervals,
482    };
483    use core::time::Duration;
484    use serde::Deserialize;
485    use std::path::PathBuf;
486
487    /// Default Fault-Tolerant Time Interval when `ftti_ms` is omitted, in
488    /// milliseconds (`AOU_0006` — 100 ms).
489    const DEFAULT_FTTI_MS: u64 = 100;
490
491    const fn default_ftti_ms() -> u64 {
492        DEFAULT_FTTI_MS
493    }
494
495    #[derive(Deserialize)]
496    pub struct NetworkConfigDto {
497        schema_version: u16,
498        bus: BusConfigDto,
499        #[serde(default)]
500        devices: Vec<DeviceInstanceDto>,
501        #[serde(default)]
502        channels: Vec<ChannelBinding>,
503    }
504
505    #[derive(Deserialize)]
506    struct BusConfigDto {
507        cycle_time_ms: u64,
508        distributed_clocks: bool,
509        max_subdevices: usize,
510        max_pdi_bytes: usize,
511        #[serde(default)]
512        default_nic: Option<String>,
513        #[serde(default = "default_ftti_ms")]
514        ftti_ms: u64,
515    }
516
517    #[derive(Deserialize)]
518    struct DeviceInstanceDto {
519        label: String,
520        #[serde(default)]
521        esi: Option<String>,
522        #[serde(default)]
523        op_mode: Option<String>,
524        #[serde(default)]
525        pdos: PdosDto,
526        #[serde(default)]
527        identity: Option<Identity>,
528        #[serde(default)]
529        station_alias: Option<u16>,
530        #[serde(default, rename = "address")]
531        address_override: Option<u16>,
532        #[serde(default)]
533        sm_watchdog_timeout_ms: Option<u64>,
534        #[serde(default)]
535        sm_watchdog_enabled: Option<bool>,
536        #[serde(default)]
537        startup_sdos: Vec<StartupSdoDto>,
538    }
539
540    #[derive(Deserialize, Default)]
541    struct PdosDto {
542        #[serde(default)]
543        rx: Vec<PdoEntry>,
544        #[serde(default)]
545        tx: Vec<PdoEntry>,
546    }
547
548    #[derive(Deserialize, Clone, Copy)]
549    #[serde(rename_all = "lowercase")]
550    enum SdoTypeDto {
551        U8,
552        U16,
553        U32,
554        I8,
555        I16,
556        I32,
557    }
558
559    impl SdoTypeDto {
560        const fn name(self) -> &'static str {
561            match self {
562                Self::U8 => "u8",
563                Self::U16 => "u16",
564                Self::U32 => "u32",
565                Self::I8 => "i8",
566                Self::I16 => "i16",
567                Self::I32 => "i32",
568            }
569        }
570    }
571
572    #[derive(Deserialize)]
573    struct StartupSdoDto {
574        index: u16,
575        subindex: u8,
576        #[serde(rename = "type")]
577        ty: SdoTypeDto,
578        value: i64,
579    }
580
581    fn convert_startup_sdo(
582        label: &str,
583        dto: &StartupSdoDto,
584    ) -> Result<StartupSdoSpec, NetcfgError> {
585        let v = dto.value;
586        let oor = || NetcfgError::SdoValueOutOfRange {
587            label: label.to_owned(),
588            index: dto.index,
589            subindex: dto.subindex,
590            ty: dto.ty.name().to_owned(),
591            value: v,
592        };
593        let value = match dto.ty {
594            SdoTypeDto::U8 => SdoValueSpec::U8(u8::try_from(v).map_err(|_| oor())?),
595            SdoTypeDto::U16 => SdoValueSpec::U16(u16::try_from(v).map_err(|_| oor())?),
596            SdoTypeDto::U32 => SdoValueSpec::U32(u32::try_from(v).map_err(|_| oor())?),
597            SdoTypeDto::I8 => SdoValueSpec::I8(i8::try_from(v).map_err(|_| oor())?),
598            SdoTypeDto::I16 => SdoValueSpec::I16(i16::try_from(v).map_err(|_| oor())?),
599            SdoTypeDto::I32 => SdoValueSpec::I32(i32::try_from(v).map_err(|_| oor())?),
600        };
601        Ok(StartupSdoSpec {
602            index: dto.index,
603            subindex: dto.subindex,
604            value,
605        })
606    }
607
608    /// A resolved device plus the ESI-derived watchdog-enable evidence the
609    /// watchdog pass needs but the public IR does not carry. For an
610    /// ESI-sourced device, `esi_output_watchdog_enabled` is `Some(true)`
611    /// iff every output (process-data-write) sync manager declares the
612    /// watchdog trigger enabled, `Some(false)` if any output SM disables
613    /// it, and `None` if the ESI declares no output SM at all. Inline
614    /// devices carry `None` (their attestation lives on the instance).
615    struct ResolvedDevice {
616        device: DeviceInstance,
617        esi_output_watchdog_enabled: Option<bool>,
618    }
619
620    impl NetworkConfigDto {
621        /// Convert into the IR, resolving any `esi:` device references
622        /// against the filesystem (`REQ_0824`), then resolving and
623        /// validating each output device's SM watchdog (`REQ_0844` /
624        /// `REQ_0845`).
625        pub fn resolve(self) -> Result<NetworkConfig, NetcfgError> {
626            let bus: BusConfig = self.bus.into();
627            // FTTI/2 bound, in microseconds, against which every output
628            // device's quantized watchdog window is checked.
629            let ftti_half_us = u64::try_from(bus.ftti.as_micros() / 2)
630                .expect("FTTI/2 in µs fits in u64 for any sane ms-granular FTTI");
631
632            let mut devices = Vec::with_capacity(self.devices.len());
633            for dto in self.devices {
634                let ResolvedDevice {
635                    mut device,
636                    esi_output_watchdog_enabled,
637                } = dto.resolve()?;
638                resolve_and_validate_watchdog(
639                    &mut device,
640                    ftti_half_us,
641                    esi_output_watchdog_enabled,
642                )?;
643                devices.push(device);
644            }
645
646            Ok(NetworkConfig {
647                schema_version: self.schema_version,
648                bus,
649                devices,
650                channels: self.channels,
651            })
652        }
653    }
654
655    /// Resolve the effective SM watchdog for an output (rx-carrying) device
656    /// and validate it against `AOU_0016` (`REQ_0844` / `REQ_0845`).
657    ///
658    /// Input-only devices (no rx PDOs) carry no watchdog and skip every
659    /// check. For an output device:
660    /// 1. Effective timeout = the `sm_watchdog_timeout` override if present,
661    ///    else FTTI/2.
662    /// 2. Quantize to ESC registers (divider 2498, `ceil` ticks) — the SAME
663    ///    arithmetic as the connector's `SmWatchdog`.
664    /// 3. The QUANTIZED effective window must be ≤ FTTI/2 (ceil can push a
665    ///    boundary value over the bound — that is why the quantized value is
666    ///    checked, not the request).
667    /// 4. The watchdog must be ENABLED: an ESI source's output SM(s) must
668    ///    declare the trigger enabled; an inline source must attest
669    ///    `sm_watchdog_enabled: true`.
670    fn resolve_and_validate_watchdog(
671        device: &mut DeviceInstance,
672        ftti_half_us: u64,
673        esi_output_watchdog_enabled: Option<bool>,
674    ) -> Result<(), NetcfgError> {
675        // Input-only devices are untouched: no rx PDOs, no watchdog.
676        if device.source.rx().is_empty() {
677            return Ok(());
678        }
679
680        // 1 + 2: effective timeout (override or FTTI/2), quantized.
681        let timeout_us = device.sm_watchdog_timeout.map_or_else(
682            || {
683                u32::try_from(ftti_half_us)
684                    .expect("FTTI/2 in µs fits in u32 for any sane ms-granular FTTI")
685            },
686            |d| {
687                u32::try_from(d.as_micros()).expect("per-device watchdog timeout in µs fits in u32")
688            },
689        );
690        let registers = SmWatchdogRegisters {
691            divider: super::DEFAULT_WATCHDOG_DIVIDER,
692            intervals: sm_watchdog_intervals(timeout_us),
693        };
694
695        // 3: the QUANTIZED effective window must be ≤ FTTI/2.
696        let effective_us = registers.effective_timeout_ns() / 1_000;
697        if effective_us > ftti_half_us {
698            return Err(NetcfgError::SmWatchdogTimeoutTooLong {
699                label: device.label.clone(),
700                effective_us,
701                bound_us: ftti_half_us,
702            });
703        }
704
705        // 4: the watchdog must be enabled.
706        match &device.source {
707            DeviceSource::Esi { .. } => {
708                // Enabled if the ESI's output SM declares the trigger, OR the
709                // operator attests it via `sm_watchdog_enabled: true`. The
710                // driver programs 0x0400/0x0420 regardless; the attestation
711                // lets an integrator accept responsibility for an ESI whose
712                // output SM does not declare the trigger (common on real
713                // Beckhoff devices). AOU_0016 / REQ_0845.
714                let attested = device.sm_watchdog_enabled == Some(true);
715                if esi_output_watchdog_enabled != Some(true) && !attested {
716                    return Err(NetcfgError::SmWatchdogDisabled {
717                        label: device.label.clone(),
718                    });
719                }
720            }
721            DeviceSource::Inline { .. } => {
722                if device.sm_watchdog_enabled != Some(true) {
723                    return Err(NetcfgError::SmWatchdogNotAttested {
724                        label: device.label.clone(),
725                    });
726                }
727            }
728        }
729
730        device.sm_watchdog = Some(registers);
731        Ok(())
732    }
733
734    /// Resolve an `esi:` reference to a LOCAL filesystem path (`REQ_0834`).
735    ///
736    /// Scheme handling for hermetic builds:
737    /// - `http://` / `https://` → [`NetcfgError::RemoteEsiNotVendored`]; no
738    ///   network access — the ESI must be vendored locally.
739    /// - `file://` → strip the scheme; the remainder is a local path. For the
740    ///   common `file:///absolute/path` form this yields `/absolute/path`.
741    /// - anything else → the reference is itself a local path (unchanged).
742    fn esi_local_path(reference: &str) -> Result<PathBuf, NetcfgError> {
743        if reference.starts_with("http://") || reference.starts_with("https://") {
744            return Err(NetcfgError::RemoteEsiNotVendored {
745                reference: reference.to_owned(),
746            });
747        }
748        if let Some(rest) = reference.strip_prefix("file://") {
749            return Ok(PathBuf::from(rest));
750        }
751        Ok(PathBuf::from(reference))
752    }
753
754    impl From<BusConfigDto> for BusConfig {
755        fn from(dto: BusConfigDto) -> Self {
756            Self {
757                cycle_time: Duration::from_millis(dto.cycle_time_ms),
758                distributed_clocks: dto.distributed_clocks,
759                max_subdevices: dto.max_subdevices,
760                max_pdi_bytes: dto.max_pdi_bytes,
761                default_nic: dto.default_nic,
762                ftti: Duration::from_millis(dto.ftti_ms),
763            }
764        }
765    }
766
767    /// Fold an ESI device's sync managers into the
768    /// `esi_output_watchdog_enabled` evidence the watchdog pass consumes:
769    /// `Some(true)` iff at least one output SM exists and every output SM
770    /// declares the watchdog trigger enabled; `Some(false)` if any output
771    /// SM disables it; `None` if there is no output SM at all.
772    fn esi_output_watchdog_enabled(
773        sync_managers: &[taktora_ethercat_esi::SyncManager],
774    ) -> Option<bool> {
775        let mut saw_output = false;
776        for sm in sync_managers {
777            if sm.direction == taktora_ethercat_esi::SmDirection::Output {
778                saw_output = true;
779                if !sm.watchdog_trigger_enable {
780                    return Some(false);
781                }
782            }
783        }
784        saw_output.then_some(true)
785    }
786
787    /// Map an ESI [`ResolveError`](taktora_ethercat_esi::ResolveError) to the
788    /// netcfg error for `label`, stamping the device label on each variant.
789    /// Extracted from [`DeviceInstanceDto::resolve`] to keep its cyclomatic
790    /// complexity within the project's gate.
791    fn map_resolve_error(label: &str, e: taktora_ethercat_esi::ResolveError) -> NetcfgError {
792        use taktora_ethercat_esi::ResolveError as R;
793        match e {
794            R::NoAlternativeMappings => NetcfgError::OpModeOnFlatDevice {
795                label: label.to_owned(),
796            },
797            R::MappingNotFound {
798                requested,
799                available,
800            } => NetcfgError::OpModeNotFound {
801                label: label.to_owned(),
802                requested,
803                available,
804            },
805            R::NoDefaultMapping => NetcfgError::NoDefaultMapping {
806                label: label.to_owned(),
807            },
808            R::UnknownAssignmentPdo { index } => NetcfgError::UnknownAssignmentPdo {
809                label: label.to_owned(),
810                index,
811            },
812        }
813    }
814
815    impl DeviceInstanceDto {
816        /// Convert into a [`DeviceInstance`], resolving an `esi:` reference
817        /// (read file, parse, convert PDOs, map identity) when present, and
818        /// capturing the ESI's output-SM watchdog-enable evidence.
819        ///
820        /// Path resolution is via `std::fs` against the process CWD;
821        /// resolving relative-to-the-yaml-file is build glue and deferred.
822        /// The resolved `sm_watchdog` is `None` here; the watchdog pass in
823        /// [`NetworkConfigDto::resolve`] fills it for output devices.
824        fn resolve(self) -> Result<ResolvedDevice, NetcfgError> {
825            let Self {
826                label,
827                esi,
828                op_mode,
829                pdos,
830                identity,
831                station_alias,
832                address_override,
833                sm_watchdog_timeout_ms,
834                sm_watchdog_enabled,
835                startup_sdos,
836            } = self;
837
838            if esi.is_none() && op_mode.is_some() {
839                return Err(NetcfgError::OpModeOnFlatDevice { label });
840            }
841
842            // Convert startup SDOs before `label` is moved into the DeviceInstance literal.
843            let startup_sdos = startup_sdos
844                .iter()
845                .map(|s| convert_startup_sdo(&label, s))
846                .collect::<Result<Vec<_>, _>>()?;
847
848            let (source, identity, esi_output_watchdog_enabled, supports_coe) = match esi {
849                Some(reference) => {
850                    let path = esi_local_path(&reference)?;
851                    let xml = std::fs::read_to_string(&path)?;
852                    let esi_file = taktora_ethercat_esi::parse(&xml)?;
853                    let count = esi_file.devices.len();
854                    // Minimal selection: exactly one device, or it is
855                    // ambiguous (identity-based selection is deferred).
856                    if count != 1 {
857                        return Err(NetcfgError::AmbiguousEsiDevice { path, count });
858                    }
859                    let device = esi_file
860                        .devices
861                        .into_iter()
862                        .next()
863                        .expect("checked count == 1");
864
865                    // A CoE mailbox is required to accept PDO-assignment SDO
866                    // writes (0x1C12/0x1C13). Simple terminals with no mailbox
867                    // (EL1008/EL2004) must NOT be written to — codegen emits an
868                    // empty assignment for them and they keep their fixed
869                    // default mapping.
870                    let supports_coe = device
871                        .mailbox
872                        .as_ref()
873                        .and_then(|m| m.coe.as_ref())
874                        .is_some();
875
876                    // op_mode selects the PDO mapping and is not retained in
877                    // the IR; the resolved PDOs in `assignment` are the only output.
878                    let assignment = device
879                        .resolve_assignment(op_mode.as_deref())
880                        .map_err(|e| map_resolve_error(&label, e))?;
881                    let to_entry = |e: &taktora_ethercat_esi::ResolvedPdoEntry| PdoEntry {
882                        index: e.index,
883                        bit_offset: e.bit_offset,
884                        bit_length: e.bit_length,
885                    };
886                    let rx: Vec<PdoEntry> = assignment.rx.iter().map(to_entry).collect();
887                    let tx: Vec<PdoEntry> = assignment.tx.iter().map(to_entry).collect();
888                    // If the device ALSO carries inline pdos:, the two
889                    // descriptions must agree. The ESI is the source of
890                    // truth, so an exact match is redundant-but-legal and a
891                    // mismatch is a contradiction (REQ_0824). The ESI side is
892                    // the op_mode-resolved mapping (if op_mode is set), so
893                    // inline pdos: must match the selected mapping's PDOs.
894                    let has_inline = !pdos.rx.is_empty() || !pdos.tx.is_empty();
895                    if has_inline && (pdos.rx != rx || pdos.tx != tx) {
896                        return Err(NetcfgError::EsiContradiction { label });
897                    }
898                    let wd_enabled = esi_output_watchdog_enabled(&device.sync_managers);
899                    // Keep an explicit YAML identity; otherwise carry the
900                    // ESI identity into the device (same type — od-core Identity).
901                    let identity = identity.or(Some(device.identity));
902                    (
903                        DeviceSource::Esi { path, rx, tx },
904                        identity,
905                        wd_enabled,
906                        supports_coe,
907                    )
908                }
909                None => (
910                    DeviceSource::Inline {
911                        rx: pdos.rx,
912                        tx: pdos.tx,
913                    },
914                    identity,
915                    None,
916                    // Inline devices carry no ESI mailbox info; the integrator
917                    // listed PDOs intending to assign them (preserves the
918                    // pre-existing emit-assignment behavior).
919                    true,
920                ),
921            };
922
923            Ok(ResolvedDevice {
924                device: DeviceInstance {
925                    label,
926                    source,
927                    identity,
928                    station_alias,
929                    address_override,
930                    sm_watchdog_timeout: sm_watchdog_timeout_ms.map(Duration::from_millis),
931                    sm_watchdog_enabled,
932                    sm_watchdog: None,
933                    startup_sdos,
934                    supports_coe,
935                },
936                esi_output_watchdog_enabled,
937            })
938        }
939    }
940}