Solution strategy

arc42 §4 — the toolchain’s shape is the consequence of eight architectural decisions. Each is captured as an ADR that :refines: the requirement or feature it answers.

Architecture Decision: Parser separated from codegen (strict layering) ADR_0070
status: open
refines: FEAT_0050

Context. A monolithic “ESI to ethercrab driver” crate would conflate three concerns — XML parsing, IR shape and naming policy, and ethercrab-specific code emission. Downstream tools that need only one of these (a TwinCAT importer, a network configurator, the EEPROM verifier) would be forced to compile ethercrab and quote! machinery.

Decision. Three crates: ethercat-esi (parser, no_std), ethercat-esi-codegen (IR + backend trait, no ethercrab), ethercat-esi-codegen-ethercrab (concrete backend). The parser owns no_std purity; the codegen owns naming and collision policy; the backend owns ethercrab opinion.

Consequences. ✅ Each layer has one job and one reason-to-change. ✅ Future backends (soem, minimal, no_std-only) are net-additive — no parser or codegen churn. ❌ Three crates instead of one — more Cargo.toml surface for the eventual maintainer. Mitigated by the workspace being single-repo.

Architecture Decision: Two-trait runtime split (EsiDevice + EsiConfigurable) ADR_0071
status: open
refines: FEAT_0054

Context. The hot path (cyclic decode_inputs / encode_outputs) is synchronous and called from the cycle-loop. The bring-up path (configure) is async, issues SDO writes through the ethercrab API, and only runs during preop. Mixing them under one trait would force either: (a) an async fn on the hot-path methods (forbidden, wrong semantics), or (b) a sync configure that can’t talk to ethercrab’s async SDO writes.

Decision. Two traits. EsiDevice is sync, mandatory, owns the hot path. EsiConfigurable: EsiDevice is async, optional (a device without configurable PDO mappings can skip it), owns the preop SDO sequence.

Consequences. ✅ Hot path stays sync and zero-cost. ✅ Devices that don’t need preop SDO writes don’t need to implement the async trait. ❌ Two trait names instead of one; the consumer-side dispatcher has to handle both shapes. Acceptable cost; the generated code shoulders most of the burden.

Architecture Decision: PDO assignment alternatives as sum types ADR_0072
status: open
refines: FEAT_0053
is refined by: ADR_0104

Context. An ESI device commonly declares 2–4 PDO assignment alternatives (e.g. “Standard 16-bit”, “Compact 8-bit”). One representation in Rust is a single struct with Option<…> fields for each alternative’s PDO entries — convenient but lossy: invalid combinations (two alternatives enabled at once) are representable.

Decision. Emit an enum <Device>PdoAssignment with one variant per alternative, plus one <Device>Pdo<Variant> struct per variant. The device struct’s pdo field is the enum; selecting a variant chooses both the assignment bitfields and the typed PDO struct.

Consequences. ✅ “Two alternatives at once” is unrepresentable. ✅ Per-variant PDO structs have Default and Clone; switching alternatives at runtime is a self.pdo = … assignment. ❌ Slightly more generated code per device (one enum + N structs instead of one struct). Negligible at the device counts in scope (<100).

Architecture Decision: Joint per-device OpMode enum supersedes per-direction PDO-assignment alternatives ADR_0104
status: accepted
refines: ADR_0072
links outgoing: REQ_0523

Context. PDO assignment alternatives... (ADR_0072) modelled PDO-assignment choice per-direction, on the assumption that an ESI device offers a small menu of interchangeable RxPDO / TxPDO alternatives. Two facts about real Beckhoff ESI break that model. First, Beckhoff marks selectable PDOs Fixed="1" — which the original codegen read as “always assigned”, when Fixed actually means only that the PDO’s entry list is not editable (orthogonal to whether the PDO is assigned to a sync manager). Second, a valid sync-manager assignment is a set spanning both directions at once, not an independent per-direction pick: the EL7047 “Positioning interface” mode assigns SM2 = {0x1601, 0x1602, 0x1606} and SM3 = {0x1a01, 0x1a03, 0x1a07} as one indivisible choice. A per-direction sum type can represent neither a multi-PDO assignment within one direction nor the SM2↔SM3 pairing that makes the choice coherent.

