b61fe38823
Authoring FOCAS tags through /raw -> Browse device... produced tags the driver
could never read. Every committed tag read BadNodeIdUnknown forever, and the
failure was silent end to end: the AdminUI accepted it, the deploy gate sealed
green, and the driver reported Healthy with 0 errors while ignoring the tags.
Net effect: the whole fixed-tree surface could not be published through any v3
authoring path. Found cutting wonder-app-vd03 over to v3 against a real Makino
Pro 5 / Fanuc 30i, where 0 of 20 committed tags read while two hand-authored
MACRO: tags on the same device read Good.
A captured browse leaf's id IS its DriverAttributeInfo.FullName, and the fixed
tree emitted "{deviceHost}/{path}" there -- an internal cache key, not an
address. Both authoring paths wrote it verbatim into TagConfig.address, and
FocasAddress.TryParse accepts no such form, so the driver skipped the tags at
init.
Fixed at the emitter rather than by stripping the host at commit, which fixes
the browse-commit flow and the manual picker's tree-select together and leaves
one canonical address form:
- FocasAddress gains a fourth kind, FIXED:<path>, and moves to Contracts so the
AdminUI and ControlPlane can parse without referencing the runtime driver
(#526). A FIXED: read is served from the fixed-tree snapshot the poll loop
already maintains -- no area read, no connection. Writes refuse: nothing in
that tree is a writable location on the CNC.
- The address carries no device. A tag's device comes from its Device row, and
a second routing channel for one fact is what created this defect.
- An authored FIXED: tag arms the fixed-tree poll loop by itself. Enabled
defaults false and the browser force-enables it only for a browse, so the
operator would otherwise browse, commit, deploy, and land on a driver whose
loop never starts -- every tag Bad with a correct address.
- Fixed-tree values are now dated from the last answered poll and go
BadCommunicationError once it is stale (8 cadences, floored at 30s). The poll
loop swallows a failed tick by design, so a dead CNC would otherwise serve its
last snapshot as Good indefinitely -- harmless while nothing could bind to the
tree, not harmless now.
Guardrails, so this class cannot recur silently:
- FocasTagConfigModel.Validate() runs the real parser instead of a non-empty
check, and FocasTagDefinitionFactory.Inspect() warns at the deploy gate, which
covers CSV/API config that never touches the modal.
- The manual picker offered axis/spindle/program/status "parameter groups" --
none of which is a FOCAS addressing space -- and its own default value,
axis:5, was unusable. It now composes PMC / PARAM: / MACRO: through
FocasAddress, so what it emits is parseable by construction.
Tests assert addresses ROUND-TRIP through the parser rather than matching an
expected string: the previous FocasAddressBuilderTests pinned "axis:5" exactly,
which is how a green suite coexisted with a picker nobody could use. Verified
red-first against the old emitter. FOCAS 308, AdminUI 1095, ControlPlane 169,
Core 581 -- all pass, 0 warnings.
The remaining half of #540 -- a driver reporting Healthy/0 errors while
skipping tags at init -- is fleet-wide (eight drivers do this) and is filed
separately rather than patched per-driver.
338 lines
18 KiB
Markdown
338 lines
18 KiB
Markdown
# FOCAS Driver
|
||
|
||
Getting-started guide for the FANUC FOCAS2 driver. This is the short path — for
|
||
the exhaustive per-node mapping read [`docs/v2/driver-specs.md §7`](../v2/driver-specs.md),
|
||
for the test-harness map read [FOCAS-Test-Fixture.md](FOCAS-Test-Fixture.md).
|
||
|
||
## What it talks to
|
||
|
||
FANUC CNCs (0i-D / 0i-F / 0i-MF / 0i-TF / 16i / 30i / 31i / 32i / Power Motion i)
|
||
over the proprietary FOCAS2 protocol on TCP port 8193. The wire is spoken
|
||
directly by the pure-managed [`Focas.Wire`](https://github.com/Ladder99/focas-mock)
|
||
client — no Fwlib64.dll, no P/Invoke, no out-of-process isolation needed.
|
||
|
||
OtOpcUa is **read-only** against FOCAS; all reads go over the native wire
|
||
protocol using the documented command IDs. Writes return
|
||
`BadNotWritable` by design.
|
||
|
||
> **Equipment tags are forced read-only (R2-11, migration note).** The equipment-tag parser
|
||
> (`FocasEquipmentTagParser`) now hard-codes `Writable: false` regardless of any authored
|
||
> `"writable": true` — previously such a tag advertised a writable node whose write always failed at
|
||
> the wire with `BadNotWritable`; now it fails one step earlier at the driver seam (same family of Bad
|
||
> status, no successful operation changes). The AdminUI FOCAS editor shows the Writable checkbox
|
||
> disabled/unchecked, and the deploy gate warns when a config requests `writable:true`. Additionally,
|
||
> equipment-tag references now run the same capability-matrix pre-flight authored tags get: a reference
|
||
> whose address violates the device series' matrix fails to resolve (`BadNodeIdUnknown`) instead of
|
||
> reaching the wire. Honour `writable` here once PMC writes ship in the wire client.
|
||
|
||
> **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 |
|
||
|---------|--------|------|
|
||
| `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/` | net10.0 | In-process driver — hosts `WireFocasClient` which speaks FOCAS2 over TCP directly |
|
||
|
||
Previous `Driver.FOCAS.Host` / `Driver.FOCAS.Shared` Tier-C split has been
|
||
retired — the managed wire client removes the native-crash blast radius
|
||
that justified the out-of-process service.
|
||
|
||
## Minimum deployment
|
||
|
||
Register the driver instance in the main server's `appsettings.json`. No
|
||
separate service, no DLL deployment, no shared-secret handshake:
|
||
|
||
```jsonc
|
||
"Drivers": {
|
||
"focas-cnc-1": {
|
||
"Type": "FOCAS",
|
||
"Config": {
|
||
"Backend": "wire",
|
||
"Devices": [
|
||
{ "HostAddress": "focas://10.20.30.40:8193", "Series": "ThirtyOne_i" }
|
||
],
|
||
"Tags": [
|
||
{ "Name": "Mode", "DeviceHostAddress": "focas://10.20.30.40:8193",
|
||
"Address": "PARAM:3402", "DataType": "Int32", "Writable": false },
|
||
{ "Name": "SpndLoad", "DeviceHostAddress": "focas://10.20.30.40:8193",
|
||
"Address": "MACRO:500", "DataType": "Float64", "Writable": false }
|
||
]
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
The main server opens two TCP sockets per configured device and speaks the
|
||
FOCAS2 binary protocol directly. No local privileged components, no
|
||
platform bitness constraint — the driver runs on every host OtOpcUa runs
|
||
on.
|
||
|
||
## Address forms
|
||
|
||
| Form | Example | Meaning |
|
||
|------|---------|---------|
|
||
| `X0.0` / `R100` / `R100.3` | PMC bit or byte | Letter + number; optional `.bit` for bit access |
|
||
| `PARAM:1815` / `PARAM:1815/0` | CNC parameter | Number + optional axis index |
|
||
| `MACRO:500` | Custom macro variable | System / user macro variable number |
|
||
| `FIXED:Identity/SeriesNumber` | Fixed-tree field | A path into the [fixed node tree](#fixed-node-tree) — read-only |
|
||
|
||
The first three name a location the driver asks the CNC for on demand.
|
||
`FIXED:` does not: it names a field of the per-device fixed-tree snapshot the
|
||
driver's own poll loop already maintains, so a `FIXED:` read issues no wire
|
||
traffic. It exists because the fixed tree is the only part of the FOCAS surface
|
||
with no address of its own, and v3 publishes nodes only for authored tags — so
|
||
without a form to author, the fixed tree could not be published at all.
|
||
|
||
**An address never carries the device.** A tag's device comes from its raw-tag
|
||
Device row; an address that also named a host would be a second, conflicting
|
||
routing channel. This is worth stating because it is exactly what went wrong in
|
||
[#540](https://gitea.dohertylan.com/dohertj2/lmxopcua/issues/540): browse leaves
|
||
advertised `{deviceHost}/{path}` as their reference, the browse-commit wrote that
|
||
into the address, and every committed tag read `BadNodeIdUnknown` forever.
|
||
|
||
Addresses are validated against the per-device `Series` at `InitializeAsync` —
|
||
a config referencing a number outside the documented range for that series
|
||
fails at load time with an error message naming the limit. See
|
||
[`docs/v2/focas-version-matrix.md`](../v2/focas-version-matrix.md) for the
|
||
authoritative range table. (`FIXED:` paths have no numeric range to check; a
|
||
path the machine does not expose reads `BadNodeIdUnknown`.)
|
||
|
||
An address the parser cannot read is rejected **when you author it** — by the
|
||
`/raw` tag modal, and by the deploy-time inspector for config arriving via CSV
|
||
import or the API. It is not a warning you can deploy past into a driver that
|
||
skips the tag at startup.
|
||
|
||
## Backend selection
|
||
|
||
The driver picks its client from `Config.Backend`:
|
||
|
||
| Value | Client | Use it for |
|
||
|-------|--------|------------|
|
||
| `wire` (default) | `WireFocasClient` | Production — pure-managed FOCAS2 over TCP |
|
||
| `unimplemented` / `none` / `stub` | `UnimplementedFocasClientFactory` | Scaffolding a DriverInstance row before the CNC endpoint is reachable |
|
||
|
||
**Fail-fast on `unimplemented` / `none` / `stub`:** a driver instance configured with any of
|
||
these backends now **faults immediately at `InitializeAsync`** with a clear error message rather
|
||
than reporting healthy and then failing on the first read/write/subscribe. The driver moves to
|
||
`Faulted` state and the error is visible on the Admin UI driver-status panel. This is intentional
|
||
— an operator who forgets to switch to `"wire"` before deploying to production sees a driver
|
||
fault at startup, not a phantom-healthy driver that silently rejects every request.
|
||
|
||
Previous backends (`fwlib`, `fwlib32`, `ipc`) have been retired along
|
||
with `Driver.FOCAS.Host` and the Fwlib P/Invoke path. Configs that still
|
||
reference them will throw at startup with a message pointing here.
|
||
|
||
## Capability surface
|
||
|
||
| Capability | Wire path | Notes |
|
||
|------------|-----------|-------|
|
||
| `IReadable` | `ReadAsync` → `cnc_rdpmcrng` / `cnc_rdparam` / `cnc_rdmacro` | One TCP request/response per read; `Focas.Wire` serializes requests on socket 2 internally |
|
||
| `IWritable` | returns `BadNotWritable` | OtOpcUa is read-only against FOCAS by design — no `cnc_wrparam` / `pmc_wrpmcrng` / `cnc_wrmacro` path is implemented |
|
||
| `ITagDiscovery` | `DiscoverAsync` | Emits `FOCAS/{device}/{tag}` folders per configured device |
|
||
| `ISubscribable` | polled via shared `PollGroupEngine` | FOCAS has no push model — subscriptions turn into per-tag polling groups |
|
||
| `IHostConnectivityProbe` | periodic `cnc_rdcncstat` | Probe cadence is `Probe.Interval`; transitions fire `OnHostStatusChanged` |
|
||
| `IPerCallHostResolver` | lookup in `_tagsByName` | Each call routes to the device of the referenced tag |
|
||
| `IAlarmSource` | polled `cnc_rdalmmsg2` via `FocasAlarmProjection` | Opt-in — set `AlarmProjection.Enabled=true`; diffs `(AlarmNumber, Type)` between ticks |
|
||
|
||
Ack is a no-op — FANUC clears alarms on its own once the underlying condition
|
||
resolves, so `AcknowledgeAsync` swallows the batch rather than surfacing
|
||
`BadNotSupported`.
|
||
|
||
## Fixed node tree
|
||
|
||
Enable a pre-defined hierarchy of CNC nodes populated automatically from
|
||
`cnc_sysinfo` + `cnc_rdaxisname` + `cnc_rddynamic2` + related FWLIB calls,
|
||
so operators get an out-of-the-box view of identity / axes / program /
|
||
timers without declaring per-address tags.
|
||
|
||
```jsonc
|
||
"Config": {
|
||
"Devices": [ ... ],
|
||
"Tags": [ ... ],
|
||
"FixedTree": {
|
||
"Enabled": true,
|
||
"PollInterval": "00:00:00.250", // fast — per-axis dynamic reads
|
||
"ProgramPollInterval": "00:00:01", // medium — program + mode changes
|
||
"TimerPollInterval": "00:00:30" // slow — cumulative counters
|
||
}
|
||
}
|
||
```
|
||
|
||
`Enabled` defaults to **false** so an upgrade does not grow nodes on configs
|
||
that never asked for them. You do not have to set it to use the tree: **authoring
|
||
any `FIXED:` tag arms the poll loop by itself**, because committing those tags
|
||
already says you want it. The flag remains the way to publish the tree without
|
||
authoring anything (and the browser turns it on for the duration of a browse).
|
||
|
||
### Authoring fixed-tree tags
|
||
|
||
Browse the device (`/raw` → device → **Browse device…**, or the tag modal's
|
||
**Browse machine**) and commit the leaves you want. Each leaf carries its own
|
||
`FIXED:` address, so the commit produces working tags with no editing. The
|
||
manual composer next to the browser covers PMC / `PARAM:` / `MACRO:` only —
|
||
fixed-tree paths are discovered from the machine, not composed from parts.
|
||
|
||
Two caveats worth knowing before you browse:
|
||
|
||
- **A real CNC can report duplicate sibling names.** One observed Makino returns
|
||
two axis folders both called `AA`. Committing both is refused at commit time
|
||
(they would be the same RawPath), and the runtime cache is keyed by the same
|
||
name — so the `Axes/` subtree on such a machine cannot currently distinguish
|
||
them. Prefer the non-axis subtrees there until this is resolved.
|
||
- **Values are dated from the last answered poll, not from the read.** If a CNC
|
||
stops answering, its fixed-tree values go `BadCommunicationError` rather than
|
||
continuing to serve the last snapshot as `Good`; the budget is eight poll
|
||
cadences, floored at 30 s.
|
||
|
||
What gets populated (all under `FOCAS/{deviceHostAddress}/`, addressed as
|
||
`FIXED:{subtree}/{field}`):
|
||
|
||
| Subtree | Nodes | Source call |
|
||
|---------|-------|-------------|
|
||
| `Identity/` | `SeriesNumber`, `Version`, `MaxAxes`, `CncType`, `MtType`, `AxisCount` | `cnc_sysinfo` once at bootstrap |
|
||
| `Axes/{name}/` | `AbsolutePosition`, `MachinePosition`, `RelativePosition`, `DistanceToGo`, `ServoLoad` — one folder per discovered axis | `cnc_rdaxisname` once + `cnc_rddynamic2` + `cnc_rdsvmeter` per tick |
|
||
| `Axes/FeedRate/Actual`, `Axes/SpindleSpeed/Actual` | Current feed + spindle RPM | `cnc_rddynamic2` |
|
||
| `Spindle/{name}/` | `Load` (percentage), `MaxRpm` — one folder per discovered spindle | `cnc_rdspdlname` once + `cnc_rdspload` + `cnc_rdspmaxrpm` |
|
||
| `Program/` | `Name` (filename), `ONumber`, `Number`, `MainNumber`, `Sequence`, `BlockCount` | `cnc_exeprgname2` + `cnc_rdblkcount` + cached `cnc_rddynamic2` |
|
||
| `OperationMode/` | `Mode` (int), `ModeText` ("AUTO", "MDI", "EDIT", …) | `cnc_rdopmode` |
|
||
| `Timers/` | `PowerOnSeconds`, `OperatingSeconds`, `CuttingSeconds`, `CycleSeconds` | `cnc_rdtimer` × 4 |
|
||
|
||
### Per-series node suppression
|
||
|
||
The driver probes each optional call once at bootstrap. If the target CNC
|
||
returns `EW_FUNC` / `EW_NOOPT` / `EW_VERSION` on the wire, the
|
||
corresponding subtree is **not emitted** — the operator doesn't see nodes
|
||
that will only ever return `BadDeviceFailure`. Capability suppression
|
||
covers `Spindle/`, `Program/` + `OperationMode/`, `Timers/`, and
|
||
per-axis `ServoLoad` independently. Identity + `Axes/*` position reads
|
||
(which every Fanuc CNC supports) are always emitted.
|
||
|
||
Position values (`AbsolutePosition`, `MachinePosition`, `RelativePosition`, `DistanceToGo`)
|
||
are CNC-internal scaled integers exposed as `Float64` OPC UA nodes. The driver converts them
|
||
to engineering units using the per-device `PositionDecimalPlaces` config field (default `0`
|
||
= no scaling). When set to a positive integer *d*, each position value is divided by `10^d`
|
||
before publishing, so a CNC that reports millimetres × 1000 is corrected by setting
|
||
`PositionDecimalPlaces: 3`.
|
||
|
||
```jsonc
|
||
"Devices": [
|
||
{
|
||
"HostAddress": "focas://10.20.30.40:8193",
|
||
"Series": "ThirtyOne_i",
|
||
"PositionDecimalPlaces": 3 // 123456 → 123.456 mm
|
||
}
|
||
]
|
||
```
|
||
|
||
The driver now attempts to auto-scale each axis position using the CNC's `cnc_getfigure` figure when available; `PositionDecimalPlaces` is the **per-axis fallback** used only when the CNC reports no figure for that axis. **Caveat:** the managed `WireFocasClient` (FOCAS/2 Ethernet wire protocol) does not issue `cnc_getfigure` today, so on real hardware all axes still fall back to the manual `PositionDecimalPlaces` config field. Live auto-fetch is a follow-up requiring a `cnc_getfigure` command in `FocasWireClient`. Negative values from `PositionDecimalPlaces` are clamped to `0`.
|
||
|
||
**Follow-ups (known gaps, non-blocking):**
|
||
- No upper-bound clamp on the figure magnitude returned by the CNC — a misbehaving figure could overflow `Math.Pow`; a max-figure guard should be added.
|
||
- The negative-figure branch (falls back to manual `PositionDecimalPlaces`) is handled but not explicitly unit-tested.
|
||
|
||
**Still hand-authored**: `PARAM:6711`, `MACRO:500`, `R100` etc. — specific
|
||
numbers whose meaning is MTB-specific, and which no browse can discover. Those
|
||
go under the device folder alongside the fixed subtree.
|
||
|
||
## Alarm projection
|
||
|
||
Alarm surfacing is **disabled by default** because the polling cost is wasted
|
||
on sites that don't consume CNC alarms. Opt in per driver instance:
|
||
|
||
```jsonc
|
||
"Config": {
|
||
"Devices": [ ... ],
|
||
"Tags": [ ... ],
|
||
"AlarmProjection": {
|
||
"Enabled": true,
|
||
"PollInterval": "00:00:02"
|
||
}
|
||
}
|
||
```
|
||
|
||
Every alarm transition fires `OnAlarmEvent` with:
|
||
|
||
- `SourceNodeId` = the device host address (FOCAS has no per-node alarm model;
|
||
the CNC exposes a single flat active-alarm list per session)
|
||
- `ConditionId` = `"{host}#{Type}:{AlarmNumber}"`
|
||
- `AlarmType` = projected from FANUC's `ALM_TYPE_*` (e.g. `Overtravel`, `Servo`,
|
||
`Parameter`, `MacroAlarm`)
|
||
- `Severity` = Overtravel / Servo / PulseCode → `Critical`; Parameter / Macro
|
||
→ `Medium`; everything else → `High`
|
||
|
||
Cleared alarms fire a second event with `" (cleared)"` appended to the message
|
||
so downstream consumers can ignore the clear if they only care about raises.
|
||
|
||
## Handle recycling
|
||
|
||
FANUC CNCs have a finite FWLIB handle pool (~5–10 concurrent connections) and
|
||
certain series have documented handle-leak bugs that manifest after long uptime.
|
||
The driver can proactively close + reopen each device's session on a cadence to
|
||
release its slot back to the pool:
|
||
|
||
```jsonc
|
||
"Config": {
|
||
"Devices": [ ... ],
|
||
"HandleRecycle": {
|
||
"Enabled": true,
|
||
"Interval": "01:00:00"
|
||
}
|
||
}
|
||
```
|
||
|
||
Disabled by default — a healthy CNC + driver doesn't need it. Enable when field
|
||
experience shows handle exhaustion. Typical tuning: 30 min for sites running
|
||
multiple OtOpcUa instances against the same CNC (they share the pool); 6 h for a
|
||
single-client deployment. Reads / writes during recycle simply wait for the
|
||
reconnect rather than failing — worst case, an operator sees a brief read
|
||
latency spike once per cadence.
|
||
|
||
## Testing
|
||
|
||
- **Unit tests** — `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/` cover the
|
||
driver surface via `FakeFocasClient`. Includes the alarm-projection raise /
|
||
clear diffing tests.
|
||
- **Integration tests** — `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.IntegrationTests/`
|
||
hold the Docker simulator scaffold; see
|
||
[`docs/v2/implementation/focas-wire-protocol.md`](../v2/implementation/focas-wire-protocol.md)
|
||
for what the simulator emits vs. real CNC behaviour.
|
||
- **E2E script** — `scripts/e2e/test-focas.ps1` stages Host + Proxy + a real
|
||
CNC (or the simulator) and exercises connect → read → write → subscribe
|
||
round-trips. See [`docs/drivers/FOCAS-Test-Fixture.md`](FOCAS-Test-Fixture.md)
|
||
for the coverage map.
|
||
|
||
## Troubleshooting
|
||
|
||
| Symptom | Likely cause | Fix |
|
||
|---------|--------------|-----|
|
||
| Driver faults immediately at startup with "unimplemented" in the error | `Backend` is `"unimplemented"` / `"none"` / `"stub"` | Change `Backend` to `"wire"` and supply the real CNC endpoint in `Devices[]` |
|
||
| `BadCommunicationError` on every read | CNC unreachable on TCP:8193 | Check firewall / LAN reachability; FOCAS Ethernet option must be licensed on the CNC side |
|
||
| Every read returns `BadNotWritable` on writes | Expected — OtOpcUa is read-only against FOCAS | If you actually need writes, open a feature request — the driver's managed wire client doesn't expose the write commands |
|
||
| `BadOutOfRange` on reads for a macro/parameter | Config address outside the declared `Series` range | Check `docs/v2/focas-version-matrix.md` — either fix the address or widen the `Series` |
|
||
| Alarm events never fire | `AlarmProjection.Enabled` left at default (false) | Set it to `true` in the driver config |
|
||
| Axis position values seem 1000× too large | `PositionDecimalPlaces` not set | Add `"PositionDecimalPlaces": 3` (or the MTB-specific value) to the device entry in `Devices[]` |
|
||
|
||
## Further reading
|
||
|
||
- [`docs/v2/driver-specs.md §7`](../v2/driver-specs.md) — full OPC UA node
|
||
mapping, pre-defined tag set, per-API notes
|
||
- [`docs/v2/focas-version-matrix.md`](../v2/focas-version-matrix.md) —
|
||
per-series macro / parameter / PMC range table
|
||
- [`docs/v2/implementation/focas-wire-protocol.md`](../v2/implementation/focas-wire-protocol.md)
|
||
— captured FOCAS2 wire semantics (magic prefix, handshake, command-id table)
|
||
- [upstream `Focas.Wire`](https://github.com/Ladder99/focas-mock/tree/main/dotnet/Focas.Wire)
|
||
— the managed client implementation OtOpcUa consumes as a NuGet dependency
|