diff --git a/CLAUDE.md b/CLAUDE.md index cda3152a..9ab5c691 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1087,7 +1087,7 @@ re-emit a concrete default. The defaults differ per driver (final review, 2026-1 because the driver factory AND the admin-node Test Connect probe both read it. ⚠️ So **the upgrade restart alone moves no-key AbCip drivers to Native**; to stay on libplctag, set the node key — NOT a per-driver key through the old AdminUI, whose typed form drops unknown keys (#576). Evidence: AbCip reads proven on a real - CompactLogix (Z-35162, read-only); AbCip writes are `ab_server`-only. + CompactLogix (Z-35162); scalar native writes (DINT/BOOL/REAL) proven on it once (2026-10-09, user-approved one-off, restored) — array/STRING/UDT-member and fragmented writes are `ab_server`-only. - **AbLegacy: an absent key resolves to `Libplctag` for every family; `Native` is opt-in** (no node key). On a wire the native PCCC path is proven only for **N-file reads/writes and one F8 read**, all on `ab_server`; `S:0` (the default probe), B/T/C/R/S/L/ST/A/O/I and the T/C/R sub-element offsets are encoded from the PCCC diff --git a/docs/drivers/AbCip.md b/docs/drivers/AbCip.md index 276a1735..15da2cea 100644 --- a/docs/drivers/AbCip.md +++ b/docs/drivers/AbCip.md @@ -237,17 +237,20 @@ The driver has two interchangeable wire transports, selected by one `DriverConfi ## Known gaps -- ⚠️ **Native writes are verified against `ab_server` only (simulator-only), never a real controller.** The - real-press gate (Task 21) was **read-only**: it proved native reads, browse and templates on Z-35162 - (2026-10-08) and nothing else. Results: +- ⚠️ **Native hardware evidence is narrow.** The read-only real-press gate (Task 21, 2026-10-08) proved native reads, browse + and templates on Z-35162; a user-approved one-off on 2026-10-09 added **scalar native writes on a real CompactLogix** + (DINT `test2`, BOOL `Program:UN01_Press.xxtest`, REAL `Program:ES03_EM03_Zone3._ff_test`: write, read back, restore, all + Good). Still ab_server-only: array, STRING and UDT-member writes and fragmented writes. Mixed STRING/array/scalar + Multiple Service Packet reads (#582) are proven on the press (one 5-read MSP, values equal to single reads). Results: [native-ab-transport-gate-results](../plans/2026-10-07-native-ab-transport-gate-results.md). - **libplctag cannot be deleted** until a bench controller allows write verification. - ⚠️ RESIDUAL (#567): wide-struct **fragmented writes** (8-byte alignment) are unit-tested only. Production presses are read-only and there is no bench controller, so this cannot be proven; it stays open until a bench controller exists. - Native browse (#572, resolved in code): listing paging no longer assumes ascending instance order, pages - are paced, and a browse's template reads share one lease. One residual: whether a real controller ever - lists a page out of order is unobserved — the reader is now correct either way. `browsePageDelayMs` is an AdminUI driver-form field ("Browse page delay (ms)", #596); keys the form does not model are preserved on save (#576). + are paced, and a browse's template reads share one lease. Observed on Z-35162 (2026-10-09, 41 + pages / 3162 tags): every page strictly ascending, so out-of-order listing has never been seen on a real controller — the + reader is correct either way. `browsePageDelayMs` is an AdminUI driver-form field ("Browse page delay (ms)", #596); keys the form does not model are preserved on save (#576). - Micro800, routed and `logixpccc` paths are covered by `ab_server` only. The Micro800 profile passes natively. The AbLegacy default excludes `logixpccc` (and PLC-5). - Follow-up issues for this workstream: #564 to #577. diff --git a/docs/drivers/AbLegacy.md b/docs/drivers/AbLegacy.md index ddf566c2..383a4eab 100644 --- a/docs/drivers/AbLegacy.md +++ b/docs/drivers/AbLegacy.md @@ -219,6 +219,7 @@ Everything below is against the `ab_server` simulator; no real PCCC controller h | F-file read (`F8`) | one capture, `slc500-read-f8` (codec parity only; no native F write on a wire) | | S (incl. the default probe `S:0`), B, T, C, R, L, ST, A, O, I | **none** — encoded from the PCCC file-type table | | MicroLogix function files (RTC/HSC/...), PLC-5 octal I/O | **none** — parsed only; native refuses function files as Unsupported | +| Bit-within-word write (`B3:0/5`) | `ab_server` SLC500 + MicroLogix, both transports (`AbLegacyBitWriteTests`): the driver RMWs the parent word, only the addressed bit changes. PLC-5 native refuses it (`BadNotSupported`, #571) | | Indirect addressing (`N7:[N7:0]`) | `ab_server` N-file, both transports (#646) - resolved by the driver, the transport sees the composed address | | T/C/R sub-elements (`.PRE`/`.ACC`/`.LEN`/`.POS`), 6-byte T/C/R element layout | **none** | diff --git a/docs/plans/2026-10-07-native-ab-transport-gate-results.md b/docs/plans/2026-10-07-native-ab-transport-gate-results.md index 5d81422c..6877f513 100644 --- a/docs/plans/2026-10-07-native-ab-transport-gate-results.md +++ b/docs/plans/2026-10-07-native-ab-transport-gate-results.md @@ -477,3 +477,85 @@ Per-pass detail (pass 2): ``` +## hw4 evidence batch — 2026-10-09 (Gitea #592; #582, #572, ab_server bit write) + +Branch `hw4-evidence`, harness `Differential/EvidenceTests.cs` (same folder, same read-only source/IL guard, extra opt-in +env gates so a standard `Differential` run never repeats them). Press: Z-35162, CompactLogix 5069-L320ERS2, VPN, pace 50 ms, +one short-lived native session per leg (1 Forward Open, 1 Forward Close acked), reads only. A diagnostics-only tap +(`ConnectionDiagnostics.ExchangeObserver`, null in production, exceptions swallowed) lets the harness see each CIP request and +its raw reply; no packet capture was used because `tcpdump` needs sudo on this Mac. + +### Real-controller scalar writes (user-approved one-off, 2026-10-09) + +Done by the controller session, not by this harness, through the AbCip CLI on the **native** transport against Z-35162. The +tags were chosen because a census of the L5X export shows no routine references them and they are absent from the Galaxy map. +Every write and read-back returned Good, and each tag was restored: + +| Tag | Type | Sequence | +|---|---|---| +| `test2` | DINT | 0 -> 4242 -> 0 | +| `Program:UN01_Press.xxtest` | BOOL | false -> true -> false | +| `Program:ES03_EM03_Zone3._ff_test` | REAL | 0 -> 42.5 -> 0 | + +This is the first native write on a real controller: scalar DINT/BOOL/REAL Write Tag is proven on a CompactLogix. The user's +write approval covered only that one-off and is consumed; every other leg of this batch was read-only. Still unproven on +hardware: fragmented wide-struct writes (#567), array writes, STRING writes, UDT member writes. + +### #582 - mixed STRING / array / scalar Multiple Service Packet (PASS) + +`AB_EVID_MIX=1 AB_DIFF_ENDPOINT=10.209.70.160:44818 AB_DIFF_PACE_MS=50` -> `EvidenceTests.Mixed_string_array_and_scalar_reads_travel_as_one_multiple_service_packet` +(raw output: [hw4-mix-msp.txt](2026-10-07-native-ab-transport-t21/hw4-mix-msp.txt)). Five reads were issued concurrently on one native session, +five times (>= 1 s apart), bracketed by sequential single reads of the same tags: + +| Tag | Type | Value (single before / all 5 batches / single after) | +|---|---|---| +| `Program:UN01_Press.Sts_CycleTime_STR` | STRING | `'2:23:37'` throughout (matches the L5X export) | +| `Program:EM01_PowerUnit._SEQ_EP[1]` | DINT element | `300` throughout | +| `Program:EM01_PowerUnit._SEQ_EP` | DINT[32] | `[0,300,0 x30]` throughout | +| `test2` | DINT | `0` | +| `Program:UN01_Press.xxtest` | BOOL | `False` | + +- **5/5 attempts put all five reads in ONE channel send**: a single 204 B Multiple Service Packet (service 0x0A) answered by a + 273 B reply with `embedded=5`, outer status 0x00, embedded statuses all 0x00 with data lengths 92 B (STRING), 6 B, 130 B + (32 x DINT + type header), 6 B, 3 B. No embedded 0x06, no fragment re-fetch, `RequestsSent` delta = 1 each time. +- Every batch value equalled both bracketing singles, so reply packing/offsets decode correctly for a mix of 3-byte, 6-byte, + 92-byte and 130-byte replies. The values are stable (static config/test tags), so equality is not a coincidence of drift. +- The tags are non-zero where it matters (the string and the array element), so decode equality is not vacuous; `test2` / + `xxtest` are zero by design and are only there to pin the scalar slots. +- On `ab_server` (loopback) the same harness showed the pump can wake mid-queue and split a batch (attempt 1 of 5: an MSP of 4 + plus a bare read). That is a scheduling race, not a packing defect, which is why the leg repeats the batch and requires at + least one whole-batch MSP; on the press all five attempts were whole. + +### #572 - does a real controller list symbol instances out of order? (observed: no) + +`AB_EVID_BROWSE=1` -> `EvidenceTests.Symbol_listing_pages_are_ascending_on_a_real_controller` (raw: [hw4-listing-order.txt](2026-10-07-native-ab-transport-t21/hw4-listing-order.txt)). +One native `@tags` browse of Z-35162 through the production enumerator (default paging and pacing): 3162 tags, **41 listing +pages** (controller scope 8 pages, then each of the 15 programs, up to 6 pages for `ES03_TemperatureControl`). + +- **0 non-ascending pages.** Within every page the instance ids were strictly ascending, and each page's first id was above the + previous page's maximum (e.g. controller scope 1..105, 106..207, ... 670..671). +- Instance ids are not contiguous: ids can be skipped (e.g. `EM02_Clamp` page ends `...,281,282,288,289`; `UN01_Press` page 1 + ends at 109 and page 2 starts at 111). The reader's "next start = highest id + 1" rule handles that and the out-of-order case + alike; this is evidence only, no code changed. +- One empty page (`Program:Auxiliary`, 0 entries) was observed and handled. + +### ab_server PCCC bit-file write (`B3:0/5`): not an ab_server gap (no patch needed) + +The reported failure `0x803D0000` is `BadNotSupported`. Reproduced with new `AbLegacyBitWriteTests` +(writes `B3:0` = 0x0A0A, sets bit 5, clears bit 3, asserts the whole word each time so neighbour bits are proven preserved), +local ab_server (libplctag v2.7.0 + patches 0001/0002, built in OrbStack), `AB_LEGACY_ENDPOINT` pinned to a lane-local port: + +| Family | Libplctag | Native | +|---|---|---| +| SLC500 | pass | pass | +| MicroLogix | pass | pass | +| PLC-5 | pass | `BadNotSupported` (0x803D0000), by design | + +- The driver does a per-parent-word read-modify-write (typed read, then typed write of the word), never a masked CMD 0x0F/FNC + 0xAB write, so the bit write needs nothing ab_server lacks. (ab_server does implement FNC 0xAB, `handle_slc_rmw_request`, for + 16-bit elements; the driver simply does not use it.) +- The 0x803D0000 is the **native PLC-5 write refusal** (#571, `NativeAbLegacyTagRuntime.WriteRefusal`: all PLC-5 writes are + refused before the wire). The test pins it for PLC-5 + Native and requires success everywhere else. Because nothing was broken, + there is no fix to revert for a red/green proof; the PLC-5 expectation was the part that failed first (red) when the test + wrongly assumed success, which shows the assertions discriminate. No Dockerfile or patch change. + diff --git a/docs/plans/2026-10-07-native-ab-transport-t21/hw4-listing-order.txt b/docs/plans/2026-10-07-native-ab-transport-t21/hw4-listing-order.txt new file mode 100644 index 00000000..7fbf1f9f --- /dev/null +++ b/docs/plans/2026-10-07-native-ab-transport-t21/hw4-listing-order.txt @@ -0,0 +1,60 @@ + +Test run for /Users/dohertj2/Desktop/OtOpcUa/.worktrees/hw4-evidence/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/bin/Debug/net10.0/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.dll (.NETCoreApp,Version=v10.0) +VSTest version 18.0.1 (arm64) + +Starting test execution, please wait... +A total of 1 test files matched the specified pattern. +/Users/dohertj2/Desktop/OtOpcUa/.worktrees/hw4-evidence/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/bin/Debug/net10.0/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.dll + Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.EvidenceTests.Symbol_listing_pages_are_ascending_on_a_real_controller [4 s] + Standard Output Messages: + ## Listing order — 10.209.70.160:44818 path '1,0' family CompactLogix (compactlogix, connection size 4002), max tags 200, max templates 50, pace 50 ms + Tags browsed: 3162; listing pages observed: 41; Forward Opens 1; Forward Closes 1/1 + page 1: scope=controller entries=105 first=1 last=105 max=105 more=True ascending=True ids=[1,2,3,4,5,6,...,102,103,104,105] + page 2: scope=controller entries=102 first=106 last=207 max=207 more=True ascending=True ids=[106,107,108,109,110,111,...,204,205,206,207] + page 3: scope=controller entries=89 first=208 last=296 max=296 more=True ascending=True ids=[208,209,210,211,212,213,...,293,294,295,296] + page 4: scope=controller entries=84 first=297 last=380 max=380 more=True ascending=True ids=[297,298,299,300,301,302,...,377,378,379,380] + page 5: scope=controller entries=94 first=381 last=474 max=474 more=True ascending=True ids=[381,382,383,384,385,386,...,471,472,473,474] + page 6: scope=controller entries=95 first=475 last=569 max=569 more=True ascending=True ids=[475,476,477,478,479,480,...,566,567,568,569] + page 7: scope=controller entries=100 first=570 last=669 max=669 more=True ascending=True ids=[570,571,572,573,574,575,...,666,667,668,669] + page 8: scope=controller entries=2 first=670 last=671 max=671 more=False ascending=True ids=[670,671] + page 9: scope=Program:Recipes entries=67 first=1 last=67 max=67 more=False ascending=True ids=[1,2,3,4,5,6,...,64,65,66,67] + page 10: scope=Program:EM01_PowerUnit entries=101 first=1 last=101 max=101 more=True ascending=True ids=[1,2,3,4,5,6,...,98,99,100,101] + page 11: scope=Program:EM01_PowerUnit entries=93 first=102 last=194 max=194 more=False ascending=True ids=[102,103,104,105,106,107,...,191,192,193,194] + page 12: scope=Program:EM02_Clamp entries=102 first=1 last=102 max=102 more=True ascending=True ids=[1,2,3,4,5,6,...,99,100,101,102] + page 13: scope=Program:EM02_Clamp entries=102 first=103 last=204 max=204 more=True ascending=True ids=[103,104,105,106,107,108,...,201,202,203,204] + page 14: scope=Program:EM02_Clamp entries=78 first=205 last=289 max=289 more=False ascending=True ids=[205,206,207,208,209,210,...,281,282,288,289] + page 15: scope=Program:ES03_EM01_Zone1 entries=103 first=1 last=103 max=103 more=True ascending=True ids=[1,2,3,4,5,6,...,100,101,102,103] + page 16: scope=Program:ES03_EM01_Zone1 entries=98 first=104 last=202 max=202 more=True ascending=True ids=[104,105,106,107,108,109,...,199,200,201,202] + page 17: scope=Program:ES03_EM01_Zone1 entries=49 first=203 last=283 max=283 more=False ascending=True ids=[203,204,205,206,207,208,...,278,279,280,283] + page 18: scope=Program:ES03_TemperatureControl entries=104 first=1 last=104 max=104 more=True ascending=True ids=[1,2,3,4,5,6,...,101,102,103,104] + page 19: scope=Program:ES03_TemperatureControl entries=102 first=105 last=207 max=207 more=True ascending=True ids=[105,106,107,108,109,110,...,204,205,206,207] + page 20: scope=Program:ES03_TemperatureControl entries=102 first=208 last=309 max=309 more=True ascending=True ids=[208,209,210,211,212,213,...,306,307,308,309] + page 21: scope=Program:ES03_TemperatureControl entries=94 first=310 last=403 max=403 more=True ascending=True ids=[310,311,312,313,314,315,...,400,401,402,403] + page 22: scope=Program:ES03_TemperatureControl entries=97 first=404 last=500 max=500 more=True ascending=True ids=[404,405,406,407,408,409,...,497,498,499,500] + page 23: scope=Program:ES03_TemperatureControl entries=58 first=501 last=559 max=559 more=False ascending=True ids=[501,502,503,504,505,506,...,556,557,558,559] + page 24: scope=Program:HMI entries=9 first=1 last=9 max=9 more=False ascending=True ids=[1,2,3,4,5,6,7,8,9] + page 25: scope=Program:IO_Mapping entries=54 first=1 last=54 max=54 more=False ascending=True ids=[1,2,3,4,5,6,...,51,52,53,54] + page 26: scope=Program:SafetyProgram entries=1 first=1 last=1 max=1 more=False ascending=True ids=[1] + page 27: scope=Program:UN01_Press entries=109 first=1 last=109 max=109 more=True ascending=True ids=[1,2,3,4,5,6,...,106,107,108,109] + page 28: scope=Program:UN01_Press entries=103 first=111 last=213 max=213 more=True ascending=True ids=[111,112,113,114,115,116,...,210,211,212,213] + page 29: scope=Program:UN01_Press entries=2 first=214 last=215 max=215 more=False ascending=True ids=[214,215] + page 30: scope=Program:ES03_EM02_Zone2 entries=102 first=1 last=102 max=102 more=True ascending=True ids=[1,2,3,4,5,6,...,99,100,101,102] + page 31: scope=Program:ES03_EM02_Zone2 entries=98 first=103 last=201 max=201 more=True ascending=True ids=[103,104,105,106,107,108,...,198,199,200,201] + page 32: scope=Program:ES03_EM02_Zone2 entries=47 first=202 last=248 max=248 more=False ascending=True ids=[202,203,204,205,206,207,...,245,246,247,248] + page 33: scope=Program:ES03_EM03_Zone3 entries=103 first=1 last=103 max=103 more=True ascending=True ids=[1,2,3,4,5,6,...,100,101,102,103] + page 34: scope=Program:ES03_EM03_Zone3 entries=98 first=104 last=201 max=201 more=True ascending=True ids=[104,105,106,107,108,109,...,198,199,200,201] + page 35: scope=Program:ES03_EM03_Zone3 entries=47 first=202 last=249 max=249 more=False ascending=True ids=[202,203,204,205,206,207,...,246,247,248,249] + page 36: scope=Program:ES03_EM04_Zone4 entries=103 first=1 last=103 max=103 more=True ascending=True ids=[1,2,3,4,5,6,...,100,101,102,103] + page 37: scope=Program:ES03_EM04_Zone4 entries=98 first=104 last=201 max=201 more=True ascending=True ids=[104,105,106,107,108,109,...,198,199,200,201] + page 38: scope=Program:ES03_EM04_Zone4 entries=45 first=202 last=247 max=247 more=False ascending=True ids=[202,203,204,205,206,207,...,244,245,246,247] + page 39: scope=Program:Wonderware entries=90 first=1 last=90 max=90 more=True ascending=True ids=[1,2,3,4,5,6,...,87,88,89,90] + page 40: scope=Program:Wonderware entries=32 first=91 last=122 max=122 more=False ascending=True ids=[91,92,93,94,95,96,...,119,120,121,122] + page 41: scope=Program:Auxiliary entries=0 first=0 last=0 max=0 more=False ascending=True ids=[] + Non-ascending pages: 0 + + + +Test Run Successful. +Total tests: 1 + Passed: 1 + Total time: 5.3771 Seconds diff --git a/docs/plans/2026-10-07-native-ab-transport-t21/hw4-mix-msp.txt b/docs/plans/2026-10-07-native-ab-transport-t21/hw4-mix-msp.txt new file mode 100644 index 00000000..d2031dc6 --- /dev/null +++ b/docs/plans/2026-10-07-native-ab-transport-t21/hw4-mix-msp.txt @@ -0,0 +1,37 @@ + +Test run for /Users/dohertj2/Desktop/OtOpcUa/.worktrees/hw4-evidence/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/bin/Debug/net10.0/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.dll (.NETCoreApp,Version=v10.0) +VSTest version 18.0.1 (arm64) + +Starting test execution, please wait... +A total of 1 test files matched the specified pattern. +/Users/dohertj2/Desktop/OtOpcUa/.worktrees/hw4-evidence/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/bin/Debug/net10.0/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.dll + Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.EvidenceTests.Mixed_string_array_and_scalar_reads_travel_as_one_multiple_service_packet [18 s] + Standard Output Messages: + ## Mixed batch — 10.209.70.160:44818 path '1,0' family CompactLogix (compactlogix, connection size 4002), max tags 200, max templates 50, pace 50 ms + Tags: Program:UN01_Press.Sts_CycleTime_STR (String) ; Program:EM01_PowerUnit._SEQ_EP[1] (DInt) ; Program:EM01_PowerUnit._SEQ_EP (DInt[32]) ; test2 (DInt) ; Program:UN01_Press.xxtest (Bool) + Native Forward Opens: 1; Forward Closes sent/acked: 1/1 + Attempt 1: 5 concurrent reads -> 1 channel send(s); ONE MSP carrying all: + - request service 0x0A, 204 B -> reply 273 B, embedded=5: outer status=0x00; #0: svc=0x52 status=0x00 data=92B; #1: svc=0x52 status=0x00 data=6B; #2: svc=0x52 status=0x00 data=130B; #3: svc=0x52 status=0x00 data=6B; #4: svc=0x52 status=0x00 data=3B + Attempt 2: 5 concurrent reads -> 1 channel send(s); ONE MSP carrying all: + - request service 0x0A, 204 B -> reply 273 B, embedded=5: outer status=0x00; #0: svc=0x52 status=0x00 data=92B; #1: svc=0x52 status=0x00 data=6B; #2: svc=0x52 status=0x00 data=130B; #3: svc=0x52 status=0x00 data=6B; #4: svc=0x52 status=0x00 data=3B + Attempt 3: 5 concurrent reads -> 1 channel send(s); ONE MSP carrying all: + - request service 0x0A, 204 B -> reply 273 B, embedded=5: outer status=0x00; #0: svc=0x52 status=0x00 data=92B; #1: svc=0x52 status=0x00 data=6B; #2: svc=0x52 status=0x00 data=130B; #3: svc=0x52 status=0x00 data=6B; #4: svc=0x52 status=0x00 data=3B + Attempt 4: 5 concurrent reads -> 1 channel send(s); ONE MSP carrying all: + - request service 0x0A, 204 B -> reply 273 B, embedded=5: outer status=0x00; #0: svc=0x52 status=0x00 data=92B; #1: svc=0x52 status=0x00 data=6B; #2: svc=0x52 status=0x00 data=130B; #3: svc=0x52 status=0x00 data=6B; #4: svc=0x52 status=0x00 data=3B + Attempt 5: 5 concurrent reads -> 1 channel send(s); ONE MSP carrying all: + - request service 0x0A, 204 B -> reply 273 B, embedded=5: outer status=0x00; #0: svc=0x52 status=0x00 data=92B; #1: svc=0x52 status=0x00 data=6B; #2: svc=0x52 status=0x00 data=130B; #3: svc=0x52 status=0x00 data=6B; #4: svc=0x52 status=0x00 data=3B + | Tag | single (before) | batch 1 | batch 2 | batch 3 | batch 4 | batch 5 | single (after) | all batch values match a bracketing single | + |---|---|---|---|---|---|---|---|---| + | `Program:UN01_Press.Sts_CycleTime_STR` | '2:23:37' | '2:23:37' | '2:23:37' | '2:23:37' | '2:23:37' | '2:23:37' | '2:23:37' | yes | + | `Program:EM01_PowerUnit._SEQ_EP[1]` | 300 | 300 | 300 | 300 | 300 | 300 | 300 | yes | + | `Program:EM01_PowerUnit._SEQ_EP` | [0,300,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0] | [0,300,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0] | [0,300,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0] | [0,300,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0] | [0,300,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0] | [0,300,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0] | [0,300,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0] | yes | + | `test2` | 0 | 0 | 0 | 0 | 0 | 0 | 0 | yes | + | `Program:UN01_Press.xxtest` | False | False | False | False | False | False | False | yes | + Attempts that carried every read in ONE MSP: 5/5 + + + +Test Run Successful. +Total tests: 1 + Passed: 1 + Total time: 18.9272 Seconds diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Cip/CipConnection.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Cip/CipConnection.cs index eb0fa39b..52861634 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Cip/CipConnection.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Cip/CipConnection.cs @@ -247,6 +247,7 @@ public sealed class CipConnection : ICipChannel, IAsyncDisposable _ => await open.Client.SendRRDataAsync(cip, _o.Timeout, linked.Token).ConfigureAwait(false), }; _diagnostics.RecordExchange(Stopwatch.GetTimestamp() - started); + _diagnostics.NotifyExchange(cip, reply); if (_o.Routing == CipRouting.Connected && ConnectionManagerServices.IsConnectionGone(reply.Span) is { } extended) { await RetireClosedByTargetAsync(open).ConfigureAwait(false); diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Cip/ConnectionDiagnostics.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Cip/ConnectionDiagnostics.cs index f89cf3f3..2bc12f38 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Cip/ConnectionDiagnostics.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Cip/ConnectionDiagnostics.cs @@ -91,6 +91,21 @@ internal sealed class ConnectionDiagnostics Ms(Interlocked.Read(ref _exchangeTotalTicks)) / count); } + /// + /// Evidence tap for the gate harness (#582 / #572): sees the CIP request and the raw CIP reply of every + /// completed exchange. Null in production; set only by the read-only differential harness. A throwing + /// observer is swallowed so it can never affect a request. + /// + internal Action, ReadOnlyMemory>? ExchangeObserver { get; set; } + + internal void NotifyExchange(ReadOnlyMemory request, ReadOnlyMemory reply) + { + var observer = ExchangeObserver; + if (observer is null) return; + try { observer(request, reply); } + catch { /* evidence only */ } + } + internal void RecordExchange(long ticks) { Interlocked.Increment(ref _exchangeCount); diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/Differential/EvidenceTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/Differential/EvidenceTests.cs new file mode 100644 index 00000000..6deb8453 --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/Differential/EvidenceTests.cs @@ -0,0 +1,260 @@ +using System.Buffers.Binary; +using System.Globalization; +using System.Text; +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport; +using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Cip; + +namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential; + +/// +/// Hardware-evidence legs of Gitea #592 (hw4 lane evidence), READ-ONLY by construction and covered by the same +/// source/IL guard as the rest of this folder. Both legs are opt-in on top of AB_DIFF_ENDPOINT because each +/// is its own press session (the browse is ~3000 tags): +/// +/// AB_EVID_MIX=1 — #582: reads a STRING, an array element, a whole array and scalars concurrently +/// through the native transport and proves, from the exchange tap on the connection, that they travelled +/// as ONE Multiple Service Packet and decode to what individual reads return. Tag set: +/// AB_EVID_TAGS = name|Type|count entries separated by ; (count > 1 or +/// [] suffix on Type ⇒ array). +/// AB_EVID_BROWSE=1 — #572: one native @tags browse with every listing page's instance-id +/// sequence recorded, reporting any page that is not ascending. +/// +/// +[Collection(DifferentialCollection.Name)] +[Trait("Category", "LiveIntegration")] +public sealed class EvidenceTests +{ + private const string DefaultMix = + "Program:UN01_Press.Sts_CycleTime_STR|String|1;Program:EM01_PowerUnit._SEQ_EP[1]|DInt|1;" + + "Program:EM01_PowerUnit._SEQ_EP|DInt|32;test2|DInt|1;Program:UN01_Press.xxtest|Bool|1"; + + private sealed record ExchangeRecord(byte Service, int RequestBytes, int ReplyBytes, int Embedded, string Detail); + + [Fact] + public async Task Mixed_string_array_and_scalar_reads_travel_as_one_multiple_service_packet() + { + var target = DifferentialTarget.FromEnvironment(); + if (target is null) Assert.Skip(DifferentialTarget.SkipReason); + if (Environment.GetEnvironmentVariable("AB_EVID_MIX") != "1") Assert.Skip("AB_EVID_MIX=1 not set."); + + var specs = ParseMix(Environment.GetEnvironmentVariable("AB_EVID_TAGS") is { Length: > 0 } t ? t : DefaultMix); + using var cts = new CancellationTokenSource(TimeSpan.FromMinutes(2)); + var ct = cts.Token; + var diagnostics = DeviceSessionPool.Shared.Diagnostics; + var exchanges = new List(); + var factory = AbCipTransportFactories.For(AbCipTransport.Native).Tags; + + const int Attempts = 5; + var ((before, attempts, after), opens, closes) = + await DifferentialRun.WithNativeAnchorAsync("evidence mix", target, async checkOpens => + { + var runtimes = new List<(MixSpec Spec, IAbCipTagRuntime Rt)>(); + try + { + foreach (var s in specs) + { + await DifferentialRun.PaceAsync(target, ct); + var rt = factory.Create(target.Params(s.Name, s.Count, s.Count > 1, s.Type)); + await rt.InitializeAsync(ct); // one read each (create-read) + checkOpens($"create of '{s.Name}'"); + rt.GetStatus().ShouldBe((int)AbStatus.Ok, $"'{s.Name}' must create/read Ok"); + runtimes.Add((s, rt)); + } + + async Task> SequentialAsync() + { + var values = new List(); + foreach (var (spec, rt) in runtimes) + { + await DifferentialRun.PaceAsync(target, ct); + await rt.ReadAsync(ct); + checkOpens($"single read of '{spec.Name}'"); + values.Add(Format(spec, rt)); + await Task.Delay(1000, ct); // >= 1 s between repeated reads + } + return values; + } + + var beforeValues = await SequentialAsync(); + + // The batch: every read issued at once so the scheduler packs them. Whether the pump wakes + // before or after the last request is queued is a scheduling race, so the batch is repeated + // (>= 1 s apart) and each attempt reports how it was packed. + var done = new List<(int Requests, List Records, List Values)>(); + for (var attempt = 0; attempt < Attempts; attempt++) + { + exchanges.Clear(); + diagnostics.ExchangeObserver = (req, rep) => exchanges.Add(Describe(req, rep)); + var requestsBefore = diagnostics.RequestsSent; + await Task.WhenAll(runtimes.Select(r => r.Rt.ReadAsync(ct))); + var requests = diagnostics.RequestsSent - requestsBefore; + diagnostics.ExchangeObserver = null; + checkOpens($"batch {attempt + 1}"); + done.Add((requests, exchanges.ToList(), runtimes.Select(r => Format(r.Spec, r.Rt)).ToList())); + await Task.Delay(1000, ct); + } + + var afterValues = await SequentialAsync(); + return (beforeValues, done, afterValues); + } + finally + { + diagnostics.ExchangeObserver = null; + foreach (var (_, rt) in runtimes) rt.Dispose(); + } + }); + + Log($"## Mixed batch — {target}"); + Log($"Tags: {string.Join(" ; ", specs.Select(s => $"{s.Name} ({s.Type}{(s.Count > 1 ? $"[{s.Count}]" : "")})"))}"); + Log($"Native Forward Opens: {opens}; Forward Closes sent/acked: {closes.Sent}/{closes.Acked}"); + var oneMsp = 0; + for (var n = 0; n < attempts.Count; n++) + { + var (requests, records, values) = attempts[n]; + var single = requests == 1 && records.Count == 1 && records[0].Service == 0x0A && records[0].Embedded == specs.Count; + if (single) oneMsp++; + Log($"Attempt {n + 1}: {specs.Count} concurrent reads -> {requests} channel send(s); {(single ? "ONE MSP carrying all" : "split")}:"); + foreach (var e in records) + Log($" - request service 0x{e.Service:X2}, {e.RequestBytes} B -> reply {e.ReplyBytes} B, embedded={e.Embedded}: {e.Detail}"); + records.ShouldAllBe(e => !e.Detail.Contains("status=0x06"), "a 0x06 inside the MSP would mean a truncated reply"); + records.Sum(e => e.Embedded).ShouldBe(specs.Count); + records.ShouldAllBe(e => e.Service == 0x0A || e.Embedded == 1); + } + Log("| Tag | single (before) | " + string.Join(" | ", Enumerable.Range(1, attempts.Count).Select(i => $"batch {i}")) + " | single (after) | all batch values match a bracketing single |"); + Log("|---|---|" + string.Concat(Enumerable.Repeat("---|", attempts.Count)) + "---|---|"); + var failures = new List(); + for (var i = 0; i < specs.Count; i++) + { + var ok = attempts.All(a => a.Values[i] == before[i] || a.Values[i] == after[i]); + if (!ok) failures.Add(specs[i].Name); + Log($"| `{specs[i].Name}` | {before[i]} | {string.Join(" | ", attempts.Select(a => a.Values[i]))} | {after[i]} | {(ok ? "yes" : "**NO**")} |"); + } + Log($"Attempts that carried every read in ONE MSP: {oneMsp}/{attempts.Count}"); + + opens.ShouldBe(1); + closes.Sent.ShouldBe(1); + oneMsp.ShouldBeGreaterThan(0, "at least one attempt must put the whole mixed batch in a single Multiple Service Packet"); + failures.ShouldBeEmpty("a batch value matched neither bracketing single read"); + } + + [Fact] + public async Task Symbol_listing_pages_are_ascending_on_a_real_controller() + { + var target = DifferentialTarget.FromEnvironment(); + if (target is null) Assert.Skip(DifferentialTarget.SkipReason); + if (Environment.GetEnvironmentVariable("AB_EVID_BROWSE") != "1") Assert.Skip("AB_EVID_BROWSE=1 not set."); + + using var cts = new CancellationTokenSource(TimeSpan.FromMinutes(10)); + var ct = cts.Token; + var diagnostics = DeviceSessionPool.Shared.Diagnostics; + var pages = new List<(string Path, List Ids, bool More)>(); + var gate = new object(); + var factory = AbCipTransportFactories.For(AbCipTransport.Native).Enumerators; + + var (count, opens, closes) = await DifferentialRun.WithNativeAnchorAsync("evidence browse", target, async checkOpens => + { + diagnostics.ExchangeObserver = (req, rep) => + { + if (req.Span.Length == 0 || req.Span[0] != 0x55) return; + var page = ParseListingPage(req.Span, rep.Span); + if (page is { } p) lock (gate) pages.Add(p); + }; + try + { + var n = 0; + await DifferentialRun.PaceAsync(target, ct); + using var enumerator = factory.Create(); + await foreach (var _ in enumerator.EnumerateAsync(target.Params("@tags"), ct)) + { + n++; + if (n % 200 == 0) checkOpens($"browse entry {n}"); + } + checkOpens("browse"); + return n; + } + finally { diagnostics.ExchangeObserver = null; } + }); + + Log($"## Listing order — {target}"); + Log($"Tags browsed: {count}; listing pages observed: {pages.Count}; Forward Opens {opens}; Forward Closes {closes.Sent}/{closes.Acked}"); + var nonAscending = 0; + for (var i = 0; i < pages.Count; i++) + { + var (path, ids, more) = pages[i]; + var strictly = true; + for (var k = 1; k < ids.Count; k++) if (ids[k] <= ids[k - 1]) { strictly = false; break; } + if (!strictly) nonAscending++; + var sample = ids.Count <= 12 + ? string.Join(",", ids) + : string.Join(",", ids.Take(6)) + ",...," + string.Join(",", ids.Skip(ids.Count - 4)); + Log($"page {i + 1}: scope={path} entries={ids.Count} first={(ids.Count > 0 ? ids[0] : 0)} last={(ids.Count > 0 ? ids[^1] : 0)} " + + $"max={(ids.Count > 0 ? ids.Max() : 0)} more={more} ascending={strictly} ids=[{sample}]"); + } + Log($"Non-ascending pages: {nonAscending}"); + pages.ShouldNotBeEmpty(); + opens.ShouldBe(1); + } + + private sealed record MixSpec(string Name, AbCipDataType Type, int Count); + + private static List ParseMix(string raw) => raw.Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .Select(e => + { + var p = e.Split('|'); + return new MixSpec(p[0], Enum.Parse(p[1], ignoreCase: true), + p.Length > 2 ? int.Parse(p[2], CultureInfo.InvariantCulture) : 1); + }).ToList(); + + private static string Format(MixSpec spec, IAbCipTagRuntime rt) + { + if (spec.Count > 1) + return rt.DecodeArray(spec.Type, spec.Count) is System.Collections.IEnumerable e + ? "[" + string.Join(",", e.Cast().Select(v => Convert.ToString(v, CultureInfo.InvariantCulture))) + "]" + : "null"; + var v = rt.DecodeValue(spec.Type, null); + return v is string s ? $"'{s}'" : Convert.ToString(v, CultureInfo.InvariantCulture) ?? "null"; + } + + private static ExchangeRecord Describe(ReadOnlyMemory request, ReadOnlyMemory reply) + { + var req = request.Span; + var service = req.Length > 0 ? req[0] : (byte)0; + if (service != 0x0A) + return new ExchangeRecord(service, req.Length, reply.Length, 1, $"single, reply head {Hex(reply.Span, 4)}"); + var count = req.Length >= 8 ? BinaryPrimitives.ReadUInt16LittleEndian(req[6..]) : 0; + var outer = CipReply.Parse(reply); + var parts = new List { $"outer status=0x{outer.GeneralStatus:X2}" }; + if (outer.GeneralStatus is 0x00 or 0x1E) + { + var inner = MultipleServicePacket.Parse(outer, count); + for (var i = 0; i < inner.Count; i++) + parts.Add($"#{i}: svc=0x{inner[i].Service:X2} status=0x{inner[i].GeneralStatus:X2} data={inner[i].Data.Length}B"); + } + return new ExchangeRecord(service, req.Length, reply.Length, count, string.Join("; ", parts)); + } + + private static (string Path, List Ids, bool More)? ParseListingPage(ReadOnlySpan request, ReadOnlySpan reply) + { + var parsed = CipReply.Parse(reply.ToArray()); + if (parsed.GeneralStatus is not (0x00 or 0x06)) return null; + var pathWords = request.Length > 1 ? request[1] : 0; + var scope = request.Length >= 2 + pathWords * 2 && request.Length > 2 && request[2] == 0x91 + ? Encoding.ASCII.GetString(request.Slice(4, request[3])) : "controller"; + var d = parsed.Data.Span; + var ids = new List(); + var at = 0; + while (at + 4 + 2 + 2 + 12 + 2 <= d.Length) + { + ids.Add(BinaryPrimitives.ReadUInt32LittleEndian(d[at..])); + var nameLen = BinaryPrimitives.ReadUInt16LittleEndian(d[(at + 4 + 2 + 2 + 12)..]); + at += 4 + 2 + 2 + 12 + 2 + nameLen; + } + return (scope, ids, parsed.GeneralStatus == 0x06); + } + + private static string Hex(ReadOnlySpan b, int max) => Convert.ToHexString(b[..Math.Min(max, b.Length)]); + + private static void Log(string line) => TestContext.Current.TestOutputHelper?.WriteLine(line); +} diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.IntegrationTests/AbLegacyBitWriteTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.IntegrationTests/AbLegacyBitWriteTests.cs new file mode 100644 index 00000000..8a0b4b2c --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.IntegrationTests/AbLegacyBitWriteTests.cs @@ -0,0 +1,65 @@ +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; +using ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.PlcFamilies; + +namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.IntegrationTests; + +/// +/// Bit-within-word write (B3:0/5) against ab_server on both transports: only the addressed +/// bit may change, its neighbours must survive. Writes B3:0 (simulator-only, gated by +/// ). PLC-5 on the native transport refuses writes (#571), which the test pins instead. +/// +[Collection(AbLegacyServerCollection.Name)] +[Trait("Category", "Integration")] +[Trait("Simulator", "ab_server-PCCC")] +public sealed class AbLegacyBitWriteTests(AbLegacyServerFixture sim) +{ + /// Seeds B3:0 = 0x0A0A, sets bit 5, clears bit 3, and checks neighbours are untouched. + /// The transport stack the driver runs on. + [AbLegacyTheory] + [MemberData(nameof(TransportMatrix.Profiles), MemberType = typeof(TransportMatrix))] + public async Task Bit_write_changes_only_the_addressed_bit(AbLegacyServerProfile profile, AbLegacyTransport transport) + { + if (sim.SkipReasonFor(profile) is { } skip) Assert.Skip(skip); + IntegrationWriteGate.SkipUnlessWritesAllowed(); + + var deviceUri = $"ab://{sim.Host}:{sim.PortFor(profile)}/{sim.CipPath}"; + await using var drv = new AbLegacyDriver(new AbLegacyDriverOptions + { + Devices = [new AbLegacyDeviceOptions(deviceUri, profile.Family)], + RawTags = AbLegacyRawTags.Entries( + new AbLegacyTagDefinition("Word", deviceUri, "B3:0", AbLegacyDataType.Int, Writable: true), + new AbLegacyTagDefinition("Bit5", deviceUri, "B3:0/5", AbLegacyDataType.Bit, Writable: true), + new AbLegacyTagDefinition("Bit3", deviceUri, "B3:0/3", AbLegacyDataType.Bit, Writable: true)), + Timeout = TimeSpan.FromSeconds(5), + Probe = new AbLegacyProbeOptions { Enabled = false }, + Transport = transport, + }, driverInstanceId: $"ablegacy-bitwrite-{profile.Family}-{transport}"); + var ct = TestContext.Current.CancellationToken; + await drv.InitializeAsync("{}", ct); + + var refusedNatively = profile.Family == AbLegacyPlcFamily.Plc5 && transport == AbLegacyTransport.Native; + + // 0x0A0A: bits 1,3,9,11 set; bit 5 clear. + if (!refusedNatively) + (await drv.WriteAsync([new WriteRequest("Word", (short)0x0A0A)], ct)).Single() + .StatusCode.ShouldBe(AbLegacyStatusMapper.Good, "seeding the whole word must succeed"); + + if (refusedNatively) + { + // Native PLC-5 writes are refused before the wire (#571): BadNotSupported (0x803D0000), not an ab_server gap. + (await drv.WriteAsync([new WriteRequest("Bit5", true)], ct)).Single() + .StatusCode.ShouldBe(AbLegacyStatusMapper.BadNotSupported); + return; + } + + (await drv.WriteAsync([new WriteRequest("Bit5", true)], ct)).Single() + .StatusCode.ShouldBe(AbLegacyStatusMapper.Good, "B3:0/5 bit write must succeed"); + Convert.ToInt32((await drv.ReadAsync(["Word"], ct)).Single().Value).ShouldBe(0x0A0A | 0x20); + + (await drv.WriteAsync([new WriteRequest("Bit3", false)], ct)).Single() + .StatusCode.ShouldBe(AbLegacyStatusMapper.Good, "clearing B3:0/3 must succeed"); + Convert.ToInt32((await drv.ReadAsync(["Word"], ct)).Single().Value).ShouldBe((0x0A0A | 0x20) & ~0x08); + } +}