Compare commits
22 Commits
e3d7a8f071
...
9dbaa6d758
| Author | SHA1 | Date | |
|---|---|---|---|
| 9dbaa6d758 | |||
| 8060cfd2a5 | |||
| 6cc40963b4 | |||
| 58c61c1610 | |||
| cc876bdf43 | |||
| e3d6b76ede | |||
| 2fc1b488fe | |||
| 9b51dbca60 | |||
| 68cf3ae8b7 | |||
| 0ca56cb1f6 | |||
| 26b512adba | |||
| a0a024c80b | |||
| 75754478ac | |||
| 1dba46bd7b | |||
| 28bbb5e5e2 | |||
| 83a314e734 | |||
| 3b48e05e9a | |||
| 69e0a622f3 | |||
| 1a74c3c2e0 | |||
| 9460da513c | |||
| b7b754fd98 | |||
| 665ecd55c2 |
@@ -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
@@ -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.
|
||||
|
||||
@@ -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
@@ -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
@@ -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:<name>.@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:<name>.@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/<id></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:<name>.@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:<name>.@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>
|
||||
/// > 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>
|
||||
/// > 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)
|
||||
{
|
||||
|
||||
+69
@@ -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:<name>.@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
|
||||
}
|
||||
}
|
||||
+85
@@ -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
|
||||
|
||||
+22
-59
@@ -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> > 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);
|
||||
}
|
||||
}
|
||||
|
||||
+98
@@ -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/<id></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/<id></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; > 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();
|
||||
}
|
||||
}
|
||||
+15
@@ -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.
|
||||
|
||||
+6
-3
@@ -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": {
|
||||
|
||||
+156
-32
@@ -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)
|
||||
|
||||
|
||||
+89
-11
@@ -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;
|
||||
|
||||
+13
-9
@@ -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()
|
||||
|
||||
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
+11
-11
@@ -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;
|
||||
}
|
||||
+5
@@ -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);
|
||||
|
||||
+45
@@ -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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user