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:
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}";
|
||||
}
|
||||
Reference in New Issue
Block a user