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}