Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 74e07225c9 | |||
| 6600ce9940 | |||
| 235b8b8e6d | |||
| 20b2df9241 | |||
| 5f0a52864c |
@@ -8,6 +8,7 @@
|
||||
<PackageVersion Include="Akka.Cluster.Hosting" Version="1.5.62" />
|
||||
<PackageVersion Include="Akka.Cluster.Tools" Version="1.5.62" />
|
||||
<PackageVersion Include="Akka.Hosting" Version="1.5.62" />
|
||||
<PackageVersion Include="Akka.Logger.Serilog" Version="1.5.60" />
|
||||
<PackageVersion Include="Akka.Remote" Version="1.5.62" />
|
||||
<PackageVersion Include="Akka.Remote.Hosting" Version="1.5.62" />
|
||||
<PackageVersion Include="Akka.Streams" Version="1.5.62" />
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
# Deployment record — wonder-app-vd03 · Makino Pro 5 (Z-34184) FOCAS
|
||||
|
||||
**Date configured:** 2026-06-25
|
||||
**Host:** `wonder-app-vd03.zmr.zimmer.com` (OtOpcUaHost Windows service, single fused admin+driver node)
|
||||
**Status:** Configured + deployed + served. **Live values blocked** by the FOCAS PDU-v3 driver gap —
|
||||
see [`docs/plans/2026-06-25-focas-pdu-v3-30i-b-support.md`](../plans/2026-06-25-focas-pdu-v3-30i-b-support.md).
|
||||
|
||||
## Equipment
|
||||
|
||||
| Field | Value |
|
||||
|---|---|
|
||||
| Machine | **Makino Pro 5** (FANUC **30i-B** control) |
|
||||
| **IP / FOCAS endpoint** | **`10.201.31.5:8193`** (FOCAS Ethernet, TCP) — reachable from the host (and from VPN clients) |
|
||||
| **ZTag** | **`Z-34184`** |
|
||||
| Manufacturer / Model | `Makino` / `Pro 5` |
|
||||
| CNC series (driver) | `Thirty_i` (30i) |
|
||||
|
||||
## OtOpcUa host endpoints
|
||||
|
||||
- AdminUI: `http://wonder-app-vd03.zmr.zimmer.com:9000` (login disabled — `Security__Auth__DisableLogin=true`)
|
||||
- OPC UA: `opc.tcp://wonder-app-vd03.zmr.zimmer.com:4840/OtOpcUa`
|
||||
- Akka cluster: `:4053` · ConfigDb: SQL `OtOpcUaConfig` on `:1433`
|
||||
|
||||
## Deployed configuration (cluster `DEV`, Enterprise `zb` / Site `wonder-app-vd03`)
|
||||
|
||||
First config ever deployed on this node. All authored via the AdminUI.
|
||||
|
||||
| Object | Value |
|
||||
|---|---|
|
||||
| Namespace | `dev-equipment` (Equipment), URI `urn:zb:wonder-app-vd03:equipment` |
|
||||
| UNS path | `zb / wonder-app-vd03 / machining / makino` |
|
||||
| FOCAS driver instance | `focas-z-34184` ("Makino Pro 5 Z-34184 (FOCAS)"), backend `wire`, FixedTree enabled |
|
||||
| Device | `10.201.31.5:8193`, series `Thirty_i` |
|
||||
| Equipment | `z-34184` → `EquipmentId = EQ-3686c0272279`, MachineCode `Z-34184`, ZTag `Z-34184` |
|
||||
| Tags | `parts-count` → `MACRO:3901` (Float64/Double, Read); `parts-required` → `MACRO:3902` (Float64/Double, Read) |
|
||||
| Deployment | `0c2db588` (rev `924b59097eba…`) — **Sealed / "In sync"** |
|
||||
|
||||
OPC UA node IDs (verified served via the OtOpcUa CLI client):
|
||||
- Equipment: `ns=2;s=EQ-3686c0272279`
|
||||
- `ns=2;s=EQ-3686c0272279/parts-count`
|
||||
- `ns=2;s=EQ-3686c0272279/parts-required`
|
||||
|
||||
## Host change required to deploy at all (Akka cluster roles)
|
||||
|
||||
This node was joining the Akka cluster **role-less**, so no deployment could ever complete
|
||||
("a task was canceled"; Fleet status: "no driver-role nodes are Up"). Root cause: `OTOPCUA_ROLES`
|
||||
(set to `admin,driver`) drives Program.cs actor wiring but does **not** populate the Akka member roles —
|
||||
those come from config `Cluster:Roles`, which was unset (no `Cluster__Roles` env var; no `Cluster`
|
||||
section in any appsettings). No code bridges `OTOPCUA_ROLES` → `AkkaClusterOptions.Roles`.
|
||||
|
||||
**Fix applied to the host service** (`HKLM\SYSTEM\CurrentControlSet\Services\OtOpcUaHost` → `Environment`):
|
||||
added `Cluster__Roles__0=admin` and `Cluster__Roles__1=driver`, then restarted the service. The member
|
||||
now joins **UP with roles ADMIN+DRIVER** and deployments seal. Prior env backed up on the host at
|
||||
`E:\ApiInstall\OtOpcUa\_envbak-20260625T145303.txt`.
|
||||
|
||||
> Product follow-up: either wire `OTOPCUA_ROLES` → `AkkaClusterOptions.Roles`, or bake `Cluster:Roles`
|
||||
> into the deploy template, so a node redeploy doesn't regress to role-less.
|
||||
|
||||
## Live verification (OtOpcUa CLI client → server)
|
||||
|
||||
`connect` + `browse` confirm the equipment + both tags are served at the node IDs above.
|
||||
|
||||
**Driver fix shipped (2026-06-25):** the wire client now accepts PDU v3 (see the linked plan). Validated
|
||||
directly against `10.201.31.5` — `MACRO:3901`/`3902` read **Good** from the live 30i-B. **However, the
|
||||
OtOpcUaHost on this box is still running the pre-v3 driver binary**, so the live OPC UA tags will keep
|
||||
returning `Bad_WaitingForInitialData` until the rebuilt `ZB.MOM.WW.OtOpcUa.Driver.FOCAS.dll` (or a fresh
|
||||
self-contained host publish) is deployed to `E:\ApiInstall\OtOpcUa\` and `OtOpcUaHost` is restarted. Once
|
||||
redeployed, `parts-count`/`parts-required` should go Good (FixedTree + PMC/Parameter still pending the
|
||||
follow-on v3 command work).
|
||||
@@ -15,6 +15,21 @@ OtOpcUa is **read-only** against FOCAS; all reads go over the native wire
|
||||
protocol using the documented command IDs. Writes return
|
||||
`BadNotWritable` by design.
|
||||
|
||||
> **PDU version (v3 / FANUC 30i/31i).** The wire client **accepts inbound PDU versions `{1, 3}`**
|
||||
> (`FocasWireProtocol`) while still emitting v1 on requests. Older controls + the docker mock answer
|
||||
> v1; modern controls (Makino Pro 5 / FANUC **31i-B**) answer v3. **Validated live against a real
|
||||
> 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
|
||||
> 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:
|
||||
> [`docs/plans/2026-06-25-focas-pdu-v3-30i-b-support.md`](../plans/2026-06-25-focas-pdu-v3-30i-b-support.md)
|
||||
> + [`…-implementation-plan.md`](../plans/2026-06-25-focas-pdu-v3-implementation-plan.md).
|
||||
> Capture tools: `scripts/focas/capture-initiate.py`, `scripts/focas/capture-v3.py`.
|
||||
|
||||
## Project split
|
||||
|
||||
| Project | Target | Role |
|
||||
|
||||
@@ -0,0 +1,222 @@
|
||||
# FOCAS wire-protocol PDU v3 — finding + support analysis (Makino Pro 5 / FANUC 30i-B)
|
||||
|
||||
**Date:** 2026-06-25
|
||||
**Author:** field investigation against a live CNC (first real FOCAS hardware contact)
|
||||
**Status:** **RESOLVED + live-validated on a real 31i-B (`10.201.31.5`) 2026-06-25** — version gate, timer, PMC range, servo-meter, alarms, probe all fixed and verified live; `cnc_rdparam` found unsupported on this control (see Resolution). See `2026-06-25-focas-pdu-v3-implementation-plan.md` for the per-phase record.
|
||||
**Components:** `ZB.MOM.WW.OtOpcUa.Driver.FOCAS` (`Wire/FocasWireProtocol.cs`, `Wire/FocasWireClient.cs`)
|
||||
|
||||
---
|
||||
|
||||
## TL;DR
|
||||
|
||||
The pure-managed `WireFocasClient` only implements **FOCAS Ethernet wire-protocol PDU version 1**
|
||||
and hard-rejects every other version. A real **Makino Pro 5 (FANUC 30i-B)** at `10.201.31.5:8193`
|
||||
speaks **PDU version 3**, so the driver fails the session with
|
||||
`BadCommunicationError` / "Unsupported FOCAS PDU version 3" and no tag ever produces a value.
|
||||
|
||||
A live experiment (relaxing the version gate to accept v3) showed the **v3 initiate handshake and the
|
||||
macro-read data framing are already compatible** — `MACRO:*` reads returned correct values from the
|
||||
real machine. **PMC** and **Parameter** reads still failed and need per-command v3 work. So v3 support
|
||||
is *not* a rewrite, but it is also *not* a one-line version bump if PMC/PARAM/FixedTree are required.
|
||||
|
||||
This was the **first time the driver has ever been pointed at real FOCAS hardware** — the wire
|
||||
implementation was written from `strangesast/fwlib` + public docs with an explicit "needs Wireshark
|
||||
traces to validate" caveat (see `docs/v2/implementation/focas-wire-protocol.md`). This finding is
|
||||
those traces.
|
||||
|
||||
---
|
||||
|
||||
## Resolution (2026-06-25 — implemented + live-validated)
|
||||
|
||||
A full v3 data-PDU capture (`scripts/focas/capture-v3.py`, fixtures under
|
||||
`tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/Fixtures/v3/`) **substantially corrected the
|
||||
initial diagnosis** — 4 of the 6 "v3 framing failures" were not framing problems at all. The block
|
||||
envelope is byte-identical to v1; only specific payload structs / request-range math / a client-side
|
||||
robustness gap were wrong. Every fix below was re-validated by reading the live 31i-B through the
|
||||
fixed driver.
|
||||
|
||||
| Surface | Root cause (from the capture) | Fix | Live result |
|
||||
|---|---|---|---|
|
||||
| version gate | hard `version != 1` reject | accept `{1,3}` inbound (`FocasWireProtocol`) | macros + all reads work on v3 |
|
||||
| `cnc_rdtimer` | 8-byte {minute,msec} payload is **little-endian** (only decode with an in-range msec) | `FocasWireClient.ParseTimer` reads LE | cutting = 1,110,700 min / 41,872 ms |
|
||||
| `pmc_rdpmcrng` | request asked for `end=start` (1 byte) but a Word needs its byte width → 0 values → spurious `BadOutOfRange` | `WireFocasClient.ReadPmcAsync` sets `end = start + width − 1`; decode extracted to `ParsePmcRange` | R0 = 7873, R100 = 0, status Good |
|
||||
| `cnc_rdsvmeter` | (a) **no wire hang** — CNC answers fully + promptly; the "hang" was `NetworkStream.ReadAsync` not aborting a genuinely stalled socket. (b) per-axis LOADELM is **8 bytes**, not 12 → 12-byte stride misaligned (→ 655360 garbage); names live in the 0x0089 block | (a) `ReadExactlyAsync` dispose-on-cancel abort. (b) `ParseServoMeters` 8-byte stride + name correlation | 7 axes X,Y,Z,B,C,AA,AA, aligned values (≈0 idle) |
|
||||
| `cnc_rdalmmsg2` | not broken — empty payload = no active alarms | none (parser already handles empty) | returned active alarm `#3080 WRONG PALLET IN MACHINE` |
|
||||
| `cnc_rddynamic2` axis 0 | not a driver bug — the FixedTree poll already iterates 1..N; only a direct harness call used 0 | contract guard in `ReadDynamicAsync` (reject `axisIndex < 1`) | axes 1..N read clean |
|
||||
| Test-Connect probe | degraded to `Ok=true` "TCP reachability only" when FWLIB absent → any TCP listener looked HEALTHY | `FocasDriverProbe` now runs a real wire session (initiate + `cnc_statinfo`) | `Ok=true` vs real CNC, `Ok=false` vs bare listener |
|
||||
|
||||
### `cnc_rdparam` — unsupported on this control (blocked)
|
||||
|
||||
The one genuine v3 problem. A live matrix of **14 request-framing variants × 4 known-present
|
||||
parameters** (8130 / 1320 / 1825 / 3201) — every combination of arg ordering, axis, length,
|
||||
request-class, and extra payload (`scripts/focas/param-probe.py`) — returned **`EW_FUNC(1)`
|
||||
uniformly**. That is not a tweakable-framing bug. `0x000e` is also the ignored post-connect setup
|
||||
command, which makes it a doubtful parameter opcode. Either parameter read is genuinely restricted on
|
||||
this control via the wire path, or the v3 command id differs from `0x000e` and cannot be recovered
|
||||
without a reference FWLIB Wireshark trace (the long-blocked "Stream C.2"). Parameter support is parked
|
||||
on that reference; the deployed config uses macros, not parameters, so nothing live depends on it.
|
||||
|
||||
### Open caveats
|
||||
- **Servo-load magnitude/scaling** (`data / 10^dec`; `dec` read as 10 → idle loads ≈ 0) is inferred
|
||||
from the wire and unconfirmed against the machine's servo-meter screen — confirm at commissioning.
|
||||
- **Timer type→counter mapping**: power-on / operating / cycle read 0 while cutting is non-zero on
|
||||
this control. The *decode* is correct; whether type 0/1/3 map to populated counters here is a
|
||||
CNC-configuration question for commissioning.
|
||||
|
||||
## Environment
|
||||
|
||||
- **CNC:** Makino Pro 5, FANUC 30i-B control, `10.201.31.5:8193` (ZTag `Z-34184`). FOCAS Ethernet
|
||||
reachable (TCP 8193 open from both the OtOpcUa host `wonder-app-vd03` and a dev laptop over VPN).
|
||||
- **Driver backend:** `wire` (the default and now only real backend — `fwlib`/`ipc` were retired in
|
||||
the Wire migration; see `FocasDriverFactoryExtensions.BuildClientFactory`). FANUC FWLIB is NOT
|
||||
installed on the host, and is not used for reads.
|
||||
- **Deployment using this CNC:** see `docs/deployments/wonder-app-vd03-makino-z-34184.md`.
|
||||
|
||||
## Symptom
|
||||
|
||||
Equipment tags backed by this device never leave OPC UA `Bad_WaitingForInitialData` (`0x80320000`),
|
||||
and the FOCAS FixedTree emits no nodes (capability detection against the CNC never succeeds).
|
||||
|
||||
## Reproduction (driver CLI, straight to the CNC — no OPC UA server involved)
|
||||
|
||||
```bash
|
||||
dotnet run --project src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Cli -- \
|
||||
probe -h 10.201.31.5 -s Thirty_i --timeout-ms 6000 --verbose
|
||||
```
|
||||
|
||||
```
|
||||
CNC: 10.201.31.5:8193
|
||||
Series: Thirty_i
|
||||
Health: Degraded
|
||||
Last error: Unsupported FOCAS PDU version 3.
|
||||
R100 → 0x80050000 (BadCommunicationError)
|
||||
```
|
||||
|
||||
## Root cause (exact)
|
||||
|
||||
`src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/Wire/FocasWireProtocol.cs`
|
||||
|
||||
- Line 22: `public const ushort Version = 1;`
|
||||
- `ReadPduAsync` (~line 102) and `ReadPdu` (~line 125): `if (version != Version) throw new FocasWireException($"Unsupported FOCAS PDU version {version}.");`
|
||||
|
||||
The 10-byte PDU header is `A0 A0 A0 A0` magic + `u16` version + type byte + direction byte + `u16`
|
||||
body length. The client emits version 1 and **rejects any response whose version field != 1**. The
|
||||
30i-B answers the initiate with version 3.
|
||||
|
||||
## What the wire actually shows (captured live, read-only initiate handshake)
|
||||
|
||||
Request the driver sends (socket-1 initiate, exactly as today):
|
||||
```
|
||||
a0 a0 a0 a0 00 01 01 01 00 02 00 01
|
||||
^magic------ ^v=1 ^t ^d ^len ^sockIdx=1
|
||||
```
|
||||
|
||||
Response from the real 30i-B:
|
||||
```
|
||||
a0 a0 a0 a0 00 03 01 02 01 68 + 360-byte body
|
||||
^magic------ ^v=3 ^t ^d ^len=0x0168(360)
|
||||
body[0..]: 00 08 00 05 00 03 00 20 00 08 00 0a 00 03 00 03 00 0f 00 0f 00 01 00 02 00 01 00 00 00 01 02 00 ...
|
||||
```
|
||||
|
||||
Key observations:
|
||||
- **The 10-byte header framing is byte-identical to v1** — same magic, same `type=0x01` (initiate),
|
||||
same `direction=0x02` (response). **Only the version field differs (3 vs 1).**
|
||||
- The initiate-response **body is 360 bytes** (v3 carries a larger capability/version descriptor block;
|
||||
it parses as a run of big-endian `u16` words). The current client doesn't deeply parse the initiate
|
||||
body, so its size/shape did not block the handshake once the version gate was relaxed.
|
||||
- **No version negotiation:** sending version 3 in our *request* header produced the identical response —
|
||||
the CNC speaks v3 unconditionally.
|
||||
|
||||
(Capture is reproducible with `scripts/focas/capture-initiate.py <host>`.)
|
||||
|
||||
## Implemented + validated — accept v3 on inbound PDUs
|
||||
|
||||
Shipped change (`Wire/FocasWireProtocol.cs`): the hard `version != Version` reject is replaced by a
|
||||
supported-read-version set `{1, 3}` (`SupportedReadVersions` + `IsSupportedReadVersion`). We still
|
||||
**emit** `Version` (v1) on requests — the 30i-B accepts v1 request framing — and now **accept** v1 or v3
|
||||
on inbound PDUs. Covered by `FocasWireProtocolTests.ReadPduAsync_accepts_supported_version` (v1 + v3
|
||||
theory) with the existing v99-rejection test still green; full FOCAS unit suite 218/218.
|
||||
|
||||
Validated live against `10.201.31.5` (30i-B) with the change in source — reading several addresses:
|
||||
|
||||
| Address | Type | Result |
|
||||
|---|---|---|
|
||||
| `MACRO:500` | Float64 | **0.02 — Good (`0x0`)** |
|
||||
| `MACRO:3901` (parts total) | Float64 | **0 — Good (`0x0`)** |
|
||||
| `MACRO:3902` (parts required) | Float64 | Good |
|
||||
| `R100` (PMC R-file) | Int16 | `0x803C0000` BadOutOfRange |
|
||||
| `PARAM:1320/0` | Int32 | `0x803D0000` BadNotSupported |
|
||||
|
||||
Interpretation:
|
||||
- **Initiate handshake + macro command (`cnc_rdmacro`, cmd path) data framing are already v3-compatible.**
|
||||
Macro reads returned correct, plausible values from the real machine. The deployed equipment tags
|
||||
(`MACRO:3901`/`3902`) would go **Good** with nothing more than the version-gate relaxation.
|
||||
- **PMC (`pmc_rdpmcrng`) and Parameter (`cnc_rdparam`) reads still fail.** Two candidate causes, not yet
|
||||
separated: (a) the v3 response *block/struct* framing for these specific commands differs from the
|
||||
v1-shaped parser (so the return-code/value lands at the wrong offset → spurious `BadOutOfRange` /
|
||||
`BadNotSupported`); or (b) genuine CNC restrictions (PMC path/range, parameter not present). Macro
|
||||
working argues the *envelope* is fine, so this is per-command struct work, not a framing rewrite.
|
||||
|
||||
## Status-command validation on v3 (the FixedTree surface) — 2026-06-25
|
||||
|
||||
Drove every `IFocasClient` status call directly against the live control (v3 accepted). **Most of the
|
||||
FixedTree lights up on v3.** Note: sysinfo reveals the control is actually a **31i** (CncType=31, Series
|
||||
`G431`, MaxAxis 32, 7 axes, MtType MM) — the deployment declared `Thirty_i`; same family, reads fine.
|
||||
|
||||
| Call (FOCAS fn) | Result on v3 |
|
||||
|---|---|
|
||||
| `GetSysInfoAsync` (`cnc_sysinfo`) → Identity | ✅ real — CncType 31, Series G431, 7 axes, MtType MM |
|
||||
| `GetAxisNamesAsync` (`cnc_rdaxisname`) | ✅ real — X,Y,Z,B,C,A,A |
|
||||
| `GetSpindleNamesAsync` (`cnc_rdspdlname`) | ✅ real — S1 |
|
||||
| `GetProgramInfoAsync` (program/mode) | ✅ real — `//CNC_MEM/USER/LIBRARY/O1111`, Mode 1 |
|
||||
| `ReadDynamicAsync(n)` (`cnc_rddynamic2`) | ✅ real for axes 1..N (feed 4200, spindle ~15000, live positions). **axis 0 → `EW_4`** — the call is 1-based; FixedTree must iterate 1..N, not 0 |
|
||||
| `GetTimerAsync(*)` | ⚠️ **misparsed** — a running machine shows PowerOn/Operating/Cycle = 0 and Cutting = garbage; the v3 timer struct differs |
|
||||
| `GetServoLoadsAsync` (`cnc_rdsvmeter`) | ❌ **hangs** — blocks awaiting bytes that never arrive (v3 framing differs) *and* ignores the cancellation token (poll-loop-stalling robustness bug; the read must honor CT regardless) |
|
||||
| `ReadAlarmsAsync` (`cnc_rdalmmsg2`) | ❓ untested — ServoLoads hung ahead of it; validate once the hang is fixed |
|
||||
|
||||
Remaining v3 work, now scoped concretely: timer struct; 1-based axis iteration for dynamic; the
|
||||
`cnc_rdsvmeter` framing + a cancellation-honoring read; and the PMC (`pmc_rdpmcrng`) + Parameter
|
||||
(`cnc_rdparam`) struct diffs. Identity / axes / positions / feed / spindle / program-mode already work.
|
||||
|
||||
## Implementation analysis — what "support PDU v3" actually involves
|
||||
|
||||
1. **Accept v3 at the framing layer (cheap, validated). — ✅ DONE.** Replaced the `version != Version`
|
||||
hard reject with the supported-set `{1, 3}`. This alone makes the **initiate handshake + all macro
|
||||
reads** work on a real 30i-B (validated live; deployed `MACRO:3901`/`3902` read Good). We still emit
|
||||
v1 on requests (the CNC accepts it). If a future command turns out to need the request version echoed,
|
||||
thread the negotiated version from the initiate response onto the connection.
|
||||
2. **Validate each command family against v3 response framing.** Capture v3 `0x21` data-PDU responses
|
||||
for `cnc_rdparam`, `pmc_rdpmcrng`, `cnc_statinfo`, `cnc_rddynamic2`, `cnc_rdaxisname`, and the timer
|
||||
reads (the FixedTree set), and diff the block/struct offsets vs the v1 assumptions in
|
||||
`FocasWireModels.cs` / the `ParseX` helpers. Where they differ, add v3 parsing. Capture by extending
|
||||
`scripts/focas/capture-initiate.py` to complete the handshake and issue one data request per command.
|
||||
3. **FixedTree depends on (2).** Identity/Axes/Timers/Program nodes only emit if `cnc_sysinfo` +
|
||||
`cnc_rdaxisname` + dynamic/timer reads succeed at discovery — so they come online once `cnc_statinfo`
|
||||
/ `cnc_rddynamic2` / timer framing is v3-validated.
|
||||
4. **Don't let the gate lie.** Shipping only step 1 makes the driver accept v3 while PMC/PARAM/FixedTree
|
||||
silently misbehave. Either gate macro-only configs as "supported on v3" with the others explicitly
|
||||
flagged, or land steps 1–3 together.
|
||||
|
||||
### Alternative considered: reinstate an FWLIB-backed client
|
||||
The official FANUC FWLIB (`Fwlib64.dll`) handles all protocol versions natively. But the `fwlib`/`ipc`
|
||||
backends were deliberately retired in the Wire migration (native Windows component, x86/x64 + STA, and
|
||||
licensing — the exact coupling the managed client removed). Reintroducing it reverses that decision and
|
||||
is heavier than completing v3 in the wire client; recommend only if multiple controls need surfaces the
|
||||
managed client can't reach.
|
||||
|
||||
## Secondary finding — the Test-Connect / health probe is misleading without FWLIB
|
||||
|
||||
`FocasDriverProbe` Phase 2 (the real `cnc_allclibhndl3` FWLIB handshake) **catches the FWLIB-absent load
|
||||
failure and degrades to `Ok=true` ("TCP reachability only")**. On a host with no FWLIB (the normal case
|
||||
for the managed wire client), the driver therefore reports **HEALTHY off a bare TCP connect** — which is
|
||||
exactly how this CNC looked "healthy" while no data flowed. The probe should exercise the wire-client
|
||||
path (open a `WireFocasClient` session + one sample read) so health reflects real FOCAS reachability,
|
||||
not just an open socket.
|
||||
|
||||
## Recommended next steps
|
||||
|
||||
1. Land step 1 (accept v3) + capture/validate PMC + Parameter + FixedTree command framing (step 2),
|
||||
ideally in one change, tested against `10.201.31.5` while access lasts.
|
||||
2. Fix the probe to use the wire client so HEALTHY means "FOCAS session + read OK," not "TCP open."
|
||||
3. Add a real-hardware row to `docs/v2/focas-version-matrix.md` (currently hardware-free) recording that
|
||||
30i-B = PDU v3, macro reads validated.
|
||||
@@ -0,0 +1,156 @@
|
||||
# FOCAS PDU v3 — implementation plan (finish real 30i/31i-B support)
|
||||
|
||||
**Date:** 2026-06-25
|
||||
**Companion to:** [`2026-06-25-focas-pdu-v3-30i-b-support.md`](2026-06-25-focas-pdu-v3-30i-b-support.md) (finding + live captures + per-command validation) and [`../deployments/wonder-app-vd03-makino-z-34184.md`](../deployments/wonder-app-vd03-makino-z-34184.md) (the deployment this unblocks).
|
||||
**Goal:** make the managed `WireFocasClient` fully interoperate with a real FANUC 30i/31i-B (FOCAS Ethernet **PDU v3**), then light up the live OPC UA data on `wonder-app-vd03` for the Makino Pro 5 (`Z-34184`, `10.201.31.5`).
|
||||
|
||||
## ⏳ Time-boxed asset — capture/validate live FIRST
|
||||
`10.201.31.5:8193` (real 31i-B) is reachable from the dev box and the wonder host **right now**. Every
|
||||
per-command v3 framing fix needs a live capture + live re-validation. **Do all captures in Phase 1 while
|
||||
access lasts**; the parser fixes + unit tests can be finished offline against the captured bytes later.
|
||||
Tools already in place: `scripts/focas/capture-initiate.py <host>` (initiate only — extend it) and the
|
||||
throwaway status harness pattern (see the finding doc).
|
||||
|
||||
## Current status — DONE (2026-06-25), live-validated on the real 31i-B (`10.201.31.5`)
|
||||
The Phase-1 capture corrected the diagnosis (4 of 6 "framing failures" were not framing problems).
|
||||
All tractable phases are implemented + unit-tested (FOCAS suite **234 green**, full solution builds 0
|
||||
errors) + re-validated live. The full corrected diagnosis + per-surface evidence is in the companion
|
||||
finding doc's **Resolution** section.
|
||||
|
||||
| Phase | Item | State |
|
||||
|---|---|---|
|
||||
| 0 | inbound PDU-version gate `{1,3}` | DONE — macros + all status reads work on v3 |
|
||||
| 1 | capture every v3 data PDU | DONE — 20 fixtures under `tests/.../Fixtures/v3/` |
|
||||
| 2 | servo "hang" → CT-bound reads | DONE — `ReadExactlyAsync` dispose-on-cancel; servo answers in 0 ms (no real wire hang) |
|
||||
| 3 | request-version policy | DONE — keep emitting v1 (CNC accepts it); no command needed v3 requests |
|
||||
| 4 | servo + alarms framing | DONE — servo 8-byte stride + names from 0x0089; alarms already correct (read `#3080` live) |
|
||||
| 5 | timer v3 struct | DONE — `ParseTimer` little-endian {minute, msec} |
|
||||
| 6 | dynamic axis iteration | DONE — driver poll already 1-based; added a `ReadDynamicAsync` contract guard |
|
||||
| 7 | PMC framing | DONE — `end = start + width - 1`; **parameter framing BLOCKED** (EW_FUNC across 14 variants — see finding) |
|
||||
| 8 | probe truthfulness | DONE — `FocasDriverProbe` runs a real wire session (initiate + cnc_statinfo) |
|
||||
| 9 | docs + version matrix | DONE — this plan, the finding doc, `FOCAS.md`, `focas-version-matrix.md` |
|
||||
| 10 | deploy to wonder + e2e | DONE (binary live, driver speaks v3) — but live-tag verify BLOCKED by a separate OtOpcUa data-plane issue (see below) |
|
||||
| 11 | commit + push | DONE — `feat/focas-pdu-v3` @ `5f0a5286` committed + pushed to gitea |
|
||||
|
||||
**Only genuinely open v3 item:** `cnc_rdparam` (EW_FUNC on every framing — needs a reference FWLIB
|
||||
trace or is restricted on this control). Deferred; the deployed config uses macros, not parameters.
|
||||
|
||||
## Phase 10 outcome — v3 binary LIVE, but a separate OtOpcUa data-plane issue blocks tag values
|
||||
Deployed the Release driver DLL to `E:\ApiInstall\OtOpcUa\` (backup `_focasbak-pre-v3-20260625T164909.dll`),
|
||||
restarted `OtOpcUaHost` (clean), and re-deployed in the AdminUI (deployment `12e0d528`, Sealed/In-sync).
|
||||
**`DRIVER STATUS: HEALTHY` now reflects a real FOCAS v3 session** (the rewritten probe does initiate +
|
||||
`cnc_statinfo`) — i.e. the deployed binary genuinely speaks v3 to the Makino, which was impossible before.
|
||||
|
||||
**However**, the live OPC UA equipment tags (`parts-count`/`parts-required` = `MACRO:3901/3902`) still read
|
||||
`Bad_WaitingForInitialData` via `read` and a 30 s `subscribe`, and a recursive browse shows ONLY the two
|
||||
UNS-projected macro tags — **no FixedTree (Identity/Axes/Timers/…) nodes** — identical to before the v3 fix,
|
||||
and unchanged by host-restart / re-deploy / driver-`Restart`. A box-side watch saw no 250 ms-cadence
|
||||
connection to the CNC (only the periodic probe), so the driver's **data poll loop isn't running** while its
|
||||
probe loop is. This is a **separate, pre-existing OtOpcUa data-plane / Equipment-projection issue**, not a
|
||||
FOCAS-protocol problem (the wire client is proven by the healthy real-session probe + exhaustive dev-box
|
||||
reads). Follow-on: investigate why the driver's DiscoverAsync FixedTree build + equipment-tag value source
|
||||
don't run/surface on this single fused admin+driver node (poll-group engine / monitored-item sampling /
|
||||
whether the Equipment projection exposes driver FixedTree auto-nodes at all).
|
||||
|
||||
---
|
||||
|
||||
## Phase 1 — Capture every v3 data-PDU response (live, do first)
|
||||
- Extend `scripts/focas/capture-initiate.py` (or add a C# capture mode to `Driver.FOCAS.Cli`) to: run the
|
||||
two-socket initiate, then send each `0x21` data request (command IDs in
|
||||
`docs/v2/implementation/focas-wire-protocol.md`) and dump the raw v3 response: `cnc_rdtimer`,
|
||||
`cnc_rdsvmeter`, `pmc_rdpmcrng` (R100), `cnc_rdparam` (e.g. 1320), `cnc_rdalmmsg2`, `cnc_rddynamic2`
|
||||
(axis 1 — a known-good — as the v3 reference layout).
|
||||
- Save raw bytes as fixtures under `tests/Drivers/.../Fixtures/v3/` for offline unit tests.
|
||||
- **Acceptance:** raw v3 response bytes captured + checked in for all six commands.
|
||||
|
||||
## Phase 2 — Safety: `cnc_rdsvmeter` must never hang
|
||||
- Root cause: the read blocks waiting for a body length the v3 response never satisfies, and the wait
|
||||
doesn't observe the `CancellationToken`. A hang here can wedge the FixedTree poll loop.
|
||||
- Make the wire read honor the per-operation timeout/CT **regardless of framing** (the socket read path in
|
||||
`FocasWireClient` must be CT-bound), so a bad parse fails fast as `BadCommunicationError`/timeout.
|
||||
- **Acceptance:** `GetServoLoadsAsync` returns or fails within the timeout on the live 31i-B; a unit test
|
||||
proves a truncated/oversized body length cancels rather than blocks. **Gating:** FixedTree must not be
|
||||
enabled on a v3 control until this lands (capability probe could otherwise hang at init).
|
||||
|
||||
## Phase 3 — Decide request-version policy
|
||||
- We currently emit v1 requests and accept v1/v3 responses; macro + most status reads work that way.
|
||||
- If any Phase 5–7 command turns out to need v3-framed *requests*, thread the version negotiated from the
|
||||
initiate response onto `FocasWireClient` and have `BuildPdu` emit it. Otherwise keep emitting v1.
|
||||
- **Acceptance:** documented decision; negotiated version plumbed only if a command requires it.
|
||||
|
||||
## Phase 4 — Servo load + alarms v3 framing
|
||||
- Diff captured `cnc_rdsvmeter` / `cnc_rdalmmsg2` bytes vs the v1 struct assumptions in `FocasWireModels.cs`
|
||||
+ the `ParseServoLoad` / alarm parsers; fix offsets/strides for v3.
|
||||
- **Acceptance:** servo-load % values are plausible; `ReadAlarmsAsync` returns the real active-alarm set;
|
||||
unit tests over the Phase-1 fixtures; live re-validation.
|
||||
|
||||
## Phase 5 — Timer v3 struct
|
||||
- Diff captured `cnc_rdtimer` bytes; fix the timer struct parse (running machine must show non-zero
|
||||
PowerOn/Operating; Cutting sane).
|
||||
- **Acceptance:** all four timers plausible on the live machine; fixture unit test; matches the
|
||||
FixedTree `Timers/*` node expectations.
|
||||
|
||||
## Phase 6 — Dynamic axis iteration (1-based)
|
||||
- FixedTree currently probes axis 0 → `EW_4`. Iterate `1..AxesCount` (from `cnc_sysinfo`); never request 0.
|
||||
- **Acceptance:** every configured axis (per sysinfo `AxesCount`) yields a `FocasDynamicSnapshot`; no `EW_4`.
|
||||
|
||||
## Phase 7 — PMC + Parameter v3 framing
|
||||
- Diff captured `pmc_rdpmcrng` (R100) + `cnc_rdparam` (1320) bytes vs the v1 `IODBPMC0` / `IODBPSD` shapes;
|
||||
fix v3 parsing. Confirm whether the failures are framing or genuine CNC restriction (PMC path / param
|
||||
presence) — macro working proves the envelope is fine, so suspect struct offsets first.
|
||||
- **Acceptance:** `R100` reads a plausible value (or a *correct* status if genuinely restricted); a known
|
||||
parameter reads its value; fixture unit tests; live re-validation.
|
||||
|
||||
## Phase 8 — Probe truthfulness
|
||||
- `FocasDriverProbe` Phase-2 degrades to `Ok=true` ("TCP only") when FWLIB is absent → HEALTHY off a bare
|
||||
socket. Replace with a wire-client probe: open `WireFocasClient` + one sample read (e.g. sysinfo). Keep
|
||||
the TCP preflight for fast rejection.
|
||||
- **Acceptance:** probe reports unhealthy when the CNC TCP-accepts but FOCAS reads fail; HEALTHY only on a
|
||||
real session + read.
|
||||
|
||||
## Phase 9 — Docs + version matrix
|
||||
- Add a real-hardware row to `docs/v2/focas-version-matrix.md`: 30i/31i-B → PDU v3; record which command
|
||||
families are validated. Update `docs/drivers/FOCAS.md` + this plan's status as phases land.
|
||||
|
||||
## Phase 10 — Deploy to wonder + end-to-end verify
|
||||
- Optional: set the device series to `ThirtyOne_i` (sysinfo says CncType 31; capability ranges identical to
|
||||
`Thirty_i`, so cosmetic).
|
||||
- Rebuild a self-contained win-x64 publish of `ZB.MOM.WW.OtOpcUa.Host` (or swap just
|
||||
`ZB.MOM.WW.OtOpcUa.Driver.FOCAS.dll`) into `E:\ApiInstall\OtOpcUa\` on `wonder-app-vd03`, preserving
|
||||
`appsettings*.json` + `data\`; restart `OtOpcUaHost`. (Access: servecli `:2222`, key
|
||||
`~/.ssh/servecli_wonder` — see the deployment doc + memory.)
|
||||
- **Re-run a deployment** in the AdminUI afterward — FixedTree nodes are emitted at `DiscoverAsync`, so the
|
||||
address space must be rebuilt to surface them.
|
||||
- **Acceptance (via the OtOpcUa CLI client → `opc.tcp://wonder-app-vd03.zmr.zimmer.com:4840/OtOpcUa`):**
|
||||
`ns=2;s=EQ-3686c0272279/parts-count` + `/parts-required` read **Good**; FixedTree Identity/Axes/Program
|
||||
nodes present with live values; (timers/servo-load good once Phases 4–5 land).
|
||||
|
||||
## Phase 11 — Commit + push
|
||||
- Commit source + tests + docs on a branch `feat/focas-pdu-v3` (keep it separate from the unrelated
|
||||
pre-existing local edits in the tree). Push to gitea per the repo's flow. The Akka-roles host fix is a
|
||||
separate concern (see deployment doc) — note it but it's a box config change, not repo code.
|
||||
|
||||
---
|
||||
|
||||
## Test strategy
|
||||
- **Offline (CI-safe):** unit tests over the Phase-1 captured v3 byte fixtures for every parser
|
||||
(`FocasWireProtocolTests` + new `FocasWireModels`/parse tests). Keep the docker mock (v1) green.
|
||||
- **Live (env-gated):** the `Driver.FOCAS.Cli` (`probe`/`read`) + the status harness, against
|
||||
`10.201.31.5`. Gate behind an env var / `[Trait]` so CI without a CNC skips.
|
||||
|
||||
## Sequencing notes
|
||||
- Phase 1 (capture) unblocks 4/5/7. Phase 2 (servo-load safety) gates enabling FixedTree on v3. Phases 4–7
|
||||
are independent and parallelizable once captures exist. Phase 10 depends on whichever surfaces you want
|
||||
live (macro tags already work after Phase 0, so a minimal deploy could happen now; full FixedTree wants
|
||||
Phases 2/5/6).
|
||||
- **Keep emitting v1 requests** unless Phase 3 proves otherwise — it's validated and minimal.
|
||||
|
||||
## File map
|
||||
- `src/Drivers/.../Wire/FocasWireProtocol.cs` — version gate (done), request-version policy (Phase 3).
|
||||
- `src/Drivers/.../Wire/FocasWireClient.cs` — CT-bound reads (Phase 2), per-command requests.
|
||||
- `src/Drivers/.../Wire/FocasWireModels.cs` + parse helpers — per-command v3 struct fixes (Phases 4–7).
|
||||
- `src/Drivers/.../FocasDriver.cs` — FixedTree axis iteration (Phase 6), FixedTree enable gating (Phase 2).
|
||||
- `src/Drivers/.../FocasDriverProbe.cs` — wire-client probe (Phase 8).
|
||||
- `scripts/focas/capture-initiate.py` — extend to data PDUs (Phase 1).
|
||||
- `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/` + `Fixtures/v3/` — fixtures + parser tests.
|
||||
- `docs/v2/focas-version-matrix.md`, `docs/drivers/FOCAS.md` — docs (Phase 9).
|
||||
@@ -0,0 +1,178 @@
|
||||
# OtOpcUa equipment-tag data-plane — why live FOCAS values don't surface (investigation plan)
|
||||
|
||||
**Date:** 2026-06-25
|
||||
**Companion to:** [`2026-06-25-focas-pdu-v3-implementation-plan.md`](2026-06-25-focas-pdu-v3-implementation-plan.md) (the FOCAS PDU-v3 driver work — DONE + deployed) and [`../deployments/wonder-app-vd03-makino-z-34184.md`](../deployments/wonder-app-vd03-makino-z-34184.md).
|
||||
**Goal:** make the deployed Makino tags (`parts-count`/`parts-required` = `MACRO:3901/3902`) actually carry live values over OPC UA, and make the FOCAS FixedTree (Identity/Axes/Timers/…) appear in the served address space.
|
||||
|
||||
## The symptom (precise, observed 2026-06-25 after the v3 deploy)
|
||||
Against `opc.tcp://wonder-app-vd03.zmr.zimmer.com:4840/OtOpcUa` (Security None, anonymous, DisableLogin):
|
||||
- `read` AND a 30 s `subscribe` of `ns=2;s=EQ-3686c0272279/parts-count` (and `/parts-required`) return **`0x80320000` Bad_WaitingForInitialData** — never a value.
|
||||
- A recursive browse of the whole served tree shows ONLY `machining → makino → z-34184 → {parts-count, parts-required}` — **no FixedTree nodes** (Identity/Axes/Timers/Program/…).
|
||||
- Unchanged by host-restart, a full AdminUI re-deploy (`12e0d528`, Sealed/In-sync), and a driver `Restart`.
|
||||
- A ~15 s box-side watch saw **no 250 ms-cadence TCP connection** to `10.201.31.5:8193` — only the occasional probe connect.
|
||||
|
||||
## What is RULED OUT (do not re-investigate)
|
||||
- **The FOCAS PDU-v3 driver.** Proven working: the rewritten `FocasDriverProbe` does a real initiate + `cnc_statinfo` and the AdminUI shows **DRIVER STATUS: HEALTHY, last success Ns ago**; and on the dev box every surface (sysinfo / axes / dynamic / macros / timer / PMC / servo / alarms) reads correctly through the same `WireFocasClient`. The wire path talks v3 to the Makino.
|
||||
- **Deployment / Akka roles.** Re-deploy sealed with no "task canceled"; the node has admin+driver roles; the address space (the 2 UNS tags) is served.
|
||||
- **Reachability.** TCP 8193 reachable from the box; OPC 4840 reachable from the dev box.
|
||||
|
||||
So the problem is the OtOpcUa **data plane**: the driver's *control/probe plane* runs, but its *data plane* (the FixedTree poll loop output + the equipment-tag value source) is not reaching the served address space. This was present last session too — it was masked by the v3 reject; with v3 now working it stands alone.
|
||||
|
||||
## Leading hypotheses (to confirm/refute, not assume)
|
||||
- **H1 — FixedTree bootstrap is stuck/throwing.** `FocasDriver.FixedTreeLoopAsync` is started at `FocasDriver.cs:160` inside `InitializeAsync` (which demonstrably ran — the probe loop at `:137` is alive). Its bootstrap `while (state.FixedTreeCache is null)` loop (~`:650`) calls `GetSysInfoAsync`/`GetAxisNamesAsync`/optional probes; if one throws it retries every 2 s and the cache never populates → no FixedTree nodes, no poll. The brief 2 s-spaced connects could explain "no steady 250 ms connection."
|
||||
- **H2 — FixedTree nodes aren't projected under the Equipment model.** Galaxy/FOCAS are now *standard Equipment-kind drivers*; the served tree is the UNS/Equipment projection (configured tags only). Driver `DiscoverAsync` FixedTree auto-nodes may only live in a raw-driver namespace that the UNS browse doesn't show — i.e. FixedTree-under-Equipment may be unsupported-by-design, not a bug.
|
||||
- **H3 — equipment-tag value source never primed.** A point-read returns the server's cached value; with no active poll/subscription seeding it, that's `WaitingForInitialData`. But a 30 s `subscribe` ALSO stayed bad — so either the monitored-item → `FocasDriver.ReadAsync` wiring isn't firing, or `ReadAsync(MACRO:3901)` returns no value on the box. (Analogy: the ScadaBridge DCL seed-ordering / static-tag false-bad class of bug — see that memory.)
|
||||
- **H4 — a data-plane gate.** A role/active-node gate (cf. "data-plane roles need `Security:Ldap:GroupToRole` mapped or write/ack is inert") could suppress the read/poll on this single fused node under DisableLogin.
|
||||
|
||||
## ⚠️ Observability is the bottleneck
|
||||
There are currently **no OtOpcUa app logs on the box** (Windows eventlog shows only NSSM start/stop; Serilog has no file sink in this deployment — cf. the MxGateway windev note). **Getting runtime logs is the single highest-leverage step** and gates H1/H3/H4.
|
||||
|
||||
---
|
||||
|
||||
## Phase 1 — Offline code trace of the data plane (do FIRST; free, no box/CNC)
|
||||
Trace, in the repo on the dev box, the two paths end-to-end. This may reveal the root cause with zero box access.
|
||||
1. **Equipment-tag read path:** from an OPC UA `Read`/`CreateMonitoredItem` on `ns=2;s=EQ-…/parts-count` → the server's address-space node → the "FullName→NodeId router" → `FocasDriver.ReadAsync`. Find: who invokes the driver read, whether point-reads vs subscriptions differ, and where/whether a value is cached vs `WaitingForInitialData` is returned. Entry points: `FocasDriver.cs:247` (resolver-produced equipment tags), `FocasDriver.ReadAsync`, and the server-side poll-group / monitored-item sampler (grep `PollGroup`, `MonitoredItem`, the Equipment namespace value provider).
|
||||
2. **FixedTree path:** `FocasDriver.FixedTreeLoopAsync` (`:644`) → `state.FixedTreeCache` → `TryReadFixedTree` → how those nodes are added to the address space. Determine whether FixedTree nodes are emitted into the **Equipment/UNS projection** at all, or only a raw-driver namespace (resolves H2). Grep `DiscoverAsync`, `FixedTreeReference`, and the address-space builder for the Equipment kind.
|
||||
3. **Driver lifecycle/gates:** confirm what conditions start/suppress the data poll vs the probe loop (active-node, role, enabled flags) — `FocasDriver.cs:87–181`, the cluster/active-node gate.
|
||||
- **Output:** a written root-cause hypothesis (which of H1–H4) + the specific code site, OR a precise list of what only box logs can disambiguate.
|
||||
|
||||
### ✅ Phase 1 FINDINGS (2026-06-25, offline subagent code-trace — DONE)
|
||||
|
||||
| Hypothesis | Verdict | Confidence |
|
||||
|---|---|---|
|
||||
| **H2** — FixedTree nodes not projected under Equipment model | **CONFIRMED root cause of symptom #2 — by-design** | proven from code |
|
||||
| **H3** — equipment-tag value source never primed | **CONFIRMED mechanism of symptom #1**; routing code is self-consistent → break is **runtime**, not static | mechanism proven; exact break needs box logs |
|
||||
| **H4** — role/active-node data-plane gate | **REFUTED** for the read/subscribe path | proven from code |
|
||||
| **H1** — FixedTree bootstrap stuck/throwing | best fit for symptom #3 (no 250 ms connect), but **moot** for #2 and **not** the cause of #1 | strongly-suggested; needs logs |
|
||||
| **H5 (new)** — FOCAS tag with blank `deviceHostAddress` → `BadNodeIdUnknown` | **latent trap** — yields a *Bad* value, not WaitingForInitialData, so it's the *next* failure, not the current symptom | proven from code |
|
||||
|
||||
**Why symptom #2 (no FixedTree nodes):** FixedTree nodes are emitted **only** by `FocasDriver.DiscoverAsync` (`FocasDriver.cs:391-494`, gated on `FixedTreeCache`), and `DiscoverAsync` is **never invoked** by the deployed equipment-kind address-space build. Production materializes the served tree **purely from Config-DB Tag rows** (`EquipmentNodeWalker.cs:24-25,161-165`; `OpcUaPublishActor.RebuildAddressSpace` → `MaterialiseEquipmentTags(composition)` at `OpcUaPublishActor.cs:326-337`). No runtime caller of `IDriver.DiscoverAsync` exists. ⇒ **By-design gap, not a bug.** Surfacing FixedTree under the Equipment tree is a *feature* (author explicit Tag rows per signal, or extend the composition pipeline to merge `ITagDiscovery` output — a substantial `AddressSpaceComposer`/`AddressSpaceApplier` change).
|
||||
|
||||
**Why symptom #1 (`0x80320000` even under a 30 s subscribe):** equipment-tag node value is **push-only**. `0x80320000` is the materialization **seed** (`OtOpcUaNodeManager.EnsureVariable:1400-1402`); there is **no `OnReadValue`/server-side sampler** for equipment tags, so both Read and MonitoredItem serve the cached node value. The seed is overwritten **only** by `ForwardToMux → WriteValue` (`OtOpcUaNodeManager.cs:261-281`), which writes even Bad/null. `StatusFromQuality` maps Bad → `0x80000000`, **not** `0x80320000`. ⇒ A persistent `0x80320000` **proves no `AttributeValueUpdate` ever landed** for those two NodeIds. The routing chain (`DriverHostActor.PushDesiredSubscriptions` → `_nodeIdByDriverRef` → `DriverInstanceActor.ResubscribeDesired` → `PollGroupEngine` forceRaise → `OnDataChangeForward` → `ForwardToMux`) is **provably self-consistent for FOCAS** (`FocasTagConfigModel` writes no top-level `FullName` → `ExtractTagFullName` returns the raw JSON blob → `FocasEquipmentTagParser.TryParse` resolves it; subscribed ref == published ref == routing-map key == NodeId, all from the same `t.FullName`). So the static mapping is **refuted**; the break is **runtime**: either the FOCAS child was never subscribed those refs, or `_nodeIdByDriverRef` lacked the `(DriverInstanceId, FullName)` key at publish time — most likely a **`DriverInstanceId` attribution mismatch** between the equipment Tag rows and the spawned `DriverInstance`.
|
||||
|
||||
**What only box logs (or a ConfigDb snapshot read) can disambiguate for symptom #1 — capture these on the driver-role node after a deploy:**
|
||||
1. `DriverHostActor` info *"SubscribeBulk pushed {Refs} references across {Drivers} driver(s)"* (`DriverHostActor.cs:1058`) — `Refs<2` ⇒ equipment Tag `DriverInstanceId` ≠ spawned FOCAS child id (attribution mismatch).
|
||||
2. `DriverInstanceActor` info *"subscribed to {Count} refs"* (`DriverInstanceActor.cs:571`) — absent/`0` ⇒ `_desiredRefs` empty or never reached `Connected`.
|
||||
3. `DriverHostActor` debug *"no equipment-tag NodeId for ({Driver},{Ref}) — value dropped"* (`DriverHostActor.cs:550`) — present ⇒ `_nodeIdByDriverRef` key miss.
|
||||
4. `FocasDriver` debug *"FOCAS fixed-tree bootstrap failed … retrying"* every ~2 s (`FocasDriver.cs:686`) ⇒ confirms H1 (`cnc_sysinfo`/`cnc_rdaxisname` failing on the Makino). Needs Debug level + logger-constructed wire client.
|
||||
5. A driver-side read of `MACRO:3901` (live Makino): `BadNodeIdUnknown` ⇒ H5 blank-`deviceHostAddress` trap; Good value ⇒ wire path fine, gap is purely the OPC-UA equipment subscription wiring.
|
||||
|
||||
**Cheaper-than-Phase-2 lead:** the prime suspect (Tag-row `DriverInstanceId` vs `DriverInstance` row mismatch) is checkable by reading the **deployed ConfigDb snapshot** (`OtOpcUaConfig` on `wonder-sql-vd03:1433`) — no service restart, no log-sink change. Worth doing before/instead of the Phase 2 log-sink change.
|
||||
|
||||
**Suggested minimal-fix shapes (NOT implemented):**
|
||||
- Symptom #1: depends on which log line/row fires — if attribution miss, it's a **config/snapshot re-bind** (no code). If a real wiring gap, localize first.
|
||||
- H5 latent: in `FocasDriver.ReadAsync`/`WriteAsync` device lookup (`:289`,`:346`), when `DeviceHostAddress` is blank and `_devices.Count==1`, resolve to the sole device instead of `BadNodeIdUnknown` (or make the AdminUI field mandatory).
|
||||
- Symptom #2 (H2): no fix — by-design; surfacing FixedTree is a feature decision.
|
||||
|
||||
### ✅ ConfigDb snapshot read (2026-06-25, symptom #1 path — chosen over box logs)
|
||||
Read live `OtOpcUaConfig` on `wonder-sql-vd03` (query run on-box so the SQL password never left the box). Ground truth:
|
||||
- **Equipment** `EQ-3686c0272279` (`z-34184`): `DriverInstanceId=focas-z-34184`, `DeviceId=NULL`, `Enabled=True`, `UnsLineId=makino`.
|
||||
- **Tags** `parts-count`/`parts-required`: both `DriverInstanceId=focas-z-34184`, `DeviceId=NULL`, `DataType=Double`, `AccessLevel=Read`, `FolderPath=NULL`, **`PollGroupId=NULL`**, `TagConfig={"deviceHostAddress":"10.201.31.5:8193","address":"MACRO:390{1,2}","dataType":"Float64"}`.
|
||||
- **DriverInstance**: one row `focas-z-34184` (`DriverType=Focas`, `Enabled=True`, ns `dev-equipment`, cluster `DEV`).
|
||||
- **Device** table: **0 rows** — NORMAL: `FocasDriver` builds `_devices` from `_options.Devices` (DriverConfig JSON) not the Device table (`FocasDriver.cs:97`).
|
||||
|
||||
**Refuted by this read:** prime-suspect `DriverInstanceId` attribution mismatch (matches exactly) and H5 blank-`deviceHostAddress` (present). The deployed config is **clean**. ⇒ symptom #1 is a pure value-flow-plumbing break. New live leads: **`PollGroupId=NULL`** on both tags (is a poll group required to subscribe/poll?) and the **resolver-registration** path (equipment-tag refs are "resolver-produced, not seeded at `InitializeAsync`" per `FocasDriver.cs:247` — does poll-time `TryResolve` of the JSON-blob ref ever succeed?). A second offline subagent trace of DriverHostActor↔DriverInstanceActor↔PollGroupEngine↔resolver is running to pin the exact broken link.
|
||||
|
||||
### ⚠️ FixedTree feature (symptom #2 — user chose "build the feature") — ARCHITECTURE REALITY
|
||||
Mapped the composition pipeline. Two address-space paths exist: (1) **Equipment/UNS projection** `AddressSpaceComposer.Compose` (config entities only) → `AddressSpaceApplier.MaterialiseEquipmentTags` → the served `ns=2` tree where `EQ-…` lives; (2) **raw-driver namespace** `GenericDriverNodeManager.BuildAddressSpaceAsync` → `driver.DiscoverAsync(IAddressSpaceBuilder)`. **Path 2 is DEAD: `BuildAddressSpaceAsync` has no runtime caller and `OpcUaApplicationHost.PopulateAddressSpaces` (its referenced caller) no longer exists.** Even `GalaxyDriver.DiscoverAsync` (`:588`) is reachable only via that dead path — Galaxy surfaces its hierarchy by being **authored as config equipment/tags**, not via discovery. ⇒ In the current Equipment-kind model **every served node is config-driven; `ITagDiscovery`/`DiscoverAsync` is legacy/dead for serving.** So "build the FixedTree feature" is NOT re-wiring an existing path — it's a **new dynamic-node-injection capability** into the Equipment projection, and it must solve a **timing problem**: composition runs at deploy/apply (before the driver connects), but FixedTree data only exists after the driver's async `FixedTreeCache` bootstrap. The far cheaper alternative that yields the same visible result is to **author FixedTree signals as config Tag rows** (each bound to a FOCAS fixed-tree reference) — same mechanism every other equipment tag uses. **Recommend re-confirming scope with the user given this cost delta before building.**
|
||||
|
||||
### 🎯 ROOT CAUSE — symptom #1 (CONFIRMED, 2026-06-25, 2nd subagent trace + code verify)
|
||||
**The FOCAS poll read hangs forever because (1) all wire I/O for a device shares one socket with NO serialization, and (2) the steady-state read has NO timeout.**
|
||||
|
||||
- **Unsynchronized shared socket:** `FocasDriver.EnsureConnectedAsync` (`:1101-1128`) returns the single `device.Client` with no I/O mutex. `DeviceState.ProbeLock` (`:1172`) only synchronizes probes with each other. Four independent loops issue wire ops on that same socket concurrently: the equipment poll (`PollGroupEngine`, 1 s — `SubscriptionPublishingInterval` const `DriverHostActor.cs:58`), the FixedTree loop (250 ms / 2 s bootstrap), the probe (5 s), the recycle loop. FOCAS/2 is strict request-response on one socket → concurrent unsynchronized reads interleave; one reader consumes another's response PDU and the victim read blocks waiting for bytes that never come.
|
||||
- **No read timeout:** the poll calls `FocasDriver.ReadAsync` (`:295-308`) → `WireFocasClient.ReadAsync` (`:417`) → `ReadMacroAsync` **without** the optional `timeout` → `FocasWireClient.CreateCallTimeout` (`:843-848`) skips `CancelAfter` when `timeout` is null → `ReadExpectedPduAsync` awaits the socket under a token that never fires. The blocked first `forceRaise` poll (`PollGroupEngine.cs:119`) never completes → `onChange` never fires → the `BadWaitingForInitialData` materialization seed (`OtOpcUaNodeManager.cs:1400`) is never overwritten → permanent `0x80320000`. (Connect `:1119` and probe `:604-610` ARE bounded — only the steady-state read isn't.)
|
||||
|
||||
**Why it works on the dev box but not deployed:** the dev-box harness/CLI does ONE read at a time with no FixedTree loop running → no socket collision → macros read fine (proven in the v3 work). Deployed, the FixedTree loop races the equipment poll on one socket → collision → hung read.
|
||||
|
||||
**Why both tags fail together:** they're in one driver's single 1 s subscription batch; `PollOnceAsync` reads them sequentially and hangs on the first, so neither reaches `onChange`.
|
||||
|
||||
**Ruled out:** `PollGroupId=NULL` is a red herring — verified at `ConfigComposer.cs:38` (snapshots all tags, no poll-group filter), `AddressSpaceComposer.Compose` (never reads `PollGroupId`), and `DriverHostActor.PushDesiredSubscriptions:965-973` (groups by `DriverInstanceId`, constant 1 s interval, no poll-group keying). Routing-key mismatch ruled out: subscribe ref == routing key == `onChange` ref (all from the same `FullName`). Not-subscribed ruled out by elimination: a *served* node implies it's in the same `ParseComposition` artifact used for subscribe; and every *deterministic* `ReadAsync` outcome pushes a specific Bad code (`BadNodeIdUnknown`/`BadCommunicationError`/`BadNotSupported`), none of which is `0x80320000` — only a hung (never-returning) read leaves the seed intact.
|
||||
|
||||
**PROPOSED FIX (two parts, FOCAS-driver-only, single managed DLL, no migration/proto change — same low-blast-radius deploy path as the v3 work):**
|
||||
1. **Serialize per-device wire I/O** — an async gate (`SemaphoreSlim`) on `DeviceState` held around each `EnsureConnected + wire op` so the equipment poll, FixedTree loop, probe, and recycle never collide on the one socket. *This is what makes values actually read Good.* MUST be paired with #2 (a lock around an unbounded read would deadlock all I/O).
|
||||
2. **Bound every steady-state wire read/write** with `_options.Timeout` (mirror the probe's linked CTS at `:604-610`, or thread `timeout` through `WireFocasClient.ReadAsync`→`ReadMacroAsync`). Converts any stall into a recoverable `BadCommunicationError` push (overwrites the seed, downgrades health → observable + self-healing) instead of permanent silence.
|
||||
|
||||
**Confidence:** the two structural gaps are *proven from code*; that their interaction is the active trigger is *strongly-suggested* (works single-threaded on dev box, hangs deployed; fits every observation). Definitive live confirmation = the fix makes the tags leave `0x80320000` (Good, or recoverable BadComm if the macro read genuinely fails). **This is a debugging fix headed for a production CNC node — implement + unit-test (serialization + read-timeout) locally, then deploy to wonder and verify, per the diagnose-before-deploy discipline.**
|
||||
|
||||
### Implementation + deploy status (2026-06-26)
|
||||
- **Fix implemented** on branch `fix/focas-poll-io-serialization` (off `feat/focas-pdu-v3`): new `SynchronizedFocasClient` decorator (per-device `SemaphoreSlim` gate + per-call `_options.Timeout`) wired into `FocasDriver.EnsureConnectedAsync`; `ReadAsync`/`WriteAsync` now map a per-call timeout (OCE while caller token live) → `BadCommunicationError` instead of rethrowing. **243 FOCAS tests green** (8 new in `FocasIoSerializationTests.cs` + 1 sibling in `FocasReadWriteTests`), full solution builds 0 errors.
|
||||
- **Live baseline re-confirmed** via OtOpcUa CLI from the Mac: both tags `[80320000]`.
|
||||
- **Deployed** the Release DLL to wonder (single managed-DLL swap; v3 DLL 222208 B → fix DLL 228864 B; backup `E:\ApiInstall\OtOpcUa\_focasbak-pre-iofix-20260626T041913.dll`; service Running, OPC 4840 listening).
|
||||
- **BLOCKER for verification:** after the swap+restart the FOCAS driver is **not connecting at all** (zero TCP to `10.201.31.5:8193` at 150 s uptime — not even a probe connect), so tags still `[80320000]`.
|
||||
- **Deploy API enabled** (user-approved): added `Security__DeployApiKey` (64-char generated, value on-box only) to the `OtOpcUaHost` service `Environment` REG_MULTI_SZ + restart; env backed up to `_envbak-pre-deploykey-20260626T043001.txt`. Headless `POST http://localhost:9000/api/deployments` (key read from registry on-box) returned **HTTP 202 Accepted**, sealed deployment `ef384b04…`.
|
||||
- **Tags STILL `[80320000]`** after the sealed deploy. **Root of the live blocker (from box logs):** the service writes Serilog to `C:\Windows\System32\logs\otopcua-<date>.log` (relative-path-from-service-CWD bug — pending.md D2). Today's log shows the **admin/publish side only**: `Phase7Applier: hierarchy materialised (areas=1,lines=1,equipment=1)` + `equipment tags materialised (tags=2)` at each restart (04:19, 04:30) — which is why the 2 tags are served with the `0x80320000` seed. But across the **entire** day's log there are **ZERO** driver-side lines: `DriverHost`=0, `SubscribeBulk`=0, `subscribed to`=0, `Focas`=0, `GenericDriver`=0 (the 13 `DriverInstance` hits are all SQL `FROM [DriverInstance]`). **⇒ the DriverHostActor / driver-role side is not spawning the FOCAS driver at all on this node today**, so there is no poll, no value push, permanent seed. Health: `/healthz` Healthy, Akka member **Up**, `admin-leader` **Active**; roles env intact (`Cluster__Roles__0=admin`, `__1=driver`, seed=self:4053); `DOTNET_ENVIRONMENT=Production`. The admin half is fully alive; only the driver half is silent.
|
||||
- **This is a SEPARATE blocker from the I/O fix** (which is correct, unit-tested, and deployed — but cannot be exercised until the driver actually polls).
|
||||
|
||||
### Driver-host activation diagnosis (2026-06-26, Debug-logging pass + code reads)
|
||||
- **Debug Serilog enabled** (env `Serilog__MinimumLevel__Default=Debug` + EF/Microsoft/System→Warning; env bak `_envbak-pre-debug-*.txt`). Revealed: **NO exceptions / load errors anywhere** (my fix DLL is NOT the cause), but also **ZERO Akka lines** in Serilog (`Akka`/`Member`/`akka.tcp`/`singleton` = 0).
|
||||
- **`DriverHostActor` logs via `Context.GetLogger()` (Akka `ILoggingAdapter`), and there is NO `Akka.Logger.Serilog` bridge anywhere in the app** (verified across `*.cs`/`*.csproj`/HOCON). So Akka actor logs go to the default console logger → **discarded for a Windows service → invisible in BOTH Production AND Development.** `appsettings.Development.json` only changes Serilog min-levels (+ `Ldap:DevStubMode=true`); it adds no Akka bridge. ⇒ **`DOTNET_ENVIRONMENT=Development` would NOT surface the driver host** (held off — futile + flips DevStubMode on prod).
|
||||
- **The `DriverHostActor` IS instantiated** (unconditionally, same `system.ActorOf` block as the working `OpcUaPublishActor` at `Runtime/ServiceCollectionExtensions.cs:~232`). So it runs — it just isn't producing a connected FOCAS driver, and logs via Akka so the reason is invisible.
|
||||
- **STRONG HYPOTHESIS (explains everything):** on bootstrap the `DriverHostActor` *recovers* its persisted state (its own logs name `entering Steady` / `recovered Applied state at rev {Rev}`), but a process restart has already killed the live `DriverInstanceActor`s + their FOCAS sockets — and **recovery does NOT re-instantiate them**. Drivers re-spawn only on a deployment dispatch whose **revision changes**. This config is now **stable** (`revisionHash 924b59097eba…` is identical across `0c2db588`/`ef384b04`/`a2a84646` — every `POST /api/deployments` returns 202 but with the SAME rev), so repeated deploys are no-ops for the driver host → after any restart there are **no live drivers** and nothing re-spawns them → zero Makino TCP → tags frozen at the `0x80320000` materialization seed. The **prior session got HEALTHY because the config was being *authored*** (each deploy had a NEW rev → forced a spawn); once authoring stopped, a restart leaves the driver side dark. ⇒ likely the real reason "host-restart didn't change it" in the original symptom.
|
||||
- **Implication:** to exercise the I/O fix live, force a driver re-spawn by bumping the revision (e.g. toggle the FOCAS DriverInstance `Enabled` off→on, or any benign config edit, then deploy) — reversible. Separately, "restart should re-spawn applied drivers" + "Akka logs should bridge to Serilog" are real OtOpcUa robustness/observability gaps (the latter = the observability bottleneck this plan flagged).
|
||||
- **Box env mutations still in place (to revert when done):** `Security__DeployApiKey` (deploy API), `Serilog__MinimumLevel__*` (Debug). Backups: `_envbak-pre-deploykey-*`, `_envbak-pre-debug-*`.
|
||||
|
||||
### Akka→Serilog observability attempt (2026-06-26) — code done, prod deploy BLOCKED by self-contained layout
|
||||
- **Code change (correct, builds 0 errors):** added `Akka.Logger.Serilog` 1.5.60 (deps all satisfied by existing pins — Akka 1.5.62 / Serilog 4.3.1) to `Directory.Packages.props` + the Cluster csproj, and `loggers=["Akka.Logger.Serilog.SerilogLogger, …"]` + `loglevel=DEBUG` + `logger-startup-timeout=30s` to the embedded `Resources/akka.conf`. This bridges the DriverHostActor's Akka `ILoggingAdapter` into the Serilog file sink.
|
||||
- **Prod deploy FAILED TWICE (both auto-rolled-back; service is UP + 4840 listening the whole time after each):**
|
||||
1. Cluster.dll + Akka.Logger.Serilog.dll swap → crash `FileNotFoundException: Could not load 'Akka.Logger.Serilog 1.5.60'` from `Serilog.Settings.Configuration.ConfigurationReader.LoadConfigurationAssemblies` (the deployed `OtOpcUa.Host.deps.json` didn't list the new assembly; Serilog's `*.Serilog` config-assembly scan then fails fatally).
|
||||
2. Added the updated `OtOpcUa.Host.deps.json` → crash **"Could not resolve CoreCLR path."** ⇒ the box deployment is **SELF-CONTAINED** (bundles its own runtime), so a `dotnet build` (framework-dependent) deps.json breaks the apphost's runtime resolution.
|
||||
- **Conclusion:** adding a NEW assembly to this box requires a **full self-contained publish-overlay** (match the box layout; preserve `appsettings*`/`data\`), NOT a DLL/deps.json swap. That's a heavy/risky prod op for what is *diagnostic* observability of the (separate, pre-existing) driver-host re-spawn issue.
|
||||
- **Prod state now (verified healthy):** FOCAS **I/O-fix DLL still deployed** (228864 B — untouched by these rollbacks), Cluster.dll + deps.json restored to baseline, `Akka.Logger.Serilog.dll` renamed `.disabled`, service Running + OPC 4840 listening. Deploy-API key + Debug-Serilog env still set.
|
||||
- **RECOMMENDED PIVOT:** the cheap, no-prod-change way to validate the I/O fix is to **force a driver re-spawn via a benign config rev-bump** (the leading hypothesis); and the *proper* driver-host re-spawn fix + the Akka-observability are best developed in a **local docker-dev repro** (safe, full logging, one clean publish-overlay deploy at the end) rather than iterated on the production CNC node. **(User chose: move proper fix to local docker-dev.)**
|
||||
|
||||
### 🎯 ROOT CAUSE of the driver-not-spawning — STALE DEPLOYED BINARY (CONFIRMED 2026-06-26)
|
||||
The DriverHostActor's bootstrap-recovery **already re-spawns drivers in the current source**: `BootstrapRecover` `case Applied:` logs `"recovered Applied state at rev"` and calls `RestoreApplied()` (`DriverHostActor.cs:449-457`), which does `ReconcileDrivers` (re-spawn) + `RebuildAddressSpace` + `PushDesiredSubscriptions` (`:910-918`) — comment: *"the in-memory driver children + OPC UA address space were lost on restart … re-spawn … instead of waiting for a config change (whose identical-config revision would no-op)."* Added by `b1b3f3ff` (2026-06-06) + `397f9b78` (2026-06-07); both ancestors of `feat/focas-pdu-v3`; covered by existing `Restore_on_bootstrap_*` tests.
|
||||
- **The DEPLOYED wonder `Runtime.dll` is the June-16 base install** (`mtime 2026-06-16 13:05:46`; only the FOCAS *driver* DLL was ever swapped). Binary string scan: `RestoreApplied`/`ReconcileDrivers` method names PRESENT, but the bootstrap-restore log strings `"recovered Applied state at rev"` + `"restored served state for applied deployment"` are **ABSENT** ⇒ the deployed binary **predates b1b3f3ff** and does **NOT** restore served state / re-spawn drivers on bootstrap. **That is exactly why, on this stable-config box, a restart leaves the driver side dark** (and why only config-authoring re-deploys ever lit it up).
|
||||
- **⇒ No new driver-host fix is needed — it already exists in source.** The proper remedy = **deploy the current Host** (full self-contained publish-overlay onto `E:\ApiInstall\OtOpcUa`, preserving `appsettings*`/`data\`), which in one shot delivers: (1) `RestoreApplied`-on-bootstrap (drivers re-spawn after restart), (2) the FOCAS I/O serialization fix (symptom #1), (3) the Akka→Serilog bridge (observability). The earlier DLL-swap crashes were purely the self-contained-layout mismatch — a full publish-overlay is internally consistent and avoids them.
|
||||
- **PLAN:** confirm the current Host build boots cleanly + re-spawns drivers on restart in **docker-dev** (safe), then do ONE self-contained publish-overlay to wonder + verify tags leave `0x80320000`.
|
||||
|
||||
### ✅ docker-dev confirmation (2026-06-26) — current build re-spawns on bootstrap + Akka bridge works
|
||||
Built the current source into the docker-dev image (`otopcua-host:dev`), booted `central-1` (fused admin+driver, like wonder). Results:
|
||||
- **Boots cleanly** with `Akka.Logger.Serilog` (the prod DLL-swap crashes were purely the self-contained-layout mismatch, now confirmed).
|
||||
- **Akka→Serilog bridge works** — but needed TWO fixes beyond the package: (1) Akka.Hosting owns logger setup so HOCON `akka.loggers` is ignored → wire via `ConfigureLoggers(setup => { setup.LogLevel=DebugLevel; setup.ClearLoggers(); setup.AddLogger<SerilogLogger>(); })` in `WithOtOpcUaClusterBootstrap`; (2) `AddZbSerilog` registers Serilog as the MEL provider but does NOT set the static `Serilog.Log.Logger` (which `Akka.Logger.Serilog` writes to, AND which the Program.cs startup banner uses) → set `Serilog.Log.Logger = app.Services.GetRequiredService<Serilog.ILogger>()` in Program.cs right after `Build()`. With both, the startup banner + full Akka cluster/DriverHost logs now emit. *(The `Log.Logger`-unset gap is a latent bug in the shared `AddZbSerilog` lib affecting all 3 apps' static-`Log` calls — worth a follow-up there.)*
|
||||
- **`RestoreApplied` re-spawns drivers on bootstrap — CONFIRMED live:** `DriverHost central-1: recovered Applied state at rev …` → `spawned GalaxyMxGateway/Modbus/OpcUaClient driver` (×3) → `SubscribeBulk pushed 5 references across 3 driver(s)` → `restored served state for applied deployment … on bootstrap` → `DriverInstance …: subscribed to N refs`. These are the exact strings ABSENT from the stale wonder binary. ⇒ deploying the current Host fixes the wonder driver-not-spawning blocker.
|
||||
|
||||
### Deploy plan (self-contained publish-overlay → wonder)
|
||||
- Local: `dotnet publish …Host.csproj -c Release -r win-x64 --self-contained true -p:PublishSingleFile=false` (self-contained = brings its own runtime; no version-match concern with the box).
|
||||
- Zip + SFTP to `win64`. Box overlay (self-healing): full app-dir backup → robocopy publish over `E:\ApiInstall\OtOpcUa` **EXCLUDING `pki\` (OPC server cert) + `appsettings*.json`** → start → verify (4840 + tags) → AUTO-ROLLBACK from backup if unhealthy. Env (deploy key, Debug logging) is registry-side, untouched by the file overlay.
|
||||
- Then revert the Debug-Serilog env (now superseded; verbose) and verify `parts-count`/`parts-required` read Good (or recoverable BadComm), and that the FixedTree/driver values flow.
|
||||
|
||||
### ✅✅ SYMPTOM #1 FIXED + LIVE-VALIDATED ON WONDER (2026-06-26)
|
||||
After the self-contained overlay (current Host) + two light single-DLL FOCAS swaps, the validation revealed — and each fix peeled back — a **cascade of latent FOCAS-config-vs-driver mismatches** the stale binary had masked. Final state, all live on wonder:
|
||||
- `DriverHost …: recovered Applied state … → spawned Focas driver focas-z-34184 (stub=false) → DriverInstance focas-z-34184: connected → subscribed to 2 refs` — **two ESTABLISHED TCP sessions to `10.201.31.5:8193`**.
|
||||
- **`read ns=2;s=EQ-3686c0272279/parts-count` → Value 0, Status `0x00000000` (Good)**; `parts-required` → Good. (Value 0 is correct on the idle machine — status, not magnitude.) The original `0x80320000` is gone.
|
||||
|
||||
**The complete fix chain (all on `fix/focas-poll-io-serialization`, deployed):**
|
||||
1. **FOCAS I/O serialization + read timeout** (`SynchronizedFocasClient`) — the original diagnosed root cause: prevents the poll read hanging on the shared socket.
|
||||
2. **`RestoreApplied`-on-bootstrap** — already in source since `b1b3f3ff`; the wonder fix was deploying the current Host over the stale June-16 binary so the driver re-spawns on restart.
|
||||
3. **Akka→Serilog bridge** (`ConfigureLoggers().AddLogger<SerilogLogger>()` + set static `Serilog.Log.Logger` in Program.cs) — made the driver-host actor observable; this is what surfaced the next two issues.
|
||||
4. **`FlexibleStringConverter`** on the FOCAS config `Series` — the AdminUI persists the enum as a number (`"series":6`); the factory now tolerates number-or-string instead of throwing → stub.
|
||||
5. **Scheme-less host tolerance** in `FocasHostAddress.TryParse` — the AdminUI persists `hostAddress` as a bare `ip:port`; `TryParse` now accepts it (canonical `focas://` unchanged) instead of failing init.
|
||||
- FOCAS test suite **247 green**; each fix carries a regression test.
|
||||
- **Follow-up (product quality):** the AdminUI authors FOCAS configs (`series` as number, `hostAddress` without `focas://`) that the driver only now tolerates — the AdminUI↔driver config-format mismatch is worth reconciling at the source. Also: the shared `AddZbSerilog` not setting static `Serilog.Log.Logger` is a latent gap across all 3 apps. And the FixedTree-under-Equipment feature (task #14) is still pending.
|
||||
|
||||
## Phase 2 — Get OtOpcUa runtime logs on wonder
|
||||
Make the Host emit driver-level logs so the data plane is observable. Options (least invasive first): point the service at a Serilog file sink via config/env, or temporarily run with `DOTNET_ENVIRONMENT=Development` (file sink + dev errors — cf. MxGateway note), or add a console capture. Preserve `appsettings*`/`data\`; restore the env after. Then read: did `InitializeAsync` start the FixedTree loop, does the bootstrap throw (and on which call), is `ReadAsync` invoked for the equipment tags, what does it return.
|
||||
- **Access:** servecli `:2222`, key `~/.ssh/servecli_wonder`, `scratchpad/wonder-ps.sh` (base64 PS over the cmd PTY), SFTP root `C:\Users\dohertj2\Desktop\win64`.
|
||||
- **Output:** the actual runtime behavior of the data poll + equipment read on the box.
|
||||
|
||||
## Phase 3 — Local reproduction in docker-dev (isolate FOCAS-specific vs general)
|
||||
Reproduce off the production box: in the docker-dev OtOpcUa, configure an equipment tag bound to a driver and check whether values flow at all. Use a driver with an easy local source (e.g. Modbus against a local sim, or the FOCAS wire client against a v1 responder if one can be stood up). If equipment-tag values flow for another driver locally but not FOCAS → FOCAS-specific; if they don't flow for any → a general equipment-projection/data-plane gap. A local repro gives a full-logging debug loop.
|
||||
- **Output:** scope (FOCAS-only vs general) + a local failing case to fix against.
|
||||
|
||||
## Phase 4 — Root cause + minimal fix
|
||||
From Phases 1–3, fix the smallest thing that makes the equipment tag carry a value and (if H2 is not by-design) the FixedTree surface. Likely shapes: a swallowed bootstrap exception; a seed/poll-group wiring gap; an Equipment-projection that should include FixedTree/driver auto-nodes; or a data-plane gate that needs config on the wonder node.
|
||||
|
||||
## Phase 5 — Validate
|
||||
- Local (docker-dev) green where reproduced; unit/integration tests for the fixed path.
|
||||
- Live: re-deploy to wonder, then via the OtOpcUa CLI confirm `parts-count`/`parts-required` read **Good** (value 0 is correct on this idle machine — assert status, not magnitude) and, if applicable, FixedTree Identity/Axes nodes appear with live values. The live Makino helps for FixedTree but is NOT blocking for the value-flow plumbing (macro values are 0 regardless), so this is lower time-pressure than the v3 capture was.
|
||||
|
||||
## Phase 6 — Docs + commit
|
||||
Update this plan's status + the deployment doc; commit on a branch (separate from `feat/focas-pdu-v3` if the fix is OtOpcUa-core rather than FOCAS-driver). Push per the repo flow when asked.
|
||||
|
||||
---
|
||||
|
||||
## Notes
|
||||
- **Execution approach:** this is a *debugging* investigation (unknown root cause) — diagnose before fixing; reproduce before claiming a fix; change the smallest thing. Don't deploy a guessed fix to the production CNC node.
|
||||
- **Key node IDs / endpoints:** equipment `ns=2;s=EQ-3686c0272279`; tags `…/parts-count` (`MACRO:3901`), `…/parts-required` (`MACRO:3902`); OPC `opc.tcp://wonder-app-vd03.zmr.zimmer.com:4840/OtOpcUa`; AdminUI `http://wonder-app-vd03.zmr.zimmer.com:9000` (DisableLogin); deploy = AdminUI `/deployments` → "Deploy current configuration" (Blazo→Akka; no headless API).
|
||||
- **Reusable tools:** `scratchpad/focas-status/` (live IFocasClient harness), `scratchpad/wonder-ps.sh`, `scratchpad/deploy-focas-v3.ps1`; the OtOpcUa CLI `src/Client/ZB.MOM.WW.OtOpcUa.Client.CLI` (`read`/`subscribe`/`browse --recursive`).
|
||||
@@ -143,3 +143,25 @@ The expensive half is Tier-C process isolation so that a crashing
|
||||
`Fwlib64.dll` doesn't take the main OPC UA server down with it. See
|
||||
[`docs/v2/implementation/focas-isolation-plan.md`](implementation/focas-isolation-plan.md)
|
||||
for that plan (task #220).
|
||||
|
||||
## Real-hardware validation (first live FOCAS contact)
|
||||
|
||||
**2026-06-25 — FANUC 31i-B (Makino Pro 5), `10.201.31.5:8193`.** The first time the managed wire
|
||||
client met real FOCAS hardware. The control answers **PDU version 3**; the 10-byte header framing and
|
||||
response block envelope are byte-identical to v1 (only the version field differs). `cnc_sysinfo`
|
||||
reports CncType 31, Series `G431`, 7 axes (X Y Z B C A A), MtType MM.
|
||||
|
||||
| Command family | v3 result | Notes |
|
||||
| --- | --- | --- |
|
||||
| `cnc_sysinfo`, `cnc_rdaxisname`, `cnc_rdspdlname` | ✅ validated | identity / axis / spindle names |
|
||||
| `cnc_rddynamic2` (positions, feed, spindle, program/seq) | ✅ validated | 1-based axis iteration; program O-number reads via big-endian |
|
||||
| `cnc_rdopmode`, `cnc_exeprgname2` | ✅ validated | mode + executing program |
|
||||
| `cnc_rdmacro` | ✅ validated | deployed `MACRO:3901/3902` tags read Good |
|
||||
| `cnc_rdtimer` | ✅ validated | 8-byte {minute, msec} payload is **little-endian** (unlike the big-endian envelope) |
|
||||
| `pmc_rdpmcrng` | ✅ validated | request range must be widened to the data-type byte width (`end = start + width - 1`) |
|
||||
| `cnc_rdsvmeter` | ✅ validated | per-axis LOADELM is **8 bytes**, not 12; names come from the 0x0089 block; load *scaling* unconfirmed |
|
||||
| `cnc_rdalmmsg2` | ✅ validated | read a live active alarm (`#3080`) |
|
||||
| `cnc_rdparam` | ❌ **unsupported** | `EW_FUNC` across 14 request-framing variants × 4 known-present params — likely wrong v3 command id (`0x000e`) or restricted on this control; needs a reference FWLIB trace |
|
||||
|
||||
Full finding + captured wire bytes + the fix per surface:
|
||||
[`../plans/2026-06-25-focas-pdu-v3-30i-b-support.md`](../plans/2026-06-25-focas-pdu-v3-30i-b-support.md).
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Passive FOCAS/2 wire capture for protocol-version scoping.
|
||||
|
||||
Sends ONLY the initiate PDU the OtOpcUa WireFocasClient sends (a read-only handshake;
|
||||
NO data/write PDUs) and dumps the raw response so the real wire framing — notably the
|
||||
PDU version field — can be inspected. Used to diagnose the 30i-B PDU-v3 gap; see
|
||||
docs/plans/2026-06-25-focas-pdu-v3-30i-b-support.md.
|
||||
|
||||
Usage: python3 scripts/focas/capture-initiate.py <host> [port]
|
||||
"""
|
||||
import socket, sys
|
||||
|
||||
MAGIC = bytes([0xA0, 0xA0, 0xA0, 0xA0])
|
||||
|
||||
|
||||
def build_pdu(version, type_, direction, body):
|
||||
h = bytearray(MAGIC)
|
||||
h += version.to_bytes(2, "big")
|
||||
h += bytes([type_, direction])
|
||||
h += len(body).to_bytes(2, "big")
|
||||
return bytes(h) + body
|
||||
|
||||
|
||||
def hexdump(b):
|
||||
return " ".join(f"{x:02x}" for x in b)
|
||||
|
||||
|
||||
def parse_header(b):
|
||||
if len(b) < 10:
|
||||
return f"(short read, {len(b)} bytes)"
|
||||
return (f"magic={hexdump(b[0:4])} version={int.from_bytes(b[4:6],'big')} "
|
||||
f"type=0x{b[6]:02x} dir=0x{b[7]:02x} bodyLen={int.from_bytes(b[8:10],'big')}")
|
||||
|
||||
|
||||
def try_initiate(host, port, version, socket_index):
|
||||
print(f"\n=== initiate header version={version}, socketIndex={socket_index} ===")
|
||||
pdu = build_pdu(version, 0x01, 0x01, socket_index.to_bytes(2, "big"))
|
||||
print(f" -> {len(pdu)} bytes: {hexdump(pdu)}")
|
||||
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
s.settimeout(6)
|
||||
try:
|
||||
s.connect((host, port))
|
||||
s.sendall(pdu)
|
||||
resp = b""
|
||||
try:
|
||||
while len(resp) < 512:
|
||||
chunk = s.recv(512 - len(resp))
|
||||
if not chunk:
|
||||
break
|
||||
resp += chunk
|
||||
if len(resp) >= 10 and len(resp) >= 10 + int.from_bytes(resp[8:10], "big"):
|
||||
break
|
||||
except socket.timeout:
|
||||
pass
|
||||
print(f" <- {len(resp)} bytes: {hexdump(resp)}")
|
||||
print(f" <- header: {parse_header(resp)}")
|
||||
if len(resp) > 10:
|
||||
print(f" <- body: {hexdump(resp[10:])}")
|
||||
except Exception as e:
|
||||
print(f" !! {type(e).__name__}: {e}")
|
||||
finally:
|
||||
s.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
if len(sys.argv) < 2:
|
||||
print(__doc__)
|
||||
sys.exit(2)
|
||||
host = sys.argv[1]
|
||||
port = int(sys.argv[2]) if len(sys.argv) > 2 else 8193
|
||||
try_initiate(host, port, 1, 1) # what OtOpcUa sends today (header version=1)
|
||||
try_initiate(host, port, 3, 1) # probe whether the CNC negotiates on our advertised version
|
||||
@@ -0,0 +1,293 @@
|
||||
#!/usr/bin/env python3
|
||||
"""FOCAS/2 v3 data-PDU capture for offline parser work.
|
||||
|
||||
Replays exactly the connect sequence ``FocasWireClient.ConnectCoreAsync`` performs
|
||||
(two-socket initiate, then the ``cnc_sysinfo`` 0x0018 + ``0x000e``/0x26f0 setup
|
||||
requests on socket 2), then issues each target data request on socket 2 and dumps the
|
||||
RAW v3 response PDU bytes. Read-only: NOT a single write/operate PDU is sent.
|
||||
|
||||
Every socket read is timeout-bounded, so a command that "hangs" the C# client
|
||||
(``cnc_rdsvmeter`` on v3) shows up here as a recorded partial/timeout rather than an
|
||||
infinite block. Captured ``.bin`` files become offline unit-test fixtures.
|
||||
|
||||
The request framing here mirrors ``Wire/FocasWireProtocol.cs`` /
|
||||
``Wire/FocasWireClient.cs`` byte-for-byte:
|
||||
PDU = a0 a0 a0 a0 | ver(u16) | type | dir | bodyLen(u16) | body
|
||||
data body = blockCount(u16) | block...
|
||||
block = blockLen(u16) | reqClass(u16) | pathId(u16) | cmd(u16)
|
||||
| arg1(i32) | arg2(i32) | arg3(i32) | arg4(i32) | arg5(u16) | extraLen(u16) | extra
|
||||
We EMIT version=1 (the CNC accepts v1 requests) and ACCEPT whatever version it answers.
|
||||
|
||||
Usage: python3 scripts/focas/capture-v3.py <host> [port] [--out DIR]
|
||||
See: docs/plans/2026-06-25-focas-pdu-v3-30i-b-support.md
|
||||
docs/plans/2026-06-25-focas-pdu-v3-implementation-plan.md (Phase 1)
|
||||
"""
|
||||
import os
|
||||
import socket
|
||||
import sys
|
||||
|
||||
MAGIC = bytes([0xA0, 0xA0, 0xA0, 0xA0])
|
||||
EMIT_VERSION = 1 # what the WireFocasClient puts in request headers
|
||||
TYPE_INITIATE, TYPE_DATA = 0x01, 0x21
|
||||
DIR_REQUEST = 0x01
|
||||
READ_TIMEOUT = 6.0 # seconds; a hang becomes a recorded timeout
|
||||
|
||||
|
||||
def hexdump(b):
|
||||
return " ".join(f"{x:02x}" for x in b)
|
||||
|
||||
|
||||
def build_pdu(version, type_, direction, body):
|
||||
h = bytearray(MAGIC)
|
||||
h += version.to_bytes(2, "big")
|
||||
h += bytes([type_, direction])
|
||||
h += len(body).to_bytes(2, "big")
|
||||
return bytes(h) + body
|
||||
|
||||
|
||||
def build_block(cmd, a1=0, a2=0, a3=0, a4=0, a5=0, req_class=1, path_id=1, extra=b""):
|
||||
blk = bytearray()
|
||||
blk += (0x1C + len(extra)).to_bytes(2, "big")
|
||||
blk += req_class.to_bytes(2, "big")
|
||||
blk += path_id.to_bytes(2, "big")
|
||||
blk += cmd.to_bytes(2, "big")
|
||||
for a in (a1, a2, a3, a4):
|
||||
blk += int(a).to_bytes(4, "big", signed=True)
|
||||
blk += (a5 & 0xFFFF).to_bytes(2, "big")
|
||||
blk += len(extra).to_bytes(2, "big")
|
||||
blk += extra
|
||||
return bytes(blk)
|
||||
|
||||
|
||||
def build_data_pdu(blocks):
|
||||
body = bytearray(len(blocks).to_bytes(2, "big"))
|
||||
for b in blocks:
|
||||
body += b
|
||||
return build_pdu(EMIT_VERSION, TYPE_DATA, DIR_REQUEST, bytes(body))
|
||||
|
||||
|
||||
def recv_exactly(sock, n):
|
||||
"""Read exactly n bytes; returns (data, complete). Stops early on timeout/close."""
|
||||
buf = b""
|
||||
while len(buf) < n:
|
||||
try:
|
||||
chunk = sock.recv(n - len(buf))
|
||||
except socket.timeout:
|
||||
return buf, False
|
||||
if not chunk:
|
||||
return buf, False
|
||||
buf += chunk
|
||||
return buf, True
|
||||
|
||||
|
||||
def read_pdu(sock):
|
||||
"""Read one full response PDU. Returns dict with header fields + raw bytes."""
|
||||
header, ok = recv_exactly(sock, 10)
|
||||
if not ok or len(header) < 10:
|
||||
return {"ok": False, "raw": header, "note": f"short/timeout header ({len(header)} bytes)"}
|
||||
version = int.from_bytes(header[4:6], "big")
|
||||
body_len = int.from_bytes(header[8:10], "big")
|
||||
body, body_ok = recv_exactly(sock, body_len)
|
||||
return {
|
||||
"ok": body_ok,
|
||||
"version": version,
|
||||
"type": header[6],
|
||||
"dir": header[7],
|
||||
"body_len": body_len,
|
||||
"body": body,
|
||||
"raw": header + body,
|
||||
"note": "" if body_ok else f"body short/timeout: got {len(body)}/{body_len}",
|
||||
}
|
||||
|
||||
|
||||
def loose_parse_blocks(body):
|
||||
"""Best-effort v1-layout block walk so we can eyeball cmd/rc/payloadLen on v3.
|
||||
|
||||
Returns a list of human-readable lines; never raises (v3 structural drift just
|
||||
yields a 'parse aborted' note while the raw bytes remain authoritative).
|
||||
"""
|
||||
out = []
|
||||
try:
|
||||
if len(body) < 2:
|
||||
return ["(body < 2 bytes; no block count)"]
|
||||
count = int.from_bytes(body[0:2], "big")
|
||||
out.append(f"blockCount={count}")
|
||||
off = 2
|
||||
for i in range(count):
|
||||
if off + 16 > len(body):
|
||||
out.append(f" block[{i}] truncated at off={off}")
|
||||
break
|
||||
blk_len = int.from_bytes(body[off:off + 2], "big")
|
||||
cmd = int.from_bytes(body[off + 6:off + 8], "big")
|
||||
rc = int.from_bytes(body[off + 8:off + 10], "big", signed=True)
|
||||
payload_len = int.from_bytes(body[off + 14:off + 16], "big")
|
||||
payload = body[off + 16:off + 16 + payload_len]
|
||||
out.append(
|
||||
f" block[{i}] blkLen={blk_len} cmd=0x{cmd:04x} rc={rc} "
|
||||
f"payloadLen={payload_len} payload={hexdump(payload) or '(empty)'}")
|
||||
if blk_len < 0x10:
|
||||
out.append(" !! blkLen < 0x10 — aborting walk (v3 layout differs)")
|
||||
break
|
||||
off += blk_len
|
||||
except Exception as e: # noqa: BLE001 - capture tool, never crash on parse
|
||||
out.append(f" !! loose parse aborted: {type(e).__name__}: {e}")
|
||||
return out
|
||||
|
||||
|
||||
class Session:
|
||||
def __init__(self, host, port, log):
|
||||
self.host, self.port, self.log = host, port, log
|
||||
self.s1 = self.s2 = None
|
||||
|
||||
def _connect_socket(self):
|
||||
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
s.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)
|
||||
s.settimeout(READ_TIMEOUT)
|
||||
s.connect((self.host, self.port))
|
||||
return s
|
||||
|
||||
def connect(self):
|
||||
self.log("=== two-socket initiate handshake ===")
|
||||
self.s1 = self._connect_socket()
|
||||
self.s1.sendall(build_pdu(EMIT_VERSION, TYPE_INITIATE, DIR_REQUEST, (1).to_bytes(2, "big")))
|
||||
r1 = read_pdu(self.s1)
|
||||
self.log(f" socket1 initiate <- v={r1.get('version')} bodyLen={r1.get('body_len')} ok={r1['ok']}")
|
||||
|
||||
self.s2 = self._connect_socket()
|
||||
self.s2.sendall(build_pdu(EMIT_VERSION, TYPE_INITIATE, DIR_REQUEST, (2).to_bytes(2, "big")))
|
||||
r2 = read_pdu(self.s2)
|
||||
self.log(f" socket2 initiate <- v={r2.get('version')} bodyLen={r2.get('body_len')} ok={r2['ok']}")
|
||||
|
||||
# mirror ConnectCoreAsync: sysinfo (cached) then the 0x000e/0x26f0 setup request
|
||||
self.request("setup-sysinfo-0x0018", [build_block(0x0018, path_id=1)], save=False)
|
||||
self.request("setup-0x000e-26f0", [build_block(0x000E, 0x26F0, 0x26F0, path_id=1)], save=False)
|
||||
return r1, r2
|
||||
|
||||
def request(self, label, blocks, save=True):
|
||||
pdu = build_data_pdu(blocks)
|
||||
self.log(f"\n--- {label} ---")
|
||||
self.log(f" -> {len(pdu)}B req: {hexdump(pdu)}")
|
||||
try:
|
||||
self.s2.sendall(pdu)
|
||||
resp = read_pdu(self.s2)
|
||||
except Exception as e: # noqa: BLE001
|
||||
self.log(f" !! send/read failed: {type(e).__name__}: {e}")
|
||||
return None
|
||||
self.log(f" <- header: version={resp.get('version')} type=0x{resp.get('type', 0):02x} "
|
||||
f"dir=0x{resp.get('dir', 0):02x} bodyLen={resp.get('body_len')} complete={resp['ok']}")
|
||||
if resp.get("note"):
|
||||
self.log(f" <- NOTE: {resp['note']}")
|
||||
self.log(f" <- raw ({len(resp['raw'])}B): {hexdump(resp['raw'])}")
|
||||
if resp.get("body"):
|
||||
for line in loose_parse_blocks(resp["body"]):
|
||||
self.log(" " + line)
|
||||
if save and OUT_DIR and resp.get("raw"):
|
||||
path = os.path.join(OUT_DIR, f"{label}.bin")
|
||||
with open(path, "wb") as f:
|
||||
f.write(resp["raw"])
|
||||
return resp
|
||||
|
||||
def close(self):
|
||||
for s in (self.s2, self.s1):
|
||||
try:
|
||||
if s:
|
||||
s.close()
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
|
||||
|
||||
# (label, [blocks]) — references first (known-good on v3), then the failing targets.
|
||||
def target_specs():
|
||||
return [
|
||||
# ---- known-good references (calibrate the v3 block envelope) ----
|
||||
("ref-sysinfo-0x0018", [build_block(0x0018, path_id=1)]),
|
||||
("ref-axisname-0x0089", [build_block(0x0089, path_id=1)]),
|
||||
("ref-spdlname-0x008a", [build_block(0x008A, path_id=1)]),
|
||||
("ref-macro-3901-0x0015", [build_block(0x0015, 3901, 3901, path_id=1)]),
|
||||
("ref-macro-500-0x0015", [build_block(0x0015, 500, 500, path_id=1)]),
|
||||
("ref-opmode-0x0057", [build_block(0x0057, path_id=1)]),
|
||||
("ref-exeprgname-0x00fc", [build_block(0x00FC, path_id=1)]),
|
||||
("ref-statinfo", [
|
||||
build_block(0x0019, path_id=1),
|
||||
build_block(0x00E1, path_id=1),
|
||||
build_block(0x0098, path_id=1),
|
||||
]),
|
||||
# cnc_rddynamic2 axis 1 — the full 9-block bundle the client sends (known-good)
|
||||
("ref-dynamic2-axis1", [
|
||||
build_block(0x001A, path_id=1),
|
||||
build_block(0x001C, path_id=1),
|
||||
build_block(0x001D, path_id=1),
|
||||
build_block(0x0024, path_id=1),
|
||||
build_block(0x0025, path_id=1),
|
||||
build_block(0x0026, 4, 1, path_id=1),
|
||||
build_block(0x0026, 1, 1, path_id=1),
|
||||
build_block(0x0026, 6, 1, path_id=1),
|
||||
build_block(0x0026, 7, 1, path_id=1),
|
||||
]),
|
||||
# ---- failing / target commands ----
|
||||
("timer-poweron-0x0120-t0", [build_block(0x0120, 0, path_id=1)]),
|
||||
("timer-operating-0x0120-t1", [build_block(0x0120, 1, path_id=1)]),
|
||||
("timer-cutting-0x0120-t2", [build_block(0x0120, 2, path_id=1)]),
|
||||
("timer-cycle-0x0120-t3", [build_block(0x0120, 3, path_id=1)]),
|
||||
# cnc_rdsvmeter — the C# client sends 0x0056(arg1=1) + 0x0089 together (this HANGS).
|
||||
("svmeter-pair-0x0056-0x0089", [
|
||||
build_block(0x0056, 1, path_id=1),
|
||||
build_block(0x0089, path_id=1),
|
||||
]),
|
||||
# and 0x0056 alone, to isolate which block stalls
|
||||
("svmeter-alone-0x0056", [build_block(0x0056, 1, path_id=1)]),
|
||||
# pmc_rdpmcrng R100 (area R=5, dataType Word=1, reqClass=2) — BadOutOfRange
|
||||
("pmcrng-R100-0x8001", [build_block(0x8001, 100, 100, 5, 1, req_class=2, path_id=1)]),
|
||||
("pmcrng-R0-0x8001", [build_block(0x8001, 0, 0, 5, 1, req_class=2, path_id=1)]),
|
||||
# cnc_rdparam 1320 (axis 0 -> arg2=dataNumber) — BadNotSupported
|
||||
("param-1320-0x000e", [build_block(0x000E, 1320, 1320, path_id=1)]),
|
||||
("param-100-0x000e", [build_block(0x000E, 100, 100, path_id=1)]),
|
||||
# cnc_rdalmmsg2 (type=-1 all, count=32, arg3=2, arg4=0x40) — untested
|
||||
("alarms-0x0023", [build_block(0x0023, -1, 32, 2, 0x40, path_id=1)]),
|
||||
]
|
||||
|
||||
|
||||
OUT_DIR = None
|
||||
|
||||
|
||||
def main():
|
||||
global OUT_DIR
|
||||
argv = [a for a in sys.argv[1:] if a]
|
||||
if not argv:
|
||||
print(__doc__)
|
||||
sys.exit(2)
|
||||
host = argv[0]
|
||||
port = 8193
|
||||
rest = argv[1:]
|
||||
while rest:
|
||||
a = rest.pop(0)
|
||||
if a == "--out":
|
||||
OUT_DIR = rest.pop(0)
|
||||
else:
|
||||
port = int(a)
|
||||
if OUT_DIR:
|
||||
os.makedirs(OUT_DIR, exist_ok=True)
|
||||
|
||||
lines = []
|
||||
|
||||
def log(msg):
|
||||
print(msg)
|
||||
lines.append(msg)
|
||||
|
||||
log(f"# FOCAS v3 capture {host}:{port} (emit version={EMIT_VERSION})")
|
||||
sess = Session(host, port, log)
|
||||
try:
|
||||
sess.connect()
|
||||
for label, blocks in target_specs():
|
||||
sess.request(label, blocks)
|
||||
finally:
|
||||
sess.close()
|
||||
|
||||
if OUT_DIR:
|
||||
with open(os.path.join(OUT_DIR, "capture-log.txt"), "w") as f:
|
||||
f.write("\n".join(lines) + "\n")
|
||||
log(f"\n# wrote .bin fixtures + capture-log.txt to {OUT_DIR}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,171 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Exploratory cnc_rdparam (0x000e) v3 request-framing probe against a live FANUC.
|
||||
|
||||
The committed WireFocasClient sends arg1=number, arg2=number(when axis 0), arg3=0 and
|
||||
gets EW_FUNC(1) for a VALID parameter on the 31i-B. cnc_rdparam(h, number, axis, length,
|
||||
&IODBPSD) needs axis + length, so this tries a matrix of (arg ordering, axis, length,
|
||||
reqClass, extra) and prints which combos return rc=0 with a non-empty payload.
|
||||
|
||||
Read-only. Usage: python3 param-probe.py <host> [port]
|
||||
"""
|
||||
import socket
|
||||
import sys
|
||||
|
||||
MAGIC = bytes([0xA0, 0xA0, 0xA0, 0xA0])
|
||||
EMIT_VERSION = 1
|
||||
|
||||
|
||||
def build_pdu(version, type_, direction, body):
|
||||
h = bytearray(MAGIC) + version.to_bytes(2, "big") + bytes([type_, direction]) + len(body).to_bytes(2, "big")
|
||||
return bytes(h) + body
|
||||
|
||||
|
||||
def build_block(cmd, a1=0, a2=0, a3=0, a4=0, a5=0, req_class=1, path_id=1, extra=b""):
|
||||
blk = bytearray()
|
||||
blk += (0x1C + len(extra)).to_bytes(2, "big")
|
||||
blk += req_class.to_bytes(2, "big")
|
||||
blk += path_id.to_bytes(2, "big")
|
||||
blk += cmd.to_bytes(2, "big")
|
||||
for a in (a1, a2, a3, a4):
|
||||
blk += int(a).to_bytes(4, "big", signed=True)
|
||||
blk += (a5 & 0xFFFF).to_bytes(2, "big")
|
||||
blk += len(extra).to_bytes(2, "big")
|
||||
blk += extra
|
||||
return bytes(blk)
|
||||
|
||||
|
||||
def data_pdu(blocks):
|
||||
body = bytearray(len(blocks).to_bytes(2, "big"))
|
||||
for b in blocks:
|
||||
body += b
|
||||
return build_pdu(EMIT_VERSION, 0x21, 0x01, bytes(body))
|
||||
|
||||
|
||||
def recv_exactly(sock, n):
|
||||
buf = b""
|
||||
while len(buf) < n:
|
||||
try:
|
||||
chunk = sock.recv(n - len(buf))
|
||||
except socket.timeout:
|
||||
return buf, False
|
||||
if not chunk:
|
||||
return buf, False
|
||||
buf += chunk
|
||||
return buf, True
|
||||
|
||||
|
||||
def read_pdu(sock):
|
||||
header, ok = recv_exactly(sock, 10)
|
||||
if not ok or len(header) < 10:
|
||||
return None
|
||||
body_len = int.from_bytes(header[8:10], "big")
|
||||
body, _ = recv_exactly(sock, body_len)
|
||||
return body
|
||||
|
||||
|
||||
def parse_first_block(body):
|
||||
"""Return (cmd, rc, payload_hex) of the first response block, or None."""
|
||||
if not body or len(body) < 2:
|
||||
return None
|
||||
count = int.from_bytes(body[0:2], "big")
|
||||
if count == 0 or len(body) < 18:
|
||||
return (None, None, "")
|
||||
blk_len = int.from_bytes(body[2:4], "big")
|
||||
cmd = int.from_bytes(body[8:10], "big")
|
||||
rc = int.from_bytes(body[10:12], "big", signed=True)
|
||||
payload_len = int.from_bytes(body[16:18], "big")
|
||||
payload = body[18:18 + payload_len]
|
||||
return (cmd, rc, " ".join(f"{x:02x}" for x in payload) or "(empty)")
|
||||
|
||||
|
||||
class Sess:
|
||||
def __init__(self, host, port):
|
||||
self.host, self.port = host, port
|
||||
self.s1 = self.s2 = None
|
||||
|
||||
def _sock(self):
|
||||
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
s.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)
|
||||
s.settimeout(6)
|
||||
s.connect((self.host, self.port))
|
||||
return s
|
||||
|
||||
def connect(self):
|
||||
self.s1 = self._sock()
|
||||
self.s1.sendall(build_pdu(EMIT_VERSION, 0x01, 0x01, (1).to_bytes(2, "big")))
|
||||
read_pdu(self.s1)
|
||||
self.s2 = self._sock()
|
||||
self.s2.sendall(build_pdu(EMIT_VERSION, 0x01, 0x01, (2).to_bytes(2, "big")))
|
||||
read_pdu(self.s2)
|
||||
# mirror the client's setup requests
|
||||
self.req([build_block(0x0018, path_id=1)])
|
||||
self.req([build_block(0x000E, 0x26F0, 0x26F0, path_id=1)])
|
||||
|
||||
def req(self, blocks):
|
||||
self.s2.sendall(data_pdu(blocks))
|
||||
return read_pdu(self.s2)
|
||||
|
||||
def close(self):
|
||||
for s in (self.s2, self.s1):
|
||||
try:
|
||||
s and s.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def main():
|
||||
host = sys.argv[1] if len(sys.argv) > 1 else "10.201.31.5"
|
||||
port = int(sys.argv[2]) if len(sys.argv) > 2 else 8193
|
||||
# 8130 = total controlled axes (global, always present); 1320 = +stroke limit (axis);
|
||||
# 1825 = servo loop gain (axis); 3201 = setting. All exist on a 31i.
|
||||
params = [8130, 1320, 1825, 3201]
|
||||
# (label, builder(P)) — each builder returns a single request block for param P
|
||||
variants = [
|
||||
("cur arg1=P,arg2=P,a3=0,rc1", lambda P: build_block(0x000E, P, P, 0, 0, req_class=1)),
|
||||
("arg2=0(axis),a3=0,rc1", lambda P: build_block(0x000E, P, 0, 0, 0, req_class=1)),
|
||||
("arg2=0,a3=8(len),rc1", lambda P: build_block(0x000E, P, 0, 8, 0, req_class=1)),
|
||||
("arg2=0,a3=36(len),rc1", lambda P: build_block(0x000E, P, 0, 36, 0, req_class=1)),
|
||||
("arg2=0,a3=4(len),rc1", lambda P: build_block(0x000E, P, 0, 4, 0, req_class=1)),
|
||||
("arg2=-1(allaxis),a3=8,rc1", lambda P: build_block(0x000E, P, -1, 8, 0, req_class=1)),
|
||||
("arg2=1(axis1),a3=8,rc1", lambda P: build_block(0x000E, P, 1, 8, 0, req_class=1)),
|
||||
("arg2=0,a3=8,rc2", lambda P: build_block(0x000E, P, 0, 8, 0, req_class=2)),
|
||||
("arg2=0,a3=36,rc2", lambda P: build_block(0x000E, P, 0, 36, 0, req_class=2)),
|
||||
("len-first arg1=8,arg2=P,rc1", lambda P: build_block(0x000E, 8, P, 0, 0, req_class=1)),
|
||||
("len-first arg1=36,arg2=P,rc1", lambda P: build_block(0x000E, 36, P, 0, 0, req_class=1)),
|
||||
("arg2=0,a3=0,a4=8,rc1", lambda P: build_block(0x000E, P, 0, 0, 8, req_class=1)),
|
||||
("arg2=0,a5=8,rc1", lambda P: build_block(0x000E, P, 0, 0, 0, a5=8, req_class=1)),
|
||||
("extra: datano+type+len(8)", lambda P: build_block(0x000E, P, 0, 8, 0, req_class=1,
|
||||
extra=P.to_bytes(2, "big") + b"\x00\x00" + (8).to_bytes(2, "big"))),
|
||||
]
|
||||
|
||||
sess = Sess(host, port)
|
||||
sess.connect()
|
||||
print(f"# cnc_rdparam v3 probe {host}:{port}\n")
|
||||
hits = []
|
||||
try:
|
||||
for P in params:
|
||||
print(f"--- param {P} ---")
|
||||
for label, build in variants:
|
||||
body = sess.req([build(P)])
|
||||
parsed = parse_first_block(body)
|
||||
if parsed is None:
|
||||
print(f" {label:32s} -> (no/short response)")
|
||||
continue
|
||||
cmd, rc, payload = parsed
|
||||
mark = " <== HIT" if rc == 0 and payload not in ("", "(empty)") else ""
|
||||
print(f" {label:32s} -> rc={rc} payload={payload}{mark}")
|
||||
if mark:
|
||||
hits.append((P, label, payload))
|
||||
print()
|
||||
finally:
|
||||
sess.close()
|
||||
|
||||
print("# HITS (rc=0, non-empty payload):")
|
||||
for P, label, payload in hits:
|
||||
print(f" param {P}: {label} -> {payload}")
|
||||
if not hits:
|
||||
print(" (none — cnc_rdparam may be genuinely restricted on this control)")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -7,6 +7,12 @@
|
||||
# Any divergence from these defaults must be deliberate and recorded in docs/v2/Architecture.md.
|
||||
|
||||
akka {
|
||||
# Akka logger wiring (route ILoggingAdapter → Serilog) is configured via Akka.Hosting's
|
||||
# ConfigureLoggers in ServiceCollectionExtensions.WithOtOpcUaClusterBootstrap — HOCON
|
||||
# `akka.loggers` alone is not honored by Akka.Hosting. logger-startup-timeout is kept here
|
||||
# since the Serilog logger can be slow to initialize at startup.
|
||||
logger-startup-timeout = 30s
|
||||
|
||||
extensions = [
|
||||
"Akka.Cluster.Tools.PublishSubscribe.DistributedPubSubExtensionProvider, Akka.Cluster.Tools"
|
||||
]
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
using Akka.Cluster.Hosting;
|
||||
using Akka.Event;
|
||||
using Akka.Hosting;
|
||||
using Akka.Logger.Serilog;
|
||||
using Akka.Remote.Hosting;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
@@ -53,6 +55,19 @@ public static class ServiceCollectionExtensions
|
||||
|
||||
builder.AddHocon(HoconLoader.LoadBaseConfig(), HoconAddMode.Append);
|
||||
|
||||
// Route Akka's internal ILoggingAdapter (DriverHostActor, DriverInstanceActor, cluster
|
||||
// events, …) into Serilog so those logs reach the same sinks as the MEL/Serilog application
|
||||
// logs. Akka.Hosting owns logger setup, so HOCON `akka.loggers` alone is not honored — the
|
||||
// logger must be registered through ConfigureLoggers. Without this the actor graph logs only
|
||||
// to the default StandardOutLogger (discarded under the Windows service host), which is why
|
||||
// the driver-role actors were invisible during the 2026-06 data-plane investigation.
|
||||
builder.ConfigureLoggers(setup =>
|
||||
{
|
||||
setup.LogLevel = LogLevel.DebugLevel;
|
||||
setup.ClearLoggers();
|
||||
setup.AddLogger<SerilogLogger>();
|
||||
});
|
||||
|
||||
builder.WithRemoting(new RemoteOptions
|
||||
{
|
||||
HostName = options.Hostname,
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
<PackageReference Include="Akka.Cluster"/>
|
||||
<PackageReference Include="Akka.Cluster.Hosting"/>
|
||||
<PackageReference Include="Akka.Cluster.Tools"/>
|
||||
<PackageReference Include="Akka.Logger.Serilog"/>
|
||||
<PackageReference Include="Akka.Remote.Hosting"/>
|
||||
<PackageReference Include="Microsoft.Extensions.Hosting"/>
|
||||
<PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions"/>
|
||||
|
||||
@@ -306,7 +306,16 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
Volatile.Read(ref _health).LastSuccessfulRead,
|
||||
$"FOCAS status 0x{status:X8} reading {reference}"));
|
||||
}
|
||||
catch (OperationCanceledException) { throw; }
|
||||
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { throw; }
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// Per-call timeout (not external cancellation) — the read stalled past the device
|
||||
// Timeout budget. Surface a recoverable comm error so the BadWaitingForInitialData
|
||||
// seed is overwritten and health degrades, instead of the read hanging forever.
|
||||
results[i] = new DataValueSnapshot(null, FocasStatusMapper.BadCommunicationError, null, now);
|
||||
Volatile.Write(ref _health, new DriverHealth(DriverState.Degraded,
|
||||
Volatile.Read(ref _health).LastSuccessfulRead, $"FOCAS read timed out for {reference}"));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
results[i] = new DataValueSnapshot(null, FocasStatusMapper.BadCommunicationError, null, now);
|
||||
@@ -356,7 +365,15 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
var status = await client.WriteAsync(parsed, def.DataType, w.Value, cancellationToken).ConfigureAwait(false);
|
||||
results[i] = new WriteResult(status);
|
||||
}
|
||||
catch (OperationCanceledException) { throw; }
|
||||
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { throw; }
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// Per-call timeout (not external cancellation) — the write stalled past the device
|
||||
// Timeout budget. Surface a recoverable comm error rather than aborting the batch.
|
||||
results[i] = new WriteResult(FocasStatusMapper.BadCommunicationError);
|
||||
Volatile.Write(ref _health, new DriverHealth(DriverState.Degraded,
|
||||
Volatile.Read(ref _health).LastSuccessfulRead, $"FOCAS write timed out for {w.FullReference}"));
|
||||
}
|
||||
catch (NotSupportedException nse)
|
||||
{
|
||||
results[i] = new WriteResult(FocasStatusMapper.BadNotSupported);
|
||||
@@ -1113,7 +1130,11 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
device.Client = null;
|
||||
}
|
||||
|
||||
device.Client = _clientFactory.Create();
|
||||
// Wrap the raw wire client so every operation on the device's single FOCAS/2 socket is
|
||||
// serialized (request→response on one socket cannot interleave) and time-bounded. Without
|
||||
// this, the equipment poll, fixed-tree loop, probe, and recycle loop collide on the shared
|
||||
// socket and a stalled read blocks forever — leaving bound tags at BadWaitingForInitialData.
|
||||
device.Client = new SynchronizedFocasClient(_clientFactory.Create(), _options.Timeout);
|
||||
try
|
||||
{
|
||||
await device.Client.ConnectAsync(device.ParsedAddress, _options.Timeout, ct).ConfigureAwait(false);
|
||||
|
||||
@@ -195,12 +195,41 @@ public static class FocasDriverFactoryExtensions
|
||||
AllowTrailingCommas = true,
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Reads a JSON property as a string, tolerating a JSON <b>number</b> token as well. The
|
||||
/// AdminUI persists the FOCAS <c>Series</c> enum as its integer value (e.g. <c>"series":6</c>),
|
||||
/// while this DTO models <c>Series</c> as a string handed to <see cref="ParseSeries"/>
|
||||
/// (Enum.TryParse accepts the numeric form). Without this, System.Text.Json throws
|
||||
/// "Cannot get the value of a token type 'Number' as a string" on the bare number and the
|
||||
/// driver falls back to a stub. Accepts string / number / null and emits a string.
|
||||
/// </summary>
|
||||
internal sealed class FlexibleStringConverter : JsonConverter<string?>
|
||||
{
|
||||
public override string? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) =>
|
||||
reader.TokenType switch
|
||||
{
|
||||
JsonTokenType.String => reader.GetString(),
|
||||
JsonTokenType.Number => reader.TryGetInt64(out var n)
|
||||
? n.ToString(System.Globalization.CultureInfo.InvariantCulture)
|
||||
: reader.GetDouble().ToString(System.Globalization.CultureInfo.InvariantCulture),
|
||||
JsonTokenType.Null => null,
|
||||
_ => throw new JsonException($"Expected string, number, or null but got {reader.TokenType}."),
|
||||
};
|
||||
|
||||
public override void Write(Utf8JsonWriter writer, string? value, JsonSerializerOptions options)
|
||||
{
|
||||
if (value is null) writer.WriteNullValue();
|
||||
else writer.WriteStringValue(value);
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class FocasDriverConfigDto
|
||||
{
|
||||
/// <summary>Gets or sets the FOCAS client factory backend name (e.g. "wire" or "stub").</summary>
|
||||
public string? Backend { get; init; }
|
||||
|
||||
/// <summary>Gets or sets the CNC series for this driver.</summary>
|
||||
[JsonConverter(typeof(FlexibleStringConverter))]
|
||||
public string? Series { get; init; }
|
||||
|
||||
/// <summary>Gets or sets the operation timeout in milliseconds.</summary>
|
||||
@@ -234,6 +263,7 @@ public static class FocasDriverFactoryExtensions
|
||||
public string? DeviceName { get; init; }
|
||||
|
||||
/// <summary>Gets or sets the CNC series for this device (overrides top-level series if provided).</summary>
|
||||
[JsonConverter(typeof(FlexibleStringConverter))]
|
||||
public string? Series { get; init; }
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
using System.Diagnostics;
|
||||
using System.Net.Sockets;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Wire;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
|
||||
|
||||
@@ -11,35 +11,29 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
|
||||
/// Two-phase Test-Connect probe for the <see cref="FocasDriverOptions"/>-shaped driver config.
|
||||
/// Phase 1: bare TCP connect to the first device's FOCAS Ethernet address + port to quickly
|
||||
/// reject unreachable targets (preserves the original "Connect failed" / "timed out"
|
||||
/// messages). Phase 2: attempts the FANUC FWLIB handle handshake — allocates a CNC handle via
|
||||
/// <c>cnc_allclibhndl3(host, port, timeoutSec, out handle)</c> and immediately frees it with
|
||||
/// <c>cnc_freelibhndl</c>. A handle that allocates (<c>EW_OK</c>) confirms the remote endpoint
|
||||
/// is a real FOCAS CNC, not just a TCP listener.
|
||||
/// messages). Phase 2: a real FOCAS session via the managed <see cref="FocasWireClient"/> — the
|
||||
/// two-socket initiate handshake plus one sample read (<c>cnc_statinfo</c>). A handshake +
|
||||
/// read that succeeds confirms the remote endpoint is a real FOCAS CNC, not just a TCP
|
||||
/// listener.
|
||||
/// <para>
|
||||
/// The P/Invoke is issued directly (it does NOT route through
|
||||
/// <see cref="UnimplementedFocasClientFactory"/>, whose <c>EnsureUsable()</c> throws by
|
||||
/// design) so the handshake works on a real Windows+FWLIB host and degrades everywhere else.
|
||||
/// The synchronous native call can block, so it runs on a worker bounded by a linked CTS
|
||||
/// (<c>ct</c> + <c>CancelAfter(timeout)</c>) — the probe always returns within the timeout
|
||||
/// budget even if FWLIB hangs.
|
||||
/// <b>Why a wire-client probe (not FWLIB).</b> The pure-managed wire client is the driver's
|
||||
/// only read backend (the FWLIB / out-of-process paths were retired in the Wire migration), so
|
||||
/// the probe must exercise the same path the driver actually uses. The previous probe issued
|
||||
/// the <c>cnc_allclibhndl3</c> FWLIB P/Invoke and, on any host without the native library (the
|
||||
/// normal case — macOS dev boxes, Linux CI, and the Windows hosts that run the managed client),
|
||||
/// degraded to <c>Ok=true</c> "TCP reachability only". That made every bare TCP listener look
|
||||
/// HEALTHY — exactly how a Makino 31i-B looked "healthy" while no FOCAS data flowed. The wire
|
||||
/// probe reports HEALTHY only on a genuine FOCAS session + read. See
|
||||
/// <c>docs/plans/2026-06-25-focas-pdu-v3-30i-b-support.md</c> (Phase 8).
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Degrade guard (the crux).</b> On a host without the FWLIB native library — this dev box
|
||||
/// (macOS) and the Linux CI containers — the <c>cnc_allclibhndl3</c> P/Invoke fails to bind
|
||||
/// and throws <see cref="DllNotFoundException"/> (or a related load failure:
|
||||
/// <see cref="TypeInitializationException"/>, <see cref="NotSupportedException"/>,
|
||||
/// <see cref="BadImageFormatException"/>, <see cref="EntryPointNotFoundException"/>). Those are
|
||||
/// caught and the probe falls back to <c>Ok=true</c> with a "FWLIB absent — TCP reachability
|
||||
/// only" note, so the probe is never worse than the original TCP-only behaviour on FWLIB-less
|
||||
/// hosts. The happy path and the FWLIB-present CNC-error path are live-verify deferred (no CNC
|
||||
/// and no FWLIB on the rig).
|
||||
/// The wire client honours the linked CTS (<c>ct</c> + <c>CancelAfter(timeout)</c>) and its
|
||||
/// reads are abort-bounded (see <see cref="FocasWireProtocol"/>), so the probe always returns
|
||||
/// within the timeout budget even against a host that accepts TCP then stalls.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed class FocasDriverProbe : IDriverProbe
|
||||
{
|
||||
/// <summary>FANUC FWLIB return code for success (<c>EW_OK</c>).</summary>
|
||||
private const short EwOk = 0;
|
||||
|
||||
private static readonly JsonSerializerOptions _opts = new()
|
||||
{
|
||||
PropertyNameCaseInsensitive = true,
|
||||
@@ -83,75 +77,32 @@ public sealed class FocasDriverProbe : IDriverProbe
|
||||
return new(false, ex.Message, null);
|
||||
}
|
||||
|
||||
// Phase 2: FOCAS handle handshake via cnc_allclibhndl3. The native call is synchronous and
|
||||
// can block, so run it on a worker bounded by a linked CTS = ct + CancelAfter(timeout).
|
||||
using var handshakeCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||||
// Phase 2: real FOCAS session via the managed wire client — initiate handshake + one
|
||||
// sample read. Bounded by a linked CTS = ct + CancelAfter(budget); the wire reads are
|
||||
// abort-bounded so a TCP-accept-then-stall host can't hold the probe past the budget.
|
||||
using var sessionCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||||
var budget = timeout > TimeSpan.Zero ? timeout : TimeSpan.FromSeconds(1);
|
||||
handshakeCts.CancelAfter(budget);
|
||||
sessionCts.CancelAfter(budget);
|
||||
|
||||
try
|
||||
{
|
||||
var (degraded, rc) = await Task.Run(
|
||||
() => TryAllocateAndFreeHandle(host, port, budget),
|
||||
handshakeCts.Token);
|
||||
|
||||
await using var wire = new FocasWireClient();
|
||||
await wire.ConnectAsync(host, port, budget, sessionCts.Token).ConfigureAwait(false);
|
||||
var status = await wire.ReadStatusAsync(sessionCts.Token, budget).ConfigureAwait(false);
|
||||
sw.Stop();
|
||||
|
||||
if (degraded)
|
||||
{
|
||||
// FWLIB absent / cannot load — never worse than the original TCP-only probe.
|
||||
return new(
|
||||
true,
|
||||
$"Reachable at {host}:{port} (FOCAS handshake unavailable on this host — " +
|
||||
"FWLIB absent, TCP reachability only)",
|
||||
sw.Elapsed);
|
||||
}
|
||||
|
||||
if (rc == EwOk)
|
||||
return new(true, "FOCAS handle OK", sw.Elapsed);
|
||||
|
||||
// FWLIB present but the remote returned an error — reachable TCP but not a CNC.
|
||||
return new(false, $"Reachable at {host}:{port} but FOCAS handshake failed: focas_rc={rc}", null);
|
||||
return status.IsOk
|
||||
? new(true, $"FOCAS session OK at {host}:{port} (cnc_statinfo)", sw.Elapsed)
|
||||
: new(false, $"Reachable at {host}:{port} but FOCAS read failed: EW_{status.Rc}", null);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// The caller cancelled, or the Task.Run was cancelled before the native call started.
|
||||
// (A native cnc_allclibhndl3 that is already running is bounded by the timeoutSeconds
|
||||
// argument passed into it, not by handshakeCts — see TryAllocateAndFreeHandle.)
|
||||
return new(false, $"Probe timed out after {timeout.TotalSeconds:F0}s.", null);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Attempts the FWLIB handle handshake against <paramref name="host"/>/<paramref name="port"/>.
|
||||
/// On success the handle is freed immediately. Returns <c>degraded=true</c> when the native
|
||||
/// library cannot be loaded (FWLIB absent — the dev/CI reality); otherwise
|
||||
/// <c>degraded=false</c> with the FWLIB return code (<c>EW_OK</c> = handle allocated).
|
||||
/// </summary>
|
||||
private static (bool degraded, short rc) TryAllocateAndFreeHandle(string host, int port, TimeSpan timeout)
|
||||
{
|
||||
var timeoutSeconds = (int)Math.Ceiling(timeout.TotalSeconds);
|
||||
if (timeoutSeconds <= 0) timeoutSeconds = 1;
|
||||
|
||||
ushort handle = 0;
|
||||
try
|
||||
catch (FocasWireException ex)
|
||||
{
|
||||
var rc = NativeFwlib.cnc_allclibhndl3(host, (ushort)port, timeoutSeconds, out handle);
|
||||
return (degraded: false, rc);
|
||||
}
|
||||
catch (DllNotFoundException) { return (degraded: true, rc: default); }
|
||||
catch (TypeInitializationException) { return (degraded: true, rc: default); }
|
||||
catch (NotSupportedException) { return (degraded: true, rc: default); }
|
||||
catch (BadImageFormatException) { return (degraded: true, rc: default); }
|
||||
catch (EntryPointNotFoundException) { return (degraded: true, rc: default); }
|
||||
finally
|
||||
{
|
||||
// Best-effort free if a handle was actually allocated (incl. after a timeout race).
|
||||
if (handle != 0)
|
||||
{
|
||||
try { NativeFwlib.cnc_freelibhndl(handle); }
|
||||
catch { /* best-effort — never let teardown hide the probe result */ }
|
||||
}
|
||||
// TCP-reachable but the FOCAS initiate/read failed — a listener that is not a CNC.
|
||||
return new(false, $"Reachable at {host}:{port} but FOCAS session failed: {ex.Message}", null);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -166,28 +117,4 @@ public sealed class FocasDriverProbe : IDriverProbe
|
||||
|
||||
return (parsed.Host, parsed.Port);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Minimal P/Invoke surface for the two FANUC FWLIB entry points the probe needs:
|
||||
/// <c>cnc_allclibhndl3</c> to allocate a CNC handle against a host/port, and
|
||||
/// <c>cnc_freelibhndl</c> to release it. The native library (<c>fwlib32.dll</c> /
|
||||
/// <c>fwlib64.dll</c> on Windows, <c>libfwlib32.so</c> on Linux) is only present on a host
|
||||
/// with the FANUC FWLIB redistributable installed. On every other host the JIT fails to
|
||||
/// bind these entry points and throws <see cref="DllNotFoundException"/> — caught by the
|
||||
/// probe's degrade guard.
|
||||
/// </summary>
|
||||
private static class NativeFwlib
|
||||
{
|
||||
private const string Library = "fwlib32";
|
||||
|
||||
[DllImport(Library, CallingConvention = CallingConvention.Cdecl, CharSet = CharSet.Ansi)]
|
||||
internal static extern short cnc_allclibhndl3(
|
||||
[MarshalAs(UnmanagedType.LPStr)] string ipaddr,
|
||||
ushort port,
|
||||
int timeout,
|
||||
out ushort handle);
|
||||
|
||||
[DllImport(Library, CallingConvention = CallingConvention.Cdecl)]
|
||||
internal static extern short cnc_freelibhndl(ushort handle);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,9 +21,19 @@ public sealed record FocasHostAddress(string Host, int Port)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value)) return null;
|
||||
const string prefix = "focas://";
|
||||
if (!value.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) return null;
|
||||
|
||||
var body = value[prefix.Length..];
|
||||
// Canonical form is focas://{ip}[:{port}], but the AdminUI persists the device host as a
|
||||
// scheme-less "{ip}[:{port}]" (e.g. "10.201.31.5:8193"). Accept that too: take the body
|
||||
// after focas:// when present, else the whole value when it carries NO other URI scheme
|
||||
// (a "://" that isn't ours — e.g. http:// — is still rejected). The host-contains-colon
|
||||
// guard below then rejects malformed scheme typos like "focas:10.0.0.5:8193".
|
||||
string body;
|
||||
if (value.StartsWith(prefix, StringComparison.OrdinalIgnoreCase))
|
||||
body = value[prefix.Length..];
|
||||
else if (!value.Contains("://", StringComparison.Ordinal))
|
||||
body = value;
|
||||
else
|
||||
return null;
|
||||
if (string.IsNullOrEmpty(body)) return null;
|
||||
|
||||
var colonIdx = body.LastIndexOf(':');
|
||||
@@ -39,7 +49,9 @@ public sealed record FocasHostAddress(string Host, int Port)
|
||||
{
|
||||
host = body;
|
||||
}
|
||||
if (string.IsNullOrEmpty(host)) return null;
|
||||
// Empty host, or a host still carrying a colon (e.g. the malformed "focas:10.0.0.5" left
|
||||
// when someone wrote "focas:10.0.0.5:8193" without the //), is invalid.
|
||||
if (string.IsNullOrEmpty(host) || host.Contains(':', StringComparison.Ordinal)) return null;
|
||||
return new FocasHostAddress(host, port);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
|
||||
|
||||
/// <summary>
|
||||
/// Decorates an <see cref="IFocasClient"/> so that every wire operation on the device's
|
||||
/// single FOCAS/2 socket is (1) <b>serialized</b> against all other operations and
|
||||
/// (2) <b>time-bounded</b>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>FOCAS/2 over TCP:8193 is a strict request→response protocol on ONE socket. The
|
||||
/// driver holds a single <see cref="IFocasClient"/> per device, but several independent loops
|
||||
/// read from it concurrently — the equipment poll (<see cref="FocasDriver.ReadAsync"/>), the
|
||||
/// fixed-tree loop (<c>FixedTreeLoopAsync</c>), the connectivity probe, and the recycle loop.
|
||||
/// Without serialization, two reads interleave their <c>send(request); read(response)</c> on the
|
||||
/// same socket: one reader consumes the other's response PDU and the victim then blocks forever
|
||||
/// waiting for bytes that never arrive — leaving the bound OPC UA node stuck at
|
||||
/// <c>BadWaitingForInitialData</c>. This was the root cause of FOCAS equipment tags never
|
||||
/// surfacing a value while the probe reported HEALTHY (the probe reads work single-threaded on a
|
||||
/// dev box, but collide deployed once the fixed-tree loop runs concurrently).</para>
|
||||
///
|
||||
/// <para>The gate (<see cref="SemaphoreSlim"/> of count 1) makes each request→response atomic on
|
||||
/// the socket. The per-call timeout ensures a stalled response can never hold the gate — and thus
|
||||
/// the socket — indefinitely; a hung read surfaces as a recoverable error at the configured
|
||||
/// <c>Timeout</c> budget instead of permanent silence. The gate and timeout are paired
|
||||
/// deliberately: a lock around an <i>unbounded</i> read would deadlock all I/O for the device.</para>
|
||||
///
|
||||
/// <para><see cref="ConnectAsync"/> and <see cref="ProbeAsync"/> are serialized but NOT bounded by
|
||||
/// this decorator's call timeout — they carry their own budgets (the connect timeout argument and
|
||||
/// the probe's caller-supplied linked token respectively), and double-bounding would shrink them.</para>
|
||||
/// </remarks>
|
||||
public sealed class SynchronizedFocasClient : IFocasClient
|
||||
{
|
||||
private readonly IFocasClient _inner;
|
||||
private readonly TimeSpan _callTimeout;
|
||||
private readonly SemaphoreSlim _gate = new(1, 1);
|
||||
|
||||
/// <summary>Wraps <paramref name="inner"/> with per-device serialization + a per-call timeout.</summary>
|
||||
/// <param name="inner">The underlying FOCAS client to serialize access to.</param>
|
||||
/// <param name="callTimeout">
|
||||
/// The budget applied to each data read/write. <see cref="TimeSpan.Zero"/> or negative disables
|
||||
/// the per-call timeout (callers' own cancellation tokens still apply).
|
||||
/// </param>
|
||||
public SynchronizedFocasClient(IFocasClient inner, TimeSpan callTimeout)
|
||||
{
|
||||
_inner = inner ?? throw new ArgumentNullException(nameof(inner));
|
||||
_callTimeout = callTimeout;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool IsConnected => _inner.IsConnected;
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task ConnectAsync(FocasHostAddress address, TimeSpan timeout, CancellationToken cancellationToken) =>
|
||||
RunGatedAsync(ct => _inner.ConnectAsync(address, timeout, ct), cancellationToken);
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<bool> ProbeAsync(CancellationToken cancellationToken) =>
|
||||
RunGatedAsync(ct => _inner.ProbeAsync(ct), cancellationToken);
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<(object? value, uint status)> ReadAsync(
|
||||
FocasAddress address, FocasDataType type, CancellationToken cancellationToken) =>
|
||||
RunBoundedAsync(ct => _inner.ReadAsync(address, type, ct), cancellationToken);
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<uint> WriteAsync(
|
||||
FocasAddress address, FocasDataType type, object? value, CancellationToken cancellationToken) =>
|
||||
RunBoundedAsync(ct => _inner.WriteAsync(address, type, value, ct), cancellationToken);
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<IReadOnlyList<FocasActiveAlarm>> ReadAlarmsAsync(CancellationToken cancellationToken) =>
|
||||
RunBoundedAsync(ct => _inner.ReadAlarmsAsync(ct), cancellationToken);
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<FocasSysInfo> GetSysInfoAsync(CancellationToken cancellationToken) =>
|
||||
RunBoundedAsync(ct => _inner.GetSysInfoAsync(ct), cancellationToken);
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<IReadOnlyList<FocasAxisName>> GetAxisNamesAsync(CancellationToken cancellationToken) =>
|
||||
RunBoundedAsync(ct => _inner.GetAxisNamesAsync(ct), cancellationToken);
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<IReadOnlyList<FocasSpindleName>> GetSpindleNamesAsync(CancellationToken cancellationToken) =>
|
||||
RunBoundedAsync(ct => _inner.GetSpindleNamesAsync(ct), cancellationToken);
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<FocasDynamicSnapshot> ReadDynamicAsync(int axisIndex, CancellationToken cancellationToken) =>
|
||||
RunBoundedAsync(ct => _inner.ReadDynamicAsync(axisIndex, ct), cancellationToken);
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<FocasProgramInfo> GetProgramInfoAsync(CancellationToken cancellationToken) =>
|
||||
RunBoundedAsync(ct => _inner.GetProgramInfoAsync(ct), cancellationToken);
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<FocasTimer> GetTimerAsync(FocasTimerKind kind, CancellationToken cancellationToken) =>
|
||||
RunBoundedAsync(ct => _inner.GetTimerAsync(kind, ct), cancellationToken);
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<IReadOnlyList<FocasServoLoad>> GetServoLoadsAsync(CancellationToken cancellationToken) =>
|
||||
RunBoundedAsync(ct => _inner.GetServoLoadsAsync(ct), cancellationToken);
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<IReadOnlyList<int>> 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);
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Dispose()
|
||||
{
|
||||
_inner.Dispose();
|
||||
_gate.Dispose();
|
||||
}
|
||||
|
||||
// Gate only — the caller already governs the budget (connect timeout arg / probe linked token).
|
||||
private async Task<T> RunGatedAsync<T>(Func<CancellationToken, Task<T>> op, CancellationToken ct)
|
||||
{
|
||||
await _gate.WaitAsync(ct).ConfigureAwait(false);
|
||||
try { return await op(ct).ConfigureAwait(false); }
|
||||
finally { _gate.Release(); }
|
||||
}
|
||||
|
||||
private async Task RunGatedAsync(Func<CancellationToken, Task> op, CancellationToken ct)
|
||||
{
|
||||
await _gate.WaitAsync(ct).ConfigureAwait(false);
|
||||
try { await op(ct).ConfigureAwait(false); }
|
||||
finally { _gate.Release(); }
|
||||
}
|
||||
|
||||
// Gate + per-call timeout. A fired timeout surfaces as OperationCanceledException whose token is
|
||||
// the linked (not the caller's) token — callers distinguish it from real cancellation by testing
|
||||
// their own token's IsCancellationRequested.
|
||||
private async Task<T> RunBoundedAsync<T>(Func<CancellationToken, Task<T>> op, CancellationToken ct)
|
||||
{
|
||||
await _gate.WaitAsync(ct).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
if (_callTimeout <= TimeSpan.Zero)
|
||||
return await op(ct).ConfigureAwait(false);
|
||||
|
||||
using var linked = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||||
linked.CancelAfter(_callTimeout);
|
||||
return await op(linked.Token).ConfigureAwait(false);
|
||||
}
|
||||
finally { _gate.Release(); }
|
||||
}
|
||||
}
|
||||
@@ -369,19 +369,7 @@ public sealed class FocasWireClient : IAsyncDisposable, IDisposable
|
||||
var rc = AggregateRc(blocks);
|
||||
if (rc != 0) return new FocasResult<IReadOnlyList<WireServoMeter>>(rc, null);
|
||||
|
||||
var payload = FindPayload(blocks, 0x0056);
|
||||
var result = new List<WireServoMeter>();
|
||||
for (var offset = 0; offset + 12 <= payload.Length && result.Count < maxCount; offset += 12)
|
||||
{
|
||||
var name = FocasWireProtocol.ReadNameRecord(payload.AsSpan(offset + 8, 4));
|
||||
result.Add(new WireServoMeter(
|
||||
(short)(result.Count + 1),
|
||||
name,
|
||||
ReadInt32(payload, offset),
|
||||
ReadInt16(payload, offset + 4),
|
||||
ReadInt16(payload, offset + 6)));
|
||||
}
|
||||
|
||||
var result = ParseServoMeters(FindPayload(blocks, 0x0056), FindPayload(blocks, 0x0089), maxCount);
|
||||
return new FocasResult<IReadOnlyList<WireServoMeter>>(rc, result);
|
||||
}
|
||||
|
||||
@@ -602,31 +590,7 @@ public sealed class FocasWireClient : IAsyncDisposable, IDisposable
|
||||
callTimeout.Token,
|
||||
new RequestBlock(0x8001, start, end, area, dataType, RequestClass: 2, PathId: EffectivePathId(pathId))).ConfigureAwait(false);
|
||||
|
||||
return ToResult(block, payload =>
|
||||
{
|
||||
var width = dataType switch
|
||||
{
|
||||
1 => 2,
|
||||
2 or 4 => 4,
|
||||
5 => 8,
|
||||
_ => 1,
|
||||
};
|
||||
|
||||
var values = new List<long>();
|
||||
for (var offset = 0; offset + width <= payload.Length; offset += width)
|
||||
{
|
||||
values.Add(width switch
|
||||
{
|
||||
1 => payload[offset],
|
||||
2 => ReadInt16(payload, offset),
|
||||
4 => ReadInt32(payload, offset),
|
||||
8 => BinaryPrimitives.ReadInt64BigEndian(payload.AsSpan(offset, 8)),
|
||||
_ => 0,
|
||||
});
|
||||
}
|
||||
|
||||
return new WirePmcRange(area, dataType, start, end, values);
|
||||
});
|
||||
return ToResult(block, payload => ParsePmcRange(area, dataType, start, end, payload));
|
||||
}
|
||||
|
||||
/// <summary>Typed overload for <see cref="ReadPmcRangeAsync(short, short, ushort, ushort, CancellationToken, TimeSpan?, ushort?)"/>.</summary>
|
||||
@@ -740,7 +704,7 @@ public sealed class FocasWireClient : IAsyncDisposable, IDisposable
|
||||
ushort? pathId = null)
|
||||
=> ReadSingleWithTimeoutAsync(
|
||||
0x0120,
|
||||
payload => new WireTimer(type, payload.Length >= 4 ? ReadInt32(payload, 0) : 0, payload.Length >= 8 ? ReadInt32(payload, 4) : 0),
|
||||
payload => ParseTimer(type, payload),
|
||||
cancellationToken, timeout, EffectivePathId(pathId), type);
|
||||
|
||||
// ---- internal plumbing ------------------------------------------------------------
|
||||
@@ -922,6 +886,88 @@ public sealed class FocasWireClient : IAsyncDisposable, IDisposable
|
||||
private static short AggregateRc(IReadOnlyList<ResponseBlock> blocks)
|
||||
=> blocks.FirstOrDefault(block => block.Rc != 0)?.Rc ?? 0;
|
||||
|
||||
/// <summary>
|
||||
/// Decode a <c>cnc_rdtimer</c> (0x0120) payload into <see cref="WireTimer"/>. The 8-byte
|
||||
/// data block is two 32-bit fields {minute, msec}, and they are <b>little-endian</b> on the
|
||||
/// wire — unlike the big-endian block envelope and every other payload (sysinfo / dynamic /
|
||||
/// macro). Validated against a live FANUC 31i-B (2026-06-25): the msec field only falls in
|
||||
/// valid range (0..59999) under little-endian; big-endian decoded it as ~2.4e9. Captured
|
||||
/// cutting-time payload <c>ac f2 10 00 90 a3 00 00</c> → minute=1110188, msec=41872. See
|
||||
/// <c>docs/plans/2026-06-25-focas-pdu-v3-30i-b-support.md</c>.
|
||||
/// </summary>
|
||||
internal static WireTimer ParseTimer(short type, byte[] payload) => new(
|
||||
type,
|
||||
payload.Length >= 4 ? BinaryPrimitives.ReadInt32LittleEndian(payload.AsSpan(0, 4)) : 0,
|
||||
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>
|
||||
/// </summary>
|
||||
internal static IReadOnlyList<WireServoMeter> ParseServoMeters(byte[] svPayload, byte[] axisNamePayload, int maxCount)
|
||||
{
|
||||
var result = new List<WireServoMeter>();
|
||||
for (var offset = 0; offset + 8 <= svPayload.Length && result.Count < maxCount; offset += 8)
|
||||
{
|
||||
var index = result.Count;
|
||||
var nameOffset = index * 4;
|
||||
var name = nameOffset + 4 <= axisNamePayload.Length
|
||||
? FocasWireProtocol.ReadNameRecord(axisNamePayload.AsSpan(nameOffset, 4))
|
||||
: string.Empty;
|
||||
result.Add(new WireServoMeter(
|
||||
(short)(index + 1),
|
||||
name,
|
||||
ReadInt32(svPayload, offset),
|
||||
ReadInt16(svPayload, offset + 4),
|
||||
ReadInt16(svPayload, offset + 6)));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>Byte width of one PMC slot for a FOCAS data-type code: Byte=1, Word=2, Long/Real=4, Double=8.</summary>
|
||||
internal static int PmcByteWidth(short dataType) => dataType switch
|
||||
{
|
||||
1 => 2,
|
||||
2 or 4 => 4,
|
||||
5 => 8,
|
||||
_ => 1,
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Decode a <c>pmc_rdpmcrng</c> payload into <see cref="WirePmcRange"/>. The CNC returns
|
||||
/// (end-start+1) BYTES; this slices them into width-sized big-endian slots. Callers must
|
||||
/// size the request range to <c>width</c> bytes per value (see
|
||||
/// <c>WireFocasClient.ReadPmcAsync</c>) or a trailing partial slot is dropped — which on the
|
||||
/// 31i-B surfaced as a spurious <c>BadOutOfRange</c> for a single Word read. 2026-06-25.
|
||||
/// </summary>
|
||||
internal static WirePmcRange ParsePmcRange(short area, short dataType, ushort start, ushort end, byte[] payload)
|
||||
{
|
||||
var width = PmcByteWidth(dataType);
|
||||
var values = new List<long>();
|
||||
for (var offset = 0; offset + width <= payload.Length; offset += width)
|
||||
{
|
||||
values.Add(width switch
|
||||
{
|
||||
1 => payload[offset],
|
||||
2 => ReadInt16(payload, offset),
|
||||
4 => ReadInt32(payload, offset),
|
||||
8 => BinaryPrimitives.ReadInt64BigEndian(payload.AsSpan(offset, 8)),
|
||||
_ => 0,
|
||||
});
|
||||
}
|
||||
return new WirePmcRange(area, dataType, start, end, values);
|
||||
}
|
||||
|
||||
private static byte[] FindPayload(IReadOnlyList<ResponseBlock> blocks, ushort command)
|
||||
=> blocks.FirstOrDefault(block => block.Command == command)?.Payload ?? Array.Empty<byte>();
|
||||
|
||||
|
||||
@@ -19,7 +19,22 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Wire;
|
||||
/// </remarks>
|
||||
internal static class FocasWireProtocol
|
||||
{
|
||||
/// <summary>The PDU version this client emits in every outgoing request header.</summary>
|
||||
public const ushort Version = 1;
|
||||
|
||||
/// <summary>
|
||||
/// PDU versions accepted on inbound PDUs. The 10-byte header framing is identical across
|
||||
/// these (only the version field differs), so the framing layer accepts both while we keep
|
||||
/// emitting <see cref="Version"/> (v1) on requests. The docker mock + older controls answer
|
||||
/// v1; modern controls answer v3 — FANUC 30i-B validated live 2026-06-25 (macro reads OK).
|
||||
/// See <c>docs/plans/2026-06-25-focas-pdu-v3-30i-b-support.md</c>.
|
||||
/// </summary>
|
||||
private static readonly ushort[] SupportedReadVersions = [1, 3];
|
||||
|
||||
/// <summary>True when <paramref name="version"/> is a PDU version this client can frame-parse.</summary>
|
||||
internal static bool IsSupportedReadVersion(ushort version) =>
|
||||
Array.IndexOf(SupportedReadVersions, version) >= 0;
|
||||
|
||||
public const byte DirectionRequest = 0x01;
|
||||
public const byte DirectionResponse = 0x02;
|
||||
public const byte TypeInitiate = 0x01;
|
||||
@@ -99,7 +114,7 @@ internal static class FocasWireProtocol
|
||||
throw new FocasWireException("Invalid FOCAS PDU magic.");
|
||||
|
||||
var version = BinaryPrimitives.ReadUInt16BigEndian(header.AsSpan(4, 2));
|
||||
if (version != Version)
|
||||
if (!IsSupportedReadVersion(version))
|
||||
throw new FocasWireException($"Unsupported FOCAS PDU version {version}.");
|
||||
|
||||
var bodyLength = BinaryPrimitives.ReadUInt16BigEndian(header.AsSpan(8, 2));
|
||||
@@ -122,7 +137,7 @@ internal static class FocasWireProtocol
|
||||
throw new FocasWireException("Invalid FOCAS PDU magic.");
|
||||
|
||||
var version = BinaryPrimitives.ReadUInt16BigEndian(header.AsSpan(4, 2));
|
||||
if (version != Version)
|
||||
if (!IsSupportedReadVersion(version))
|
||||
throw new FocasWireException($"Unsupported FOCAS PDU version {version}.");
|
||||
|
||||
var bodyLength = BinaryPrimitives.ReadUInt16BigEndian(header.AsSpan(8, 2));
|
||||
@@ -135,13 +150,29 @@ internal static class FocasWireProtocol
|
||||
|
||||
private static async Task ReadExactlyAsync(NetworkStream stream, byte[] buffer, CancellationToken cancellationToken)
|
||||
{
|
||||
// NetworkStream.ReadAsync's CancellationToken does not reliably abort a socket read that is
|
||||
// blocked waiting for bytes the peer never sends — a CNC that TCP-accepts then stalls
|
||||
// mid-PDU (the cnc_rdsvmeter "hang" the 31i-B work chased). Register a hard abort that
|
||||
// disposes the stream on cancellation so a stalled read throws instead of wedging the
|
||||
// caller's poll loop, and normalize the resulting failure to OperationCanceledException so
|
||||
// the request path tears the transport down as a transient. See
|
||||
// docs/plans/2026-06-25-focas-pdu-v3-30i-b-support.md (Phase 2).
|
||||
await using var abort = cancellationToken.Register(static s => ((IDisposable)s!).Dispose(), stream);
|
||||
var offset = 0;
|
||||
while (offset < buffer.Length)
|
||||
try
|
||||
{
|
||||
var read = await stream.ReadAsync(buffer, offset, buffer.Length - offset, cancellationToken).ConfigureAwait(false);
|
||||
if (read == 0)
|
||||
throw new EndOfStreamException("FOCAS socket closed before the expected number of bytes were read.");
|
||||
offset += read;
|
||||
while (offset < buffer.Length)
|
||||
{
|
||||
var read = await stream.ReadAsync(buffer.AsMemory(offset, buffer.Length - offset), cancellationToken).ConfigureAwait(false);
|
||||
if (read == 0)
|
||||
throw new EndOfStreamException("FOCAS socket closed before the expected number of bytes were read.");
|
||||
offset += read;
|
||||
}
|
||||
}
|
||||
catch (Exception ex) when (cancellationToken.IsCancellationRequested && ex is not OperationCanceledException)
|
||||
{
|
||||
// The stalled read was aborted by the dispose-on-cancel registration above.
|
||||
throw new OperationCanceledException(cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -196,6 +196,11 @@ public sealed class WireFocasClient : IFocasClient
|
||||
/// <returns>The dynamic snapshot of the axis.</returns>
|
||||
public async Task<FocasDynamicSnapshot> ReadDynamicAsync(int axisIndex, CancellationToken cancellationToken)
|
||||
{
|
||||
// FOCAS axes are 1-based; cnc_rddynamic2 with axis 0 returns EW_4 (live-confirmed on the
|
||||
// 31i-B). The FixedTree poll already iterates 1..AxesCount, but enforce the contract so a
|
||||
// future caller can't silently request axis 0. 2026-06-25.
|
||||
if (axisIndex < 1)
|
||||
throw new ArgumentOutOfRangeException(nameof(axisIndex), axisIndex, "FOCAS axis index is 1-based.");
|
||||
RequireConnected();
|
||||
var result = await _wire.ReadDynamic2Async((short)axisIndex, cancellationToken).ConfigureAwait(false);
|
||||
ThrowIfRcNonZero(result.Rc, "cnc_rddynamic2", result.IsOk);
|
||||
@@ -337,7 +342,11 @@ public sealed class WireFocasClient : IFocasClient
|
||||
if (area is null) return (null, FocasStatusMapper.BadNodeIdUnknown);
|
||||
var dataType = FocasPmcDataTypeLookup.FromFocasDataType(type);
|
||||
var start = (ushort)address.Number;
|
||||
var end = start;
|
||||
// pmc_rdpmcrng returns (end-start+1) BYTES. A multi-byte slot (Word/Long/Real/Double) needs
|
||||
// the range widened to its byte width, else the value parser gets too few bytes → 0 values
|
||||
// → spurious BadOutOfRange (live-confirmed on the 31i-B: a Word read with end=start returned
|
||||
// a single byte). Bit/Byte stay width 1 so end==start. 2026-06-25.
|
||||
var end = (ushort)(start + FocasWireClient.PmcByteWidth((short)dataType) - 1);
|
||||
|
||||
try
|
||||
{
|
||||
|
||||
@@ -228,6 +228,14 @@ builder.Services.AddOtOpcUaHealth();
|
||||
builder.Services.AddOtOpcUaObservability(builder.Configuration);
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
// AddZbSerilog registers Serilog as the MEL logging provider but does NOT assign the static
|
||||
// Serilog.Log.Logger. Set it from the DI root logger so (1) static Log.* calls like the startup
|
||||
// banner below emit, and (2) Akka.Logger.Serilog's SerilogLogger — which writes to Log.Logger —
|
||||
// routes the actor graph's logs (DriverHostActor et al.) to the configured sinks. Must run before
|
||||
// app.RunAsync() starts the ActorSystem (the Akka logger captures Log.Logger at system start).
|
||||
Serilog.Log.Logger = app.Services.GetRequiredService<Serilog.ILogger>();
|
||||
|
||||
app.UseSerilogRequestLogging();
|
||||
|
||||
// Razor class library static assets (_content/<libname>/...) are served via endpoint
|
||||
|
||||
Binary file not shown.
@@ -0,0 +1,169 @@
|
||||
# FOCAS v3 capture 10.201.31.5:8193 (emit version=1)
|
||||
=== two-socket initiate handshake ===
|
||||
socket1 initiate <- v=3 bodyLen=360 ok=True
|
||||
socket2 initiate <- v=3 bodyLen=360 ok=True
|
||||
|
||||
--- setup-sysinfo-0x0018 ---
|
||||
-> 40B req: a0 a0 a0 a0 00 01 21 01 00 1e 00 01 00 1c 00 01 00 01 00 18 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
|
||||
<- header: version=3 type=0x21 dir=0x02 bodyLen=36 complete=True
|
||||
<- raw (46B): a0 a0 a0 a0 00 03 21 02 00 24 00 01 00 22 00 01 00 01 00 18 00 00 00 00 00 00 00 12 02 02 00 20 33 31 4d 4d 47 34 33 31 32 32 2e 30 30 37
|
||||
blockCount=1
|
||||
block[0] blkLen=34 cmd=0x0018 rc=0 payloadLen=18 payload=02 02 00 20 33 31 4d 4d 47 34 33 31 32 32 2e 30 30 37
|
||||
|
||||
--- setup-0x000e-26f0 ---
|
||||
-> 40B req: a0 a0 a0 a0 00 01 21 01 00 1e 00 01 00 1c 00 01 00 01 00 0e 00 00 26 f0 00 00 26 f0 00 00 00 00 00 00 00 00 00 00 00 00
|
||||
<- header: version=3 type=0x21 dir=0x02 bodyLen=18 complete=True
|
||||
<- raw (28B): a0 a0 a0 a0 00 03 21 02 00 12 00 01 00 10 00 01 00 01 00 0e 00 01 00 00 00 00 00 00
|
||||
blockCount=1
|
||||
block[0] blkLen=16 cmd=0x000e rc=1 payloadLen=0 payload=(empty)
|
||||
|
||||
--- ref-sysinfo-0x0018 ---
|
||||
-> 40B req: a0 a0 a0 a0 00 01 21 01 00 1e 00 01 00 1c 00 01 00 01 00 18 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
|
||||
<- header: version=3 type=0x21 dir=0x02 bodyLen=36 complete=True
|
||||
<- raw (46B): a0 a0 a0 a0 00 03 21 02 00 24 00 01 00 22 00 01 00 01 00 18 00 00 00 00 00 00 00 12 02 02 00 20 33 31 4d 4d 47 34 33 31 32 32 2e 30 30 37
|
||||
blockCount=1
|
||||
block[0] blkLen=34 cmd=0x0018 rc=0 payloadLen=18 payload=02 02 00 20 33 31 4d 4d 47 34 33 31 32 32 2e 30 30 37
|
||||
|
||||
--- ref-axisname-0x0089 ---
|
||||
-> 40B req: a0 a0 a0 a0 00 01 21 01 00 1e 00 01 00 1c 00 01 00 01 00 89 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
|
||||
<- header: version=3 type=0x21 dir=0x02 bodyLen=46 complete=True
|
||||
<- raw (56B): a0 a0 a0 a0 00 03 21 02 00 2e 00 01 00 2c 00 01 00 01 00 89 00 00 00 00 00 00 00 1c 58 00 00 00 59 00 00 00 5a 00 00 00 42 00 00 00 43 00 00 00 41 41 36 00 41 41 37 00
|
||||
blockCount=1
|
||||
block[0] blkLen=44 cmd=0x0089 rc=0 payloadLen=28 payload=58 00 00 00 59 00 00 00 5a 00 00 00 42 00 00 00 43 00 00 00 41 41 36 00 41 41 37 00
|
||||
|
||||
--- ref-spdlname-0x008a ---
|
||||
-> 40B req: a0 a0 a0 a0 00 01 21 01 00 1e 00 01 00 1c 00 01 00 01 00 8a 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
|
||||
<- header: version=3 type=0x21 dir=0x02 bodyLen=22 complete=True
|
||||
<- raw (32B): a0 a0 a0 a0 00 03 21 02 00 16 00 01 00 14 00 01 00 01 00 8a 00 00 00 00 00 00 00 04 53 31 00 00
|
||||
blockCount=1
|
||||
block[0] blkLen=20 cmd=0x008a rc=0 payloadLen=4 payload=53 31 00 00
|
||||
|
||||
--- ref-macro-3901-0x0015 ---
|
||||
-> 40B req: a0 a0 a0 a0 00 01 21 01 00 1e 00 01 00 1c 00 01 00 01 00 15 00 00 0f 3d 00 00 0f 3d 00 00 00 00 00 00 00 00 00 00 00 00
|
||||
<- header: version=3 type=0x21 dir=0x02 bodyLen=26 complete=True
|
||||
<- raw (36B): a0 a0 a0 a0 00 03 21 02 00 1a 00 01 00 18 00 01 00 01 00 15 00 00 00 00 00 00 00 08 00 00 00 00 00 0a 00 00
|
||||
blockCount=1
|
||||
block[0] blkLen=24 cmd=0x0015 rc=0 payloadLen=8 payload=00 00 00 00 00 0a 00 00
|
||||
|
||||
--- ref-macro-500-0x0015 ---
|
||||
-> 40B req: a0 a0 a0 a0 00 01 21 01 00 1e 00 01 00 1c 00 01 00 01 00 15 00 00 01 f4 00 00 01 f4 00 00 00 00 00 00 00 00 00 00 00 00
|
||||
<- header: version=3 type=0x21 dir=0x02 bodyLen=26 complete=True
|
||||
<- raw (36B): a0 a0 a0 a0 00 03 21 02 00 1a 00 01 00 18 00 01 00 01 00 15 00 00 00 00 00 00 00 08 05 f5 e1 00 00 0a 00 08
|
||||
blockCount=1
|
||||
block[0] blkLen=24 cmd=0x0015 rc=0 payloadLen=8 payload=05 f5 e1 00 00 0a 00 08
|
||||
|
||||
--- ref-opmode-0x0057 ---
|
||||
-> 40B req: a0 a0 a0 a0 00 01 21 01 00 1e 00 01 00 1c 00 01 00 01 00 57 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
|
||||
<- header: version=3 type=0x21 dir=0x02 bodyLen=34 complete=True
|
||||
<- raw (44B): a0 a0 a0 a0 00 03 21 02 00 22 00 01 00 20 00 01 00 01 00 57 00 00 00 00 00 00 00 10 00 02 00 e1 0a 00 08 00 00 5a 00 00 00 42 00 00
|
||||
blockCount=1
|
||||
block[0] blkLen=32 cmd=0x0057 rc=0 payloadLen=16 payload=00 02 00 e1 0a 00 08 00 00 5a 00 00 00 42 00 00
|
||||
|
||||
--- ref-exeprgname-0x00fc ---
|
||||
-> 40B req: a0 a0 a0 a0 00 01 21 01 00 1e 00 01 00 1c 00 01 00 01 00 fc 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
|
||||
<- header: version=3 type=0x21 dir=0x02 bodyLen=274 complete=True
|
||||
<- raw (284B): a0 a0 a0 a0 00 03 21 02 01 12 00 01 01 10 00 01 00 01 00 fc 00 00 00 00 00 00 01 00 2f 2f 43 4e 43 5f 4d 45 4d 2f 55 53 45 52 2f 4c 49 42 52 41 52 59 2f 4f 39 30 30 31 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
|
||||
blockCount=1
|
||||
block[0] blkLen=272 cmd=0x00fc rc=0 payloadLen=256 payload=2f 2f 43 4e 43 5f 4d 45 4d 2f 55 53 45 52 2f 4c 49 42 52 41 52 59 2f 4f 39 30 30 31 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
|
||||
|
||||
--- ref-statinfo ---
|
||||
-> 96B req: a0 a0 a0 a0 00 01 21 01 00 56 00 03 00 1c 00 01 00 01 00 19 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 1c 00 01 00 01 00 e1 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 1c 00 01 00 01 00 98 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
|
||||
<- header: version=3 type=0x21 dir=0x02 bodyLen=70 complete=True
|
||||
<- raw (80B): a0 a0 a0 a0 00 03 21 02 00 46 00 03 00 1e 00 01 00 01 00 19 00 00 00 00 00 00 00 0e 00 01 00 03 00 00 00 01 00 00 00 00 00 00 00 14 00 01 00 01 00 e1 00 00 00 00 00 00 00 04 00 00 00 03 00 12 00 01 00 01 00 98 00 00 00 00 00 00 00 02 00 00
|
||||
blockCount=3
|
||||
block[0] blkLen=30 cmd=0x0019 rc=0 payloadLen=14 payload=00 01 00 03 00 00 00 01 00 00 00 00 00 00
|
||||
block[1] blkLen=20 cmd=0x00e1 rc=0 payloadLen=4 payload=00 00 00 03
|
||||
block[2] blkLen=18 cmd=0x0098 rc=0 payloadLen=2 payload=00 00
|
||||
|
||||
--- ref-dynamic2-axis1 ---
|
||||
-> 264B req: a0 a0 a0 a0 00 01 21 01 00 fe 00 09 00 1c 00 01 00 01 00 1a 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 1c 00 01 00 01 00 1c 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 1c 00 01 00 01 00 1d 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 1c 00 01 00 01 00 24 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 1c 00 01 00 01 00 25 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 1c 00 01 00 01 00 26 00 00 00 04 00 00 00 01 00 00 00 00 00 00 00 00 00 00 00 00 00 1c 00 01 00 01 00 26 00 00 00 01 00 00 00 01 00 00 00 00 00 00 00 00 00 00 00 00 00 1c 00 01 00 01 00 26 00 00 00 06 00 00 00 01 00 00 00 00 00 00 00 00 00 00 00 00 00 1c 00 01 00 01 00 26 00 00 00 07 00 00 00 01 00 00 00 00 00 00 00 00 00 00 00 00
|
||||
<- header: version=3 type=0x21 dir=0x02 bodyLen=210 complete=True
|
||||
<- raw (220B): a0 a0 a0 a0 00 03 21 02 00 d2 00 09 00 14 00 01 00 01 00 1a 00 00 00 00 00 00 00 04 00 00 00 00 00 18 00 01 00 01 00 1c 00 00 00 00 00 00 00 08 00 00 23 29 00 00 04 57 00 14 00 01 00 01 00 1d 00 00 00 00 00 00 00 04 00 00 00 01 00 18 00 01 00 01 00 24 00 00 00 00 00 00 00 08 00 00 00 00 00 0a 00 00 00 18 00 01 00 01 00 25 00 00 00 00 00 00 00 08 00 00 00 00 00 0a 00 00 00 18 00 01 00 01 00 26 00 00 00 00 00 00 00 08 00 2a bf a6 00 0a 00 04 00 18 00 01 00 01 00 26 00 00 00 00 00 00 00 08 00 2a be 30 00 0a 00 04 00 18 00 01 00 01 00 26 00 00 00 00 00 00 00 08 00 00 00 20 00 0a 00 04 00 18 00 01 00 01 00 26 00 00 00 00 00 00 00 08 00 00 00 00 00 0a 00 04
|
||||
blockCount=9
|
||||
block[0] blkLen=20 cmd=0x001a rc=0 payloadLen=4 payload=00 00 00 00
|
||||
block[1] blkLen=24 cmd=0x001c rc=0 payloadLen=8 payload=00 00 23 29 00 00 04 57
|
||||
block[2] blkLen=20 cmd=0x001d rc=0 payloadLen=4 payload=00 00 00 01
|
||||
block[3] blkLen=24 cmd=0x0024 rc=0 payloadLen=8 payload=00 00 00 00 00 0a 00 00
|
||||
block[4] blkLen=24 cmd=0x0025 rc=0 payloadLen=8 payload=00 00 00 00 00 0a 00 00
|
||||
block[5] blkLen=24 cmd=0x0026 rc=0 payloadLen=8 payload=00 2a bf a6 00 0a 00 04
|
||||
block[6] blkLen=24 cmd=0x0026 rc=0 payloadLen=8 payload=00 2a be 30 00 0a 00 04
|
||||
block[7] blkLen=24 cmd=0x0026 rc=0 payloadLen=8 payload=00 00 00 20 00 0a 00 04
|
||||
block[8] blkLen=24 cmd=0x0026 rc=0 payloadLen=8 payload=00 00 00 00 00 0a 00 04
|
||||
|
||||
--- timer-poweron-0x0120-t0 ---
|
||||
-> 40B req: a0 a0 a0 a0 00 01 21 01 00 1e 00 01 00 1c 00 01 00 01 01 20 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
|
||||
<- header: version=3 type=0x21 dir=0x02 bodyLen=26 complete=True
|
||||
<- raw (36B): a0 a0 a0 a0 00 03 21 02 00 1a 00 01 00 18 00 01 00 01 01 20 00 00 00 00 00 00 00 08 00 00 00 00 00 00 00 00
|
||||
blockCount=1
|
||||
block[0] blkLen=24 cmd=0x0120 rc=0 payloadLen=8 payload=00 00 00 00 00 00 00 00
|
||||
|
||||
--- timer-operating-0x0120-t1 ---
|
||||
-> 40B req: a0 a0 a0 a0 00 01 21 01 00 1e 00 01 00 1c 00 01 00 01 01 20 00 00 00 01 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
|
||||
<- header: version=3 type=0x21 dir=0x02 bodyLen=26 complete=True
|
||||
<- raw (36B): a0 a0 a0 a0 00 03 21 02 00 1a 00 01 00 18 00 01 00 01 01 20 00 00 00 00 00 00 00 08 00 00 00 00 00 00 00 00
|
||||
blockCount=1
|
||||
block[0] blkLen=24 cmd=0x0120 rc=0 payloadLen=8 payload=00 00 00 00 00 00 00 00
|
||||
|
||||
--- timer-cutting-0x0120-t2 ---
|
||||
-> 40B req: a0 a0 a0 a0 00 01 21 01 00 1e 00 01 00 1c 00 01 00 01 01 20 00 00 00 02 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
|
||||
<- header: version=3 type=0x21 dir=0x02 bodyLen=26 complete=True
|
||||
<- raw (36B): a0 a0 a0 a0 00 03 21 02 00 1a 00 01 00 18 00 01 00 01 01 20 00 00 00 00 00 00 00 08 ac f2 10 00 90 a3 00 00
|
||||
blockCount=1
|
||||
block[0] blkLen=24 cmd=0x0120 rc=0 payloadLen=8 payload=ac f2 10 00 90 a3 00 00
|
||||
|
||||
--- timer-cycle-0x0120-t3 ---
|
||||
-> 40B req: a0 a0 a0 a0 00 01 21 01 00 1e 00 01 00 1c 00 01 00 01 01 20 00 00 00 03 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
|
||||
<- header: version=3 type=0x21 dir=0x02 bodyLen=26 complete=True
|
||||
<- raw (36B): a0 a0 a0 a0 00 03 21 02 00 1a 00 01 00 18 00 01 00 01 01 20 00 00 00 00 00 00 00 08 00 00 00 00 00 00 00 00
|
||||
blockCount=1
|
||||
block[0] blkLen=24 cmd=0x0120 rc=0 payloadLen=8 payload=00 00 00 00 00 00 00 00
|
||||
|
||||
--- svmeter-pair-0x0056-0x0089 ---
|
||||
-> 68B req: a0 a0 a0 a0 00 01 21 01 00 3a 00 02 00 1c 00 01 00 01 00 56 00 00 00 01 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 1c 00 01 00 01 00 89 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
|
||||
<- header: version=3 type=0x21 dir=0x02 bodyLen=318 complete=True
|
||||
<- raw (328B): a0 a0 a0 a0 00 03 21 02 01 3e 00 02 01 10 00 01 00 01 00 56 00 00 00 00 00 00 01 00 00 00 00 00 00 0a 00 00 00 00 00 32 00 0a 00 00 ff ff ff fd 00 0a 00 00 00 00 00 00 00 0a 00 00 00 00 00 00 00 0a 00 00 00 00 00 00 00 0a 00 00 00 00 00 00 00 0a 00 00 00 00 00 00 00 0a 00 00 00 00 00 00 00 0a 00 00 00 00 00 00 00 0a 00 00 00 00 00 00 00 0a 00 00 00 00 00 00 00 0a 00 00 00 00 00 00 00 0a 00 00 00 00 00 00 00 0a 00 00 00 00 00 00 00 0a 00 00 00 00 00 00 00 0a 00 00 00 00 00 00 00 0a 00 00 00 00 00 00 00 0a 00 00 00 00 00 00 00 0a 00 00 00 00 00 00 00 0a 00 00 00 00 00 00 00 0a 00 00 00 00 00 00 00 0a 00 00 00 00 00 00 00 0a 00 00 00 00 00 00 00 0a 00 00 00 00 00 00 00 0a 00 00 00 00 00 00 00 0a 00 00 00 00 00 00 00 0a 00 00 00 00 00 00 00 0a 00 00 00 00 00 00 00 0a 00 00 00 00 00 00 00 0a 00 00 00 00 00 00 00 0a 00 00 00 00 00 00 00 0a 00 00 00 2c 00 01 00 01 00 89 00 00 00 00 00 00 00 1c 58 00 00 00 59 00 00 00 5a 00 00 00 42 00 00 00 43 00 00 00 41 41 36 00 41 41 37 00
|
||||
blockCount=2
|
||||
block[0] blkLen=272 cmd=0x0056 rc=0 payloadLen=256 payload=00 00 00 00 00 0a 00 00 00 00 00 32 00 0a 00 00 ff ff ff fd 00 0a 00 00 00 00 00 00 00 0a 00 00 00 00 00 00 00 0a 00 00 00 00 00 00 00 0a 00 00 00 00 00 00 00 0a 00 00 00 00 00 00 00 0a 00 00 00 00 00 00 00 0a 00 00 00 00 00 00 00 0a 00 00 00 00 00 00 00 0a 00 00 00 00 00 00 00 0a 00 00 00 00 00 00 00 0a 00 00 00 00 00 00 00 0a 00 00 00 00 00 00 00 0a 00 00 00 00 00 00 00 0a 00 00 00 00 00 00 00 0a 00 00 00 00 00 00 00 0a 00 00 00 00 00 00 00 0a 00 00 00 00 00 00 00 0a 00 00 00 00 00 00 00 0a 00 00 00 00 00 00 00 0a 00 00 00 00 00 00 00 0a 00 00 00 00 00 00 00 0a 00 00 00 00 00 00 00 0a 00 00 00 00 00 00 00 0a 00 00 00 00 00 00 00 0a 00 00 00 00 00 00 00 0a 00 00 00 00 00 00 00 0a 00 00 00 00 00 00 00 0a 00 00 00 00 00 00 00 0a 00 00 00 00 00 00 00 0a 00 00
|
||||
block[1] blkLen=44 cmd=0x0089 rc=0 payloadLen=28 payload=58 00 00 00 59 00 00 00 5a 00 00 00 42 00 00 00 43 00 00 00 41 41 36 00 41 41 37 00
|
||||
|
||||
--- svmeter-alone-0x0056 ---
|
||||
-> 40B req: a0 a0 a0 a0 00 01 21 01 00 1e 00 01 00 1c 00 01 00 01 00 56 00 00 00 01 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
|
||||
<- header: version=3 type=0x21 dir=0x02 bodyLen=274 complete=True
|
||||
<- raw (284B): a0 a0 a0 a0 00 03 21 02 01 12 00 01 01 10 00 01 00 01 00 56 00 00 00 00 00 00 01 00 00 00 00 01 00 0a 00 00 00 00 00 32 00 0a 00 00 ff ff ff fd 00 0a 00 00 00 00 00 00 00 0a 00 00 00 00 00 00 00 0a 00 00 00 00 00 00 00 0a 00 00 00 00 00 00 00 0a 00 00 00 00 00 00 00 0a 0a 00 00 00 00 00 00 0a 0a 00 00 00 00 00 00 0a 0a 00 00 00 00 00 00 0a 0a 00 00 00 00 00 00 0a 0a 00 00 00 00 00 00 0a 0a 00 00 00 00 00 00 0a 0a 00 00 00 00 00 00 0a 0a 00 00 00 00 00 00 0a 0a 00 00 00 00 00 00 0a 0a 00 00 00 00 00 00 0a 0a 00 00 00 00 00 00 0a 0a 00 00 00 00 00 00 0a 0a 00 00 00 00 00 00 0a 0a 00 00 00 00 00 00 0a 0a 00 00 00 00 00 00 0a 0a 00 00 00 00 00 00 0a 0a 00 00 00 00 00 00 0a 0a 00 00 00 00 00 00 0a 0a 00 00 00 00 00 00 0a 0a 00 00 00 00 00 00 0a 0a 00 00 00 00 00 00 0a 0a 00 00 00 00 00 00 0a 0a 00 00 00 00 00 00 0a 0a 00 00 00 00 00 00 0a 0a 00
|
||||
blockCount=1
|
||||
block[0] blkLen=272 cmd=0x0056 rc=0 payloadLen=256 payload=00 00 00 01 00 0a 00 00 00 00 00 32 00 0a 00 00 ff ff ff fd 00 0a 00 00 00 00 00 00 00 0a 00 00 00 00 00 00 00 0a 00 00 00 00 00 00 00 0a 00 00 00 00 00 00 00 0a 00 00 00 00 00 00 00 0a 0a 00 00 00 00 00 00 0a 0a 00 00 00 00 00 00 0a 0a 00 00 00 00 00 00 0a 0a 00 00 00 00 00 00 0a 0a 00 00 00 00 00 00 0a 0a 00 00 00 00 00 00 0a 0a 00 00 00 00 00 00 0a 0a 00 00 00 00 00 00 0a 0a 00 00 00 00 00 00 0a 0a 00 00 00 00 00 00 0a 0a 00 00 00 00 00 00 0a 0a 00 00 00 00 00 00 0a 0a 00 00 00 00 00 00 0a 0a 00 00 00 00 00 00 0a 0a 00 00 00 00 00 00 0a 0a 00 00 00 00 00 00 0a 0a 00 00 00 00 00 00 0a 0a 00 00 00 00 00 00 0a 0a 00 00 00 00 00 00 0a 0a 00 00 00 00 00 00 0a 0a 00 00 00 00 00 00 0a 0a 00 00 00 00 00 00 0a 0a 00 00 00 00 00 00 0a 0a 00 00 00 00 00 00 0a 0a 00
|
||||
|
||||
--- pmcrng-R100-0x8001 ---
|
||||
-> 40B req: a0 a0 a0 a0 00 01 21 01 00 1e 00 01 00 1c 00 02 00 01 80 01 00 00 00 64 00 00 00 64 00 00 00 05 00 00 00 01 00 00 00 00
|
||||
<- header: version=3 type=0x21 dir=0x02 bodyLen=19 complete=True
|
||||
<- raw (29B): a0 a0 a0 a0 00 03 21 02 00 13 00 01 00 11 00 02 00 01 80 01 00 00 00 00 00 00 00 01 00
|
||||
blockCount=1
|
||||
block[0] blkLen=17 cmd=0x8001 rc=0 payloadLen=1 payload=00
|
||||
|
||||
--- pmcrng-R0-0x8001 ---
|
||||
-> 40B req: a0 a0 a0 a0 00 01 21 01 00 1e 00 01 00 1c 00 02 00 01 80 01 00 00 00 00 00 00 00 00 00 00 00 05 00 00 00 01 00 00 00 00
|
||||
<- header: version=3 type=0x21 dir=0x02 bodyLen=19 complete=True
|
||||
<- raw (29B): a0 a0 a0 a0 00 03 21 02 00 13 00 01 00 11 00 02 00 01 80 01 00 00 00 00 00 00 00 01 00
|
||||
blockCount=1
|
||||
block[0] blkLen=17 cmd=0x8001 rc=0 payloadLen=1 payload=00
|
||||
|
||||
--- param-1320-0x000e ---
|
||||
-> 40B req: a0 a0 a0 a0 00 01 21 01 00 1e 00 01 00 1c 00 01 00 01 00 0e 00 00 05 28 00 00 05 28 00 00 00 00 00 00 00 00 00 00 00 00
|
||||
<- header: version=3 type=0x21 dir=0x02 bodyLen=18 complete=True
|
||||
<- raw (28B): a0 a0 a0 a0 00 03 21 02 00 12 00 01 00 10 00 01 00 01 00 0e 00 01 00 00 00 00 00 00
|
||||
blockCount=1
|
||||
block[0] blkLen=16 cmd=0x000e rc=1 payloadLen=0 payload=(empty)
|
||||
|
||||
--- param-100-0x000e ---
|
||||
-> 40B req: a0 a0 a0 a0 00 01 21 01 00 1e 00 01 00 1c 00 01 00 01 00 0e 00 00 00 64 00 00 00 64 00 00 00 00 00 00 00 00 00 00 00 00
|
||||
<- header: version=3 type=0x21 dir=0x02 bodyLen=18 complete=True
|
||||
<- raw (28B): a0 a0 a0 a0 00 03 21 02 00 12 00 01 00 10 00 01 00 01 00 0e 00 01 00 00 00 00 00 00
|
||||
blockCount=1
|
||||
block[0] blkLen=16 cmd=0x000e rc=1 payloadLen=0 payload=(empty)
|
||||
|
||||
--- alarms-0x0023 ---
|
||||
-> 40B req: a0 a0 a0 a0 00 01 21 01 00 1e 00 01 00 1c 00 01 00 01 00 23 ff ff ff ff 00 00 00 20 00 00 00 02 00 00 00 40 00 00 00 00
|
||||
<- header: version=3 type=0x21 dir=0x02 bodyLen=18 complete=True
|
||||
<- raw (28B): a0 a0 a0 a0 00 03 21 02 00 12 00 01 00 10 00 01 00 01 00 23 00 00 00 00 00 00 00 00
|
||||
blockCount=1
|
||||
block[0] blkLen=16 cmd=0x0023 rc=0 payloadLen=0 payload=(empty)
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
@@ -7,17 +7,16 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for <see cref="FocasDriverProbe"/>. Covers the offline-determinable failure
|
||||
/// paths (invalid JSON, missing host/port, unreachable closed port) plus the degrade path:
|
||||
/// on a host with no FANUC FWLIB native library present (this dev box / CI Linux containers),
|
||||
/// the <c>cnc_allclibhndl3</c> P/Invoke throws <see cref="DllNotFoundException"/> at JIT bind
|
||||
/// time, so a TCP-reachable target must still report <c>Ok=true</c> with a "FWLIB absent"
|
||||
/// note — never worse than the pre-Phase-5 TCP-only probe.
|
||||
/// paths (invalid JSON, missing host/port, unreachable closed port) plus the Phase-8
|
||||
/// truthfulness behaviour: a TCP-reachable endpoint that is NOT a FOCAS CNC (a bare listener)
|
||||
/// must report <c>Ok=false</c>, because the probe now completes a real <c>FocasWireClient</c>
|
||||
/// session (initiate handshake + <c>cnc_statinfo</c>) rather than degrading to "TCP
|
||||
/// reachability only" when FWLIB is absent.
|
||||
/// <para>
|
||||
/// <b>Live-verify DEFERRED.</b> The happy path (a real CNC answers <c>cnc_allclibhndl3</c>
|
||||
/// with <c>EW_OK</c> → "FOCAS handle OK") and the CNC-error path (FWLIB present but the
|
||||
/// remote returns e.g. <c>EW_SOCKET</c>/<c>EW_PROTOCOL</c> → "FOCAS handshake failed:
|
||||
/// focas_rc=...") cannot run on this rig: there is neither a FANUC CNC nor the FWLIB native
|
||||
/// library available. Those two paths are verified manually against a real Windows+FWLIB host.
|
||||
/// <b>Live-verify DEFERRED.</b> The happy path (a real CNC completes the handshake + read →
|
||||
/// "FOCAS session OK") cannot run on this rig — there is no FANUC CNC available at unit-test
|
||||
/// time. It is verified against the live 31i-B at <c>10.201.31.5</c> (see the implementation
|
||||
/// plan's deploy/validate step).
|
||||
/// </para>
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
@@ -71,9 +70,10 @@ public sealed class FocasDriverProbeTests
|
||||
[Fact]
|
||||
public async Task MalformedHostAddress_Returns_OkFalse_WithNoHostPortMessage()
|
||||
{
|
||||
// "not-a-focas-url" is not a focas:// URL — TryParse returns null.
|
||||
// A foreign URI scheme ("http://…") is rejected by TryParse → null. (A bare
|
||||
// "{ip}[:{port}]" without a scheme is now tolerated, so it can't be the malformed case.)
|
||||
var result = await Probe.ProbeAsync(
|
||||
"{\"devices\":[{\"hostAddress\":\"not-a-focas-url\"}]}",
|
||||
"{\"devices\":[{\"hostAddress\":\"http://10.0.0.5/\"}]}",
|
||||
TimeSpan.FromSeconds(3),
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
@@ -118,32 +118,32 @@ public sealed class FocasDriverProbeTests
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// 4. Degrade path — TCP reachable, FWLIB absent (the key test)
|
||||
// 4. TCP reachable but not a CNC — wire-session probe must say Ok=false (Phase 8)
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/// <summary>
|
||||
/// Against an in-process <see cref="TcpListener"/> that accepts the connection, the TCP
|
||||
/// preflight succeeds. On this box the FANUC FWLIB native library is absent, so the
|
||||
/// <c>cnc_allclibhndl3</c> P/Invoke throws <see cref="DllNotFoundException"/> (or a
|
||||
/// related load failure). The probe MUST degrade gracefully — return <c>Ok=true</c> with
|
||||
/// a "FWLIB absent ... TCP reachability only" note — proving no regression versus the
|
||||
/// pre-Phase-5 TCP-only probe on FWLIB-less hosts.
|
||||
/// Against an in-process <see cref="TcpListener"/> that accepts the connection but speaks no
|
||||
/// FOCAS (drops each accepted socket), the TCP preflight succeeds but the Phase-2 wire
|
||||
/// session can't complete the initiate handshake + <c>cnc_statinfo</c> read. The probe MUST
|
||||
/// report <c>Ok=false</c> — a bare TCP listener is not a CNC. This is the Phase-8 fix: the
|
||||
/// old probe degraded such a listener to <c>Ok=true</c> "FWLIB absent, TCP reachability
|
||||
/// only", which made any TCP listener look HEALTHY.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task TcpReachable_FwlibAbsent_Degrades_To_OkTrue_WithReachabilityNote()
|
||||
public async Task TcpReachable_NotACnc_Returns_OkFalse()
|
||||
{
|
||||
// Accept-only listener: completes the TCP handshake but speaks no FOCAS bytes.
|
||||
var listener = new TcpListener(IPAddress.Loopback, 0);
|
||||
listener.Start();
|
||||
var port = ((IPEndPoint)listener.LocalEndpoint).Port;
|
||||
// Keep accepting so the connect always completes; ignore the accepted socket.
|
||||
// Keep accepting so the connect always completes; drop the accepted socket.
|
||||
_ = AcceptLoopAsync(listener, TestContext.Current.CancellationToken);
|
||||
|
||||
try
|
||||
{
|
||||
using var cts = CancellationTokenSource.CreateLinkedTokenSource(
|
||||
TestContext.Current.CancellationToken);
|
||||
cts.CancelAfter(TimeSpan.FromSeconds(5));
|
||||
cts.CancelAfter(TimeSpan.FromSeconds(15));
|
||||
|
||||
var configJson = $"{{\"devices\":[{{\"hostAddress\":\"focas://127.0.0.1:{port}\"}}]}}";
|
||||
var result = await Probe.ProbeAsync(
|
||||
@@ -151,13 +151,11 @@ public sealed class FocasDriverProbeTests
|
||||
TimeSpan.FromSeconds(3),
|
||||
cts.Token);
|
||||
|
||||
// No FWLIB here → degrade, never worse than TCP-only.
|
||||
result.Ok.ShouldBeTrue(
|
||||
$"Expected degrade to Ok=true on an FWLIB-less host but got: {result.Message}");
|
||||
// A bare listener is not a CNC — the FOCAS session fails, so the probe is NOT ok.
|
||||
result.Ok.ShouldBeFalse(
|
||||
$"Expected Ok=false for a non-CNC TCP listener but got: {result.Message}");
|
||||
result.Message.ShouldNotBeNull();
|
||||
result.Message!.ShouldContain("FWLIB absent");
|
||||
result.Message!.ShouldContain("TCP reachability only");
|
||||
result.Latency.ShouldNotBeNull();
|
||||
result.Latency.ShouldBeNull();
|
||||
}
|
||||
finally
|
||||
{
|
||||
|
||||
@@ -38,6 +38,25 @@ public sealed class FocasFactoryConfigTests
|
||||
drv.Options.FixedTree.TimerPollInterval.ShouldBe(TimeSpan.FromSeconds(30));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The AdminUI persists FocasCncSeries as its integer value (e.g. <c>"series":6</c> = Thirty_i) —
|
||||
/// a bare JSON number. The factory must tolerate it (via FlexibleStringConverter) and build the
|
||||
/// real driver, not throw + fall back to a stub. Regression for the 2026-06-26 wonder data-plane
|
||||
/// deploy where the driver stubbed on "Cannot get the value of a token type 'Number' as a string".
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void CreateInstance_accepts_numeric_Series_from_AdminUI_serialization()
|
||||
{
|
||||
const string json = """
|
||||
{"Backend":"wire","series":6,"devices":[{"hostAddress":"10.0.0.5:8193","deviceName":"Makino","series":6,"positionDecimalPlaces":0}]}
|
||||
""";
|
||||
|
||||
var drv = FocasDriverFactoryExtensions.CreateInstance("drv-1", json);
|
||||
|
||||
drv.Options.Devices.ShouldHaveSingleItem();
|
||||
drv.Options.Devices[0].Series.ShouldBe(FocasCncSeries.Thirty_i);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the AlarmProjection configuration section is mapped to driver options.</summary>
|
||||
[Fact]
|
||||
public void CreateInstance_maps_AlarmProjection_section_onto_options()
|
||||
|
||||
@@ -0,0 +1,207 @@
|
||||
using System.Diagnostics;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Coverage for the FOCAS data-plane fix (2026-06-25 equipment-tag investigation): all wire I/O
|
||||
/// on a device's single FOCAS/2 socket must be serialized (request→response cannot interleave)
|
||||
/// and every steady-state read/write must be time-bounded so a stalled CNC read surfaces as a
|
||||
/// recoverable error instead of hanging forever at BadWaitingForInitialData. See
|
||||
/// <c>docs/plans/2026-06-25-otopcua-equipment-dataplane-investigation.md</c>.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class FocasIoSerializationTests
|
||||
{
|
||||
private static readonly FocasAddress Macro500 = new(FocasAreaKind.Macro, null, 500, null);
|
||||
|
||||
// ---- SynchronizedFocasClient: serialization ----
|
||||
|
||||
[Fact]
|
||||
public async Task Concurrent_reads_are_serialized_onto_the_inner_client()
|
||||
{
|
||||
var inner = new RecordingClient { ReadDelay = TimeSpan.FromMilliseconds(20) };
|
||||
await using var _ = NoopDispose(inner);
|
||||
var client = new SynchronizedFocasClient(inner, TimeSpan.FromSeconds(5));
|
||||
|
||||
var reads = Enumerable.Range(0, 8)
|
||||
.Select(_ => client.ReadAsync(Macro500, FocasDataType.Float64, CancellationToken.None));
|
||||
await Task.WhenAll(reads);
|
||||
|
||||
inner.MaxConcurrency.ShouldBe(1); // never more than one wire op on the socket at a time
|
||||
inner.ReadCount.ShouldBe(8);
|
||||
}
|
||||
|
||||
// ---- SynchronizedFocasClient: per-call timeout ----
|
||||
|
||||
[Fact]
|
||||
public async Task A_hung_read_is_bounded_by_the_call_timeout()
|
||||
{
|
||||
var inner = new RecordingClient { BlockReadUntilCancelled = true };
|
||||
var client = new SynchronizedFocasClient(inner, TimeSpan.FromMilliseconds(100));
|
||||
|
||||
var sw = Stopwatch.StartNew();
|
||||
await Should.ThrowAsync<OperationCanceledException>(
|
||||
() => client.ReadAsync(Macro500, FocasDataType.Float64, CancellationToken.None));
|
||||
sw.Stop();
|
||||
|
||||
sw.Elapsed.ShouldBeLessThan(TimeSpan.FromSeconds(2)); // bounded, not the indefinite OS TCP wait
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_hung_read_does_not_hold_the_socket_for_the_next_call()
|
||||
{
|
||||
// The gate must be released when a bounded call times out, otherwise one stall would wedge
|
||||
// every subsequent op on the device. Read #1 hangs (times out); read #2 must still proceed.
|
||||
var inner = new TimeoutThenServeClient { FirstCallBlocks = true };
|
||||
var client = new SynchronizedFocasClient(inner, TimeSpan.FromMilliseconds(100));
|
||||
|
||||
await Should.ThrowAsync<OperationCanceledException>(
|
||||
() => client.ReadAsync(Macro500, FocasDataType.Float64, CancellationToken.None));
|
||||
|
||||
var (value, status) = await client.ReadAsync(Macro500, FocasDataType.Float64, CancellationToken.None);
|
||||
status.ShouldBe(FocasStatusMapper.Good);
|
||||
value.ShouldBe(42);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Probe_is_not_bounded_by_the_call_timeout()
|
||||
{
|
||||
// Connect/Probe carry their own budgets; the decorator must not shrink them to its read budget.
|
||||
var inner = new RecordingClient { ProbeDelay = TimeSpan.FromMilliseconds(200) };
|
||||
var client = new SynchronizedFocasClient(inner, TimeSpan.FromMilliseconds(50));
|
||||
|
||||
var result = await client.ProbeAsync(CancellationToken.None);
|
||||
|
||||
result.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Zero_call_timeout_disables_the_per_call_bound()
|
||||
{
|
||||
var inner = new RecordingClient { ReadDelay = TimeSpan.FromMilliseconds(120) };
|
||||
var client = new SynchronizedFocasClient(inner, TimeSpan.Zero);
|
||||
|
||||
var (value, status) = await client.ReadAsync(Macro500, FocasDataType.Float64, CancellationToken.None);
|
||||
|
||||
status.ShouldBe(FocasStatusMapper.Good);
|
||||
value.ShouldBe(42);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Dispose_disposes_the_inner_client()
|
||||
{
|
||||
var inner = new RecordingClient();
|
||||
var client = new SynchronizedFocasClient(inner, TimeSpan.FromSeconds(1));
|
||||
|
||||
client.Dispose();
|
||||
|
||||
inner.DisposeCount.ShouldBe(1);
|
||||
}
|
||||
|
||||
// ---- Driver level: a timed-out read overwrites the seed with a recoverable status ----
|
||||
|
||||
[Fact]
|
||||
public async Task Driver_read_that_times_out_returns_BadCommunicationError_not_a_hang()
|
||||
{
|
||||
var factory = new FakeFocasClientFactory { Customise = () => new RecordingClient { BlockReadUntilCancelled = true } };
|
||||
var drv = new FocasDriver(new FocasDriverOptions
|
||||
{
|
||||
Devices = [new FocasDeviceOptions("focas://10.0.0.5:8193")],
|
||||
Tags = [new FocasTagDefinition("CustomVar", "focas://10.0.0.5:8193", "MACRO:500", FocasDataType.Float64)],
|
||||
Probe = new FocasProbeOptions { Enabled = false },
|
||||
Timeout = TimeSpan.FromMilliseconds(150),
|
||||
}, "drv-1", factory);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
var sw = Stopwatch.StartNew();
|
||||
var snap = (await drv.ReadAsync(["CustomVar"], CancellationToken.None)).Single();
|
||||
sw.Stop();
|
||||
|
||||
snap.StatusCode.ShouldBe(FocasStatusMapper.BadCommunicationError);
|
||||
sw.Elapsed.ShouldBeLessThan(TimeSpan.FromSeconds(2)); // bounded by Timeout, not hung
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Driver_read_does_not_propagate_a_call_timeout_as_cancellation()
|
||||
{
|
||||
// The per-call timeout must NOT bubble out of ReadAsync as OperationCanceledException — that
|
||||
// would abort the whole poll batch. It must be caught and turned into a per-tag Bad status.
|
||||
var factory = new FakeFocasClientFactory { Customise = () => new RecordingClient { BlockReadUntilCancelled = true } };
|
||||
var drv = new FocasDriver(new FocasDriverOptions
|
||||
{
|
||||
Devices = [new FocasDeviceOptions("focas://10.0.0.5:8193")],
|
||||
Tags = [new FocasTagDefinition("CustomVar", "focas://10.0.0.5:8193", "MACRO:500", FocasDataType.Float64)],
|
||||
Probe = new FocasProbeOptions { Enabled = false },
|
||||
Timeout = TimeSpan.FromMilliseconds(120),
|
||||
}, "drv-1", factory);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
// Should complete (not throw) with a Bad snapshot, even though the caller's token is never cancelled.
|
||||
var snaps = await drv.ReadAsync(["CustomVar"], CancellationToken.None);
|
||||
snaps.Single().StatusCode.ShouldBe(FocasStatusMapper.BadCommunicationError);
|
||||
}
|
||||
|
||||
private static DisposeGuard NoopDispose(IDisposable d) => new(d);
|
||||
|
||||
private sealed class DisposeGuard(IDisposable inner) : IAsyncDisposable
|
||||
{
|
||||
public ValueTask DisposeAsync() { inner.Dispose(); return ValueTask.CompletedTask; }
|
||||
}
|
||||
|
||||
/// <summary>Fake that records concurrency + optionally delays/blocks reads and probes.</summary>
|
||||
private class RecordingClient : FakeFocasClient
|
||||
{
|
||||
private int _current;
|
||||
public int MaxConcurrency;
|
||||
public int ReadCount;
|
||||
public TimeSpan ReadDelay = TimeSpan.Zero;
|
||||
public bool BlockReadUntilCancelled;
|
||||
public TimeSpan ProbeDelay = TimeSpan.Zero;
|
||||
|
||||
public override async Task<(object? value, uint status)> ReadAsync(
|
||||
FocasAddress address, FocasDataType type, CancellationToken ct)
|
||||
{
|
||||
Interlocked.Increment(ref ReadCount);
|
||||
var observed = Interlocked.Increment(ref _current);
|
||||
InterlockedMax(ref MaxConcurrency, observed);
|
||||
try
|
||||
{
|
||||
if (BlockReadUntilCancelled) await Task.Delay(Timeout.Infinite, ct).ConfigureAwait(false);
|
||||
else if (ReadDelay > TimeSpan.Zero) await Task.Delay(ReadDelay, ct).ConfigureAwait(false);
|
||||
return ((object?)42, FocasStatusMapper.Good);
|
||||
}
|
||||
finally { Interlocked.Decrement(ref _current); }
|
||||
}
|
||||
|
||||
public override async Task<bool> ProbeAsync(CancellationToken ct)
|
||||
{
|
||||
if (ProbeDelay > TimeSpan.Zero) await Task.Delay(ProbeDelay, ct).ConfigureAwait(false);
|
||||
return true;
|
||||
}
|
||||
|
||||
private static void InterlockedMax(ref int target, int value)
|
||||
{
|
||||
int seen;
|
||||
do { seen = Volatile.Read(ref target); if (value <= seen) return; }
|
||||
while (Interlocked.CompareExchange(ref target, value, seen) != seen);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>First read blocks until cancelled; subsequent reads serve a Good value immediately.</summary>
|
||||
private sealed class TimeoutThenServeClient : FakeFocasClient
|
||||
{
|
||||
public bool FirstCallBlocks;
|
||||
private int _calls;
|
||||
|
||||
public override async Task<(object? value, uint status)> ReadAsync(
|
||||
FocasAddress address, FocasDataType type, CancellationToken ct)
|
||||
{
|
||||
var n = Interlocked.Increment(ref _calls);
|
||||
if (n == 1 && FirstCallBlocks) await Task.Delay(Timeout.Infinite, ct).ConfigureAwait(false);
|
||||
return ((object?)42, FocasStatusMapper.Good);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -245,6 +245,32 @@ public sealed class FocasReadWriteTests
|
||||
/// <summary>Verifies that cancellation signals are propagated.</summary>
|
||||
[Fact]
|
||||
public async Task Cancellation_propagates()
|
||||
{
|
||||
var (drv, factory) = NewDriver(
|
||||
new FocasTagDefinition("X", "focas://10.0.0.5:8193", "R100", FocasDataType.Byte));
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
using var cts = new CancellationTokenSource();
|
||||
cts.Cancel();
|
||||
factory.Customise = () => new FakeFocasClient
|
||||
{
|
||||
ThrowOnRead = true,
|
||||
Exception = new OperationCanceledException(cts.Token),
|
||||
};
|
||||
|
||||
// A CANCELLATION of the caller's token must propagate (abort the read). This is distinct
|
||||
// from a per-call timeout — an OCE raised while the caller's token is still live is swallowed
|
||||
// to a per-tag BadCommunicationError (see Swallows_a_spurious_read_OCE_when_caller_not_cancelled).
|
||||
await Should.ThrowAsync<OperationCanceledException>(
|
||||
() => drv.ReadAsync(["X"], cts.Token));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// An OperationCanceledException from the wire read while the CALLER'S token is NOT cancelled
|
||||
/// (e.g. a per-call timeout firing) must be turned into a per-tag BadCommunicationError, not
|
||||
/// propagated — otherwise one stalled tag would abort the whole poll batch.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Swallows_a_spurious_read_OCE_when_caller_not_cancelled()
|
||||
{
|
||||
var (drv, factory) = NewDriver(
|
||||
new FocasTagDefinition("X", "focas://10.0.0.5:8193", "R100", FocasDataType.Byte));
|
||||
@@ -255,8 +281,8 @@ public sealed class FocasReadWriteTests
|
||||
Exception = new OperationCanceledException(),
|
||||
};
|
||||
|
||||
await Should.ThrowAsync<OperationCanceledException>(
|
||||
() => drv.ReadAsync(["X"], CancellationToken.None));
|
||||
var snap = (await drv.ReadAsync(["X"], CancellationToken.None)).Single();
|
||||
snap.StatusCode.ShouldBe(FocasStatusMapper.BadCommunicationError);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that ShutdownAsync disposes the client.</summary>
|
||||
|
||||
@@ -20,6 +20,9 @@ public sealed class FocasScaffoldingTests
|
||||
[InlineData("focas://cnc-01.factory.internal:8193", "cnc-01.factory.internal", 8193)]
|
||||
[InlineData("focas://10.0.0.5:12345", "10.0.0.5", 12345)]
|
||||
[InlineData("FOCAS://10.0.0.5:8193", "10.0.0.5", 8193)] // case-insensitive scheme
|
||||
[InlineData("10.201.31.5:8193", "10.201.31.5", 8193)] // scheme-less (AdminUI-persisted form)
|
||||
[InlineData("10.0.0.5", "10.0.0.5", 8193)] // scheme-less, default port
|
||||
[InlineData("cnc-01.factory.internal:8193", "cnc-01.factory.internal", 8193)] // scheme-less hostname
|
||||
public void HostAddress_parses_valid(string input, string host, int port)
|
||||
{
|
||||
var parsed = FocasHostAddress.TryParse(input);
|
||||
@@ -224,9 +227,11 @@ public sealed class FocasScaffoldingTests
|
||||
[Fact]
|
||||
public async Task InitializeAsync_malformed_address_faults()
|
||||
{
|
||||
// A non-focas:// URI scheme is rejected by TryParse (a bare "{ip}[:{port}]" is now
|
||||
// tolerated, so the malformed case must carry a foreign scheme).
|
||||
var drv = new FocasDriver(new FocasDriverOptions
|
||||
{
|
||||
Devices = [new FocasDeviceOptions("not-an-address")],
|
||||
Devices = [new FocasDeviceOptions("http://10.0.0.5/")],
|
||||
}, "drv-1");
|
||||
|
||||
await Should.ThrowAsync<InvalidOperationException>(
|
||||
|
||||
@@ -107,6 +107,36 @@ public sealed class FocasWireProtocolTests
|
||||
finally { client.Dispose(); server.Dispose(); }
|
||||
}
|
||||
|
||||
// The 10-byte header framing is identical across supported versions (only the version field
|
||||
// differs) — older controls + the mock answer v1, modern controls answer v3 (FANUC 30i-B).
|
||||
// Validated live 2026-06-25; see docs/plans/2026-06-25-focas-pdu-v3-30i-b-support.md.
|
||||
[Theory]
|
||||
[InlineData((ushort)1)]
|
||||
[InlineData((ushort)3)]
|
||||
public async Task ReadPduAsync_accepts_supported_version(ushort version)
|
||||
{
|
||||
var body = new byte[] { 9, 8, 7 };
|
||||
var pdu = new byte[10 + body.Length];
|
||||
new byte[] { 0xa0, 0xa0, 0xa0, 0xa0 }.CopyTo(pdu, 0);
|
||||
BinaryPrimitives.WriteUInt16BigEndian(pdu.AsSpan(4, 2), version);
|
||||
pdu[6] = FocasWireProtocol.TypeData;
|
||||
pdu[7] = FocasWireProtocol.DirectionResponse;
|
||||
BinaryPrimitives.WriteUInt16BigEndian(pdu.AsSpan(8, 2), (ushort)body.Length);
|
||||
body.CopyTo(pdu.AsSpan(10));
|
||||
|
||||
var (client, server) = await ConnectedPairAsync();
|
||||
try
|
||||
{
|
||||
await server.GetStream().WriteAsync(pdu);
|
||||
var read = await FocasWireProtocol.ReadPduAsync(client.GetStream(), CancellationToken.None);
|
||||
|
||||
read.Type.ShouldBe(FocasWireProtocol.TypeData);
|
||||
read.Direction.ShouldBe(FocasWireProtocol.DirectionResponse);
|
||||
read.Body.ShouldBe(body);
|
||||
}
|
||||
finally { client.Dispose(); server.Dispose(); }
|
||||
}
|
||||
|
||||
// ---- BuildRequestBody framing ----
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
using System.Buffers.Binary;
|
||||
using System.Diagnostics;
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Wire;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Byte-level coverage for the FOCAS PDU-v3 fixes derived from a live FANUC 31i-B capture
|
||||
/// (2026-06-25). The fixtures under <c>Fixtures/v3/</c> are the raw responses; the specific
|
||||
/// payload bytes are inlined here so the tests stay hermetic. See
|
||||
/// <c>docs/plans/2026-06-25-focas-pdu-v3-30i-b-support.md</c> +
|
||||
/// <c>docs/plans/2026-06-25-focas-pdu-v3-implementation-plan.md</c>.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class FocasWireV3Tests
|
||||
{
|
||||
// ---- cnc_rdtimer (0x0120): little-endian {minute, msec} ----
|
||||
|
||||
// Captured cutting-time (type 2) payload from the live 31i-B.
|
||||
private static readonly byte[] CuttingTimerPayload = [0xac, 0xf2, 0x10, 0x00, 0x90, 0xa3, 0x00, 0x00];
|
||||
|
||||
[Fact]
|
||||
public void ParseTimer_decodes_the_live_payload_as_little_endian_minute_and_msec()
|
||||
{
|
||||
var timer = FocasWireClient.ParseTimer(2, CuttingTimerPayload);
|
||||
|
||||
timer.Type.ShouldBe((short)2);
|
||||
timer.Minutes.ShouldBe(1_110_700); // 0x0010F2AC little-endian
|
||||
timer.Milliseconds.ShouldBe(41_872); // 0x0000A390 little-endian
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseTimer_little_endian_is_the_only_decode_with_an_in_range_msec()
|
||||
{
|
||||
// The whole point: under big-endian the msec field is nonsensical (~2.4e9), so it cannot be
|
||||
// the "fractional milliseconds 0..59999" field the model documents. Little-endian is in range.
|
||||
var beMsec = BinaryPrimitives.ReadUInt32BigEndian(CuttingTimerPayload.AsSpan(4, 4));
|
||||
beMsec.ShouldBeGreaterThan(59_999u);
|
||||
|
||||
var timer = FocasWireClient.ParseTimer(2, CuttingTimerPayload);
|
||||
timer.Milliseconds.ShouldBeInRange(0, 59_999);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseTimer_handles_a_short_payload_without_throwing()
|
||||
{
|
||||
var timer = FocasWireClient.ParseTimer(0, []);
|
||||
timer.Minutes.ShouldBe(0);
|
||||
timer.Milliseconds.ShouldBe(0);
|
||||
}
|
||||
|
||||
// ---- pmc_rdpmcrng (0x8001): byte width + range decode ----
|
||||
|
||||
[Theory]
|
||||
[InlineData((short)0, 1)] // Byte
|
||||
[InlineData((short)1, 2)] // Word
|
||||
[InlineData((short)2, 4)] // Long
|
||||
[InlineData((short)4, 4)] // Real
|
||||
[InlineData((short)5, 8)] // Double
|
||||
[InlineData((short)99, 1)] // unknown → 1
|
||||
public void PmcByteWidth_maps_focas_datatype_codes(short dataType, int expectedWidth)
|
||||
{
|
||||
FocasWireClient.PmcByteWidth(dataType).ShouldBe(expectedWidth);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParsePmcRange_decodes_a_two_byte_word_into_one_value()
|
||||
{
|
||||
// A Word read of R100 must request bytes 100..101 (2 bytes); the CNC then returns 2 bytes.
|
||||
var range = FocasWireClient.ParsePmcRange(area: 5, dataType: 1, start: 100, end: 101, payload: [0x00, 0x64]);
|
||||
range.Values.Count.ShouldBe(1);
|
||||
range.Values[0].ShouldBe(100L); // 0x0064 big-endian
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParsePmcRange_with_a_single_byte_for_a_word_yields_no_value()
|
||||
{
|
||||
// This is the pre-fix bug: requesting end==start for a Word returned 1 byte, the 2-byte
|
||||
// slot never completed, so the value list was empty → WireFocasClient mapped it BadOutOfRange.
|
||||
var range = FocasWireClient.ParsePmcRange(area: 5, dataType: 1, start: 100, end: 100, payload: [0x00]);
|
||||
range.Values.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParsePmcRange_decodes_a_single_byte_value()
|
||||
{
|
||||
var range = FocasWireClient.ParsePmcRange(area: 5, dataType: 0, start: 0, end: 0, payload: [0x07]);
|
||||
range.Values.Count.ShouldBe(1);
|
||||
range.Values[0].ShouldBe(7L);
|
||||
}
|
||||
|
||||
// ---- cnc_rdsvmeter: 8-byte LOADELM stride + axis-name correlation ----
|
||||
|
||||
[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.
|
||||
byte[] sv =
|
||||
[
|
||||
0x00, 0x00, 0x00, 0x00, 0x00, 0x0a, 0x00, 0x00, // axis 1: data=0
|
||||
0x00, 0x00, 0x00, 0x32, 0x00, 0x0a, 0x00, 0x00, // axis 2: data=50
|
||||
0xff, 0xff, 0xff, 0xfd, 0x00, 0x0a, 0x00, 0x00, // axis 3: data=-3
|
||||
];
|
||||
// 0x0089 axis-name block: 4-byte records X, Y, Z.
|
||||
byte[] names =
|
||||
[
|
||||
0x58, 0x00, 0x00, 0x00, // "X"
|
||||
0x59, 0x00, 0x00, 0x00, // "Y"
|
||||
0x5a, 0x00, 0x00, 0x00, // "Z"
|
||||
];
|
||||
|
||||
var meters = FocasWireClient.ParseServoMeters(sv, names, maxCount: 32);
|
||||
|
||||
meters.Count.ShouldBe(3);
|
||||
meters[0].Name.ShouldBe("X");
|
||||
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[2].Name.ShouldBe("Z");
|
||||
meters[2].Value.ShouldBe(-3);
|
||||
}
|
||||
|
||||
// ---- cnc_rddynamic2: 1-based axis guard ----
|
||||
|
||||
[Theory]
|
||||
[InlineData(0)]
|
||||
[InlineData(-1)]
|
||||
public async Task ReadDynamicAsync_rejects_a_non_positive_axis_index(int axisIndex)
|
||||
{
|
||||
using var client = new WireFocasClient();
|
||||
await Should.ThrowAsync<ArgumentOutOfRangeException>(async () =>
|
||||
await client.ReadDynamicAsync(axisIndex, CancellationToken.None));
|
||||
}
|
||||
|
||||
// ---- read-stall hardening: a stalled peer must not wedge the poll loop ----
|
||||
|
||||
[Fact]
|
||||
public async Task ReadPduAsync_aborts_a_stalled_peer_within_the_cancellation_budget()
|
||||
{
|
||||
var (client, server) = await ConnectedPairAsync();
|
||||
try
|
||||
{
|
||||
// Send only 5 of the 10 header bytes, then stall forever — the cnc_rdsvmeter hang shape.
|
||||
await server.GetStream().WriteAsync(new byte[] { 0xa0, 0xa0, 0xa0, 0xa0, 0x00 });
|
||||
|
||||
using var cts = new CancellationTokenSource(TimeSpan.FromMilliseconds(300));
|
||||
var sw = Stopwatch.StartNew();
|
||||
await Should.ThrowAsync<OperationCanceledException>(async () =>
|
||||
await FocasWireProtocol.ReadPduAsync(client.GetStream(), cts.Token));
|
||||
sw.Stop();
|
||||
|
||||
// Must abort near the 300ms budget, not hang — generous ceiling for CI jitter.
|
||||
sw.Elapsed.ShouldBeLessThan(TimeSpan.FromSeconds(5));
|
||||
}
|
||||
finally { client.Dispose(); server.Dispose(); }
|
||||
}
|
||||
|
||||
private static async Task<(TcpClient client, TcpClient server)> ConnectedPairAsync()
|
||||
{
|
||||
var listener = new TcpListener(IPAddress.Loopback, 0);
|
||||
listener.Start();
|
||||
try
|
||||
{
|
||||
var port = ((IPEndPoint)listener.LocalEndpoint).Port;
|
||||
var client = new TcpClient();
|
||||
var connect = client.ConnectAsync(IPAddress.Loopback, port);
|
||||
var server = await listener.AcceptTcpClientAsync();
|
||||
await connect;
|
||||
return (client, server);
|
||||
}
|
||||
finally { listener.Stop(); }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user