diff --git a/docs/v2/implementation/focas-wire-protocol.md b/docs/v2/implementation/focas-wire-protocol.md new file mode 100644 index 0000000..58f0172 --- /dev/null +++ b/docs/v2/implementation/focas-wire-protocol.md @@ -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 `` (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 diff --git a/src/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Drivers/FocasDetail.razor b/src/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Drivers/FocasDetail.razor new file mode 100644 index 0000000..11ba185 --- /dev/null +++ b/src/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Drivers/FocasDetail.razor @@ -0,0 +1,224 @@ +@page "/drivers/focas/{InstanceId}" +@using ZB.MOM.WW.OtOpcUa.Admin.Services +@inject FocasDriverDetailService DetailSvc + +

FOCAS driver @InstanceId

+ +@if (_loading) +{ +

Loading…

+} +else if (_detail is null) +{ +
+ No FOCAS driver instance with id @InstanceId was found. +
+ Either the id is wrong, or the instance's DriverType is not "Focas". The list of drivers per cluster draft is on the Clusters page. +
+
+} +else +{ +
+
+
Name
+
@_detail.Instance.Name
+
+
+
Cluster
+
@_detail.Instance.ClusterId
+
+
+
Namespace
+
@_detail.Instance.NamespaceId
+
+
+
Enabled
+
@(_detail.Instance.Enabled ? "Yes" : "No")
+
+
+ + @if (_detail.ParseError is not null) + { +
+ DriverConfig JSON failed to parse: @_detail.ParseError +
+ Falling back to raw-JSON view below; the per-section tables are hidden because the shape couldn't be projected. +
+
+ } + else if (_detail.Config is not null) + { +

Devices

+ @if (_detail.Config.Devices is null || _detail.Config.Devices.Count == 0) + { +

No devices configured.

+ } + else + { + + + + @foreach (var d in _detail.Config.Devices) + { + + + + + + } + +
HostAddressDeviceNameSeries
@d.HostAddress@(d.DeviceName ?? "—")@(string.IsNullOrEmpty(d.Series) ? "Unknown" : d.Series)
+ } + +

Tags

+ @if (_detail.Config.Tags is null || _detail.Config.Tags.Count == 0) + { +

No tags configured.

+ } + else + { +

@_detail.Config.Tags.Count tag(s) configured.

+ + + + @foreach (var t in _detail.Config.Tags) + { + + + + + + + + } + +
NameDeviceAddressDataTypeWritable
@t.Name@t.DeviceHostAddress@t.Address@t.DataType@(t.Writable ? "Yes" : "No")
+ } + +

Driver behaviour

+ + + + + + + + + + + + + + + +
Probe + @if (_detail.Config.Probe is { } probe) + { + @(probe.Enabled ? "Enabled" : "Disabled") + Interval: @(probe.Interval ?? "default") + } + else { default (enabled) } +
Alarm projection + @if (_detail.Config.AlarmProjection is { } ap) + { + @(ap.Enabled ? "Enabled" : "Disabled") + PollInterval: @(ap.PollInterval ?? "default") + } + else { disabled (default) } +
Handle recycling + @if (_detail.Config.HandleRecycle is { } hr) + { + @(hr.Enabled ? "Enabled" : "Disabled") + Interval: @(hr.Interval ?? "default (01:00:00)") + } + else { disabled (default) } +
+ } + +

Host status

+ @if (_detail.HostStatuses.Count == 0) + { +
+ No DriverHostStatus 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. +
+ } + else + { + + + + + + + + + + + + + + + @foreach (var r in _detail.HostStatuses) + { + + + + + + + + + + + } + +
NodeHostStateFail#Breaker last openedLast recycledLast seenDetail
@r.NodeId@r.HostName@r.State@r.ConsecutiveFailures@FormatUtc(r.LastCircuitBreakerOpenUtc)@FormatUtc(r.LastRecycleUtc)@FormatAge(r.LastSeenUtc)@r.Detail
+ } + +

Raw DriverConfig JSON

+
@_detail.Instance.DriverConfig
+ +
+ Docs: docs/drivers/FOCAS.md (getting started) · docs/v2/focas-deployment.md (NSSM + pipe ACL) · docs/drivers/FOCAS-Test-Fixture.md (test coverage). +
+} + +@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'"); + } +} diff --git a/src/ZB.MOM.WW.OtOpcUa.Admin/Services/FocasDriverDetailService.cs b/src/ZB.MOM.WW.OtOpcUa.Admin/Services/FocasDriverDetailService.cs new file mode 100644 index 0000000..c185a75 --- /dev/null +++ b/src/ZB.MOM.WW.OtOpcUa.Admin/Services/FocasDriverDetailService.cs @@ -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; + +/// +/// Per-instance detail view for FOCAS driver rows. Loads the latest +/// row for the requested DriverInstanceId (most-recent +/// draft wins when multiple rows exist across generations), parses the schemaless +/// DriverConfig JSON into , and joins the +/// per-device rows so the Admin page can render host +/// state + consecutive-failure counters next to each configured device. +/// +public sealed class FocasDriverDetailService(OtOpcUaConfigDbContext db) +{ + private static readonly JsonSerializerOptions JsonOpts = new() + { + PropertyNameCaseInsensitive = true, + NumberHandling = JsonNumberHandling.AllowReadingFromString, + }; + + public async Task 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(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); + } +} + +/// Projected view of a FOCAS driver's parsed config. Unknown fields are ignored. +public sealed record FocasDriverConfigView +{ + public List? Devices { get; set; } + public List? 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; } +} + +/// Composite payload returned to the Admin page. +public sealed record FocasDriverDetail( + DriverInstance Instance, + FocasDriverConfigView? Config, + string? ParseError, + IReadOnlyList HostStatuses); + +public sealed record FocasHostStatusRow( + string NodeId, + string HostName, + string State, + DateTime StateChangedUtc, + DateTime LastSeenUtc, + string? Detail, + int ConsecutiveFailures, + DateTime? LastCircuitBreakerOpenUtc, + DateTime? LastRecycleUtc); diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasAlarmProjection.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasAlarmProjection.cs new file mode 100644 index 0000000..8ce93ae --- /dev/null +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasAlarmProjection.cs @@ -0,0 +1,195 @@ +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; + +namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS; + +/// +/// Polls each device's CNC active-alarm list via +/// on a timer and translates raise / clear transitions into +/// events on the owning . One poll loop per subscription; the +/// loop fans out across every configured device and diffs the (AlarmNumber, +/// Type) keyed active-alarm set between ticks. +/// +/// +/// FOCAS alarms are flat per session — the CNC exposes a single active-alarm list via +/// cnc_rdalmmsg2, not per-node structures the way Galaxy / AbCip ALMD do. So the +/// projection ignores sourceNodeIds at the member level: every alarm event is +/// raised with SourceNodeId=device-host-address. Callers that want per-device +/// filtering can pass the specific host addresses as sourceNodeIds and the +/// projection will skip devices not listed. +/// +internal sealed class FocasAlarmProjection : IAsyncDisposable +{ + private readonly FocasDriver _driver; + private readonly TimeSpan _pollInterval; + private readonly Dictionary _subs = new(); + private readonly Lock _subsLock = new(); + private long _nextId; + + public FocasAlarmProjection(FocasDriver driver, TimeSpan pollInterval) + { + _driver = driver; + _pollInterval = pollInterval; + } + + public Task SubscribeAsync( + IReadOnlyList 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(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(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(); + } + + /// + /// 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. + /// + public Task AcknowledgeAsync( + IReadOnlyList acknowledgements, CancellationToken cancellationToken) => + Task.CompletedTask; + + public async ValueTask DisposeAsync() + { + List 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(); + } + } + + /// + /// 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. + /// + internal void Tick(Subscription sub, string deviceHostAddress, IReadOnlyList 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}"; + + /// Map FOCAS type to a human-readable category; falls back to the numeric type. + 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}", + }; + + /// + /// 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). + /// + 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? deviceFilter, + CancellationTokenSource cts) + { + public FocasAlarmSubscriptionHandle Handle { get; } = handle; + public HashSet? DeviceFilter { get; } = deviceFilter; + public CancellationTokenSource Cts { get; } = cts; + public Task Loop { get; set; } = Task.CompletedTask; + public Dictionary> LastByDevice { get; } = + new(StringComparer.OrdinalIgnoreCase); + } +} + +/// Handle returned by . +public sealed record FocasAlarmSubscriptionHandle(long Id) : IAlarmSubscriptionHandle +{ + public string DiagnosticId => $"focas-alarm-sub-{Id}"; +} diff --git a/tests/ZB.MOM.WW.OtOpcUa.Admin.Tests/FocasDriverDetailServiceTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Admin.Tests/FocasDriverDetailServiceTests.cs new file mode 100644 index 0000000..b71c4e8 --- /dev/null +++ b/tests/ZB.MOM.WW.OtOpcUa.Admin.Tests/FocasDriverDetailServiceTests.cs @@ -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() + .UseInMemoryDatabase(Guid.NewGuid().ToString()) + .Options; + return new OtOpcUaConfigDbContext(opts); + } +} diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasAlarmProjectionTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasAlarmProjectionTests.cs new file mode 100644 index 0000000..c21a9b7 --- /dev/null +++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasAlarmProjectionTests.cs @@ -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(() => + 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(); + 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(); + 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 pred, TimeSpan timeout) + { + var deadline = DateTime.UtcNow + timeout; + while (DateTime.UtcNow < deadline) + { + if (pred()) return; + await Task.Delay(30); + } + } +} diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasHandleRecycleTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasHandleRecycleTests.cs new file mode 100644 index 0000000..56fc612 --- /dev/null +++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasHandleRecycleTests.cs @@ -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 pred, TimeSpan timeout) + { + var deadline = DateTime.UtcNow + timeout; + while (DateTime.UtcNow < deadline) + { + if (pred()) return; + await Task.Delay(20); + } + } +}