Decision. Resolve each device to a set of selectable assignments (one per <AlternativeSmMapping>, else a single default) and emit one joint <Dev>OpMode enum — one variant per assignment, each variant carrying the complete { inputs, outputs } for that mode (Selectable PDO assignments ... (REQ_0523), Each OpMode variant carries... (REQ_0524)). The per-direction classify_alternatives / MultipleAlternativeGroups machinery and the <IDENT>PdoAssignment sum type are retired. This refines PDO assignment alternatives... (ADR_0072)’s “make illegal states unrepresentable” principle, moving its granularity from per-direction to per-device: the illegal state now ruled out is “two operating modes active at once”, and the pairing between an RxPDO set and its TxPDO set is captured by construction.

Consequences. ✅ Fixed is decoupled from “always-on”: the default assignment derives from Sm= / Mandatory, not Fixed (Default PDO assignment deri... (REQ_0527)). ✅ Every device is uniformly struct <Dev> { mode: <Dev>OpMode }; a single-mode device simply gets a one-variant Default enum, so there is no special case for “non-selectable” devices. ✅ Multi-PDO and cross-direction assignments are now expressible. ❌ This is pre-v1.0 churn: the golden snapshot output changes for every device, since even single-mode devices move from a bare struct to the struct { mode } shape. Acceptable before the first release.

Architecture Decision: Future CANopen support via shared OD IR ADR_0073
status: accepted
refines: FEAT_0050
links outgoing: ADR_0078
links incoming: ADR_0078

Context. EtherCAT’s CoE inherits the CANopen Object Dictionary (CiA 301) wholesale: index/subindex addressing, PDO mapping objects, the data-type table. EDS (CANopen) and ESI (EtherCAT) describe overlapping object dictionaries.

Decision. When CANopen support is in scope, the OD IR is extracted to a shared fieldbus-od-core crate. ethercat-esi shrinks to “ESI-specific elements + reference to OD core”. canopen-eds (new) parses CiA-306 INI and emits the same OD IR. The runtime traits are not merged (per NO unification of EtherCAT ... (REQ_0592)) — CANopen’s event-driven transport gets its own CanOpenDevice family in a separate crate.

Consequences. ✅ ~70 % of code is shared at the IR level when CANopen lands. ✅ Each transport keeps its honest runtime shape. ❌ Future refactor required to lift OD types out of ethercat-esi. Manageable — the lift is mechanical because parser and IR are already decoupled (per Parser separated from codeg... (ADR_0070)).

Closure. The OD-core lift (Lift OD IR to fieldbus-od-c... (ADR_0078)) is realised by the taktora-fieldbus-od-core crate (2026-05), which now holds the shared Identity, DataType, and DictEntry types; taktora-ethercat-esi depends on it and re-exports them, and taktora-ethercat-netcfg consumes the shared Identity. First-class FEAT/REQ authoring for taktora-fieldbus-od-core as a standalone feature is a follow-on round, paired with the CANopen consumer (CANopen device-driver codeg... (FEAT_0060)) that motivates a shared OD crate.

Architecture Decision: Vendor extensions captured as opaque blobs ADR_0074
status: open
refines: FEAT_0051
links incoming: RISK_0011

Context. Vendor-specific <Vendor:Foo> elements appear in real-world ESI files (notably Beckhoff). Three policies are possible: hard-fail on unknown elements (rejects most real files), warn (reports but parses), capture as opaque blobs (parses, retains the data, defers interpretation).

Decision. Capture as opaque blobs. The IR carries a RawXml { name, attributes, inner } for every unrecognised element. The codegen layer ignores them; downstream tools (e.g. a Beckhoff-specific importer) can interpret them.

