Compare commits

...

22 Commits

Author SHA1 Message Date
Joseph Doherty 9dbaa6d758 Merge TwinCAT fixes ported from ignitionoee + live-validated on the Elma PLCs
v2-ci / build (push) Successful in 4m41s
v2-ci / unit-tests (push) Failing after 16m28s
2026-10-07 14:28:01 -04:00
Joseph Doherty 8060cfd2a5 fix(twincat): size strings by the resolved type and refuse VAR_IN_OUT references (live TC2 gate)
Found by the read-only live gate against the TwinCAT 2.11 Elma cleaners
Z33856 / Z33733:

- The Beckhoff library reports a TC2 dotted MEMBER symbol's ByteSize as if the
  PLC's byte count were a bit count (STRING(80) -> 11, INT -> 1, UDINT -> 1)
  while DataType.ByteSize is right (81/2/4, raw SYM_INFOBYNAMEEX says 81). The
  previous commit's typeof(byte[]) read sliced by the symbol size, so
  .OPC_MACHINEDATA.PROCESSSTATUS read "ready for o" at Good; the notification
  width check would also have refused every numeric member. The shape now takes
  the resolved type's size, and string reads go by index group/offset (or a
  fresh handle for 0xF005) at that width.
- P_INITINPUT_ACTION.SLOTID is "STRING(20) (VAR_IN_OUT)" - a 4-byte reference -
  but resolves to a plain STRING(20); a String tag read 21 bytes at the pointer
  as text at Good. The symbol's own VAR_IN_OUT marker now classifies it
  NonScalar -> BadTypeMismatch.
2026-10-07 14:27:22 -04:00
Joseph Doherty 6cc40963b4 Merge AbCip fixes found against real CompactLogix presses 2026-10-07 14:17:58 -04:00
Joseph Doherty 58c61c1610 Merge FOCAS wire-decode corrections ported from ignitionoee 2026-10-07 14:17:58 -04:00
Joseph Doherty cc876bdf43 docs(abcip): record the 2026-10-07 real-controller verification and the browse/read behaviour it changed 2026-10-07 14:17:19 -04:00
Joseph Doherty e3d6b76ede fix(abcip): a scalar read whose width disagrees with its authored type is BadTypeMismatch
libplctag decodes whatever bytes the controller returns as the authored type and never
checks the CIP type code. Live read-only check on a production press: WW_ActualVacuum is a
REAL (L5X + the controller's symbol table agree) but the legacy Kepware export in the
ignitionoee repo types it Short; read as INT it returned Good -17346 while the real value
was 5.37. A scalar atomic read (not an array, not a bit-in-DINT) whose buffer size is not
the authored type's width now reads BadTypeMismatch with a warning naming both. Widths
observed live: SINT/BOOL 1, INT 2, DINT/REAL 4, LINT 8. IAbCipTagRuntime.GetSize() is a
default-interface member (-1 = unknown, never checked). Same-width mismatches (DINT vs
REAL) stay undetectable without the type code libplctag strips.
2026-10-07 14:15:19 -04:00
Joseph Doherty 2fc1b488fe docs(focas): record the wire decode corrections ported from the ignitionoee driver
FOCAS.md: positions scale from each block's own base/exponent and
PositionDecimalPlaces is only the scale-less fallback; cnc_getfigure is gone;
Spindle Load/MotorSpeedPct are Float64 percentages (MaxRpm renamed); program
fields the CNC does not report read BadNoData; the #548 Makino 'AA' duplicates
were a 2-byte name decode (now AA6/AA7). New 'Wire decode corrections (2026-10)'
table lists each before/after with client-visible changes flagged, plus what
was deliberately not ported and why. Troubleshooting rows for BadNoData and
integer-macro BadTypeMismatch. Test-fixture doc follows the mock changes.
2026-10-07 14:13:00 -04:00
Joseph Doherty 9b51dbca60 test(focas): bring the vendored focas-mock onto the real 31i-B wire shapes; integration tests follow
The mock had served the shapes the driver once agreed with it on, so it
validated the very defects just fixed: it ANSWERED the non-existent 0x00D3
cnc_getfigure, wrote base/exponent 0/0 into position blocks, put the macro
decimal at offset 4, and served spindle 'max rpm' as a raw constant.

Ported from the sister ignitionoee mock (local divergence recorded in
VENDORED.md; the LE timer and alarm cap are kept):
- 0x00D3 refused with EW_FUNC, as the real control does.
- 0x0026 blocks carry base 10 / position_figures exponent; signed arg2,
  arg2 = 0 or past the axis count refused EW_ATTRIB.
- cnc_rdmacro is the 8-byte ODBM shape, 9-digit mantissa, unset = vacant.
- servo/spindle meter records carry the envelope; spindle meters are
  1/16384-of-full-scale counts (motor speed = speed / max_rpm).

Integration tests: spindle test reads Load/MotorSpeedPct percentages; the
position-scale test now proves the block's own scale wins; new
Vacant_positions_and_macros_read_bad_over_the_wire. 12/12 pass against the
mock run locally (python -m focas_mock.cli serve, OTOPCUA_FOCAS_SIM_ENDPOINT).
2026-10-07 14:12:11 -04:00
Joseph Doherty 68cf3ae8b7 fix(abcip): stride user-string arrays by the read buffer, not the built-in STRING size
Live read-only check against a production CompactLogix press: Program:UN01_Press._ModeNames
(STR0016[32], 4 elements requested) returned an 80-byte buffer with libplctag.NET 1.5.2's
ElementSize unset. The driver fell back to the static 88-byte STRING stride, read element
1 past the buffer, and the whole read failed (ErrorOutOfBounds -> BadCommunicationError).
When the buffer divides evenly by the element count, that quotient is now the stride;
the read returns [undefined, Production, Maintenance, Manual] as the controller holds.
2026-10-07 14:11:08 -04:00
Joseph Doherty 0ca56cb1f6 fix(abcip): an unknown tag no longer throttles its whole device or reads as a comms error
Live read-only check against a production CompactLogix press: with one nonexistent tag
first in the read list, libplctag's create threw LibPlcTagException("ErrorNotFound"); the
driver counted it as a failed Forward-Open, opened the device's 05/STAB-8 connect-backoff
window, and every other cold tag on the device - all real (WW_InCycle, WW_ActualPress,
WW_WorkOrderNum) - read BadCommunicationError. In steady polling the bad tag re-opens
the window each attempt, so siblings ordered after it may never connect.

- A create that fails with a "controller answered" status (NotFound/NoMatch/BadDevice,
  the probe's existing reachable classification) records backoff SUCCESS, not failure.
  Genuine connection failures still open the window (falsification control test).
- Read exceptions map through the libplctag Status they carry: unknown tag ->
  BadNodeIdUnknown, decode past the buffer -> BadOutOfRange, timeout -> BadTimeout,
  instead of a blanket BadCommunicationError. One status-name parser
  (AbCipStatusMapper.TryGetStatus) now serves both the read path and the probe.
2026-10-07 14:09:43 -04:00
Joseph Doherty 26b512adba fix(focas): a missing O-number or refused program sub-read goes Bad, not a Good 0
WireFocasClient.GetProgramInfoAsync defaulted every field the CNC did not report:
ONumber ?? 0 when the cnc_exeprgname2 payload was too short to carry it, and
Name '' / BlockCount 0 / Mode 0 when the sub-read was refused. 0 is a genuine
O-number (a 31i reports 0 for a path-executed program -- live Z-34184), a
genuine block count, and Mode 0 is MDI, a real operating mode.

FocasProgramInfo fields are now nullable; MapProgramInfo leaves unreported
fields null and the fixed tree publishes them BadNoData while the bundle's
other fields stay Good.

Ported from the sister ignitionoee FOCAS driver (101a3b1, 2026-08-12).
2026-10-07 14:08:26 -04:00
Joseph Doherty a0a024c80b fix(abcip): accept module-defined I/O tag paths (Local:1:I.Data, ABB_OmniCore:I.X)
Live read-only check: a production CompactLogix lists the module tag ABB_OmniCore:I in
@tags and libplctag reads ABB_OmniCore:I.ConnectionFaulted without complaint, but the
driver refused the path as malformed (identifier rule had no colon) and reported the
tag BadCommunicationError. Colon-separated identifier/slot parts are now legal in the
controller-scope base segment only (never past it, never under Program: scope).
2026-10-07 14:07:49 -04:00
Joseph Doherty 75754478ac docs(twincat): TC2 leading-dot globals, port 801 / no AddRoute, string + declared-type rules, native arrays refused 2026-10-07 14:07:35 -04:00
Joseph Doherty 1dba46bd7b fix(focas): decode servo/spindle meters by their base/exponent envelope; MaxRpm -> MotorSpeedPct
Servo (0x0056) and spindle (0x0040) meter records are 8 bytes:
[i32 value][u16 base][u16 exponent], the same self-describing envelope as the
position blocks.

Servo: offset 4 was read as a decimal count. It is the BASE (a steady 10), so
every load was divided by 10^10 and published ~0 -- the sister ignitionoee panel
read servo 0% on every axis of a Makino cutting at 20,000 rpm. Loads are now
value / base^exponent, a direct percentage.

Spindle: the raw meter is a 1/16384 fraction of full scale. Settled on the real
Z-34184 by regressing the type-1 meter against cnc_rddynamic2 spindle speed over
596..20,000 rpm (rpm = 1.220749 x meter; 16384 x 1.220749 = 20000.75). So:
- Spindle/{name}/Load publishes % of rated load (Float64; was the raw Int32
  count -- a reading of 9155 meant 55.9%).
- Spindle/{name}/MaxRpm is RENAMED Spindle/{name}/MotorSpeedPct (Float64, % of
  maximum speed) and is polled with Load. The old node was actively wrong: the
  type-1 meter is the live motor speed, and it was read once at bootstrap and
  served forever as if it were a max-RPM constant. Client-visible: FIXED: tags
  on .../MaxRpm must be re-pointed.
- The spindle read no longer stops at the first zero after slot 1 (which dropped
  an idle second spindle and kept non-zero residue); every slot is returned.

A record whose envelope is undescribable (base not 2/10, exponent > 18 -- e.g.
residue past the configured axis/spindle count) publishes BadNoData, never a Good 0.

Ported from the sister ignitionoee FOCAS driver v0.17.2/v0.17.3 (Focas.Wire-019);
live captures live-spmeter-{load,maxrpm}-0x0040.bin added as fixtures.
2026-10-07 14:07:22 -04:00
Joseph Doherty 28bbb5e5e2 fix(abcip): browse program-scoped tags and surface symbol array dimensions
Live read-only check against production CompactLogix 5069-L320ERS2 presses:
- The controller @tags listing names each program (Program:UN01_Press, a system symbol)
  but none of its tags; those are listed only by Program:<name>.@tags. The enumerator read
  @tags alone, so every program-scoped tag was unbrowseable — including the PackML state
  (Program:UN01_Press.Out_eState) and all 51 alarm bits the Ignition project reads. The
  enumerator now reads each program's listing and decodes it with that program as scope;
  an unreadable program listing is skipped, not fatal.
- The Symbol Object decoder ignored the dimension bits + dim words, so every discovered
  array (DINT[32], REAL[32], STRING[50], UDT[23] on the two controllers checked) browsed as
  a scalar. IsArray/ElementCount are now populated (multi-dim flattened). A 1-D single
  element entry stays scalar: ab_server reports every scalar that way, a real controller
  sets no dimension bits on scalars.

Pinned by entries captured off the controllers (CipSymbolObjectRealControllerCaptureTests,
AbCipProgramScopeEnumerationTests).
2026-10-07 14:06:48 -04:00
Joseph Doherty 83a314e734 fix(twincat): string subscriptions, Windows-1252 STRING decode, and declared-type kind checks (ignitionoee 0.19.2-0.19.4)
Port of the ignitionoee TwinCAT string + type-reconciliation fixes onto the
Beckhoff-library client:

- STRING/WSTRING subscriptions never worked: AddDeviceNotificationExAsync was
  called with typeof(string) and no length, and the Beckhoff any-type
  marshaller throws ArgumentNullException for a string without one, so one
  string tag tore down its whole subscribe call. A string now registers the
  PLC's own byte width as raw bytes (0.19.2: STRING(n) of any n).
- Strings are read as raw bytes and decoded by TwinCATStringCodec. The
  Beckhoff marshaller already cut a read at the first NUL (0.19.3 / TwinCAT-007
  were not defects on the read path); the codec keeps that rule for both STRING
  and WSTRING and extends it to the new notification path. A STRING now decodes
  as Windows-1252 (TwinCAT-010) - or UTF-8 when the target reports UTF-8
  strings - instead of the host culture's ANSI code page the Beckhoff
  marshaller falls back to (1250 on a Polish host, 1251 Cyrillic, ...).
- TwinCATTypeGuard (TwinCAT-002/-008): a read refuses a numeric tag over a PLC
  string (and vice versa / STRING vs WSTRING / string over a container) with
  BadTypeMismatch instead of handing back a string object at Good; a
  notification - raw bytes decoded as the DECLARED type - additionally refuses
  width and float/integer mismatches (DINT over REAL reinterpreted the bits).
- A refused notification is isolated: that tag publishes BadTypeMismatch with
  one WARN, keeps a dead intent for the probe-tick retry, and the rest of the
  subscription registers.
2026-10-07 14:05:55 -04:00
Joseph Doherty 3b48e05e9a fix(abcip): decode real @udt template buffers; browse STRINGs as String, hide BOOL hosts
Live read-only check against production CompactLogix 5069-L320ERS2 rev 34.11 presses
(Z-35162 / 10.209.70.160): every @udt/<id> template the controller returned decoded to
null, so controller-discovered UDTs never fanned out. The decoder assumed a 12-byte
header led by the member count and [info][array][offset] blocks with ';'-terminated
names; libplctag actually returns a 14-byte header (u16 id, u32 def words, u32 instance
size, u16 member count, u16 handle), [info][type][offset] blocks, and NUL-terminated
names after a 'Name;<tail>' type name. All three test builders restated the old
assumption, so the unit suite agreed with itself (the #468 trap again); they now share a
TemplateBufferBuilder pinned by real captures (CipTemplateObjectWireCaptureTests).

Discovery follow-ons that the working decoder exposed:
- STRING (and user string types, LEN DINT + DATA SINT[n]) browse as one String variable,
  top-level and nested, not as a LEN/DATA folder.
- Controller-generated members (ZZZZZZZZZZ* BOOL hosts, __* AOI backing) are not browsed.
- A UDT array is not fanned out into element-free Tag.Member paths.
2026-10-07 14:04:11 -04:00
Joseph Doherty 69e0a622f3 fix(focas): scale positions from each cnc_rddynamic2 block; delete the fabricated cnc_getfigure
Every 0x0026 position block is self-describing: [i32 raw][u16 base][u16 exponent],
value = raw / base^exponent. The live 31i-B capture (ref-dynamic2-axis1.bin)
reports base 10 / exponent 4 on every block, so axis 1 is 280.1574 mm, not
2801574. The driver ignored the envelope and instead sent 'cnc_getfigure'
(0x00D3) -- a command that does not exist in the FOCAS wire protocol, served only
by the in-tree mock; the real control refuses it with rc=1, so on hardware every
position fell back to PositionDecimalPlaces (default 0) and published raw counts.

- ParsePositionBlock decodes the envelope into FocasPosition; the driver applies
  raw / base^exponent at publish. PositionDecimalPlaces is now only the fallback
  for a scale-less (4-byte) block.
- A block describing no position (0xFFFF vacant marker, base not 2/10, exponent
  > 18 -- raw / 10^400 would read as a Good 0.0) publishes BadNoData on that node
  alone; siblings are untouched.
- ReadPositionFiguresAsync / IFocasClient.GetPositionFiguresAsync and the
  per-axis figure cache are deleted.

Ported from the sister ignitionoee FOCAS driver v0.17.0 (+ Focas.Wire-014);
live axis-scoped captures live-dynamic2-axis{1,2}-0x0026.bin (Z-34184 X 0.0374,
Y 370.177) added as fixtures.
2026-10-07 14:03:08 -04:00
Joseph Doherty 1a74c3c2e0 fix(twincat): map TwinCAT 2 ADS base-type names in symbol browse
A TC2 PLC reports top-level integer globals as INT16/UINT16 (354 symbols on
the Elma cleaner Z33856) and the Beckhoff type system keeps that spelling as
a built-in primitive, so MapSymbolTypeName returned null and discovery dropped
them silently. INT16/UINT16/INT32/UINT32/UINT8/FLOAT/DOUBLE now map like their
IEC twins; padded member names ("REAL ") are trimmed; a parenthesised
non-string form ("INT (VAR_IN_OUT)", a reference) stays unsupported.
2026-10-07 13:59:40 -04:00
Joseph Doherty 9460da513c fix(focas): decode all four bytes of an axis/spindle name record, up to the first NUL
ReadNameRecord read only the first 2 bytes of each 4-byte 0x0089/0x008A record,
so the real 31i-B's paired axes 'AA6'/'AA7' (live capture ref-axisname-0x0089.bin)
both decoded as 'AA' -- the 'Makino with two AA axes' that #548 then had to
disambiguate as AA_1/AA_2. The name is now the record's ASCII up to its FIRST
NUL (not trailing-NUL trimming), so buffer residue such as Z-34184's spindle
record 53 31 00 4e ('S1' + an 'N' left from an earlier 'CNC') is discarded.

Client-visible: on such a machine Axes/AA_1 and Axes/AA_2 become Axes/AA6 and
Axes/AA7 (the old names were a decode artefact). #548's disambiguation stays as
the guard for genuinely duplicate names.

Ported from the sister ignitionoee FOCAS driver (Focas.Wire-001, Focas.Wire-022
/ v0.18.1).
2026-10-07 13:58:40 -04:00
Joseph Doherty b7b754fd98 fix(twincat): accept TwinCAT 2 leading-dot globals in TwinCATSymbolPath (ignitionoee 0.19.1)
A TC2 global is named with ONE leading dot (.OPC_HEARTBEAT) and the PLC
requires it (SYM_HNDBYNAME answers 0x710 without it). TryParse rejected the
dot, so the driver fell back to the raw string and lost bit-index parsing:
.X.WORD.3 went to ADS verbatim instead of reading .X.WORD and extracting bit 3.
The dot now stays on the root segment and round-trips through ToAdsSymbolName;
.., ., .[1] and .9x stay invalid.
2026-10-07 13:58:40 -04:00
Joseph Doherty 665ecd55c2 fix(focas): decode cnc_rdmacro dec_val at offset 6; vacant/fractional macros go Bad
The ODBM wire payload is [i32 mantissa][i16 length, a constant 10][i16 dec_val].
ReadMacroAsync read the decimal at offset 4, so every macro decoded without
failing but wrongly: the captured #500 = 1.0 (05 f5 e1 00 00 0a 00 08) published
0.01. FANUC normalises macros to a 9-significant-digit mantissa, so the error
was never small.

- dec_val is read at [6..7]; a payload < 8 bytes is refused, not guessed at.
- A vacant variable (dec_val 0xFFFF) publishes BadNoData, never a Good 0.
- Integer-typed tags get the exact whole value (integer arithmetic); a
  fractional macro on an integer tag is BadTypeMismatch, a whole value that
  does not fit the type BadOutOfRange. Float tags get mantissa / 10^dec.

Ported from the sister ignitionoee FOCAS driver v0.15.0 (Focas.Wire-013,
Focas-017/018); live vectors from Makino Z-34184 (31i-B, G431).
Adds a WireFixtures helper that loads the raw 31i-B captures in Fixtures/v3.
2026-10-07 13:57:11 -04:00
77 changed files with 3856 additions and 868 deletions
+1 -1
View File
@@ -124,7 +124,7 @@ UDTs / function-block instances don't appear.
| Flag | Default | Purpose |
|---|---|---|
| `--prefix` | _(none)_ | Case-sensitive instance-path prefix filter (e.g. `GVL_Fixture`) |
| `--prefix` | _(none)_ | Case-sensitive instance-path prefix filter (e.g. `GVL_Fixture`). A TwinCAT 2 global keeps its leading dot, so filter with it (`.OPC_`) |
| `--max` | `500` | Max symbols to print. `0` = unbounded |
```powershell
+31 -8
View File
@@ -37,7 +37,7 @@ AB Legacy surface.
| Capability | Implementation entry point | Notes |
|------------|---------------------------|-------|
| `ITagDiscovery` | `DiscoverAsync` | Emits pre-declared tags under per-device folders; UDT tags with declared `Members` fan out into a sub-folder + one variable per member. With `EnableControllerBrowse` the `@tags` symbol table is walked into a `Discovered/` folder (system/module/routine tags filtered out). |
| `ITagDiscovery` | `DiscoverAsync` | Emits pre-declared tags under per-device folders; UDT tags with declared `Members` fan out into a sub-folder + one variable per member. With `EnableControllerBrowse` the `@tags` symbol table — plus each `Program:<name>.@tags` listing — is walked into a `Discovered/` folder (system/module/routine tags filtered out). |
| `IReadable` | `ReadAsync` → `ReadGroupAsync` / `ReadSingleAsync` | Per-tag reads; opt-in whole-UDT grouping (`EnableDeclarationOnlyUdtGrouping`) collapses N member reads into one. |
| `IWritable` | `WriteAsync` | BOOL-within-DINT writes do a per-parent read-modify-write under a lock; `SafetyTag` and non-writable tags return `BadNotWritable`. |
| `ISubscribable` | `SubscribeAsync` driven by the shared `PollGroupEngine` | CIP has no push model — subscriptions become polling groups. |
@@ -142,13 +142,32 @@ Variables — in addition to the UDT container tag itself.
Nested struct leaves that resolve to no emittable atomic leaf produce no sub-folder (lazy
materialisation).
**Live verification is Emulate-tier only** (`AB_SERVER_PROFILE=emulate` + a Logix Emulate
instance): `ab_server` (the default Docker simulator) does not implement the CIP Template
Object service (class 0x6C), so the nested-id fetch cannot be exercised against it.
See `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/Emulate/AbCipEmulateNestedUdtTests.cs`
for the skip-gated live-gate smoke (backlog #6). Offline unit coverage lives in
`AbCipDriverDiscoveryTests.Controller_discovered_UDT_nested_struct_expands_via_nested_template_id_fetch_no_seam`
and `CipTemplateObjectDecoderTests`.
`ab_server` (the default Docker simulator) does not implement the CIP Template Object service
(class 0x6C), so the fixture cannot exercise it; see
`tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/Emulate/AbCipEmulateNestedUdtTests.cs`
for the skip-gated Emulate smoke (backlog #6).
- **STRING tags and members** — a controller reports every STRING (built-in, or a user string
type such as `STR0016`) as a `LEN` DINT + `DATA` SINT[n] struct. Discovery recognises that
shape and emits **one String variable**, top-level or nested, instead of a LEN/DATA folder.
- **Hidden members are skipped** — the `ZZZZZZZZZZ…` hosts Logix packs BOOL members into and
`__…` AOI backing members are not browsed (a user name can never start with `__`).
- **Arrays** — the Symbol Object's dimension bits populate `IsArray`/`ElementCount` (multi-dim
flattened). An array of a UDT is emitted as one variable, not fanned out (`Tag.Member` names no
element); an array of STRING is a String array.
- **Program scope** — `@tags` lists each program (`Program:Main`) but none of its tags; the
enumerator reads every `Program:<name>.@tags` listing too, so program-scoped tags appear as
`Program:<name>.<tag>`. One unreadable program listing is skipped, not fatal.
### Real-controller verification (2026-10-07)
Run read-only against production CompactLogix 5069-L320ERS2 rev 34.11 presses (and a second
CompactLogix) from the Mac over VPN. Before the fixes above, every real `@udt/<id>` buffer
decoded to `null` (the decoder assumed a layout no controller sends — the test builders shared
the assumption, the #468 trap), program-scoped tags were absent, and arrays browsed as scalars.
After them, one browse of a press took ~30 s and produced 23,412 variables covering all 80
tags the Ignition project reads on it, with matching types. The template and symbol layouts are
pinned by bytes captured off those controllers: `CipTemplateObjectWireCaptureTests`,
`CipSymbolObjectRealControllerCaptureTests`, `AbCipProgramScopeEnumerationTests`.
### Bare-container reads
@@ -170,4 +189,8 @@ UDT-typed tags found in the walk.
- **Native heap is invisible to the GC.** libplctag's native `Tag` heap does not show up in CLR allocation figures. Watch whole-process RSS, and use `ReinitializeAsync` (tears down + re-creates every device's libplctag handles) as the remediation for native-heap growth. (There is no per-driver footprint number to consult: `IDriver.GetMemoryFootprint()` was deleted as unconsumed in Gitea #525.)
- **Handle eviction on failure** — a non-zero libplctag status or a transport exception evicts the cached tag runtime so the next read/write re-creates a fresh handle, mirroring the probe loop's recreate-on-failure behaviour.
- **Read statuses carry the libplctag cause** — a tag the controller does not have reads `BadNodeIdUnknown` (not `BadCommunicationError`), and it does **not** open the device's connect-backoff window (the controller answered; before this, one typo throttled every sibling tag on the device). A genuine connection failure still opens it.
- **Width check on scalar reads** — libplctag never checks the CIP type the controller returns. A scalar atomic read whose buffer is not the authored type's width (e.g. a REAL authored as INT — the legacy Kepware export does exactly this for some press tags) reads `BadTypeMismatch` instead of Good garbage. Same-width mistakes (DINT vs REAL) are still undetectable.
- **Module-defined I/O tags** — `Local:1:I.Data`, `ABB_OmniCore:I.ConnectionFaulted` and similar colon-bearing base names are valid tag paths (controller scope only).
- **User string arrays** — `STR0016[N]` and similar arrays stride by the actual element size (read buffer ÷ count); libplctag.NET 1.5.2 leaves `ElementSize` unset after a read.
- **Declaration-only UDT grouping is a footgun unless verified** — only enable `EnableDeclarationOnlyUdtGrouping` when every UDT's member declaration order has been hand-verified against the controller's compiled layout.
+9 -4
View File
@@ -61,16 +61,21 @@ binary PDUs against the documented command IDs. Covered assertions:
- Session open / close (`cnc_allclibhndl3` + `cnc_freelibhndl`)
- Parameter read-back after `mock_patch` seed → `cnc_rdparam`
- Macro read-back after seed → `cnc_rdmacro` (scaled-decimal
translation verified)
- Macro read-back after seed → `cnc_rdmacro` (8-byte ODBM shape:
9-digit mantissa, `dec_val` at offset 6; vacant / fractional-on-integer
macros read Bad)
- PMC range read after seed → `pmc_rdpmcrng`
- `IAlarmSource` raise + clear transitions after `mock_patch`
alarm-list changes → `cnc_rdalmmsg2`
- Fixed-tree bootstrap: identity / axes / spindle / program / timers /
servo meters populate via `cnc_sysinfo`, `cnc_rdaxisname`,
`cnc_rdspdlname`, `cnc_rddynamic2`, `cnc_exeprgname2`,
`cnc_rdblkcount`, `cnc_rdopmode`, `cnc_rdsvmeter`, `cnc_rdspload`,
`cnc_rdspmaxrpm`, `cnc_rdtimer`
`cnc_rdblkcount`, `cnc_rdopmode`, `cnc_rdsvmeter`, `cnc_rdspmeter`
(load + motor speed, 1/16384 of full scale), `cnc_rdtimer`; positions
scale from each `cnc_rddynamic2` block's own base/exponent (the mock
refuses the non-existent `cnc_getfigure` 0x00D3, as a real 31i-B does).
The vendored mock's local divergence from upstream is listed in
`Docker/focas-mock/VENDORED.md`.
- Per-series profile selection via `mock_load_profile` — tests can
pin one profile and assert series-gated capability suppression
+55 -21
View File
@@ -31,8 +31,10 @@ protocol using the documented command IDs. Writes return
> 31i-B (`10.201.31.5`) 2026-06-25:** sysinfo, axis/spindle names, dynamic positions/feed/spindle,
> program/mode, **macros**, **timers** (8-byte payload is little-endian {minute, msec}), **PMC range**
> (request widened to the data-type byte width), **servo meters** (8-byte LOADELM stride + names from
> the 0x0089 block; load *scaling* still to be confirmed at commissioning), and **alarms** all read
> correctly. The driver `cnc_rddynamic2` poll is 1-based. **`cnc_rdparam` is unsupported on this
> the 0x0089 block), and **alarms** all read correctly. Several *decode* errors that survived that
> validation (because they decode without failing — just wrongly) were later corrected from the
> sister ignitionoee driver's live work on the same family of controls; see
> [Wire decode corrections (2026-10)](#wire-decode-corrections-2026-10). The driver `cnc_rddynamic2` poll is 1-based. **`cnc_rdparam` is unsupported on this
> control** — every request-framing variant returns `EW_FUNC` (likely a wrong v3 command id; needs a
> reference FWLIB trace). The Test-Connect probe now runs a real wire session (initiate + cnc_statinfo)
> instead of a bare TCP check. Full finding, captured wire bytes, validation results, and analysis:
@@ -187,11 +189,14 @@ fixed-tree paths are discovered from the machine, not composed from parts.
Two caveats worth knowing before you browse:
- **A real CNC can report duplicate sibling names, and they are disambiguated
automatically** (#548). One observed Makino returns two axes both called `AA`;
every member of such a collision set gets a 1-based occurrence suffix in slot
order — the tree offers `Axes/AA_1/` and `Axes/AA_2/`, each reading its own
axis — while unique names stay bare (`Axes/Y/`). The bare name of a duplicate
- **A CNC that reports duplicate sibling names is disambiguated automatically**
(#548): every member of a collision set gets a 1-based occurrence suffix in slot
order (`Axes/AA_1/`, `Axes/AA_2/`), each reading its own axis, while unique names
stay bare (`Axes/Y/`). ⚠️ The Makino that motivated #548 does **not** in fact
report duplicates — its axes are `AA6` and `AA7`, and the wire layer used to read
only 2 of each 4-byte name record. Since the name-record fix those axes publish as
`Axes/AA6/` and `Axes/AA7/`; tags committed against `Axes/AA_1/…` / `Axes/AA_2/…`
on such a machine must be re-browsed and re-committed. The bare name of a duplicate
set deliberately does not resolve: a tag authored against `Axes/AA/…` on such
a machine reads `BadNodeIdUnknown` rather than one arbitrary axis's data. The
suffix counts occurrences of the *same* name, so adding an unrelated axis
@@ -212,8 +217,8 @@ What gets populated (all under `FOCAS/{deviceHostAddress}/`, addressed as
| `Identity/` | `SeriesNumber`, `Version`, `MaxAxes`, `CncType`, `MtType`, `AxisCount` | `cnc_sysinfo` once at bootstrap |
| `Axes/{name}/` | `AbsolutePosition`, `MachinePosition`, `RelativePosition`, `DistanceToGo`, `ServoLoad` — one folder per discovered axis | `cnc_rdaxisname` once + `cnc_rddynamic2` + `cnc_rdsvmeter` per tick |
| `Axes/FeedRate/Actual`, `Axes/SpindleSpeed/Actual` | Current feed + spindle RPM | `cnc_rddynamic2` |
| `Spindle/{name}/` | `Load` (percentage), `MaxRpm` — one folder per discovered spindle | `cnc_rdspdlname` once + `cnc_rdspload` + `cnc_rdspmaxrpm` |
| `Program/` | `Name` (filename), `ONumber`, `Number`, `MainNumber`, `Sequence`, `BlockCount` | `cnc_exeprgname2` + `cnc_rdblkcount` + cached `cnc_rddynamic2` |
| `Spindle/{name}/` | `Load` (Float64, % of rated load), `MotorSpeedPct` (Float64, % of maximum speed) — one folder per discovered spindle | `cnc_rdspdlname` once + `cnc_rdspmeter` type 0 + type 1 per tick |
| `Program/` | `Name` (filename), `ONumber`, `Number`, `MainNumber`, `Sequence`, `BlockCount` — a field the CNC did not report (refused sub-read, payload without an O-number) reads `BadNoData`, never `0` | `cnc_exeprgname2` + `cnc_rdblkcount` + cached `cnc_rddynamic2` |
| `OperationMode/` | `Mode` (int), `ModeText` ("AUTO", "MDI", "EDIT", …) | `cnc_rdopmode` |
| `Timers/` | `PowerOnSeconds`, `OperatingSeconds`, `CuttingSeconds`, `CycleSeconds` | `cnc_rdtimer` × 4 |
@@ -228,27 +233,29 @@ per-axis `ServoLoad` independently. Identity + `Axes/*` position reads
(which every Fanuc CNC supports) are always emitted.
Position values (`AbsolutePosition`, `MachinePosition`, `RelativePosition`, `DistanceToGo`)
are CNC-internal scaled integers exposed as `Float64` OPC UA nodes. The driver converts them
to engineering units using the per-device `PositionDecimalPlaces` config field (default `0`
= no scaling). When set to a positive integer *d*, each position value is divided by `10^d`
before publishing, so a CNC that reports millimetres × 1000 is corrected by setting
`PositionDecimalPlaces: 3`.
are published as `Float64` in engineering units. **Each `cnc_rddynamic2` position block carries
its own scale** — `[i32 raw][u16 base][u16 exponent]`, value = `raw / base^exponent` (a real
31i-B reports base 10 / exponent 4: `2801574` → `280.1574` mm) — and the driver always applies
it. A block that describes no usable position (the `0xFFFF` vacant-slot marker, a base other
than 2 or 10, an exponent > 18) publishes `BadNoData` on that node alone.
`PositionDecimalPlaces` (per device, default `0`) is now only the **fallback for a block that
carries no scale** (a 4-byte payload, e.g. an older simulator): such a position is divided by
`10^PositionDecimalPlaces`. Negative values are clamped to `0`.
```jsonc
"Devices": [
{
"HostAddress": "focas://10.20.30.40:8193",
"Series": "ThirtyOne_i",
"PositionDecimalPlaces": 3 // 123456 → 123.456 mm
"PositionDecimalPlaces": 3 // only used if the CNC's blocks carry no scale
}
]
```
The driver now attempts to auto-scale each axis position using the CNC's `cnc_getfigure` figure when available; `PositionDecimalPlaces` is the **per-axis fallback** used only when the CNC reports no figure for that axis. **Caveat:** the managed `WireFocasClient` (FOCAS/2 Ethernet wire protocol) does not issue `cnc_getfigure` today, so on real hardware all axes still fall back to the manual `PositionDecimalPlaces` config field. Live auto-fetch is a follow-up requiring a `cnc_getfigure` command in `FocasWireClient`. Negative values from `PositionDecimalPlaces` are clamped to `0`.
**Follow-ups (known gaps, non-blocking):**
- No upper-bound clamp on the figure magnitude returned by the CNC — a misbehaving figure could overflow `Math.Pow`; a max-figure guard should be added.
- The negative-figure branch (falls back to manual `PositionDecimalPlaces`) is handled but not explicitly unit-tested.
There is no `cnc_getfigure` read: the `0x00D3` command the driver used to send is not a FOCAS
wire command (a real 31i-B refuses it with `rc=1`), so on hardware every axis had silently
fallen back to `PositionDecimalPlaces` and published raw counts.
**Still hand-authored**: `PARAM:6711`, `MACRO:500`, `R100` etc. — specific
numbers whose meaning is MTB-specific, and which no browse can discover. Those
@@ -330,7 +337,34 @@ latency spike once per cadence.
| Every read returns `BadNotWritable` on writes | Expected — OtOpcUa is read-only against FOCAS | If you actually need writes, open a feature request — the driver's managed wire client doesn't expose the write commands |
| `BadOutOfRange` on reads for a macro/parameter | Config address outside the declared `Series` range | Check `docs/v2/focas-version-matrix.md` — either fix the address or widen the `Series` |
| Alarm events never fire | `AlarmProjection.Enabled` left at default (false) | Set it to `true` in the driver config |
| Axis position values seem 1000× too large | `PositionDecimalPlaces` not set | Add `"PositionDecimalPlaces": 3` (or the MTB-specific value) to the device entry in `Devices[]` |
| Axis position values seem 1000× too large | The CNC's position blocks carry no scale (older control / simulator) and `PositionDecimalPlaces` is not set | Add `"PositionDecimalPlaces": 3` (or the MTB-specific value) to the device entry in `Devices[]`. A real 30i/31i carries the scale in each block and needs no setting |
| A fixed-tree node or `MACRO:` tag reads `BadNoData` | The CNC answered but described no value — a vacant macro variable, an undescribable position/meter record, a program field it did not report | Check the address on the control; this is the control's answer, not a comms fault |
| An integer-typed `MACRO:` tag reads `BadTypeMismatch` | The macro holds a fractional value (e.g. `148.5`) | Author the tag as `Float64`, or point it at a whole-valued variable — a fraction is never truncated into an integer tag |
## Wire decode corrections (2026-10)
Each of these decoded without ever failing — just wrongly — so live validation that only checked
"the read succeeds" passed them. All were found by the sister **ignitionoee** FOCAS driver
against a real Makino 31i-B (Z-34184, series G431) and ported here with its captured payloads as
test fixtures (`tests/.../Driver.FOCAS.Tests/Fixtures/v3/`). Client-visible changes are marked ⚠️.
| What | Before | Now |
|------|--------|-----|
| `cnc_rdmacro` decimal | read at payload offset 4 — a constant `10` length field — so `#500 = 1.0` published `0.01` | `dec_val` at offset 6; `mantissa / 10^dec`. Vacant variable → `BadNoData`; fractional value on an integer tag → `BadTypeMismatch`; integer tags receive the exact whole value |
| Axis positions | scaled by `cnc_getfigure` (0x00D3), which does not exist — on hardware: raw counts | scaled by each block's own `base^exponent`; undescribable block → `BadNoData` |
| Axis / spindle names | first 2 of 4 record bytes — `AA6`/`AA7` collapsed to `AA` | whole record up to the first NUL (residue after it discarded) — ⚠️ `Axes/AA_1`→`Axes/AA6` on such a machine |
| `ServoLoad` | `value / 10^base` (offset 4 is the base, 10) — always ~0 | `value / base^exponent`, a direct percentage |
| `Spindle/{n}/Load` | raw meter count, Int32 (`9155`) | ⚠️ Float64 % of rated load (`55.9`): the meter is a 1/16384 fraction of full scale |
| `Spindle/{n}/MaxRpm` | the type-1 meter read once at bootstrap and served as a "max RPM" | ⚠️ **renamed `MotorSpeedPct`**, Float64 % of maximum speed, polled every tick |
| Program `ONumber` / `Name` / `BlockCount` / `Mode` | defaulted to `0` / `""` when not reported (Mode `0` = MDI) | `BadNoData` |
Not ported (and why): the ignitionoee `RunState` run-word decode and fault debounce (this driver
publishes no run-state — `cnc_statinfo` is used only as the connectivity probe); `cnc_rdparam3`
(0x008D) and its echo guard (this driver never sends 0x008D — the real 31i-B answers it with
264-byte records that are not a parameter read); the IODBPSD-offset and Fanuc-real `FLOAT`
re-interpretation of `cnc_rdparam` (no successful `cnc_rdparam` capture exists on either side —
the reference 31i-B refuses it with `EW_FUNC`, which already reads `BadNotSupported` here); and
the all-axes multi-block `cnc_rddynamic2` batching (a latency optimisation, not a correctness fix).
## Further reading
+91 -3
View File
@@ -51,7 +51,13 @@ an AMS Net ID + port.
is six dot-separated octets (NOT an IP — a Beckhoff-specific identifier the
router maps to a route); the port is the AMS service port (851 = TC3 PLC runtime
1, 852 = runtime 2, 801 / 811 / 821 = TC2 PLC runtimes). Port defaults to 851
when omitted (`ads://5.23.91.23.1.1`).
when omitted (`ads://5.23.91.23.1.1`) — **a TwinCAT 2 target must name `:801`**.
The driver never registers a route on the PLC: it sends no UDP `AddRoute` and
writes nothing to the PLC's route table. The route from the PLC back to this
host's AMS Net ID (and the AMS router's route to the PLC) is provisioned
out-of-band — the same posture as the ignitionoee driver's
`Register Route on Connect = off` (TwinCAT-009).
### Symbol path form
@@ -62,6 +68,15 @@ mirrors IEC 61131-3 structured-text identifiers: global-variable-list
(`Motor1.Status.Running`), array subscripts (`Data[5]`, `Matrix[1,2]`), and
bit-access (`Flags.0`).
**TwinCAT 2 globals keep their leading dot.** A TC2 global is named with ONE
leading dot (`.OPC_HEARTBEAT`, `.OPC_MACHINEDATA.PROCESSSTATUS`,
`.ARRCARRIERDATA_V[1].SWORKORDER`) and the PLC requires it — without it
`SYM_HNDBYNAME` answers 0x710 (symbol not found), observed live on a TC 2.11 CX.
The parser keeps the dot on the root segment, so bit access on a TC2 global
(`.OPC_STATUS.WORD.3`) still reads the parent word `.OPC_STATUS.WORD`. Exactly one
dot: `..x`, `.` and `.[1]` are rejected. (Before this, the dot made the parser
give up and the raw string went to ADS verbatim, losing bit-index handling.)
**BOOL-within-word writes** — writing a bit-access path (e.g. `MAIN.Flags.3`)
is a driver-level parent-word read-modify-write: the driver reads the parent
word as `UDInt`, flips the target bit, and writes the word back, under a
@@ -79,6 +94,74 @@ globals / program locals under a `Discovered/` sub-folder; any symbol-loader
error falls back to pre-declared-only so a flaky symbol download never blocks
discovery.
Discovered variables are named by their full instance path (flat, no dot
splitting), so a TC2 global appears as `.OPC_HEARTBEAT` — with its dot, never
as an empty-named folder. TwinCAT 2 reports many top-level integers by their
ADS base-type name (`INT16`, `UINT16` — 354 symbols on a real TC 2.11 Elma
cleaner) and pads some member type names (`"REAL "`); both map like their IEC
twins, so those symbols are no longer silently dropped. The CLI `browse
--prefix` filter is a case-sensitive `StartsWith` on that name, so filter TC2
globals with the dot (`--prefix .OPC_`; `OPC_` matches nothing).
## Strings and declared-type checking
Ported from the ignitionoee TwinCAT driver (0.19.2–0.19.4), where each was
found against a real TwinCAT 2 PLC:
- **A `String` / `WString` tag matches a PLC `STRING(n)` / `WSTRING(n)` of any
`n`** and is read at the PLC's own width — `STRING(n)` is n+1 bytes,
`WSTRING(n)` 2(n+1). This also applies to native notifications, which
previously could not register a string at all (the Beckhoff marshaller needs
an explicit length and threw), taking the rest of that subscribe call down
with it.
- **A string ends at its first NUL** (`0x00` for STRING, an aligned `U+0000`
for WSTRING). Bytes after it are stale content from a longer earlier value
(live: work order `67504440` read back with a stale `"\0" + "1"` tail).
- **A STRING decodes as Windows-1252** (umlauts, `°` in alarm texts), or UTF-8
when the target reports UTF-8 string data (TwinCAT 3.1 4026+ "UTF8 Encoding
for STRING"). It no longer depends on the server's culture — the Beckhoff
marshaller falls back to the host's ANSI code page, which is 1250 on a Polish
host. Writes still go through the Beckhoff marshaller.
- **String-ness is a kind.** A numeric tag over a PLC string, a string tag over
a numeric, `String` over a `WSTRING` (or vice versa), and a string over a
struct/array are refused with `BadTypeMismatch` and a WARN, instead of
publishing the PLC's bytes as the wrong type at Good.
- **Native notifications are additionally width- and float-checked.** A
notification delivers raw bytes decoded as the *declared* type, so `DInt`
over a PLC `REAL` (same width, bit pattern reinterpreted) or `DInt` over
`LINT` (truncated) is refused too. A same-width, same-kind divergence (`DInt`
vs `UDINT`, `Time` vs a 4-byte integer) is tolerated. A refused tag is
isolated: it publishes `BadTypeMismatch`, the rest of the subscription
registers, and the probe-tick retry re-checks it after a PLC program change.
Reads go through the Beckhoff symbol cache, which converts numeric values by
the PLC's own type, so only the string-kind rule applies there.
- **Sizes come from the resolved data type, not the symbol.** On a TwinCAT 2
target the Beckhoff library reports a dotted *member* symbol's `ByteSize` as if
the PLC's byte count were a bit count (`.OPC_MACHINEDATA.PROCESSSTATUS`
`STRING(80)` → 11 B, an `INT` member → 1, a `UDINT` → 1) while its resolved
`DataType.ByteSize` is correct (81 / 2 / 4, matching raw `SYM_INFOBYNAMEEX`).
String reads therefore address the symbol's index group/offset explicitly at
the type's width — sizing them by the symbol truncated `"ready for operation"`
to `"ready for o"` at Good.
- **A `VAR_IN_OUT` member is a reference, not a value.** TC2 names it
`STRING(20) (VAR_IN_OUT)` (4 bytes) but the library resolves its type to the
plain `STRING(20)`; the symbol's own name wins and such a tag is refused
(`BadTypeMismatch`) instead of publishing the memory at the pointer as text.
### Live validation (2026-10-07, TwinCAT 2.11 Elma cleaners)
Read-only, against Z33856 (`10.201.45.236.1.1:801`) and Z33733
(`5.25.53.46.1.1:801`) over the VPN, through an in-process
`Beckhoff.TwinCAT.Ads.TcpRouter` in a scratch harness: ReadState RUN; browse
discovered 45,083 / 42,666 variables incl. the `INT16`/`UINT16` globals
(`.ACTUALOPERATIONMODE`, `.ADSSTATE_RUN`) and 3,663 / 3,564 leading-dot names, no
empty names; `.ADSSTATE_RUN` = 5 with bits `.0`/`.1`/`.2` = true/false/true;
work-order and part-number strings whose PLC buffers carry a stale tail after
the NUL (`68261005\0` + `01`) read as `68261005` through both poll and native
notifications; `DInt` over a `STRING(80)` → `BadTypeMismatch` in both paths with
the rest of the subscription Good. No PLC value held a genuine non-ASCII string
to exercise Windows-1252 live — that rule is unit-tested only.
## Capability surface
`TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery, ISubscribable, IHostConnectivityProbe, IPerCallHostResolver, IRediscoverable`
@@ -145,8 +228,13 @@ A TwinCAT tag becomes a **1-D OPC UA array node** when its `TagConfig` JSON carr
**Read mechanism** — the driver performs an **ADS native array symbol read** against the
declared `SymbolPath`. ADS returns the full array value buffer for a symbol that maps to a
TwinCAT array type (e.g. `ARRAY [0..9] OF REAL`); the driver reads `N` elements from the
response. When `UseNativeNotifications` is enabled, ADS also pushes array-value changes in
a single notification payload — no per-element polling.
response.
⚠️ **Native notifications do not carry arrays.** The notification path registers the
declared *scalar* type, so before the declared-type check an array tag subscribed natively
published only its first element at Good. It is now refused with `BadTypeMismatch` (the PLC
symbol is an array, not a scalar). Subscribe array tags with `UseNativeNotifications=false`
(the poll engine uses the array read above) until native array notifications are added.
**Unit test coverage** — `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/` covers
the array symbol read path via the fake ADS client factory. The TwinCAT integration
@@ -14,7 +14,8 @@ public sealed class ReadCommand : TwinCATTagCommandBase
/// <summary>Gets or sets the TwinCAT symbol path to read.</summary>
[CommandOption("symbol", 's', Description =
"Symbol path. Program scope: 'MAIN.bStart'. Global: 'GVL.Counter'. " +
"Nested UDT member: 'Motor1.Status.Running'. Array element: 'Recipe[3]'.",
"Nested UDT member: 'Motor1.Status.Running'. Array element: 'Recipe[3]'. " +
"TwinCAT 2 global (keep its leading dot): '.OPC_HEARTBEAT'.",
IsRequired = true)]
public string SymbolPath { get; init; } = default!;
@@ -1,3 +1,4 @@
using libplctag;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using ZB.MOM.WW.OtOpcUa.Commons.Observability;
@@ -798,6 +799,21 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
var tagPath = AbCipTagPath.TryParse(def.TagPath);
var bitIndex = tagPath?.BitIndex;
// libplctag never checks the CIP type the controller returned; it decodes the bytes as
// the authored type. A scalar whose buffer is not that type's width is mis-typed config
// (seen live: a REAL authored as INT read Good -17346), so refuse it rather than publish
// garbage as Good. Same-width mismatches (DINT vs REAL) cannot be seen this way.
if (!IsArrayTag(def) && bitIndex is null
&& ExpectedScalarWidth(def.DataType) is { } width
&& runtime.GetSize() is var size && size >= 0 && size != width)
{
results[fb.OriginalIndex] = new DataValueSnapshot(null, AbCipStatusMapper.BadTypeMismatch, null, now);
_logger.LogWarning(
"AbCip read of {Tag} on device {Device} returned {Size} bytes but its authored type {DataType} is {Width} bytes wide; check the tag's dataType",
reference, def.DeviceHostAddress, size, def.DataType, width);
return ReadOutcome.Degraded($"type mismatch reading {reference}: {size} bytes for {def.DataType}");
}
// Review I-1 — an array tag (the EXPLICIT IsArray flag) decodes the whole buffer into an
// element-typed CLR array (int[]/float[]/bool[]/string[]…), INCLUDING a 1-element array
// (ElementCount 1). Scalar tags keep the single-value path.
@@ -818,10 +834,12 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
}
catch (Exception ex)
{
// Transport exception — evict so the next read creates a fresh handle.
// Transport exception — evict so the next read creates a fresh handle. A libplctag
// exception carries its Status, so a tag the controller does not have reads
// BadNodeIdUnknown and a decode past the buffer BadOutOfRange, not a comms error.
EvictRuntime(device, def.Name);
results[fb.OriginalIndex] = new DataValueSnapshot(null,
AbCipStatusMapper.BadCommunicationError, null, now);
AbCipStatusMapper.MapException(ex), null, now);
_logger.LogWarning(ex,
"AbCip read transport exception for tag {Tag} on device {Device}",
reference, def.DeviceHostAddress);
@@ -891,7 +909,7 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
catch (Exception ex)
{
EvictRuntime(device, parent.Name);
StampGroupStatus(group, results, now, AbCipStatusMapper.BadCommunicationError);
StampGroupStatus(group, results, now, AbCipStatusMapper.MapException(ex));
_logger.LogWarning(ex,
"AbCip whole-UDT read transport exception for parent {Parent} on device {Device}",
group.ParentName, parent.DeviceHostAddress);
@@ -1158,10 +1176,20 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
{
await runtime.InitializeAsync(ct).ConfigureAwait(false);
}
catch
catch (Exception ex)
{
runtime.Dispose();
device.Backoff.RecordFailure(DateTime.UtcNow); // 05/STAB-8: open the backoff window
// 05/STAB-8: a failed create opens the backoff window — but only when the device did
// not answer. "Tag not found" means the controller replied over a working session; it
// is a config error on this one tag, and counting it as a failed Forward-Open throttled
// every sibling tag on the device (seen live: one typo blacked out the whole device).
if (ex is LibPlcTagException lpe
&& AbCipStatusMapper.TryGetStatus(lpe, out var status)
&& status != Status.Ok
&& AbCipDriverProbe.IsReachableStatus(status))
device.Backoff.RecordSuccess();
else
device.Backoff.RecordFailure(DateTime.UtcNow);
throw;
}
device.Backoff.RecordSuccess(); // 05/STAB-8: Forward-Open succeeded — reset the window
@@ -1285,6 +1313,11 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
// bogus single Structure → String placeholder Variable. When the shape can't be
// resolved (or yields no emittable leaf) we fall through to the legacy single
// Variable so nothing regresses.
// Review I-1 — a discovered array of length 1 is still an array; honour the
// explicit IsArray flag (legacy ElementCount > 1 still surfaces as an array).
var discoveredIsArray = discovered.IsArray || discovered.ElementCount > 1;
var discoveredType = discovered.DataType;
if (discovered.DataType == AbCipDataType.Structure)
{
// Pass the discovered tag's CIP template instance id (surfaced by the Symbol
@@ -1294,7 +1327,14 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
var shape = await ResolveDiscoveredUdtShapeAsync(
device.HostAddress, discovered.Name, discovered.TemplateInstanceId, cancellationToken)
.ConfigureAwait(false);
if (shape is not null)
if (shape is { IsStringFamily: true })
{
// A controller reports every STRING (built-in or a user string type such as
// STR0016) as a LEN + DATA struct. It is one String value — the read path reads
// it as such — so it becomes one String Variable, never a LEN/DATA folder.
discoveredType = AbCipDataType.String;
}
else if (shape is not null && !discoveredIsArray)
{
// Materialise the member sub-folder LAZILY — only once a leaf is actually
// about to be emitted into it. A shape that yields no usable atomic leaf
@@ -1313,12 +1353,9 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
}
if (!emitted.Add(fullName)) continue;
// Review I-1 — a discovered array of length 1 is still an array; honour the
// explicit IsArray flag (legacy ElementCount > 1 still surfaces as an array).
var discoveredIsArray = discovered.IsArray || discovered.ElementCount > 1;
discoveredFolder.Variable(fullName, discovered.Name, new DriverAttributeInfo(
FullName: fullName,
DriverDataType: discovered.DataType.ToDriverDataType(),
DriverDataType: discoveredType.ToDriverDataType(),
IsArray: discoveredIsArray,
ArrayDim: discoveredIsArray ? (uint)Math.Max(1, discovered.ElementCount) : null,
SecurityClass: discovered.ReadOnly
@@ -1327,12 +1364,39 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
IsHistorized: false,
IsAlarm: false,
WriteIdempotent: false,
NativeDataType: discovered.DataType.ToString()));
NativeDataType: discoveredType.ToString()));
}
}
}
}
/// <summary>
/// <c>true</c> for a UDT member the controller generates rather than the user declares: the
/// hidden SINT/DINT hosts Logix packs BOOL members into (<c>ZZZZZZZZZZAlarms0</c>) and AOI
/// backing members (<c>__BitHost00</c>). A user-declared Logix name can never begin with two
/// underscores, so neither rule can hide a real member.
/// </summary>
/// <param name="memberName">The member name from the template.</param>
/// <returns><c>true</c> when the member is controller-generated.</returns>
internal static bool IsHiddenUdtMember(string memberName) =>
memberName.StartsWith("ZZZZZZZZZZ", StringComparison.Ordinal)
|| memberName.StartsWith("__", StringComparison.Ordinal);
/// <summary>
/// Wire width of a scalar atomic Logix type, or <c>null</c> where the read buffer's size is not
/// a reliable type check (BOOL, STRING, DT, structures).
/// </summary>
/// <param name="type">The authored type.</param>
/// <returns>The width in bytes, or <c>null</c>.</returns>
internal static int? ExpectedScalarWidth(AbCipDataType type) => type switch
{
AbCipDataType.SInt or AbCipDataType.USInt => 1,
AbCipDataType.Int or AbCipDataType.UInt => 2,
AbCipDataType.DInt or AbCipDataType.UDInt or AbCipDataType.Real => 4,
AbCipDataType.LInt or AbCipDataType.ULInt or AbCipDataType.LReal => 8,
_ => null,
};
/// <summary>
/// Recursively fan a controller-discovered UDT shape out into Variable nodes — one per
/// ATOMIC leaf — under the folder produced by <paramref name="folder"/>. Mirrors the
@@ -1381,37 +1445,50 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
var anyEmitted = false;
foreach (var member in shape.Members)
{
if (IsHiddenUdtMember(member.Name)) continue;
var memberFullName = $"{parentFullName}.{member.Name}";
var leafType = member.DataType;
if (member.DataType == AbCipDataType.Structure)
{
// Nested struct: recurse into its sub-shape if it resolves, the depth cap allows it,
// and we're not re-entering a struct already on this path (cycle guard). The nested
// members live a level deeper, so the cap is checked against depth + 1.
if (depth + 1 > MaxUdtDepth) continue;
if (!visited.Add(member.Name)) continue; // cyclic UDT reference — stop here
// The decoded parent shape carries the nested UDT's template instance id (low 12 bits of the
// member info, captured by CipTemplateObjectDecoder) — thread it so the nested shape is fetched
// off the controller via FetchUdtShapeAsync, making nested-struct members addressable in
// production (name-keyed seeded shapes still win first, for the test seam).
// Nested struct: resolve its sub-shape first. The decoded parent shape carries the nested
// UDT's template instance id (low 12 bits of the member type word, captured by
// CipTemplateObjectDecoder) — thread it so the nested shape is fetched off the controller
// via FetchUdtShapeAsync (name-keyed seeded shapes still win first, for the test seam).
var nested = await ResolveDiscoveredUdtShapeAsync(
deviceHostAddress, member.Name, templateInstanceId: member.NestedTemplateId, cancellationToken)
.ConfigureAwait(false);
if (nested is not null)
if (nested is { IsStringFamily: true })
{
// Materialise the nested sub-folder lazily under the parent folder (itself lazy),
// so a nested struct that yields no leaf leaves no empty sub-folder behind.
var parentFolder = folder;
var nestedFolder = new Lazy<IAddressSpaceBuilder>(
() => parentFolder.Value.Folder(memberFullName, member.Name));
var nestedEmitted = await FanOutDiscoveredUdtMembersAsync(
nestedFolder, deviceHostAddress, memberFullName, nested, parentReadOnly,
depth + 1, emitted, visited, cancellationToken)
.ConfigureAwait(false);
anyEmitted |= nestedEmitted;
// A nested STRING (or user string type) member is one String leaf, not LEN + DATA.
leafType = AbCipDataType.String;
}
else
{
// An array of a UDT has no element-free member path (Parent.Arr.X names no element),
// so it is not recursed into. Otherwise recurse if the depth cap allows it and we're
// not re-entering a struct already on this path (cycle guard). The nested members
// live a level deeper, so the cap is checked against depth + 1.
if (member.ArrayLength > 1) continue;
if (depth + 1 > MaxUdtDepth) continue;
if (!visited.Add(member.Name)) continue; // cyclic UDT reference — stop here
if (nested is not null)
{
// Materialise the nested sub-folder lazily under the parent folder (itself lazy),
// so a nested struct that yields no leaf leaves no empty sub-folder behind.
var parentFolder = folder;
var nestedFolder = new Lazy<IAddressSpaceBuilder>(
() => parentFolder.Value.Folder(memberFullName, member.Name));
var nestedEmitted = await FanOutDiscoveredUdtMembersAsync(
nestedFolder, deviceHostAddress, memberFullName, nested, parentReadOnly,
depth + 1, emitted, visited, cancellationToken)
.ConfigureAwait(false);
anyEmitted |= nestedEmitted;
}
visited.Remove(member.Name);
continue;
}
visited.Remove(member.Name);
continue;
}
// Atomic leaf — emit one Variable. Dedup by full-name within the Discovered folder.
@@ -1420,7 +1497,7 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
var memberIsArray = member.ArrayLength > 1;
folder.Value.Variable(member.Name, member.Name, new DriverAttributeInfo(
FullName: memberFullName,
DriverDataType: member.DataType.ToDriverDataType(),
DriverDataType: leafType.ToDriverDataType(),
IsArray: memberIsArray,
ArrayDim: memberIsArray ? (uint)Math.Max(1, member.ArrayLength) : null,
// The Symbol Object carries no per-member ExternalAccess bits, so members inherit the
@@ -1431,7 +1508,7 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
IsHistorized: false,
IsAlarm: false,
WriteIdempotent: false,
NativeDataType: member.DataType.ToString()));
NativeDataType: leafType.ToString()));
anyEmitted = true;
}
@@ -200,7 +200,7 @@ public sealed class AbCipDriverProbe : IDriverProbe
internal static bool IsReachableException(LibPlcTagException ex)
{
var msg = ex.Message ?? string.Empty;
if (Enum.TryParse<Status>(msg.Trim(), ignoreCase: true, out var status) && Enum.IsDefined(status))
if (AbCipStatusMapper.TryGetStatus(ex, out var status))
return IsReachableStatus(status);
return msg.Contains("NOT_FOUND", StringComparison.OrdinalIgnoreCase)
@@ -76,6 +76,34 @@ public static class AbCipStatusMapper
/// <returns>The mapped OPC UA StatusCode value.</returns>
public static uint MapLibplctagStatus(int status) => MapLibplctagStatus((Status)status);
/// <summary>
/// Recover the libplctag <see cref="Status"/> a <see cref="LibPlcTagException"/> was thrown on.
/// The wrapper builds the exception from <c>Status.ToString()</c>, so the message is the enum
/// name (<c>"ErrorNotFound"</c>).
/// </summary>
/// <param name="ex">The exception.</param>
/// <param name="status">The recovered status.</param>
/// <returns><c>true</c> when the message names a defined <see cref="Status"/>.</returns>
public static bool TryGetStatus(LibPlcTagException ex, out Status status)
{
ArgumentNullException.ThrowIfNull(ex);
return Enum.TryParse((ex.Message ?? string.Empty).Trim(), ignoreCase: true, out status)
&& Enum.IsDefined(status);
}
/// <summary>
/// Status for an exception thrown while creating / reading a tag: a libplctag exception maps
/// through its <see cref="Status"/> (so a tag the controller does not have reads
/// <see cref="BadNodeIdUnknown"/>, not a comms error); anything else is
/// <see cref="BadCommunicationError"/>.
/// </summary>
/// <param name="ex">The exception.</param>
/// <returns>The OPC UA status code.</returns>
public static uint MapException(Exception ex) =>
ex is LibPlcTagException lpe && TryGetStatus(lpe, out var status)
? MapLibplctagStatus(status)
: BadCommunicationError;
/// <summary>
/// Map a libplctag.NET <see cref="Status"/> enum value to an OPC UA StatusCode. This is
/// the strongly-typed core of the mapper; the <c>int</c> overload exists only for the
@@ -100,7 +100,10 @@ public sealed record AbCipTagPath(
var bracketIdx = part.IndexOf('[');
if (bracketIdx < 0)
{
if (!IsValidIdent(part)) return null;
// The controller-scope base name of a module-defined I/O tag carries colons
// (Local:1:I, ABB_OmniCore:I) — legal in the first segment only.
var isModuleBase = segments.Count == 0 && programScope is null && IsValidModuleBase(part);
if (!isModuleBase && !IsValidIdent(part)) return null;
segments.Add(new AbCipTagPathSegment(part, []));
continue;
}
@@ -122,6 +125,25 @@ public sealed record AbCipTagPath(
return new AbCipTagPath(programScope, segments, bitIndex);
}
/// <summary>
/// <c>true</c> for a module-defined I/O tag base name: an identifier followed by one or more
/// <c>:</c>-separated identifier or slot-number parts (<c>Local:1:I</c>, <c>ABB_OmniCore:I</c>).
/// </summary>
/// <param name="s">The candidate base name.</param>
/// <returns><c>true</c> when <paramref name="s"/> is a module tag base name.</returns>
private static bool IsValidModuleBase(string s)
{
var parts = s.Split(':');
if (parts.Length < 2 || !IsValidIdent(parts[0])) return false;
for (var i = 1; i < parts.Length; i++)
{
var p = parts[i];
if (p.Length == 0) return false;
if (!IsValidIdent(p) && !p.All(char.IsAsciiDigit)) return false;
}
return true;
}
private static bool IsValidIdent(string s)
{
if (string.IsNullOrEmpty(s)) return false;
@@ -57,7 +57,19 @@ public sealed class AbCipTemplateCache
public sealed record AbCipUdtShape(
string TypeName,
int TotalSize,
IReadOnlyList<AbCipUdtMember> Members);
IReadOnlyList<AbCipUdtMember> Members)
{
/// <summary>
/// <c>true</c> when the shape is a Logix string-family type — the built-in <c>STRING</c> or
/// a user string type such as <c>STR0016</c>: exactly a <c>LEN</c> DINT at offset 0 and a
/// <c>DATA</c> SINT array at offset 4. A controller reports every string tag as a struct of
/// this shape, but it is read and presented as one String value, never as LEN + DATA leaves.
/// </summary>
public bool IsStringFamily =>
Members.Count == 2
&& Members[0] is { Name: "LEN", DataType: AbCipDataType.DInt, Offset: 0 }
&& Members[1] is { Name: "DATA", DataType: AbCipDataType.SInt, Offset: 4, ArrayLength: > 1 };
}
/// <summary>One member of a Logix UDT.</summary>
/// <param name="Name">Member name as reported by the Template Object.</param>
@@ -42,6 +42,10 @@ public static class CipSymbolObjectDecoder
private const ushort SymbolTypeSystemFlag = 0x1000;
private const ushort SymbolTypeStructFlag = 0x8000;
private const ushort SymbolTypeTypeCodeMask = 0x0FFF;
private const int SymbolTypeDimsShift = 13;
private const int SymbolTypeDimsMask = 0x3;
private const string ProgramPrefix = "Program:";
/// <summary>
/// Decode the raw <c>@tags</c> blob into an enumerable sequence. Malformed entries at
@@ -49,22 +53,44 @@ public static class CipSymbolObjectDecoder
/// cleanly before the corruption.
/// </summary>
/// <param name="buffer">The raw byte buffer from the Symbol Object response.</param>
/// <param name="programScope">The program this listing belongs to when it is a
/// <c>Program:&lt;name&gt;.@tags</c> read — whose entries carry bare names — or <c>null</c>
/// for the controller-scope <c>@tags</c> listing.</param>
/// <returns>An enumerable sequence of discovered CIP tags.</returns>
public static IEnumerable<AbCipDiscoveredTag> Decode(byte[] buffer)
public static IEnumerable<AbCipDiscoveredTag> Decode(byte[] buffer, string? programScope = null)
{
ArgumentNullException.ThrowIfNull(buffer);
return DecodeImpl(buffer);
return DecodeImpl(buffer, programScope);
}
private static IEnumerable<AbCipDiscoveredTag> DecodeImpl(byte[] buffer)
/// <summary>
/// The program name when <paramref name="tag"/> is a controller-scope program entry
/// (<c>Program:UN01_Press</c>, a system symbol) — the name to read <c>Program:&lt;name&gt;.@tags</c>
/// for, since a controller lists program-scope tags only under their own program. <c>null</c>
/// for anything else.
/// </summary>
/// <param name="tag">A decoded controller-scope symbol.</param>
/// <returns>The program name, or <c>null</c>.</returns>
public static string? ProgramNameOf(AbCipDiscoveredTag tag)
{
ArgumentNullException.ThrowIfNull(tag);
if (!tag.IsSystemTag || tag.ProgramScope is not null) return null;
if (!tag.Name.StartsWith(ProgramPrefix, StringComparison.Ordinal)) return null;
var name = tag.Name[ProgramPrefix.Length..];
return name.Length == 0 || name.Contains('.') ? null : name;
}
private static IEnumerable<AbCipDiscoveredTag> DecodeImpl(byte[] buffer, string? listingScope)
{
var pos = 0;
while (pos + FixedHeaderSize <= buffer.Length)
{
var instanceId = BinaryPrimitives.ReadUInt32LittleEndian(buffer.AsSpan(pos));
var symbolType = BinaryPrimitives.ReadUInt16LittleEndian(buffer.AsSpan(pos + 4));
// element_length at pos+6 (u16) — useful for array sizing but not surfaced here
// array_dims at pos+8, pos+12, pos+16 — same (scalar-tag case has all zeros)
// element_length at pos+6 (u16) — not surfaced.
var dim0 = BinaryPrimitives.ReadUInt32LittleEndian(buffer.AsSpan(pos + 8));
var dim1 = BinaryPrimitives.ReadUInt32LittleEndian(buffer.AsSpan(pos + 12));
var dim2 = BinaryPrimitives.ReadUInt32LittleEndian(buffer.AsSpan(pos + 16));
var nameLength = BinaryPrimitives.ReadUInt16LittleEndian(buffer.AsSpan(pos + 20));
pos += FixedHeaderSize;
@@ -83,7 +109,10 @@ public static class CipSymbolObjectDecoder
var isStruct = (symbolType & SymbolTypeStructFlag) != 0;
var typeCode = symbolType & SymbolTypeTypeCodeMask;
var (programScope, simpleName) = SplitProgramScope(name);
// A program listing (Program:X.@tags) carries bare names; the scope is the program read.
var (programScope, simpleName) = listingScope is null
? SplitProgramScope(name)
: (listingScope, name);
var dataType = isStruct ? AbCipDataType.Structure : MapTypeCode((byte)typeCode);
// For a struct symbol the lower 12 bits (typeCode) are NOT a primitive CIP type code —
@@ -92,18 +121,46 @@ public static class CipSymbolObjectDecoder
// carry no template id.
var templateInstanceId = isStruct ? (uint?)typeCode : null;
var (isArray, elementCount) = ArrayShape((symbolType >> SymbolTypeDimsShift) & SymbolTypeDimsMask,
dim0, dim1, dim2);
yield return new AbCipDiscoveredTag(
Name: simpleName,
ProgramScope: programScope,
DataType: dataType ?? AbCipDataType.Structure, // unknown type code → treat as opaque
ReadOnly: false, // Symbol Object doesn't carry write-protection bits; lift via AccessControl Object later
IsSystemTag: isSystem,
ElementCount: elementCount,
IsArray: isArray,
TemplateInstanceId: templateInstanceId);
_ = instanceId; // retained in the wire format for diagnostics; not surfaced to the driver today
}
}
/// <summary>
/// Array shape from the symbol type's dimension count (bits 13-14) and the three dimension
/// words. A real controller sets no dimension bits on a scalar; <c>ab_server</c> reports every
/// scalar as a one-dimensional array of one, so a 1-D single-element entry is taken as a scalar
/// too. A multi-dimensional array is surfaced flattened (element count = product of dims) —
/// the driver addresses 1-D arrays only.
/// </summary>
/// <param name="dimCount">Dimension count from bits 13-14 of the symbol type.</param>
/// <param name="dim0">First dimension.</param>
/// <param name="dim1">Second dimension.</param>
/// <param name="dim2">Third dimension.</param>
/// <returns>Whether the symbol is an array, and its flattened element count.</returns>
internal static (bool IsArray, int ElementCount) ArrayShape(int dimCount, uint dim0, uint dim1, uint dim2)
{
if (dimCount == 0) return (false, 1);
long count = 1;
if (dimCount >= 1) count *= Math.Max(1u, dim0);
if (dimCount >= 2) count *= Math.Max(1u, dim1);
if (dimCount >= 3) count *= Math.Max(1u, dim2);
if (dimCount == 1 && count == 1) return (false, 1);
return (true, (int)Math.Min(count, int.MaxValue));
}
/// <summary>
/// Split a <c>Program:MainProgram.StepIndex</c>-style name into its scope + local
/// parts. Names without the <c>Program:</c> prefix pass through unchanged.
@@ -4,103 +4,101 @@ using System.Text;
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip;
/// <summary>
/// Decoder for the CIP Template Object (class 0x6C) blob returned by a <c>Read Template</c>
/// service. Produces an <see cref="AbCipUdtShape"/> describing the UDT's name, total size,
/// + ordered member list with per-member offset + type + array length.
/// Decoder for the UDT template buffer libplctag returns for an <c>@udt/&lt;id&gt;</c> tag (CIP
/// Template Object, class 0x6C). Produces an <see cref="AbCipUdtShape"/> describing the UDT's
/// name, total size, + ordered member list with per-member offset + type + array length.
/// </summary>
/// <remarks>
/// <para>Wire format per Rockwell CIP Vol 1 §5A + Logix 5000 CIP Programming Manual
/// 1756-PM019 §"Template Object", cross-checked against libplctag's <c>ab/cip.c</c>
/// <c>handle_read_template_reply</c>:</para>
/// <para>The layout below is what libplctag hands back from <c>Tag.GetBuffer()</c> — its own
/// header followed by the controller's Read Template reply — matching libplctag's
/// <c>list_tags_logix</c> example and pinned by real captures in
/// <c>CipTemplateObjectWireCaptureTests</c> (CompactLogix 5069-L320ERS2 rev 34.11). An earlier
/// version assumed a 12-byte header led by the member count and <c>[info][array][offset]</c>
/// blocks; every real buffer decoded to <c>null</c> under that assumption.</para>
///
/// <para>Header (fixed-size, little-endian):</para>
/// <para>Header (14 bytes, little-endian):</para>
/// <list type="table">
/// <item><term>u16</term><description>Member count.</description></item>
/// <item><term>u16</term><description>Struct handle (opaque id).</description></item>
/// <item><term>u16</term><description>Template instance id.</description></item>
/// <item><term>u32</term><description>Member-definition size in 32-bit words — not used here.</description></item>
/// <item><term>u32</term><description>Instance size — bytes per structure instance.</description></item>
/// <item><term>u32</term><description>Member-definition total size — not used here.</description></item>
/// <item><term>u16</term><description>Member count.</description></item>
/// <item><term>u16</term><description>Struct handle (opaque).</description></item>
/// </list>
///
/// <para>Then <c>member_count</c> member blocks (8 bytes each):</para>
/// <list type="table">
/// <item><term>u16</term><description>Member info — type code + flags (same encoding
/// as Symbol Object: bit 15 = struct, lower 12 = CIP type code).</description></item>
/// <item><term>u16</term><description>Array size — 0 for scalar members.</description></item>
/// <item><term>u32</term><description>Struct offset — byte offset from struct start.</description></item>
/// <item><term>u16</term><description>Info — the array length for an array member, the bit
/// number for a BOOL packed into a host integer, otherwise 0.</description></item>
/// <item><term>u16</term><description>Type — bit 15 = struct, bit 13 = array, low 12 bits =
/// CIP atomic type code, or for a struct the nested template instance id.</description></item>
/// <item><term>u32</term><description>Byte offset from the struct start.</description></item>
/// </list>
///
/// <para>Then strings: UDT name followed by each member name, each terminated by a
/// semicolon <c>;</c> followed by a null <c>\0</c>. The UDT name may itself contain the
/// sequence <c>UDTName;0\0</c> where <c>0</c> after the semicolon is an ASCII flag byte.
/// Decoder trims to the first semicolon.</para>
/// <para>Then the strings: the UDT name terminated by <c>;</c> (followed by an encoded tail that
/// runs to a NUL — discarded), then each member name terminated by a NUL. Anything after the
/// last member name is ignored.</para>
/// </remarks>
public static class CipTemplateObjectDecoder
{
private const int HeaderSize = 12; // u16 + u16 + u32 + u32
private const int MemberBlockSize = 8; // u16 + u16 + u32
private const int HeaderSize = 14; // u16 id + u32 def size + u32 instance size + u16 count + u16 handle
private const int MemberBlockSize = 8; // u16 info + u16 type + u32 offset
private const ushort MemberInfoStructFlag = 0x8000;
private const ushort MemberInfoTypeCodeMask = 0x0FFF;
private const ushort TypeStructFlag = 0x8000;
private const ushort TypeArrayFlag = 0x2000;
private const ushort TypeCodeMask = 0x0FFF;
/// <summary>
/// Decode the raw Template Object blob. Returns <c>null</c> when the header indicates
/// zero members or the buffer is too short to hold the fixed header.
/// Decode the raw template buffer. Returns <c>null</c> when the header indicates zero
/// members or the buffer is too short to hold the header + member blocks + type name.
/// </summary>
/// <param name="buffer">The raw Template Object buffer to decode.</param>
/// <param name="buffer">The raw template buffer to decode.</param>
/// <returns>An AbCipUdtShape describing the structure, or null if decoding fails.</returns>
public static AbCipUdtShape? Decode(byte[] buffer)
{
ArgumentNullException.ThrowIfNull(buffer);
if (buffer.Length < HeaderSize) return null;
var memberCount = BinaryPrimitives.ReadUInt16LittleEndian(buffer.AsSpan(0));
// bytes 2-3: struct handle — opaque, not needed for the shape record
var instanceSize = BinaryPrimitives.ReadUInt32LittleEndian(buffer.AsSpan(4));
// bytes 8-11: member-definition total size — inferred from names list instead
// bytes 0-1: template instance id; bytes 2-5: definition size in words — neither needed.
var instanceSize = BinaryPrimitives.ReadUInt32LittleEndian(buffer.AsSpan(6));
var memberCount = BinaryPrimitives.ReadUInt16LittleEndian(buffer.AsSpan(10));
// bytes 12-13: struct handle — opaque.
if (memberCount == 0) return null;
var memberBlocksOffset = HeaderSize;
var namesOffset = memberBlocksOffset + MemberBlockSize * memberCount;
var namesOffset = HeaderSize + (MemberBlockSize * memberCount);
if (namesOffset > buffer.Length) return null;
var stringsSpan = buffer.AsSpan(namesOffset);
var names = ParseSemicolonTerminatedStrings(stringsSpan);
if (names.Count == 0) return null;
// Strings layout: UDT name first, then one per member (in the same order as the
// member-info blocks). Always consume the first entry as the UDT name; missing
// trailing member names get <member_N> placeholders below.
var udtName = names[0];
var memberNames = names.Skip(1).ToArray();
var strings = ParseTemplateStrings(buffer.AsSpan(namesOffset), memberCount);
if (strings.Count == 0) return null;
var udtName = strings[0];
var members = new List<AbCipUdtMember>(memberCount);
for (var i = 0; i < memberCount; i++)
{
var blockOffset = memberBlocksOffset + (i * MemberBlockSize);
var blockOffset = HeaderSize + (i * MemberBlockSize);
var info = BinaryPrimitives.ReadUInt16LittleEndian(buffer.AsSpan(blockOffset));
var arraySize = BinaryPrimitives.ReadUInt16LittleEndian(buffer.AsSpan(blockOffset + 2));
var type = BinaryPrimitives.ReadUInt16LittleEndian(buffer.AsSpan(blockOffset + 2));
var offset = (int)BinaryPrimitives.ReadUInt32LittleEndian(buffer.AsSpan(blockOffset + 4));
var isStruct = (info & MemberInfoStructFlag) != 0;
// Scalar path only — Rockwell primitive type codes (0xC1-0xD0) fit a byte. Do NOT use for a
// struct member: a nested template id can exceed a byte (see nestedTemplateId below).
var typeCode = (byte)(info & MemberInfoTypeCodeMask);
var isStruct = (type & TypeStructFlag) != 0;
var isArray = (type & TypeArrayFlag) != 0;
// Atomic CIP type codes (0xC1-0xD0) fit a byte; for a struct the low 12 bits are the nested
// template id, which can exceed a byte, so it is taken from the full 12-bit mask below.
var dataType = isStruct
? AbCipDataType.Structure
: (CipSymbolObjectDecoder.MapTypeCode(typeCode) ?? AbCipDataType.Structure);
: (CipSymbolObjectDecoder.MapTypeCode((byte)(type & TypeCodeMask)) ?? AbCipDataType.Structure);
var nestedTemplateId = isStruct ? (uint?)(type & TypeCodeMask) : null;
// For a struct member the low 12 bits are the nested UDT's template instance id (same encoding as the
// Symbol Object), NOT a primitive type code — capture it so the nested shape can be fetched. Use the
// FULL 12-bit mask (not the byte-cast typeCode, which truncates a 12-bit id).
var nestedTemplateId = isStruct ? (uint?)(info & MemberInfoTypeCodeMask) : null;
// info is the element count only for an array member; for a BOOL it is the bit number,
// which must never be read as a length.
var arrayLength = isArray && info > 0 ? info : 1;
var memberName = i < memberNames.Length ? memberNames[i] : $"<member_{i}>";
var memberName = i + 1 < strings.Count ? strings[i + 1] : $"<member_{i}>";
members.Add(new AbCipUdtMember(
Name: memberName,
Offset: offset,
DataType: dataType,
ArrayLength: arraySize == 0 ? 1 : arraySize,
ArrayLength: arrayLength,
NestedTemplateId: nestedTemplateId));
}
@@ -111,41 +109,30 @@ public static class CipTemplateObjectDecoder
}
/// <summary>
/// Walk a span of <c>NAME;\0NAME;\0…</c> byte sequences. Splits at each semicolon —
/// the null byte after each semicolon is optional padding per Rockwell's string
/// encoding convention. Stops at a trailing null / end of buffer.
/// Split the template string section into the UDT name (cut at its first <c>;</c>) followed
/// by up to <paramref name="memberCount"/> NUL-terminated member names. Stops at the end of
/// the buffer; trailing bytes past the last member name are ignored.
/// </summary>
/// <param name="span">The byte span containing semicolon-terminated strings.</param>
/// <returns>List of parsed strings, one per name in the span.</returns>
internal static List<string> ParseSemicolonTerminatedStrings(ReadOnlySpan<byte> span)
/// <param name="span">The string section, starting right after the last member block.</param>
/// <param name="memberCount">Member names to read after the UDT name.</param>
/// <returns>The UDT name followed by the member names found.</returns>
internal static List<string> ParseTemplateStrings(ReadOnlySpan<byte> span, int memberCount)
{
var result = new List<string>();
var start = 0;
for (var i = 0; i < span.Length; i++)
var result = new List<string>(memberCount + 1);
var pos = 0;
while (pos < span.Length && result.Count < memberCount + 1)
{
var b = span[i];
if (b == ';')
var nul = span[pos..].IndexOf((byte)0);
var end = nul < 0 ? span.Length : pos + nul;
var raw = span[pos..end];
if (result.Count == 0)
{
if (i > start)
result.Add(Encoding.ASCII.GetString(span[start..i]));
// Skip the optional null/space padding following the semicolon.
while (i + 1 < span.Length && (span[i + 1] == '\0' || span[i + 1] == ' '))
i++;
start = i + 1;
// The type name carries an encoded tail after ';' ("STRING;nEAIA").
var semi = raw.IndexOf((byte)';');
if (semi >= 0) raw = raw[..semi];
}
else if (b == 0 && start == i)
{
// Trailing null at a string boundary — done.
break;
}
}
// Trailing name without a semicolon (unlikely but observed on some firmwares).
if (start < span.Length)
{
var zeroAt = span[start..].IndexOf((byte)0);
var end = zeroAt < 0 ? span.Length : start + zeroAt;
if (end > start)
result.Add(Encoding.ASCII.GetString(span[start..end]));
result.Add(Encoding.ASCII.GetString(raw));
pos = end + 1;
}
return result;
}
@@ -31,6 +31,13 @@ public interface IAbCipTagRuntime : IDisposable
/// <returns>The raw libplctag status code (zero on success, negative on error).</returns>
int GetStatus();
/// <summary>
/// Bytes in the last read's buffer, or <c>-1</c> when the runtime cannot say. Used to catch an
/// authored type whose width disagrees with what the controller actually returned.
/// </summary>
/// <returns>The buffer size in bytes, or -1 if unknown.</returns>
int GetSize() => -1;
/// <summary>
/// Decode the local buffer into a boxed .NET value per the tag's configured type.
/// <paramref name="bitIndex"/> is non-null only for BOOL-within-DINT tags captured in
@@ -19,29 +19,73 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip;
/// </remarks>
internal sealed class LibplctagTagEnumerator : IAbCipTagEnumerator
{
private Tag? _tag;
private readonly Func<AbCipTagCreateParams, string, CancellationToken, Task<byte[]>> _readListing;
/// <summary>Production enumerator: reads each listing through libplctag.</summary>
public LibplctagTagEnumerator() : this(ReadListingAsync) { }
/// <summary>Enumerator over an injected listing reader (unit-test seam).</summary>
/// <param name="readListing">Reads one symbol listing (<c>@tags</c> or <c>Program:X.@tags</c>) and
/// returns its raw Symbol Object bytes.</param>
internal LibplctagTagEnumerator(Func<AbCipTagCreateParams, string, CancellationToken, Task<byte[]>> readListing)
=> _readListing = readListing;
/// <inheritdoc />
/// <remarks>
/// A controller's <c>@tags</c> listing holds controller-scope tags plus one system entry per
/// program (<c>Program:UN01_Press</c>); the program's own tags are listed only by
/// <c>Program:&lt;name&gt;.@tags</c>. So the controller listing is read first, then each program's
/// listing, decoded with that program as the scope. A program whose listing cannot be read is
/// skipped — browse is best-effort and one unreadable program must not cost the others.
/// </remarks>
public async IAsyncEnumerable<AbCipDiscoveredTag> EnumerateAsync(
AbCipTagCreateParams deviceParams,
[EnumeratorCancellation] CancellationToken cancellationToken)
{
_tag = BuildSymbolListTag(deviceParams);
var buffer = await _readListing(deviceParams, "@tags", cancellationToken).ConfigureAwait(false);
await _tag.InitializeAsync(cancellationToken).ConfigureAwait(false);
await _tag.ReadAsync(cancellationToken).ConfigureAwait(false);
var buffer = _tag.GetBuffer();
// The decoder already surfaces each struct tag's CIP template instance id on
// AbCipDiscoveredTag.TemplateInstanceId (from the lower 12 bits of the symbol type), so
// re-yielding the decoded records as-is carries it through to DiscoverAsync's UDT fan-out.
var programs = new List<string>();
foreach (var tag in CipSymbolObjectDecoder.Decode(buffer))
{
if (CipSymbolObjectDecoder.ProgramNameOf(tag) is { } program) programs.Add(program);
yield return tag;
}
foreach (var program in programs)
{
byte[] programBuffer;
try
{
programBuffer = await _readListing(deviceParams, $"Program:{program}.@tags", cancellationToken)
.ConfigureAwait(false);
}
catch (OperationCanceledException) { throw; }
catch (Exception)
{
continue;
}
foreach (var tag in CipSymbolObjectDecoder.Decode(programBuffer, programScope: program))
yield return tag;
}
}
private static async Task<byte[]> ReadListingAsync(
AbCipTagCreateParams deviceParams, string listingName, CancellationToken cancellationToken)
{
using var tag = BuildSymbolListTag(deviceParams, listingName);
await tag.InitializeAsync(cancellationToken).ConfigureAwait(false);
await tag.ReadAsync(cancellationToken).ConfigureAwait(false);
return tag.GetBuffer();
}
/// <summary>
/// Builds the (uninitialized) libplctag tag for the <c>@tags</c> pseudo — same gateway +
/// path as the device, distinguished by the name alone.
/// Builds the (uninitialized) libplctag tag for a symbol listing pseudo-tag — <c>@tags</c> or
/// <c>Program:&lt;name&gt;.@tags</c> — same gateway + path as the device, distinguished by the
/// name alone.
/// </summary>
/// <remarks>
/// Gateway must be <see cref="AbCipTagCreateParams.LibplctagGateway"/> (host:port), never the
@@ -49,18 +93,21 @@ internal sealed class LibplctagTagEnumerator : IAbCipTagEnumerator
/// host, so a browse against a device authored on another port silently returns whatever
/// answers on 44818 (Gitea #539, the discovery half of #534).
/// </remarks>
internal static Tag BuildSymbolListTag(AbCipTagCreateParams deviceParams) => new()
/// <param name="deviceParams">The device's create params.</param>
/// <param name="listingName">The listing pseudo-tag name (default <c>@tags</c>).</param>
/// <returns>The uninitialized tag.</returns>
internal static Tag BuildSymbolListTag(AbCipTagCreateParams deviceParams, string listingName = "@tags") => new()
{
Gateway = deviceParams.LibplctagGateway,
Path = deviceParams.CipPath,
PlcType = MapPlcType(deviceParams.LibplctagPlcAttribute),
Protocol = Protocol.ab_eip,
Name = "@tags",
Name = listingName,
Timeout = deviceParams.Timeout,
};
/// <summary>Disposes the enumerator and releases the underlying libplctag tag.</summary>
public void Dispose() => _tag?.Dispose();
/// <summary>Nothing to release — each listing's libplctag tag is disposed after its read.</summary>
public void Dispose() { }
private static PlcType MapPlcType(string attribute) => attribute switch
{
@@ -46,6 +46,9 @@ internal sealed class LibplctagTagRuntime : IAbCipTagRuntime
/// <inheritdoc />
public int GetStatus() => (int)_tag.GetStatus();
/// <inheritdoc />
public int GetSize() => _tag.GetSize();
/// <inheritdoc />
public object? DecodeValue(AbCipDataType type, int? bitIndex) => DecodeValueAt(type, 0, bitIndex);
@@ -79,8 +82,7 @@ internal sealed class LibplctagTagRuntime : IAbCipTagRuntime
// accounts for STRING capacity + atomic widths. ElementSize is int? on the wrapper — fall
// back to a static size table when it is null/<=0 (e.g. a zero-length read) so the stride
// is never 0.
var elementSize = _tag.ElementSize ?? 0;
var stride = elementSize > 0 ? elementSize : ElementByteSize(type);
var stride = ResolveArrayStride(_tag.ElementSize, _tag.GetSize(), count, type);
return type switch
{
@@ -99,6 +101,26 @@ internal sealed class LibplctagTagRuntime : IAbCipTagRuntime
};
}
/// <summary>
/// Byte stride between array elements. libplctag's <c>ElementSize</c> wins when populated, but
/// after a read it is <c>null</c> (observed live, libplctag.NET 1.5.2) — and the static table
/// assumes the built-in 88-byte STRING, which is wrong for a user string type: a <c>STR0016</c>
/// element is 20 bytes, so element 1 was read at offset 88 of an 80-byte buffer and the whole
/// read failed. When the read buffer divides evenly by the element count, that quotient IS the
/// element size, so it is used before the static table.
/// </summary>
/// <param name="elementSize">libplctag's reported element size, if any.</param>
/// <param name="bufferSize">Bytes in the read buffer.</param>
/// <param name="count">Element count requested.</param>
/// <param name="type">Element type.</param>
/// <returns>The stride in bytes.</returns>
internal static int ResolveArrayStride(int? elementSize, int bufferSize, int count, AbCipDataType type)
{
if (elementSize is > 0) return elementSize.Value;
if (count > 0 && bufferSize > 0 && bufferSize % count == 0) return bufferSize / count;
return ElementByteSize(type);
}
private bool[] BuildBoolArray(int count)
{
var arr = new bool[count];
@@ -132,12 +132,11 @@ public sealed class FocasAlarmProjectionOptions
/// default) to skip range validation and preserve legacy behaviour.
/// </param>
/// <param name="PositionDecimalPlaces">
/// Axis positions returned by <c>cnc_rddynamic2</c> are scaled integers. The driver
/// divides AbsolutePosition / MachinePosition / RelativePosition / DistanceToGo by
/// <c>10^PositionDecimalPlaces</c> at the publish seam so they surface in engineering
/// units on the Float64 axis nodes. Default <c>0</c> (no scaling) is byte-identical to
/// legacy behaviour. Auto-fetching this via <c>cnc_getfigure</c> is deferred (wire-gated),
/// so it is config-supplied. Negative values are clamped to 0 (no scaling).
/// <b>Fallback only.</b> A real CNC's <c>cnc_rddynamic2</c> position block carries its own
/// scale (<c>raw / base^exponent</c>), which the driver always applies and which wins over
/// this setting. Only a block that carries no scale (a 4-byte payload, e.g. an older
/// simulator) is divided by <c>10^PositionDecimalPlaces</c>. Default <c>0</c> (no scaling).
/// Negative values are clamped to 0.
/// </param>
public sealed record FocasDeviceOptions(
string HostAddress,
@@ -41,11 +41,18 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
/// config redeploy).
/// </para>
/// <para>
/// Suffix collisions with genuine names are structurally impossible for axes (FOCAS axis
/// displays are at most two characters — name + suffix — so they cannot contain <c>_</c>),
/// but the generator still checks: a generated segment that would collide with anything
/// else in the set escalates its ordinal until unique, so the output is always
/// collision-free whatever the input.
/// Suffix collisions with genuine names are not expected (a FOCAS name record is at most four
/// ASCII characters and controls do not use <c>_</c>), but the generator still checks: a
/// generated segment that would collide with anything else in the set escalates its ordinal
/// until unique, so the output is always collision-free whatever the input.
/// </para>
/// <para>
/// <b>Note on the Makino that motivated this.</b> Its two "AA" axes were a decode artefact:
/// the name records actually read <c>"AA6"</c> and <c>"AA7"</c>, and the wire layer read only
/// the first two bytes of each 4-byte record. With the full record decoded
/// (<see cref="Wire.FocasWireProtocol.ReadNameRecord"/>) that machine reports unique names and
/// its axes publish bare (<c>Axes/AA6</c>, <c>Axes/AA7</c>). This disambiguation remains as the
/// guard for a control that genuinely reports duplicates.
/// </para>
/// </remarks>
internal static class FixedTreeNames
@@ -643,7 +643,8 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
// Spindle subtree — one folder per discovered spindle, suppressed
// entirely on series that don't export cnc_rdspdlname. Per-spindle
// Load + MaxRpm each gated on their own capability probe.
// Load + MotorSpeedPct (both cnc_rdspmeter percentages of full scale)
// each gated on their own capability probe.
if (cache.Capabilities.Spindles)
{
var spindleRoot = deviceFolder.Folder("Spindle", "Spindle");
@@ -652,9 +653,9 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
var name = cache.SpindleSegments[i];
var spindleFolder = spindleRoot.Folder(name, name);
if (cache.Capabilities.SpindleLoad)
EmitFixedVariable(spindleFolder, $"Spindle/{name}", "Load", DriverDataType.Int32);
if (cache.Capabilities.SpindleMaxRpm && i < cache.SpindleMaxRpms.Count)
EmitFixedVariable(spindleFolder, $"Spindle/{name}", "MaxRpm", DriverDataType.Int32);
EmitFixedVariable(spindleFolder, $"Spindle/{name}", "Load", DriverDataType.Float64);
if (cache.Capabilities.SpindleMotorSpeed)
EmitFixedVariable(spindleFolder, $"Spindle/{name}", "MotorSpeedPct", DriverDataType.Float64);
}
}
@@ -925,15 +926,9 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
var sys = await client.GetSysInfoAsync(ct).ConfigureAwait(false);
var axes = await client.GetAxisNamesAsync(ct).ConfigureAwait(false);
// Per-axis decimal-place figures (cnc_getfigure), fetched once. Auto figures win
// over the manual PositionDecimalPlaces config at the publish seam; an empty list
// (or a failed/empty figure read) makes every axis fall back to the config knob.
// Defensive: a figure-read failure must NOT fault device init — default to empty.
state.PositionFigures = await SafeProbe(() => client.GetPositionFiguresAsync(ct), []);
// Optional-API probes — each returns empty / throws when unsupported.
var spindles = await SafeProbe(() => client.GetSpindleNamesAsync(ct), []);
var spindleMaxRpms = await SafeProbe(() => client.GetSpindleMaxRpmsAsync(ct), []);
var spindleMotorSpeeds = await SafeProbe(() => client.GetSpindleMotorSpeedsAsync(ct), []);
var servoLoads = await SafeProbe(() => client.GetServoLoadsAsync(ct), []);
var programInfo = await SafeTryProbe(() => client.GetProgramInfoAsync(ct));
var timer = await SafeTryProbe(() => client.GetTimerAsync(FocasTimerKind.PowerOn, ct));
@@ -942,13 +937,12 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
var caps = new FocasFixedTreeCapabilities(
Spindles: spindles.Count > 0,
SpindleLoad: spindleLoad.Count > 0,
SpindleMaxRpm: spindleMaxRpms.Count > 0,
SpindleMotorSpeed: spindleMotorSpeeds.Count > 0,
ServoLoad: servoLoads.Count > 0,
ProgramInfo: programInfo is not null,
Timers: timer is not null);
state.FixedTreeCache = new FocasFixedTreeCache(
sys, [.. axes], [.. spindles], [.. spindleMaxRpms], caps);
state.FixedTreeCache = new FocasFixedTreeCache(sys, [.. axes], [.. spindles], caps);
// The bootstrap IS an answer from the device, and Identity comes entirely from it —
// so stamp liveness here as well as per tick, or a CNC whose axis poll never
// succeeds would serve a perfectly valid sysinfo as stale.
@@ -964,15 +958,14 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
}
}
// Prime the spindle-loads cache from bootstrap if supported — avoids a
// Prime the spindle-meter caches from bootstrap if supported — avoids a
// "tree is there but reads say BadNodeIdUnknown" window on startup.
if (state.FixedTreeCache?.Capabilities is { SpindleLoad: true })
if (state.FixedTreeCache?.Capabilities is { SpindleLoad: true } or { SpindleMotorSpeed: true })
{
try
{
var client2 = await EnsureConnectedAsync(state, ct).ConfigureAwait(false);
var loads = await client2.GetSpindleLoadsAsync(ct).ConfigureAwait(false);
for (var i = 0; i < loads.Count; i++) state.LastSpindleLoads[i] = loads[i];
await PollSpindleMetersAsync(state, client2, ct).ConfigureAwait(false);
}
catch (Exception ex)
{
@@ -999,7 +992,7 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
var snap = await client.ReadDynamicAsync(axisIndex, ct).ConfigureAwait(false);
// Keyed by the disambiguated segment, matching what DiscoverAsync emitted —
// two same-named axes must not share (and overwrite) one cache slot (#548).
PublishAxisSnapshot(state, cache.AxisSegments[i], snap, i); // i = 0-based axis index → PositionFigures lookup
PublishAxisSnapshot(state, cache.AxisSegments[i], snap);
if (i == 0) { firstAxisSnap = snap; PublishRateSnapshot(state, snap); }
}
@@ -1025,17 +1018,16 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
state.Options.HostAddress);
}
}
if (cache.Capabilities.SpindleLoad)
if (cache.Capabilities.SpindleLoad || cache.Capabilities.SpindleMotorSpeed)
{
try
{
var loads = await client.GetSpindleLoadsAsync(ct).ConfigureAwait(false);
for (var i = 0; i < loads.Count; i++) state.LastSpindleLoads[i] = loads[i];
await PollSpindleMetersAsync(state, client, ct).ConfigureAwait(false);
}
catch (Exception ex)
{
/* transient */
_logger.LogDebug(ex, "FOCAS spindle-loads poll failed for {Host}",
_logger.LogDebug(ex, "FOCAS spindle-meter poll failed for {Host}",
state.Options.HostAddress);
}
}
@@ -1102,40 +1094,34 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
/// <see cref="PollGroupEngine"/> → <see cref="ReadAsync"/> path, which hits
/// <see cref="TryReadFixedTree"/> and returns these cached values.
/// </summary>
private static void PublishAxisSnapshot(DeviceState state, string axisSegment, FocasDynamicSnapshot snap, int axisIndex)
private static void PublishAxisSnapshot(DeviceState state, string axisSegment, FocasDynamicSnapshot snap)
{
var host = state.Options.HostAddress;
// cnc_rddynamic2 returns positions as scaled integers; divide by 10^figures so they
// surface in engineering units on the Float64 axis nodes. The figure is per-axis:
// an auto cnc_getfigure figure WINS, and the configured PositionDecimalPlaces is the
// fallback when the CNC didn't report one for that axis (see AxisFactor). A figure of
// 0 yields factor 1.0 — i.e. the integer widened to double, byte-identical to legacy
// behaviour (12345 / 1.0 == 12345.0). The managed WireFocasClient fetches figures live
// via ReadPositionFiguresAsync; the config knob is the per-axis fallback only. FeedRate /
// Each cnc_rddynamic2 position block carries its OWN scale (raw / base^exponent — see
// FocasPosition), so positions surface in engineering units with no second read; the
// configured PositionDecimalPlaces applies only to a block that carried no scale. A block
// describing no usable position (vacant 0xFFFF marker, odd base, absurd exponent) is
// cached as NaN and read back as BadNoData for that node alone (see NoValue). FeedRate /
// SpindleSpeed (rate snapshot) and ServoLoad are NOT position-scaled (published elsewhere).
var factor = AxisFactor(state, axisIndex);
state.LastFixedSnapshots[FixedTreeReference(host, $"Axes/{axisSegment}/AbsolutePosition")] = snap.AbsolutePosition / factor;
state.LastFixedSnapshots[FixedTreeReference(host, $"Axes/{axisSegment}/MachinePosition")] = snap.MachinePosition / factor;
state.LastFixedSnapshots[FixedTreeReference(host, $"Axes/{axisSegment}/RelativePosition")] = snap.RelativePosition / factor;
state.LastFixedSnapshots[FixedTreeReference(host, $"Axes/{axisSegment}/DistanceToGo")] = snap.DistanceToGo / factor;
var dp = state.Options.PositionDecimalPlaces;
state.LastFixedSnapshots[FixedTreeReference(host, $"Axes/{axisSegment}/AbsolutePosition")] = snap.AbsolutePosition.ToEngineering(dp) ?? NoValue;
state.LastFixedSnapshots[FixedTreeReference(host, $"Axes/{axisSegment}/MachinePosition")] = snap.MachinePosition.ToEngineering(dp) ?? NoValue;
state.LastFixedSnapshots[FixedTreeReference(host, $"Axes/{axisSegment}/RelativePosition")] = snap.RelativePosition.ToEngineering(dp) ?? NoValue;
state.LastFixedSnapshots[FixedTreeReference(host, $"Axes/{axisSegment}/DistanceToGo")] = snap.DistanceToGo.ToEngineering(dp) ?? NoValue;
}
/// <summary>
/// Resolve the position-scale factor (10^figure) for a single axis. Auto
/// (<c>cnc_getfigure</c>) wins per-axis; manual <c>PositionDecimalPlaces</c> is the
/// fallback when the CNC didn't report a figure for that axis. Both clamp non-negative;
/// 0 ⇒ factor 1.0 (legacy byte-identical). The managed wire backend fetches figures live
/// via <c>ReadPositionFiguresAsync</c>; the fallback branch fires only when the CNC
/// reports no figure for a given axis (or the figure read returns empty).
/// Cache sentinel for "the CNC answered, but this field holds no usable value" (an
/// undescribable position / meter record). The read path turns it into
/// <see cref="FocasStatusMapper.BadNoData"/> for that node only — never a Good number.
/// </summary>
private static double AxisFactor(DeviceState state, int axisIndex)
{
var figures = state.PositionFigures;
var dp = axisIndex >= 0 && axisIndex < figures.Count && figures[axisIndex] >= 0
? figures[axisIndex]
: Math.Max(0, state.Options.PositionDecimalPlaces);
return dp > 0 ? Math.Pow(10, dp) : 1.0;
}
private const double NoValue = double.NaN;
/// <summary>Builds the snapshot for one cached double, mapping <see cref="NoValue"/> to BadNoData.</summary>
private static DataValueSnapshot CachedDouble(double value, uint status, DateTime sourceUtc, DateTime now) =>
double.IsNaN(value)
? new DataValueSnapshot(null, status == FocasStatusMapper.Good ? FocasStatusMapper.BadNoData : status, sourceUtc, now)
: new DataValueSnapshot(value, status, sourceUtc, now);
private static void PublishRateSnapshot(DeviceState state, FocasDynamicSnapshot snap)
{
@@ -1160,7 +1146,27 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
{
var segments = FixedTreeNames.Disambiguate(loads.Select(l => l.AxisName));
for (var i = 0; i < loads.Count; i++)
state.LastServoLoads[segments[i]] = loads[i].LoadPercent;
state.LastServoLoads[segments[i]] = loads[i].LoadPercent ?? NoValue;
}
/// <summary>
/// Read and cache both spindle meters (load, motor speed) — each a <c>cnc_rdspmeter</c>
/// percentage of full scale (16384 = 100%) — for every spindle slot, as capability allows.
/// A slot whose record describes no value is cached as <see cref="NoValue"/> (BadNoData).
/// </summary>
private static async Task PollSpindleMetersAsync(DeviceState state, IFocasClient client, CancellationToken ct)
{
var caps = state.FixedTreeCache?.Capabilities;
if (caps is { SpindleLoad: true })
{
var loads = await client.GetSpindleLoadsAsync(ct).ConfigureAwait(false);
for (var i = 0; i < loads.Count; i++) state.LastSpindleLoads[i] = loads[i] ?? NoValue;
}
if (caps is { SpindleMotorSpeed: true })
{
var speeds = await client.GetSpindleMotorSpeedsAsync(ct).ConfigureAwait(false);
for (var i = 0; i < speeds.Count; i++) state.LastSpindleMotorSpeeds[i] = speeds[i] ?? NoValue;
}
}
private static object? TimerValue(DeviceState state, FocasTimerKind kind) =>
@@ -1241,7 +1247,7 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
if (state.LastFixedSnapshots.TryGetValue(
FixedTreeReference(state.Options.HostAddress, path), out var raw))
return new DataValueSnapshot(raw, status, sourceUtc, now);
return CachedDouble(raw, status, sourceUtc, now);
// Servo-load match: path shape is "Axes/{name}/ServoLoad"
var suffixFull = path;
@@ -1249,10 +1255,10 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
{
var axisName = suffixFull["Axes/".Length..^"/ServoLoad".Length];
if (state.LastServoLoads.TryGetValue(axisName, out var load))
return new DataValueSnapshot(load, status, sourceUtc, now);
return CachedDouble(load, status, sourceUtc, now);
}
// Spindle matches: "Spindle/{name}/Load" + "Spindle/{name}/MaxRpm"
// Spindle matches: "Spindle/{name}/Load" + "Spindle/{name}/MotorSpeedPct"
if (suffixFull.StartsWith("Spindle/", StringComparison.Ordinal)
&& state.FixedTreeCache is { } spindleCache)
{
@@ -1271,14 +1277,14 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
}
if (idx >= 0)
{
object? value = field switch
var meters = field switch
{
"Load" => state.LastSpindleLoads.TryGetValue(idx, out var l) ? (object)l : null,
"MaxRpm" => idx < spindleCache.SpindleMaxRpms.Count ? (object)spindleCache.SpindleMaxRpms[idx] : null,
"Load" => state.LastSpindleLoads,
"MotorSpeedPct" => state.LastSpindleMotorSpeeds,
_ => null,
};
if (value is not null)
return new DataValueSnapshot(value, status, sourceUtc, now);
if (meters is not null && meters.TryGetValue(idx, out var pct))
return CachedDouble(pct, status, sourceUtc, now);
}
}
}
@@ -1301,8 +1307,8 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
"Program/MainNumber" => state.LastProgramAxisRef?.MainProgramNumber,
"Program/Sequence" => state.LastProgramAxisRef?.SequenceNumber,
"OperationMode/Mode" => state.LastProgramInfo?.Mode,
"OperationMode/ModeText" => state.LastProgramInfo is { } pi
? FocasOpMode.ToText(pi.Mode) : null,
"OperationMode/ModeText" => state.LastProgramInfo?.Mode is { } mode
? FocasOpMode.ToText(mode) : null,
"Timers/PowerOnSeconds" => TimerValue(state, FocasTimerKind.PowerOn),
"Timers/OperatingSeconds" => TimerValue(state, FocasTimerKind.Operating),
"Timers/CuttingSeconds" => TimerValue(state, FocasTimerKind.Cutting),
@@ -1311,6 +1317,18 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
};
if (value is not null)
return new DataValueSnapshot(value, status, sourceUtc, now);
// The program bundle has been read, but the CNC did not report this field (a refused
// sub-read, or no O-number in the payload). That is Bad — never a fabricated 0, and
// not "no such node" either.
if (state.LastProgramInfo is not null
&& path is "Program/Name" or "Program/ONumber" or "Program/BlockCount"
or "OperationMode/Mode" or "OperationMode/ModeText")
return new DataValueSnapshot(
null,
status == FocasStatusMapper.Good ? FocasStatusMapper.BadNoData : status,
sourceUtc,
now);
}
return null;
}
@@ -1489,7 +1507,6 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
FocasSysInfo SysInfo,
IReadOnlyList<FocasAxisName> Axes,
IReadOnlyList<FocasSpindleName> Spindles,
IReadOnlyList<int> SpindleMaxRpms,
FocasFixedTreeCapabilities Capabilities)
{
/// <summary>
@@ -1515,14 +1532,14 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
/// <summary>
/// Per-device optional-API capability flags — which of the "this may or may not
/// exist on this CNC series" calls succeeded at bootstrap. Drives per-series
/// node suppression so a 16i that doesn't export <c>cnc_rdspmaxrpm</c> simply
/// doesn't get a <c>Spindle/{name}/MaxRpm</c> node (instead of surfacing
/// node suppression so a 16i that doesn't export <c>cnc_rdspmeter</c> simply
/// doesn't get a <c>Spindle/{name}/MotorSpeedPct</c> node (instead of surfacing
/// <c>BadDeviceFailure</c> on every read).
/// </summary>
internal sealed record FocasFixedTreeCapabilities(
bool Spindles, // cnc_rdspdlname returned 1+ spindle names
bool SpindleLoad, // cnc_rdspload bootstrap probe succeeded
bool SpindleMaxRpm, // cnc_rdspmaxrpm bootstrap probe succeeded
bool SpindleLoad, // cnc_rdspmeter type 0 (load) bootstrap probe succeeded
bool SpindleMotorSpeed, // cnc_rdspmeter type 1 (motor speed) bootstrap probe succeeded
bool ServoLoad, // cnc_rdsvmeter bootstrap probe returned data
bool ProgramInfo, // cnc_exeprgname2 + cnc_rdblkcount + cnc_rdopmode work
bool Timers); // cnc_rdtimer works for at least PowerOn
@@ -1578,9 +1595,10 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
public DateTime? LastFixedPollUtc { get; set; }
/// <summary>
/// Gets the last fixed-tree snapshots by field name. Double-typed so axis
/// positions can carry the <c>10^PositionDecimalPlaces</c> engineering-unit
/// scale applied at <see cref="PublishAxisSnapshot"/>; integer fields
/// (feed rate, spindle speed) widen to double on store.
/// positions can carry the engineering-unit scale applied at
/// <see cref="PublishAxisSnapshot"/>; integer fields (feed rate, spindle speed) widen
/// to double on store. <see cref="NoValue"/> (NaN) marks a field the CNC described as
/// holding no value.
/// </summary>
public ConcurrentDictionary<string, double> LastFixedSnapshots { get; } = new(StringComparer.OrdinalIgnoreCase);
/// <summary>Gets or sets the last program information snapshot.</summary>
@@ -1591,15 +1609,16 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
public ConcurrentDictionary<FocasTimerKind, FocasTimer> LastTimers { get; } = new();
/// <summary>Gets the last servo load percentages by servo name.</summary>
public ConcurrentDictionary<string, double> LastServoLoads { get; } = new(StringComparer.OrdinalIgnoreCase);
/// <summary>Gets the last spindle load percentages by spindle index.</summary>
public ConcurrentDictionary<int, int> LastSpindleLoads { get; } = new();
/// <summary>
/// Gets or sets the per-axis position decimal-place figures fetched once at init via
/// <c>cnc_getfigure</c> (parallel to the axis-name list; index = axis). An auto figure
/// for an axis WINS over the configured <c>PositionDecimalPlaces</c>; an empty list (or
/// a failed/empty figure read) makes every axis fall back to that config knob.
/// Gets the last spindle load percentages (% of rated load) by spindle index;
/// <see cref="NoValue"/> for a slot the CNC described as holding none.
/// </summary>
public IReadOnlyList<int> PositionFigures { get; set; } = [];
public ConcurrentDictionary<int, double> LastSpindleLoads { get; } = new();
/// <summary>
/// Gets the last spindle motor-speed percentages (% of maximum speed) by spindle index;
/// <see cref="NoValue"/> for a slot the CNC described as holding none.
/// </summary>
public ConcurrentDictionary<int, double> LastSpindleMotorSpeeds { get; } = new();
/// <summary>Disposes the FOCAS client instance.</summary>
public void DisposeClient()
@@ -290,9 +290,9 @@ public static class FocasDriverFactoryExtensions
public string? Series { get; init; }
/// <summary>
/// Gets or sets the axis-position decimal places. <c>cnc_rddynamic2</c> returns
/// positions as scaled integers; the driver divides by <c>10^PositionDecimalPlaces</c>
/// so they surface in engineering units. Omitted / <c>0</c> = no scaling (legacy).
/// Gets or sets the fallback axis-position decimal places, applied only to a
/// <c>cnc_rddynamic2</c> position block that carries no scale of its own (a real CNC's
/// block does, and its scale wins). Omitted / <c>0</c> = no scaling.
/// </summary>
public int? PositionDecimalPlaces { get; init; }
}
@@ -19,6 +19,14 @@ public static class FocasStatusMapper
public const uint BadTimeout = 0x800A0000u;
public const uint BadTypeMismatch = 0x80740000u;
/// <summary>
/// The CNC answered, but its answer describes no usable value — a vacant macro variable, a
/// position / meter record whose own scale envelope is undecodable, a field the control did
/// not report. Distinct from a comms fault (the device is fine) and never a fabricated
/// Good <c>0</c>.
/// </summary>
public const uint BadNoData = 0x809B0000u;
/// <summary>
/// Map common FWLIB <c>EW_*</c> return codes. The values below match Fanuc's published
/// numeric conventions (EW_OK=0, EW_FUNC=1, EW_NUMBER=3, EW_LENGTH=4, EW_ATTRIB=7,
@@ -140,9 +140,10 @@ public interface IFocasClient : IDisposable
// ---- Fixed-tree T3.5 (servo meters) ----
/// <summary>
/// Read the servo-load meter percentages across all configured axes.
/// Values are percentages (scaled by <c>10^Dec</c>). Empty list on a
/// disconnected session or unsupported CNC.
/// Read the servo-load meter percentages across all configured axes. Each record's own
/// <c>value / base^exponent</c> envelope is applied (servo loads are direct percentages);
/// a record whose envelope describes no value carries a null <see cref="FocasServoLoad.LoadPercent"/>.
/// Empty list on a disconnected session or unsupported CNC.
/// </summary>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>The servo-load meter percentages across all configured axes.</returns>
@@ -151,36 +152,31 @@ public interface IFocasClient : IDisposable
// ---- Fixed-tree T3.6 (spindle meters) ----
/// <summary>
/// Read per-spindle load percentages. Result list index corresponds to
/// spindle index from <see cref="GetSpindleNamesAsync"/>. Empty list on a
/// disconnected session or when the CNC doesn't support the call (older
/// series like 16i may return EW_FUNC).
/// Read per-spindle load as a percentage of rated load (<c>cnc_rdspmeter</c> type 0; the
/// raw meter is a 1/16384 fraction of full scale). Result list index corresponds to
/// spindle index from <see cref="GetSpindleNamesAsync"/>; a slot whose record describes no
/// value is null. Empty list on a disconnected session or when the CNC doesn't support the
/// call (older series like 16i may return EW_FUNC).
/// </summary>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>Per-spindle load percentages, indexed as per <see cref="GetSpindleNamesAsync"/>.</returns>
Task<IReadOnlyList<int>> GetSpindleLoadsAsync(CancellationToken cancellationToken);
Task<IReadOnlyList<double?>> GetSpindleLoadsAsync(CancellationToken cancellationToken);
/// <summary>
/// Read per-spindle maximum RPM values. Static configuration, fetched once at
/// bootstrap. Index alignment as per <see cref="GetSpindleLoadsAsync"/>.
/// Read per-spindle motor speed as a percentage of maximum speed (<c>cnc_rdspmeter</c>
/// type 1, historically labelled <c>cnc_rdspmaxrpm</c>). A live value, not a max-RPM
/// constant. Index alignment and null semantics as per <see cref="GetSpindleLoadsAsync"/>.
/// </summary>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>Per-spindle maximum RPM values, indexed as per <see cref="GetSpindleLoadsAsync"/>.</returns>
Task<IReadOnlyList<int>> GetSpindleMaxRpmsAsync(CancellationToken cancellationToken);
/// <summary>
/// Read the per-axis position decimal-place figure via <c>cnc_getfigure</c>. The returned
/// list is parallel to <see cref="GetAxisNamesAsync"/> (index = axis). An EMPTY list means the
/// CNC/backend does not report figures — the driver then falls back to the configured
/// <c>PositionDecimalPlaces</c>. Values are clamped non-negative.
/// </summary>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>Per-axis position decimal-place figures; empty when the CNC/backend doesn't report them.</returns>
Task<IReadOnlyList<int>> GetPositionFiguresAsync(CancellationToken cancellationToken);
/// <returns>Per-spindle motor-speed percentages, indexed as per <see cref="GetSpindleLoadsAsync"/>.</returns>
Task<IReadOnlyList<double?>> GetSpindleMotorSpeedsAsync(CancellationToken cancellationToken);
}
/// <summary>One servo-meter entry — one axis's current load percentage.</summary>
public sealed record FocasServoLoad(string AxisName, double LoadPercent);
/// <summary>
/// One servo-meter entry — one axis's current load percentage; null when the CNC's record
/// described no usable value (never a fabricated 0).
/// </summary>
public sealed record FocasServoLoad(string AxisName, double? LoadPercent);
/// <summary>Which cumulative counter <see cref="IFocasClient.GetTimerAsync"/> reads.</summary>
public enum FocasTimerKind
@@ -238,22 +234,19 @@ public sealed record FocasSpindleName(string Name, string Suffix1, string Suffix
}
}
/// <summary>
/// Fast-poll bundle for one axis. Position values are scaled integers; the caller
/// divides by <c>10^DecimalPlaces</c> to get the decimal value. DecimalPlaces is
/// supplied by the caller — either via device config (<c>PositionDecimalPlaces</c>) or
/// the per-axis <see cref="IFocasClient.GetPositionFiguresAsync"/> (<c>cnc_getfigure</c>) path.
/// </summary>
/// <summary>
/// Program + operation-mode snapshot. Name is the currently-executing
/// program filename (e.g. "O0001.NC"); ONumber is its Fanuc O-number (1-9999).
/// Mode is the numeric code from <c>cnc_rdopmode</c> — see <see cref="FocasOpMode"/>.
/// <b>A null field means the CNC did not report it</b> (its sub-read was refused, or the
/// payload was too short to carry it); the driver publishes that node Bad. It is never
/// defaulted to <c>0</c>, which is a genuine O-number, block count, and mode (MDI).
/// </summary>
public sealed record FocasProgramInfo(
string Name,
int ONumber,
int BlockCount,
int Mode);
string? Name,
int? ONumber,
int? BlockCount,
int? Mode);
/// <summary>Human-readable text for the <see cref="FocasProgramInfo.Mode"/> integer.</summary>
public static class FocasOpMode
@@ -277,6 +270,10 @@ public static class FocasOpMode
};
}
/// <summary>
/// Fast-poll bundle for one axis (<c>cnc_rddynamic2</c>). Each position carries its own scale
/// (see <see cref="FocasPosition"/>); the driver converts it to engineering units at publish.
/// </summary>
public sealed record FocasDynamicSnapshot(
int AxisIndex,
int AlarmFlags,
@@ -285,10 +282,60 @@ public sealed record FocasDynamicSnapshot(
int SequenceNumber,
int ActualFeedRate,
int ActualSpindleSpeed,
int AbsolutePosition,
int MachinePosition,
int RelativePosition,
int DistanceToGo);
FocasPosition AbsolutePosition,
FocasPosition MachinePosition,
FocasPosition RelativePosition,
FocasPosition DistanceToGo);
/// <summary>
/// One <c>cnc_rddynamic2</c> position (command <c>0x0026</c>) as the CNC reported it. The 8-byte
/// wire block is self-describing — <c>[0..3] i32 raw</c>, <c>[4..5] u16 base</c> (2 or 10),
/// <c>[6..7] u16 exponent</c> (<c>0xFFFF</c> = the slot holds no valid position) — and the real
/// coordinate is <c>raw / base^exponent</c>. Confirmed on a real FANUC 31i-B: every block of the
/// <c>ref-dynamic2-axis1.bin</c> capture reports base 10 / exponent 4 (280.1574 mm, not
/// 2801574). That envelope is why no separate figure read exists: the <c>cnc_getfigure</c>
/// (0x00D3) this driver used to send is not a FOCAS wire command, and the 31i-B refuses it.
/// </summary>
/// <param name="Raw">The raw i32 position value.</param>
/// <param name="Base">The block's scale base (only 2 and 10 are meaningful).</param>
/// <param name="Exponent">The block's scale exponent.</param>
/// <param name="HasScale">
/// True when the block carried the base/exponent envelope. A scale-less block (a 4-byte payload
/// from an older simulator, or a raw integer a test supplies) falls back to the device's
/// configured <c>PositionDecimalPlaces</c>.
/// </param>
public readonly record struct FocasPosition(int Raw, int Base = 0, int Exponent = 0, bool HasScale = false)
{
/// <summary>
/// False when the block says it holds no usable position: the <c>0xFFFF</c> vacant marker, a
/// base other than 2 or 10, or an absurd exponent. A per-node quality decision — that node
/// publishes Bad, its siblings are unaffected.
/// </summary>
/// <remarks>
/// The exponent bound matters: left unbounded, <c>raw / 10^400</c> is
/// <c>raw / Infinity = 0.0</c> — a Good reading of a machine parked at the origin.
/// </remarks>
public bool IsValid => !HasScale || Wire.FocasValueEnvelope.IsValid(Base, Exponent);
/// <summary>
/// The position in engineering units: <c>Raw / Base^Exponent</c> when the block carried its
/// own scale (the configured fallback is then ignored), else <c>Raw / 10^fallbackDecimalPlaces</c>.
/// Null when <see cref="IsValid"/> is false.
/// </summary>
/// <param name="fallbackDecimalPlaces">The device's configured <c>PositionDecimalPlaces</c>.</param>
/// <returns>The scaled position, or null when the block describes none.</returns>
public double? ToEngineering(int fallbackDecimalPlaces)
{
if (!IsValid) return null;
if (HasScale) return Wire.FocasValueEnvelope.Scale(Raw, Base, Exponent);
var dp = Math.Max(0, fallbackDecimalPlaces);
return dp > 0 ? Raw / Math.Pow(10, dp) : Raw;
}
/// <summary>A bare integer is a scale-less position (legacy shape; tests and fakes).</summary>
/// <param name="raw">The raw position value.</param>
public static implicit operator FocasPosition(int raw) => new(raw);
}
/// <summary>
/// One active alarm surfaced by <see cref="IFocasClient.ReadAlarmsAsync"/>. Shape
@@ -99,16 +99,12 @@ public sealed class SynchronizedFocasClient : IFocasClient
RunBoundedAsync(ct => _inner.GetServoLoadsAsync(ct), cancellationToken);
/// <inheritdoc />
public Task<IReadOnlyList<int>> GetSpindleLoadsAsync(CancellationToken cancellationToken) =>
public Task<IReadOnlyList<double?>> GetSpindleLoadsAsync(CancellationToken cancellationToken) =>
RunBoundedAsync(ct => _inner.GetSpindleLoadsAsync(ct), cancellationToken);
/// <inheritdoc />
public Task<IReadOnlyList<int>> GetSpindleMaxRpmsAsync(CancellationToken cancellationToken) =>
RunBoundedAsync(ct => _inner.GetSpindleMaxRpmsAsync(ct), cancellationToken);
/// <inheritdoc />
public Task<IReadOnlyList<int>> GetPositionFiguresAsync(CancellationToken cancellationToken) =>
RunBoundedAsync(ct => _inner.GetPositionFiguresAsync(ct), cancellationToken);
public Task<IReadOnlyList<double?>> GetSpindleMotorSpeedsAsync(CancellationToken cancellationToken) =>
RunBoundedAsync(ct => _inner.GetSpindleMotorSpeedsAsync(ct), cancellationToken);
/// <summary>Disposes the inner client and the call gate.</summary>
public void Dispose()
@@ -339,21 +339,59 @@ public sealed class FocasWireClient : IAsyncDisposable, IDisposable
var rc = AggregateRc(blocks);
if (rc != 0) return new FocasResult<WireDynamic>(rc, null);
return new FocasResult<WireDynamic>(rc, ParseDynamic2(blocks));
}
/// <summary>
/// Aggregate the <c>cnc_rddynamic2</c> response blocks of one axis read into a
/// <see cref="WireDynamic"/>. The four <c>0x0026</c> blocks are taken in request order
/// (absolute / machine / relative / distance-to-go), each decoded with its own scale by
/// <see cref="ParsePositionBlock"/>.
/// </summary>
/// <param name="blocks">The response blocks of one <see cref="ReadDynamic2Async"/> request.</param>
/// <returns>The decoded fast-poll bundle.</returns>
internal static WireDynamic ParseDynamic2(IReadOnlyList<ResponseBlock> blocks)
{
var programPayload = FindPayload(blocks, 0x001c);
return new FocasResult<WireDynamic>(
rc,
new WireDynamic(
ReadFirstInt32(blocks, 0x001a),
programPayload.Length >= 4 ? ReadInt32(programPayload, 0) : 0,
programPayload.Length >= 8 ? ReadInt32(programPayload, 4) : 0,
ReadFirstInt32(blocks, 0x001d),
ReadFirstInt32(blocks, 0x0024),
ReadFirstInt32(blocks, 0x0025),
new WireAxisPosition(
ReadSelectorPosition(blocks, 0x0026, 0),
ReadSelectorPosition(blocks, 0x0026, 1),
ReadSelectorPosition(blocks, 0x0026, 2),
ReadSelectorPosition(blocks, 0x0026, 3))));
return new WireDynamic(
ReadFirstInt32(blocks, 0x001a),
programPayload.Length >= 4 ? ReadInt32(programPayload, 0) : 0,
programPayload.Length >= 8 ? ReadInt32(programPayload, 4) : 0,
ReadFirstInt32(blocks, 0x001d),
ReadFirstInt32(blocks, 0x0024),
ReadFirstInt32(blocks, 0x0025),
new WireAxisPosition(
ReadSelectorPosition(blocks, 0x0026, 0),
ReadSelectorPosition(blocks, 0x0026, 1),
ReadSelectorPosition(blocks, 0x0026, 2),
ReadSelectorPosition(blocks, 0x0026, 3)));
}
/// <summary>
/// Decode one <c>cnc_rddynamic2</c> position block (0x0026). The 8-byte block is
/// self-describing — <c>[0..3] i32 raw</c>, <c>[4..5] u16 base</c>, <c>[6..7] u16 exponent</c>,
/// value = <c>raw / base^exponent</c> — pinned on a real FANUC 31i-B (every block of
/// <c>ref-dynamic2-axis1.bin</c> reports base 10 / exponent 4). A 4..7-byte block carries no
/// envelope and is returned scale-less (the driver then applies the configured
/// <c>PositionDecimalPlaces</c>); a shorter one decodes as a scale-less 0, as before.
/// </summary>
/// <remarks>
/// Validity (vacant <c>0xFFFF</c> exponent, a base other than 2/10, an absurd exponent) is a
/// per-node quality decision made by <see cref="FocasPosition.IsValid"/>, not a fault here:
/// raising would let one empty slot take out the whole poll. Ported from the sister
/// ignitionoee driver v0.17.0 (<c>Dynamic2.scaledPosition</c>, Focas.Wire-014).
/// </remarks>
/// <param name="payload">The 0x0026 response-block payload.</param>
/// <returns>The position with whatever scale the block carried.</returns>
internal static FocasPosition ParsePositionBlock(byte[] payload)
{
if (payload.Length >= 8)
return new FocasPosition(
ReadInt32(payload, 0),
BinaryPrimitives.ReadUInt16BigEndian(payload.AsSpan(4, 2)),
BinaryPrimitives.ReadUInt16BigEndian(payload.AsSpan(6, 2)),
HasScale: true);
return new FocasPosition(payload.Length >= 4 ? ReadInt32(payload, 0) : 0);
}
/// <summary>Read servo-meter load percentages via <c>cnc_rdsvmeter</c> (command <c>0x0056</c>).</summary>
@@ -383,47 +421,14 @@ public sealed class FocasWireClient : IAsyncDisposable, IDisposable
}
/// <summary>
/// Read the per-axis position decimal-place figures via <c>cnc_getfigure</c>
/// (command <c>0x00d3</c>). The response payload is a self-delimiting sequence of
/// big-endian <c>short</c> decimal-place counts — one per configured axis — which the
/// driver applies as a <c>10^figure</c> scale on raw position reads.
/// Read the per-spindle load meter via <c>cnc_rdspmeter</c> (command <c>0x0040</c>, type 0).
/// <see cref="WireSpindleMetric.Percent"/> is % of rated load (16384 = 100%).
/// </summary>
/// <remarks>
/// The <c>0x00d3</c> command id is co-designed with the in-tree <c>focas_mock</c> sim and
/// validated against it; like the rest of this managed wire backend the binary shape is
/// sim-consistent and has not been validated against a real Fanuc CNC (bench-CNC-gated).
/// </remarks>
/// <param name="cancellationToken">Cancellation token for the read operation.</param>
/// <param name="timeout">Optional per-call timeout override.</param>
/// <param name="pathId">Optional path ID override; defaults to <see cref="PathId"/>.</param>
/// <returns>The per-axis position decimal-place figures, wrapped in a <see cref="FocasResult{T}"/>.</returns>
public async Task<FocasResult<IReadOnlyList<int>>> ReadPositionFiguresAsync(
CancellationToken cancellationToken = default,
TimeSpan? timeout = null,
ushort? pathId = null)
{
using var callTimeout = CreateCallTimeout(cancellationToken, timeout);
var requestPathId = EffectivePathId(pathId);
var block = await SendSingleRequestAsync(
callTimeout.Token,
new RequestBlock(0x00d3, PathId: requestPathId)).ConfigureAwait(false);
if (block.Rc != 0) return new FocasResult<IReadOnlyList<int>>(block.Rc, null);
var payload = block.Payload;
var figures = new List<int>(payload.Length / 2);
for (var offset = 0; offset + 2 <= payload.Length; offset += 2)
figures.Add(ReadInt16(payload, offset));
return new FocasResult<IReadOnlyList<int>>(block.Rc, figures);
}
/// <summary>Read per-spindle load percentages via <c>cnc_rdspload</c> (command <c>0x0040</c> with arg1=0).</summary>
/// <param name="spindleSelector">Spindle selector; -1 selects all spindles.</param>
/// <param name="cancellationToken">Cancellation token for the read operation.</param>
/// <param name="timeout">Optional per-call timeout override.</param>
/// <param name="pathId">Optional path ID override; defaults to <see cref="PathId"/>.</param>
/// <returns>The per-spindle load percentages, wrapped in a <see cref="FocasResult{T}"/>.</returns>
/// <returns>The per-spindle load meters, wrapped in a <see cref="FocasResult{T}"/>.</returns>
public Task<FocasResult<IReadOnlyList<WireSpindleMetric>>> ReadSpindleLoadAsync(
short spindleSelector = -1,
CancellationToken cancellationToken = default,
@@ -431,13 +436,19 @@ public sealed class FocasWireClient : IAsyncDisposable, IDisposable
ushort? pathId = null)
=> ReadSpindleMetricAsync(0, spindleSelector, cancellationToken, timeout, pathId);
/// <summary>Read per-spindle maximum RPMs via <c>cnc_rdspmaxrpm</c> (command <c>0x0040</c> with arg1=1).</summary>
/// <summary>
/// Read the per-spindle motor-speed meter via <c>cnc_rdspmeter</c> (command <c>0x0040</c>,
/// type 1 — historically labelled <c>cnc_rdspmaxrpm</c>). It is NOT a maximum-RPM constant:
/// it is the live motor speed as a fraction of maximum, <see cref="WireSpindleMetric.Percent"/>
/// (a regression against <c>cnc_rddynamic2</c> spindle speed on a real 31i-B gives
/// <c>rpm = 1.220749 × meter</c>, i.e. 16384 = the spindle's 20,000 rpm maximum).
/// </summary>
/// <param name="spindleSelector">Spindle selector; -1 selects all spindles.</param>
/// <param name="cancellationToken">Cancellation token for the read operation.</param>
/// <param name="timeout">Optional per-call timeout override.</param>
/// <param name="pathId">Optional path ID override; defaults to <see cref="PathId"/>.</param>
/// <returns>The per-spindle maximum RPMs, wrapped in a <see cref="FocasResult{T}"/>.</returns>
public Task<FocasResult<IReadOnlyList<WireSpindleMetric>>> ReadSpindleMaxRpmAsync(
/// <returns>The per-spindle motor-speed meters, wrapped in a <see cref="FocasResult{T}"/>.</returns>
public Task<FocasResult<IReadOnlyList<WireSpindleMetric>>> ReadSpindleMotorSpeedAsync(
short spindleSelector = -1,
CancellationToken cancellationToken = default,
TimeSpan? timeout = null,
@@ -577,9 +588,33 @@ public sealed class FocasWireClient : IAsyncDisposable, IDisposable
ushort? pathId = null)
=> ReadSingleWithTimeoutAsync(
0x0015,
payload => new WireMacro(number, payload.Length >= 4 ? ReadInt32(payload, 0) : 0, payload.Length >= 6 ? ReadInt16(payload, 4) : (short)0),
payload => ParseMacro(number, payload),
cancellationToken, timeout, EffectivePathId(pathId), number, number);
/// <summary>
/// Decode a <c>cnc_rdmacro</c> (0x0015) payload. The on-wire <c>ODBM</c> record is 8
/// big-endian bytes: <c>[0..3] i32 mantissa</c>, <c>[4..5] i16</c> length field (a
/// <b>constant 10</b> on every observed response), <c>[6..7] i16 dec_val</c>
/// (<c>0xFFFF</c> = vacant).
/// </summary>
/// <remarks>
/// The middle field is the trap: it is not the decimal count but looks like a plausible one,
/// so reading the decimal at offset 4 decoded every macro on every control without ever
/// failing — just wrongly (the captured <c>#500 = 1.0</c> published <c>0.01</c>). Pinned by
/// a live Makino 31i-B capture; found and fixed by the sister ignitionoee driver
/// (Focas.Wire-013, 2026-08-05). A payload shorter than 8 bytes carries no
/// <c>dec_val</c> and is refused rather than guessed at.
/// </remarks>
/// <param name="number">The macro number the request asked for.</param>
/// <param name="payload">The response-block payload.</param>
/// <returns>The decoded macro.</returns>
/// <exception cref="FocasWireException">The payload is shorter than 8 bytes.</exception>
internal static WireMacro ParseMacro(short number, byte[] payload)
{
RequireLength(payload, 8, "cnc_rdmacro");
return new WireMacro(number, ReadInt32(payload, 0), ReadInt16(payload, 6));
}
/// <summary>
/// Read a PMC range via <c>pmc_rdpmcrng</c>. <paramref name="area"/> is the numeric
/// address-letter code (see <see cref="FocasPmcArea"/>); <paramref name="dataType"/>
@@ -745,13 +780,27 @@ public sealed class FocasWireClient : IAsyncDisposable, IDisposable
callTimeout.Token,
new RequestBlock(0x0040, metric, spindleSelector, PathId: EffectivePathId(pathId))).ConfigureAwait(false);
return ToResult<IReadOnlyList<WireSpindleMetric>>(block, payload =>
{
var values = new List<WireSpindleMetric>();
for (var offset = 0; offset + 8 <= payload.Length; offset += 8)
values.Add(new WireSpindleMetric((short)(values.Count + 1), ReadInt32(payload, offset)));
return values;
});
return ToResult(block, ParseSpindleMetrics);
}
/// <summary>
/// Decode a <c>cnc_rdspmeter</c> (0x0040) payload: 8-byte records of
/// <c>[+0..3] i32 value</c>, <c>[+4..5] u16 base</c>, <c>[+6..7] u16 exponent</c>, one per
/// slot of a fixed multi-slot buffer (slots past the spindle count are residue and decode
/// as <see cref="WireSpindleMetric.IsValid"/> = false). Every slot is returned, in wire order.
/// </summary>
/// <param name="payload">The response-block payload.</param>
/// <returns>The per-slot meters.</returns>
internal static IReadOnlyList<WireSpindleMetric> ParseSpindleMetrics(byte[] payload)
{
var values = new List<WireSpindleMetric>();
for (var offset = 0; offset + 8 <= payload.Length; offset += 8)
values.Add(new WireSpindleMetric(
(short)(values.Count + 1),
ReadInt32(payload, offset),
BinaryPrimitives.ReadUInt16BigEndian(payload.AsSpan(offset + 4, 2)),
BinaryPrimitives.ReadUInt16BigEndian(payload.AsSpan(offset + 6, 2))));
return values;
}
private async Task<FocasResult<T>> ReadSingleAsync<T>(
@@ -932,18 +981,16 @@ public sealed class FocasWireClient : IAsyncDisposable, IDisposable
payload.Length >= 8 ? BinaryPrimitives.ReadInt32LittleEndian(payload.AsSpan(4, 4)) : 0);
/// <summary>
/// Decode a <c>cnc_rdsvmeter</c> response into <see cref="WireServoMeter"/> records. On the
/// 31i-B (v3) each per-axis LOADELM is <b>8 bytes</b> — {int32 data; int16 dec; int16 unit}
/// — NOT the 12-byte shape the original parser assumed. The 12-byte stride misaligned after
/// the first record (it read the dec/unit shorts of the prior record as the next record's
/// data → wild values like 655360). Axis NAMES are not in the svmeter payload; they come
/// from the <c>cnc_rdaxisname</c> (0x0089) block requested alongside it and are correlated
/// by index. Validated against a live 31i-B 2026-06-25.
/// <para><b>Scaling caveat:</b> downstream applies LoadPercent = data / 10^dec; on the 31i-B
/// <c>dec</c> read as 10, which makes idle loads vanishingly small. The data/dec/unit field
/// semantics for servo load are inferred from the wire and not yet confirmed against the
/// machine's servo-meter screen — confirm magnitude at commissioning. The alignment + name
/// fix here is what removes the gross misaligned garbage.</para>
/// Decode a <c>cnc_rdsvmeter</c> response into <see cref="WireServoMeter"/> records. Each
/// per-axis record is <b>8 bytes</b> — <c>[+0..3] i32 value</c>, <c>[+4..5] u16 base</c>,
/// <c>[+6..7] u16 exponent</c> — the same self-describing envelope as the position blocks,
/// so the load is <c>value / base^exponent</c>, a direct percentage. (A 12-byte stride
/// misaligned after the first record; reading offset 4 as a decimal count divided every load
/// by 10^10 — both fixed against live 31i-B captures, the latter by the sister ignitionoee
/// driver, Focas.Wire-019.) Axis NAMES are not in the svmeter payload; they come from the
/// <c>cnc_rdaxisname</c> (0x0089) block requested alongside it and are correlated by index.
/// The payload is a fixed 32-slot buffer: slots past the controlled-axis count are residue
/// and decode as <see cref="WireServoMeter.IsValid"/> = false.
/// </summary>
/// <param name="svPayload">The raw <c>cnc_rdsvmeter</c> response payload.</param>
/// <param name="axisNamePayload">The raw <c>cnc_rdaxisname</c> response payload used to correlate axis names by index.</param>
@@ -963,8 +1010,8 @@ public sealed class FocasWireClient : IAsyncDisposable, IDisposable
(short)(index + 1),
name,
ReadInt32(svPayload, offset),
ReadInt16(svPayload, offset + 4),
ReadInt16(svPayload, offset + 6)));
BinaryPrimitives.ReadUInt16BigEndian(svPayload.AsSpan(offset + 4, 2)), // BASE, not a decimal
BinaryPrimitives.ReadUInt16BigEndian(svPayload.AsSpan(offset + 6, 2))));
}
return result;
}
@@ -1020,17 +1067,17 @@ public sealed class FocasWireClient : IAsyncDisposable, IDisposable
return payload.Length >= 4 ? ReadInt32(payload, 0) : 0;
}
private static int ReadSelectorPosition(IReadOnlyList<ResponseBlock> blocks, ushort command, int selectorIndex)
private static FocasPosition ReadSelectorPosition(IReadOnlyList<ResponseBlock> blocks, ushort command, int selectorIndex)
{
var seen = 0;
foreach (var block in blocks)
{
if (block.Command != command) continue;
if (seen == selectorIndex)
return block.Payload.Length >= 4 ? ReadInt32(block.Payload, 0) : 0;
return ParsePositionBlock(block.Payload);
seen++;
}
return 0;
return new FocasPosition(0);
}
private static WireSysInfo ParseSysInfo(byte[] payload)
@@ -1090,7 +1137,17 @@ public sealed class FocasWireClient : IAsyncDisposable, IDisposable
return alarms;
}
private static IReadOnlyList<T> ReadNameRecords<T>(byte[] payload, short maxCount, Func<short, string, T> factory)
/// <summary>
/// Decode an axis-name (0x0089) or spindle-name (0x008A) payload: an array of 4-byte records,
/// each named by <see cref="FocasWireProtocol.ReadNameRecord"/>. Blank records are skipped but
/// keep their slot's 1-based index.
/// </summary>
/// <typeparam name="T">The record type the factory builds.</typeparam>
/// <param name="payload">The response-block payload.</param>
/// <param name="maxCount">Maximum number of records to inspect.</param>
/// <param name="factory">Builds one record from its 1-based index and name.</param>
/// <returns>The non-blank records, in wire order.</returns>
internal static IReadOnlyList<T> ReadNameRecords<T>(byte[] payload, short maxCount, Func<short, string, T> factory)
{
var names = new List<T>();
for (var offset = 0; offset + 4 <= payload.Length && offset / 4 < maxCount; offset += 4)
@@ -35,12 +35,15 @@ public sealed record WireStatus(
short Edit,
short TmMode);
/// <summary>Four-slot position quadruple for one axis: absolute / machine / relative / distance-to-go.</summary>
/// <summary>
/// Four-slot position quadruple for one axis: absolute / machine / relative / distance-to-go,
/// each carrying the scale its own <c>0x0026</c> block reported.
/// </summary>
public sealed record WireAxisPosition(
int Absolute,
int Machine,
int Relative,
int Distance);
FocasPosition Absolute,
FocasPosition Machine,
FocasPosition Relative,
FocasPosition Distance);
/// <summary>
/// Fast-poll bundle for one axis from <c>cnc_rddynamic2</c> — alarm flags, active program
@@ -56,18 +59,104 @@ public sealed record WireDynamic(
int SpindleSpeed,
WireAxisPosition Axis);
/// <summary>One servo-meter entry from <c>cnc_rdsvmeter</c> — per-axis load percentage (scale by 10^<see cref="Decimal"/>).</summary>
/// <summary>
/// The self-describing <c>(value, base, exponent)</c> envelope shared by every value record on
/// the FOCAS wire — <c>cnc_rddynamic2</c> positions, servo meters, spindle meters:
/// <c>scaled = value / base^exponent</c>, where only base 2 and base 10 are meaningful.
/// </summary>
internal static class FocasValueEnvelope
{
/// <summary>
/// Largest exponent that can describe a real value. An i32 carries at most ten digits, so
/// anything beyond is a misread record — and <c>value / 10^400</c> would be
/// <c>value / Infinity = 0.0</c>, a plausible Good zero.
/// </summary>
public const int MaxExponent = 18;
/// <summary>
/// True when the record describes a usable value. A fixed multi-slot buffer's slots past the
/// configured axis/spindle count hold unrelated memory, which shows up exactly here: a base
/// that is neither 2 nor 10, or an absurd exponent (incl. the <c>0xFFFF</c> vacant marker).
/// </summary>
/// <param name="base">The record's base field.</param>
/// <param name="exponent">The record's exponent field.</param>
/// <returns>Whether the record may be published.</returns>
public static bool IsValid(int @base, int exponent) => exponent <= MaxExponent && @base is 2 or 10;
/// <summary><c>value / base^exponent</c>, or null when <see cref="IsValid"/> is false.</summary>
/// <param name="value">The raw value.</param>
/// <param name="base">The record's base field.</param>
/// <param name="exponent">The record's exponent field.</param>
/// <returns>The scaled value, or null.</returns>
public static double? Scale(int value, int @base, int exponent) =>
!IsValid(@base, exponent) ? null : exponent == 0 ? value : value / Math.Pow(@base, exponent);
}
/// <summary>
/// One servo-meter entry from <c>cnc_rdsvmeter</c> (0x0056): one axis's load. The 8-byte record
/// is <c>[+0..3] i32 value</c>, <c>[+4..5] u16 base</c>, <c>[+6..7] u16 exponent</c> and the load
/// is a <b>direct</b> percentage, <see cref="Scaled"/> = <c>value / base^exponent</c>.
/// </summary>
/// <remarks>
/// Offset 4 used to be read as a decimal count ("data / 10^dec"). It is the <b>base</b> — a
/// steady 10 — so every load was divided by 10^10 and published ~0 (a real Makino cutting at
/// 20,000 rpm read servo 0% on every axis). Found by the sister ignitionoee driver,
/// Focas.Wire-019 (v0.17.2).
/// </remarks>
/// <param name="Index">1-based axis slot.</param>
/// <param name="Name">Axis name from the companion 0x0089 block (empty when none).</param>
/// <param name="Value">Raw load value.</param>
/// <param name="Base">Scale base (2 or 10 when meaningful).</param>
/// <param name="Exponent">Scale exponent.</param>
public sealed record WireServoMeter(
short Index,
string Name,
int Value,
short Decimal,
short Unit);
int Base,
int Exponent)
{
/// <summary>False for a record whose envelope describes no usable value (buffer residue).</summary>
public bool IsValid => FocasValueEnvelope.IsValid(Base, Exponent);
/// <summary>One spindle metric slot from <c>cnc_rdspload</c> / <c>cnc_rdspmaxrpm</c>.</summary>
/// <summary>The load percentage, <c>Value / Base^Exponent</c>; null when <see cref="IsValid"/> is false.</summary>
public double? Scaled => FocasValueEnvelope.Scale(Value, Base, Exponent);
}
/// <summary>
/// One spindle-meter slot from <c>cnc_rdspmeter</c> (0x0040; type 0 = load, type 1 = motor
/// speed). Same 8-byte envelope as <see cref="WireServoMeter"/>, but the scaled value is a
/// fraction of full scale in <c>1/16384</c> units — see <see cref="Percent"/>.
/// </summary>
/// <param name="Index">1-based spindle slot.</param>
/// <param name="Value">Raw meter value, in 1/16384-of-full-scale units.</param>
/// <param name="Base">Scale base.</param>
/// <param name="Exponent">Scale exponent.</param>
public sealed record WireSpindleMetric(
short Index,
int Value);
int Value,
int Base,
int Exponent)
{
/// <summary>
/// The meter's full-scale count: <c>16384</c> = 2^14 = 100% of rated load (type 0) or of
/// maximum spindle speed (type 1). Settled on the real Z-34184 by regressing the type-1
/// meter against <c>cnc_rddynamic2</c> spindle speed over 596–20,000 rpm:
/// <c>rpm = 1.220749 × meter</c>, <c>16384 × 1.220749 = 20000.75</c> (Focas.Wire-019).
/// </summary>
public const int FullScale = 1 << 14;
/// <summary>False for a record whose envelope describes no usable value (buffer residue).</summary>
public bool IsValid => FocasValueEnvelope.IsValid(Base, Exponent);
/// <summary>
/// The meter as a percentage — <c>(Value / Base^Exponent) × 100 / 16384</c> — or null when
/// <see cref="IsValid"/> is false. Values above 100 are real (short-term overload; the i16
/// record clamps one count short of 200%).
/// </summary>
public double? Percent => FocasValueEnvelope.Scale(Value, Base, Exponent) is { } scaled
? scaled * 100.0 / FullScale
: null;
}
/// <summary>
/// One axis-name slot from <c>cnc_rdaxisname</c>. <see cref="Index"/> is the 1-based
@@ -86,13 +175,31 @@ public sealed record WireParameter(
int Value);
/// <summary>
/// Macro variable from <c>cnc_rdmacro</c>. Scaled decimal: the callable value is
/// <c>Value / 10^Decimal</c>.
/// Macro variable from <c>cnc_rdmacro</c>. Scaled decimal: the real value is
/// <c>Value / 10^Decimal</c> (<see cref="Scaled"/>). FANUC normalises every macro to a
/// 9-significant-digit mantissa, so <see cref="Value"/> alone is never a value to publish
/// (live: <c>#502 = 270</c> arrives as <c>270000000 / 10^6</c>).
/// </summary>
/// <param name="Number">Macro variable number (the request argument; not carried in the payload).</param>
/// <param name="Value">The 9-significant-digit mantissa.</param>
/// <param name="Decimal">Implied decimal places; negative (<c>0xFFFF</c> on the wire) = vacant.</param>
public sealed record WireMacro(
short Number,
int Value,
short Decimal);
short Decimal)
{
/// <summary>
/// True when the CNC reported the variable as vacant (undefined / never set):
/// <c>dec_val == 0xFFFF</c>. A vacant macro's mantissa is 0, indistinguishable from a
/// genuine 0, so this must be tested before <see cref="Value"/> means anything. Any
/// negative decimal is treated as vacant — no other negative has ever been observed and
/// failing closed is the safe reading.
/// </summary>
public bool IsVacant => Decimal < 0;
/// <summary>The real value, <c>Value / 10^Decimal</c>; null when <see cref="IsVacant"/>.</summary>
public double? Scaled => IsVacant ? null : Decimal == 0 ? Value : Value / Math.Pow(10.0, Decimal);
}
/// <summary>PMC range read-back from <c>pmc_rdpmcrng</c>: one or more values of the requested width.</summary>
public sealed record WirePmcRange(
@@ -239,19 +239,34 @@ internal static class FocasWireProtocol
}
/// <summary>
/// Read an axis/spindle name record — the first 2 bytes of a 2-byte (axis) or 4-byte
/// (spindle) slot. Trailing spaces and NULs are stripped so <c>"X "</c> becomes
/// <c>"X"</c>.
/// Read one 4-byte axis/spindle name record: the ASCII up to (not including) the record's
/// FIRST NUL, at most 4 bytes, with trailing spaces trimmed — <c>"X \0\0"</c> → <c>"X"</c>,
/// <c>"AA6\0"</c> → <c>"AA6"</c>, <c>"S1\0N"</c> → <c>"S1"</c>.
/// </summary>
/// <param name="bytes">The bytes to decode.</param>
/// <returns>The decoded name record.</returns>
/// <remarks>
/// <para><b>All four bytes are name bytes.</b> This used to read only the first two, which
/// collapsed the real 31i-B's paired axes <c>"AA6"</c>/<c>"AA7"</c> (live capture
/// <c>ref-axisname-0x0089.bin</c>) to two axes both called <c>"AA"</c> — the duplicate Gitea
/// #548 then had to disambiguate as <c>AA_1</c>/<c>AA_2</c>.</para>
/// <para><b>The first NUL terminates, not trailing-NUL trimming.</b> A control writes only the
/// bytes its name needs and leaves the rest of the buffer as it found it: Makino Z-34184's
/// <c>cnc_rdspdlname</c> answered <c>53 31 00 4e</c> — <c>"S1"</c> plus the <c>'N'</c> of an
/// earlier <c>"CNC"</c> response. Both rules are ported from the sister ignitionoee FOCAS
/// driver (Focas.Wire-001 / Focas.Wire-022).</para>
/// </remarks>
/// <param name="bytes">The record bytes (4 on the wire; a shorter span is read as far as it goes).</param>
/// <returns>The decoded name; empty for a blank record.</returns>
public static string ReadNameRecord(ReadOnlySpan<byte> bytes)
{
if (bytes.Length < 2) return string.Empty;
var buffer = bytes.Slice(0, Math.Min(2, bytes.Length)).ToArray();
return Encoding.ASCII.GetString(buffer).TrimEnd(' ', '\0');
var record = bytes.Slice(0, Math.Min(NameRecordLength, bytes.Length));
var nul = record.IndexOf((byte)0);
if (nul >= 0) record = record.Slice(0, nul);
return Encoding.ASCII.GetString(record).TrimEnd(' ');
}
/// <summary>Byte length of one axis/spindle name record.</summary>
public const int NameRecordLength = 4;
private static byte[] BuildRequestBlock(RequestBlock request)
{
var extra = request.ExtraPayload ?? Array.Empty<byte>();
@@ -62,7 +62,7 @@ public sealed class WireFocasClient : IFocasClient
{
FocasAreaKind.Pmc => await ReadPmcAsync(address, type, cancellationToken).ConfigureAwait(false),
FocasAreaKind.Parameter => await ReadParameterAsync(address, type, cancellationToken).ConfigureAwait(false),
FocasAreaKind.Macro => await ReadMacroAsync(address, cancellationToken).ConfigureAwait(false),
FocasAreaKind.Macro => await ReadMacroAsync(address, type, cancellationToken).ConfigureAwait(false),
_ => (null, FocasStatusMapper.BadNotSupported),
};
}
@@ -181,6 +181,8 @@ public sealed class WireFocasClient : IFocasClient
ThrowIfRcNonZero(result.Rc, "cnc_rddynamic2", result.IsOk);
var d = result.Value!;
var pos = d.Axis ?? new WireAxisPosition(0, 0, 0, 0);
// Positions keep the scale their own 0x0026 block reported; the driver converts them to
// engineering units at publish (FocasPosition.ToEngineering).
return new FocasDynamicSnapshot(
AxisIndex: axisIndex,
AlarmFlags: d.Alarm,
@@ -215,12 +217,29 @@ public sealed class WireFocasClient : IFocasClient
throw new InvalidOperationException(
$"cnc_exeprgname2 failed EW_{nameResult.Rc} and cnc_rdopmode failed EW_{modeResult.Rc}.");
var wireName = nameResult.Value;
return MapProgramInfo(nameResult, blkResult, modeResult);
}
/// <summary>
/// Combine the three program sub-reads into a <see cref="FocasProgramInfo"/>, leaving any
/// field the CNC did not report null. A refused read, or an <c>cnc_exeprgname2</c> payload
/// too short to carry the trailing O-number, used to publish a fabricated <c>0</c> at Good
/// quality — indistinguishable from a real 0 (a 31i reports O-number 0 for a program run
/// by path) and, for the mode, from MDI. Ported from the sister ignitionoee driver (101a3b1).
/// </summary>
/// <param name="name">The <c>cnc_exeprgname2</c> result.</param>
/// <param name="blockCount">The <c>cnc_rdblkcount</c> result.</param>
/// <param name="mode">The <c>cnc_rdopmode</c> result.</param>
/// <returns>The snapshot, null where the CNC did not say.</returns>
internal static FocasProgramInfo MapProgramInfo(
FocasResult<WireProgramName> name, FocasResult<int> blockCount, FocasResult<short> mode)
{
var wireName = name.IsOk ? name.Value : null;
return new FocasProgramInfo(
Name: wireName?.Name ?? string.Empty,
ONumber: wireName?.ONumber ?? 0,
BlockCount: blkResult.IsOk ? blkResult.Value : 0,
Mode: modeResult.IsOk ? modeResult.Value : 0);
Name: wireName?.Name,
ONumber: wireName?.ONumber,
BlockCount: blockCount.IsOk ? blockCount.Value : null,
Mode: mode.IsOk ? mode.Value : null);
}
/// <inheritdoc />
@@ -240,52 +259,32 @@ public sealed class WireFocasClient : IFocasClient
var result = await _wire.ReadServoMeterAsync(32, cancellationToken).ConfigureAwait(false);
if (!result.IsOk || result.Value is null) return [];
return result.Value
.Select(m => new FocasServoLoad(m.Name ?? string.Empty, m.Value / Math.Pow(10.0, m.Decimal)))
.Select(m => new FocasServoLoad(m.Name ?? string.Empty, m.Scaled))
.Where(s => s.AxisName.Length > 0)
.ToList();
}
/// <inheritdoc />
public Task<IReadOnlyList<int>> GetSpindleLoadsAsync(CancellationToken cancellationToken) =>
public Task<IReadOnlyList<double?>> GetSpindleLoadsAsync(CancellationToken cancellationToken) =>
ReadSpindleMetricAsync((sel, ct) => _wire.ReadSpindleLoadAsync(sel, ct), cancellationToken);
/// <inheritdoc />
public Task<IReadOnlyList<int>> GetSpindleMaxRpmsAsync(CancellationToken cancellationToken) =>
ReadSpindleMetricAsync((sel, ct) => _wire.ReadSpindleMaxRpmAsync(sel, ct), cancellationToken);
public Task<IReadOnlyList<double?>> GetSpindleMotorSpeedsAsync(CancellationToken cancellationToken) =>
ReadSpindleMetricAsync((sel, ct) => _wire.ReadSpindleMotorSpeedAsync(sel, ct), cancellationToken);
/// <inheritdoc />
// Reads via FocasWireClient.ReadPositionFiguresAsync; returns an empty list when the read
// fails (non-zero RC — e.g. the series lacks cnc_getfigure) or is not connected. Never
// throws — figure reads degrade to the configured PositionDecimalPlaces rather than faulting.
public async Task<IReadOnlyList<int>> GetPositionFiguresAsync(CancellationToken cancellationToken)
{
if (!_wire.IsConnected) return [];
try
{
var result = await _wire.ReadPositionFiguresAsync(cancellationToken).ConfigureAwait(false);
return result.IsOk && result.Value is { } figures ? figures : [];
}
catch (FocasWireException)
{
return [];
}
}
private static async Task<IReadOnlyList<int>> ReadSpindleMetricAsync(
/// <summary>
/// One <c>cnc_rdspmeter</c> read as per-slot percentages (null for an undescribable slot).
/// Every slot is returned — the driver reads only the slots of discovered spindles. (This
/// used to stop at the first zero after slot 1, which both dropped a genuinely idle second
/// spindle and kept non-zero buffer residue.)
/// </summary>
private static async Task<IReadOnlyList<double?>> ReadSpindleMetricAsync(
Func<short, CancellationToken, Task<FocasResult<IReadOnlyList<WireSpindleMetric>>>> call,
CancellationToken cancellationToken)
{
var result = await call(-1, cancellationToken).ConfigureAwait(false);
if (!result.IsOk || result.Value is null) return [];
var list = new List<int>();
foreach (var m in result.Value)
{
// Fanuc pads unused spindle slots with 0 — stop at the first trailing zero so the
// list length matches the configured spindle count.
if (m.Value == 0 && list.Count > 0) break;
list.Add(m.Value);
}
return list;
return result.Value.Select(m => m.Percent).ToList();
}
/// <summary>Disposes the wire client and releases all resources.</summary>
@@ -368,17 +367,14 @@ public sealed class WireFocasClient : IFocasClient
}
private async Task<(object? value, uint status)> ReadMacroAsync(
FocasAddress address, CancellationToken cancellationToken)
FocasAddress address, FocasDataType type, CancellationToken cancellationToken)
{
try
{
var result = await _wire.ReadMacroAsync((short)address.Number, cancellationToken).ConfigureAwait(false);
if (!result.IsOk || result.Value is null)
return (null, FocasStatusMapper.MapFocasReturn(result.Rc));
var m = result.Value;
// Macro value is scaled-decimal: the real value is Value / 10^Decimal.
var scaled = m.Value / Math.Pow(10.0, m.Decimal);
return ((object)scaled, FocasStatusMapper.Good);
return MapMacro(result.Value, type, address.BitIndex);
}
catch (FocasWireException ex)
{
@@ -386,6 +382,62 @@ public sealed class WireFocasClient : IFocasClient
}
}
/// <summary>
/// Turn a decoded macro into the value + quality its tag publishes.
/// </summary>
/// <remarks>
/// <para>A <b>vacant</b> variable has no value at all → <see cref="FocasStatusMapper.BadNoData"/>,
/// never a Good <c>0</c> (its mantissa is 0, indistinguishable from a genuine zero).</para>
/// <para>Float tags get <c>mantissa / 10^decimal</c>. <b>Integer</b> tags (Byte / Int16 /
/// Int32 / Bit) get the exact whole value, tested with integer arithmetic
/// (<c>mantissa % 10^decimal</c>) so no epsilon is involved; a fractional macro on an
/// integer tag is <see cref="FocasStatusMapper.BadTypeMismatch"/> — truncating or rounding
/// would publish a plausible wrong count at Good quality — and a whole value that does not
/// fit the type is <see cref="FocasStatusMapper.BadOutOfRange"/>. Ported from the sister
/// ignitionoee driver's <c>macroInteger</c>/<c>macroReal</c> (Focas-017/018).</para>
/// </remarks>
/// <param name="m">The decoded macro.</param>
/// <param name="type">The tag's declared data type.</param>
/// <param name="bitIndex">Optional bit index for a <see cref="FocasDataType.Bit"/> tag.</param>
/// <returns>The boxed value (null when Bad) and its status.</returns>
internal static (object? value, uint status) MapMacro(WireMacro m, FocasDataType type, int? bitIndex)
{
if (m.Scaled is not { } scaled) return (null, FocasStatusMapper.BadNoData);
switch (type)
{
case FocasDataType.Float64:
return (scaled, FocasStatusMapper.Good);
case FocasDataType.Float32:
return ((float)scaled, FocasStatusMapper.Good);
case FocasDataType.Byte or FocasDataType.Int16 or FocasDataType.Int32 or FocasDataType.Bit:
if (!TryWholeMacroValue(m, out var whole)) return (null, FocasStatusMapper.BadTypeMismatch);
return type switch
{
FocasDataType.Bit => (bitIndex is int bit ? ((whole >> bit) & 1L) != 0 : whole != 0, FocasStatusMapper.Good),
FocasDataType.Byte when whole is >= sbyte.MinValue and <= sbyte.MaxValue => ((sbyte)whole, FocasStatusMapper.Good),
FocasDataType.Int16 when whole is >= short.MinValue and <= short.MaxValue => ((short)whole, FocasStatusMapper.Good),
FocasDataType.Int32 when whole is >= int.MinValue and <= int.MaxValue => ((int)whole, FocasStatusMapper.Good),
_ => (null, FocasStatusMapper.BadOutOfRange),
};
default:
return (scaled, FocasStatusMapper.Good);
}
}
/// <summary>Exact <c>mantissa / 10^decimal</c> when it is a whole number; integer arithmetic only.</summary>
private static bool TryWholeMacroValue(WireMacro m, out long whole)
{
whole = 0;
if (m.Value == 0) return true; // 0 is whole at any decimal
if (m.Decimal > 18) return false; // an i32 mantissa over 10^19+ is always fractional
var divisor = 1L;
for (var i = 0; i < m.Decimal; i++) divisor *= 10L;
if (m.Value % divisor != 0) return false;
whole = m.Value / divisor;
return true;
}
private void RequireConnected()
{
if (!_wire.IsConnected)
@@ -41,6 +41,7 @@ internal sealed class AdsTwinCATClient : ITwinCATClient
private readonly CancellationTokenSource _dispatchCts = new();
private readonly Task _dispatchLoop;
private int _disposed;
private bool? _stringsAreUtf8;
/// <summary>
/// Initializes a new instance of the <see cref="AdsTwinCATClient"/> class.
@@ -125,6 +126,30 @@ internal sealed class AdsTwinCATClient : ITwinCATClient
return (arrResult.Value, TwinCATStatusMapper.Good);
}
// Scalar read: check the declared type against the PLC symbol first. The symbol is
// resolved through the Beckhoff symbol cache (the same lookup ReadValueAsync makes
// internally), so this costs no extra round trip after the first read.
var (shape, symbol, symbolStatus) = await DescribeSymbolAsync(symbolPath, cancellationToken)
.ConfigureAwait(false);
if (symbolStatus != TwinCATStatusMapper.Good) return (null, symbolStatus);
if (TwinCATTypeGuard.CheckRead(type, shape) is not null)
return (null, TwinCATStatusMapper.BadTypeMismatch);
// A STRING / WSTRING is read as its raw bytes at the PLC's own declared width and
// decoded here: it ends at the first NUL, and a STRING is Windows-1252 rather than the
// host culture's ANSI code page the Beckhoff marshaller would fall back to. The width is
// the RESOLVED type's (shape.ByteSize) and the read is addressed explicitly: asking the
// library for typeof(byte[]) slices by the symbol's own ByteSize, which a TwinCAT 2
// member symbol under-reports (STRING(80) → 11 B, live on a TC 2.11 CX).
if (type is TwinCATDataType.String or TwinCATDataType.WString)
{
var (bytes, readStatus) = await ReadRawAsync(symbolPath, symbol!, shape.ByteSize, cancellationToken)
.ConfigureAwait(false);
return readStatus == TwinCATStatusMapper.Good
? (DecodeString(type, bytes, StringsAreUtf8()), TwinCATStatusMapper.Good)
: (null, readStatus);
}
var clrType = MapToClrType(type);
var result = await _client.ReadValueAsync(symbolPath, clrType, cancellationToken)
.ConfigureAwait(false);
@@ -140,6 +165,87 @@ internal sealed class AdsTwinCATClient : ITwinCATClient
}
}
/// <summary>
/// Resolves the PLC symbol behind <paramref name="symbolPath"/> into the facts
/// <see cref="TwinCATTypeGuard"/> compares a declared type against. Uses the Beckhoff symbol
/// cache (lookup on), so repeat calls are local.
/// </summary>
private async Task<(TwinCATPlcSymbolShape shape, IAdsSymbol? symbol, uint status)> DescribeSymbolAsync(
string symbolPath, CancellationToken cancellationToken)
{
var result = await _client.ReadSymbolAsync(symbolPath, cancellationToken).ConfigureAwait(false);
if (result.ErrorCode != AdsErrorCode.NoError)
return (default, null, MapAndSignal((uint)result.ErrorCode));
if (result.Value is not { } symbol)
return (default, null, TwinCATStatusMapper.BadNodeIdUnknown);
// Resolve aliases (T_MaxString → STRING(255)) so the guard sees the base type, and take the
// resolved type's byte size — a TwinCAT 2 member symbol's own ByteSize is under-reported.
var dataType = symbol.DataType?.Resolve();
var shape = TwinCATPlcSymbolShape.Classify(
dataType?.Name ?? symbol.TypeName, dataType?.Category, symbol.ByteSize, dataType?.ByteSize, symbol.TypeName);
return (shape, symbol, TwinCATStatusMapper.Good);
}
/// <summary>
/// Reads exactly <paramref name="length"/> raw bytes of a resolved symbol: by its
/// index group/offset, or — for a handle-addressed symbol (0xF005, whose offset is a handle
/// that may be stale) — through a fresh variable handle.
/// </summary>
private async Task<(byte[] bytes, uint status)> ReadRawAsync(
string symbolPath, IAdsSymbol symbol, int length, CancellationToken cancellationToken)
{
const uint SymbolValueByHandle = 0xF005;
if (symbol.IndexGroup != SymbolValueByHandle)
{
var direct = await _client.ReadAsync(symbol.IndexGroup, symbol.IndexOffset, length, cancellationToken)
.ConfigureAwait(false);
return direct.ErrorCode == AdsErrorCode.NoError
? (direct.Data.ToArray(), TwinCATStatusMapper.Good)
: ([], MapAndSignal((uint)direct.ErrorCode));
}
var handle = await _client.CreateVariableHandleAsync(symbolPath, cancellationToken).ConfigureAwait(false);
if (handle.ErrorCode != AdsErrorCode.NoError) return ([], MapAndSignal((uint)handle.ErrorCode));
try
{
var read = await _client.ReadAsync(handle.Handle, length, cancellationToken).ConfigureAwait(false);
return read.ErrorCode == AdsErrorCode.NoError
? (read.Data.ToArray(), TwinCATStatusMapper.Good)
: ([], MapAndSignal((uint)read.ErrorCode));
}
finally
{
try { await _client.DeleteVariableHandleAsync(handle.Handle, CancellationToken.None).ConfigureAwait(false); }
catch { /* best-effort release */ }
}
}
/// <summary>
/// True when the target reports UTF-8 STRING data (TwinCAT 3.1 4026+ "UTF8 Encoding for
/// STRING"); otherwise a STRING is Windows-1252. Read once per client from the symbol upload
/// info the Beckhoff client already fetches.
/// </summary>
private bool StringsAreUtf8()
{
if (_stringsAreUtf8 is bool known) return known;
try
{
var utf8 = _client.DefaultValueEncoding.CodePage == System.Text.Encoding.UTF8.CodePage;
_stringsAreUtf8 = utf8;
return utf8;
}
catch
{
return false; // not determinable yet (e.g. not connected) — Windows-1252, retry next time
}
}
private static string DecodeString(TwinCATDataType type, byte[] bytes, bool utf8) =>
type == TwinCATDataType.WString
? TwinCATStringCodec.DecodeWString(bytes)
: TwinCATStringCodec.DecodeString(bytes, utf8);
private static string StripBitSuffix(string symbolPath)
{
var lastDot = symbolPath.LastIndexOf('.');
@@ -197,6 +303,33 @@ internal sealed class AdsTwinCATClient : ITwinCATClient
CancellationToken cancellationToken)
{
var clrType = MapToClrType(type);
int[]? anyTypeArgs = null;
var stringsUtf8 = false;
if (bitIndex is null || type != TwinCATDataType.Bool)
{
// A notification carries raw bytes decoded as the DECLARED type, so a declared type that
// cannot decode the PLC symbol (width, float/integer or string kind differ) would publish
// wrong values at Good. Refuse it here; the driver isolates the one tag to Bad.
var (shape, _, symbolStatus) = await DescribeSymbolAsync(symbolPath, cancellationToken)
.ConfigureAwait(false);
if (symbolStatus != TwinCATStatusMapper.Good)
throw new InvalidOperationException(
$"Resolving {symbolPath} for a notification failed with status 0x{symbolStatus:X8}");
if (TwinCATTypeGuard.CheckNotification(type, shape) is { } reason)
throw new TwinCATTypeMismatchException(symbolPath, reason);
// A string has no fixed width: the "any" type string needs an explicit length (the
// Beckhoff marshaller throws ArgumentNullException without one), and a STRING(n) is n+1
// bytes with n chosen per declaration. Register the PLC's own byte width as raw bytes
// and decode them in OnAdsNotificationEx with the same rules as a read.
if (type is TwinCATDataType.String or TwinCATDataType.WString)
{
clrType = typeof(byte[]);
anyTypeArgs = [shape.ByteSize];
stringsUtf8 = StringsAreUtf8();
}
}
var cycleMs = (int)Math.Max(1, cycleTime.TotalMilliseconds);
var settings = new NotificationSettings(AdsTransMode.OnChange, cycleMs, Math.Max(0, maxDelayMs));
@@ -204,13 +337,16 @@ internal sealed class AdsTwinCATClient : ITwinCATClient
// with the handle as part of the event args so we use the handle as the correlation
// key into _notifications.
var result = await _client.AddDeviceNotificationExAsync(
symbolPath, settings, userData: null, clrType, args: null, cancellationToken)
symbolPath, settings, userData: null, clrType, anyTypeArgs, cancellationToken)
.ConfigureAwait(false);
if (result.ErrorCode != AdsErrorCode.NoError)
throw new InvalidOperationException(
$"AddDeviceNotificationExAsync failed with ADS error {result.ErrorCode} for {symbolPath}");
var reg = new NotificationRegistration(symbolPath, type, bitIndex, onChange, this, result.Handle);
var reg = new NotificationRegistration(symbolPath, type, bitIndex, onChange, this, result.Handle)
{
StringsUtf8 = stringsUtf8,
};
_notifications[result.Handle] = reg;
return reg;
}
@@ -227,6 +363,8 @@ internal sealed class AdsTwinCATClient : ITwinCATClient
var value = args.Value;
if (reg.BitIndex is int bit && reg.Type == TwinCATDataType.Bool && value is not bool)
value = ExtractBit(value, bit);
else if (value is byte[] raw && reg.Type is TwinCATDataType.String or TwinCATDataType.WString)
value = DecodeString(reg.Type, raw, reg.StringsUtf8);
_notificationQueue.Writer.TryWrite(new PendingNotification(reg, value));
}
@@ -376,27 +514,36 @@ internal sealed class AdsTwinCATClient : ITwinCATClient
return (MapSymbolTypeName(dataType.Name), null);
}
private static TwinCATDataType? MapSymbolTypeName(string? typeName)
internal static TwinCATDataType? MapSymbolTypeName(string? typeName)
{
if (typeName is null) return null;
// A TwinCAT 2 data-type upload pads some member type names with a trailing blank
// ("REAL ", "DINT " on a TC 2.11 CX) — trim before matching.
var name = typeName.Trim();
// SymbolLoader emits STRING(80) / WSTRING(80) with the declared bound baked into
// the type name — strip the "(...)" suffix so sized strings map onto the bare
// String/WString atom the driver speaks.
var paren = typeName.IndexOf('(');
var bare = paren > 0 ? typeName[..paren] : typeName;
// String/WString atom the driver speaks. Only for the string kinds: any other
// parenthesised form ("INT (VAR_IN_OUT)" — a 4-byte reference on TC2) is not the
// scalar its prefix names and stays unsupported.
var paren = name.IndexOf('(');
var bare = paren > 0 ? name[..paren] : name;
if (paren > 0 && bare is not ("STRING" or "WSTRING")) return null;
return bare switch
{
"BOOL" or "BIT" => TwinCATDataType.Bool,
"SINT" or "BYTE" => TwinCATDataType.SInt,
"USINT" => TwinCATDataType.USInt,
"INT" or "WORD" => TwinCATDataType.Int,
"UINT" => TwinCATDataType.UInt,
"DINT" or "DWORD" => TwinCATDataType.DInt,
"UDINT" => TwinCATDataType.UDInt,
// TwinCAT 2 reports top-level globals by their ADS base-type name (INT16 / UINT16 / …),
// and the Beckhoff type system keeps that spelling as a built-in primitive
// (AdsSymbolProvider.CreateBuildInTypes) — map each like its IEC twin.
"USINT" or "UINT8" => TwinCATDataType.USInt,
"INT" or "WORD" or "INT16" => TwinCATDataType.Int,
"UINT" or "UINT16" => TwinCATDataType.UInt,
"DINT" or "DWORD" or "INT32" => TwinCATDataType.DInt,
"UDINT" or "UINT32" => TwinCATDataType.UDInt,
"LINT" or "LWORD" => TwinCATDataType.LInt,
"ULINT" => TwinCATDataType.ULInt,
"REAL" => TwinCATDataType.Real,
"LREAL" => TwinCATDataType.LReal,
"REAL" or "FLOAT" => TwinCATDataType.Real,
"LREAL" or "DOUBLE" => TwinCATDataType.LReal,
"STRING" => TwinCATDataType.String,
"WSTRING" => TwinCATDataType.WString,
"TIME" => TwinCATDataType.Time,
@@ -463,6 +610,11 @@ internal sealed class AdsTwinCATClient : ITwinCATClient
/// </summary>
public Action<string, object?> OnChange { get; } = onChange;
/// <summary>
/// Gets a value indicating whether a STRING notification decodes as UTF-8 (else Windows-1252).
/// </summary>
public bool StringsUtf8 { get; init; }
/// <summary>
/// Disposes the notification subscription.
/// </summary>
@@ -94,6 +94,9 @@ public interface ITwinCATClient : IDisposable
/// <param name="onChange">Invoked with <c>(symbolPath, boxedValue)</c> per notification.</param>
/// <param name="cancellationToken">Cancels the initial registration; does not tear down an established notification.</param>
/// <returns>A handle whose disposal tears the registered notification down.</returns>
/// <exception cref="TwinCATTypeMismatchException">The declared <paramref name="type"/> cannot decode
/// the PLC symbol (width, float/integer or string kind differ) — the caller isolates that one tag
/// to Bad rather than registering a notification that would publish wrong bytes at Good.</exception>
Task<ITwinCATNotificationHandle> AddNotificationAsync(
string symbolPath,
TwinCATDataType type,
@@ -276,9 +276,15 @@ public sealed class TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery
_health = new DriverHealth(DriverState.Healthy, now, null);
else
{
_logger.LogWarning(
"TwinCAT driver '{DriverInstanceId}' ADS read error 0x{Status:X8} for '{Reference}'",
_driverInstanceId, status, reference);
if (status == TwinCATStatusMapper.BadTypeMismatch)
_logger.LogWarning(
"TwinCAT driver '{DriverInstanceId}' refused '{Reference}': declared {DataType} " +
"cannot decode the PLC symbol '{Symbol}' (string/numeric kind mismatch or a reference/container)",
_driverInstanceId, reference, def.DataType, symbolName);
else
_logger.LogWarning(
"TwinCAT driver '{DriverInstanceId}' ADS read error 0x{Status:X8} for '{Reference}'",
_driverInstanceId, status, reference);
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead,
$"ADS status {status:X8} reading {reference}");
}
@@ -535,7 +541,22 @@ public sealed class TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery
new DataChangeEventArgs(handle, reference, new DataValueSnapshot(
null, TwinCATStatusMapper.BadCommunicationError, null, DateTime.UtcNow))));
await RegisterNotificationAsync(device, reg, cancellationToken).ConfigureAwait(false);
try
{
await RegisterNotificationAsync(device, reg, cancellationToken).ConfigureAwait(false);
}
catch (TwinCATTypeMismatchException mismatch)
{
// The declared type cannot decode this PLC symbol (ignitionoee TwinCAT-002/-008):
// isolate THIS tag to Bad — one WARN, one BadTypeMismatch snapshot — and keep the
// rest of the subscription. Its intent stays on the device with no live handle,
// so the probe-tick retry re-checks it after a PLC program change.
_logger.LogWarning(
"TwinCAT driver '{DriverInstanceId}' refused native notification for '{Reference}': {Reason}",
_driverInstanceId, reference, mismatch.Message);
OnDataChange?.Invoke(this, new DataChangeEventArgs(handle, reference, new DataValueSnapshot(
null, TwinCATStatusMapper.BadTypeMismatch, null, DateTime.UtcNow)));
}
registrations.Add(reg);
}
}
@@ -569,9 +590,20 @@ public sealed class TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery
try
{
var client = await EnsureConnectedUnderGateAsync(device, ct, timeoutOverride: null).ConfigureAwait(false);
var newHandle = await client.AddNotificationAsync(
reg.SymbolName, reg.DataType, reg.BitIndex, reg.Interval,
_options.NotificationMaxDelayMs, reg.OnChange, ct).ConfigureAwait(false);
ITwinCATNotificationHandle newHandle;
try
{
newHandle = await client.AddNotificationAsync(
reg.SymbolName, reg.DataType, reg.BitIndex, reg.Interval,
_options.NotificationMaxDelayMs, reg.OnChange, ct).ConfigureAwait(false);
}
catch (TwinCATTypeMismatchException)
{
// Keep the intent with no live handle (a "dead" registration) so the probe-tick
// retry re-checks it and UnsubscribeAsync still removes it; the caller isolates it.
device.NativeRegistrations[reg.Id] = reg;
throw;
}
reg.SwapHandle(newHandle); // initial set — previous handle is null
device.NativeRegistrations[reg.Id] = reg;
}
@@ -0,0 +1,69 @@
using System.Text;
namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT;
/// <summary>
/// Decodes an IEC <c>STRING(n)</c> / <c>WSTRING(n)</c> buffer read at the PLC's own declared width.
/// The driver reads string symbols as raw bytes and decodes them here instead of letting the
/// Beckhoff marshaller pick an encoding, because that marshaller falls back to the HOST's ANSI code
/// page (<c>CultureInfo.CurrentCulture.TextInfo.ANSICodePage</c>) for a non-UTF-8 target — so a
/// TwinCAT 2 string would decode differently on a Polish (1250) or Cyrillic (1251) server.
/// </summary>
/// <remarks>
/// Two rules, both ported from the ignitionoee TwinCAT driver and both found on a real PLC:
/// <list type="bullet">
/// <item>A value ENDS at its first NUL — <c>0x00</c> for a STRING, an aligned <c>0x00 0x00</c>
/// code unit for a WSTRING. Whatever follows is stale content a longer earlier value left in the
/// buffer (live on the Elma cleaner Z33856: work order <c>67504440</c> read back with a stale
/// <c>"\0" + "1"</c> tail).</item>
/// <item>A STRING is single-byte <b>Windows-1252</b> (umlauts, <c>°</c> in alarm texts), unless
/// the target reports UTF-8 string data (TwinCAT 3.1 4026+ "UTF8 Encoding for STRING").
/// ISO-8859-1 is the fallback on a runtime without code-page support — identical outside
/// 0x80–0x9F.</item>
/// </list>
/// </remarks>
internal static class TwinCATStringCodec
{
private static readonly Encoding Windows1252 = CreateWindows1252();
private static Encoding CreateWindows1252()
{
try
{
Encoding.RegisterProvider(CodePagesEncodingProvider.Instance);
return Encoding.GetEncoding(1252);
}
catch (Exception)
{
return Encoding.Latin1;
}
}
/// <summary>Decodes an IEC <c>STRING(n)</c> buffer: ends at the first NUL, Windows-1252 unless <paramref name="utf8"/>.</summary>
/// <param name="bytes">The raw symbol bytes (n+1 for a <c>STRING(n)</c>).</param>
/// <param name="utf8">True when the target reports UTF-8 string data.</param>
/// <returns>The decoded value.</returns>
public static string DecodeString(ReadOnlySpan<byte> bytes, bool utf8)
{
var end = bytes.IndexOf((byte)0);
if (end >= 0) bytes = bytes[..end];
return (utf8 ? Encoding.UTF8 : Windows1252).GetString(bytes);
}
/// <summary>Decodes an IEC <c>WSTRING(n)</c> buffer (UTF-16LE): ends at the first aligned <c>U+0000</c>.</summary>
/// <param name="bytes">The raw symbol bytes (2(n+1) for a <c>WSTRING(n)</c>).</param>
/// <returns>The decoded value.</returns>
public static string DecodeWString(ReadOnlySpan<byte> bytes)
{
var end = bytes.Length & ~1; // whole UTF-16 code units only — a trailing odd byte is ignored
for (var i = 0; i + 1 < end; i += 2)
{
if (bytes[i] == 0 && bytes[i + 1] == 0)
{
end = i;
break;
}
}
return Encoding.Unicode.GetString(bytes[..end]);
}
}
@@ -11,6 +11,12 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT;
/// grammar maps cleanly onto the AbCip Logix path parser, but without Logix's
/// <c>Program:</c> scope prefix. The leading segment is the namespace (POU name /
/// GVL name) and subsequent segments walk into struct/array members.</para>
/// <para><b>TwinCAT 2 globals</b> are named with ONE leading dot (<c>.OPC_HEARTBEAT</c>,
/// <c>.OPC_MACHINEDATA.PROCESSSTATUS</c>) and the PLC requires it — without the dot
/// <c>SYM_HNDBYNAME</c> answers 0x710 (symbol not found), observed live on a TC 2.11 CX. The dot
/// is kept on the ROOT segment's <see cref="TwinCATSymbolSegment.Name"/> so
/// <see cref="ToAdsSymbolName"/> round-trips it unchanged; it is not a separator and never yields
/// an empty segment. Exactly one dot: <c>..x</c>, <c>.</c> and <c>.[1]</c> stay invalid.</para>
/// </remarks>
public sealed record TwinCATSymbolPath(
IReadOnlyList<TwinCATSymbolSegment> Segments,
@@ -41,6 +47,11 @@ public sealed record TwinCATSymbolPath(
if (string.IsNullOrWhiteSpace(value)) return null;
var src = value.Trim();
// A TwinCAT 2 global's single leading dot belongs to the root segment, not before it.
// Strip it for the split and put it back on segment 0 below.
var tc2Global = src.StartsWith('.');
if (tc2Global) src = src[1..];
var parts = new List<string>();
var depth = 0;
var start = 0;
@@ -91,6 +102,7 @@ public sealed record TwinCATSymbolPath(
segments.Add(new TwinCATSymbolSegment(name, subs));
}
if (segments.Count == 0) return null;
if (tc2Global) segments[0] = segments[0] with { Name = "." + segments[0].Name };
return new TwinCATSymbolPath(segments, bitIndex);
}
@@ -0,0 +1,164 @@
using System.Text.RegularExpressions;
using TwinCAT.TypeSystem;
namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT;
/// <summary>What kind of value a PLC symbol holds, as far as the type guard is concerned.</summary>
internal enum TwinCATPlcKind
{
/// <summary>A primitive / enum / alias-of-primitive — a single fixed-width value.</summary>
Scalar,
/// <summary>An IEC <c>STRING(n)</c> (any n, incl. an alias such as <c>T_MaxString</c>).</summary>
String,
/// <summary>An IEC <c>WSTRING(n)</c>.</summary>
WString,
/// <summary>A struct / FB / array / pointer / reference / <c>VAR_IN_OUT</c> — no scalar value.</summary>
NonScalar,
}
/// <summary>The facts about a PLC symbol the type guard compares a declared type against.</summary>
/// <param name="Kind">The symbol's kind.</param>
/// <param name="ByteSize">The symbol's byte size as the PLC reports it.</param>
/// <param name="IsFloat">True for a REAL / LREAL scalar.</param>
/// <param name="TypeName">The (alias-resolved) PLC type name, for log messages.</param>
internal readonly record struct TwinCATPlcSymbolShape(TwinCATPlcKind Kind, int ByteSize, bool IsFloat, string TypeName)
{
private static readonly Regex StringTypeName =
new(@"^(W?)STRING(\s*\(\s*\d+\s*\))?$", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant);
/// <summary>
/// Classifies a PLC symbol from its (alias-resolved) type name, its Beckhoff
/// <see cref="DataTypeCategory"/> when the type system resolved one, and its byte size.
/// </summary>
/// <param name="typeName">The PLC type name (<c>STRING(80)</c>, <c>INT16</c>, <c>ST_LOGDATA</c>, …).</param>
/// <param name="category">The resolved type category, or null when the type system has none.</param>
/// <param name="byteSize">The symbol's byte size.</param>
/// <param name="dataTypeByteSize">The resolved data type's byte size, when the type system has one.
/// It wins over <paramref name="byteSize"/>: on a TwinCAT 2 target the Beckhoff library reports a
/// dotted MEMBER symbol's size as if the PLC's byte count were a bit count (STRING(80) → 11,
/// INT → 1, UDINT → 1; live on a TC 2.11 CX), while the resolved type carries the true size.</param>
/// <param name="symbolTypeName">The symbol's OWN type name, when it differs from the resolved one. A
/// TwinCAT 2 <c>VAR_IN_OUT</c> member is named <c>STRING(20) (VAR_IN_OUT)</c> — a 4-byte reference —
/// while the library resolves its data type to the plain <c>STRING(20)</c>; the reference marker
/// wins, so the symbol is never read as the value it points at.</param>
/// <returns>The classified shape.</returns>
public static TwinCATPlcSymbolShape Classify(
string? typeName, DataTypeCategory? category, int byteSize, int? dataTypeByteSize = null,
string? symbolTypeName = null)
{
if (symbolTypeName?.Contains("VAR_IN_OUT", StringComparison.OrdinalIgnoreCase) == true
|| typeName?.Contains("VAR_IN_OUT", StringComparison.OrdinalIgnoreCase) == true)
return new(TwinCATPlcKind.NonScalar, byteSize, false, (symbolTypeName ?? typeName)!.Trim());
if (dataTypeByteSize is > 0) byteSize = dataTypeByteSize.Value;
var name = (typeName ?? string.Empty).Trim();
var match = StringTypeName.Match(name);
if (match.Success || category == DataTypeCategory.String)
{
var wide = match.Success
? match.Groups[1].Length > 0
: name.StartsWith("WSTRING", StringComparison.OrdinalIgnoreCase);
return new(wide ? TwinCATPlcKind.WString : TwinCATPlcKind.String, byteSize, false, name);
}
if (category is DataTypeCategory.Array or DataTypeCategory.Struct or DataTypeCategory.FunctionBlock
or DataTypeCategory.Program or DataTypeCategory.Pointer or DataTypeCategory.Reference
or DataTypeCategory.Union or DataTypeCategory.Interface
|| name.StartsWith("ARRAY", StringComparison.OrdinalIgnoreCase)
|| name.StartsWith("POINTER TO", StringComparison.OrdinalIgnoreCase)
|| name.StartsWith("REFERENCE TO", StringComparison.OrdinalIgnoreCase)
|| name.Contains("VAR_IN_OUT", StringComparison.OrdinalIgnoreCase))
return new(TwinCATPlcKind.NonScalar, byteSize, false, name);
var mapped = AdsTwinCATClient.MapSymbolTypeName(name);
return new(TwinCATPlcKind.Scalar, byteSize, mapped is TwinCATDataType.Real or TwinCATDataType.LReal, name);
}
}
/// <summary>
/// Decides whether a tag's DECLARED <see cref="TwinCATDataType"/> may decode the PLC symbol it names —
/// the port of the ignitionoee TwinCAT driver's extra-type reconciliation (TwinCAT-002, -008). A
/// refused pair leaves the node Bad (<see cref="TwinCATStatusMapper.BadTypeMismatch"/>) instead of
/// publishing the PLC's bytes decoded as the wrong type at Good quality. A <c>null</c> result means
/// compatible; otherwise it is a one-line reason for the log.
/// </summary>
/// <remarks>
/// The two paths need different rules because they decode differently:
/// <list type="bullet">
/// <item><b>Read</b> (<see cref="CheckRead"/>) goes through the Beckhoff symbol cache, which
/// decodes by the PLC's OWN type and converts the value to the declared CLR type — a numeric
/// pair is value-converted, never reinterpreted. Only string-ness can go wrong there: a
/// numeric tag over a PLC string got a <c>string</c> object back at Good.</item>
/// <item><b>Notification</b> (<see cref="CheckNotification"/>) registers raw bytes decoded as the
/// DECLARED type, so a width mismatch truncates/over-reads and a same-width float/integer clash
/// reinterprets the bit pattern — both at Good.</item>
/// </list>
/// String-ness is a KIND (TwinCAT-008): a STRING/WSTRING tag matches only a PLC string of the SAME
/// kind, at any declared length (0.19.2). A same-width, same-kind numeric divergence
/// (<c>DINT</c> vs <c>UDINT</c>, <c>TIME</c> vs a 4-byte integer) is tolerated so a valid config is
/// never refused.
/// </remarks>
internal static class TwinCATTypeGuard
{
/// <summary>Checks a declared type against a PLC symbol for a symbol-aware READ.</summary>
/// <param name="declared">The tag's declared type.</param>
/// <param name="plc">The PLC symbol's shape.</param>
/// <returns><c>null</c> when compatible; otherwise the reason.</returns>
public static string? CheckRead(TwinCATDataType declared, TwinCATPlcSymbolShape plc)
{
var declaredKind = StringKindOf(declared);
if (declaredKind is not null)
{
return plc.Kind == declaredKind
? null
: $"declared {declared} but the PLC symbol is {plc.TypeName} ({plc.ByteSize} B) — a string tag " +
$"matches only a PLC {declaredKind.Value.ToString().ToUpperInvariant()} of any length";
}
return plc.Kind is TwinCATPlcKind.String or TwinCATPlcKind.WString
? $"declared {declared} but the PLC symbol is the string {plc.TypeName} — declare it String/WString"
: null;
}
/// <summary>Checks a declared type against a PLC symbol for a raw-bytes NOTIFICATION.</summary>
/// <param name="declared">The tag's declared type.</param>
/// <param name="plc">The PLC symbol's shape.</param>
/// <returns><c>null</c> when compatible; otherwise the reason.</returns>
public static string? CheckNotification(TwinCATDataType declared, TwinCATPlcSymbolShape plc)
{
var reason = CheckRead(declared, plc);
if (reason is not null || StringKindOf(declared) is not null) return reason;
if (plc.Kind == TwinCATPlcKind.NonScalar)
return $"declared scalar {declared} but the PLC symbol is {plc.TypeName} ({plc.ByteSize} B) — not a scalar";
var size = DeclaredByteSize(declared);
if (size != plc.ByteSize)
return $"declared {declared} ({size} B) but the PLC symbol {plc.TypeName} is {plc.ByteSize} B";
var declaredFloat = declared is TwinCATDataType.Real or TwinCATDataType.LReal;
return declaredFloat == plc.IsFloat
? null
: $"declared {declared} but the PLC symbol is {plc.TypeName} — float/integer kinds differ";
}
/// <summary>The byte width the declared type decodes from a notification buffer.</summary>
/// <param name="type">The declared type.</param>
/// <returns>The width in bytes.</returns>
internal static int DeclaredByteSize(TwinCATDataType type) => type switch
{
TwinCATDataType.Bool or TwinCATDataType.SInt or TwinCATDataType.USInt => 1,
TwinCATDataType.Int or TwinCATDataType.UInt => 2,
TwinCATDataType.LInt or TwinCATDataType.ULInt or TwinCATDataType.LReal => 8,
_ => 4, // DInt / UDInt / Real / Time / Date / DateTime / TimeOfDay
};
private static TwinCATPlcKind? StringKindOf(TwinCATDataType type) => type switch
{
TwinCATDataType.String => TwinCATPlcKind.String,
TwinCATDataType.WString => TwinCATPlcKind.WString,
_ => null,
};
}
@@ -0,0 +1,22 @@
namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT;
/// <summary>
/// Thrown by <see cref="ITwinCATClient.AddNotificationAsync"/> when a tag's declared
/// <see cref="TwinCATDataType"/> cannot decode the PLC symbol it names (see
/// <see cref="TwinCATTypeGuard"/>). The driver isolates the one tag to
/// <see cref="TwinCATStatusMapper.BadTypeMismatch"/> and keeps the rest of the subscription.
/// </summary>
internal sealed class TwinCATTypeMismatchException : Exception
{
/// <summary>Initializes a new instance of the <see cref="TwinCATTypeMismatchException"/> class.</summary>
/// <param name="symbolPath">The ADS symbol the tag names.</param>
/// <param name="reason">The guard's one-line reason.</param>
public TwinCATTypeMismatchException(string symbolPath, string reason)
: base($"TwinCAT symbol '{symbolPath}': {reason}")
{
SymbolPath = symbolPath;
}
/// <summary>Gets the ADS symbol the refused tag names.</summary>
public string SymbolPath { get; }
}
@@ -0,0 +1,45 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Driver.AbCip;
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests;
/// <summary>
/// Array element stride. Seen live on a production CompactLogix: reading
/// <c>Program:UN01_Press._ModeNames</c> (<c>STR0016[32]</c>, 4 elements requested) returned an
/// 80-byte buffer with libplctag's <c>ElementSize</c> unset; the driver strode 88 bytes (built-in
/// STRING) and the read failed <c>ErrorOutOfBounds</c>. The real values were
/// "undefined" / "Production" / "Maintenance" / "Manual" at a 20-byte stride.
/// </summary>
[Trait("Category", "Unit")]
public sealed class AbCipArrayStrideTests
{
/// <summary>A user string type's stride comes from the buffer, not the built-in STRING size.</summary>
[Fact]
public void User_string_array_strides_by_buffer_size_over_count()
{
LibplctagTagRuntime.ResolveArrayStride(null, bufferSize: 80, count: 4, AbCipDataType.String).ShouldBe(20);
}
/// <summary>The built-in STRING still strides 88.</summary>
[Fact]
public void Builtin_STRING_array_strides_88()
{
LibplctagTagRuntime.ResolveArrayStride(null, bufferSize: 3 * 88, count: 3, AbCipDataType.String).ShouldBe(88);
}
/// <summary>libplctag's own element size wins when it is populated.</summary>
[Fact]
public void Reported_element_size_wins()
{
LibplctagTagRuntime.ResolveArrayStride(4, bufferSize: 80, count: 4, AbCipDataType.DInt).ShouldBe(4);
}
/// <summary>An empty or uneven buffer falls back to the static per-type size.</summary>
[Fact]
public void Empty_or_uneven_buffer_falls_back_to_the_type_size()
{
LibplctagTagRuntime.ResolveArrayStride(null, bufferSize: 0, count: 4, AbCipDataType.Real).ShouldBe(4);
LibplctagTagRuntime.ResolveArrayStride(null, bufferSize: 81, count: 4, AbCipDataType.String).ShouldBe(88);
}
}
@@ -603,6 +603,110 @@ public sealed class AbCipDriverDiscoveryTests
cache.Count.ShouldBe(0);
}
// ---- real-controller shapes (captures from a CompactLogix 5069-L320ERS2 rev 34.11) ----
private static async Task<RecordingBuilder> DiscoverWithTemplatesAsync(
Dictionary<uint, byte[]> templates, params AbCipDiscoveredTag[] tags)
{
var builder = new RecordingBuilder();
var readers = new IdKeyedTemplateReaderFactory();
foreach (var (id, blob) in templates) readers.Templates[id] = blob;
var drv = new AbCipDriver(new AbCipDriverOptions
{
Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")],
EnableControllerBrowse = true,
}, "drv-1", enumeratorFactory: new FakeEnumeratorFactory(tags), templateReaderFactory: readers);
await drv.InitializeAsync("{}", CancellationToken.None);
await drv.DiscoverAsync(builder, CancellationToken.None);
return builder;
}
/// <summary>
/// A controller reports every STRING tag as a struct (symbol type 0x8FCE → template 0x0FCE,
/// LEN + DATA). Browse must present it as the one String value it is — the read path already
/// reads it as a String — not as a folder holding a DINT and an SINT[82].
/// </summary>
[Fact]
public async Task Discovered_STRING_struct_emits_one_String_variable_not_LEN_and_DATA()
{
var builder = await DiscoverWithTemplatesAsync(
new() { [0x0FCE] = Convert.FromHexString(CipTemplateObjectWireCaptureTests.StringTemplate) },
new AbCipDiscoveredTag("WW_WorkOrderNum", null, AbCipDataType.Structure, ReadOnly: false,
TemplateInstanceId: 0x0FCE));
var v = builder.Variables.ShouldHaveSingleItem();
v.Info.FullName.ShouldBe("WW_WorkOrderNum");
v.Info.DriverDataType.ShouldBe(DriverDataType.String);
v.Info.IsArray.ShouldBeFalse();
builder.Folders.ShouldNotContain(f => f.BrowseName == "WW_WorkOrderNum");
}
/// <summary>A nested STRING member of a UDT is one String leaf, addressable as Parent.Member.</summary>
[Fact]
public async Task Nested_STRING_member_emits_a_String_leaf()
{
var builder = await DiscoverWithTemplatesAsync(
new()
{
[3386] = Convert.FromHexString(CipTemplateObjectWireCaptureTests.LookupMemberTemplate),
[0x0FCE] = Convert.FromHexString(CipTemplateObjectWireCaptureTests.StringTemplate),
},
new AbCipDiscoveredTag("Lookup", null, AbCipDataType.Structure, ReadOnly: false,
TemplateInstanceId: 3386));
var byName = builder.Variables.ToDictionary(v => v.Info.FullName, v => v.Info);
byName.Keys.ShouldBe(["Lookup.Code", "Lookup.Desc", "Lookup.DataType", "Lookup.Lock"], ignoreOrder: true);
byName["Lookup.Desc"].DriverDataType.ShouldBe(DriverDataType.String);
byName["Lookup.Code"].DriverDataType.ShouldBe(DriverDataType.Int32);
}
/// <summary>
/// Logix packs BOOL members into hidden host integers (<c>ZZZZZZZZZZAlarms0</c>) and AOIs carry
/// <c>__BitHost00</c>-style backing members. Neither is user data, and a Logix name can never
/// contain a double underscore, so both are dropped from the browse.
/// </summary>
[Fact]
public async Task Hidden_BOOL_host_members_are_not_browsed()
{
var alarms = TemplateBufferBuilder.Build("Alarms", 4,
("ZZZZZZZZZZAlarms0", 0xC2, 0, 0),
("_00", 0xC1, 0, 0),
("__BitHost00", 0xC4, 0, 0));
var builder = await DiscoverWithTemplatesAsync(
new() { [3425] = alarms },
new AbCipDiscoveredTag("Alarms", null, AbCipDataType.Structure, ReadOnly: false,
TemplateInstanceId: 3425));
builder.Variables.Select(v => v.Info.FullName).ShouldBe(["Alarms._00"]);
}
/// <summary>
/// An ARRAY of a UDT is not fanned out into <c>Tag.Member</c> leaves — those paths name no
/// element and every read of them would fail. An array of STRING is a String array.
/// </summary>
[Fact]
public async Task Discovered_struct_arrays_do_not_fan_out_and_STRING_arrays_are_String_arrays()
{
var udt = TemplateBufferBuilder.Build("Rec", 8, ("A", 0xC4, 0, 0), ("B", 0xCA, 0, 4));
var builder = await DiscoverWithTemplatesAsync(
new()
{
[0x0FCE] = Convert.FromHexString(CipTemplateObjectWireCaptureTests.StringTemplate),
[0x55] = udt,
},
new AbCipDiscoveredTag("arrMessages", null, AbCipDataType.Structure, ReadOnly: false,
ElementCount: 50, IsArray: true, TemplateInstanceId: 0x0FCE),
new AbCipDiscoveredTag("Stored", null, AbCipDataType.Structure, ReadOnly: false,
ElementCount: 255, IsArray: true, TemplateInstanceId: 0x55));
builder.Variables.ShouldNotContain(v => v.Info.FullName.StartsWith("Stored."));
var messages = builder.Variables.Single(v => v.Info.FullName == "arrMessages").Info;
messages.DriverDataType.ShouldBe(DriverDataType.String);
messages.IsArray.ShouldBeTrue();
messages.ArrayDim.ShouldBe(50u);
}
// ---- helpers ----
/// <summary>Test implementation of IAddressSpaceBuilder that records calls.</summary>
@@ -714,34 +818,11 @@ public sealed class AbCipDriverDiscoveryTests
}
/// <summary>
/// Build a minimal CIP Template Object blob <see cref="CipTemplateObjectDecoder"/> can decode
/// into an <see cref="AbCipUdtShape"/>. Mirrors the helper in
/// <c>AbCipFetchUdtShapeTests</c>: 12-byte header + 8-byte member blocks + semicolon/NUL
/// terminated UDT-then-member name strings.
/// Build a template buffer in libplctag's real <c>@udt/id</c> layout via
/// <see cref="TemplateBufferBuilder"/>. <c>info</c> is the member's CIP type word; <c>arr</c>
/// &gt; 0 marks an array member.
/// </summary>
private static byte[] BuildSimpleTemplate(
string name, uint instanceSize, params (string n, ushort info, ushort arr, uint off)[] members)
{
const int headerSize = 12;
const int blockSize = 8;
var strings = new MemoryStream();
void Add(string s) { var b = Encoding.ASCII.GetBytes(s + ";\0"); strings.Write(b, 0, b.Length); }
Add(name);
foreach (var m in members) Add(m.n);
var stringsArr = strings.ToArray();
var buf = new byte[headerSize + blockSize * members.Length + stringsArr.Length];
BinaryPrimitives.WriteUInt16LittleEndian(buf.AsSpan(0), (ushort)members.Length);
BinaryPrimitives.WriteUInt16LittleEndian(buf.AsSpan(2), 0x1234);
BinaryPrimitives.WriteUInt32LittleEndian(buf.AsSpan(4), instanceSize);
for (var i = 0; i < members.Length; i++)
{
var o = headerSize + i * blockSize;
BinaryPrimitives.WriteUInt16LittleEndian(buf.AsSpan(o), members[i].info);
BinaryPrimitives.WriteUInt16LittleEndian(buf.AsSpan(o + 2), members[i].arr);
BinaryPrimitives.WriteUInt32LittleEndian(buf.AsSpan(o + 4), members[i].off);
}
Buffer.BlockCopy(stringsArr, 0, buf, headerSize + blockSize * members.Length, stringsArr.Length);
return buf;
}
=> TemplateBufferBuilder.Build(name, instanceSize, [.. members.Select(m => (m.n, m.info, m.arr, m.off))]);
}
@@ -60,30 +60,14 @@ public sealed class AbCipFetchUdtShapeTests
}
}
private static byte[] BuildSimpleTemplate(string name, uint instanceSize, params (string n, ushort info, ushort arr, uint off)[] members)
{
var headerSize = 12;
var blockSize = 8;
var strings = new MemoryStream();
void Add(string s) { var b = Encoding.ASCII.GetBytes(s + ";\0"); strings.Write(b, 0, b.Length); }
Add(name);
foreach (var m in members) Add(m.n);
var stringsArr = strings.ToArray();
var buf = new byte[headerSize + blockSize * members.Length + stringsArr.Length];
BinaryPrimitives.WriteUInt16LittleEndian(buf.AsSpan(0), (ushort)members.Length);
BinaryPrimitives.WriteUInt16LittleEndian(buf.AsSpan(2), 0x1234);
BinaryPrimitives.WriteUInt32LittleEndian(buf.AsSpan(4), instanceSize);
for (var i = 0; i < members.Length; i++)
{
var o = headerSize + i * blockSize;
BinaryPrimitives.WriteUInt16LittleEndian(buf.AsSpan(o), members[i].info);
BinaryPrimitives.WriteUInt16LittleEndian(buf.AsSpan(o + 2), members[i].arr);
BinaryPrimitives.WriteUInt32LittleEndian(buf.AsSpan(o + 4), members[i].off);
}
Buffer.BlockCopy(stringsArr, 0, buf, headerSize + blockSize * members.Length, stringsArr.Length);
return buf;
}
/// <summary>
/// Build a template buffer in libplctag's real <c>@udt/id</c> layout via
/// <see cref="TemplateBufferBuilder"/>. <c>info</c> is the member's CIP type word; <c>arr</c>
/// &gt; 0 marks an array member.
/// </summary>
private static byte[] BuildSimpleTemplate(
string name, uint instanceSize, params (string n, ushort info, ushort arr, uint off)[] members)
=> TemplateBufferBuilder.Build(name, instanceSize, [.. members.Select(m => (m.n, m.info, m.arr, m.off))]);
private static Task<AbCipUdtShape?> InvokeFetch(AbCipDriver drv, string deviceHostAddress, uint templateId)
{
@@ -0,0 +1,69 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Driver.AbCip;
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests;
/// <summary>
/// A controller's <c>@tags</c> listing names each program (<c>Program:UN01_Press</c>, a system
/// symbol) but none of its tags — those are listed only by <c>Program:&lt;name&gt;.@tags</c>.
/// Seen live on a production press: the PackML state (<c>Program:UN01_Press.Out_eState</c>) and
/// every alarm bit the Ignition project reads are program-scoped, and the browse showed none.
/// </summary>
[Trait("Category", "Unit")]
public sealed class AbCipProgramScopeEnumerationTests
{
// Captured entries (CompactLogix 5069-L320ERS2 rev 34.11): WW_InCycle + the Program:UN01_Press entry
// from @tags, and Out_eState from Program:UN01_Press.@tags.
private const string ControllerListing =
"AF010000C10001000000000000000000000000000A0057575F496E4379636C65" +
"7900000068100000000000000000000000000000120050726F6772616D3A554E30315F5072657373";
private const string Un01Listing = "1A000000C40004000000000000000000000000000A004F75745F655374617465";
private static readonly AbCipTagCreateParams Params = new(
Gateway: "10.0.0.5", Port: 44818, CipPath: "1,0", LibplctagPlcAttribute: "controllogix",
TagName: "@tags", Timeout: TimeSpan.FromSeconds(2));
/// <summary>The enumerator reads each program's own listing and yields its tags program-scoped.</summary>
[Fact]
public async Task Program_scoped_tags_are_enumerated_from_each_program_listing()
{
var reads = new List<string>();
using var enumerator = new LibplctagTagEnumerator((_, name, _) =>
{
reads.Add(name);
return Task.FromResult(name switch
{
"@tags" => Convert.FromHexString(ControllerListing),
"Program:UN01_Press.@tags" => Convert.FromHexString(Un01Listing),
_ => throw new InvalidOperationException(name),
});
});
var tags = new List<AbCipDiscoveredTag>();
await foreach (var t in enumerator.EnumerateAsync(Params, TestContext.Current.CancellationToken))
tags.Add(t);
reads.ShouldBe(["@tags", "Program:UN01_Press.@tags"]);
var outState = tags.Single(t => t.Name == "Out_eState");
outState.ProgramScope.ShouldBe("UN01_Press");
outState.DataType.ShouldBe(AbCipDataType.DInt);
tags.ShouldContain(t => t.Name == "WW_InCycle" && t.ProgramScope == null);
}
/// <summary>One unreadable program listing does not cost the controller tags or other programs.</summary>
[Fact]
public async Task A_failing_program_listing_is_skipped()
{
using var enumerator = new LibplctagTagEnumerator((_, name, _) => name == "@tags"
? Task.FromResult(Convert.FromHexString(ControllerListing))
: Task.FromException<byte[]>(new InvalidOperationException("ErrorNotFound")));
var tags = new List<AbCipDiscoveredTag>();
await foreach (var t in enumerator.EnumerateAsync(Params, TestContext.Current.CancellationToken))
tags.Add(t);
tags.ShouldContain(t => t.Name == "WW_InCycle");
}
}
@@ -0,0 +1,72 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Driver.AbCip;
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests;
/// <summary>
/// libplctag hands back whatever bytes the controller sends and decodes them as the authored type
/// — it never checks the CIP type code. Seen live on a production press: <c>WW_ActualVacuum</c> is a
/// REAL (the L5X and the controller's symbol table agree), the legacy Kepware export types it as
/// Short, and reading it authored as INT returned <c>Good -17346</c> (the low half of a float)
/// where the real value was 5.37. A scalar atomic read whose buffer is not the authored type's
/// width is now <c>BadTypeMismatch</c>. Same-width mismatches (DINT vs REAL) remain undetectable
/// without the type code.
/// </summary>
[Trait("Category", "Unit")]
public sealed class AbCipReadWidthMismatchTests
{
private const string Device = "ab://10.0.0.5/1,0";
private static async Task<uint> ReadStatusAsync(AbCipDataType authored, int bufferSize, string path = "WW_ActualVacuum")
{
var factory = new FakeAbCipTagFactory
{
Customise = p => new FakeAbCipTag(p) { Value = 1, Size = bufferSize },
};
var drv = new AbCipDriver(new AbCipDriverOptions
{
Devices = [new AbCipDeviceOptions(Device)],
RawTags = AbCipRawTags.From(new AbCipTagDefinition("T", Device, path, authored)),
Probe = new AbCipProbeOptions { Enabled = false },
}, "abcip-1", factory);
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
var r = await drv.ReadAsync(["T"], TestContext.Current.CancellationToken);
return r[0].StatusCode;
}
/// <summary>An INT read that comes back 4 bytes wide is a type mismatch, not a Good value.</summary>
[Fact]
public async Task INT_authored_over_a_4_byte_REAL_is_BadTypeMismatch()
{
(await ReadStatusAsync(AbCipDataType.Int, bufferSize: 4)).ShouldBe(AbCipStatusMapper.BadTypeMismatch);
}
/// <summary>Matching widths still read Good — including the live-observed sizes per type.</summary>
/// <param name="type">Authored type.</param>
/// <param name="size">Buffer size the controller returned for that type (observed live).</param>
[Theory]
[InlineData(AbCipDataType.Int, 2)]
[InlineData(AbCipDataType.DInt, 4)]
[InlineData(AbCipDataType.Real, 4)]
[InlineData(AbCipDataType.LInt, 8)]
[InlineData(AbCipDataType.SInt, 1)]
public async Task Matching_width_reads_Good(AbCipDataType type, int size)
{
(await ReadStatusAsync(type, size)).ShouldBe(AbCipStatusMapper.Good);
}
/// <summary>An unknown size (a runtime that cannot report it) never blocks a read.</summary>
[Fact]
public async Task Unknown_size_is_not_checked()
{
(await ReadStatusAsync(AbCipDataType.Int, bufferSize: -1)).ShouldBe(AbCipStatusMapper.Good);
}
/// <summary>A bit-in-DINT read decodes from the parent's 4-byte buffer and is not width-checked.</summary>
[Fact]
public async Task Bit_index_read_is_not_width_checked()
{
(await ReadStatusAsync(AbCipDataType.Bool, bufferSize: 4, path: "Flags.3")).ShouldBe(AbCipStatusMapper.Good);
}
}
@@ -128,6 +128,41 @@ public sealed class AbCipTagPathTests
AbCipTagPath.TryParse(input).ShouldBeNull();
}
/// <summary>
/// Module-defined I/O tags carry colons in their base name (<c>Local:1:I</c>,
/// <c>ABB_OmniCore:I</c>). Seen live: a CompactLogix lists <c>ABB_OmniCore:I</c> in
/// <c>@tags</c>, libplctag reads <c>ABB_OmniCore:I.ConnectionFaulted</c> fine, and the driver
/// refused it as a malformed path.
/// </summary>
/// <param name="input">A module tag path.</param>
/// <param name="baseName">The expected base (first segment) name.</param>
[Theory]
[InlineData("ABB_OmniCore:I.ConnectionFaulted", "ABB_OmniCore:I")]
[InlineData("Local:1:I.Data", "Local:1:I")]
[InlineData("Local:2:O.Data.3", "Local:2:O")]
[InlineData("Rack1:3:C.Ch0Config.RangeType", "Rack1:3:C")]
public void Module_IO_tag_base_names_with_colons_parse_and_round_trip(string input, string baseName)
{
var parsed = AbCipTagPath.TryParse(input);
parsed.ShouldNotBeNull();
parsed.ProgramScope.ShouldBeNull();
parsed.Segments[0].Name.ShouldBe(baseName);
parsed.ToLibplctagName().ShouldBe(input);
}
/// <summary>A colon is only legal in the base name, and never as a dangling or doubled separator.</summary>
/// <param name="input">A malformed path.</param>
[Theory]
[InlineData("Motor.Sub:I")] // colon past the base segment
[InlineData("Local::I.Data")] // empty colon part
[InlineData("Local:1:.Data")] // trailing colon
[InlineData(":Local.Data")] // leading colon
[InlineData("Program:P.Local:1:I")] // module tags are controller-scope only
public void Colons_outside_a_module_base_name_are_rejected(string input)
{
AbCipTagPath.TryParse(input).ShouldBeNull();
}
/// <summary>Verifies that identifiers with underscores are accepted.</summary>
[Fact]
public void Ident_with_underscore_accepted()
@@ -0,0 +1,73 @@
using libplctag;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Driver.AbCip;
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests;
/// <summary>
/// A tag the controller does not have is a configuration error, not a lost connection. Seen live
/// against a production CompactLogix: with one misspelled tag first in the read list, libplctag's
/// create threw <c>LibPlcTagException("ErrorNotFound")</c>, the driver counted that as a failed
/// Forward-Open, opened the device's connect-backoff window, and every other not-yet-connected tag
/// on the device — all of them real — came back <c>BadCommunicationError</c> (throttled). The bad
/// tag itself also read <c>BadCommunicationError</c> instead of <c>BadNodeIdUnknown</c>.
/// </summary>
[Trait("Category", "Unit")]
public sealed class AbCipUnknownTagReadTests
{
private const string Device = "ab://10.0.0.5/1,0";
private static (AbCipDriver Driver, FakeAbCipTagFactory Factory) Build(Func<string, Exception?> initFault)
{
var factory = new FakeAbCipTagFactory
{
Customise = p => initFault(p.TagName) is { } ex
? new FakeAbCipTag(p) { ThrowOnInitialize = true, Exception = ex }
: new FakeAbCipTag(p) { Value = 42 },
};
var drv = new AbCipDriver(new AbCipDriverOptions
{
Devices = [new AbCipDeviceOptions(Device)],
RawTags = AbCipRawTags.From(
new AbCipTagDefinition("Typo", Device, "NoSuchTag_xyz", AbCipDataType.DInt),
new AbCipTagDefinition("Real1", Device, "WW_InCycle", AbCipDataType.DInt),
new AbCipTagDefinition("Real2", Device, "WW_ActualPress", AbCipDataType.DInt)),
Probe = new AbCipProbeOptions { Enabled = false },
MaxConcurrentReads = 1,
}, "abcip-1", factory);
return (drv, factory);
}
/// <summary>The unknown tag reads BadNodeIdUnknown and its siblings still connect and read Good.</summary>
[Fact]
public async Task Unknown_tag_does_not_throttle_sibling_tags_and_reads_BadNodeIdUnknown()
{
var (drv, _) = Build(name => name == "NoSuchTag_xyz" ? new LibPlcTagException("ErrorNotFound") : null);
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
var r = await drv.ReadAsync(["Typo", "Real1", "Real2"], TestContext.Current.CancellationToken);
r[0].StatusCode.ShouldBe(AbCipStatusMapper.BadNodeIdUnknown);
r[1].StatusCode.ShouldBe(AbCipStatusMapper.Good);
r[1].Value.ShouldBe(42);
r[2].StatusCode.ShouldBe(AbCipStatusMapper.Good);
}
/// <summary>
/// Falsification control: a genuine connection failure still opens the backoff window, so a
/// sibling cold tag is throttled exactly as before (05/STAB-8 is unchanged).
/// </summary>
[Fact]
public async Task A_connection_failure_still_opens_the_backoff_window()
{
var (drv, factory) = Build(name => name == "NoSuchTag_xyz" ? new LibPlcTagException("ErrorTimeout") : null);
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
var r = await drv.ReadAsync(["Typo", "Real1"], TestContext.Current.CancellationToken);
r[0].StatusCode.ShouldBe(AbCipStatusMapper.BadTimeout);
r[1].StatusCode.ShouldBe(AbCipStatusMapper.BadCommunicationError);
factory.Tags.ContainsKey("WW_InCycle").ShouldBeFalse(); // throttled before any create
}
}
@@ -0,0 +1,85 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Driver.AbCip;
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests;
/// <summary>
/// Symbol Object (<c>@tags</c>) entries captured byte for byte off two real CompactLogix
/// controllers on 2026-10-07 (read-only, one listing read each): a 5069-L320ERS2 rev 34.11 press
/// and a second CompactLogix. Unlike the <c>ab_server</c> capture in
/// <see cref="CipSymbolObjectWireCaptureTests"/> — which reports every scalar as a 1-element
/// array — a real controller sets the dimension bits only on genuine arrays.
/// </summary>
[Trait("Category", "Unit")]
public sealed class CipSymbolObjectRealControllerCaptureTests
{
// Controller scope, press: WW_InCycle (BOOL), Inf_LookupFwkB (UDT[23], template 3386),
// Program:UN01_Press (the program entry itself, type 0x1068).
private const string WwInCycle = "AF010000C10001000000000000000000000000000A0057575F496E4379636C65";
private const string InfLookupFwkB = "6D0000003AAD60001700000000000000000000000E00496E665F4C6F6F6B757046776B42";
private const string ProgramUn01 = "7900000068100000000000000000000000000000120050726F6772616D3A554E30315F5072657373";
// Controller scope, second controller: arrSysTime (DINT[7]), arrMessages (STRING[50]).
private const string ArrSysTime = "11000000C42004000700000000000000000000000A0061727253797354696D65";
private const string ArrMessages = "61000000CEAF58003200000000000000000000000B006172724D65737361676573";
// Program:UN01_Press.@tags: names arrive WITHOUT the Program: prefix.
private const string OutEState = "1A000000C40004000000000000000000000000000A004F75745F655374617465";
private const string DisableStates = "4C000000C42004002000000000000000000000000E005F44697361626C65537461746573";
private static List<AbCipDiscoveredTag> Decode(params string[] entries) =>
CipSymbolObjectDecoder.Decode(Convert.FromHexString(string.Concat(entries))).ToList();
/// <summary>Array dimensions in the entry surface as IsArray + ElementCount; scalars stay scalar.</summary>
[Fact]
public void Real_controller_arrays_surface_their_element_count()
{
var tags = Decode(WwInCycle, InfLookupFwkB, ArrSysTime, ArrMessages);
var inCycle = tags.Single(t => t.Name == "WW_InCycle");
inCycle.IsArray.ShouldBeFalse();
inCycle.ElementCount.ShouldBe(1);
var sysTime = tags.Single(t => t.Name == "arrSysTime");
sysTime.DataType.ShouldBe(AbCipDataType.DInt);
sysTime.IsArray.ShouldBeTrue();
sysTime.ElementCount.ShouldBe(7);
var lookup = tags.Single(t => t.Name == "Inf_LookupFwkB");
lookup.DataType.ShouldBe(AbCipDataType.Structure);
lookup.TemplateInstanceId.ShouldBe(3386u);
lookup.IsArray.ShouldBeTrue();
lookup.ElementCount.ShouldBe(23);
var messages = tags.Single(t => t.Name == "arrMessages");
messages.TemplateInstanceId.ShouldBe(0x0FCEu);
messages.IsArray.ShouldBeTrue();
messages.ElementCount.ShouldBe(50);
}
/// <summary>A program entry is a system tag whose program name the enumerator can walk.</summary>
[Fact]
public void Program_entry_is_system_and_names_its_program()
{
var program = Decode(ProgramUn01).ShouldHaveSingleItem();
program.IsSystemTag.ShouldBeTrue();
CipSymbolObjectDecoder.ProgramNameOf(program).ShouldBe("UN01_Press");
CipSymbolObjectDecoder.ProgramNameOf(Decode(WwInCycle).Single()).ShouldBeNull();
}
/// <summary>A program listing decodes with the program as each tag's scope.</summary>
[Fact]
public void Program_listing_decodes_with_program_scope()
{
var tags = CipSymbolObjectDecoder.Decode(
Convert.FromHexString(OutEState + DisableStates), programScope: "UN01_Press").ToList();
tags.Select(t => t.Name).ShouldBe(["Out_eState", "_DisableStates"]);
tags.ShouldAllBe(t => t.ProgramScope == "UN01_Press");
tags[0].DataType.ShouldBe(AbCipDataType.DInt);
tags[1].IsArray.ShouldBeTrue();
tags[1].ElementCount.ShouldBe(32);
}
}
@@ -100,6 +100,24 @@ public sealed class CipSymbolObjectWireCaptureTests
tags.Single(t => t.Name == "TestSINT").DataType.ShouldBe(AbCipDataType.SInt);
}
/// <summary>
/// <c>ab_server</c> marks every symbol as a one-dimensional array (a scalar is
/// <c>[1]</c>); only <c>TestArray</c> has more than one element. A 1-element, 1-D entry is
/// treated as a scalar, so the fixture's scalars do not browse as 1-element arrays — a
/// real controller sets no dimension bits on a scalar at all
/// (<see cref="CipSymbolObjectRealControllerCaptureTests"/>).
/// </summary>
[Fact]
public void Only_the_multi_element_capture_entry_is_an_array()
{
var tags = CipSymbolObjectDecoder.Decode(Capture()).ToList();
var array = tags.Single(t => t.Name == "TestArray");
array.IsArray.ShouldBeTrue();
array.ElementCount.ShouldBe(16);
tags.Where(t => t.Name != "TestArray").ShouldAllBe(t => !t.IsArray && t.ElementCount == 1);
}
/// <summary>None of the captured symbols is misread as a system tag or a structure.</summary>
/// <remarks>
/// The dimension bits sit next to the flags the decoder tests for, so a mask that is one
@@ -10,45 +10,15 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests;
public sealed class CipTemplateObjectDecoderTests
{
/// <summary>
/// Construct a Template Object blob — header + member blocks + semicolon-delimited
/// strings (UDT name first, then member names).
/// Construct a template buffer in libplctag's real layout (see <see cref="TemplateBufferBuilder"/>).
/// <c>info</c> here is the member's CIP type word; <c>arraySize</c> &gt; 0 marks an array member.
/// </summary>
private static byte[] BuildTemplate(
string udtName,
uint instanceSize,
params (string name, ushort info, ushort arraySize, uint offset)[] members)
{
var memberCount = (ushort)members.Length;
var headerSize = 12;
var memberBlockSize = 8;
var blocksSize = memberBlockSize * members.Length;
var stringsBuf = new MemoryStream();
void AppendString(string s)
{
var bytes = Encoding.ASCII.GetBytes(s + ";\0");
stringsBuf.Write(bytes, 0, bytes.Length);
}
AppendString(udtName);
foreach (var m in members) AppendString(m.name);
var strings = stringsBuf.ToArray();
var buf = new byte[headerSize + blocksSize + strings.Length];
BinaryPrimitives.WriteUInt16LittleEndian(buf.AsSpan(0), memberCount);
BinaryPrimitives.WriteUInt16LittleEndian(buf.AsSpan(2), 0x1234);
BinaryPrimitives.WriteUInt32LittleEndian(buf.AsSpan(4), instanceSize);
BinaryPrimitives.WriteUInt32LittleEndian(buf.AsSpan(8), 0);
for (var i = 0; i < members.Length; i++)
{
var o = headerSize + (i * memberBlockSize);
BinaryPrimitives.WriteUInt16LittleEndian(buf.AsSpan(o), members[i].info);
BinaryPrimitives.WriteUInt16LittleEndian(buf.AsSpan(o + 2), members[i].arraySize);
BinaryPrimitives.WriteUInt32LittleEndian(buf.AsSpan(o + 4), members[i].offset);
}
Buffer.BlockCopy(strings, 0, buf, headerSize + blocksSize, strings.Length);
return buf;
}
=> TemplateBufferBuilder.Build(udtName, instanceSize,
[.. members.Select(m => (m.name, m.info, m.arraySize, m.offset))]);
/// <summary>Verifies that a simple UDT with two members decodes correctly.</summary>
[Fact]
@@ -161,8 +131,9 @@ public sealed class CipTemplateObjectDecoderTests
[Fact]
public void Zero_member_count_returns_null()
{
var buf = new byte[12];
BinaryPrimitives.WriteUInt16LittleEndian(buf.AsSpan(0), 0);
var buf = new byte[14];
BinaryPrimitives.WriteUInt32LittleEndian(buf.AsSpan(6), 8); // instance size
BinaryPrimitives.WriteUInt16LittleEndian(buf.AsSpan(10), 0); // member count
CipTemplateObjectDecoder.Decode(buf).ShouldBeNull();
}
@@ -178,19 +149,10 @@ public sealed class CipTemplateObjectDecoderTests
public void Missing_member_name_surfaces_placeholder()
{
// Header says 3 members but strings list has only UDT name + 2 member names.
var memberCount = (ushort)3;
var buf = new byte[12 + 8 * 3];
BinaryPrimitives.WriteUInt16LittleEndian(buf.AsSpan(0), memberCount);
BinaryPrimitives.WriteUInt32LittleEndian(buf.AsSpan(4), 12);
for (var i = 0; i < 3; i++)
{
var o = 12 + i * 8;
BinaryPrimitives.WriteUInt16LittleEndian(buf.AsSpan(o), 0xC4);
BinaryPrimitives.WriteUInt32LittleEndian(buf.AsSpan(o + 4), (uint)(i * 4));
}
// strings: only UDT + 2 members, missing the third.
var strings = Encoding.ASCII.GetBytes("MyUdt;\0A;\0B;\0");
var combined = buf.Concat(strings).ToArray();
var full = TemplateBufferBuilder.Build("MyUdt", 12,
("A", 0xC4, 0, 0), ("B", 0xC4, 0, 4), ("C", 0xC4, 0, 8));
// Drop the trailing "C\0" so only two member names remain.
var combined = full[..^2];
var shape = CipTemplateObjectDecoder.Decode(combined);
shape.ShouldNotBeNull();
@@ -198,19 +160,20 @@ public sealed class CipTemplateObjectDecoderTests
shape.Members[2].Name.ShouldBe("<member_2>");
}
/// <summary>Verifies that semicolon-terminated string parsing handles various input shapes.</summary>
/// <param name="input">The raw semicolon-terminated string input.</param>
/// <param name="expected">The expected array of parsed strings.</param>
/// <summary>Verifies the template string section splits into the type name + NUL-terminated member names.</summary>
/// <param name="input">The raw string section.</param>
/// <param name="memberCount">Member names to read after the type name.</param>
/// <param name="expected">The expected type name followed by member names.</param>
[Theory]
[InlineData("Foo;\0Bar;\0", new[] { "Foo", "Bar" })]
[InlineData("Foo;Bar;", new[] { "Foo", "Bar" })] // no nulls
[InlineData("Only;\0", new[] { "Only" })]
[InlineData(";\0", new string[] { })] // empty
[InlineData("", new string[] { })]
public void ParseSemicolonTerminatedStrings_handles_shapes(string input, string[] expected)
[InlineData("STRING;nEAIA\0LEN\0DATA\0", 2, new[] { "STRING", "LEN", "DATA" })]
[InlineData("Udt;n\0A\0B\0\0\u0001junk", 2, new[] { "Udt", "A", "B" })] // trailing bytes ignored
[InlineData("Udt\0A\0", 1, new[] { "Udt", "A" })] // no ';' tail
[InlineData("Udt;nEA\0A\0", 3, new[] { "Udt", "A" })] // fewer names than members
[InlineData("", 1, new string[] { })]
public void ParseTemplateStrings_handles_shapes(string input, int memberCount, string[] expected)
{
var bytes = Encoding.ASCII.GetBytes(input);
var result = CipTemplateObjectDecoder.ParseSemicolonTerminatedStrings(bytes);
var result = CipTemplateObjectDecoder.ParseTemplateStrings(bytes, memberCount);
result.ShouldBe(expected);
}
}
@@ -0,0 +1,98 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Driver.AbCip;
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests;
/// <summary>
/// Decodes <c>@udt/&lt;id&gt;</c> template buffers captured byte for byte off a real controller —
/// a CompactLogix 5069-L320ERS2 rev 34.11, read through libplctag 1.5.2's <c>Tag.GetBuffer()</c>
/// on 2026-10-07 (read-only; one template read per id).
/// </summary>
/// <remarks>
/// <para>
/// Every pre-existing template test built its own input in the layout the decoder assumed
/// (u16 member count first, a 12-byte header, <c>[info][array][offset]</c> blocks, names
/// each ending <c>;\0</c>). A real buffer agrees with none of that, so the decoder returned
/// <c>null</c> for every UDT a controller has and discovered UDTs never fanned out. The
/// same builder-shares-the-parser's-assumption trap as #468.
/// </para>
/// <para>
/// What libplctag actually hands back for <c>@udt/id</c> (cross-checked against its own
/// <c>list_tags_logix</c> example): a 14-byte header — u16 template id, u32 member-definition
/// size in 32-bit words, u32 instance size in bytes, u16 member count, u16 struct handle —
/// then one 8-byte block per member: u16 <b>info</b> (array length, or the bit number of a
/// BOOL), u16 <b>type</b> (bit 15 = struct, bit 13 = array, low 12 bits = CIP type code or
/// the nested template id), u32 byte offset. Then the strings: the type name ending at
/// <c>;</c> (followed by an encoded tail up to a NUL), then each member name NUL-terminated.
/// </para>
/// </remarks>
[Trait("Category", "Unit")]
public sealed class CipTemplateObjectWireCaptureTests
{
/// <summary>The built-in STRING template (id 0x0FCE): LEN DINT + DATA SINT[82], 88 bytes.</summary>
internal const string StringTemplate =
"CE0F10000000580000000200CE0F0000C400000000005200C22004000000" +
"535452494E473B6E45414941004C454E004441544100000001000200";
/// <summary>
/// Rockwell library UDT <c>raC_UDT_LookupMember_STR0082</c> (id 3386): DINT, a nested
/// STRING member (struct type 0x8FCE), SINT, BOOL. Trailing bytes after the last name are
/// part of the capture and must be ignored.
/// </summary>
internal const string LookupMemberTemplate =
"3A0D1F000000600000000400B3810000C400000000000000CE8F040000000000C2005C0000000000C1005C000000" +
"7261435F5544545F4C6F6F6B75704D656D6265725F535452303038323B6E45414141454245430043" +
"6F64650044657363004461746154797065004C6F636B00008F92361DA1314013";
private static AbCipUdtShape DecodeHex(string hex)
{
var shape = CipTemplateObjectDecoder.Decode(Convert.FromHexString(hex));
shape.ShouldNotBeNull();
return shape;
}
/// <summary>The STRING template decodes to its two members with the real array length.</summary>
[Fact]
public void Captured_STRING_template_decodes_LEN_and_DATA()
{
var shape = DecodeHex(StringTemplate);
shape.TypeName.ShouldBe("STRING");
shape.TotalSize.ShouldBe(88);
shape.Members.Select(m => m.Name).ShouldBe(["LEN", "DATA"]);
shape.Members[0].DataType.ShouldBe(AbCipDataType.DInt);
shape.Members[0].Offset.ShouldBe(0);
shape.Members[0].ArrayLength.ShouldBe(1);
shape.Members[1].DataType.ShouldBe(AbCipDataType.SInt);
shape.Members[1].Offset.ShouldBe(4);
shape.Members[1].ArrayLength.ShouldBe(82);
}
/// <summary>A nested struct member carries its template id; trailing bytes are ignored.</summary>
[Fact]
public void Captured_library_UDT_decodes_nested_STRING_member_and_ignores_trailing_bytes()
{
var shape = DecodeHex(LookupMemberTemplate);
shape.TypeName.ShouldBe("raC_UDT_LookupMember_STR0082");
shape.TotalSize.ShouldBe(96);
shape.Members.Select(m => m.Name).ShouldBe(["Code", "Desc", "DataType", "Lock"]);
shape.Members[0].DataType.ShouldBe(AbCipDataType.DInt);
shape.Members[1].DataType.ShouldBe(AbCipDataType.Structure);
shape.Members[1].NestedTemplateId.ShouldBe(0x0FCEu);
shape.Members[1].Offset.ShouldBe(4);
shape.Members[2].DataType.ShouldBe(AbCipDataType.SInt);
shape.Members[2].Offset.ShouldBe(92);
shape.Members[3].DataType.ShouldBe(AbCipDataType.Bool);
shape.Members.ShouldAllBe(m => m.ArrayLength == 1);
}
/// <summary>A shape is recognised as a Logix string family (LEN DINT + DATA SINT[n]).</summary>
[Fact]
public void Captured_STRING_template_is_a_string_family_shape_and_a_plain_UDT_is_not()
{
DecodeHex(StringTemplate).IsStringFamily.ShouldBeTrue();
DecodeHex(LookupMemberTemplate).IsStringFamily.ShouldBeFalse();
}
}
@@ -64,6 +64,12 @@ internal class FakeAbCipTag : IAbCipTagRuntime
/// <summary>Returns the simulated status code.</summary>
public virtual int GetStatus() => Status;
/// <summary>Gets or sets the simulated read-buffer size in bytes (-1 = unknown, the default).</summary>
public int Size { get; set; } = -1;
/// <summary>Returns the simulated read-buffer size.</summary>
public virtual int GetSize() => Size;
/// <summary>Returns the mock tag value.</summary>
/// <param name="type">The data type being decoded.</param>
/// <param name="bitIndex">The optional bit index for bit operations.</param>
@@ -0,0 +1,45 @@
using System.Buffers.Binary;
using System.Text;
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests;
/// <summary>
/// Builds an <c>@udt/&lt;id&gt;</c> template buffer in the layout libplctag really returns —
/// the layout pinned by the real-controller captures in <c>CipTemplateObjectWireCaptureTests</c>.
/// Keep it in step with those captures, never with the decoder: a builder that restates the
/// decoder's assumption proves nothing (#468, and the template decoder after it).
/// </summary>
internal static class TemplateBufferBuilder
{
/// <summary>Build a template buffer.</summary>
/// <param name="udtName">UDT type name (written as <c>name;n</c> + an encoded tail, as a controller does).</param>
/// <param name="instanceSize">Bytes per instance.</param>
/// <param name="members">Per member: name, CIP type word (atomic code, or <c>0x8000 | nestedTemplateId</c>),
/// array length (0 = scalar; &gt; 0 sets the array flag and the info word), byte offset.</param>
/// <returns>The buffer.</returns>
public static byte[] Build(
string udtName, uint instanceSize, params (string name, ushort type, ushort arraySize, uint offset)[] members)
{
const int header = 14;
var buf = new MemoryStream();
var head = new byte[header + (8 * members.Length)];
BinaryPrimitives.WriteUInt16LittleEndian(head.AsSpan(0), 0x0123); // template id
BinaryPrimitives.WriteUInt32LittleEndian(head.AsSpan(2), 0); // def size (words)
BinaryPrimitives.WriteUInt32LittleEndian(head.AsSpan(6), instanceSize);
BinaryPrimitives.WriteUInt16LittleEndian(head.AsSpan(10), (ushort)members.Length);
BinaryPrimitives.WriteUInt16LittleEndian(head.AsSpan(12), 0xABCD); // struct handle
for (var i = 0; i < members.Length; i++)
{
var o = header + (i * 8);
var (_, type, arraySize, offset) = members[i];
var typeWord = arraySize > 0 ? (ushort)(type | 0x2000) : type;
BinaryPrimitives.WriteUInt16LittleEndian(head.AsSpan(o), arraySize);
BinaryPrimitives.WriteUInt16LittleEndian(head.AsSpan(o + 2), typeWord);
BinaryPrimitives.WriteUInt32LittleEndian(head.AsSpan(o + 4), offset);
}
buf.Write(head);
buf.Write(Encoding.ASCII.GetBytes(udtName + ";nEAIA\0"));
foreach (var m in members) buf.Write(Encoding.ASCII.GetBytes(m.name + "\0"));
return buf.ToArray();
}
}
@@ -43,3 +43,18 @@ Copy-Item "$src\Dockerfile" "$dest\"
Update the snapshot date at the top of this file afterward. No other
files belong here — the Docker build context is just the Python package
and its metadata.
## Local divergence (do not lose on refresh)
`src/focas_mock/server.py` and `defaults.py` carry OtOpcUa-local edits that make the wire
shapes match a real FANUC 31i-B (Makino Z-34184) instead of shapes the driver and the mock had
agreed on between themselves. A refresh that overwrites `src/` must re-apply them:
- the little-endian `cnc_rdtimer` minute/msec encoding (2026-06-25, live-validated);
- the alarm count cap on `cnc_rdalmmsg2`;
- (2026-10-07, ported from the sister ignitionoee FOCAS mock) `0x00D3` ("cnc_getfigure", not a
real FOCAS wire command) refused with `EW_FUNC`; `0x0026` position blocks carrying their own
`base/exponent` (`position_figures` is now that exponent) and a signed `arg2`;
`cnc_rdmacro` as the 8-byte ODBM shape (`dec_val` at `[6..7]`, 9-digit mantissa, unset
macro = vacant); servo/spindle meter records with the `base/exponent` envelope, spindle
meters in 1/16384-of-full-scale counts.
@@ -78,9 +78,12 @@ def make_default_state(profile: Mapping[str, Any]) -> dict[str, Any]:
"acts": 3200,
"acts2": [3200] + [0] * (max_spindles - 1),
"axis_names": axis_names[:max_axis],
# Per-axis position decimal places served by cnc_getfigure (command 0x00d3).
# Default every configured axis to 0 ⇒ 10^0 = 1.0 scale, leaving existing
# position reads unchanged. mock_patch can override individual axes.
# Per-axis position scale, served INSIDE each cnc_rddynamic2 (0x0026) block as
# base 10 / this exponent — NOT by a separate command (0x00d3 "cnc_getfigure" was
# never real; the mock refuses it with EW_FUNC like the control does). Default 0 ⇒
# 10^0 = 1.0, leaving existing position reads unchanged. mock_patch can override an
# axis with an int exponent, or a dict {"base": .., "exponent": ..} — e.g.
# {"exponent": 65535} for the vacant marker.
"position_figures": {name: 0 for name in axis_names[:max_axis]},
"spindle_names": spindle_names,
"parameters": {
@@ -7,6 +7,7 @@ from typing import Any, Awaitable, Callable
from .constants import (
ADMIN_METHODS,
EW_ATTRIB,
EW_FUNC,
EW_HANDLE,
EW_NUMBER,
@@ -24,7 +25,52 @@ from .profiles import load_profile
Handler = Callable[[dict[str, Any]], Awaitable[tuple[int, dict[str, Any] | None, str | None]]]
class WireRefusal(Exception):
"""One response block refuses with this FOCAS ``rc``.
Raised from a payload builder when the COMMAND exists but its ARGUMENTS name nothing the
control would answer (``cnc_rddynamic2`` with ``arg2 = 0``, for instance). Command-level
refusals live in ``FocasMockServer._WIRE_REFUSED_COMMANDS``. Ported from the sister
ignitionoee mock (Focas.Wire-020).
"""
def __init__(self, rc: int) -> None:
super().__init__(f"refused with rc={rc}")
self.rc = rc
class FocasMockServer:
# OtOpcUa-local changes to this vendored snapshot (2026-10-07), ported from the sister
# ignitionoee FOCAS mock so the wire shapes match a real FANUC 31i-B (Makino Z-34184) rather
# than shapes the driver and the mock once agreed on between themselves:
# - 0x00D3 ("cnc_getfigure") is refused with EW_FUNC, as the real control does;
# - 0x0026 position blocks carry their own base/exponent;
# - cnc_rdmacro is the 8-byte ODBM shape with dec_val at [6..7];
# - servo/spindle meter records carry the base/exponent envelope, spindle meters in
# 1/16384-of-full-scale counts.
# The little-endian cnc_rdtimer encoding and the alarm count cap stay as they were.
# cnc_rdmacro (ODBM) encoding constants — see _wire_rdmacro / _normalize_macro.
_MACRO_ODBM_LENGTH = 10
_MACRO_VACANT_DEC = -1
_MACRO_SIGNIFICANT_DIGITS = 9
# Commands this mock REFUSES, and with which rc — the real control's answer. 0x00D3 does not
# exist in the FOCAS wire protocol; serving it let a driver validate a command the real
# 31i-B refuses with rc=1.
_WIRE_REFUSED_COMMANDS = {0xD3: EW_FUNC}
# Default base served inside a 0x0026 position block (every block of the live 31i-B
# capture reports base 10).
_POSITION_DEFAULT_BASE = 10
# Servo/spindle meter records carry the same base/exponent envelope (live: base 10, exp 0).
_METER_BASE = 10
# SPINDLE meters (0x0040) are a fraction of full scale in 1/16384 units: 16384 = 100% of
# rated load (metric 0) or of maximum spindle speed (metric 1). SERVO meters are direct %.
_METER_FULL_SCALE = 16384
def __init__(
self,
host: str,
@@ -208,10 +254,22 @@ class FocasMockServer:
def _wire_response_block(self, request_block: bytes) -> bytes:
command = int.from_bytes(request_block[6:8], "big") if len(request_block) >= 8 else 0
payload = self._wire_payload(command, request_block)
refused_rc = self._WIRE_REFUSED_COMMANDS.get(command)
if refused_rc is not None:
return self._make_wire_response_block(command, b"", request_block, rc=refused_rc)
try:
payload = self._wire_payload(command, request_block)
except WireRefusal as refusal:
return self._make_wire_response_block(command, b"", request_block, rc=refusal.rc)
return self._make_wire_response_block(command, payload, request_block)
def _make_wire_response_block(self, command: int, payload: bytes, request_block: bytes | None = None) -> bytes:
def _make_wire_response_block(
self,
command: int,
payload: bytes,
request_block: bytes | None = None,
rc: int = EW_OK,
) -> bytes:
block_len = 0x10 + len(payload)
block = bytearray(block_len)
block[0:2] = block_len.to_bytes(2, "big")
@@ -219,6 +277,7 @@ class FocasMockServer:
block[2:8] = request_block[2:8]
else:
block[6:8] = command.to_bytes(2, "big")
block[8:10] = int(rc).to_bytes(2, "big", signed=True)
block[14:16] = len(payload).to_bytes(2, "big")
block[16:] = payload
return bytes(block)
@@ -256,8 +315,6 @@ class FocasMockServer:
return self._wire_spindle_metric(request_block)
if command == 0x56:
return self._wire_servo_meter()
if command == 0xD3:
return self._wire_position_figures()
if command == 0x57:
mode = self.store.snapshot()["operation_mode"]
return self._u16(int(mode.get("mode", 0) if isinstance(mode, dict) else mode))
@@ -322,23 +379,54 @@ class FocasMockServer:
return self._u32(value)
def _wire_dynamic_axis(self, request_block: bytes) -> bytes:
"""Serves `cnc_rddynamic2` (0x0026) positions, honouring a SIGNED `arg2` the way the real
31i-B does (probed 2026-08-12 by the sister ignitionoee project, Focas.Wire-017/020):
`arg2 = 1..n` -> exactly one 8-byte block for that axis; `arg2 = -1` -> every slot in one
block; `arg2 = 0` or past the axis count -> refused (EW_ATTRIB)."""
selector = self._block_u32(request_block, 8)
axis = self._block_u32(request_block, 12)
axis = self._block_i32(request_block, 12)
field = {4: "absolute", 1: "machine", 6: "relative", 7: "distance"}.get(selector, "absolute")
state = self.store.snapshot()
names = list(state["axis_names"])
dynamic_axes = state["dynamic"]["axes"]
if axis == 0 or axis > len(names):
raise WireRefusal(EW_ATTRIB)
if axis > 0:
name = names[axis - 1]
return self._wire_position_block(state, name, dynamic_axes.get(name, {}).get(field, 0))
payload = bytearray()
for name in names:
position = dynamic_axes.get(name, {})
payload += self._u32(int(position.get(field, 0)))
payload += self._u32(0)
if axis > 0 and axis <= len(names):
name = names[axis - 1]
position = dynamic_axes.get(name, {})
return self._u32(int(position.get(field, 0))) + self._u32(0)
payload += self._wire_position_block(state, name, dynamic_axes.get(name, {}).get(field, 0))
return bytes(payload)
def _wire_position_block(self, state: dict[str, Any], name: str, raw: int) -> bytes:
"""Encodes one 8-byte `cnc_rddynamic2` position block: value, then its OWN scale.
[0..3] i32 raw value
[4..5] u16 base (2 or 10)
[6..7] u16 exponent (0xFFFF = the slot holds no valid position)
`position_figures[name]` is the exponent (base 10); a dict value
`{"base": .., "exponent": ..}` overrides the base too, so a profile can serve a
deliberately undecodable block and prove the client Bads that one axis node.
"""
base, exponent = self._position_scale(state, name)
return (
self._u32(int(raw))
+ (base & 0xFFFF).to_bytes(2, "big")
+ (exponent & 0xFFFF).to_bytes(2, "big")
)
def _position_scale(self, state: dict[str, Any], name: str) -> tuple[int, int]:
"""Returns the (base, exponent) pair `position_figures` configures for one axis."""
configured = state.get("position_figures", {}).get(name, 0)
if isinstance(configured, dict):
return (
int(configured.get("base", self._POSITION_DEFAULT_BASE)),
int(configured.get("exponent", 0)),
)
return self._POSITION_DEFAULT_BASE, int(configured)
def _wire_wrparam(self, request_block: bytes) -> None:
datano = self._block_u32(request_block, 8)
extra = request_block[28:]
@@ -346,11 +434,38 @@ class FocasMockServer:
self.store.merge_patch({"parameters": {str(datano): {"type": "long", "value": value, "decimal": 0}}})
def _wire_rdmacro(self, request_block: bytes) -> bytes:
"""Encodes an ODBM `cnc_rdmacro` (0x0015) response payload, as a real 31i-B sends it
(golden capture `ref-macro-500-0x0015.bin`: `05 f5 e1 00 00 0a 00 08` = 1.0):
[0..3] i32 mantissa (normalised to 9 significant digits)
[4..5] i16 length field — a CONSTANT 10
[6..7] i16 dec_val — decimal places; 0xFFFF (-1) = VACANT
A macro the profile does not mock is emitted as vacant, as a real control answers an
undefined variable.
"""
number = self._block_u32(request_block, 8)
macro = self.store.snapshot()["macros"].get(str(number), {})
value = int(macro.get("value", 0)) if isinstance(macro, dict) else 0
decimal = int(macro.get("decimal", 0)) if isinstance(macro, dict) else 0
return self._u32(value) + self._u16(decimal) + self._u16(0)
macro = self.store.snapshot()["macros"].get(str(number))
if isinstance(macro, dict):
mantissa, dec_val = self._normalize_macro(
int(macro.get("value", 0)), int(macro.get("decimal", 0))
)
else:
mantissa, dec_val = 0, self._MACRO_VACANT_DEC
return self._u32(mantissa) + self._u16(self._MACRO_ODBM_LENGTH) + self._u16(dec_val)
def _normalize_macro(self, value: int, decimal: int) -> tuple[int, int]:
"""Turns a configured fixed-point `(value, decimal)` into the `(mantissa, dec_val)` a CNC
returns: the mantissa padded to 9 significant digits, `dec_val` raised to match
(live: `#502 = 270` -> `270000000/10^6`). The real value is unchanged."""
if decimal < 0:
return 0, self._MACRO_VACANT_DEC
if value == 0:
return 0, decimal
pad = self._MACRO_SIGNIFICANT_DIGITS - len(str(abs(value)))
if pad <= 0:
return value, decimal
return value * (10**pad), decimal + pad
def _wire_wrmacro(self, request_block: bytes) -> None:
number = self._block_u32(request_block, 8)
@@ -377,31 +492,34 @@ class FocasMockServer:
payload = bytearray()
for name in state["axis_names"]:
entry = configured.get(name, {"name": name, "value": 0})
# 8-byte record (live 31i-B): value, then the base/exponent envelope. No axis name —
# the client pairs names positionally from the companion 0x0089 block.
payload += self._u32(int(entry.get("value", 0)))
payload += self._u16(0)
payload += self._u16(1)
payload += self._name_record(name, 4)
return bytes(payload)
def _wire_position_figures(self) -> bytes:
state = self.store.snapshot()
figures = state.get("position_figures", {})
payload = bytearray()
for name in state["axis_names"]:
payload += self._u16(int(figures.get(name, 0)))
payload += self._u16(self._METER_BASE)
payload += self._u16(int(entry.get("exponent", 0)))
return bytes(payload)
def _wire_spindle_metric(self, request_block: bytes) -> bytes:
# Both metrics are 1/16384-of-full-scale COUNTS on the wire, not the human units the
# config holds. Metric 0: load % of rated (16384 = 100%). Metric 1: motor speed as a
# fraction of the spindle's maximum (`speed / max_rpm`) — NOT the maximum itself.
metric = self._block_u32(request_block, 8)
spindle = self.store.snapshot()["spindle"]
if metric == 1:
values = [int(value) for value in spindle.get("max_rpm", [])]
else:
values = [int(entry.get("load", 0)) for entry in spindle.get("load", [])]
maxima = [int(value) for value in spindle.get("max_rpm", [])]
entries = spindle.get("load", [])
values = []
for index, entry in enumerate(entries):
if metric == 1:
maximum = maxima[index] if index < len(maxima) else 0
speed = int(entry.get("speed", 0))
values.append(round(speed * self._METER_FULL_SCALE / maximum) if maximum else 0)
else:
values.append(round(int(entry.get("load", 0)) * self._METER_FULL_SCALE / 100))
payload = bytearray()
for value in values[:4]:
payload += self._u32(value)
payload += self._u32(0)
payload += self._u16(self._METER_BASE)
payload += self._u16(0)
return bytes(payload)
def _wire_program_name(self) -> bytes:
@@ -480,6 +598,12 @@ class FocasMockServer:
return 0
return int.from_bytes(block[offset : offset + 4], "big")
def _block_i32(self, block: bytes, offset: int) -> int:
"""A SIGNED request argument — `-1` is a real selector (all axes / all spindles)."""
if offset + 4 > len(block):
return 0
return int.from_bytes(block[offset : offset + 4], "big", signed=True)
def _u16(self, value: int) -> bytes:
return int(value).to_bytes(2, "big", signed=True)
@@ -236,14 +236,15 @@ public sealed class WireBackendCoverageTests
}
/// <summary>
/// Verifies that per-axis position figures fetched via <c>cnc_getfigure</c> (wire command
/// 0x00D3) scale the published <c>AbsolutePosition</c>, and that the auto figure wins over
/// the configured <c>PositionDecimalPlaces</c> fallback knob.
/// Seed: X axis absolute = 12345, figure = 3 → published value = 12.345 (÷ 10^3).
/// Config knob = 1 → fallback would give 1234.5. 12.345 uniquely proves the wire path.
/// Verifies that the scale carried INSIDE each <c>cnc_rddynamic2</c> position block
/// (base 10 / exponent) scales the published <c>AbsolutePosition</c>, and that it wins over
/// the configured <c>PositionDecimalPlaces</c> fallback knob. (The mock refuses the old
/// <c>cnc_getfigure</c> 0x00D3 with EW_FUNC, as a real 31i-B does.)
/// Seed: X axis absolute = 12345, exponent = 3 → published value = 12.345 (÷ 10^3).
/// Config knob = 1 → fallback would give 1234.5. 12.345 uniquely proves the block scale.
/// </summary>
[Fact]
public async Task Position_figures_scale_axis_via_wire_backend()
public async Task Position_block_scale_scales_axis_via_wire_backend()
{
if (_fx.SkipReason is not null) Assert.Skip(_fx.SkipReason);
var ct = TestContext.Current.CancellationToken;
@@ -261,13 +262,13 @@ public sealed class WireBackendCoverageTests
X = new { absolute = 12345, machine = 12345, relative = 0, distance = 0 },
},
},
// Per-axis decimal-place figures for cnc_getfigure (command 0x00D3).
// The mock's _wire_position_figures() reads state["position_figures"][axisName].
// Per-axis exponent served inside each 0x0026 position block (base 10).
// The mock's _position_scale() reads state["position_figures"][axisName].
position_figures = new { X = 3 },
}, ct);
// PositionDecimalPlaces = 1 is intentionally different from the auto figure (3)
// so the assertion 12.345 uniquely proves the cnc_getfigure path won.
// PositionDecimalPlaces = 1 is intentionally different from the block exponent (3)
// so the assertion 12.345 uniquely proves the block's own scale won.
var driver = new FocasDriver(new FocasDriverOptions
{
Devices = [new FocasDeviceOptions(DeviceHost, PositionDecimalPlaces: 1)],
@@ -295,13 +296,90 @@ public sealed class WireBackendCoverageTests
state.ShouldNotBeNull();
var published = state.LastFixedSnapshots[$"{DeviceHost}/Axes/X/AbsolutePosition"];
// 12345 ÷ 10^3 = 12.345 → auto figure (3) won.
// 12345 ÷ 10^3 = 12.345 → the block's own scale (3) won.
// 12345 ÷ 10^1 = 1234.5 → would mean config knob (1) was used instead.
// 12345 ÷ 10^0 = 12345.0 → would mean no scaling at all.
published.ShouldBe(12.345, tolerance: 0.0001);
}
}
/// <summary>
/// "The control described no value" reads Bad over the real wire, never a Good number:
/// a vacant <c>0x0026</c> position block (exponent <c>0xFFFF</c>) Bads only its own axis
/// node, an unset macro (vacant <c>dec_val</c>) Bads its tag, and a fractional macro on an
/// integer tag is refused rather than truncated — while a normalised 9-digit macro
/// mantissa de-scales to its real value.
/// </summary>
[Fact]
public async Task Vacant_positions_and_macros_read_bad_over_the_wire()
{
if (_fx.SkipReason is not null) Assert.Skip(_fx.SkipReason);
var ct = TestContext.Current.CancellationToken;
await _fx.LoadProfileAsync("FWLIB64", ct);
await _fx.PatchStateAsync(new
{
axis_names = new[] { "X", "Y" },
dynamic = new
{
alarm = 0, prgnum = 1, prgmnum = 1, seqnum = 1, actf = 0, acts = 0,
axes = new
{
X = new { absolute = 1, machine = 1, relative = 0, distance = 0 },
Y = new { absolute = 370177, machine = 370177, relative = 0, distance = 0 },
},
},
position_figures = new Dictionary<string, object>
{
["X"] = new { exponent = 0xFFFF }, // vacant-slot marker
["Y"] = 3,
},
macros = new Dictionary<string, object>
{
["502"] = new { value = 270, @decimal = 0 }, // served as 270000000 / 10^6
["508"] = new { value = 1485, @decimal = 1 }, // 148.5
},
}, ct);
var drv = new FocasDriver(new FocasDriverOptions
{
Devices = [new FocasDeviceOptions(DeviceHost)],
RawTags = FocasRawTags.Entries(
new FocasTagDefinition("Count502", DeviceHost, "MACRO:502", FocasDataType.Int32, Writable: false),
new FocasTagDefinition("Frac508", DeviceHost, "MACRO:508", FocasDataType.Int32, Writable: false),
new FocasTagDefinition("Unset611", DeviceHost, "MACRO:611", FocasDataType.Float64, Writable: false)),
Probe = new FocasProbeOptions { Enabled = false },
FixedTree = new FocasFixedTreeOptions
{
Enabled = true,
PollInterval = TimeSpan.FromMilliseconds(100),
ProgramPollInterval = TimeSpan.Zero,
TimerPollInterval = TimeSpan.Zero,
},
}, driverInstanceId: "wire-vacant", clientFactory: new WireFocasClientFactory());
await using (drv)
{
await drv.InitializeAsync("{}", ct);
await WaitFor(() =>
drv.GetDeviceState(DeviceHost)?.LastFixedSnapshots
.ContainsKey($"{DeviceHost}/Axes/Y/AbsolutePosition") == true,
TimeSpan.FromSeconds(5));
var snaps = await drv.ReadAsync(
[$"{DeviceHost}/Axes/X/AbsolutePosition", $"{DeviceHost}/Axes/Y/AbsolutePosition",
"Count502", "Frac508", "Unset611"], ct);
snaps[0].StatusCode.ShouldBe(FocasStatusMapper.BadNoData);
snaps[1].StatusCode.ShouldBe(FocasStatusMapper.Good);
Convert.ToDouble(snaps[1].Value).ShouldBe(370.177, tolerance: 1e-9);
snaps[2].StatusCode.ShouldBe(FocasStatusMapper.Good);
snaps[2].Value.ShouldBe(270);
snaps[3].StatusCode.ShouldBe(FocasStatusMapper.BadTypeMismatch);
snaps[4].StatusCode.ShouldBe(FocasStatusMapper.BadNoData);
}
}
private static async Task WaitFor(Func<bool> pred, TimeSpan timeout)
{
var deadline = DateTime.UtcNow + timeout;
@@ -210,9 +210,13 @@ public sealed class WireBackendTests
}
}
/// <summary>Verifies that spindle load and max RPM data populate via the wire backend.</summary>
/// <summary>
/// Verifies that the spindle load and motor-speed meters populate via the wire backend as
/// percentages of full scale (the mock serves 1/16384-of-full-scale counts, as a real
/// 31i-B does): S1 load 56% / speed 3200 of 6000 rpm = 53.3%, S2 load 12% / 1800 of 4500 = 40%.
/// </summary>
[Fact]
public async Task Spindle_load_and_max_rpm_populate_via_wire_backend()
public async Task Spindle_load_and_motor_speed_populate_via_wire_backend()
{
if (_fx.SkipReason is not null) Assert.Skip(_fx.SkipReason);
var ct = TestContext.Current.CancellationToken;
@@ -260,21 +264,21 @@ public sealed class WireBackendTests
await WaitFor(() =>
{
var state = driver.GetDeviceState(DeviceHost);
return state?.FixedTreeCache is { Capabilities.SpindleLoad: true, Capabilities.SpindleMaxRpm: true }
return state?.FixedTreeCache is { Capabilities.SpindleLoad: true, Capabilities.SpindleMotorSpeed: true }
&& state.LastSpindleLoads.Count >= 2;
}, TimeSpan.FromSeconds(5));
var snapshots = await driver.ReadAsync(
[$"{DeviceHost}/Spindle/S1/Load",
$"{DeviceHost}/Spindle/S1/MaxRpm",
$"{DeviceHost}/Spindle/S1/MotorSpeedPct",
$"{DeviceHost}/Spindle/S2/Load",
$"{DeviceHost}/Spindle/S2/MaxRpm"], ct);
$"{DeviceHost}/Spindle/S2/MotorSpeedPct"], ct);
snapshots.ShouldAllBe(s => s.StatusCode == FocasStatusMapper.Good);
Convert.ToInt32(snapshots[0].Value).ShouldBe(56);
Convert.ToInt32(snapshots[1].Value).ShouldBe(6000);
Convert.ToInt32(snapshots[2].Value).ShouldBe(12);
Convert.ToInt32(snapshots[3].Value).ShouldBe(4500);
Convert.ToDouble(snapshots[0].Value).ShouldBe(56.0, tolerance: 0.01);
Convert.ToDouble(snapshots[1].Value).ShouldBe(3200 * 100.0 / 6000, tolerance: 0.01);
Convert.ToDouble(snapshots[2].Value).ShouldBe(12.0, tolerance: 0.01);
Convert.ToDouble(snapshots[3].Value).ShouldBe(40.0, tolerance: 0.01);
}
}
@@ -140,25 +140,18 @@ internal class FakeFocasClient : IFocasClient
public virtual Task<IReadOnlyList<FocasServoLoad>> GetServoLoadsAsync(CancellationToken ct) =>
Task.FromResult<IReadOnlyList<FocasServoLoad>>([.. ServoLoads]);
/// <summary>Gets the list of spindle loads.</summary>
public List<int> SpindleLoads { get; } = [];
/// <summary>Gets the list of spindle maximum RPMs.</summary>
public List<int> SpindleMaxRpms { get; } = [];
/// <summary>Gets the list of spindle load percentages (null = undescribable slot).</summary>
public List<double?> SpindleLoads { get; } = [];
/// <summary>Gets the list of spindle motor-speed percentages (null = undescribable slot).</summary>
public List<double?> SpindleMotorSpeeds { get; } = [];
/// <summary>Gets spindle loads asynchronously.</summary>
/// <param name="ct">The cancellation token.</param>
public virtual Task<IReadOnlyList<int>> GetSpindleLoadsAsync(CancellationToken ct) =>
Task.FromResult<IReadOnlyList<int>>([.. SpindleLoads]);
/// <summary>Gets spindle maximum RPMs asynchronously.</summary>
public virtual Task<IReadOnlyList<double?>> GetSpindleLoadsAsync(CancellationToken ct) =>
Task.FromResult<IReadOnlyList<double?>>([.. SpindleLoads]);
/// <summary>Gets spindle motor-speed percentages asynchronously.</summary>
/// <param name="ct">The cancellation token.</param>
public virtual Task<IReadOnlyList<int>> GetSpindleMaxRpmsAsync(CancellationToken ct) =>
Task.FromResult<IReadOnlyList<int>>([.. SpindleMaxRpms]);
/// <summary>Gets or sets the per-axis position decimal-place figures returned by <see cref="GetPositionFiguresAsync"/>.</summary>
public IReadOnlyList<int> PositionFigures { get; set; } = [];
/// <summary>Gets the per-axis position decimal-place figures asynchronously.</summary>
/// <param name="ct">The cancellation token.</param>
public virtual Task<IReadOnlyList<int>> GetPositionFiguresAsync(CancellationToken ct) =>
Task.FromResult(PositionFigures);
public virtual Task<IReadOnlyList<double?>> GetSpindleMotorSpeedsAsync(CancellationToken ct) =>
Task.FromResult<IReadOnlyList<double?>>([.. SpindleMotorSpeeds]);
/// <summary>Disposes the client.</summary>
public virtual void Dispose()
@@ -34,8 +34,8 @@ public sealed class FocasDuplicateSiblingNameTests
// Two spindles whose displays also collide, with distinct data per slot.
c.SpindleNames.Clear();
c.SpindleNames.AddRange([new("S", "1", "", ""), new("S", "1", "", "")]);
c.SpindleLoads.AddRange([5, 7]);
c.SpindleMaxRpms.AddRange([8_000, 12_000]);
c.SpindleLoads.AddRange([5.0, 7.0]);
c.SpindleMotorSpeeds.AddRange([40.0, 60.0]);
return c;
},
};
@@ -159,17 +159,17 @@ public sealed class FocasDuplicateSiblingNameTests
// The spindle read path matched by display name and took the FIRST index that matched,
// so a duplicate spindle name made the second spindle's data unreachable.
var drv = await StartedDriverAsync(
new FocasTagDefinition("raw/s1load", Host, "FIXED:Spindle/S1_1/Load", FocasDataType.Int32, Writable: false),
new FocasTagDefinition("raw/s2load", Host, "FIXED:Spindle/S1_2/Load", FocasDataType.Int32, Writable: false),
new FocasTagDefinition("raw/s1max", Host, "FIXED:Spindle/S1_1/MaxRpm", FocasDataType.Int32, Writable: false),
new FocasTagDefinition("raw/s2max", Host, "FIXED:Spindle/S1_2/MaxRpm", FocasDataType.Int32, Writable: false));
new FocasTagDefinition("raw/s1load", Host, "FIXED:Spindle/S1_1/Load", FocasDataType.Float64, Writable: false),
new FocasTagDefinition("raw/s2load", Host, "FIXED:Spindle/S1_2/Load", FocasDataType.Float64, Writable: false),
new FocasTagDefinition("raw/s1speed", Host, "FIXED:Spindle/S1_1/MotorSpeedPct", FocasDataType.Float64, Writable: false),
new FocasTagDefinition("raw/s2speed", Host, "FIXED:Spindle/S1_2/MotorSpeedPct", FocasDataType.Float64, Writable: false));
var read = await drv.ReadAsync(["raw/s1load", "raw/s2load", "raw/s1max", "raw/s2max"], CancellationToken.None);
var read = await drv.ReadAsync(["raw/s1load", "raw/s2load", "raw/s1speed", "raw/s2speed"], CancellationToken.None);
read[0].Value.ShouldBe(5);
read[1].Value.ShouldBe(7);
read[2].Value.ShouldBe(8_000);
read[3].Value.ShouldBe(12_000);
read[0].Value.ShouldBe(5.0);
read[1].Value.ShouldBe(7.0);
read[2].Value.ShouldBe(40.0);
read[3].Value.ShouldBe(60.0);
read.ShouldAllBe(r => r.StatusCode == FocasStatusMapper.Good);
await drv.ShutdownAsync(CancellationToken.None);
@@ -0,0 +1,143 @@
using System.Buffers.Binary;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Wire;
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests;
/// <summary>
/// <c>cnc_rdmacro</c> (0x0015) decode, pinned to real FANUC 31i-B payloads. Ported from the
/// sister ignitionoee FOCAS driver's v0.15.0 fix (review finding Focas.Wire-013 / Focas-017).
/// </summary>
/// <remarks>
/// The on-wire <c>ODBM</c> payload is 8 bytes: <c>[0..3] i32 mantissa</c>,
/// <c>[4..5] i16</c> — a <b>constant 10</b> on every observed response (a length field, NOT the
/// decimal), and <c>[6..7] i16 dec_val</c> (<c>0xFFFF</c> = vacant). Reading the decimal at
/// offset 4 decodes every macro on every control without ever failing — just wrongly: the
/// captured <c>#500 = 1.0</c> (<c>05 f5 e1 00 00 0a 00 08</c>) read as <c>100000000 / 10^10 =
/// 0.01</c>. FANUC normalises a macro to a 9-significant-digit mantissa, so the live vectors
/// below (Makino Z-34184, 2026-08-05) all carry large mantissas.
/// </remarks>
[Trait("Category", "Unit")]
public sealed class FocasMacroDecodeTests
{
private static byte[] Odbm(int mantissa, int decVal, int lengthField = 10)
{
var p = new byte[8];
BinaryPrimitives.WriteInt32BigEndian(p.AsSpan(0, 4), mantissa);
BinaryPrimitives.WriteUInt16BigEndian(p.AsSpan(4, 2), (ushort)lengthField);
BinaryPrimitives.WriteUInt16BigEndian(p.AsSpan(6, 2), (ushort)(decVal & 0xFFFF));
return p;
}
[Fact]
public void ParseMacro_decodes_the_live_capture_with_the_decimal_at_offset_6()
{
var payload = WireFixtures.Payload("ref-macro-500-0x0015.bin", 0x0015);
var m = FocasWireClient.ParseMacro(500, payload);
m.Number.ShouldBe((short)500);
m.Value.ShouldBe(100_000_000);
m.Decimal.ShouldBe((short)8); // offset 6 — NOT the constant 10 at offset 4
m.IsVacant.ShouldBeFalse();
m.Scaled!.Value.ShouldBe(1.0, 1e-12);
}
[Fact]
public void ParseMacro_ignores_the_length_field_at_offset_4()
{
var a = FocasWireClient.ParseMacro(508, Odbm(148_500_000, 6));
var b = FocasWireClient.ParseMacro(508, Odbm(148_500_000, 6, lengthField: 0));
b.ShouldBe(a);
b.Scaled!.Value.ShouldBe(148.5, 1e-9);
}
[Theory]
[InlineData(270_000_000, 6, 270.0)] // #502
[InlineData(148_500_000, 6, 148.5)] // #508
[InlineData(208_850_000, 4, 20885.0)] // #610
[InlineData(202_608_050, 1, 20_260_805.0)] // #3011 date
[InlineData(-100_000_000, 12, -1.0e-4)] // #802
[InlineData(969_479_000, 9, 0.969479)] // #801
[InlineData(1234, 0, 1234.0)]
public void ParseMacro_scales_the_live_vectors(int mantissa, int dec, double expected)
{
FocasWireClient.ParseMacro(1, Odbm(mantissa, dec)).Scaled!.Value.ShouldBe(expected, 1e-9);
}
[Fact]
public void A_vacant_macro_has_no_value_not_zero()
{
var m = FocasWireClient.ParseMacro(611, Odbm(0, 0xFFFF));
m.Decimal.ShouldBe((short)-1);
m.IsVacant.ShouldBeTrue();
m.Scaled.ShouldBeNull();
}
[Fact]
public void A_genuine_zero_is_not_vacant()
{
var m = FocasWireClient.ParseMacro(3901, Odbm(0, 0));
m.IsVacant.ShouldBeFalse();
m.Scaled.ShouldBe(0.0);
}
[Theory]
[InlineData(0)]
[InlineData(4)]
[InlineData(6)]
[InlineData(7)]
public void ParseMacro_refuses_a_payload_too_short_to_carry_dec_val(int length)
{
Should.Throw<FocasWireException>(() => FocasWireClient.ParseMacro(1, new byte[length]));
}
// ---- published value / quality ----
[Fact]
public void Float64_publishes_the_scaled_value_good()
{
var (value, status) = WireFocasClient.MapMacro(FocasWireClient.ParseMacro(508, Odbm(148_500_000, 6)), FocasDataType.Float64, null);
status.ShouldBe(FocasStatusMapper.Good);
value.ShouldBe(148.5);
}
[Fact]
public void Int32_publishes_the_exact_whole_value_not_the_mantissa()
{
var (value, status) = WireFocasClient.MapMacro(FocasWireClient.ParseMacro(502, Odbm(270_000_000, 6)), FocasDataType.Int32, null);
status.ShouldBe(FocasStatusMapper.Good);
value.ShouldBe(270);
}
[Fact]
public void A_fractional_macro_on_an_integer_tag_is_bad_not_truncated()
{
var (value, status) = WireFocasClient.MapMacro(FocasWireClient.ParseMacro(508, Odbm(148_500_000, 6)), FocasDataType.Int32, null);
status.ShouldBe(FocasStatusMapper.BadTypeMismatch);
value.ShouldBeNull();
}
[Theory]
[InlineData(FocasDataType.Float64)]
[InlineData(FocasDataType.Float32)]
[InlineData(FocasDataType.Int32)]
public void A_vacant_macro_is_bad_for_every_type(FocasDataType type)
{
var (value, status) = WireFocasClient.MapMacro(FocasWireClient.ParseMacro(611, Odbm(0, 0xFFFF)), type, null);
status.ShouldBe(FocasStatusMapper.BadNoData);
value.ShouldBeNull();
}
[Fact]
public void A_whole_macro_out_of_range_for_its_integer_type_is_bad()
{
// 70000 is whole, but does not fit Int16.
var (value, status) = WireFocasClient.MapMacro(FocasWireClient.ParseMacro(1, Odbm(700_000_000, 4)), FocasDataType.Int16, null);
status.ShouldBe(FocasStatusMapper.BadOutOfRange);
value.ShouldBeNull();
}
}
@@ -0,0 +1,127 @@
using System.Buffers.Binary;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Wire;
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests;
/// <summary>
/// Servo (<c>cnc_rdsvmeter</c>, 0x0056) and spindle (<c>cnc_rdspmeter</c>, 0x0040) meter decode.
/// Both are arrays of 8-byte records carrying the same self-describing envelope as every other
/// value block on this wire — <c>[+0..3] i32 value</c>, <c>[+4..5] u16 base</c> (2 or 10),
/// <c>[+6..7] u16 exponent</c> — so <c>scaled = value / base^exponent</c>.
/// Ported from the sister ignitionoee FOCAS driver v0.17.2 / v0.17.3 (Focas.Wire-019).
/// </summary>
/// <remarks>
/// <para><b>Servo: offset 4 is the BASE, not a decimal count.</b> It holds a steady 10, so the
/// old <c>value / 10^decimal</c> divided every load by 10^10 and published ~0 — on a real
/// Makino cutting at 20,000 rpm the panel read servo 0% on every axis. Servo meters are
/// <b>direct</b> percentages (the live capture shows −57..227).</para>
/// <para><b>Spindle: a fraction of full scale in 1/16384 units.</b> Settled on the real Z-34184
/// by regressing the type-1 meter against the independently read <c>cnc_rddynamic2</c> spindle
/// speed over 596–20,000 rpm: <c>rpm = 1.220749 × meter</c>, and <c>16384 × 1.220749 =
/// 20000.75</c> — full scale is the spindle's 20,000 rpm maximum. So the type-1 meter
/// (<c>cnc_rdspmaxrpm</c> by name) is the <b>motor speed as a % of maximum</b>, not a max-rpm
/// constant; the type-0 load meter shares the envelope and the 16384 = 100% scale (its
/// acceleration peaks pin at 32767 — one count short of a 200% overload).</para>
/// </remarks>
[Trait("Category", "Unit")]
public sealed class FocasMeterDecodeTests
{
private static byte[] Record(int value, int @base, int exponent)
{
var b = new byte[8];
BinaryPrimitives.WriteInt32BigEndian(b.AsSpan(0, 4), value);
BinaryPrimitives.WriteUInt16BigEndian(b.AsSpan(4, 2), (ushort)@base);
BinaryPrimitives.WriteUInt16BigEndian(b.AsSpan(6, 2), (ushort)exponent);
return b;
}
// ---- servo ----
[Fact]
public void The_live_servo_capture_reads_direct_percentages_not_value_over_ten_to_the_tenth()
{
var blocks = WireFixtures.Blocks("svmeter-pair-0x0056-0x0089.bin");
var meters = FocasWireClient.ParseServoMeters(
blocks.First(b => b.Command == 0x0056).Payload,
blocks.First(b => b.Command == 0x0089).Payload,
maxCount: 32);
meters[0].Base.ShouldBe(10);
meters[0].Exponent.ShouldBe(0);
meters.Take(3).Select(m => m.Scaled).ShouldBe([0.0, 50.0, -3.0]);
}
[Fact]
public void A_servo_record_applies_its_own_exponent()
{
var m = FocasWireClient.ParseServoMeters(Record(1234, 10, 1), Array.Empty<byte>(), 32).Single();
m.Scaled!.Value.ShouldBe(123.4, 1e-9);
}
[Theory]
[InlineData(5, 7, 0)] // base neither 2 nor 10
[InlineData(5, 10, 2560)] // residue past the controlled-axis count
public void An_undescribable_servo_record_has_no_value_not_zero(int value, int @base, int exponent)
{
var m = FocasWireClient.ParseServoMeters(Record(value, @base, exponent), Array.Empty<byte>(), 32).Single();
m.IsValid.ShouldBeFalse();
m.Scaled.ShouldBeNull();
}
// ---- spindle ----
[Fact]
public void The_live_spindle_load_capture_is_a_percentage_of_16384()
{
var metrics = FocasWireClient.ParseSpindleMetrics(
WireFixtures.Payload("live-spmeter-load-0x0040.bin", 0x0040));
// Slot 1: raw 0x23C3 = 9155, base 10, exponent 0 → 9155 × 100 / 16384 ≈ 55.88 %.
metrics[0].Value.ShouldBe(9155);
metrics[0].Percent!.Value.ShouldBe(9155 * 100.0 / 16384, 1e-9);
// Slot 2 is buffer residue (exponent 0x0A00 = 2560): no value, not a number.
metrics[1].IsValid.ShouldBeFalse();
metrics[1].Percent.ShouldBeNull();
}
[Fact]
public void The_live_motor_speed_capture_is_a_percentage_of_maximum_speed()
{
var metrics = FocasWireClient.ParseSpindleMetrics(
WireFixtures.Payload("live-spmeter-maxrpm-0x0040.bin", 0x0040));
// 0x3FFD = 16381 ≈ 99.98 % — a spindle at (just under) its 20,000 rpm maximum.
metrics[0].Value.ShouldBe(16381);
metrics[0].Percent!.Value.ShouldBe(16381 * 100.0 / 16384, 1e-9);
}
[Theory]
[InlineData(16382, 19998)]
[InlineData(12286, 14998)]
[InlineData(9830, 12000)]
[InlineData(695, 850)]
[InlineData(488, 595)]
public void Percent_reproduces_the_live_motor_speed_regression(int meter, int rpm)
{
var m = FocasWireClient.ParseSpindleMetrics(Record(meter, 10, 0)).Single();
m.Percent!.Value.ShouldBe(rpm * 100.0 / 20000.0, 0.02);
}
[Fact]
public void Full_scale_is_16384_and_overload_is_not_clamped()
{
WireSpindleMetric.FullScale.ShouldBe(16384);
FocasWireClient.ParseSpindleMetrics(Record(16384, 10, 0)).Single().Percent.ShouldBe(100.0);
var overload = FocasWireClient.ParseSpindleMetrics(Record(32767, 10, 0)).Single().Percent!.Value;
overload.ShouldBeInRange(199.9, 200.0);
}
[Fact]
public void The_spindle_envelope_exponent_applies_before_the_full_scale_division()
{
// 163840 / 10^1 = 16384 counts = 100 %.
FocasWireClient.ParseSpindleMetrics(Record(163840, 10, 1)).Single().Percent!.Value.ShouldBe(100.0, 1e-9);
}
}
@@ -0,0 +1,134 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests;
/// <summary>
/// Driver-level publish of servo / spindle meters (Focas.Wire-019 port): percentages publish
/// Good as Float64, an undescribable record publishes BadNoData (never a Good 0), and the
/// spindle motor-speed meter is a live, polled <c>MotorSpeedPct</c> — not the bootstrap-time
/// <c>MaxRpm</c> constant it was mislabelled as.
/// </summary>
[Trait("Category", "Unit")]
public sealed class FocasMeterPublishTests
{
private const string Host = "focas://10.0.0.5:8193";
private static FocasDriver NewDriver(FakeFocasClient client) =>
new(new FocasDriverOptions
{
Devices = [new FocasDeviceOptions(Host)],
Probe = new FocasProbeOptions { Enabled = false },
FixedTree = new FocasFixedTreeOptions { Enabled = true, PollInterval = TimeSpan.FromMilliseconds(10) },
}, "drv-1", new FakeFocasClientFactory { Customise = () => client });
private static async Task<DataValueSnapshot> ReadSettledAsync(FocasDriver drv, string reference, Func<DataValueSnapshot, bool> settled)
{
var deadline = DateTime.UtcNow + TimeSpan.FromSeconds(5);
DataValueSnapshot last;
do
{
last = (await drv.ReadAsync([reference], CancellationToken.None)).Single();
if (settled(last)) return last;
await Task.Delay(15);
} while (DateTime.UtcNow < deadline);
return last;
}
private static FakeFocasClient MachineClient()
{
var c = new FakeFocasClient();
c.AxisNames.Clear();
c.AxisNames.AddRange([new("X", ""), new("Y", "")]);
c.ServoLoads.AddRange([new("X", 46.0), new("Y", null)]); // Y: undescribable record
c.SpindleNames.Clear();
c.SpindleNames.Add(new("S", "1", "", ""));
c.SpindleLoads.Add(55.88);
c.SpindleMotorSpeeds.Add(2.49);
return c;
}
[Fact]
public async Task Servo_loads_publish_percentages_and_an_undescribable_record_is_bad()
{
var drv = NewDriver(MachineClient());
await drv.InitializeAsync("{}", CancellationToken.None);
var x = await ReadSettledAsync(drv, $"{Host}/Axes/X/ServoLoad", s => s.StatusCode == FocasStatusMapper.Good);
var y = await ReadSettledAsync(drv, $"{Host}/Axes/Y/ServoLoad", s => s.StatusCode == FocasStatusMapper.BadNoData);
x.Value.ShouldBe(46.0);
y.StatusCode.ShouldBe(FocasStatusMapper.BadNoData);
y.Value.ShouldBeNull();
await drv.ShutdownAsync(CancellationToken.None);
}
[Fact]
public async Task Spindle_meters_publish_float64_percentages_and_motor_speed_is_polled_live()
{
var client = MachineClient();
var drv = NewDriver(client);
await drv.InitializeAsync("{}", CancellationToken.None);
var load = await ReadSettledAsync(drv, $"{Host}/Spindle/S1/Load", s => s.StatusCode == FocasStatusMapper.Good);
load.Value.ShouldBe(55.88);
(await ReadSettledAsync(drv, $"{Host}/Spindle/S1/MotorSpeedPct", s => s.StatusCode == FocasStatusMapper.Good))
.Value.ShouldBe(2.49);
// The spindle speeds up: the poll loop must follow it (MaxRpm was read once at bootstrap).
client.SpindleMotorSpeeds[0] = 99.98;
(await ReadSettledAsync(drv, $"{Host}/Spindle/S1/MotorSpeedPct", s => Equals(s.Value, 99.98)))
.Value.ShouldBe(99.98);
await drv.ShutdownAsync(CancellationToken.None);
}
[Fact]
public async Task An_undescribable_spindle_slot_is_bad_not_zero()
{
var client = MachineClient();
client.SpindleLoads[0] = null;
var drv = NewDriver(client);
await drv.InitializeAsync("{}", CancellationToken.None);
var load = await ReadSettledAsync(drv, $"{Host}/Spindle/S1/Load", s => s.StatusCode == FocasStatusMapper.BadNoData);
load.StatusCode.ShouldBe(FocasStatusMapper.BadNoData);
load.Value.ShouldBeNull();
await drv.ShutdownAsync(CancellationToken.None);
}
[Fact]
public async Task The_spindle_folder_offers_MotorSpeedPct_not_MaxRpm()
{
var drv = NewDriver(MachineClient());
await drv.InitializeAsync("{}", CancellationToken.None);
await ReadSettledAsync(drv, $"{Host}/Spindle/S1/Load", s => s.StatusCode == FocasStatusMapper.Good);
var builder = new NameRecordingBuilder();
await drv.DiscoverAsync(builder, CancellationToken.None);
builder.Variables.ShouldContain("MotorSpeedPct");
builder.Variables.ShouldNotContain("MaxRpm");
await drv.ShutdownAsync(CancellationToken.None);
}
private sealed class NameRecordingBuilder : IAddressSpaceBuilder
{
public List<string> Variables { get; } = [];
public IAddressSpaceBuilder Folder(string browseName, string displayName) => this;
public IVariableHandle Variable(string browseName, string displayName, DriverAttributeInfo attributeInfo)
{
Variables.Add(browseName);
return new Handle(attributeInfo.FullName);
}
public void AddProperty(string browseName, DriverDataType dataType, object? value) { }
private sealed class Handle(string fullRef) : IVariableHandle
{
public string FullReference => fullRef;
public IAlarmConditionSink MarkAsAlarmCondition(AlarmConditionInfo info) => throw new NotSupportedException();
}
}
}
@@ -0,0 +1,78 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Wire;
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests;
/// <summary>
/// Axis-name (0x0089) / spindle-name (0x008A) record decode. Each record is 4 bytes; the name is
/// the ASCII up to the record's FIRST NUL. Ported from the sister ignitionoee FOCAS driver
/// (Focas.Wire-001 and Focas.Wire-022, v0.18.1).
/// </summary>
/// <remarks>
/// <para><b>Reading only 2 bytes collides real names.</b> The live 31i-B capture
/// (<c>ref-axisname-0x0089.bin</c>) carries <c>"AA6"</c> and <c>"AA7"</c>; the old 2-byte read
/// turned both into <c>"AA"</c>, which is exactly the "one observed Makino returns two axis
/// folders both called AA" that Gitea #548 then had to disambiguate as <c>AA_1</c>/<c>AA_2</c>.</para>
/// <para><b>Trailing-only trimming keeps buffer residue.</b> A control writes only the bytes the
/// name needs; on Makino Z-34184 (2026-08-19) <c>cnc_rdspdlname</c> came back
/// <c>53 31 00 4e</c> — <c>"S1"</c> then the <c>'N'</c> of an earlier <c>"CNC"</c>. The name
/// ends at the first NUL.</para>
/// </remarks>
[Trait("Category", "Unit")]
public sealed class FocasNameRecordTests
{
[Fact]
public void The_live_axis_name_capture_keeps_three_character_names_distinct()
{
var payload = WireFixtures.Payload("ref-axisname-0x0089.bin", 0x0089);
var names = FocasWireClient.ReadNameRecords(payload, 32, (index, name) => (index, name));
names.Select(n => n.name).ShouldBe(["X", "Y", "Z", "B", "C", "AA6", "AA7"]);
names.Select(n => n.index).ShouldBe([(short)1, 2, 3, 4, 5, 6, 7]);
}
[Fact]
public void The_live_spindle_name_capture_decodes()
{
var payload = WireFixtures.Payload("ref-spdlname-0x008a.bin", 0x008a);
FocasWireClient.ReadNameRecords(payload, 8, (_, name) => name).ShouldBe(["S1"]);
}
[Fact]
public void A_name_ends_at_its_first_NUL_so_residue_after_it_is_discarded()
{
// The Z-34184 spindle-name record: "S1", NUL, then an 'N' left over from a previous "CNC".
FocasWireProtocol.ReadNameRecord([0x53, 0x31, 0x00, 0x4e]).ShouldBe("S1");
// A single-character name with residue in BOTH trailing bytes.
FocasWireProtocol.ReadNameRecord([0x58, 0x00, 0x43, 0x4e]).ShouldBe("X");
}
[Fact]
public void All_four_record_bytes_are_name_bytes()
{
FocasWireProtocol.ReadNameRecord("AA6\0"u8.ToArray()).ShouldBe("AA6");
FocasWireProtocol.ReadNameRecord("ABCD"u8.ToArray()).ShouldBe("ABCD");
FocasWireProtocol.ReadNameRecord("X \0\0"u8.ToArray()).ShouldBe("X");
}
[Fact]
public void A_record_that_starts_with_NUL_is_blank()
{
FocasWireProtocol.ReadNameRecord([0x00, 0x41, 0x41, 0x00]).ShouldBe(string.Empty);
}
[Fact]
public void Servo_meter_names_come_out_whole_too()
{
var blocks = WireFixtures.Blocks("svmeter-pair-0x0056-0x0089.bin");
var sv = blocks.First(b => b.Command == 0x0056).Payload;
var names = blocks.First(b => b.Command == 0x0089).Payload;
var meters = FocasWireClient.ParseServoMeters(sv, names, maxCount: 32);
meters.Take(7).Select(m => m.Name).ShouldBe(["X", "Y", "Z", "B", "C", "AA6", "AA7"]);
}
}
@@ -1,164 +0,0 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests;
/// <summary>
/// Phase 4b — per-axis auto-scale from <c>cnc_getfigure</c> figures. The driver fetches
/// per-axis position decimal-place figures at init and applies them at the
/// <see cref="FocasDriver"/> publish seam. Precedence: an auto figure for an axis WINS;
/// the configured <c>PositionDecimalPlaces</c> is the per-axis fallback when the CNC did
/// not report a figure for that axis. These tests drive the Fake to exercise the driver's
/// auto-scale-vs-fallback logic independently of the wire path.
/// </summary>
[Trait("Category", "Unit")]
public sealed class FocasPositionAutoScaleTests
{
private const string Host = "focas://10.0.0.5:8193";
// A single-axis CNC (X only) so the snapshot we seed is unambiguous.
private static FakeFocasClient SingleAxisClient(
FocasDynamicSnapshot snap, IReadOnlyList<int> figures)
{
var c = new FakeFocasClient();
c.AxisNames.Clear();
c.AxisNames.Add(new("X", ""));
c.DynamicByAxis[1] = snap; // FOCAS axis indexing is 1-based
c.PositionFigures = figures;
return c;
}
// A two-axis CNC (X, Y) — each axis gets its own seeded snapshot + figure.
private static FakeFocasClient TwoAxisClient(
FocasDynamicSnapshot xSnap, FocasDynamicSnapshot ySnap, IReadOnlyList<int> figures)
{
var c = new FakeFocasClient();
c.AxisNames.Clear();
c.AxisNames.Add(new("X", ""));
c.AxisNames.Add(new("Y", ""));
c.DynamicByAxis[1] = xSnap; // axis 0 → 1-based index 1
c.DynamicByAxis[2] = ySnap; // axis 1 → 1-based index 2
c.PositionFigures = figures;
return c;
}
private static FocasDynamicSnapshot AbsSnap(int axisIndex, int absolutePosition) =>
new(AxisIndex: axisIndex, AlarmFlags: 0, ProgramNumber: 0, MainProgramNumber: 0,
SequenceNumber: 0, ActualFeedRate: 0, ActualSpindleSpeed: 0,
AbsolutePosition: absolutePosition, MachinePosition: 0,
RelativePosition: 0, DistanceToGo: 0);
private static FocasDriver NewDriver(int positionDecimalPlaces, FakeFocasClientFactory factory)
{
return new FocasDriver(new FocasDriverOptions
{
Devices = [new FocasDeviceOptions(Host, PositionDecimalPlaces: positionDecimalPlaces)],
Probe = new FocasProbeOptions { Enabled = false },
FixedTree = new FocasFixedTreeOptions
{
Enabled = true,
PollInterval = TimeSpan.FromMilliseconds(10),
},
}, "drv-1", factory);
}
// Drive the fixed-tree poll loop and wait (bounded) for the cached value to appear.
private static async Task<object?> ReadWhenPublishedAsync(FocasDriver drv, string reference)
{
var deadline = DateTime.UtcNow + TimeSpan.FromSeconds(5);
while (DateTime.UtcNow < deadline)
{
var snaps = await drv.ReadAsync([reference], CancellationToken.None);
var snap = snaps.Single();
if (snap.StatusCode == FocasStatusMapper.Good && snap.Value is not null)
return snap.Value;
await Task.Delay(15);
}
return null;
}
/// <summary>
/// An auto figure for an axis WINS over the configured manual value: figure 3 ⇒ ÷10^3
/// (12345 → 12.345), even though <c>PositionDecimalPlaces: 1</c> would have given ÷10.
/// </summary>
[Fact]
public async Task Auto_figure_wins_over_manual_config()
{
var factory = new FakeFocasClientFactory
{
Customise = () => SingleAxisClient(AbsSnap(1, 12345), figures: [3]),
};
var drv = NewDriver(positionDecimalPlaces: 1, factory);
await drv.InitializeAsync("{}", CancellationToken.None);
var abs = await ReadWhenPublishedAsync(drv, $"{Host}/Axes/X/AbsolutePosition");
abs.ShouldBe(12.345); // ÷10^3 (auto), NOT 1234.5 (manual ÷10)
await drv.ShutdownAsync(CancellationToken.None);
}
/// <summary>
/// When <c>cnc_getfigure</c> reports nothing (empty list — the real wire backend's
/// behaviour), the driver falls back to the configured <c>PositionDecimalPlaces</c>:
/// manual 2 ⇒ ÷10^2 (12345 → 123.45).
/// </summary>
[Fact]
public async Task Falls_back_to_manual_when_getfigure_empty()
{
var factory = new FakeFocasClientFactory
{
Customise = () => SingleAxisClient(AbsSnap(1, 12345), figures: []),
};
var drv = NewDriver(positionDecimalPlaces: 2, factory);
await drv.InitializeAsync("{}", CancellationToken.None);
var abs = await ReadWhenPublishedAsync(drv, $"{Host}/Axes/X/AbsolutePosition");
abs.ShouldBe(123.45); // ÷10^2 (manual fallback)
await drv.ShutdownAsync(CancellationToken.None);
}
/// <summary>
/// Figures are applied per-axis independently: figures [3, 1] ⇒ axis 0 ÷10^3 and
/// axis 1 ÷10^1.
/// </summary>
[Fact]
public async Task Per_axis_figures_scale_independently()
{
var factory = new FakeFocasClientFactory
{
Customise = () => TwoAxisClient(
AbsSnap(1, 12345), AbsSnap(2, 67890), figures: [3, 1]),
};
var drv = NewDriver(positionDecimalPlaces: 0, factory);
await drv.InitializeAsync("{}", CancellationToken.None);
var x = await ReadWhenPublishedAsync(drv, $"{Host}/Axes/X/AbsolutePosition");
var y = await ReadWhenPublishedAsync(drv, $"{Host}/Axes/Y/AbsolutePosition");
x.ShouldBe(12.345); // ÷10^3
y.ShouldBe(6789.0); // ÷10^1
await drv.ShutdownAsync(CancellationToken.None);
}
/// <summary>
/// The legacy manual-only path is unchanged: no auto figures + <c>PositionDecimalPlaces: 0</c>
/// ⇒ factor 1.0, so the raw integer is published widened to double (12345 → 12345.0).
/// </summary>
[Fact]
public async Task Manual_only_legacy_path_unchanged()
{
var factory = new FakeFocasClientFactory
{
Customise = () => SingleAxisClient(AbsSnap(1, 12345), figures: []),
};
var drv = NewDriver(positionDecimalPlaces: 0, factory);
await drv.InitializeAsync("{}", CancellationToken.None);
var abs = await ReadWhenPublishedAsync(drv, $"{Host}/Axes/X/AbsolutePosition");
abs.ShouldBe(12345.0); // factor 1.0 — raw widened to double
await drv.ShutdownAsync(CancellationToken.None);
}
}
@@ -0,0 +1,105 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests;
/// <summary>
/// Driver-level publish of self-describing <c>cnc_rddynamic2</c> positions. Each block's own
/// <c>base^exponent</c> wins over the configured <c>PositionDecimalPlaces</c> (which is now only
/// the fallback for a scale-less block), and a block describing no usable position Bads its own
/// node without disturbing its siblings. Replaces the <c>cnc_getfigure</c> (0x00D3) auto-scale
/// tests — that command is not a FOCAS wire command; the real 31i-B refuses it with <c>rc=1</c>.
/// </summary>
[Trait("Category", "Unit")]
public sealed class FocasPositionBlockScaleTests
{
private const string Host = "focas://10.0.0.5:8193";
private static FocasDynamicSnapshot Snap(int axisIndex, FocasPosition abs, FocasPosition mach) =>
new(AxisIndex: axisIndex, AlarmFlags: 0, ProgramNumber: 0, MainProgramNumber: 0,
SequenceNumber: 0, ActualFeedRate: 0, ActualSpindleSpeed: 0,
AbsolutePosition: abs, MachinePosition: mach, RelativePosition: 0, DistanceToGo: 0);
private static FocasDriver NewDriver(int positionDecimalPlaces, FakeFocasClient client) =>
new(new FocasDriverOptions
{
Devices = [new FocasDeviceOptions(Host, PositionDecimalPlaces: positionDecimalPlaces)],
Probe = new FocasProbeOptions { Enabled = false },
FixedTree = new FocasFixedTreeOptions { Enabled = true, PollInterval = TimeSpan.FromMilliseconds(10) },
}, "drv-1", new FakeFocasClientFactory { Customise = () => client });
private static async Task<DataValueSnapshot> ReadWhenPolledAsync(FocasDriver drv, string reference)
{
var deadline = DateTime.UtcNow + TimeSpan.FromSeconds(5);
DataValueSnapshot last;
do
{
// Until the first poll lands the reference resolves to nothing; once it has, the node
// reads either Good or (for an undescribable block) BadNoData.
last = (await drv.ReadAsync([reference], CancellationToken.None)).Single();
if (last.StatusCode is FocasStatusMapper.Good or FocasStatusMapper.BadNoData) return last;
await Task.Delay(15);
} while (DateTime.UtcNow < deadline);
return last;
}
private static FakeFocasClient XAxisClient(FocasDynamicSnapshot snap)
{
var c = new FakeFocasClient();
c.AxisNames.Clear();
c.AxisNames.Add(new("X", ""));
c.DynamicByAxis[1] = snap;
return c;
}
[Fact]
public async Task The_blocks_own_scale_wins_over_PositionDecimalPlaces()
{
// The live 31i-B shape: raw 2801574, base 10, exponent 4 → 280.1574 mm.
var client = XAxisClient(Snap(1, new FocasPosition(2_801_574, 10, 4, HasScale: true), 0));
var drv = NewDriver(positionDecimalPlaces: 1, client);
await drv.InitializeAsync("{}", CancellationToken.None);
var abs = await ReadWhenPolledAsync(drv, $"{Host}/Axes/X/AbsolutePosition");
abs.StatusCode.ShouldBe(FocasStatusMapper.Good);
((double)abs.Value!).ShouldBe(280.1574, 1e-9); // NOT 280157.4 (the ÷10 fallback)
await drv.ShutdownAsync(CancellationToken.None);
}
[Fact]
public async Task A_scale_less_block_falls_back_to_PositionDecimalPlaces()
{
var client = XAxisClient(Snap(1, 12345, 0));
var drv = NewDriver(positionDecimalPlaces: 2, client);
await drv.InitializeAsync("{}", CancellationToken.None);
var abs = await ReadWhenPolledAsync(drv, $"{Host}/Axes/X/AbsolutePosition");
abs.StatusCode.ShouldBe(FocasStatusMapper.Good);
abs.Value.ShouldBe(123.45);
await drv.ShutdownAsync(CancellationToken.None);
}
[Fact]
public async Task A_vacant_block_bads_its_own_node_and_not_its_sibling()
{
var client = XAxisClient(Snap(
1,
abs: new FocasPosition(0, 10, 0xFFFF, HasScale: true), // vacant-slot marker
mach: new FocasPosition(-22000, 10, 3, HasScale: true)));
var drv = NewDriver(positionDecimalPlaces: 0, client);
await drv.InitializeAsync("{}", CancellationToken.None);
var mach = await ReadWhenPolledAsync(drv, $"{Host}/Axes/X/MachinePosition");
var abs = await ReadWhenPolledAsync(drv, $"{Host}/Axes/X/AbsolutePosition");
mach.StatusCode.ShouldBe(FocasStatusMapper.Good);
mach.Value.ShouldBe(-22.0);
abs.StatusCode.ShouldBe(FocasStatusMapper.BadNoData);
abs.Value.ShouldBeNull("an undescribable block is not a Good 0.0 at the origin");
await drv.ShutdownAsync(CancellationToken.None);
}
}
@@ -0,0 +1,107 @@
using System.Buffers.Binary;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Wire;
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests;
/// <summary>
/// <c>cnc_rddynamic2</c> position blocks (0x0026) are self-describing: <c>[0..3] i32 raw</c>,
/// <c>[4..5] u16 base</c> (2 or 10), <c>[6..7] u16 exponent</c> (<c>0xFFFF</c> = vacant slot);
/// <c>value = raw / base^exponent</c>. Ported from the sister ignitionoee FOCAS driver v0.17.0
/// (which also deleted the fabricated <c>cnc_getfigure</c> 0x00D3 — the real 31i-B refuses it
/// with <c>rc=1</c>) and Focas.Wire-014 (absurd exponents are refused, not divided by).
/// </summary>
[Trait("Category", "Unit")]
public sealed class FocasPositionDecodeTests
{
private static byte[] Block(int raw, int @base, int exponent)
{
var b = new byte[8];
BinaryPrimitives.WriteInt32BigEndian(b.AsSpan(0, 4), raw);
BinaryPrimitives.WriteUInt16BigEndian(b.AsSpan(4, 2), (ushort)@base);
BinaryPrimitives.WriteUInt16BigEndian(b.AsSpan(6, 2), (ushort)exponent);
return b;
}
[Fact]
public void The_live_dynamic2_capture_scales_every_position_from_its_own_block()
{
// ref-dynamic2-axis1.bin: four 0x0026 blocks (absolute / machine / relative / distance of
// axis 1), every one base 10, exponent 4. Undecoded they read 2801574 / 2801200 / 32 / 0.
var dynamic = FocasWireClient.ParseDynamic2(WireFixtures.Blocks("ref-dynamic2-axis1.bin"));
dynamic.Axis.Absolute.ToEngineering(0)!.Value.ShouldBe(280.1574, 1e-9);
dynamic.Axis.Machine.ToEngineering(0)!.Value.ShouldBe(280.1200, 1e-9);
dynamic.Axis.Relative.ToEngineering(0)!.Value.ShouldBe(0.0032, 1e-9);
dynamic.Axis.Distance.ToEngineering(0)!.Value.ShouldBe(0.0, 1e-9);
dynamic.FeedRate.ShouldBe(0);
dynamic.SpindleSpeed.ShouldBe(0);
}
[Fact]
public void The_block_scale_wins_over_the_configured_decimal_places()
{
var p = FocasWireClient.ParsePositionBlock(Block(123456, 10, 3));
p.ToEngineering(fallbackDecimalPlaces: 1)!.Value.ShouldBe(123.456, 1e-9);
}
[Theory]
[InlineData("live-dynamic2-axis1-0x0026.bin", 0.0374)] // Z-34184 X
[InlineData("live-dynamic2-axis2-0x0026.bin", 370.177)] // Z-34184 Y
public void Axis_scoped_live_requests_return_that_axis_scaled(string fixture, double expected)
{
var blocks = WireFixtures.Blocks(fixture).Where(b => b.Command == 0x0026).ToList();
blocks.Count.ShouldBe(1, "an axis-scoped request returns exactly one 0x0026 block");
FocasWireClient.ParsePositionBlock(blocks[0].Payload).ToEngineering(0)!.Value.ShouldBe(expected, 1e-9);
}
[Theory]
[InlineData(40, 2, 3, 5.0)] // inch-mode binary base
[InlineData(-22000, 10, 3, -22.0)]
[InlineData(42, 10, 0, 42.0)]
public void Positions_scale_by_base_to_the_exponent(int raw, int @base, int exponent, double expected)
{
FocasWireClient.ParsePositionBlock(Block(raw, @base, exponent)).ToEngineering(0)!.Value.ShouldBe(expected, 1e-9);
}
[Theory]
[InlineData(1, 10, 0xFFFF)] // vacant-slot marker
[InlineData(1234, 7, 2)] // base neither 2 nor 10
[InlineData(1234, 0, 2)]
[InlineData(123456, 10, 400)] // would divide by Infinity → a Good 0.0 at the origin
[InlineData(123456, 10, 40)]
public void An_undescribable_block_has_no_value(int raw, int @base, int exponent)
{
var p = FocasWireClient.ParsePositionBlock(Block(raw, @base, exponent));
p.IsValid.ShouldBeFalse();
p.ToEngineering(3).ShouldBeNull();
}
[Fact]
public void Every_plausible_exponent_still_decodes()
{
for (var exp = 0; exp <= 18; exp++)
FocasWireClient.ParsePositionBlock(Block(1, 10, exp)).IsValid.ShouldBeTrue($"exponent {exp}");
}
[Fact]
public void A_block_without_a_scale_falls_back_to_the_configured_decimal_places()
{
// A 4-byte block carries no envelope (older simulators); only then does
// PositionDecimalPlaces apply.
var p = FocasWireClient.ParsePositionBlock([0x00, 0x00, 0x30, 0x39]); // 12345
p.HasScale.ShouldBeFalse();
p.ToEngineering(2)!.Value.ShouldBe(123.45, 1e-9);
p.ToEngineering(0)!.Value.ShouldBe(12345.0);
}
[Fact]
public void A_raw_integer_position_converts_implicitly_as_a_scale_less_value()
{
FocasPosition p = 12345;
p.HasScale.ShouldBeFalse();
p.Raw.ShouldBe(12345);
}
}
@@ -5,12 +5,11 @@ using ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests;
/// <summary>
/// Phase 4 data-type tier — axis-position scaling. <c>cnc_rddynamic2</c> returns
/// positions as scaled integers; the driver applies a <c>10^PositionDecimalPlaces</c>
/// divide at the <see cref="FocasDriver"/> publish seam so positions surface in
/// engineering units on the Float64 axis nodes. The managed wire backend fetches per-axis
/// figures live via <c>cnc_getfigure</c>; the config knob is the per-axis fallback used
/// when the CNC reports no figure for an axis. These tests cover the config-supplied path.
/// Phase 4 data-type tier — axis-position scaling, config-supplied fallback path. A real
/// CNC's <c>cnc_rddynamic2</c> position block carries its own <c>base^exponent</c> scale
/// (covered by <see cref="FocasPositionBlockScaleTests"/>); a scale-less position — the
/// raw integers these tests seed — is divided by <c>10^PositionDecimalPlaces</c> at the
/// <see cref="FocasDriver"/> publish seam.
/// </summary>
[Trait("Category", "Unit")]
public sealed class FocasPositionScalingTests
@@ -0,0 +1,92 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Wire;
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests;
/// <summary>
/// "The control did not say" must read Bad, never a fabricated Good 0. Ported from the sister
/// ignitionoee FOCAS driver (commit 101a3b1, 2026-08-12): a <c>cnc_exeprgname2</c> payload too
/// short to carry the O-number published <c>0</c> at Good quality — indistinguishable from a
/// 31i's genuine 0 for a path-executed program. The same idiom covered every sub-read of the
/// program bundle here: a refused <c>cnc_rdopmode</c> published Mode <c>0</c> (= MDI, a real
/// mode) and a refused <c>cnc_rdblkcount</c> a BlockCount of 0.
/// </summary>
[Trait("Category", "Unit")]
public sealed class FocasProgramInfoQualityTests
{
private const string Host = "focas://10.0.0.5:8193";
private static FocasResult<WireProgramName> Name(string name, int? oNumber) => new(0, new WireProgramName(name, oNumber));
[Fact]
public void A_payload_without_an_O_number_maps_to_null_not_zero()
{
var info = WireFocasClient.MapProgramInfo(Name("O1111", null), new(0, 42), new(0, 1));
info.Name.ShouldBe("O1111");
info.ONumber.ShouldBeNull();
info.BlockCount.ShouldBe(42);
info.Mode.ShouldBe(1);
}
[Fact]
public void A_genuine_zero_O_number_stays_zero()
{
// Live Z-34184: a 256-byte payload whose o_num is present and genuinely 0.
WireFocasClient.MapProgramInfo(Name("//CNC_MEM/USER/LIBRARY/O1111", 0), new(0, 0), new(0, 1))
.ONumber.ShouldBe(0);
}
[Fact]
public void Refused_sub_reads_map_to_null_not_a_fabricated_value()
{
var info = WireFocasClient.MapProgramInfo(Name("O1", 1), new FocasResult<int>(1, 0), new FocasResult<short>(1, 0));
info.BlockCount.ShouldBeNull();
info.Mode.ShouldBeNull("Mode 0 is MDI — a real mode, not 'unknown'");
var nameless = WireFocasClient.MapProgramInfo(new FocasResult<WireProgramName>(1, null), new(0, 3), new(0, 1));
nameless.Name.ShouldBeNull();
nameless.ONumber.ShouldBeNull();
}
[Fact]
public async Task A_missing_field_reads_BadNoData_while_its_siblings_stay_good()
{
var client = new FakeFocasClient { ProgramInfo = new FocasProgramInfo("O1111", null, 12, null) };
var drv = new FocasDriver(new FocasDriverOptions
{
Devices = [new FocasDeviceOptions(Host)],
Probe = new FocasProbeOptions { Enabled = false },
FixedTree = new FocasFixedTreeOptions
{
Enabled = true,
PollInterval = TimeSpan.FromMilliseconds(10),
ProgramPollInterval = TimeSpan.FromMilliseconds(10),
},
}, "drv-1", new FakeFocasClientFactory { Customise = () => client });
await drv.InitializeAsync("{}", CancellationToken.None);
var deadline = DateTime.UtcNow + TimeSpan.FromSeconds(5);
while (DateTime.UtcNow < deadline && drv.GetDeviceState(Host)?.LastProgramInfo is null)
await Task.Delay(15);
var read = await drv.ReadAsync(
[$"{Host}/Program/Name", $"{Host}/Program/BlockCount", $"{Host}/Program/ONumber",
$"{Host}/OperationMode/Mode", $"{Host}/OperationMode/ModeText"],
CancellationToken.None);
read[0].StatusCode.ShouldBe(FocasStatusMapper.Good);
read[0].Value.ShouldBe("O1111");
read[1].StatusCode.ShouldBe(FocasStatusMapper.Good);
read[1].Value.ShouldBe(12);
read[2].StatusCode.ShouldBe(FocasStatusMapper.BadNoData);
read[2].Value.ShouldBeNull();
read[3].StatusCode.ShouldBe(FocasStatusMapper.BadNoData);
read[4].StatusCode.ShouldBe(FocasStatusMapper.BadNoData);
await drv.ShutdownAsync(CancellationToken.None);
}
}
@@ -10,7 +10,7 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests;
/// Offline byte-level coverage for the managed FOCAS/2 wire-protocol decode layer
/// (<see cref="FocasWireProtocol"/>) — the big-endian framing + block-envelope decode that
/// is this driver's analogue of P/Invoke struct marshalling. Before this suite the entire
/// wire decode surface (including the <c>cnc_getfigure</c> figure stride) ran without a
/// wire decode surface ran without a
/// single byte-level test; every other test drives the <c>IFocasClient</c> seam through the
/// Fake, which bypasses all framing. Recorded as Driver.FOCAS-013.
/// </summary>
@@ -200,29 +200,6 @@ public sealed class FocasWireProtocolTests
Should.Throw<FocasWireException>(() => FocasWireProtocol.ParseResponseBlocks(body));
}
// ---- The cnc_getfigure figure stride: decode a response block into shorts ----
[Fact]
public void ParseResponseBlocks_yields_a_payload_that_decodes_as_a_sequence_of_figure_shorts()
{
// cnc_getfigure (command 0x00d3) returns one big-endian short per axis. Build the
// response block and assert the payload decodes to the [3, 1, 0] figure sequence the
// driver's auto-scale path reads.
var figurePayload = new byte[6];
BinaryPrimitives.WriteInt16BigEndian(figurePayload.AsSpan(0, 2), 3);
BinaryPrimitives.WriteInt16BigEndian(figurePayload.AsSpan(2, 2), 1);
BinaryPrimitives.WriteInt16BigEndian(figurePayload.AsSpan(4, 2), 0);
var body = BuildResponseBody((command: (ushort)0x00d3, rc: (short)0, payload: figurePayload));
var block = FocasWireProtocol.ParseResponseBlocks(body).Single();
var figures = new List<int>();
for (var offset = 0; offset + 2 <= block.Payload.Length; offset += 2)
figures.Add(BinaryPrimitives.ReadInt16BigEndian(block.Payload.AsSpan(offset, 2)));
figures.ShouldBe(new[] { 3, 1, 0 });
}
// ---- ReadAscii / ReadNameRecord ----
[Fact]
@@ -239,11 +216,12 @@ public sealed class FocasWireProtocolTests
}
[Fact]
public void ReadNameRecord_reads_two_bytes_and_strips_padding()
public void ReadNameRecord_strips_padding()
{
// Full record semantics (first NUL, all four bytes) are pinned in FocasNameRecordTests.
FocasWireProtocol.ReadNameRecord("X \0\0"u8.ToArray()).ShouldBe("X");
FocasWireProtocol.ReadNameRecord("XY"u8.ToArray()).ShouldBe("XY");
FocasWireProtocol.ReadNameRecord(new byte[] { 0x58 }).ShouldBe(string.Empty); // < 2 bytes
FocasWireProtocol.ReadNameRecord(ReadOnlySpan<byte>.Empty).ShouldBe(string.Empty);
}
// ---- helpers ----
@@ -98,7 +98,7 @@ public sealed class FocasWireV3Tests
[Fact]
public void ParseServoMeters_uses_an_eight_byte_stride_and_names_from_the_axis_block()
{
// Three 8-byte records {int32 data; int16 dec=10; int16 unit=0}: data = 0, 50, -3.
// Three 8-byte records {int32 value; u16 base=10; u16 exponent=0}: value = 0, 50, -3.
byte[] sv =
[
0x00, 0x00, 0x00, 0x00, 0x00, 0x0a, 0x00, 0x00, // axis 1: data=0
@@ -120,7 +120,8 @@ public sealed class FocasWireV3Tests
meters[0].Value.ShouldBe(0);
meters[1].Name.ShouldBe("Y");
meters[1].Value.ShouldBe(50); // 8-byte stride: a 12-byte stride would misread this as 655360
meters[1].Decimal.ShouldBe((short)10);
meters[1].Base.ShouldBe(10); // offset 4 is the envelope BASE, not a decimal count
meters[1].Scaled.ShouldBe(50.0);
meters[2].Name.ShouldBe("Z");
meters[2].Value.ShouldBe(-3);
}
@@ -0,0 +1,31 @@
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Wire;
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests;
/// <summary>
/// Loads the raw FOCAS response PDUs under <c>Fixtures/v3/</c> — byte-for-byte captures from a
/// real FANUC 31i-B (Makino, series G431) — and splits them into response blocks with the
/// production <see cref="FocasWireProtocol.ParseResponseBlocks"/>. The <c>live-*</c> captures
/// were taken by the sister ignitionoee FOCAS driver's read-only probes of Z-34184 (2026-08-12)
/// and are shared verbatim; the <c>ref-*</c> ones are this repo's own 2026-06-25 capture.
/// </summary>
internal static class WireFixtures
{
private const int PduHeaderLength = 10;
/// <summary>Every response block of the named capture, in wire order.</summary>
/// <param name="name">File name under <c>Fixtures/v3/</c>.</param>
/// <returns>The parsed response blocks.</returns>
public static IReadOnlyList<ResponseBlock> Blocks(string name)
{
var raw = File.ReadAllBytes(Path.Combine(AppContext.BaseDirectory, "Fixtures", "v3", name));
return FocasWireProtocol.ParseResponseBlocks(raw.AsSpan(PduHeaderLength));
}
/// <summary>The payload of the first block carrying <paramref name="command"/>.</summary>
/// <param name="name">File name under <c>Fixtures/v3/</c>.</param>
/// <param name="command">The FOCAS command id to locate.</param>
/// <returns>That block's payload bytes.</returns>
public static byte[] Payload(string name, ushort command) =>
Blocks(name).First(b => b.Command == command).Payload;
}
@@ -19,6 +19,11 @@
</PackageReference>
</ItemGroup>
<ItemGroup>
<!-- Real FANUC 31i-B response captures (raw PDUs); read by WireFixtures. -->
<None Include="Fixtures\v3\*.bin" CopyToOutputDirectory="PreserveNewest"/>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\..\src\Drivers\ZB.MOM.WW.OtOpcUa.Driver.FOCAS\ZB.MOM.WW.OtOpcUa.Driver.FOCAS.csproj"/>
</ItemGroup>
@@ -116,6 +116,9 @@ internal class FakeTwinCATClient : ITwinCATClient
public List<FakeNotification> Notifications { get; } = new();
/// <summary>Gets or sets a value indicating whether AddNotificationAsync should throw.</summary>
public bool ThrowOnAddNotification { get; set; }
/// <summary>Per-symbol exceptions <see cref="AddNotificationAsync"/> throws (e.g. a
/// <see cref="TwinCATTypeMismatchException"/> for one refused tag).</summary>
public Dictionary<string, Exception> AddNotificationErrors { get; } = new(StringComparer.OrdinalIgnoreCase);
/// <summary>Records the most recently-supplied <c>maxDelayMs</c> for Driver.TwinCAT-014 tests.</summary>
public int LastMaxDelayMs { get; private set; }
/// <summary>When set, <see cref="AddNotificationAsync"/> awaits this gate before creating the handle —
@@ -137,6 +140,8 @@ internal class FakeTwinCATClient : ITwinCATClient
{
if (ThrowOnAddNotification)
throw Exception ?? new InvalidOperationException("fake AddNotification failure");
if (AddNotificationErrors.TryGetValue(symbolPath, out var perSymbol))
throw perSymbol;
if (AddNotificationGate is { } gate)
await gate.Task.ConfigureAwait(false);
@@ -128,6 +128,51 @@ public sealed class TwinCATNativeNotificationTests
fake.Notifications.Count.ShouldBe(0); // partial handle cleaned up
}
/// <summary>
/// A tag whose declared type cannot decode its PLC symbol (ignitionoee TwinCAT-002/-008) is refused
/// by the client with <see cref="TwinCATTypeMismatchException"/>. The refusal is ISOLATED: that
/// one tag goes Bad with <c>BadTypeMismatch</c> and every other tag in the same subscribe call
/// still registers — before, the exception tore the whole subscription down.
/// </summary>
[Fact]
public async Task Native_subscribe_isolates_a_type_mismatched_tag_to_BadTypeMismatch()
{
var fake = new FakeTwinCATClient();
fake.AddNotificationErrors["MAIN.Order"] =
new TwinCATTypeMismatchException("MAIN.Order", "declared DInt but the PLC symbol is the string STRING(3)");
var factory = new FakeTwinCATClientFactory { Customise = () => fake };
var drv = new TwinCATDriver(new TwinCATDriverOptions
{
Devices = [new TwinCATDeviceOptions("ads://5.23.91.23.1.1:851")],
RawTags = TwinCATRawTags.Entries(
[
new TwinCATTagDefinition("Count", "ads://5.23.91.23.1.1:851", "MAIN.Count", TwinCATDataType.DInt),
new TwinCATTagDefinition("Order", "ads://5.23.91.23.1.1:851", "MAIN.Order", TwinCATDataType.DInt),
new TwinCATTagDefinition("Speed", "ads://5.23.91.23.1.1:851", "MAIN.Speed", TwinCATDataType.Real),
]),
Probe = new TwinCATProbeOptions { Enabled = false },
UseNativeNotifications = true,
}, "drv-1", factory);
await drv.InitializeAsync("{}", CancellationToken.None);
var events = new ConcurrentQueue<DataChangeEventArgs>();
drv.OnDataChange += (_, e) => events.Enqueue(e);
var handle = await drv.SubscribeAsync(["Count", "Order", "Speed"], TimeSpan.FromMilliseconds(100), CancellationToken.None);
fake.Notifications.Select(n => n.SymbolPath).ShouldBe(["MAIN.Count", "MAIN.Speed"], ignoreOrder: true);
var refused = events.ShouldHaveSingleItem();
refused.FullReference.ShouldBe("Order");
refused.Snapshot.StatusCode.ShouldBe(TwinCATStatusMapper.BadTypeMismatch);
refused.Snapshot.Value.ShouldBeNull();
// The good tags still deliver, and unsubscribe tears down cleanly.
fake.FireNotification("MAIN.Count", 7);
events.Last().Snapshot.Value.ShouldBe(7);
await drv.UnsubscribeAsync(handle, CancellationToken.None);
fake.Notifications.ShouldBeEmpty();
}
private sealed class AbTagParamsIrrelevant { }
private sealed class FailAfterNAddsFake : FakeTwinCATClient
@@ -0,0 +1,82 @@
using System.Text;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Driver.TwinCAT;
namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests;
/// <summary>
/// Pins <see cref="TwinCATStringCodec"/> — how an IEC <c>STRING(n)</c> / <c>WSTRING(n)</c> buffer,
/// read at the PLC's own declared width, becomes a value. Ported from the ignitionoee TwinCAT
/// driver (0.19.3 first-NUL STRING, 0.19.4 TwinCAT-007 first-NUL WSTRING and TwinCAT-010
/// Windows-1252), whose byte fixtures these reuse.
/// </summary>
[Trait("Category", "Unit")]
public sealed class TwinCATStringCodecTests
{
/// <summary>
/// An IEC STRING ends at its FIRST NUL; the buffer beyond keeps whatever a longer earlier value
/// left there. Live on the Elma cleaner Z33856 (2026-09-24) a decoder that only trimmed trailing
/// NULs published work order <c>67504440</c> as <c>"67504440\0" + "1"</c> and part
/// <c>110032285</c> as <c>"110032285\0" + "1\0" + "30"</c> — a wrong value at Good quality.
/// </summary>
[Fact]
public void String_stops_at_the_first_nul_and_ignores_stale_bytes_after_it()
{
byte[] workOrder = [(byte)'6', (byte)'7', (byte)'5', (byte)'0', (byte)'4', (byte)'4', (byte)'4', (byte)'0',
0x00, (byte)'1', 0x00, (byte)'3', (byte)'0', 0x00];
TwinCATStringCodec.DecodeString(workOrder, utf8: false).ShouldBe("67504440");
byte[] emptied = [0x00, (byte)'O', (byte)'L', (byte)'D'];
TwinCATStringCodec.DecodeString(emptied, utf8: false).ShouldBe("");
}
/// <summary>A buffer with no NUL at all (a full-length value) decodes whole.</summary>
[Fact]
public void String_without_a_nul_decodes_the_whole_buffer()
=> TwinCATStringCodec.DecodeString("ABC"u8.ToArray(), utf8: false).ShouldBe("ABC");
/// <summary>
/// An IEC STRING on TwinCAT is single-byte Windows-1252, not ASCII and not the host's culture
/// code page: <c>°</c> (0xB0, an Elma alarm text), <c>ä</c> (0xE4) and <c>€</c> (0x80, the one
/// byte where 1252 and ISO-8859-1 differ) must decode to themselves on any host.
/// </summary>
[Fact]
public void String_decodes_windows_1252_high_bytes()
{
byte[] text = [(byte)'8', (byte)'0', 0xB0, (byte)'C', (byte)' ', 0xE4, 0x80, 0x00, 0xFF];
TwinCATStringCodec.DecodeString(text, utf8: false).ShouldBe("80°C ä€");
}
/// <summary>
/// A TwinCAT 3.1 build 4026+ project with "UTF8 Encoding for STRING" on reports UTF-8 string
/// data; the codec honours it (still ending at the first NUL).
/// </summary>
[Fact]
public void String_decodes_utf8_when_the_target_reports_utf8_strings()
{
var bytes = Encoding.UTF8.GetBytes("80°C ä€").Concat(new byte[] { 0x00, (byte)'X' }).ToArray();
TwinCATStringCodec.DecodeString(bytes, utf8: true).ShouldBe("80°C ä€");
}
/// <summary>
/// A WSTRING ends at its first ALIGNED <c>U+0000</c> code unit (TwinCAT-007): <c>"NEW\0LD"</c>
/// must publish <c>"NEW"</c>. A zero HIGH byte of a non-NUL unit (every ASCII character in
/// UTF-16LE) is not a terminator.
/// </summary>
[Fact]
public void WString_stops_at_the_first_aligned_nul_code_unit()
{
var bytes = Encoding.Unicode.GetBytes("NEW\0LD\0");
TwinCATStringCodec.DecodeWString(bytes).ShouldBe("NEW");
TwinCATStringCodec.DecodeWString(Encoding.Unicode.GetBytes("Grüße")).ShouldBe("Grüße");
}
/// <summary>A trailing odd byte is not half a character — it is ignored.</summary>
[Fact]
public void WString_ignores_a_trailing_odd_byte()
{
var bytes = Encoding.Unicode.GetBytes("AB").Concat(new byte[] { 0x43 }).ToArray();
TwinCATStringCodec.DecodeWString(bytes).ShouldBe("AB");
}
}
@@ -104,7 +104,12 @@ public sealed class TwinCATSymbolPathTests
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
[InlineData(".Motor")] // leading dot
[InlineData(".")] // a bare dot names nothing
[InlineData("..Motor")] // TwinCAT 2 globals carry ONE leading dot, never two
[InlineData(".[1]")] // leading dot then no identifier
[InlineData(".9x")] // leading dot then an ident starting with a digit
[InlineData(".x.")] // leading dot, trailing dot
[InlineData(" . x")] // whitespace between the dot and the identifier
[InlineData("Motor.")] // trailing dot
[InlineData("Motor.[0]")] // empty segment
[InlineData("1bad")] // ident starts with digit
@@ -148,4 +153,42 @@ public sealed class TwinCATSymbolPathTests
parsed.ToAdsSymbolName().ShouldBe(c);
}
}
/// <summary>
/// TwinCAT 2 names a GLOBAL variable with ONE leading dot (<c>.OPC_HEARTBEAT</c>) and the PLC
/// requires it: on a real TC 2.11 CX (Elma cleaner Z33856, 2026-09-24) <c>SYM_HNDBYNAME</c>
/// answered a handle for <c>.OPC_HEARTBEAT</c> and 0x710 (symbol not found) for
/// <c>OPC_HEARTBEAT</c>. The parser rejected the dot, so a TC2 global fell back to the raw
/// string — losing bit-index parsing (<c>.X.WORD.3</c> was sent to ADS verbatim).
/// Ported from ignitionoee driver 0.19.1.
/// </summary>
/// <param name="input">A TwinCAT 2 global path as the PLC names it.</param>
[Theory]
[InlineData(".x")]
[InlineData(".OPC_HEARTBEAT")]
[InlineData(".OPC_MACHINEDATA.PROCESSSTATUS")]
[InlineData(".ARRCARRIERDATA_V[1].SWORKORDER")]
[InlineData(".OPC_LOGDATA.STATIONDATA[5].TEMPERATUREMAXIMUM")]
public void TwinCAT2_leading_dot_global_parses_and_keeps_its_dot(string input)
{
var p = TwinCATSymbolPath.TryParse(input);
p.ShouldNotBeNull(input);
p.Segments[0].Name.ShouldStartWith(".");
p.ToAdsSymbolName().ShouldBe(input);
}
/// <summary>
/// The leading dot belongs to the ROOT segment only, so bit access on a TC2 global still parses:
/// the parent word is read as <c>.OPC_STATUS.WORD</c> and bit 3 is extracted locally.
/// </summary>
[Fact]
public void TwinCAT2_global_bit_access_parses_with_the_dot_on_the_root_segment()
{
var p = TwinCATSymbolPath.TryParse(".OPC_STATUS.WORD.3");
p.ShouldNotBeNull();
p.Segments.Select(s => s.Name).ShouldBe([".OPC_STATUS", "WORD"]);
p.BitIndex.ShouldBe(3);
(p with { BitIndex = null }).ToAdsSymbolName().ShouldBe(".OPC_STATUS.WORD");
p.ToAdsSymbolName().ShouldBe(".OPC_STATUS.WORD.3");
}
}
@@ -0,0 +1,60 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Driver.TwinCAT;
namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests;
/// <summary>
/// Pins <c>AdsTwinCATClient.MapSymbolTypeName</c> — the symbol-browse type mapper — against the type
/// names a real TwinCAT 2 PLC reports. A name the mapper does not know drops the symbol from
/// discovery silently, so every TC2 spelling below was a symbol the browse never offered.
/// </summary>
[Trait("Category", "Unit")]
public sealed class TwinCATSymbolTypeNameTests
{
/// <summary>
/// TwinCAT 2 reports top-level integer globals by their ADS base-type name, not the IEC name:
/// the Elma cleaner Z33856 (TC 2.11, symbol upload 2026-09-24) lists 233 symbols as
/// <c>INT16</c> and 121 as <c>UINT16</c> (e.g. <c>.ACTUALOPERATIONMODE</c>). The Beckhoff
/// type system carries these as built-in primitives (<c>AdsSymbolProvider.CreateBuildInTypes</c>:
/// INT16, UINT16, INT32, UINT32, UINT8, FLOAT, DOUBLE), so <c>IDataType.Name</c> arrives spelled
/// that way and must map like its IEC twin.
/// </summary>
/// <param name="typeName">The type name as the PLC / Beckhoff type system reports it.</param>
/// <param name="expected">The driver atomic type it must map to.</param>
[Theory]
[InlineData("INT16", TwinCATDataType.Int)]
[InlineData("UINT16", TwinCATDataType.UInt)]
[InlineData("INT32", TwinCATDataType.DInt)]
[InlineData("UINT32", TwinCATDataType.UDInt)]
[InlineData("UINT8", TwinCATDataType.USInt)]
[InlineData("FLOAT", TwinCATDataType.Real)]
[InlineData("DOUBLE", TwinCATDataType.LReal)]
public void TwinCAT2_ads_base_type_names_map_like_their_iec_twins(string typeName, TwinCATDataType expected)
=> AdsTwinCATClient.MapSymbolTypeName(typeName).ShouldBe(expected);
/// <summary>
/// The same TC2 data-type upload spells struct members with a trailing blank — <c>"REAL "</c>
/// (54 members on Z33856) and <c>"DINT "</c> (24) — so the name is trimmed before matching.
/// </summary>
/// <param name="typeName">A padded type name.</param>
/// <param name="expected">The driver atomic type it must map to.</param>
[Theory]
[InlineData("REAL ", TwinCATDataType.Real)]
[InlineData("DINT ", TwinCATDataType.DInt)]
[InlineData(" STRING(80) ", TwinCATDataType.String)]
public void Padded_type_names_are_trimmed(string typeName, TwinCATDataType expected)
=> AdsTwinCATClient.MapSymbolTypeName(typeName).ShouldBe(expected);
/// <summary>The IEC spellings, sized strings and unsupported names are unchanged.</summary>
[Fact]
public void Existing_iec_mappings_are_unchanged()
{
AdsTwinCATClient.MapSymbolTypeName("INT").ShouldBe(TwinCATDataType.Int);
AdsTwinCATClient.MapSymbolTypeName("STRING(8)").ShouldBe(TwinCATDataType.String);
AdsTwinCATClient.MapSymbolTypeName("WSTRING(20)").ShouldBe(TwinCATDataType.WString);
AdsTwinCATClient.MapSymbolTypeName("ST_LOGDATA").ShouldBeNull();
AdsTwinCATClient.MapSymbolTypeName("INT (VAR_IN_OUT)").ShouldBeNull();
AdsTwinCATClient.MapSymbolTypeName(null).ShouldBeNull();
}
}
@@ -0,0 +1,155 @@
using Shouldly;
using TwinCAT.TypeSystem;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Driver.TwinCAT;
namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests;
/// <summary>
/// Pins <see cref="TwinCATTypeGuard"/> — whether a tag's DECLARED <see cref="TwinCATDataType"/> may
/// decode the PLC symbol it names. Ported from the ignitionoee TwinCAT driver's extra-type
/// reconciliation (TwinCAT-002 width / float kind, TwinCAT-008 string kind): a refused pair leaves
/// the node Bad (<c>BadTypeMismatch</c>) instead of publishing the PLC's bytes decoded as the wrong
/// type at Good quality.
/// </summary>
[Trait("Category", "Unit")]
public sealed class TwinCATTypeGuardTests
{
private static TwinCATPlcSymbolShape Plc(string typeName, int byteSize, DataTypeCategory? category = null)
=> TwinCATPlcSymbolShape.Classify(typeName, category, byteSize);
// ---- classification ----
/// <summary>The PLC's type name / category classifies into the four kinds the guard reasons about.</summary>
[Fact]
public void Classify_recognises_string_kinds_scalars_and_containers()
{
Plc("STRING(80)", 81).Kind.ShouldBe(TwinCATPlcKind.String);
Plc("STRING", 81).Kind.ShouldBe(TwinCATPlcKind.String);
Plc("WSTRING(20)", 42).Kind.ShouldBe(TwinCATPlcKind.WString);
Plc("T_MaxString", 256, DataTypeCategory.String).Kind.ShouldBe(TwinCATPlcKind.String);
Plc("STRING_DATA", 40).Kind.ShouldBe(TwinCATPlcKind.Scalar); // a UDT named STRING_… is not a string
Plc("ST_LOGDATA", 986, DataTypeCategory.Struct).Kind.ShouldBe(TwinCATPlcKind.NonScalar);
Plc("ARRAY [0..12] OF DINT", 52).Kind.ShouldBe(TwinCATPlcKind.NonScalar);
Plc("INT (VAR_IN_OUT)", 4).Kind.ShouldBe(TwinCATPlcKind.NonScalar);
Plc("POINTER TO BYTE", 4, DataTypeCategory.Pointer).Kind.ShouldBe(TwinCATPlcKind.NonScalar);
Plc("REAL", 4).IsFloat.ShouldBeTrue();
Plc("INT16", 2).IsFloat.ShouldBeFalse();
}
/// <summary>
/// Live on the TwinCAT 2.11 Elma cleaner Z33856 (2026-10-07): for a dotted MEMBER path the
/// Beckhoff library reports the symbol's <c>ByteSize</c> as if the PLC's byte count were a bit
/// count — <c>.OPC_MACHINEDATA.PROCESSSTATUS</c> (STRING(80)) came back as 11 bytes, an
/// <c>INT</c> member as 1, a <c>UDINT</c> as 1 — while its resolved <c>DataType.ByteSize</c> was
/// the true 81 / 2 / 4 (raw <c>SYM_INFOBYNAMEEX</c> answers 81). Sizing a string read by the
/// symbol truncated <c>"ready for operation"</c> to <c>"ready for o"</c> at Good, and the
/// notification width check refused every numeric member. The resolved type's size wins.
/// </summary>
[Fact]
public void Classify_prefers_the_resolved_data_type_size_over_the_symbol_size()
{
TwinCATPlcSymbolShape.Classify("STRING(80)", DataTypeCategory.String, byteSize: 11, dataTypeByteSize: 81)
.ByteSize.ShouldBe(81);
var member = TwinCATPlcSymbolShape.Classify("INT", DataTypeCategory.Primitive, byteSize: 1, dataTypeByteSize: 2);
member.ByteSize.ShouldBe(2);
TwinCATTypeGuard.CheckNotification(TwinCATDataType.Int, member).ShouldBeNull();
TwinCATPlcSymbolShape.Classify("UDINT", DataTypeCategory.Primitive, byteSize: 1, dataTypeByteSize: 4).ByteSize.ShouldBe(4);
// No resolved type (or a zero size) falls back to the symbol's own size.
TwinCATPlcSymbolShape.Classify("STRING(30)", null, byteSize: 31, dataTypeByteSize: null).ByteSize.ShouldBe(31);
TwinCATPlcSymbolShape.Classify("STRING(30)", null, byteSize: 31, dataTypeByteSize: 0).ByteSize.ShouldBe(31);
}
/// <summary>
/// Live on Z33856 (2026-10-07): <c>P_INITINPUT_ACTION.SLOTID</c> is a TwinCAT 2
/// <c>STRING(20) (VAR_IN_OUT)</c> — a 4-byte reference — but the Beckhoff library resolves its
/// <c>DataType</c> to the plain <c>STRING(20)</c> (category String). Classifying by the resolved
/// type alone let a String tag read 21 bytes starting at the pointer and publish them as text at
/// Good (<c>"n±\u008dâa»‰â…"</c>). The symbol's own type name marks it a reference, and that wins.
/// </summary>
[Fact]
public void Classify_treats_a_var_in_out_symbol_as_non_scalar_even_when_its_resolved_type_is_a_string()
{
var shape = TwinCATPlcSymbolShape.Classify("STRING(20)", DataTypeCategory.String, byteSize: 4,
dataTypeByteSize: 21, symbolTypeName: "STRING(20) (VAR_IN_OUT)");
shape.Kind.ShouldBe(TwinCATPlcKind.NonScalar);
TwinCATTypeGuard.CheckRead(TwinCATDataType.String, shape).ShouldNotBeNull();
TwinCATTypeGuard.CheckNotification(TwinCATDataType.String, shape).ShouldNotBeNull();
}
// ---- read path (symbol-aware: only the string kind can go wrong) ----
/// <summary>
/// TwinCAT-008: before the fix only width separated a string from a number, so a numeric declared
/// over a PLC string decoded the string's bytes as a number at Good. The Beckhoff read path hands
/// a declared-numeric tag over a PLC <c>STRING(3)</c> back a <c>string</c> object at Good — refused.
/// </summary>
/// <param name="declared">The tag's declared type.</param>
[Theory]
[InlineData(TwinCATDataType.DInt)]
[InlineData(TwinCATDataType.UDInt)]
[InlineData(TwinCATDataType.Time)]
[InlineData(TwinCATDataType.Int)]
[InlineData(TwinCATDataType.LInt)]
public void Read_refuses_a_numeric_declared_over_a_plc_string(TwinCATDataType declared)
=> TwinCATTypeGuard.CheckRead(declared, Plc("STRING(3)", 4)).ShouldNotBeNull();
/// <summary>A string declared over a number, the other string kind, or a container is refused.</summary>
[Fact]
public void Read_refuses_a_string_over_anything_but_the_same_string_kind()
{
TwinCATTypeGuard.CheckRead(TwinCATDataType.String, Plc("DINT", 4)).ShouldNotBeNull();
TwinCATTypeGuard.CheckRead(TwinCATDataType.WString, Plc("STRING(161)", 162)).ShouldNotBeNull();
TwinCATTypeGuard.CheckRead(TwinCATDataType.String, Plc("WSTRING(80)", 162)).ShouldNotBeNull();
TwinCATTypeGuard.CheckRead(TwinCATDataType.String, Plc("ST_LOGDATA", 81, DataTypeCategory.Struct)).ShouldNotBeNull();
}
/// <summary>
/// 0.19.2: a STRING / WSTRING tag matches a PLC <c>STRING(n)</c> / <c>WSTRING(n)</c> of ANY length.
/// A numeric-over-numeric pair is left to the symbol-aware read, which converts the value.
/// </summary>
[Fact]
public void Read_accepts_same_kind_strings_of_any_length_and_numeric_pairs()
{
TwinCATTypeGuard.CheckRead(TwinCATDataType.String, Plc("STRING(8)", 9)).ShouldBeNull();
TwinCATTypeGuard.CheckRead(TwinCATDataType.String, Plc("STRING(255)", 256)).ShouldBeNull();
TwinCATTypeGuard.CheckRead(TwinCATDataType.WString, Plc("WSTRING(20)", 42)).ShouldBeNull();
TwinCATTypeGuard.CheckRead(TwinCATDataType.DInt, Plc("REAL", 4)).ShouldBeNull();
TwinCATTypeGuard.CheckRead(TwinCATDataType.DInt, Plc("E_STATE", 2, DataTypeCategory.Enum)).ShouldBeNull();
}
// ---- notification path (raw bytes decoded as the declared type) ----
/// <summary>
/// TwinCAT-002: an ADS notification carries raw bytes decoded as the DECLARED type, so a same-width
/// float/integer clash reinterprets the bit pattern (DINT over REAL) and a width mismatch truncates
/// (DINT over LINT) — both at Good before the guard.
/// </summary>
[Fact]
public void Notification_refuses_width_and_float_kind_mismatches()
{
TwinCATTypeGuard.CheckNotification(TwinCATDataType.DInt, Plc("REAL", 4)).ShouldNotBeNull();
TwinCATTypeGuard.CheckNotification(TwinCATDataType.Real, Plc("DINT", 4)).ShouldNotBeNull();
TwinCATTypeGuard.CheckNotification(TwinCATDataType.DInt, Plc("LINT", 8)).ShouldNotBeNull();
TwinCATTypeGuard.CheckNotification(TwinCATDataType.LReal, Plc("REAL", 4)).ShouldNotBeNull();
TwinCATTypeGuard.CheckNotification(TwinCATDataType.DInt, Plc("ARRAY [0..12] OF DINT", 52)).ShouldNotBeNull();
TwinCATTypeGuard.CheckNotification(TwinCATDataType.DInt, Plc("STRING(3)", 4)).ShouldNotBeNull();
}
/// <summary>
/// A same-width, same-kind divergence (DINT vs UDINT, TIME vs a 4-byte integer, a TC2 INT16) is
/// tolerated so a valid config is never refused; strings of the same kind match at any length.
/// </summary>
[Fact]
public void Notification_tolerates_same_width_same_kind_and_same_kind_strings()
{
TwinCATTypeGuard.CheckNotification(TwinCATDataType.UDInt, Plc("DINT", 4)).ShouldBeNull();
TwinCATTypeGuard.CheckNotification(TwinCATDataType.Time, Plc("UDINT", 4)).ShouldBeNull();
TwinCATTypeGuard.CheckNotification(TwinCATDataType.Int, Plc("INT16", 2)).ShouldBeNull();
TwinCATTypeGuard.CheckNotification(TwinCATDataType.Bool, Plc("BOOL", 1)).ShouldBeNull();
TwinCATTypeGuard.CheckNotification(TwinCATDataType.Real, Plc("REAL", 4)).ShouldBeNull();
TwinCATTypeGuard.CheckNotification(TwinCATDataType.String, Plc("STRING(14)", 15)).ShouldBeNull();
TwinCATTypeGuard.CheckNotification(TwinCATDataType.WString, Plc("WSTRING(20)", 42)).ShouldBeNull();
}
}