fix(abcip): unblock the controller-browse gate, and fix the decoder it exposed (#468)
v2-ci / build (push) Successful in 4m17s
v2-ci / unit-tests (push) Failing after 14m43s

The AbCip controller browse could never be verified end to end: libplctag's
ab_server answers CIP service 0x55 (Get Instance Attribute List) on the Symbol
Object class with "unsupported", so the `@tags` walk had no backend. #468's own
definition of done offered a sim that serves `@tags` as an alternative to real
hardware; this is that.

The fixture now applies a vendored patch teaching ab_server to serve the Symbol
Object listing, with partial-transfer paging. Correctness is checked against
libplctag's own `list_tags_logix` rather than against our decoder — our decoder
is the thing under test, so its agreement would prove nothing. The clone is also
pinned to a concrete tag: the Dockerfile called `--branch release` "pinned" while
tracking a branch that moves.

That immediately exposed a real defect. CipSymbolObjectDecoder advanced to an
even byte boundary after each symbol name, but entries are packed with no
padding — libplctag walks them by sizeof(entry) + string_len, and a captured
reply confirms it. Any odd-length name therefore desynchronised every entry
behind it, and odd-length names are ordinary. In the live browse below,
`Motor_Run` is 9 bytes and first on the wire, so the old decoder would have
shown that one tag and silently dropped the other eight.

Every existing multi-entry test passed throughout, because the test builder
padded exactly as the decoder did: the two agreed with each other and neither
agreed with a controller. The builder no longer pads, and the layout is pinned
by a captured reply (CipSymbolObjectWireCaptureTests) instead of by a second
statement of the assumption. Restoring the alignment step reddens exactly the
three new tests and none of the 29 others.

Live gate on docker-dev, all three of #468's criteria:
  - populated tree — all nine seeded tags render as leaves, in wire order
  - refresh re-captures
  - leaf commit — Pump_1_Speed + TestDINT saved with their CIP tag paths

The gate also surfaced two things it was not asked about: browse-commit drops
the discovered data type, so a browsed REAL commits as a DINT and the driver
decodes it as one (filed as #536), and Test Connect fails against this fixture
regardless of the device because it probes `@raw_cpu_type` (documented).

TwinCAT and FOCAS still have no browsable backend; #468's AbCip half is closed,
theirs is not.

Claude-Session: https://claude.ai/code/session_015p7wGqy3YpZNCpDzTpGMKo
This commit is contained in:
Joseph Doherty
2026-08-01 12:25:15 -04:00
parent 1a1dbc3f00
commit c2c3c52274
8 changed files with 485 additions and 24 deletions
@@ -1105,12 +1105,43 @@ Equipment-kind namespace and bound to an equipment; a tag was added on the Tags-
- ⚠️ **Not verifiable on this fixture** — a *populated* tree render + leaf-commit. The libplctag `ab_server`
test sim returns `ErrorUnsupported` for controller symbol enumeration (it implements tag read/write, not the
`@tags` Symbol Object walk). This is a **fixture limitation, not a code defect** — the binding is proven alive.
**CLOSED 2026-08-01 — see the second gate below.**
### Follow-ups discovered during the gate
## Second live-gate result (Gitea #468 — docker-dev, 2026-08-01)
1. **Full tree-render verification is fixture-blocked.** `ab_server` doesn't implement the `@tags` controller
walk. To live-verify a populated tree + `TagConfig.FullName` leaf commit, point the universal browser at a
symbol-enumeration-capable AB backend (a real ControlLogix, or a sim that serves `@tags`).
The fixture gap was closed by teaching `ab_server` to serve the Symbol Object listing
(`tests/Drivers/…AbCip.IntegrationTests/Docker/patches/`), which was the option #468's own
definition of done offered alongside real hardware. All three criteria ran green against the
patched fixture on `10.100.0.35:44818`:
- ✅ **Populated controller-tag tree** — the AbCip browse rendered all nine seeded tags as leaves
under `AbCip → Device1 → Discovered`, in wire order: `Motor_Run`, `Line1_Count`, `Pump_1_Speed`,
`TestArray`, `TestString`, `TestSINT`, `TestBOOL`, `TestREAL`, `TestDINT`.
- ✅ **Refresh re-captures** — Refresh collapsed to the root and re-expansion returned the same
nine leaves from a fresh capture.
- ✅ **Leaf commit round-trip** — selecting `Pump_1_Speed` + `TestDINT` and committing wrote both
into the raw tree with the CIP tag path in `TagConfig` (`{"tagPath":"TestDINT",…}`) and saved.
**The gate earned its keep — it found a defect no unit test could have.** `CipSymbolObjectDecoder`
advanced to an even byte boundary after each symbol name, but Symbol Object entries are packed with
no padding. `Motor_Run` is 9 bytes, so under the old decoder the browse above would have shown
**`Motor_Run` alone** and silently dropped the other eight. Every existing multi-entry test passed,
because the test builder padded exactly as the decoder did — the pair agreed with each other and
neither agreed with a controller. Fixed, with the layout now pinned by a captured reply
(`CipSymbolObjectWireCaptureTests`) rather than by a restatement of the assumption.
### Follow-ups discovered during the second gate
1. **Browse-commit discards the discovered data type** — a browsed REAL commits with
`"dataType":"DInt"` in its `TagConfig`, and the driver decodes off that field rather than the
row's `DataType`. Filed as **Gitea #536**; affects AbCip and (by inspection) TwinCAT.
2. **Test Connect fails against this fixture regardless of the device** — the probe falls back to
`@raw_cpu_type` when no tags are authored, which `ab_server` does not implement. Fixture
limitation, documented in the fixture README; browse is unaffected.
### Follow-ups discovered during the first gate
1. ~~**Full tree-render verification is fixture-blocked.**~~ **CLOSED** by the second gate above.
2. **Driver-page address pickers pass no live config.** `AbCipDriverPage`/`TwinCATDriverPage`/`FOCASDriverPage`
host the same `*AddressPickerBody` but do **not** pass `DriverType`/`GetConfigJson`, so their (now-visible)
Browse button would capture against `"{}"` (no device). Out of Task 14's scope (which listed only the