Skip to content

Drive ADS symbol expansion from terminal YAML (closes #54, #53)#59

Merged
gilesknap merged 24 commits into
mainfrom
issue-54-rebase
May 22, 2026
Merged

Drive ADS symbol expansion from terminal YAML (closes #54, #53)#59
gilesknap merged 24 commits into
mainfrom
issue-54-rebase

Conversation

@gilesknap
Copy link
Copy Markdown
Contributor

@gilesknap gilesknap commented May 21, 2026

Rebased / cleaner-history alternative to #56. Same target: collapse the two parallel pipelines #54 describes, with the cleanups #53 was a band-aid for.

Summary

  • Replace the hardcoded AdsSymbolTypePattern LUT in src/fastcs_catio/symbols.py with a YAML-driven expander. For each bus-discovered AdsSymbolNode we look up the matching terminal by (vendor_id, product_code, revision_number) (with vendor+product fallback for revision drift) and emit one AdsSymbol per selected: true leaf YAML row at parent.offset + row.bit_offset // 8.
  • New SymbolNode.bit_offset field; XML parser (src/catio_terminals/xml/pdo.py) now records cumulative bit position per entry within its parent struct.
  • Subscriptions gated on attribute_map membership — the IOC only subscribes to symbols a PV consumes. Closes the seam Spurious "No reference to _SyncUnits._default_._unreferenced_.WcState.WcState" warnings on real hardware #53 papered over.
  • Master-device Inputs / Outputs frame state (Frm0State, SlaveCount, ...) kept as a tiny built-in layout — not user-extensible, doesn't belong in YAML.
  • .claude/skills/beckhoff-xml/SKILL.md updated to drop the "two things" wording.

EP4374-0002 follow-ups

  • SymbolNode.channel_indices: list[int] so combo terminals like EP4374-0002 (AI on ch.1-2, AO on ch.3-4) can pin non-1-based channel numbering; parser populates it, expanders walk it, YAML row patched with channel_indices: [3, 4].
  • GUI terminal-details panel now shows a firmware revision dropdown populated from the ESI cache (src/catio_terminals/ui_components/terminal_details.py). Picking a different revision re-runs merge_xml_for_terminal and surfaces a toast with the drop/add/preserve counts. Lets users pin the right revision when Beckhoff renames PDOs across revisions, without hand-editing YAML. Partial step toward Support multiple firmware revisions of the same terminal in one YAML #60.
  • Fixed a latent idempotency bug in merge_xml_for_terminal surfaced by the revision dropdown: it force-set selected=True on every surviving entry, so a second merge promoted XML-only rows the first merge had added unselected — which silently selected CoE 0x1011 ("Restore default parameters", write-only command) and crashed the IOC at startup. Merge now preserves selection state; TerminalConfig.from_yaml marks loaded CoEs selected=True to keep round-trips correct (regression tests in tests/test_new_terminal_coe_selection.py).
  • EP4374-0002 in the shipped YAML pinned to revision 0x00130002 (was 0x00100002) to match the renamed AI Inputs Channel … / AO Outputs Channel … PDOs.

Generated-YAML & PDO-inference follow-ups (latest pushes)

  • PDO group inference, shared PDOs (8eb4247): EL3314's TC Input TxPDOs appear in two AlternativeSmMapping blocks. Last-writer-wins reverse-map left the "Inputs only" group with an empty symbol_indices, so selecting it in the GUI showed nothing. Group assignment now appends to every group a PDO belongs to.
  • PDO group inference, symmetric Exclude pairs (9fddb4a): EP4374-0002 / EP3174-0002 declare per-channel Standard ↔ Compact alternatives via reciprocal <Exclude> tags rather than a Combined PDO. The pre-existing Combined/Channel fallback didn't match, so pdo_groups was empty, every row stayed selected, and "No bus node provides offset for …" warnings fired at IOC startup. New fallback emits two groups, defaulting to whichever side carries the Sm attribute.
  • Non-leaf bare-struct parents dropped at generation (f6fbfbc): the same _drop_non_leaf_parents filter the bus side already applied now runs at the end of create_symbol_nodes, with symbol_index_to_pdo remapped so PDO group assignments still bind to surviving rows. Bus-side gate stays in place as defense-in-depth against stale or hand-edited YAML.
  • clean-yaml --remove-coe (8674e3e): the no-flag default used to silently throw away every CoE in the file — capable of erasing hours of GUI work in one command. Default flipped to preserve the 0x8000-0x8FFF settings range; explicit --remove-coe opts in to the old behaviour.
  • terminal_types.yaml ~29% smaller (315338f): CoESubIndex / CoEObject validators now fill fastcs_name, primitive bit_size, and all-zero default_data on load; to_yaml's _clean_coe_for_yaml strips them on save only when stored == derived, so any future hand-edit survives the round-trip. Removes the now-dead fastcs_name patch-up in merge_xml_for_terminal.
  • Per-slave revision in startup chain printout (c1d43b5): each slave line now ends with rev=0xHHHHLLLL (high word firmware, low word variant suffix), so EL3202-0010 / -0020 variants — which share a ProductCode and differ only in the RevisionNo low word — can be told apart, and rig-vs-cache firmware drift is visible immediately.
  • beckhoff-xml skill updates (ff899f0, 4b35901, fd1f9fb): captured the merge-idempotency invariant, the to_yaml / from_yaml CoE-selection asymmetry that broke the rig on 2026-05-21, the shared-PDO group last-writer-wins shape, the new clean-yaml flag scheme, and the generation-time non-leaf filter — so future sessions touching this code don't re-introduce the bugs.
  • terminal_types.yaml regenerated to pick up all of the above (3b4c031, plus the regenerations folded into f6fbfbc / 315338f / 9fddb4a).

Hardware verification

Verified against the rig at 172.23.242.39 (EK1100 + EK1110 + 3× EL3314). One hardware-only issue surfaced and was filed as #58 — the YAML's selected_pdo_group is per-terminal-class but the bus PDO config is per-slave-instance, so mixed-mode terminals on one chain need follow-up work.

Also verified the revision-dropdown + AO-write fix against an EP4374-0002 on the rig.

Test plan

  • uv run pytest — 198 passed, 23 skipped.
  • uv run ruff check, uv run pyright src tests — clean.
  • Live hardware: IOC starts cleanly, EL3314 Value PVs update, no per-cycle warning spam, writes work for PDO entries the rig exposes.
  • Live hardware: EP4374-0002 AO writes (ch.3/4) resolve correctly after pinning rev 0x00130002.

Related

🤖 Generated with Claude Code

gilesknap and others added 11 commits May 21, 2026 12:36
Collapse the two parallel pipelines that decided what symbols existed:
delete the hardcoded AdsSymbolTypePattern LUT in symbols.py and drive
bus-side expansion from the terminal YAML. The XML parser now records
each entry's bit_offset within its parent struct on the SymbolNode it
emits, so the runtime can compute every sub-symbol's ADS offset without
a per-struct case-arm. selected: false rows are no longer subscribed.

Also drops the _unreferenced_ carve-out (closes #53) since the seam it
papered over no longer exists, and updates the beckhoff-xml skill to
remove the "two things" guidance.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Beckhoff bumps revision numbers on backward-compatible firmware/silicon
updates while keeping the PDO layout identical. The rig at
172.23.242.39 hit identity-not-found warnings because its EL3314 is at
rev 0x180000 while the cached XML produced a YAML pinned to 0x100000.
Match on (vendor_id, product_code) when no exact revision match
exists — same physical layout, same bit_offsets.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EA.md and index.md are generated by the IOC at runtime as FastCS
attribute dumps in the working directory. They snuck in via `git add -A`
in the previous commit. Add them to .gitignore so this doesn't recur.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
ADS addresses are byte-granular. YAML rows with `bit_offset % 8 != 0`
(e.g. EL3104's `.Status__Limit 1` at bit 2, EL3314's `.Limit 1` at
bit 2) represent bit fields within a parent status byte and can't be
subscribed as standalone notifications — the ADS server rejects them
with ADSERR_DEVICE_SYMBOLVERSIONINVALID, crashing IOC startup.

The data is still available via the parent row's subscription; reading
individual bit fields is a FastCS-attribute-layer concern, not a
bus-side one. Match the old LUT's behavior by simply not emitting
AdsSymbols for non-byte-aligned rows.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The bare parent row of a struct terminal (e.g. EL3314's
`TC Inputs Channel {channel}` with children `.Value`, `.Limit 1`) has
type metadata sized for only the bit-collapsed status (typically 1
byte) but the bus reports the full parent struct size (4 bytes for
AI16/TC). Subscribing succeeds but the notification stream parser then
hits `assert symbol.nbytes == sample.size` (no message) in
messages.py, surfacing as "Notification flushing error:" with an empty
detail and breaking the flush loop.

Only emit AdsSymbols for "leaf" YAML rows — rows whose name_template
isn't a strict prefix of any other row's. Sub-fields cover the data
the user actually cares about; the bit-field bits that lived only in
the bare parent are not yet exposed as PVs (and weren't in the old
LUT path either for terminals like EL3314).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The bare 'assert symbol.nbytes == sample.size' in messages.py (both
get_notification_dtype and get_combined_notifications_dtype) and
'assert streams_dtype.fields' in client.py fire with empty
AssertionError detail, producing log lines like "Notification flushing
error:" with nothing useful after the colon. Add messages that name
the symbol and the conflicting sizes so the next failure is
diagnosable.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
_dtype_for_type_name was treating _DTYPE_MAP's element byte size as
the element count, so e.g. type INT (2 bytes per int16) emitted
AdsSymbol(dtype=int16, size=2) → nbytes=4, while the server reported
size=2 for the actual symbol. The notification stream then failed the
sample-size assertion on every flush.

For scalar primitives, count is always 1; only the ARRAY [a..b] OF X
branch uses an element count derived from the array bounds. Fix the
scalar branch to return count=1.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The IOC was subscribing to every symbol the bus discovered, including
master-device housekeeping like _SyncUnits._default_._unreferenced_.*
that has no PV consumer. After the _unreferenced_ carve-out was
removed in c65d2dd these notifications surfaced as per-cycle warnings.

Pass the server controller's full attribute_map keys into the
connection right after the map is built, then skip any AdsSymbol
whose f"_{name}" doesn't appear in that key set when add_notifications
runs. The connection falls back to subscribing everything if the
filter is never set (preserves the pre-#54 behaviour for callers that
bypass CATioServerController).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
A YAML row whose name_template (after channel substitution) doesn't
correspond to any bus-discovered symbol or parent struct silently
disappeared from _ecsymbols. The PV still got created via the IOC
path, but every read/write failed with "No match for controller N
and ADS Symbol ...". Log a warning at expansion time so the gap is
visible at startup instead of on first PV write.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
When a YAML row's parent isn't on the bus and the terminal has dynamic
PDO groups, the most likely cause is a mismatch between the YAML's
selected_pdo_group and the rig's actual PDO configuration. EL3314 is
the canonical example: YAML selects "with ColdJunction Compensation"
but the rig runs in the default "Inputs only" mode, so TC Outputs
Channel N never appears on the bus. Surface that hypothesis in the
warning message.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@codecov
Copy link
Copy Markdown

codecov Bot commented May 21, 2026

Codecov Report

❌ Patch coverage is 94.69697% with 7 lines in your changes missing coverage. Please review.
✅ Project coverage is 75.26%. Comparing base (460f5ff) to head (9fddb4a).

Files with missing lines Patch % Lines
src/fastcs_catio/catio_connection.py 86.66% 2 Missing ⚠️
src/fastcs_catio/messages.py 0.00% 2 Missing ⚠️
src/fastcs_catio/catio_controller.py 50.00% 1 Missing ⚠️
src/fastcs_catio/client.py 83.33% 1 Missing ⚠️
src/fastcs_catio/symbols.py 98.93% 1 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main      #59      +/-   ##
==========================================
+ Coverage   73.91%   75.26%   +1.35%     
==========================================
  Files          19       19              
  Lines        4129     4096      -33     
==========================================
+ Hits         3052     3083      +31     
+ Misses       1077     1013      -64     

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

gilesknap and others added 13 commits May 21, 2026 14:28
symbols.py was the largest gap in #59's patch coverage (45%). The new
tests exercise every entry point — _dtype_for_type_name, _find_parent_node,
expand_symbols_for_slave (including the unselected/non-leaf/sub-byte/
multi-channel/missing-parent/dynamic-PDO-hint branches), expand_device_symbols
(Inputs, Outputs, bare names, non-BIGTYPE skip), expand_primitive_node, and
build_symbols_for_device end-to-end. Also covers the new
get_terminal_type_by_identity exact-match, vendor+product fallback, and
no-match paths in terminal_config.py.

Coverage: symbols.py 45% → 99%, terminal_config.py 84% → 90%.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Combo I/O terminals like EP4374-0002 number their AO channels 3 and 4
(continuing on from AI channels 1, 2). The parser was storing only
channels=N (count), and the four iterators that resolve {channel} all
walked range(1, N+1) — fabricating Channel 1/2 names that don't exist
on the bus and silently dropping the write path.

SymbolNode now carries channel_indices: list[int]; the parser
populates it from sorted(set(channel_nums)); the bus expander and IOC
PV creator walk it directly. Legacy YAMLs without the field stay
1-based via a model validator. to_yaml omits the field when it equals
the implicit default.

EP4374-0002 rev 0x00100002 is the only DLS-shipped terminal where
this matters for the YAML as written; #60 tracks multi-revision
support for the case where the same product code has different PDO
naming across firmware revisions (EP2338-0002, and now EP4374-0002's
own newer revisions where the PDO names also changed).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The terminal-details panel now offers a dropdown of all firmware
revisions present in the cached ESI XML for the selected terminal.
Picking a different revision mutates `identity.revision_number` and
re-runs `merge_xml_for_terminal`, then surfaces a toast summarising
how many YAML rows were dropped, added unselected, or preserved. This
unblocks the EP2338-0002 case (and any future Beckhoff PDO rename) by
letting users pin the matching revision without hand-editing the YAML.

While verifying the dropdown on the rig, the re-merge surfaced a
long-standing idempotency bug in `merge_xml_for_terminal`: it forced
`selected=True` on every entry that survived merge, which on a second
pass promoted XML-only rows that the first pass had added unselected.
The downstream effect was the IOC trying to read CoE 0x1011 (Restore
default parameters, a write-only command CoE) and crashing on the
empty response. Fix:

- Drop the `yaml_sym.selected = True` / `yaml_coe.selected = True`
  force-assignments in both branches of `merge_xml_for_terminal`.
- `TerminalConfig.from_yaml` now marks loaded CoE objects as
  `selected=True` so the round-trip through YAML preserves intent
  (`to_yaml` already filters unselected CoE out on save).

Also pin EP4374-0002 to revision 0x00130002 so the channel-3/4 AO
writes resolve against the renamed PDOs.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Capture the merge-idempotency invariant and the to_yaml /
from_yaml CoE-selection asymmetry that broke the rig on 2026-05-21,
so future sessions touching the merge or YAML round-trip code don't
re-introduce the bug.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EL3314's TC Input TxPDOs (#x1a00..#x1a03) are listed in both the
`Inputs only` AlternativeSmMapping and `with ColdJunction Compensation`.
The previous last-writer-wins reverse map in assign_symbols_to_groups
let the second group claim those PDOs exclusively, leaving `Inputs only`
with an empty symbol_indices list. Selecting the default group in the
GUI therefore showed no symbols.

Collect every group a PDO belongs to and append the symbol to each.
The re-merged EL3314 YAML at rev 0x00180000 now populates both groups
correctly.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The startup EtherCAT chain dump showed each slave's terminal type and
name but not its identity revision. Without the revision it's awkward
to tell e.g. an EL3202-0010 from an EL3202-0020 (which share a
ProductCode and only differ in the low word of RevisionNo), or to spot
a firmware revision mismatch between the rig and the cached XML.

Append `rev=0xHHHHLLLL` to each slave line — high word firmware,
low word variant suffix.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
A command called clean-yaml shouldn't quietly throw away every CoE
setting in the file — yet that's what running it without flags used to
do. Hours of GUI configuration could vanish in one command. The repo's
checked-in YAMLs all carry settings-range CoEs, so the no-flag output
also didn't match how the file is actually used.

Defaults flipped:
- No flag: keep CoE in 0x8000-0x8FFF (was: strip everything).
- --include-all-coe: keep every CoE from XML (unchanged).
- --remove-coe: strip all CoE objects (the old default, now opt-in).
- --include-coe is gone — equivalent to the new default.

Print a one-line warning at the top of a --remove-coe run so the
destructive path is loud.

Verified: clean-yaml with no flags now produces byte-identical output
to the previously-committed re-merge (which used --include-coe).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
… skill

Two new sections lift this session's lessons out of memory and into the
skill so they surface automatically next time someone edits the XML
parser or runs clean-yaml:

- PDO groups can share PDOs across `AlternativeSmMapping` blocks
  (with the 8eb4247 last-writer-wins bug shape and the regression
  test that nails the invariant).
- `clean-yaml` selection contract: which symbols get selected after
  re-merge, the new CoE flag scheme (default keeps 0x8000-0x8FFF,
  --include-all-coe / --remove-coe), and the empty-diff verification
  trick.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Bare-struct parent rows (like EL3314's `TC Inputs Channel {channel}`)
whose name_template is a strict prefix of a sibling's were producing
orphan PVs: the FastCS attribute pipeline (driven by `selected`) created
them, but the bus-side `expand_symbols_for_slave` skipped them as
non-leaf — so the PV existed but never got a subscription.

Fix at the source: `_drop_non_leaf_parents` filters them out at the end
of `create_symbol_nodes` and remaps `symbol_index_to_pdo` so PDO group
assignments still bind to the surviving rows. Regenerating
`terminal_types.yaml` via `clean-yaml --all` removes 16 such rows across
12 terminals (-201/+38).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Promotes the durable rule introduced by f6fbfbc into the skill: the
non-leaf bare-struct filter now runs at XML→YAML generation as well as
on the bus side. Bus-side gate stays in place as defense-in-depth
against stale or hand-edited YAML.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
CoESubIndex/CoEObject validators now fill fastcs_name, primitive
bit_size, and (implicitly) all-zero default_data on load; to_yaml's
new _clean_coe_for_yaml strips them on save only when stored == derived,
so any future hand-edit survives the round-trip. Removes the now-dead
fastcs_name patch-up in merge_xml_for_terminal and documents the
convention in the beckhoff-xml skill.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ence

EP4374-0002 (and EP3174-0002) declare per-channel Standard ↔ Compact PDO
alternatives via reciprocal <Exclude> tags rather than a Combined PDO
that excludes ≥2 channels. The pre-existing fallback only matched the
Combined/Channel shape, so no pdo_groups landed in the YAML, every row
stayed selected, and "No bus node provides offset for AI Inputs Compact
…" warnings fired at IOC startup. Detect the symmetric-pair pattern and
emit two groups, defaulting to whichever side carries the Sm attribute.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@gilesknap gilesknap merged commit c36e26b into main May 22, 2026
11 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant