FOCAS — commit previously-orphaned support files
Brings seven FOCAS-related files into git that shipped as part of earlier FOCAS work but were never staged. Adding them now so the tree reflects the compilable state + pre-empts dead references from the migration commit that follows: - src/.../Driver.FOCAS/FocasAlarmProjection.cs — raise/clear diffing + severity mapping surfaced via IAlarmSource on FocasDriver. Referenced by committed FocasDriver.cs; tests in FocasAlarmProjectionTests.cs. - src/.../Admin/Services/FocasDriverDetailService.cs — Admin UI per-instance detail page data source. - src/.../Admin/Components/Pages/Drivers/FocasDetail.razor — Blazor page rendering the above (from task #69). - tests/.../Admin.Tests/FocasDriverDetailServiceTests.cs — exercises the detail service. - tests/.../Driver.FOCAS.Tests/FocasAlarmProjectionTests.cs — raise/clear diff semantics against FakeFocasClient. - tests/.../Driver.FOCAS.Tests/FocasHandleRecycleTests.cs — proactive recycle cadence test. - docs/v2/implementation/focas-wire-protocol.md — captured FOCAS/2 Ethernet wire protocol reference. Useful going forward even though the Tier-C / simulator plan docs are historical. No runtime behaviour change — these files compile today and the solution build/test pass already depends on them. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
291
docs/v2/implementation/focas-wire-protocol.md
Normal file
291
docs/v2/implementation/focas-wire-protocol.md
Normal file
@@ -0,0 +1,291 @@
|
||||
# FOCAS wire protocol — what's authoritative vs. what's guessed
|
||||
|
||||
Companion to [`focas-simulator-plan.md`](focas-simulator-plan.md). Written during
|
||||
Stream B on 2026-04-23 after a research pass through `strangesast/fwlib` +
|
||||
public FOCAS documentation. Purpose: separate what we *know* about the FOCAS
|
||||
wire protocol (can quote with confidence) from what we're *guessing* (will need
|
||||
Wireshark traces to validate in Stream C).
|
||||
|
||||
This document directly informs `tests/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.IntegrationTests/Docker/server/`.
|
||||
|
||||
## Authoritative — from Fanuc's public `fwlib32.h`
|
||||
|
||||
The header file is distributed with the FOCAS Developer Kit and mirrored in OSS
|
||||
repos (notably `strangesast/fwlib`). The **struct layouts** documented there
|
||||
are stable across FOCAS versions and authoritative for the payload shapes our
|
||||
Python mock has to emit.
|
||||
|
||||
### ODBM — macro variable read buffer
|
||||
|
||||
```c
|
||||
typedef struct odbm {
|
||||
short datano; // macro variable number
|
||||
short dummy; // reserved / alignment padding
|
||||
long mcr_val; // 32-bit signed macro value
|
||||
short dec_val; // decimal-point count (0-9)
|
||||
} ODBM;
|
||||
```
|
||||
|
||||
With `#pragma pack(push, 4)` (the FOCAS default), total size is **10 bytes** on
|
||||
Windows: 2 + 2 + 4 + 2. Our `FwlibNative.cs` matches this exactly.
|
||||
|
||||
Our mock's `_READ_RESP_STRUCT = struct.Struct(">iH")` is **only 6 bytes** —
|
||||
missing `datano` + `dummy`. A real Fwlib decoding the scaffold response will
|
||||
read garbage. Stream C fix: prepend two `short` fields.
|
||||
|
||||
### IODBPSD — CNC parameter read/write buffer
|
||||
|
||||
```c
|
||||
typedef struct iodbpsd {
|
||||
short datano; // parameter number
|
||||
short type; // axis index (0 for non-axis parameters)
|
||||
union {
|
||||
char cdata;
|
||||
short idata;
|
||||
long ldata;
|
||||
char cdatas[MAX_AXIS]; // MAX_AXIS varies — 8 on 0i, 32 on 30i
|
||||
short idatas[MAX_AXIS];
|
||||
long ldatas[MAX_AXIS];
|
||||
} u;
|
||||
} IODBPSD;
|
||||
```
|
||||
|
||||
With `pack(4)` and `MAX_AXIS=8`, total size = 2 + 2 + 32 = **36 bytes**. Our
|
||||
`FwlibNative.cs` matches this (`[SizeConst = 32]` data buffer).
|
||||
|
||||
Our mock's current param handler doesn't return bytes in IODBPSD shape —
|
||||
response payload is just the raw value. Stream C fix: wrap in 4-byte header
|
||||
+ union-padded data.
|
||||
|
||||
### ODBST — status info
|
||||
|
||||
```c
|
||||
typedef struct odbst {
|
||||
short dummy; // reserved
|
||||
short tmmode; // Memory / Tape / MDI / EDIT / DNC
|
||||
short aut; // automatic mode
|
||||
short run; // running state
|
||||
short motion; // motion state
|
||||
short mstb; // M/S/T/B finish signal
|
||||
short emergency; // emergency stop
|
||||
short alarm; // alarm state
|
||||
short edit; // edit mode sub-state
|
||||
} ODBST;
|
||||
```
|
||||
|
||||
9 × short = **18 bytes**. Our mock already emits 18 bytes via
|
||||
`struct.Struct(">9h")`. ✓ correct.
|
||||
|
||||
### IODBPMC — PMC range read/write buffer
|
||||
|
||||
```c
|
||||
typedef struct iodbpmc {
|
||||
short type_a; // PMC address letter encoded as ADR_* numeric code
|
||||
short type_d; // data type: 0=byte, 1=word, 2=long, 4=float, 5=double
|
||||
unsigned short datano_s; // start address number
|
||||
unsigned short datano_e; // end address number
|
||||
union {
|
||||
char cdata[5];
|
||||
short idata[5];
|
||||
long ldata[5];
|
||||
float fdata[5];
|
||||
double dbdata[5];
|
||||
} u; // 40-byte union (widest = dbdata = 5×8 bytes)
|
||||
} IODBPMC;
|
||||
```
|
||||
|
||||
With `pack(4)` the union is 40 bytes; struct total = 8 + 40 = **48 bytes**.
|
||||
Our `FwlibNative.cs` matches this.
|
||||
|
||||
Our mock's PMC handler takes a different layout (uint16 handle + uint8 letter
|
||||
+ ...). Stream C fix: rewrite to IODBPMC shape.
|
||||
|
||||
## Reference trace findings (2026-04-23 dev-box reversing)
|
||||
|
||||
**Good news** — we don't need a bench CNC for first-pass reversing. Loading
|
||||
`Fwlib64.dll` in `otopcua-focas-cli` + pointing it at our Python simulator on
|
||||
`127.0.0.1:8193` + enabling `OTOPCUA_FOCAS_RAW_CAPTURE=1` on the sim lets us
|
||||
observe Fwlib's outbound bytes + iterate on reply shapes. Each cycle is ~5s;
|
||||
progress measure is "Fwlib sends more bytes before disconnecting".
|
||||
|
||||
### Confirmed wire facts
|
||||
|
||||
**Magic prefix** — every frame Fwlib sends begins with `0xA0 0xA0 0xA0 0xA0`
|
||||
(4 bytes). This is NOT a length prefix — our scaffold tried to decode it as
|
||||
uint32-big-endian = 2.7 GB and died. It's a fixed protocol marker.
|
||||
|
||||
**Handshake request** — `cnc_allclibhndl3` produces this 8-byte frame:
|
||||
|
||||
```
|
||||
a0 a0 a0 a0 00 01 01 01
|
||||
└─ magic ─┘ └── negotiation ──┘
|
||||
```
|
||||
|
||||
The 4-byte negotiation field is stable across our observations (always
|
||||
`00 01 01 01`). Interpretation TBD — possibly `(version_major=0x0001,
|
||||
version_minor=0x0101)` or `(protocol=0x01, subtype=0x010101)`.
|
||||
|
||||
**Handshake reply that Fwlib accepts** (empirically confirmed — doesn't
|
||||
disconnect):
|
||||
|
||||
```
|
||||
a0 a0 a0 a0 00 01 01 01 00 XX 00 YY
|
||||
└─ magic ─┘ └── echo ──┘ handle api_version
|
||||
```
|
||||
|
||||
12 bytes: magic + echoed negotiation + 2-byte handle + 2-byte api_version code.
|
||||
|
||||
### Post-handshake frame shape — decoded via drain mode
|
||||
|
||||
The simulator's `OTOPCUA_FOCAS_DRAIN_AFTER_HANDSHAKE=1` mode reads all inbound
|
||||
bytes for 1000 ms after the handshake reply without attempting any decode.
|
||||
Captured payload from `cnc_allclibhndl3`:
|
||||
|
||||
```
|
||||
00 02 00 02 a0 a0 a0 a0 00 01 21 01 00 00
|
||||
└── prefix ─┘ └── magic ─┘ └─── body ────┘
|
||||
4 bytes 4 bytes 6 bytes (total = 14 bytes)
|
||||
```
|
||||
|
||||
**Key discovery**: post-handshake frames have a **4-byte prefix BEFORE the
|
||||
magic**, not magic-first. Frame shape:
|
||||
|
||||
```
|
||||
uint16 msg_counter // starts at 2; handshake was #1 implicitly
|
||||
uint16 handle_echo // matches the handle our open reply returned
|
||||
4 bytes FOCAS_MAGIC // 0xA0A0A0A0
|
||||
N bytes body // function-specific
|
||||
```
|
||||
|
||||
Session 1's drain captured only the prefix (`00 02 00 01`) before timing
|
||||
out — TCP multiplexed the two test sessions's bytes differently. Session 2
|
||||
caught the full 14-byte frame.
|
||||
|
||||
### Body bytes — first post-handshake request
|
||||
|
||||
Body on `cnc_allclibhndl3` first post-handshake frame:
|
||||
|
||||
```
|
||||
00 01 21 01 00 00
|
||||
```
|
||||
|
||||
Informed guesses (unvalidated):
|
||||
|
||||
- `00 01` = body length (1 useful byte?) or sub-request count
|
||||
- `21 01` = function code / operation tag — `0x21` is seen in public FOCAS
|
||||
reverse-engineering notes associated with "system info" / "controller
|
||||
identification" queries
|
||||
- `00 00` = padding / reserved
|
||||
|
||||
Likely this is Fwlib's "tell me what CNC you are" query — part of
|
||||
`cnc_allclibhndl3`'s internal handshake continuation before the handle is
|
||||
fully established. Returning an empty or malformed response causes Fwlib
|
||||
to declare the far end "not a CNC" and error with `EW_FUNC` (16).
|
||||
|
||||
### Iteration 3 — echo response, error-code advances
|
||||
|
||||
Sending back `<prefix><magic><echoed body>` (14 bytes matching request shape)
|
||||
advances Fwlib's client-side error code from **`EW_-16` (socket-level)** to
|
||||
**`EW_-17` (protocol-level rejection)**. Fwlib reads our response in full
|
||||
before disconnecting with `peer closed mid-frame`.
|
||||
|
||||
Meaning: our **frame structure is correct enough** that Fwlib parses it as a
|
||||
valid FOCAS frame; the **body content** (the 6 bytes after magic) is where
|
||||
the semantic mismatch now lives. Fwlib expects specific bytes back for the
|
||||
`0x2101` system-info query and an echo doesn't match.
|
||||
|
||||
### Current iteration block
|
||||
|
||||
Going deeper without reference requires either:
|
||||
|
||||
- **A bench CNC** (#54) to capture a real response to the `0x2101` query.
|
||||
Stream C.2 Wireshark trace gives us the exact byte pattern Fwlib expects.
|
||||
- **Published FOCAS response specs** for sub-function `0x2101` — not present
|
||||
in `strangesast/fwlib` headers; likely only in the licensed Developer Kit
|
||||
binary docs.
|
||||
- **Blind enumeration** — try N variations of the 6-byte body response until
|
||||
Fwlib's error code changes again. High cost, low signal.
|
||||
|
||||
The first two are both blocked on resources we don't have. The third is
|
||||
~hundreds of cycles with no guarantee of convergence.
|
||||
|
||||
### Diminishing-returns checkpoint
|
||||
|
||||
**What we've proven without hardware**:
|
||||
1. Magic prefix `0xA0A0A0A0` confirmed
|
||||
2. Handshake request format decoded (`magic + 4-byte negotiation`)
|
||||
3. Handshake response format that Fwlib accepts (`magic + echo + handle + api`)
|
||||
4. Post-handshake frame format decoded (`prefix + magic + body`)
|
||||
5. First post-handshake function code observed (`0x2101` — likely system-info)
|
||||
6. Error code progression `EW_SOCKET` → `EW_PROTOCOL` confirms our framing is
|
||||
structurally correct
|
||||
|
||||
**What we can't prove without bench CNC or reference docs**:
|
||||
1. The exact 6-byte response body Fwlib expects for `0x2101`
|
||||
2. The full list of post-handshake function codes + their body shapes
|
||||
3. Whether subsequent frames use length prefixes or fixed body sizes
|
||||
|
||||
**Recommendation**: checkpoint here. The framing discoveries above are
|
||||
preserved in `server/frames.py` + `server/state.py` + `server/focas_server.py`
|
||||
+ `server/handlers/__init__.py`. When bench-CNC access unblocks Stream C.2's
|
||||
reference trace, the iteration loop (with the framing work already done)
|
||||
should converge in hours rather than days.
|
||||
|
||||
### Still unknown
|
||||
|
||||
- **Response shape** for the post-handshake body request — we can frame the
|
||||
prefix + magic correctly now, but what the 6-byte body response should
|
||||
carry (CNC series ID? version? capability flags?) needs further iteration.
|
||||
- **Function-id numeric values** for the 9 FWLIB calls our driver makes —
|
||||
one per call, need to be observed separately.
|
||||
- **Error encoding** on the wire.
|
||||
|
||||
### Next iteration cycles
|
||||
|
||||
With the handshake working, each subsequent function gets its own probe-and-observe
|
||||
loop. The simulator now has a `RAW_FRAME_MARKER = 0xFFFF` sentinel that lets a
|
||||
handler return exact wire bytes (bypassing the scaffold envelope) — use that to
|
||||
try different post-handshake replies and watch Fwlib's reaction.
|
||||
|
||||
## Stream C work order
|
||||
|
||||
Given what's authoritative vs. guessed, here's the most efficient path:
|
||||
|
||||
### Phase 1 — payload shapes (no hardware required)
|
||||
|
||||
- [ ] Rewrite `server/handlers/macro.py` response to return 10-byte ODBM:
|
||||
`short datano, short dummy, int32 mcr_val, short dec_val`
|
||||
- [ ] Rewrite `server/handlers/param.py` response to return 36-byte IODBPSD:
|
||||
`short datano, short type, bytes[32] u`
|
||||
- [ ] Rewrite `server/handlers/pmc.py` response to return 48-byte IODBPMC:
|
||||
`short type_a, short type_d, uint16 datano_s, uint16 datano_e, bytes[40] u`
|
||||
- [ ] Add unit tests asserting byte-exact sizes
|
||||
- [ ] Update validate_harness.py to match the new shapes
|
||||
|
||||
Effect: when Stream C gets its first Wireshark trace, the payload-layer of the
|
||||
mock is already correct. Only the framing layer needs iteration.
|
||||
|
||||
### Phase 2 — framing (requires hardware)
|
||||
|
||||
This is the iterative Wireshark loop — no point starting until the Windows rig
|
||||
+ licensed Fwlib64.dll + real CNC are all available. See the implementer's
|
||||
checklist in
|
||||
[`tests/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.IntegrationTests/Docker/README.md`](../../../tests/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.IntegrationTests/Docker/README.md).
|
||||
|
||||
### Phase 3 — flip the C# test gate
|
||||
|
||||
Once Phase 2 proves Fwlib64 can talk to the mock:
|
||||
|
||||
- [ ] Flip `OTOPCUA_FOCAS_SIM_WIRE_COMPAT=1` in the CI env
|
||||
- [ ] Expand `tests/.../IntegrationTests/Series/WireCompatGatedTests.cs` with
|
||||
real per-series assertions
|
||||
- [ ] Update `scripts/e2e/test-focas.ps1` to accept `-ProfileName`
|
||||
- [ ] Close Stream D
|
||||
|
||||
## References
|
||||
|
||||
- [`src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FwlibNative.cs`](../../../src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FwlibNative.cs) — P/Invoke surface, authoritative struct layouts
|
||||
- [`src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FwlibFocasClient.cs`](../../../src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FwlibFocasClient.cs) — reference C# implementation of each FWLIB call
|
||||
- [`src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasStatusMapper.cs`](../../../src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasStatusMapper.cs) — EW_* → OPC UA status mapping
|
||||
- Fanuc FOCAS Developer Kit (licensed, not in repo) — ultimate source of truth
|
||||
- `strangesast/fwlib` on GitHub — redistributes `fwlib32.h` + runtime binaries; no wire protocol docs
|
||||
@@ -0,0 +1,224 @@
|
||||
@page "/drivers/focas/{InstanceId}"
|
||||
@using ZB.MOM.WW.OtOpcUa.Admin.Services
|
||||
@inject FocasDriverDetailService DetailSvc
|
||||
|
||||
<h1 class="mb-3">FOCAS driver <code>@InstanceId</code></h1>
|
||||
|
||||
@if (_loading)
|
||||
{
|
||||
<p>Loading…</p>
|
||||
}
|
||||
else if (_detail is null)
|
||||
{
|
||||
<div class="alert alert-warning">
|
||||
No FOCAS driver instance with id <code>@InstanceId</code> was found.
|
||||
<div class="small text-muted mt-1">
|
||||
Either the id is wrong, or the instance's <code>DriverType</code> is not "Focas". The list of drivers per cluster draft is on the <a href="/clusters">Clusters</a> page.
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="row g-3 mb-4">
|
||||
<div class="col-md-3"><div class="card"><div class="card-body">
|
||||
<h6 class="text-muted mb-1">Name</h6>
|
||||
<div class="fs-5">@_detail.Instance.Name</div>
|
||||
</div></div></div>
|
||||
<div class="col-md-3"><div class="card"><div class="card-body">
|
||||
<h6 class="text-muted mb-1">Cluster</h6>
|
||||
<div class="fs-5"><code>@_detail.Instance.ClusterId</code></div>
|
||||
</div></div></div>
|
||||
<div class="col-md-3"><div class="card"><div class="card-body">
|
||||
<h6 class="text-muted mb-1">Namespace</h6>
|
||||
<div class="fs-5"><code>@_detail.Instance.NamespaceId</code></div>
|
||||
</div></div></div>
|
||||
<div class="col-md-3"><div class="card @(_detail.Instance.Enabled ? "border-success" : "border-secondary")"><div class="card-body">
|
||||
<h6 class="text-muted mb-1">Enabled</h6>
|
||||
<div class="fs-5">@(_detail.Instance.Enabled ? "Yes" : "No")</div>
|
||||
</div></div></div>
|
||||
</div>
|
||||
|
||||
@if (_detail.ParseError is not null)
|
||||
{
|
||||
<div class="alert alert-danger">
|
||||
<strong>DriverConfig JSON failed to parse:</strong> @_detail.ParseError
|
||||
<div class="small text-muted mt-1">
|
||||
Falling back to raw-JSON view below; the per-section tables are hidden because the shape couldn't be projected.
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
else if (_detail.Config is not null)
|
||||
{
|
||||
<h2 class="h5 mt-4">Devices</h2>
|
||||
@if (_detail.Config.Devices is null || _detail.Config.Devices.Count == 0)
|
||||
{
|
||||
<p class="text-muted">No devices configured.</p>
|
||||
}
|
||||
else
|
||||
{
|
||||
<table class="table table-sm align-middle">
|
||||
<thead><tr><th>HostAddress</th><th>DeviceName</th><th>Series</th></tr></thead>
|
||||
<tbody>
|
||||
@foreach (var d in _detail.Config.Devices)
|
||||
{
|
||||
<tr>
|
||||
<td><code>@d.HostAddress</code></td>
|
||||
<td>@(d.DeviceName ?? "—")</td>
|
||||
<td>@(string.IsNullOrEmpty(d.Series) ? "Unknown" : d.Series)</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
}
|
||||
|
||||
<h2 class="h5 mt-4">Tags</h2>
|
||||
@if (_detail.Config.Tags is null || _detail.Config.Tags.Count == 0)
|
||||
{
|
||||
<p class="text-muted">No tags configured.</p>
|
||||
}
|
||||
else
|
||||
{
|
||||
<p class="small text-muted">@_detail.Config.Tags.Count tag(s) configured.</p>
|
||||
<table class="table table-sm align-middle">
|
||||
<thead><tr><th>Name</th><th>Device</th><th>Address</th><th>DataType</th><th>Writable</th></tr></thead>
|
||||
<tbody>
|
||||
@foreach (var t in _detail.Config.Tags)
|
||||
{
|
||||
<tr>
|
||||
<td>@t.Name</td>
|
||||
<td><code class="small">@t.DeviceHostAddress</code></td>
|
||||
<td><code>@t.Address</code></td>
|
||||
<td>@t.DataType</td>
|
||||
<td>@(t.Writable ? "Yes" : "No")</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
}
|
||||
|
||||
<h2 class="h5 mt-4">Driver behaviour</h2>
|
||||
<table class="table table-sm align-middle" style="max-width: 640px;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<th style="width: 30%;">Probe</th>
|
||||
<td>
|
||||
@if (_detail.Config.Probe is { } probe)
|
||||
{
|
||||
<span class="badge @(probe.Enabled ? "bg-success" : "bg-secondary")">@(probe.Enabled ? "Enabled" : "Disabled")</span>
|
||||
<span class="ms-2 small text-muted">Interval: @(probe.Interval ?? "default")</span>
|
||||
}
|
||||
else { <span class="text-muted">default (enabled)</span> }
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Alarm projection</th>
|
||||
<td>
|
||||
@if (_detail.Config.AlarmProjection is { } ap)
|
||||
{
|
||||
<span class="badge @(ap.Enabled ? "bg-success" : "bg-secondary")">@(ap.Enabled ? "Enabled" : "Disabled")</span>
|
||||
<span class="ms-2 small text-muted">PollInterval: @(ap.PollInterval ?? "default")</span>
|
||||
}
|
||||
else { <span class="text-muted">disabled (default)</span> }
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Handle recycling</th>
|
||||
<td>
|
||||
@if (_detail.Config.HandleRecycle is { } hr)
|
||||
{
|
||||
<span class="badge @(hr.Enabled ? "bg-warning text-dark" : "bg-secondary")">@(hr.Enabled ? "Enabled" : "Disabled")</span>
|
||||
<span class="ms-2 small text-muted">Interval: @(hr.Interval ?? "default (01:00:00)")</span>
|
||||
}
|
||||
else { <span class="text-muted">disabled (default)</span> }
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
}
|
||||
|
||||
<h2 class="h5 mt-4">Host status</h2>
|
||||
@if (_detail.HostStatuses.Count == 0)
|
||||
{
|
||||
<div class="alert alert-secondary small">
|
||||
No <code>DriverHostStatus</code> rows yet for this instance. The Server publishes its first
|
||||
tick ~2 s after the driver starts — if this stays empty after a minute, check that the Server is running and the instance is in a published generation.
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<table class="table table-sm table-hover align-middle">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Node</th>
|
||||
<th>Host</th>
|
||||
<th>State</th>
|
||||
<th class="text-end" title="Consecutive failures">Fail#</th>
|
||||
<th>Breaker last opened</th>
|
||||
<th>Last recycled</th>
|
||||
<th>Last seen</th>
|
||||
<th>Detail</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (var r in _detail.HostStatuses)
|
||||
{
|
||||
<tr class="@(IsStale(r) ? "table-warning" : "")">
|
||||
<td><code>@r.NodeId</code></td>
|
||||
<td>@r.HostName</td>
|
||||
<td><span class="badge @StateBadge(r.State)">@r.State</span></td>
|
||||
<td class="text-end small">@r.ConsecutiveFailures</td>
|
||||
<td class="small">@FormatUtc(r.LastCircuitBreakerOpenUtc)</td>
|
||||
<td class="small">@FormatUtc(r.LastRecycleUtc)</td>
|
||||
<td class="small @(IsStale(r) ? "text-warning" : "")">@FormatAge(r.LastSeenUtc)</td>
|
||||
<td class="text-truncate small" style="max-width: 240px;" title="@r.Detail">@r.Detail</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
}
|
||||
|
||||
<h2 class="h5 mt-4">Raw DriverConfig JSON</h2>
|
||||
<pre class="small bg-light border p-3"><code>@_detail.Instance.DriverConfig</code></pre>
|
||||
|
||||
<div class="mt-4 small text-muted">
|
||||
Docs: <code>docs/drivers/FOCAS.md</code> (getting started) · <code>docs/v2/focas-deployment.md</code> (NSSM + pipe ACL) · <code>docs/drivers/FOCAS-Test-Fixture.md</code> (test coverage).
|
||||
</div>
|
||||
}
|
||||
|
||||
@code {
|
||||
[Parameter] public string InstanceId { get; set; } = string.Empty;
|
||||
|
||||
private FocasDriverDetail? _detail;
|
||||
private bool _loading = true;
|
||||
|
||||
protected override async Task OnParametersSetAsync()
|
||||
{
|
||||
_loading = true;
|
||||
try { _detail = await DetailSvc.GetAsync(InstanceId, CancellationToken.None); }
|
||||
finally { _loading = false; }
|
||||
}
|
||||
|
||||
private static bool IsStale(FocasHostStatusRow r) =>
|
||||
DateTime.UtcNow - r.LastSeenUtc > TimeSpan.FromSeconds(30);
|
||||
|
||||
private static string StateBadge(string state) => state switch
|
||||
{
|
||||
"Running" => "bg-success",
|
||||
"Faulted" => "bg-danger",
|
||||
"Starting" => "bg-info",
|
||||
"Stopped" => "bg-secondary",
|
||||
_ => "bg-secondary",
|
||||
};
|
||||
|
||||
private static string FormatUtc(DateTime? utc) =>
|
||||
utc is null ? "—" : utc.Value.ToString("yyyy-MM-dd HH:mm 'UTC'");
|
||||
|
||||
private static string FormatAge(DateTime utc)
|
||||
{
|
||||
var age = DateTime.UtcNow - utc;
|
||||
if (age.TotalSeconds < 60) return $"{(int)age.TotalSeconds}s ago";
|
||||
if (age.TotalMinutes < 60) return $"{(int)age.TotalMinutes}m ago";
|
||||
if (age.TotalHours < 48) return $"{(int)age.TotalHours}h ago";
|
||||
return utc.ToString("yyyy-MM-dd HH:mm 'UTC'");
|
||||
}
|
||||
}
|
||||
123
src/ZB.MOM.WW.OtOpcUa.Admin/Services/FocasDriverDetailService.cs
Normal file
123
src/ZB.MOM.WW.OtOpcUa.Admin/Services/FocasDriverDetailService.cs
Normal file
@@ -0,0 +1,123 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Admin.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Per-instance detail view for FOCAS driver rows. Loads the latest
|
||||
/// <see cref="DriverInstance"/> row for the requested <c>DriverInstanceId</c> (most-recent
|
||||
/// draft wins when multiple rows exist across generations), parses the schemaless
|
||||
/// <c>DriverConfig</c> JSON into <see cref="FocasDriverConfigView"/>, and joins the
|
||||
/// per-device <see cref="DriverHostStatus"/> rows so the Admin page can render host
|
||||
/// state + consecutive-failure counters next to each configured device.
|
||||
/// </summary>
|
||||
public sealed class FocasDriverDetailService(OtOpcUaConfigDbContext db)
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOpts = new()
|
||||
{
|
||||
PropertyNameCaseInsensitive = true,
|
||||
NumberHandling = JsonNumberHandling.AllowReadingFromString,
|
||||
};
|
||||
|
||||
public async Task<FocasDriverDetail?> GetAsync(string driverInstanceId, CancellationToken ct = default)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(driverInstanceId)) return null;
|
||||
|
||||
var instance = await db.DriverInstances.AsNoTracking()
|
||||
.Where(d => d.DriverInstanceId == driverInstanceId
|
||||
&& d.DriverType.ToLower() == "focas")
|
||||
.OrderByDescending(d => d.GenerationId)
|
||||
.FirstOrDefaultAsync(ct);
|
||||
if (instance is null) return null;
|
||||
|
||||
FocasDriverConfigView? config = null;
|
||||
string? parseError = null;
|
||||
try { config = JsonSerializer.Deserialize<FocasDriverConfigView>(instance.DriverConfig, JsonOpts); }
|
||||
catch (JsonException ex) { parseError = ex.Message; }
|
||||
|
||||
var hostStatuses = await (from s in db.DriverHostStatuses.AsNoTracking()
|
||||
where s.DriverInstanceId == driverInstanceId
|
||||
join r in db.DriverInstanceResilienceStatuses.AsNoTracking()
|
||||
on new { s.DriverInstanceId, s.HostName }
|
||||
equals new { r.DriverInstanceId, r.HostName } into rj
|
||||
from r in rj.DefaultIfEmpty()
|
||||
orderby s.HostName
|
||||
select new FocasHostStatusRow(
|
||||
s.NodeId,
|
||||
s.HostName,
|
||||
s.State.ToString(),
|
||||
s.StateChangedUtc,
|
||||
s.LastSeenUtc,
|
||||
s.Detail,
|
||||
r != null ? r.ConsecutiveFailures : 0,
|
||||
r != null ? r.LastCircuitBreakerOpenUtc : null,
|
||||
r != null ? r.LastRecycleUtc : null)).ToListAsync(ct);
|
||||
|
||||
return new FocasDriverDetail(instance, config, parseError, hostStatuses);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Projected view of a FOCAS driver's parsed config. Unknown fields are ignored.</summary>
|
||||
public sealed record FocasDriverConfigView
|
||||
{
|
||||
public List<FocasDeviceView>? Devices { get; set; }
|
||||
public List<FocasTagView>? Tags { get; set; }
|
||||
public FocasProbeView? Probe { get; set; }
|
||||
public FocasAlarmProjectionView? AlarmProjection { get; set; }
|
||||
public FocasHandleRecycleView? HandleRecycle { get; set; }
|
||||
}
|
||||
|
||||
public sealed record FocasDeviceView
|
||||
{
|
||||
public string? HostAddress { get; set; }
|
||||
public string? DeviceName { get; set; }
|
||||
public string? Series { get; set; }
|
||||
}
|
||||
|
||||
public sealed record FocasTagView
|
||||
{
|
||||
public string? Name { get; set; }
|
||||
public string? DeviceHostAddress { get; set; }
|
||||
public string? Address { get; set; }
|
||||
public string? DataType { get; set; }
|
||||
public bool Writable { get; set; } = true;
|
||||
}
|
||||
|
||||
public sealed record FocasProbeView
|
||||
{
|
||||
public bool Enabled { get; set; } = true;
|
||||
public string? Interval { get; set; }
|
||||
}
|
||||
|
||||
public sealed record FocasAlarmProjectionView
|
||||
{
|
||||
public bool Enabled { get; set; }
|
||||
public string? PollInterval { get; set; }
|
||||
}
|
||||
|
||||
public sealed record FocasHandleRecycleView
|
||||
{
|
||||
public bool Enabled { get; set; }
|
||||
public string? Interval { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>Composite payload returned to the Admin page.</summary>
|
||||
public sealed record FocasDriverDetail(
|
||||
DriverInstance Instance,
|
||||
FocasDriverConfigView? Config,
|
||||
string? ParseError,
|
||||
IReadOnlyList<FocasHostStatusRow> HostStatuses);
|
||||
|
||||
public sealed record FocasHostStatusRow(
|
||||
string NodeId,
|
||||
string HostName,
|
||||
string State,
|
||||
DateTime StateChangedUtc,
|
||||
DateTime LastSeenUtc,
|
||||
string? Detail,
|
||||
int ConsecutiveFailures,
|
||||
DateTime? LastCircuitBreakerOpenUtc,
|
||||
DateTime? LastRecycleUtc);
|
||||
195
src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasAlarmProjection.cs
Normal file
195
src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasAlarmProjection.cs
Normal file
@@ -0,0 +1,195 @@
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
|
||||
|
||||
/// <summary>
|
||||
/// Polls each device's CNC active-alarm list via <see cref="IFocasClient.ReadAlarmsAsync"/>
|
||||
/// on a timer and translates raise / clear transitions into <see cref="IAlarmSource"/>
|
||||
/// events on the owning <see cref="FocasDriver"/>. One poll loop per subscription; the
|
||||
/// loop fans out across every configured device and diffs the (<c>AlarmNumber</c>,
|
||||
/// <c>Type</c>) keyed active-alarm set between ticks.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// FOCAS alarms are flat per session — the CNC exposes a single active-alarm list via
|
||||
/// <c>cnc_rdalmmsg2</c>, not per-node structures the way Galaxy / AbCip ALMD do. So the
|
||||
/// projection ignores <c>sourceNodeIds</c> at the member level: every alarm event is
|
||||
/// raised with <c>SourceNodeId=device-host-address</c>. Callers that want per-device
|
||||
/// filtering can pass the specific host addresses as <c>sourceNodeIds</c> and the
|
||||
/// projection will skip devices not listed.
|
||||
/// </remarks>
|
||||
internal sealed class FocasAlarmProjection : IAsyncDisposable
|
||||
{
|
||||
private readonly FocasDriver _driver;
|
||||
private readonly TimeSpan _pollInterval;
|
||||
private readonly Dictionary<long, Subscription> _subs = new();
|
||||
private readonly Lock _subsLock = new();
|
||||
private long _nextId;
|
||||
|
||||
public FocasAlarmProjection(FocasDriver driver, TimeSpan pollInterval)
|
||||
{
|
||||
_driver = driver;
|
||||
_pollInterval = pollInterval;
|
||||
}
|
||||
|
||||
public Task<IAlarmSubscriptionHandle> SubscribeAsync(
|
||||
IReadOnlyList<string> sourceNodeIds, CancellationToken cancellationToken)
|
||||
{
|
||||
var id = Interlocked.Increment(ref _nextId);
|
||||
var handle = new FocasAlarmSubscriptionHandle(id);
|
||||
var cts = new CancellationTokenSource();
|
||||
// Empty filter = listen to every configured device. Otherwise only devices whose
|
||||
// host address appears in sourceNodeIds are polled.
|
||||
var filter = sourceNodeIds.Count == 0
|
||||
? null
|
||||
: new HashSet<string>(sourceNodeIds, StringComparer.OrdinalIgnoreCase);
|
||||
var sub = new Subscription(handle, filter, cts);
|
||||
|
||||
lock (_subsLock) _subs[id] = sub;
|
||||
|
||||
sub.Loop = Task.Run(() => RunPollLoopAsync(sub, cts.Token), cts.Token);
|
||||
return Task.FromResult<IAlarmSubscriptionHandle>(handle);
|
||||
}
|
||||
|
||||
public async Task UnsubscribeAsync(IAlarmSubscriptionHandle handle, CancellationToken cancellationToken)
|
||||
{
|
||||
if (handle is not FocasAlarmSubscriptionHandle h) return;
|
||||
Subscription? sub;
|
||||
lock (_subsLock)
|
||||
{
|
||||
if (!_subs.Remove(h.Id, out sub)) return;
|
||||
}
|
||||
try { sub.Cts.Cancel(); } catch { }
|
||||
try { await sub.Loop.ConfigureAwait(false); } catch { }
|
||||
sub.Cts.Dispose();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// FOCAS has no ack wire call — the CNC clears alarms on its own when the underlying
|
||||
/// condition resolves. Swallow the request so capability negotiation succeeds, rather
|
||||
/// than surfacing a confusing "not supported" error to the operator.
|
||||
/// </summary>
|
||||
public Task AcknowledgeAsync(
|
||||
IReadOnlyList<AlarmAcknowledgeRequest> acknowledgements, CancellationToken cancellationToken) =>
|
||||
Task.CompletedTask;
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
List<Subscription> snap;
|
||||
lock (_subsLock) { snap = [.. _subs.Values]; _subs.Clear(); }
|
||||
foreach (var sub in snap)
|
||||
{
|
||||
try { sub.Cts.Cancel(); } catch { }
|
||||
try { await sub.Loop.ConfigureAwait(false); } catch { }
|
||||
sub.Cts.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// One poll-tick for one device. Diffs the new alarm list against the previous snapshot,
|
||||
/// emits raise + clear events. Extracted so tests can drive a tick without spinning up
|
||||
/// the full Task.Run loop.
|
||||
/// </summary>
|
||||
internal void Tick(Subscription sub, string deviceHostAddress, IReadOnlyList<FocasActiveAlarm> current)
|
||||
{
|
||||
var prev = sub.LastByDevice.GetValueOrDefault(deviceHostAddress) ?? [];
|
||||
var nowKeys = current.Select(a => AlarmKey(a)).ToHashSet();
|
||||
var prevKeys = prev.Select(a => AlarmKey(a)).ToHashSet();
|
||||
|
||||
foreach (var a in current)
|
||||
{
|
||||
if (prevKeys.Contains(AlarmKey(a))) continue;
|
||||
_driver.InvokeAlarmEvent(new AlarmEventArgs(
|
||||
sub.Handle,
|
||||
SourceNodeId: deviceHostAddress,
|
||||
ConditionId: $"{deviceHostAddress}#{AlarmKey(a)}",
|
||||
AlarmType: MapAlarmType(a.Type),
|
||||
Message: a.Message,
|
||||
Severity: MapSeverity(a.Type),
|
||||
SourceTimestampUtc: DateTime.UtcNow));
|
||||
}
|
||||
|
||||
foreach (var a in prev)
|
||||
{
|
||||
if (nowKeys.Contains(AlarmKey(a))) continue;
|
||||
_driver.InvokeAlarmEvent(new AlarmEventArgs(
|
||||
sub.Handle,
|
||||
SourceNodeId: deviceHostAddress,
|
||||
ConditionId: $"{deviceHostAddress}#{AlarmKey(a)}",
|
||||
AlarmType: MapAlarmType(a.Type),
|
||||
Message: $"{a.Message} (cleared)",
|
||||
Severity: MapSeverity(a.Type),
|
||||
SourceTimestampUtc: DateTime.UtcNow));
|
||||
}
|
||||
|
||||
sub.LastByDevice[deviceHostAddress] = [.. current];
|
||||
}
|
||||
|
||||
private async Task RunPollLoopAsync(Subscription sub, CancellationToken ct)
|
||||
{
|
||||
while (!ct.IsCancellationRequested)
|
||||
{
|
||||
try
|
||||
{
|
||||
foreach (var (host, alarms) in await _driver.ReadActiveAlarmsAcrossDevicesAsync(sub.DeviceFilter, ct).ConfigureAwait(false))
|
||||
{
|
||||
Tick(sub, host, alarms);
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException) when (ct.IsCancellationRequested) { break; }
|
||||
catch { /* per-tick failures are non-fatal — next tick retries */ }
|
||||
|
||||
try { await Task.Delay(_pollInterval, ct).ConfigureAwait(false); }
|
||||
catch (OperationCanceledException) { break; }
|
||||
}
|
||||
}
|
||||
|
||||
private static string AlarmKey(FocasActiveAlarm a) => $"{a.Type}:{a.AlarmNumber}";
|
||||
|
||||
/// <summary>Map FOCAS type to a human-readable category; falls back to the numeric type.</summary>
|
||||
internal static string MapAlarmType(short type) => type switch
|
||||
{
|
||||
FocasAlarmType.Parameter => "Parameter",
|
||||
FocasAlarmType.PulseCode => "PulseCode",
|
||||
FocasAlarmType.Overtravel => "Overtravel",
|
||||
FocasAlarmType.Overheat => "Overheat",
|
||||
FocasAlarmType.Servo => "Servo",
|
||||
FocasAlarmType.DataIo => "DataIo",
|
||||
FocasAlarmType.MemoryCheck => "MemoryCheck",
|
||||
FocasAlarmType.MacroAlarm => "MacroAlarm",
|
||||
_ => $"Type{type}",
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Project FOCAS alarm types into the driver-agnostic 4-band severity. Overtravel /
|
||||
/// Servo / Emergency-equivalents are Critical; Parameter + Macro are Medium; rest land
|
||||
/// at High (everything else on a CNC is safety-relevant).
|
||||
/// </summary>
|
||||
internal static AlarmSeverity MapSeverity(short type) => type switch
|
||||
{
|
||||
FocasAlarmType.Overtravel => AlarmSeverity.Critical,
|
||||
FocasAlarmType.Servo => AlarmSeverity.Critical,
|
||||
FocasAlarmType.PulseCode => AlarmSeverity.Critical,
|
||||
FocasAlarmType.Parameter => AlarmSeverity.Medium,
|
||||
FocasAlarmType.MacroAlarm => AlarmSeverity.Medium,
|
||||
_ => AlarmSeverity.High,
|
||||
};
|
||||
|
||||
internal sealed class Subscription(
|
||||
FocasAlarmSubscriptionHandle handle,
|
||||
HashSet<string>? deviceFilter,
|
||||
CancellationTokenSource cts)
|
||||
{
|
||||
public FocasAlarmSubscriptionHandle Handle { get; } = handle;
|
||||
public HashSet<string>? DeviceFilter { get; } = deviceFilter;
|
||||
public CancellationTokenSource Cts { get; } = cts;
|
||||
public Task Loop { get; set; } = Task.CompletedTask;
|
||||
public Dictionary<string, IReadOnlyList<FocasActiveAlarm>> LastByDevice { get; } =
|
||||
new(StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Handle returned by <see cref="FocasAlarmProjection.SubscribeAsync"/>.</summary>
|
||||
public sealed record FocasAlarmSubscriptionHandle(long Id) : IAlarmSubscriptionHandle
|
||||
{
|
||||
public string DiagnosticId => $"focas-alarm-sub-{Id}";
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Admin.Services;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Admin.Tests;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class FocasDriverDetailServiceTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task GetAsync_returns_null_for_unknown_instance()
|
||||
{
|
||||
using var ctx = NewContext();
|
||||
var svc = new FocasDriverDetailService(ctx);
|
||||
(await svc.GetAsync("missing", CancellationToken.None)).ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetAsync_returns_null_for_non_focas_driver_type()
|
||||
{
|
||||
using var ctx = NewContext();
|
||||
ctx.DriverInstances.Add(NewInstance("drv-modbus", "ModbusTcp", "{}"));
|
||||
await ctx.SaveChangesAsync();
|
||||
|
||||
var svc = new FocasDriverDetailService(ctx);
|
||||
(await svc.GetAsync("drv-modbus", CancellationToken.None)).ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetAsync_parses_devices_tags_and_alarm_projection()
|
||||
{
|
||||
using var ctx = NewContext();
|
||||
ctx.DriverInstances.Add(NewInstance("drv-focas", "Focas", """
|
||||
{
|
||||
"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 }
|
||||
],
|
||||
"AlarmProjection": { "Enabled": true, "PollInterval": "00:00:05" },
|
||||
"HandleRecycle": { "Enabled": true, "Interval": "01:00:00" }
|
||||
}
|
||||
"""));
|
||||
await ctx.SaveChangesAsync();
|
||||
|
||||
var svc = new FocasDriverDetailService(ctx);
|
||||
var detail = await svc.GetAsync("drv-focas", CancellationToken.None);
|
||||
|
||||
detail.ShouldNotBeNull();
|
||||
detail.ParseError.ShouldBeNull();
|
||||
detail.Config.ShouldNotBeNull();
|
||||
detail.Config.Devices!.Single().HostAddress.ShouldBe("focas://10.20.30.40:8193");
|
||||
detail.Config.Devices!.Single().Series.ShouldBe("ThirtyOne_i");
|
||||
detail.Config.Tags!.Single().Name.ShouldBe("Mode");
|
||||
detail.Config.AlarmProjection!.Enabled.ShouldBeTrue();
|
||||
detail.Config.HandleRecycle!.Enabled.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetAsync_surfaces_parse_error_for_malformed_json()
|
||||
{
|
||||
using var ctx = NewContext();
|
||||
ctx.DriverInstances.Add(NewInstance("drv-bad", "Focas", "{ not-valid-json"));
|
||||
await ctx.SaveChangesAsync();
|
||||
|
||||
var svc = new FocasDriverDetailService(ctx);
|
||||
var detail = await svc.GetAsync("drv-bad", CancellationToken.None);
|
||||
|
||||
detail.ShouldNotBeNull();
|
||||
detail.ParseError.ShouldNotBeNull();
|
||||
detail.Config.ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetAsync_joins_host_status_rows_for_the_instance()
|
||||
{
|
||||
using var ctx = NewContext();
|
||||
ctx.DriverInstances.Add(NewInstance("drv-focas", "Focas", "{}"));
|
||||
ctx.DriverHostStatuses.Add(new DriverHostStatus
|
||||
{
|
||||
NodeId = "node-A",
|
||||
DriverInstanceId = "drv-focas",
|
||||
HostName = "focas://10.0.0.1:8193",
|
||||
State = DriverHostState.Running,
|
||||
StateChangedUtc = DateTime.UtcNow.AddMinutes(-5),
|
||||
LastSeenUtc = DateTime.UtcNow.AddSeconds(-3),
|
||||
});
|
||||
await ctx.SaveChangesAsync();
|
||||
|
||||
var svc = new FocasDriverDetailService(ctx);
|
||||
var detail = await svc.GetAsync("drv-focas", CancellationToken.None);
|
||||
|
||||
detail.ShouldNotBeNull();
|
||||
detail.HostStatuses.Count.ShouldBe(1);
|
||||
detail.HostStatuses[0].HostName.ShouldBe("focas://10.0.0.1:8193");
|
||||
detail.HostStatuses[0].State.ShouldBe("Running");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetAsync_picks_latest_generation_when_multiple_rows_exist()
|
||||
{
|
||||
using var ctx = NewContext();
|
||||
ctx.DriverInstances.Add(NewInstance("drv-focas", "Focas", "{\"Tags\":[]}", generationId: 1));
|
||||
ctx.DriverInstances.Add(NewInstance("drv-focas", "Focas", """{"Tags":[{"Name":"later"}]}""", generationId: 2));
|
||||
await ctx.SaveChangesAsync();
|
||||
|
||||
var svc = new FocasDriverDetailService(ctx);
|
||||
var detail = await svc.GetAsync("drv-focas", CancellationToken.None);
|
||||
|
||||
detail.ShouldNotBeNull();
|
||||
detail.Config!.Tags!.Single().Name.ShouldBe("later");
|
||||
}
|
||||
|
||||
private static DriverInstance NewInstance(
|
||||
string driverInstanceId, string driverType, string driverConfigJson, long generationId = 1) => new()
|
||||
{
|
||||
GenerationId = generationId,
|
||||
DriverInstanceId = driverInstanceId,
|
||||
ClusterId = "cluster-1",
|
||||
NamespaceId = "ns-1",
|
||||
Name = driverInstanceId,
|
||||
DriverType = driverType,
|
||||
DriverConfig = driverConfigJson,
|
||||
};
|
||||
|
||||
private static OtOpcUaConfigDbContext NewContext()
|
||||
{
|
||||
var opts = new DbContextOptionsBuilder<OtOpcUaConfigDbContext>()
|
||||
.UseInMemoryDatabase(Guid.NewGuid().ToString())
|
||||
.Options;
|
||||
return new OtOpcUaConfigDbContext(opts);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class FocasAlarmProjectionTests
|
||||
{
|
||||
private const string Host = "focas://10.0.0.5:8193";
|
||||
|
||||
private static (FocasDriver drv, FakeFocasClientFactory factory) NewDriver(bool alarmsEnabled)
|
||||
{
|
||||
var factory = new FakeFocasClientFactory();
|
||||
var drv = new FocasDriver(new FocasDriverOptions
|
||||
{
|
||||
Devices = [new FocasDeviceOptions(Host)],
|
||||
Tags = [],
|
||||
Probe = new FocasProbeOptions { Enabled = false },
|
||||
AlarmProjection = new FocasAlarmProjectionOptions
|
||||
{
|
||||
Enabled = alarmsEnabled,
|
||||
PollInterval = TimeSpan.FromMilliseconds(30),
|
||||
},
|
||||
}, "drv-1", factory);
|
||||
return (drv, factory);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Subscribe_without_Enable_throws_NotSupported()
|
||||
{
|
||||
var (drv, _) = NewDriver(alarmsEnabled: false);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
await Should.ThrowAsync<NotSupportedException>(() =>
|
||||
drv.SubscribeAlarmsAsync([], CancellationToken.None));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Raise_then_clear_emits_both_events()
|
||||
{
|
||||
var (drv, factory) = NewDriver(alarmsEnabled: true);
|
||||
factory.Customise = () => new FakeFocasClient();
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
var events = new List<AlarmEventArgs>();
|
||||
drv.OnAlarmEvent += (_, e) => { lock (events) events.Add(e); };
|
||||
|
||||
var sub = await drv.SubscribeAlarmsAsync([], CancellationToken.None);
|
||||
|
||||
// First tick creates the client via EnsureConnectedAsync — wait for it before we
|
||||
// poke the alarm list so we don't race the poll loop.
|
||||
await WaitFor(() => factory.Clients.Count > 0, TimeSpan.FromSeconds(3));
|
||||
var client = factory.Clients[0];
|
||||
client.Alarms.Add(new FocasActiveAlarm(500, FocasAlarmType.Overtravel, 1, "Axis 1 overtravel"));
|
||||
await WaitFor(() => events.Any(e => e.Message.Contains("overtravel")), TimeSpan.FromSeconds(3));
|
||||
|
||||
// Clear — the clear event wraps the original message with "(cleared)".
|
||||
client.Alarms.Clear();
|
||||
await WaitFor(() => events.Any(e => e.Message.Contains("cleared")), TimeSpan.FromSeconds(3));
|
||||
|
||||
await drv.UnsubscribeAlarmsAsync(sub, CancellationToken.None);
|
||||
await drv.ShutdownAsync(CancellationToken.None);
|
||||
|
||||
events.ShouldContain(e => e.AlarmType == "Overtravel" && e.Severity == AlarmSeverity.Critical);
|
||||
events.ShouldContain(e => e.Message.Contains("cleared"));
|
||||
events[0].SourceNodeId.ShouldBe(Host);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Tick_diffs_raises_and_clears_without_polling_loop()
|
||||
{
|
||||
// Drive Tick directly so the test isn't timing-dependent. The projection's
|
||||
// Tick() is internal so we reach it through the driver using a handcrafted
|
||||
// subscription — simpler than standing up the full loop.
|
||||
var (drv, factory) = NewDriver(alarmsEnabled: true);
|
||||
factory.Customise = () => new FakeFocasClient();
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
var projection = new FocasAlarmProjection(drv, TimeSpan.FromMinutes(1));
|
||||
var sub = new FocasAlarmProjection.Subscription(
|
||||
new FocasAlarmSubscriptionHandle(1), deviceFilter: null,
|
||||
new CancellationTokenSource());
|
||||
|
||||
var events = new List<AlarmEventArgs>();
|
||||
drv.OnAlarmEvent += (_, e) => events.Add(e);
|
||||
|
||||
// Tick 1 — raise two alarms.
|
||||
projection.Tick(sub, Host, [
|
||||
new FocasActiveAlarm(100, FocasAlarmType.Parameter, 0, "Param 100"),
|
||||
new FocasActiveAlarm(200, FocasAlarmType.Servo, 1, "Servo 200"),
|
||||
]);
|
||||
events.Count.ShouldBe(2);
|
||||
events[0].Severity.ShouldBe(AlarmSeverity.Medium);
|
||||
events[1].Severity.ShouldBe(AlarmSeverity.Critical);
|
||||
|
||||
// Tick 2 — same alarms stay active → no new events.
|
||||
events.Clear();
|
||||
projection.Tick(sub, Host, [
|
||||
new FocasActiveAlarm(100, FocasAlarmType.Parameter, 0, "Param 100"),
|
||||
new FocasActiveAlarm(200, FocasAlarmType.Servo, 1, "Servo 200"),
|
||||
]);
|
||||
events.ShouldBeEmpty();
|
||||
|
||||
// Tick 3 — one clears, one stays → one "cleared" event only.
|
||||
projection.Tick(sub, Host, [
|
||||
new FocasActiveAlarm(200, FocasAlarmType.Servo, 1, "Servo 200"),
|
||||
]);
|
||||
events.Count.ShouldBe(1);
|
||||
events[0].Message.ShouldEndWith("(cleared)");
|
||||
events[0].AlarmType.ShouldBe("Parameter");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Severity_mapping_matches_docs()
|
||||
{
|
||||
FocasAlarmProjection.MapSeverity(FocasAlarmType.Overtravel).ShouldBe(AlarmSeverity.Critical);
|
||||
FocasAlarmProjection.MapSeverity(FocasAlarmType.Servo).ShouldBe(AlarmSeverity.Critical);
|
||||
FocasAlarmProjection.MapSeverity(FocasAlarmType.PulseCode).ShouldBe(AlarmSeverity.Critical);
|
||||
FocasAlarmProjection.MapSeverity(FocasAlarmType.Parameter).ShouldBe(AlarmSeverity.Medium);
|
||||
FocasAlarmProjection.MapSeverity(FocasAlarmType.MacroAlarm).ShouldBe(AlarmSeverity.Medium);
|
||||
FocasAlarmProjection.MapSeverity(FocasAlarmType.Overheat).ShouldBe(AlarmSeverity.High);
|
||||
}
|
||||
|
||||
private static async Task WaitFor(Func<bool> pred, TimeSpan timeout)
|
||||
{
|
||||
var deadline = DateTime.UtcNow + timeout;
|
||||
while (DateTime.UtcNow < deadline)
|
||||
{
|
||||
if (pred()) return;
|
||||
await Task.Delay(30);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class FocasHandleRecycleTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task Recycle_loop_disposes_client_on_interval_reads_reopen_fresh_one()
|
||||
{
|
||||
var factory = new FakeFocasClientFactory();
|
||||
var drv = new FocasDriver(new FocasDriverOptions
|
||||
{
|
||||
Devices = [new FocasDeviceOptions("focas://10.0.0.5:8193")],
|
||||
Tags = [new FocasTagDefinition("R", "focas://10.0.0.5:8193", "R100", FocasDataType.Byte)],
|
||||
Probe = new FocasProbeOptions { Enabled = false },
|
||||
HandleRecycle = new FocasHandleRecycleOptions
|
||||
{
|
||||
Enabled = true,
|
||||
Interval = TimeSpan.FromMilliseconds(80),
|
||||
},
|
||||
}, "drv-1", factory);
|
||||
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
// First read forces the initial connect.
|
||||
await drv.ReadAsync(["R"], CancellationToken.None);
|
||||
var initialClients = factory.Clients.Count;
|
||||
initialClients.ShouldBe(1);
|
||||
|
||||
// Wait for a recycle tick, then read again — a new client must have been created.
|
||||
await WaitFor(() => factory.Clients[0].DisposeCount > 0, TimeSpan.FromSeconds(3));
|
||||
await drv.ReadAsync(["R"], CancellationToken.None);
|
||||
|
||||
factory.Clients.Count.ShouldBeGreaterThan(initialClients);
|
||||
|
||||
await drv.ShutdownAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Recycle_loop_stays_off_when_not_enabled()
|
||||
{
|
||||
var factory = new FakeFocasClientFactory();
|
||||
var drv = new FocasDriver(new FocasDriverOptions
|
||||
{
|
||||
Devices = [new FocasDeviceOptions("focas://10.0.0.5:8193")],
|
||||
Tags = [new FocasTagDefinition("R", "focas://10.0.0.5:8193", "R100", FocasDataType.Byte)],
|
||||
Probe = new FocasProbeOptions { Enabled = false },
|
||||
}, "drv-1", factory);
|
||||
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
await drv.ReadAsync(["R"], CancellationToken.None);
|
||||
await Task.Delay(150);
|
||||
|
||||
// With recycle off the same client stays live — no Dispose during the window.
|
||||
factory.Clients.Count.ShouldBe(1);
|
||||
factory.Clients[0].DisposeCount.ShouldBe(0);
|
||||
|
||||
await drv.ShutdownAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
private static async Task WaitFor(Func<bool> pred, TimeSpan timeout)
|
||||
{
|
||||
var deadline = DateTime.UtcNow + timeout;
|
||||
while (DateTime.UtcNow < deadline)
|
||||
{
|
||||
if (pred()) return;
|
||||
await Task.Delay(20);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user