Consequences. ✅ Real-world ESI files parse without bespoke patches per vendor. ✅ Information is preserved, not discarded. ❌ The IR carries a payload nobody on the ethercrab-backend side reads. Negligible cost; the alternative (parse-and-discard) is worse.

Architecture Decision: Object dictionary as static table, feature-gated ADR_0075
status: open
refines: FEAT_0054
links incoming: RISK_0022, RISK_0010

Context. OD-heavy devices (e.g. Beckhoff ELxxx coupling modules with 200+ entries) can balloon generated code by 10–50× if every OD entry becomes a match arm. Three options: match arms (large, fast lookup), static table (smaller, linear lookup but O(log n) with binary search on sorted index), no emission (smallest, fails the “OD-aware diagnostics” use case).

Decision. Emit OD entries as a sorted static OD: &[(u16, u8, DataType, &str)] table, gated behind a default-off object-dictionary cargo feature on the generated module’s parent crate.

Consequences. ✅ Default builds carry zero OD-related code or rodata. ✅ Diagnostic tools that need OD lookup cargo build --features object-dictionary and binary-search the sorted slice. ❌ OD-aware code is a separate compile profile; CI must cover both.

Architecture Decision: Use prettyplease, not rustfmt, for emit formatting ADR_0076
status: open
refines: FEAT_0055

Context. Emitting raw TokenStream produces single-line files that are unreadable when inspected. Two formatting options: shell out to rustfmt (requires a working rustfmt binary at build time; cargo doesn’t guarantee one) or call prettyplease::unparse (a library; works offline; smaller formatter than rustfmt).

Decision. prettyplease in-process. No shell-out to rustfmt.

Consequences. ✅ Build helper has no external command dependency. ✅ Output is stable across rustfmt versions. ❌ Slightly different formatting than the rest of the workspace’s rustfmt-formatted code (prettyplease is opinionated but not 100 % rustfmt-compatible). Acceptable — generated files are not human-edited.

Architecture Decision: cargo subcommand for inspection, not proc-macro ADR_0077
status: open
refines: FEAT_0056

Context. Two inspection mechanisms were on the table. Option A: an esi_device!("EL3001.xml") proc-macro that inserts the device’s generated code at call sites — gives the IDE rust-analyzer hover info at the call site, doubles the codegen path. Option B: a cargo esi expand subcommand that prints generated code to stdout — no IDE integration, one codegen path.

Decision. Cargo subcommand only. Proc-macro form is rejected per NO proc-macro front-end (REQ_0591).

Consequences. ✅ One codegen path; tests run once. ✅ No proc-macro compile-time cost on every workspace cargo build. ❌ IDE doesn’t surface generated symbols on hover. Acceptable; the generated file is a regular file in $OUT_DIR that rust-analyzer indexes.

Architecture Decision: std/POSIX baseline for the parser and OD-core crates ADR_0097
status: accepted
refines: FEAT_0051
links incoming: ADR_0098

Context. no_std + alloc compatible (REQ_0501) and no_std + alloc baseline for... (CON_0013) specified a no_std + alloc baseline. In practice taktora targets a POSIX OS today, and no_std forced a hand-rolled error type (no core::error::Error) and blocked thiserror-derived errors carrying source positions (Parse errors carry line and... (REQ_0506)).

Decision. taktora-ethercat-esi and the new taktora-fieldbus-od-core crate target std. thiserror v2 provides the error derive. no_std is deferred, not abandoned; genuinely no_std crates (e.g. taktora-bounded-alloc) are unaffected.

Consequences. no_std + alloc compatible (REQ_0501) is rejected and no_std + alloc baseline for... (CON_0013)’s scope narrows to the runtime-trait crate (Runtime trait surface (FEAT_0054)), revisited in its own round. The parser gains located, core::error::Error-implementing errors.

Architecture Decision: Object-safe EsiDevice, identity reuse, ethercrab behind SdoWrite ADR_0098
status: accepted
refines: FEAT_0054
links outgoing: ADR_0078, ADR_0097

