From 55245a962eefda776ef1fc932f911ec2084608bf Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Tue, 21 Apr 2026 11:06:08 -0400 Subject: [PATCH] =?UTF-8?q?Task=20#210=20=E2=80=94=20Modbus=20server-side?= =?UTF-8?q?=20factory=20+=20seed=20SQL=20(closes=20first=20of=20#209=20umb?= =?UTF-8?q?rella)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Parent: #209. Adds the server-side wiring so a Config DB `DriverType='Modbus'` row actually boots a Modbus driver instance + publishes its tags under OPC UA NodeIds, instead of being silently skipped by DriverInstanceBootstrapper. Changes: - `ModbusDriverFactoryExtensions` (new) — mirrors `GalaxyProxyDriverFactoryExtensions` + `FocasDriverFactoryExtensions`. `DriverTypeName="Modbus"`, `CreateInstance` deserialises `ModbusDriverConfigDto` (Host/Port/UnitId/TimeoutMs/Probe/Tags) to a full `ModbusDriverOptions` and hands back a `ModbusDriver`. Strict enum parsing (Region / DataType / ByteOrder / StringByteOrder) — unknown values fail fast with an explicit "expected one of" error rather than at first read. - `Program.cs` — register the factory after Galaxy + FOCAS. - `Driver.Modbus.csproj` — add `Core` project reference (the DI-free factory needs `DriverFactoryRegistry` from `Core.Hosting`). Matches the FOCAS driver's reference shape. - `Server.csproj` — add the `Driver.Modbus` ProjectReference so the Program.cs registration compiles against the same assembly the server loads at runtime. - `scripts/smoke/seed-modbus-smoke.sql` (new) — one-cluster smoke seed modelled on `seed-phase-7-smoke.sql`. Creates a `modbus-smoke` cluster + `modbus-smoke-node` + Draft generation + Namespace + UnsArea/UnsLine/ Equipment + one Modbus `DriverInstance` pointing at the pymodbus standard fixture (`127.0.0.1:5020`) + one Tag at `HR[200]:UInt16`, ending in `EXEC sp_PublishGeneration`. HR[100] is deliberately *not* used because pymodbus `standard.json` runs an auto-increment action on that register. Full-solution build: 0 errors, only the pre-existing xUnit1051 warnings. AB CIP / S7 / AB Legacy factories follow in their own PRs per #211 / #212 / #213. Live boot verification happens in the exit-gate PR once all four factories are in place. Co-Authored-By: Claude Opus 4.7 (1M context) --- scripts/smoke/seed-modbus-smoke.sql | 139 ++++++++++++++++++ .../ModbusDriverFactoryExtensions.cs | 132 +++++++++++++++++ .../ZB.MOM.WW.OtOpcUa.Driver.Modbus.csproj | 1 + src/ZB.MOM.WW.OtOpcUa.Server/Program.cs | 2 + .../ZB.MOM.WW.OtOpcUa.Server.csproj | 1 + 5 files changed, 275 insertions(+) create mode 100644 scripts/smoke/seed-modbus-smoke.sql create mode 100644 src/ZB.MOM.WW.OtOpcUa.Driver.Modbus/ModbusDriverFactoryExtensions.cs diff --git a/scripts/smoke/seed-modbus-smoke.sql b/scripts/smoke/seed-modbus-smoke.sql new file mode 100644 index 0000000..e78efb5 --- /dev/null +++ b/scripts/smoke/seed-modbus-smoke.sql @@ -0,0 +1,139 @@ +-- Modbus e2e smoke seed — closes #210 (umbrella #209). +-- +-- Idempotent — DROP-and-recreate of one cluster's worth of Modbus test config: +-- * 1 ServerCluster ('modbus-smoke') + ClusterNode ('modbus-smoke-node') +-- * 1 ConfigGeneration (Draft → Published at the end) +-- * 1 Namespace + UnsArea + UnsLine + Equipment +-- * 1 Modbus DriverInstance pointing at the pymodbus standard fixture +-- (127.0.0.1:5020 per tests/.../Modbus.IntegrationTests/Docker) +-- * 1 Tag at HR[200]:UInt16 (HR[100] is auto-increment in standard.json, +-- unusable as a write target — the e2e script uses HR[200] for that reason) +-- +-- Usage: +-- sqlcmd -S "localhost,14330" -d OtOpcUaConfig -U sa -P "OtOpcUaDev_2026!" \ +-- -i scripts/smoke/seed-modbus-smoke.sql +-- +-- After seeding, update src/.../Server/appsettings.json: +-- Node:NodeId = "modbus-smoke-node" +-- Node:ClusterId = "modbus-smoke" +-- +-- Then start the simulator + server + run the e2e script: +-- docker compose -f tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests/Docker/docker-compose.yml --profile standard up -d +-- dotnet run --project src/ZB.MOM.WW.OtOpcUa.Server +-- ./scripts/e2e/test-modbus.ps1 -BridgeNodeId "ns=2;s=HR200" + +SET NOCOUNT ON; +SET XACT_ABORT ON; +SET QUOTED_IDENTIFIER ON; +SET ANSI_NULLS ON; +SET ANSI_PADDING ON; +SET ANSI_WARNINGS ON; +SET ARITHABORT ON; +SET CONCAT_NULL_YIELDS_NULL ON; + +DECLARE @ClusterId nvarchar(64) = 'modbus-smoke'; +DECLARE @NodeId nvarchar(64) = 'modbus-smoke-node'; +DECLARE @DrvId nvarchar(64) = 'modbus-smoke-drv'; +DECLARE @NsId nvarchar(64) = 'modbus-smoke-ns'; +DECLARE @AreaId nvarchar(64) = 'modbus-smoke-area'; +DECLARE @LineId nvarchar(64) = 'modbus-smoke-line'; +DECLARE @EqId nvarchar(64) = 'modbus-smoke-eq'; +DECLARE @EqUuid uniqueidentifier = '72BD5A10-72BD-45A1-B72B-D5A1072BD5A1'; +DECLARE @TagHr200 nvarchar(64) = 'modbus-smoke-tag-hr200'; + +BEGIN TRAN; + +-- Clean prior smoke state (child rows first). +DELETE FROM dbo.Tag WHERE TagId IN (@TagHr200); +DELETE FROM dbo.Equipment WHERE EquipmentId = @EqId; +DELETE FROM dbo.UnsLine WHERE UnsLineId = @LineId; +DELETE FROM dbo.UnsArea WHERE UnsAreaId = @AreaId; +DELETE FROM dbo.DriverInstance WHERE DriverInstanceId = @DrvId; +DELETE FROM dbo.Namespace WHERE NamespaceId = @NsId; +DELETE FROM dbo.ConfigGeneration WHERE ClusterId = @ClusterId; +DELETE FROM dbo.ClusterNodeCredential WHERE NodeId = @NodeId; +DELETE FROM dbo.ClusterNodeGenerationState WHERE NodeId = @NodeId; +DELETE FROM dbo.ClusterNode WHERE NodeId = @NodeId; +DELETE FROM dbo.ServerCluster WHERE ClusterId = @ClusterId; + +-- 1. Cluster + Node. +INSERT dbo.ServerCluster(ClusterId, Name, Enterprise, Site, NodeCount, RedundancyMode, Enabled, CreatedBy) +VALUES (@ClusterId, 'Modbus Smoke', 'zb', 'lab', 1, 'None', 1, 'modbus-smoke'); + +INSERT dbo.ClusterNode(NodeId, ClusterId, RedundancyRole, Host, OpcUaPort, DashboardPort, + ApplicationUri, ServiceLevelBase, Enabled, CreatedBy) +VALUES (@NodeId, @ClusterId, 'Primary', 'localhost', 4840, 5000, + 'urn:OtOpcUa:modbus-smoke-node', 200, 1, 'modbus-smoke'); + +-- 2. Draft generation. +DECLARE @Gen bigint; +INSERT dbo.ConfigGeneration(ClusterId, Status, CreatedBy) +VALUES (@ClusterId, 'Draft', 'modbus-smoke'); +SET @Gen = SCOPE_IDENTITY(); + +-- 3. Namespace. +INSERT dbo.Namespace(GenerationId, NamespaceId, ClusterId, Kind, NamespaceUri, Enabled) +VALUES (@Gen, @NsId, @ClusterId, 'Equipment', 'urn:modbus-smoke:eq', 1); + +-- 4. UNS hierarchy. +INSERT dbo.UnsArea(GenerationId, UnsAreaId, ClusterId, Name) +VALUES (@Gen, @AreaId, @ClusterId, 'lab-floor'); + +INSERT dbo.UnsLine(GenerationId, UnsLineId, UnsAreaId, Name) +VALUES (@Gen, @LineId, @AreaId, 'modbus-line'); + +INSERT dbo.Equipment(GenerationId, EquipmentId, EquipmentUuid, DriverInstanceId, UnsLineId, + Name, MachineCode, Enabled) +VALUES (@Gen, @EqId, @EqUuid, @DrvId, @LineId, 'modbus-sim', 'modbus-001', 1); + +-- 5. Modbus DriverInstance. DriverConfig mirrors ModbusDriverConfigDto +-- (mapped to ModbusDriverOptions by ModbusDriverFactoryExtensions). +INSERT dbo.DriverInstance(GenerationId, DriverInstanceId, ClusterId, NamespaceId, + Name, DriverType, DriverConfig, Enabled) +VALUES (@Gen, @DrvId, @ClusterId, @NsId, 'pymodbus-smoke', 'Modbus', N'{ + "Host": "127.0.0.1", + "Port": 5020, + "UnitId": 1, + "TimeoutMs": 2000, + "AutoReconnect": true, + "Probe": { "Enabled": true, "IntervalMs": 5000, "TimeoutMs": 2000, "ProbeAddress": 0 }, + "Tags": [ + { + "Name": "HR200", + "Region": "HoldingRegisters", + "Address": 200, + "DataType": "UInt16", + "Writable": true, + "WriteIdempotent": true + } + ] +}', 1); + +-- 6. Tag row bound to the Equipment. Driver reports the same tag via +-- DiscoverAsync + the walker maps the UnsArea/Line/Equipment/Tag path to the +-- driver's folder/variable (NodeId ends up ns=;s=HR200 per +-- ModbusDriver.DiscoverAsync using FullName = tag.Name). +INSERT dbo.Tag(GenerationId, TagId, DriverInstanceId, EquipmentId, Name, DataType, + AccessLevel, TagConfig, WriteIdempotent) +VALUES (@Gen, @TagHr200, @DrvId, @EqId, 'HR200', 'UInt16', 'ReadWrite', + N'{"FullName":"HR200","DataType":"UInt16"}', 1); + +-- 7. Publish the generation — flips Status Draft → Published, merges +-- ExternalIdReservation, claims cluster write lock. +EXEC dbo.sp_PublishGeneration @ClusterId = @ClusterId, @DraftGenerationId = @Gen, + @Notes = N'Modbus smoke — task #210'; + +COMMIT; + +PRINT ''; +PRINT 'Modbus smoke seed complete.'; +PRINT ' Cluster: ' + @ClusterId; +PRINT ' Node: ' + @NodeId; +PRINT ' Generation: ' + CONVERT(nvarchar(20), @Gen); +PRINT ''; +PRINT 'Next steps:'; +PRINT ' 1. Set src/.../Server/appsettings.json Node:NodeId = "modbus-smoke-node"'; +PRINT ' Node:ClusterId = "modbus-smoke"'; +PRINT ' 2. docker compose -f tests/.../Modbus.IntegrationTests/Docker/docker-compose.yml --profile standard up -d'; +PRINT ' 3. dotnet run --project src/ZB.MOM.WW.OtOpcUa.Server'; +PRINT ' 4. ./scripts/e2e/test-modbus.ps1 -BridgeNodeId "ns=2;s=HR200"'; diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.Modbus/ModbusDriverFactoryExtensions.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.Modbus/ModbusDriverFactoryExtensions.cs new file mode 100644 index 0000000..d86772d --- /dev/null +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.Modbus/ModbusDriverFactoryExtensions.cs @@ -0,0 +1,132 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using ZB.MOM.WW.OtOpcUa.Core.Hosting; + +namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus; + +/// +/// Static factory registration helper for . Server's Program.cs +/// calls once at startup; the bootstrapper (task #248) then +/// materialises Modbus DriverInstance rows from the central config DB into live driver +/// instances. Mirrors GalaxyProxyDriverFactoryExtensions / FocasDriverFactoryExtensions. +/// +public static class ModbusDriverFactoryExtensions +{ + public const string DriverTypeName = "Modbus"; + + public static void Register(DriverFactoryRegistry registry) + { + ArgumentNullException.ThrowIfNull(registry); + registry.Register(DriverTypeName, CreateInstance); + } + + internal static ModbusDriver CreateInstance(string driverInstanceId, string driverConfigJson) + { + ArgumentException.ThrowIfNullOrWhiteSpace(driverInstanceId); + ArgumentException.ThrowIfNullOrWhiteSpace(driverConfigJson); + + var dto = JsonSerializer.Deserialize(driverConfigJson, JsonOptions) + ?? throw new InvalidOperationException( + $"Modbus driver config for '{driverInstanceId}' deserialised to null"); + + if (string.IsNullOrWhiteSpace(dto.Host)) + throw new InvalidOperationException( + $"Modbus driver config for '{driverInstanceId}' missing required Host"); + + var options = new ModbusDriverOptions + { + Host = dto.Host!, + Port = dto.Port ?? 502, + UnitId = dto.UnitId ?? 1, + Timeout = TimeSpan.FromMilliseconds(dto.TimeoutMs ?? 2_000), + MaxRegistersPerRead = dto.MaxRegistersPerRead ?? 125, + MaxRegistersPerWrite = dto.MaxRegistersPerWrite ?? 123, + AutoReconnect = dto.AutoReconnect ?? true, + Tags = dto.Tags is { Count: > 0 } + ? [.. dto.Tags.Select(t => BuildTag(t, driverInstanceId))] + : [], + Probe = new ModbusProbeOptions + { + Enabled = dto.Probe?.Enabled ?? true, + Interval = TimeSpan.FromMilliseconds(dto.Probe?.IntervalMs ?? 5_000), + Timeout = TimeSpan.FromMilliseconds(dto.Probe?.TimeoutMs ?? 2_000), + ProbeAddress = dto.Probe?.ProbeAddress ?? 0, + }, + }; + + return new ModbusDriver(options, driverInstanceId); + } + + private static ModbusTagDefinition BuildTag(ModbusTagDto t, string driverInstanceId) => + new( + Name: t.Name ?? throw new InvalidOperationException( + $"Modbus config for '{driverInstanceId}' has a tag missing Name"), + Region: ParseEnum(t.Region, t.Name, driverInstanceId, "Region"), + Address: t.Address ?? throw new InvalidOperationException( + $"Modbus tag '{t.Name}' in '{driverInstanceId}' missing Address"), + DataType: ParseEnum(t.DataType, t.Name, driverInstanceId, "DataType"), + Writable: t.Writable ?? true, + ByteOrder: t.ByteOrder is null + ? ModbusByteOrder.BigEndian + : ParseEnum(t.ByteOrder, t.Name, driverInstanceId, "ByteOrder"), + BitIndex: t.BitIndex ?? 0, + StringLength: t.StringLength ?? 0, + StringByteOrder: t.StringByteOrder is null + ? ModbusStringByteOrder.HighByteFirst + : ParseEnum(t.StringByteOrder, t.Name, driverInstanceId, "StringByteOrder"), + WriteIdempotent: t.WriteIdempotent ?? false); + + private static T ParseEnum(string? raw, string? tagName, string driverInstanceId, string field) where T : struct, Enum + { + if (string.IsNullOrWhiteSpace(raw)) + throw new InvalidOperationException( + $"Modbus tag '{tagName ?? ""}' in '{driverInstanceId}' missing {field}"); + return Enum.TryParse(raw, ignoreCase: true, out var v) + ? v + : throw new InvalidOperationException( + $"Modbus tag '{tagName}' has unknown {field} '{raw}'. " + + $"Expected one of {string.Join(", ", Enum.GetNames())}"); + } + + private static readonly JsonSerializerOptions JsonOptions = new() + { + PropertyNameCaseInsensitive = true, + ReadCommentHandling = JsonCommentHandling.Skip, + AllowTrailingCommas = true, + }; + + internal sealed class ModbusDriverConfigDto + { + public string? Host { get; init; } + public int? Port { get; init; } + public byte? UnitId { get; init; } + public int? TimeoutMs { get; init; } + public ushort? MaxRegistersPerRead { get; init; } + public ushort? MaxRegistersPerWrite { get; init; } + public bool? AutoReconnect { get; init; } + public List? Tags { get; init; } + public ModbusProbeDto? Probe { get; init; } + } + + internal sealed class ModbusTagDto + { + public string? Name { get; init; } + public string? Region { get; init; } + public ushort? Address { get; init; } + public string? DataType { get; init; } + public bool? Writable { get; init; } + public string? ByteOrder { get; init; } + public byte? BitIndex { get; init; } + public ushort? StringLength { get; init; } + public string? StringByteOrder { get; init; } + public bool? WriteIdempotent { get; init; } + } + + internal sealed class ModbusProbeDto + { + public bool? Enabled { get; init; } + public int? IntervalMs { get; init; } + public int? TimeoutMs { get; init; } + public ushort? ProbeAddress { get; init; } + } +} diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.Modbus/ZB.MOM.WW.OtOpcUa.Driver.Modbus.csproj b/src/ZB.MOM.WW.OtOpcUa.Driver.Modbus/ZB.MOM.WW.OtOpcUa.Driver.Modbus.csproj index 4d72868..5a77a80 100644 --- a/src/ZB.MOM.WW.OtOpcUa.Driver.Modbus/ZB.MOM.WW.OtOpcUa.Driver.Modbus.csproj +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.Modbus/ZB.MOM.WW.OtOpcUa.Driver.Modbus.csproj @@ -13,6 +13,7 @@ + diff --git a/src/ZB.MOM.WW.OtOpcUa.Server/Program.cs b/src/ZB.MOM.WW.OtOpcUa.Server/Program.cs index 0d62a8c..2c78ecf 100644 --- a/src/ZB.MOM.WW.OtOpcUa.Server/Program.cs +++ b/src/ZB.MOM.WW.OtOpcUa.Server/Program.cs @@ -11,6 +11,7 @@ using ZB.MOM.WW.OtOpcUa.Core.Hosting; using ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian; using ZB.MOM.WW.OtOpcUa.Driver.FOCAS; using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy; +using ZB.MOM.WW.OtOpcUa.Driver.Modbus; using ZB.MOM.WW.OtOpcUa.Server; using ZB.MOM.WW.OtOpcUa.Server.OpcUa; using ZB.MOM.WW.OtOpcUa.Server.Phase7; @@ -100,6 +101,7 @@ builder.Services.AddSingleton(_ => var registry = new DriverFactoryRegistry(); GalaxyProxyDriverFactoryExtensions.Register(registry); FocasDriverFactoryExtensions.Register(registry); + ModbusDriverFactoryExtensions.Register(registry); return registry; }); builder.Services.AddSingleton(); diff --git a/src/ZB.MOM.WW.OtOpcUa.Server/ZB.MOM.WW.OtOpcUa.Server.csproj b/src/ZB.MOM.WW.OtOpcUa.Server/ZB.MOM.WW.OtOpcUa.Server.csproj index 4879b83..ca94418 100644 --- a/src/ZB.MOM.WW.OtOpcUa.Server/ZB.MOM.WW.OtOpcUa.Server.csproj +++ b/src/ZB.MOM.WW.OtOpcUa.Server/ZB.MOM.WW.OtOpcUa.Server.csproj @@ -36,6 +36,7 @@ +