CANopen device-driver codegen — architecture (arc42)¶
Architecture documentation for the CANopen device-driver codegen toolchain (see CANopen device-driver codegen), structured per the arc42 template and encoded with sphinx-needs using the useblocks “x-as-code” arc42 directive types. Mirrors the structure of Device-driver codegen — architecture (arc42) so reviewers can read both umbrellas 1:1.
Each architectural element :refines: or :implements: a parent
requirement so the trace is preserved end-to-end.
1. Introduction and goals¶
The toolchain’s reason-to-exist is build-time monomorphisation of
CANopen device drivers: vendor-supplied EDS files describe each
node’s PDOs, OD, and bring-up SDO sequence; we want strongly-typed
on_rpdo / drain_tpdos code per device, with zero INI parsing
at runtime and no hand-written boilerplate per node.
Quality goals capture the qualities the architecture is optimised for.
The same set of EDS inputs (modulo file ordering) shall produce a byte-identical generated module across machines, toolchain versions, and clock walls. Generation order, hash-map iteration order, and timestamp inclusion are explicitly excluded as sources of nondeterminism. Required so the generated file is reviewable in diffs and cachable in CI. |
Each crate in the toolchain shall depend only on crates to its
left in the OD-core → parse → codegen → tooling chain (see
Toolchain layering (crate d... (ARCH_0070)). Crossover dependencies — e.g. the parser
reaching for |
A consumer of the generated modules shall pay no runtime cost
for the codegen layer’s existence: no INI parse, no allocation
for OD tables when |
The |
2. Constraints¶
The build helper shall live within cargo’s |
The EDS schema is published by CAN in Automation (CiA 306) and the underlying OD semantics by CiA 301. The parser shall track the published schema; schema drift across vendors shall be handled as captured in Unknown sections captured a... (REQ_0724) (unknown-section policy) and Liberal parsing — warn and ... (REQ_0725) (liberal-quirks policy) rather than by hard-failing the parse. |
|
|
3. Context and scope¶
Five layers, strict left-to-right dependency. Each crate has one
job and depends only on crates to its left. The follow-on
graph LR
subgraph OD["1. Shared OD core"]
ODC["fieldbus-od-core<br/>Identity, DictEntry,<br/>PdoEntry, PdoMap"]
end
subgraph Parse["2. Parse layer"]
EE["ethercat-esi<br/>(re-exports + ESI specifics)"]
P["canopen-eds<br/>INI → typed IR"]
end
subgraph Gen["3. Codegen layer"]
G["canopen-eds-codegen<br/>IR → TokenStream"]
B["canopen-eds-codegen-taktora<br/>concrete backend"]
end
subgraph RT["4. Runtime trait"]
RTC["canopen-eds-rt<br/>CanOpenDevice / CanOpenConfigurable"]
end
subgraph Tool["5. Tooling layer"]
BR["canopen-eds-build<br/>build.rs glue"]
CLI["canopen-eds-cli<br/>cargo eds expand / list"]
VER["canopen-eds-verify<br/>EDS ↔ SDO-dump diff"]
end
subgraph Cons["Consumers (follow-on)"]
USER["any CAN consumer<br/>(includes generated code)"]
SCC["taktora-connector-can<br/>thin CanOpenDevice adapter"]
end
ODC --> EE
ODC --> P
P --> G
G --> B
B --> RTC
B --> BR
B --> CLI
P --> VER
RTC --> BR
BR --> USER
USER --> SCC
|
The toolchain runs entirely at build time. Runtime consumers
only see the generated module and link against
|
4. Building blocks (per-crate)¶
The shared OD types lifted out of |
INI parser front-end. |
Codegen IR + |
The opinionated concrete backend. Emits one device struct per
EDS, sum-typed RPDO / TPDO enums (one variant per declared
mapping), an |
The runtime trait crate. |
|
|
Offline EDS vs JSON SDO-dump diff. Consumes the same |
Out-of-scope for this round. A thin adapter that maps any
|
5. Solution strategy¶
The toolchain’s shape is the consequence of nine architectural decisions captured below. The structure of the decisions mirrors Device-driver codegen — architecture (arc42) so the rationale chain from ESI to EDS is explicit and visible.
Context. Future CANopen support via ... (ADR_0073) foresaw the OD-IR lift but left it open. Two paths were possible: (a) lift now as part of the CANopen codegen round, (b) ship CANopen with a duplicated OD IR and lift later. Decision. Lift now. Consequences. ✅ Parser cost amortised over both fieldbuses. ✅ Closes Future CANopen support via ... (ADR_0073). ✅ No future breaking-change cycle to lift later. ❌ One mechanical refactor on existing Device-driver codegen toolc... (FEAT_0050) crates. The lift is low-risk because parser and IR were already decoupled (per Parser separated from codeg... (ADR_0070)). |
Context. Possible additions to a shared OD crate include
built-in serde derives, Decision. Consequences. ✅ Smallest possible blast radius for both parsers. ✅ Stable surface — adding a derive is additive. ❌ Two consumers (parsers) carry their own serde wiring; acceptable since the serde frontends are different anyway (XML vs INI). |
Context. Device-driver codegen toolc... (FEAT_0050) is already shipped. Two options
for compatibility: (a) break the API and bump major version,
(b) re-export the lifted types from Decision. Re-export. Consequences. ✅ Existing Device-driver codegen toolc... (FEAT_0050) consumers
compile source-unchanged. ✅ No major version bump needed. ❌
Two paths exist for the same type. Acceptable — the canonical
path is documented ( |
Context. Two reasonable INI crates exist in the Rust
ecosystem with passive (no-I/O) APIs: Decision. Treat them as interchangeable behind a serde-derive
façade. Consequences. ✅ The IR is decoupled from the INI tokeniser choice. ✅ Backend can be flipped without IR churn. ❌ The choice is deferred to the planning phase; the spec does not fix it. |
Context. When two devices’ PDOs carry the same bit-len +
data-type tuple list but different field names (e.g.
Decision. Structural dedup only — names are not part of the dedup key (per Common PDO entry types dedu... (REQ_0733)). The EtherCAT side made the same call implicitly; this ADR captures it explicitly for both fieldbuses going forward. Consequences. ✅ Higher dedup hit rate across devices that share a CiA profile (e.g. CiA 402 servo drives). ✅ Smaller generated artefacts. ❌ Two devices’ identical-shaped PDO structs may have field names from one device only. Acceptable — downstream code accesses by position via typed getter, not by the EDS-side string. |
Context. CANopen permits Decision. Skip. Typed PDO structs carry only real-payload
fields; bit offsets are threaded through generated Consequences. ✅ Cleaner API surface. ✅ No temptation for
callers to write padding fields. ❌ |
Context. Outbound TPDO frames need a payload buffer. Three
options: Decision. Consequences. ✅ No per-frame allocation. ✅ Sound across
embedded targets without a global allocator. ❌ CAN-FD’s
64-byte payload would need |
Context. The trait surface must distinguish hot-path frame
plumbing ( Decision. Mixed. Consequences. ✅ No runtime dependency leaks into the hot
path. ✅ Same posture as |
Context. The verifier needs a dump format to compare EDS against. Four options: CSV (lossy on hex / type info), custom binary (opaque to git review), YAML (heavier dep), JSON (inspectable, diff-able, schema-tag-able). Decision. JSON with explicit Consequences. ✅ Inspectable in git diffs. ✅ Easy to
produce from any tool (Python |
6. Risks¶
Vendor EDS exporters historically produce subtly different dialects (LineFeed key variations, comment styles, value trimming). Mitigation is the liberal-parser policy (Liberal parsing — warn and ... (REQ_0725)) — warn and continue rather than reject. The parser will accumulate fixture exposure to known-quirky files over time; the warning channel makes regressions visible. |
The Rust serde-INI ecosystem is less mature than serde-XML.
Both candidate crates ( |
CiA 402 servo drives and similar profile-rich devices carry 200+ OD entries. Generating the full OD table per device would balloon the codegen artefact by an order of magnitude. Mitigation: OD emission is feature-gated default-off (Object dictionary emission ... (REQ_0747) mirroring Object dictionary as static... (ADR_0075)); the EtherCAT side has already proven this approach for OD-heavy Beckhoff modules (cf. OD table size blow-up on co... (RISK_0010)). |
Generated code computes |
7. Cross-cutting traceability¶
ID |
Title |
Status |
Refines |
|---|---|---|---|
Lift OD IR to fieldbus-od-core now |
open |
||
fieldbus-od-core stays data-only |
open |
||
Re-export from ethercat-esi, do not break it |
open |
||
INI backend choice — serde-derive façade |
open |
||
PDO entry dedup is structural, name-blind |
open |
||
Dummy entries skip into bit offsets, not padding fields |
open |
||
heapless::Vec<u8, 8> for PdoOut payload |
open |
||
Async only on configure, sync on frame path |
open |
||
JSON SDO-dump format with versioned schema |
open |
ID |
Title |
Status |
Implements |
|---|---|---|---|
fieldbus-od-core |
open |
||
canopen-eds parser crate |
open |
||
canopen-eds-codegen |
open |
||
canopen-eds-codegen-taktora |
open |
||
canopen-eds-rt |
open |
||
canopen-eds-build |
open |
||
canopen-eds-cli |
open |
||
canopen-eds-verify |
open |
||
taktora-connector-can adapter (follow-on) |
open |
ID |
Title |
Status |
Refines |
|---|---|---|---|
Build-time determinism (same EDS in → same code out) |
open |
||
Layering integrity (strict left-to-right deps) |
open |
||
Zero runtime cost of codegen presence |
open |
||
Trait stability for ecosystem adoption |
open |
ID |
Title |
Status |
Refines |
|---|---|---|---|
cargo build-script semantics |
open |
||
CiA 301 / 306 own the EDS schema |
open |
||
no_std + alloc baseline for OD core, parser, runtime |
open |
||
heapless 0.8 surface for fixed-capacity buffers |
open |
ID |
Title |
Status |
Links |
|---|---|---|---|
EDS files in the wild are inconsistent |
open |
||
serde-ini ecosystem thinness |
open |
||
CiA 301 OD blow-up on profile-rich devices |
open |
||
COB-ID base assumptions in generated code |
open |