Context. The runtime-trait surface as first specified carried three latent contradictions. (1) EsiDevice trait shape (REQ_0530) put const IDENTITY on the EsiDevice trait, but an associated const makes a trait dyn-incompatible — directly breaking the Box<dyn EsiDevice> registry factories Generated module root expos... (REQ_0525) mandates. (2) ethercat-esi-rt (runtime tr... (BB_0063) had ethercat-esi-rt depend on ethercrab and be no_std, which is impossible (ethercrab is a std async-networking crate) and would force every EsiDevice consumer — a simulator, a TwinCAT importer — to compile ethercrab’s full stack, defeating Traits live in ethercat-esi... (REQ_0532) / Trait stability for ecosyst... (QG_0013). (3) The spec named a fresh SubDeviceIdentity type alongside the already-adopted od-core Identity (Lift OD IR to fieldbus-od-c... (ADR_0078)) and ethercrab’s own SubDeviceIdentity, re-opening the type-triplication the OD-core lift had just closed.

Decision. Resolve all three at the trait crate:

  • EsiDevice is object-safe — identity is the method fn identity(&self) -> Identity, not an associated const. The static identity value lives in the standalone const of Identity const emitted per ... (REQ_0522); the registry keys on that const and stores Box<dyn EsiDevice> factories.

  • EsiConfigurable::configure is generic over a minimal SdoWrite trait (SdoWrite abstraction keeps ... (REQ_0535)) instead of naming SubDevicePreOperational. ethercat-esi-rt thus has no ethercrab dependency; the concrete impl SdoWrite for SubDevicePreOperational lives in the backend (Backend crate is the sole e... (REQ_0520)).

  • Identity is the shared od-core Identity (vendor_id / product_code / revision); no SubDeviceIdentity is minted. Mapping onto ethercrab’s wire identity (which adds serial) is the connector adapter’s job (taktora-connector-ethercat ... (BB_0067)).

Consequences. ✅ The Box<dyn EsiDevice> registry compiles. ✅ EsiDevice consumers pay nothing for ethercrab. ✅ Generated configure is unit-testable against a mock SdoWrite. ✅ One identity type across the toolchain. ❌ Deviates from the literal wording of EsiDevice trait shape (REQ_0530) / EsiConfigurable trait shape... (REQ_0531) / Identity const emitted per ... (REQ_0522) / Revision collision handled ... (REQ_0512), now reworded to match. ❌ A thin extra trait (SdoWrite) and one generic on configure.

Architecture Decision: The ESI parser emits a faithful IR and never resolves configuration ADR_0102
status: accepted
refines: FEAT_0051
links outgoing: REQ_0504, REQ_0849, REQ_0850

Context. Several ESI constructs describe a space of possible device configurations rather than one concrete configuration: PDO assignment alternatives (multiple PDOs competing for a sync manager), MDP module slots (pluggable terminals chosen per installation), and the SII source data (whose meaning depends on verifier policy). Each parser extension re-raises the same question: should the parser resolve these into something concrete — a flat process image, an effective PDO list, decoded PDI fields?

Decision. No. The parser captures what the document declares, structurally and faithfully, and stops there: PDO alternatives are kept per declared element with no bit offsets (IR carries identity, PDO ma... (REQ_0504)), the MDP catalog and slot constraints are kept unexpanded (MDP module catalog and slot... (REQ_0850)), and EEPROM payloads are decoded to bytes but not interpreted (EEPROM (SII source) data ca... (REQ_0849)). Resolution — picking alternatives, expanding a plugged lineup, assembling or checking an SII image — is consumer work (codegen, network configurator, verifier), where the missing inputs (the user’s terminal lineup, the verifier’s policy) actually live.

Consequences. ✅ One parse result serves every consumer regardless of its policy. ✅ Parser tests need no configuration fixtures, only documents. ✅ Consumer resolution logic is testable against a stable IR. ❌ Every consumer that needs a process image must implement (or share) resolution logic — the netcfg modular round will design that layer against this boundary. Lossless re-representations (hex → bytes, #x → integers) are NOT resolution and stay in the parser.