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:
Joseph Doherty
2026-04-24 14:09:51 -04:00
parent 21e0fdd4cd
commit 404b54add0
7 changed files with 1178 additions and 0 deletions

View File

@@ -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'");
}
}

View 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);

View 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}";
}