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,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);
}
}

View File

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

View File

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