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:
@@ -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