Skip to content

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

Open
gilesknap wants to merge 23 commits into
mainfrom
issue-54-rebase
Open

Drive ADS symbol expansion from terminal YAML (closes #54, #53)#59
gilesknap wants to merge 23 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 (latest pushes)

  • 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.

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 (315338f).

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 12 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>
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