From 7ba783de7769f90b6741fd97900a6418d915e7ac Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Tue, 21 Apr 2026 11:15:38 -0400 Subject: [PATCH] =?UTF-8?q?Tasks=20#211=20#212=20#213=20=E2=80=94=20AbCip?= =?UTF-8?q?=20/=20S7=20/=20AbLegacy=20server-side=20factories=20+=20seed?= =?UTF-8?q?=20SQL?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Parent: #209. Follow-up to #210 (Modbus). Registers the remaining three non-Galaxy driver factories so a Config DB `DriverType` in {`AbCip`, `S7`, `AbLegacy`} actually boots a live driver instead of being silently skipped by DriverInstanceBootstrapper. Each factory follows the same shape as ModbusDriverFactoryExtensions + the existing Galaxy + FOCAS patterns: - Static `Register(DriverFactoryRegistry)` entry point. - Internal `CreateInstance(driverInstanceId, driverConfigJson)` — deserialises a DTO, strict-parses enum fields (fail-fast with an explicit "expected one of" list), composes the driver's options object, returns a new driver. - DriverType keys: `"AbCip"`, `"S7"`, `"AbLegacy"` (case-insensitive at the registry layer). DTO surfaces cover every option the respective driver's Options class exposes — devices, tags, probe, timeouts, per-driver quirks (AbCip `EnableControllerBrowse` / `EnableAlarmProjection`, S7 Rack/Slot/ CpuType, AbLegacy PlcFamily). Seed SQL (mirrors `seed-modbus-smoke.sql` shape): - `seed-abcip-smoke.sql` — `abcip-smoke` cluster + ControlLogix device + `TestDINT:DInt` tag, pointing at the ab_server compose fixture (`ab://127.0.0.1:44818/1,0`). - `seed-s7-smoke.sql` — `s7-smoke` cluster + S71500 CPU + `DB1.DBW0:Int16` tag at the python-snap7 fixture (`127.0.0.1:1102`, non-priv port). - `seed-ablegacy-smoke.sql` — `ablegacy-smoke` cluster + SLC 500 + `N7:5` tag. Hardware-gated per #222; placeholder gateway to be replaced with real SLC/MicroLogix/PLC-5/RSEmulate before running. Build plumbing: - Each driver project now ProjectReferences `Core` (was `Core.Abstractions`-only). `DriverFactoryRegistry` lives in `Core.Hosting` so the factory extensions can't compile without it. Matches the FOCAS + Galaxy.Proxy reference shape. - `Server.csproj` adds the three new driver ProjectReferences so Program.cs resolves the symbols at compile-time + ships the assemblies at runtime. Full-solution build: 0 errors, 334 pre-existing xUnit1051 warnings only. Live boot verification of all four (Modbus + these three) happens in the exit-gate PR — factories + seeds are pre-conditions and are being shipped first so the exit-gate PR can scope to "does the server publish the expected NodeIds + does the e2e script pass." Co-Authored-By: Claude Opus 4.7 (1M context) --- scripts/smoke/seed-abcip-smoke.sql | 123 ++++++++++++++ scripts/smoke/seed-ablegacy-smoke.sql | 117 ++++++++++++++ scripts/smoke/seed-s7-smoke.sql | 121 ++++++++++++++ .../AbCipDriverFactoryExtensions.cs | 151 ++++++++++++++++++ .../ZB.MOM.WW.OtOpcUa.Driver.AbCip.csproj | 1 + .../AbLegacyDriverFactoryExtensions.cs | 124 ++++++++++++++ .../ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.csproj | 1 + .../S7DriverFactoryExtensions.cs | 125 +++++++++++++++ .../ZB.MOM.WW.OtOpcUa.Driver.S7.csproj | 1 + src/ZB.MOM.WW.OtOpcUa.Server/Program.cs | 6 + .../ZB.MOM.WW.OtOpcUa.Server.csproj | 3 + 11 files changed, 773 insertions(+) create mode 100644 scripts/smoke/seed-abcip-smoke.sql create mode 100644 scripts/smoke/seed-ablegacy-smoke.sql create mode 100644 scripts/smoke/seed-s7-smoke.sql create mode 100644 src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriverFactoryExtensions.cs create mode 100644 src/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriverFactoryExtensions.cs create mode 100644 src/ZB.MOM.WW.OtOpcUa.Driver.S7/S7DriverFactoryExtensions.cs diff --git a/scripts/smoke/seed-abcip-smoke.sql b/scripts/smoke/seed-abcip-smoke.sql new file mode 100644 index 0000000..7130d5a --- /dev/null +++ b/scripts/smoke/seed-abcip-smoke.sql @@ -0,0 +1,123 @@ +-- AB CIP e2e smoke seed — closes #211 (umbrella #209). +-- +-- One-cluster seed pointing at the ab_server ControlLogix fixture +-- (`docker compose -f tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/Docker/docker-compose.yml --profile controllogix up -d`). +-- Publishes a single `TestDINT:DInt` tag under NodeId `ns=;s=TestDINT` +-- (ab_server seeds this tag by default). +-- +-- Usage: +-- sqlcmd -S "localhost,14330" -d OtOpcUaConfig -U sa -P "OtOpcUaDev_2026!" \ +-- -i scripts/smoke/seed-abcip-smoke.sql +-- +-- After seeding, point appsettings at this cluster: +-- Node:NodeId = "abcip-smoke-node" +-- Node:ClusterId = "abcip-smoke" +-- Then start server + run `./scripts/e2e/test-abcip.ps1 -BridgeNodeId "ns=2;s=TestDINT"`. + +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) = 'abcip-smoke'; +DECLARE @NodeId nvarchar(64) = 'abcip-smoke-node'; +DECLARE @DrvId nvarchar(64) = 'abcip-smoke-drv'; +DECLARE @NsId nvarchar(64) = 'abcip-smoke-ns'; +DECLARE @AreaId nvarchar(64) = 'abcip-smoke-area'; +DECLARE @LineId nvarchar(64) = 'abcip-smoke-line'; +DECLARE @EqId nvarchar(64) = 'abcip-smoke-eq'; +DECLARE @EqUuid uniqueidentifier = '41BC12E0-41BC-412E-841B-C12E041BC12E'; +DECLARE @TagId nvarchar(64) = 'abcip-smoke-tag-testdint'; + +BEGIN TRAN; + +DELETE FROM dbo.Tag WHERE TagId IN (@TagId); +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; + +INSERT dbo.ServerCluster(ClusterId, Name, Enterprise, Site, NodeCount, RedundancyMode, Enabled, CreatedBy) +VALUES (@ClusterId, 'AB CIP Smoke', 'zb', 'lab', 1, 'None', 1, 'abcip-smoke'); + +INSERT dbo.ClusterNode(NodeId, ClusterId, RedundancyRole, Host, OpcUaPort, DashboardPort, + ApplicationUri, ServiceLevelBase, Enabled, CreatedBy) +VALUES (@NodeId, @ClusterId, 'Primary', 'localhost', 4840, 5000, + 'urn:OtOpcUa:abcip-smoke-node', 200, 1, 'abcip-smoke'); + +DECLARE @Gen bigint; +INSERT dbo.ConfigGeneration(ClusterId, Status, CreatedBy) +VALUES (@ClusterId, 'Draft', 'abcip-smoke'); +SET @Gen = SCOPE_IDENTITY(); + +INSERT dbo.Namespace(GenerationId, NamespaceId, ClusterId, Kind, NamespaceUri, Enabled) +VALUES (@Gen, @NsId, @ClusterId, 'Equipment', 'urn:abcip-smoke:eq', 1); + +INSERT dbo.UnsArea(GenerationId, UnsAreaId, ClusterId, Name) +VALUES (@Gen, @AreaId, @ClusterId, 'lab-floor'); + +INSERT dbo.UnsLine(GenerationId, UnsLineId, UnsAreaId, Name) +VALUES (@Gen, @LineId, @AreaId, 'abcip-line'); + +INSERT dbo.Equipment(GenerationId, EquipmentId, EquipmentUuid, DriverInstanceId, UnsLineId, + Name, MachineCode, Enabled) +VALUES (@Gen, @EqId, @EqUuid, @DrvId, @LineId, 'ab-sim', 'abcip-001', 1); + +-- AB CIP DriverInstance — single ControlLogix device at the ab_server fixture +-- gateway. DriverConfig shape mirrors AbCipDriverConfigDto. +INSERT dbo.DriverInstance(GenerationId, DriverInstanceId, ClusterId, NamespaceId, + Name, DriverType, DriverConfig, Enabled) +VALUES (@Gen, @DrvId, @ClusterId, @NsId, 'ab-server-smoke', 'AbCip', N'{ + "TimeoutMs": 2000, + "Devices": [ + { + "HostAddress": "ab://127.0.0.1:44818/1,0", + "PlcFamily": "ControlLogix", + "DeviceName": "ab-server" + } + ], + "Probe": { "Enabled": true, "IntervalMs": 5000, "TimeoutMs": 2000 }, + "Tags": [ + { + "Name": "TestDINT", + "DeviceHostAddress": "ab://127.0.0.1:44818/1,0", + "TagPath": "TestDINT", + "DataType": "DInt", + "Writable": true, + "WriteIdempotent": true + } + ] +}', 1); + +INSERT dbo.Tag(GenerationId, TagId, DriverInstanceId, EquipmentId, Name, DataType, + AccessLevel, TagConfig, WriteIdempotent) +VALUES (@Gen, @TagId, @DrvId, @EqId, 'TestDINT', 'Int32', 'ReadWrite', + N'{"FullName":"TestDINT","DataType":"DInt"}', 1); + +EXEC dbo.sp_PublishGeneration @ClusterId = @ClusterId, @DraftGenerationId = @Gen, + @Notes = N'AB CIP smoke — task #211'; + +COMMIT; + +PRINT ''; +PRINT 'AB CIP 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 = "abcip-smoke-node"'; +PRINT ' Node:ClusterId = "abcip-smoke"'; +PRINT ' 2. docker compose -f tests/.../AbCip.IntegrationTests/Docker/docker-compose.yml --profile controllogix up -d'; +PRINT ' 3. dotnet run --project src/ZB.MOM.WW.OtOpcUa.Server'; +PRINT ' 4. ./scripts/e2e/test-abcip.ps1 -BridgeNodeId "ns=2;s=TestDINT"'; diff --git a/scripts/smoke/seed-ablegacy-smoke.sql b/scripts/smoke/seed-ablegacy-smoke.sql new file mode 100644 index 0000000..aed62f9 --- /dev/null +++ b/scripts/smoke/seed-ablegacy-smoke.sql @@ -0,0 +1,117 @@ +-- AB Legacy e2e smoke seed — closes #213 (umbrella #209). +-- +-- Hardware-gated. The ab_server PCCC dispatcher is upstream-broken (task #222 +-- tracks the lab rig + alternative fixtures), so verifying this seed end-to-end +-- requires real SLC 500 / MicroLogix / PLC-5 hardware or an RSEmulate 500 +-- golden-box. The factory + seed ship here so the wiring is in place the +-- moment real hardware becomes available — no server code changes needed. +-- +-- Usage (once hardware is reachable): +-- sqlcmd -S "localhost,14330" -d OtOpcUaConfig -U sa -P "OtOpcUaDev_2026!" \ +-- -i scripts/smoke/seed-ablegacy-smoke.sql +-- (Update the `HostAddress` below to point at the real gateway first.) + +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) = 'ablegacy-smoke'; +DECLARE @NodeId nvarchar(64) = 'ablegacy-smoke-node'; +DECLARE @DrvId nvarchar(64) = 'ablegacy-smoke-drv'; +DECLARE @NsId nvarchar(64) = 'ablegacy-smoke-ns'; +DECLARE @AreaId nvarchar(64) = 'ablegacy-smoke-area'; +DECLARE @LineId nvarchar(64) = 'ablegacy-smoke-line'; +DECLARE @EqId nvarchar(64) = 'ablegacy-smoke-eq'; +DECLARE @EqUuid uniqueidentifier = '5A1D2030-5A1D-4203-A5A1-D20305A1D203'; +DECLARE @TagId nvarchar(64) = 'ablegacy-smoke-tag-n7_5'; + +BEGIN TRAN; + +DELETE FROM dbo.Tag WHERE TagId IN (@TagId); +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; + +INSERT dbo.ServerCluster(ClusterId, Name, Enterprise, Site, NodeCount, RedundancyMode, Enabled, CreatedBy) +VALUES (@ClusterId, 'AB Legacy Smoke', 'zb', 'lab', 1, 'None', 1, 'ablegacy-smoke'); + +INSERT dbo.ClusterNode(NodeId, ClusterId, RedundancyRole, Host, OpcUaPort, DashboardPort, + ApplicationUri, ServiceLevelBase, Enabled, CreatedBy) +VALUES (@NodeId, @ClusterId, 'Primary', 'localhost', 4840, 5000, + 'urn:OtOpcUa:ablegacy-smoke-node', 200, 1, 'ablegacy-smoke'); + +DECLARE @Gen bigint; +INSERT dbo.ConfigGeneration(ClusterId, Status, CreatedBy) +VALUES (@ClusterId, 'Draft', 'ablegacy-smoke'); +SET @Gen = SCOPE_IDENTITY(); + +INSERT dbo.Namespace(GenerationId, NamespaceId, ClusterId, Kind, NamespaceUri, Enabled) +VALUES (@Gen, @NsId, @ClusterId, 'Equipment', 'urn:ablegacy-smoke:eq', 1); + +INSERT dbo.UnsArea(GenerationId, UnsAreaId, ClusterId, Name) +VALUES (@Gen, @AreaId, @ClusterId, 'lab-floor'); + +INSERT dbo.UnsLine(GenerationId, UnsLineId, UnsAreaId, Name) +VALUES (@Gen, @LineId, @AreaId, 'ablegacy-line'); + +INSERT dbo.Equipment(GenerationId, EquipmentId, EquipmentUuid, DriverInstanceId, UnsLineId, + Name, MachineCode, Enabled) +VALUES (@Gen, @EqId, @EqUuid, @DrvId, @LineId, 'slc-sim', 'ablegacy-001', 1); + +-- AB Legacy DriverInstance — SLC 500 target. Replace the placeholder gateway +-- `192.168.1.10` with the real PLC / RSEmulate host before running. +INSERT dbo.DriverInstance(GenerationId, DriverInstanceId, ClusterId, NamespaceId, + Name, DriverType, DriverConfig, Enabled) +VALUES (@Gen, @DrvId, @ClusterId, @NsId, 'ablegacy-smoke', 'AbLegacy', N'{ + "TimeoutMs": 2000, + "Devices": [ + { + "HostAddress": "ab://192.168.1.10/1,0", + "PlcFamily": "Slc500", + "DeviceName": "slc-500" + } + ], + "Probe": { "Enabled": true, "IntervalMs": 5000, "TimeoutMs": 2000, "ProbeAddress": "S:0" }, + "Tags": [ + { + "Name": "N7_5", + "DeviceHostAddress": "ab://192.168.1.10/1,0", + "Address": "N7:5", + "DataType": "Int", + "Writable": true, + "WriteIdempotent": true + } + ] +}', 1); + +INSERT dbo.Tag(GenerationId, TagId, DriverInstanceId, EquipmentId, Name, DataType, + AccessLevel, TagConfig, WriteIdempotent) +VALUES (@Gen, @TagId, @DrvId, @EqId, 'N7_5', 'Int16', 'ReadWrite', + N'{"FullName":"N7_5","Address":"N7:5","DataType":"Int"}', 1); + +EXEC dbo.sp_PublishGeneration @ClusterId = @ClusterId, @DraftGenerationId = @Gen, + @Notes = N'AB Legacy smoke — task #213'; + +COMMIT; + +PRINT ''; +PRINT 'AB Legacy smoke seed complete.'; +PRINT ' Cluster: ' + @ClusterId; +PRINT ' Node: ' + @NodeId; +PRINT ' Generation: ' + CONVERT(nvarchar(20), @Gen); +PRINT ''; +PRINT 'NOTE: hardware-gated. ab_server PCCC is upstream-broken (#222). Point the'; +PRINT ' DriverConfig HostAddress at real SLC / MicroLogix / PLC-5 / RSEmulate'; +PRINT ' and run the e2e script with AB_LEGACY_TRUST_WIRE=1.'; diff --git a/scripts/smoke/seed-s7-smoke.sql b/scripts/smoke/seed-s7-smoke.sql new file mode 100644 index 0000000..9a347cc --- /dev/null +++ b/scripts/smoke/seed-s7-smoke.sql @@ -0,0 +1,121 @@ +-- S7 e2e smoke seed — closes #212 (umbrella #209). +-- +-- One-cluster seed pointing at the python-snap7 fixture +-- (`docker compose -f tests/ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests/Docker/docker-compose.yml --profile s7_1500 up -d`). +-- python-snap7 listens on port 1102 (non-priv); real S7 CPUs listen on 102. +-- Publishes one Int16 tag at DB1.DBW0 under `ns=;s=DB1_DBW0` (driver +-- sanitises the dot for browse names — see S7Driver.DiscoverAsync). +-- +-- Usage: +-- sqlcmd -S "localhost,14330" -d OtOpcUaConfig -U sa -P "OtOpcUaDev_2026!" \ +-- -i scripts/smoke/seed-s7-smoke.sql +-- +-- After seeding: +-- Node:NodeId = "s7-smoke-node" +-- Node:ClusterId = "s7-smoke" +-- Then start server + run `./scripts/e2e/test-s7.ps1 -BridgeNodeId "ns=2;s=DB1_DBW0" -S7Host "127.0.0.1:1102"`. + +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) = 's7-smoke'; +DECLARE @NodeId nvarchar(64) = 's7-smoke-node'; +DECLARE @DrvId nvarchar(64) = 's7-smoke-drv'; +DECLARE @NsId nvarchar(64) = 's7-smoke-ns'; +DECLARE @AreaId nvarchar(64) = 's7-smoke-area'; +DECLARE @LineId nvarchar(64) = 's7-smoke-line'; +DECLARE @EqId nvarchar(64) = 's7-smoke-eq'; +DECLARE @EqUuid uniqueidentifier = '17BD5A10-17BD-417B-917B-D5A1017BD5A1'; +DECLARE @TagId nvarchar(64) = 's7-smoke-tag-db1dbw0'; + +BEGIN TRAN; + +DELETE FROM dbo.Tag WHERE TagId IN (@TagId); +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; + +INSERT dbo.ServerCluster(ClusterId, Name, Enterprise, Site, NodeCount, RedundancyMode, Enabled, CreatedBy) +VALUES (@ClusterId, 'S7 Smoke', 'zb', 'lab', 1, 'None', 1, 's7-smoke'); + +INSERT dbo.ClusterNode(NodeId, ClusterId, RedundancyRole, Host, OpcUaPort, DashboardPort, + ApplicationUri, ServiceLevelBase, Enabled, CreatedBy) +VALUES (@NodeId, @ClusterId, 'Primary', 'localhost', 4840, 5000, + 'urn:OtOpcUa:s7-smoke-node', 200, 1, 's7-smoke'); + +DECLARE @Gen bigint; +INSERT dbo.ConfigGeneration(ClusterId, Status, CreatedBy) +VALUES (@ClusterId, 'Draft', 's7-smoke'); +SET @Gen = SCOPE_IDENTITY(); + +INSERT dbo.Namespace(GenerationId, NamespaceId, ClusterId, Kind, NamespaceUri, Enabled) +VALUES (@Gen, @NsId, @ClusterId, 'Equipment', 'urn:s7-smoke:eq', 1); + +INSERT dbo.UnsArea(GenerationId, UnsAreaId, ClusterId, Name) +VALUES (@Gen, @AreaId, @ClusterId, 'lab-floor'); + +INSERT dbo.UnsLine(GenerationId, UnsLineId, UnsAreaId, Name) +VALUES (@Gen, @LineId, @AreaId, 's7-line'); + +INSERT dbo.Equipment(GenerationId, EquipmentId, EquipmentUuid, DriverInstanceId, UnsLineId, + Name, MachineCode, Enabled) +VALUES (@Gen, @EqId, @EqUuid, @DrvId, @LineId, 's7-sim', 's7-001', 1); + +-- S7 DriverInstance — python-snap7 S7-1500 profile, slot 0, port 1102. +-- DriverConfig shape mirrors S7DriverConfigDto. +INSERT dbo.DriverInstance(GenerationId, DriverInstanceId, ClusterId, NamespaceId, + Name, DriverType, DriverConfig, Enabled) +VALUES (@Gen, @DrvId, @ClusterId, @NsId, 'snap7-smoke', 'S7', N'{ + "Host": "127.0.0.1", + "Port": 1102, + "CpuType": "S71500", + "Rack": 0, + "Slot": 0, + "TimeoutMs": 5000, + "Probe": { "Enabled": true, "IntervalMs": 5000, "TimeoutMs": 2000, "ProbeAddress": "MW0" }, + "Tags": [ + { + "Name": "DB1_DBW0", + "Address": "DB1.DBW0", + "DataType": "Int16", + "Writable": true, + "WriteIdempotent": true + } + ] +}', 1); + +INSERT dbo.Tag(GenerationId, TagId, DriverInstanceId, EquipmentId, Name, DataType, + AccessLevel, TagConfig, WriteIdempotent) +VALUES (@Gen, @TagId, @DrvId, @EqId, 'DB1_DBW0', 'Int16', 'ReadWrite', + N'{"FullName":"DB1_DBW0","Address":"DB1.DBW0","DataType":"Int16"}', 1); + +EXEC dbo.sp_PublishGeneration @ClusterId = @ClusterId, @DraftGenerationId = @Gen, + @Notes = N'S7 smoke — task #212'; + +COMMIT; + +PRINT ''; +PRINT 'S7 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 = "s7-smoke-node"'; +PRINT ' Node:ClusterId = "s7-smoke"'; +PRINT ' 2. docker compose -f tests/.../S7.IntegrationTests/Docker/docker-compose.yml --profile s7_1500 up -d'; +PRINT ' 3. dotnet run --project src/ZB.MOM.WW.OtOpcUa.Server'; +PRINT ' 4. ./scripts/e2e/test-s7.ps1 -BridgeNodeId "ns=2;s=DB1_DBW0" -S7Host "127.0.0.1:1102"'; diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriverFactoryExtensions.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriverFactoryExtensions.cs new file mode 100644 index 0000000..501b51e --- /dev/null +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriverFactoryExtensions.cs @@ -0,0 +1,151 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using ZB.MOM.WW.OtOpcUa.Core.Hosting; + +namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip; + +/// +/// Static factory registration helper for . Server's Program.cs +/// calls once at startup; the bootstrapper (task #248) then +/// materialises AB CIP DriverInstance rows from the central config DB into live driver +/// instances. Mirrors GalaxyProxyDriverFactoryExtensions. +/// +public static class AbCipDriverFactoryExtensions +{ + public const string DriverTypeName = "AbCip"; + + public static void Register(DriverFactoryRegistry registry) + { + ArgumentNullException.ThrowIfNull(registry); + registry.Register(DriverTypeName, CreateInstance); + } + + internal static AbCipDriver CreateInstance(string driverInstanceId, string driverConfigJson) + { + ArgumentException.ThrowIfNullOrWhiteSpace(driverInstanceId); + ArgumentException.ThrowIfNullOrWhiteSpace(driverConfigJson); + + var dto = JsonSerializer.Deserialize(driverConfigJson, JsonOptions) + ?? throw new InvalidOperationException( + $"AB CIP driver config for '{driverInstanceId}' deserialised to null"); + + var options = new AbCipDriverOptions + { + Devices = dto.Devices is { Count: > 0 } + ? [.. dto.Devices.Select(d => new AbCipDeviceOptions( + HostAddress: d.HostAddress ?? throw new InvalidOperationException( + $"AB CIP config for '{driverInstanceId}' has a device missing HostAddress"), + PlcFamily: ParseEnum(d.PlcFamily, "device", driverInstanceId, "PlcFamily", + fallback: AbCipPlcFamily.ControlLogix), + DeviceName: d.DeviceName))] + : [], + Tags = dto.Tags is { Count: > 0 } + ? [.. dto.Tags.Select(t => BuildTag(t, driverInstanceId))] + : [], + Probe = new AbCipProbeOptions + { + Enabled = dto.Probe?.Enabled ?? true, + Interval = TimeSpan.FromMilliseconds(dto.Probe?.IntervalMs ?? 5_000), + Timeout = TimeSpan.FromMilliseconds(dto.Probe?.TimeoutMs ?? 2_000), + ProbeTagPath = dto.Probe?.ProbeTagPath, + }, + Timeout = TimeSpan.FromMilliseconds(dto.TimeoutMs ?? 2_000), + EnableControllerBrowse = dto.EnableControllerBrowse ?? false, + EnableAlarmProjection = dto.EnableAlarmProjection ?? false, + AlarmPollInterval = TimeSpan.FromMilliseconds(dto.AlarmPollIntervalMs ?? 1_000), + }; + + return new AbCipDriver(options, driverInstanceId); + } + + private static AbCipTagDefinition BuildTag(AbCipTagDto t, string driverInstanceId) => + new( + Name: t.Name ?? throw new InvalidOperationException( + $"AB CIP config for '{driverInstanceId}' has a tag missing Name"), + DeviceHostAddress: t.DeviceHostAddress ?? throw new InvalidOperationException( + $"AB CIP tag '{t.Name}' in '{driverInstanceId}' missing DeviceHostAddress"), + TagPath: t.TagPath ?? throw new InvalidOperationException( + $"AB CIP tag '{t.Name}' in '{driverInstanceId}' missing TagPath"), + DataType: ParseEnum(t.DataType, t.Name, driverInstanceId, "DataType"), + Writable: t.Writable ?? true, + WriteIdempotent: t.WriteIdempotent ?? false, + Members: t.Members is { Count: > 0 } + ? [.. t.Members.Select(m => new AbCipStructureMember( + Name: m.Name ?? throw new InvalidOperationException( + $"AB CIP tag '{t.Name}' in '{driverInstanceId}' has a member missing Name"), + DataType: ParseEnum(m.DataType, t.Name, driverInstanceId, + $"Members[{m.Name}].DataType"), + Writable: m.Writable ?? true, + WriteIdempotent: m.WriteIdempotent ?? false))] + : null, + SafetyTag: t.SafetyTag ?? false); + + private static T ParseEnum(string? raw, string? tagName, string driverInstanceId, string field, + T? fallback = null) where T : struct, Enum + { + if (string.IsNullOrWhiteSpace(raw)) + { + if (fallback.HasValue) return fallback.Value; + throw new InvalidOperationException( + $"AB CIP tag '{tagName ?? ""}' in '{driverInstanceId}' missing {field}"); + } + return Enum.TryParse(raw, ignoreCase: true, out var v) + ? v + : throw new InvalidOperationException( + $"AB CIP 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 AbCipDriverConfigDto + { + public int? TimeoutMs { get; init; } + public bool? EnableControllerBrowse { get; init; } + public bool? EnableAlarmProjection { get; init; } + public int? AlarmPollIntervalMs { get; init; } + public List? Devices { get; init; } + public List? Tags { get; init; } + public AbCipProbeDto? Probe { get; init; } + } + + internal sealed class AbCipDeviceDto + { + public string? HostAddress { get; init; } + public string? PlcFamily { get; init; } + public string? DeviceName { get; init; } + } + + internal sealed class AbCipTagDto + { + public string? Name { get; init; } + public string? DeviceHostAddress { get; init; } + public string? TagPath { get; init; } + public string? DataType { get; init; } + public bool? Writable { get; init; } + public bool? WriteIdempotent { get; init; } + public List? Members { get; init; } + public bool? SafetyTag { get; init; } + } + + internal sealed class AbCipMemberDto + { + public string? Name { get; init; } + public string? DataType { get; init; } + public bool? Writable { get; init; } + public bool? WriteIdempotent { get; init; } + } + + internal sealed class AbCipProbeDto + { + public bool? Enabled { get; init; } + public int? IntervalMs { get; init; } + public int? TimeoutMs { get; init; } + public string? ProbeTagPath { get; init; } + } +} diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/ZB.MOM.WW.OtOpcUa.Driver.AbCip.csproj b/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/ZB.MOM.WW.OtOpcUa.Driver.AbCip.csproj index fa4995b..e773960 100644 --- a/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/ZB.MOM.WW.OtOpcUa.Driver.AbCip.csproj +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/ZB.MOM.WW.OtOpcUa.Driver.AbCip.csproj @@ -14,6 +14,7 @@ + diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriverFactoryExtensions.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriverFactoryExtensions.cs new file mode 100644 index 0000000..0fbccad --- /dev/null +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriverFactoryExtensions.cs @@ -0,0 +1,124 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using ZB.MOM.WW.OtOpcUa.Core.Hosting; +using ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.PlcFamilies; + +namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy; + +/// +/// Static factory registration helper for . Server's Program.cs +/// calls once at startup; the bootstrapper (task #248) then +/// materialises AB Legacy DriverInstance rows from the central config DB into live +/// driver instances. Mirrors GalaxyProxyDriverFactoryExtensions. +/// +public static class AbLegacyDriverFactoryExtensions +{ + public const string DriverTypeName = "AbLegacy"; + + public static void Register(DriverFactoryRegistry registry) + { + ArgumentNullException.ThrowIfNull(registry); + registry.Register(DriverTypeName, CreateInstance); + } + + internal static AbLegacyDriver CreateInstance(string driverInstanceId, string driverConfigJson) + { + ArgumentException.ThrowIfNullOrWhiteSpace(driverInstanceId); + ArgumentException.ThrowIfNullOrWhiteSpace(driverConfigJson); + + var dto = JsonSerializer.Deserialize(driverConfigJson, JsonOptions) + ?? throw new InvalidOperationException( + $"AB Legacy driver config for '{driverInstanceId}' deserialised to null"); + + var options = new AbLegacyDriverOptions + { + Devices = dto.Devices is { Count: > 0 } + ? [.. dto.Devices.Select(d => new AbLegacyDeviceOptions( + HostAddress: d.HostAddress ?? throw new InvalidOperationException( + $"AB Legacy config for '{driverInstanceId}' has a device missing HostAddress"), + PlcFamily: ParseEnum(d.PlcFamily, driverInstanceId, "PlcFamily", + fallback: AbLegacyPlcFamily.Slc500), + DeviceName: d.DeviceName))] + : [], + Tags = dto.Tags is { Count: > 0 } + ? [.. dto.Tags.Select(t => new AbLegacyTagDefinition( + Name: t.Name ?? throw new InvalidOperationException( + $"AB Legacy config for '{driverInstanceId}' has a tag missing Name"), + DeviceHostAddress: t.DeviceHostAddress ?? throw new InvalidOperationException( + $"AB Legacy tag '{t.Name}' in '{driverInstanceId}' missing DeviceHostAddress"), + Address: t.Address ?? throw new InvalidOperationException( + $"AB Legacy tag '{t.Name}' in '{driverInstanceId}' missing Address"), + DataType: ParseEnum(t.DataType, driverInstanceId, "DataType", + tagName: t.Name), + Writable: t.Writable ?? true, + WriteIdempotent: t.WriteIdempotent ?? false))] + : [], + Probe = new AbLegacyProbeOptions + { + 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 ?? "S:0", + }, + Timeout = TimeSpan.FromMilliseconds(dto.TimeoutMs ?? 2_000), + }; + + return new AbLegacyDriver(options, driverInstanceId); + } + + private static T ParseEnum(string? raw, string driverInstanceId, string field, + string? tagName = null, T? fallback = null) where T : struct, Enum + { + if (string.IsNullOrWhiteSpace(raw)) + { + if (fallback.HasValue) return fallback.Value; + throw new InvalidOperationException( + $"AB Legacy {(tagName is null ? "config" : $"tag '{tagName}'")} in '{driverInstanceId}' missing {field}"); + } + return Enum.TryParse(raw, ignoreCase: true, out var v) + ? v + : throw new InvalidOperationException( + $"AB Legacy {(tagName is null ? "config" : $"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 AbLegacyDriverConfigDto + { + public int? TimeoutMs { get; init; } + public List? Devices { get; init; } + public List? Tags { get; init; } + public AbLegacyProbeDto? Probe { get; init; } + } + + internal sealed class AbLegacyDeviceDto + { + public string? HostAddress { get; init; } + public string? PlcFamily { get; init; } + public string? DeviceName { get; init; } + } + + internal sealed class AbLegacyTagDto + { + public string? Name { get; init; } + public string? DeviceHostAddress { get; init; } + public string? Address { get; init; } + public string? DataType { get; init; } + public bool? Writable { get; init; } + public bool? WriteIdempotent { get; init; } + } + + internal sealed class AbLegacyProbeDto + { + public bool? Enabled { get; init; } + public int? IntervalMs { get; init; } + public int? TimeoutMs { get; init; } + public string? ProbeAddress { get; init; } + } +} diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.csproj b/src/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.csproj index 7162229..200456d 100644 --- a/src/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.csproj +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.csproj @@ -14,6 +14,7 @@ + diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.S7/S7DriverFactoryExtensions.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.S7/S7DriverFactoryExtensions.cs new file mode 100644 index 0000000..3809dbf --- /dev/null +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.S7/S7DriverFactoryExtensions.cs @@ -0,0 +1,125 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using ZB.MOM.WW.OtOpcUa.Core.Hosting; +using S7NetCpuType = global::S7.Net.CpuType; + +namespace ZB.MOM.WW.OtOpcUa.Driver.S7; + +/// +/// Static factory registration helper for . Server's Program.cs +/// calls once at startup; the bootstrapper (task #248) then +/// materialises S7 DriverInstance rows from the central config DB into live driver +/// instances. Mirrors GalaxyProxyDriverFactoryExtensions. +/// +public static class S7DriverFactoryExtensions +{ + public const string DriverTypeName = "S7"; + + public static void Register(DriverFactoryRegistry registry) + { + ArgumentNullException.ThrowIfNull(registry); + registry.Register(DriverTypeName, CreateInstance); + } + + internal static S7Driver CreateInstance(string driverInstanceId, string driverConfigJson) + { + ArgumentException.ThrowIfNullOrWhiteSpace(driverInstanceId); + ArgumentException.ThrowIfNullOrWhiteSpace(driverConfigJson); + + var dto = JsonSerializer.Deserialize(driverConfigJson, JsonOptions) + ?? throw new InvalidOperationException( + $"S7 driver config for '{driverInstanceId}' deserialised to null"); + + if (string.IsNullOrWhiteSpace(dto.Host)) + throw new InvalidOperationException( + $"S7 driver config for '{driverInstanceId}' missing required Host"); + + var options = new S7DriverOptions + { + Host = dto.Host!, + Port = dto.Port ?? 102, + CpuType = ParseEnum(dto.CpuType, driverInstanceId, "CpuType", + fallback: S7NetCpuType.S71500), + Rack = dto.Rack ?? 0, + Slot = dto.Slot ?? 0, + Timeout = TimeSpan.FromMilliseconds(dto.TimeoutMs ?? 5_000), + Tags = dto.Tags is { Count: > 0 } + ? [.. dto.Tags.Select(t => BuildTag(t, driverInstanceId))] + : [], + Probe = new S7ProbeOptions + { + 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 ?? "MW0", + }, + }; + + return new S7Driver(options, driverInstanceId); + } + + private static S7TagDefinition BuildTag(S7TagDto t, string driverInstanceId) => + new( + Name: t.Name ?? throw new InvalidOperationException( + $"S7 config for '{driverInstanceId}' has a tag missing Name"), + Address: t.Address ?? throw new InvalidOperationException( + $"S7 tag '{t.Name}' in '{driverInstanceId}' missing Address"), + DataType: ParseEnum(t.DataType, driverInstanceId, "DataType", + tagName: t.Name), + Writable: t.Writable ?? true, + StringLength: t.StringLength ?? 254, + WriteIdempotent: t.WriteIdempotent ?? false); + + private static T ParseEnum(string? raw, string driverInstanceId, string field, + string? tagName = null, T? fallback = null) where T : struct, Enum + { + if (string.IsNullOrWhiteSpace(raw)) + { + if (fallback.HasValue) return fallback.Value; + throw new InvalidOperationException( + $"S7 tag '{tagName ?? ""}' in '{driverInstanceId}' missing {field}"); + } + return Enum.TryParse(raw, ignoreCase: true, out var v) + ? v + : throw new InvalidOperationException( + $"S7 {(tagName is null ? "config" : $"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 S7DriverConfigDto + { + public string? Host { get; init; } + public int? Port { get; init; } + public string? CpuType { get; init; } + public short? Rack { get; init; } + public short? Slot { get; init; } + public int? TimeoutMs { get; init; } + public List? Tags { get; init; } + public S7ProbeDto? Probe { get; init; } + } + + internal sealed class S7TagDto + { + public string? Name { get; init; } + public string? Address { get; init; } + public string? DataType { get; init; } + public bool? Writable { get; init; } + public int? StringLength { get; init; } + public bool? WriteIdempotent { get; init; } + } + + internal sealed class S7ProbeDto + { + public bool? Enabled { get; init; } + public int? IntervalMs { get; init; } + public int? TimeoutMs { get; init; } + public string? ProbeAddress { get; init; } + } +} diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.S7/ZB.MOM.WW.OtOpcUa.Driver.S7.csproj b/src/ZB.MOM.WW.OtOpcUa.Driver.S7/ZB.MOM.WW.OtOpcUa.Driver.S7.csproj index e1459d3..2ed9b10 100644 --- a/src/ZB.MOM.WW.OtOpcUa.Driver.S7/ZB.MOM.WW.OtOpcUa.Driver.S7.csproj +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.S7/ZB.MOM.WW.OtOpcUa.Driver.S7.csproj @@ -14,6 +14,7 @@ + diff --git a/src/ZB.MOM.WW.OtOpcUa.Server/Program.cs b/src/ZB.MOM.WW.OtOpcUa.Server/Program.cs index 2c78ecf..f02a385 100644 --- a/src/ZB.MOM.WW.OtOpcUa.Server/Program.cs +++ b/src/ZB.MOM.WW.OtOpcUa.Server/Program.cs @@ -9,9 +9,12 @@ using ZB.MOM.WW.OtOpcUa.Configuration; using ZB.MOM.WW.OtOpcUa.Configuration.LocalCache; using ZB.MOM.WW.OtOpcUa.Core.Hosting; using ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian; +using ZB.MOM.WW.OtOpcUa.Driver.AbCip; +using ZB.MOM.WW.OtOpcUa.Driver.AbLegacy; 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.Driver.S7; using ZB.MOM.WW.OtOpcUa.Server; using ZB.MOM.WW.OtOpcUa.Server.OpcUa; using ZB.MOM.WW.OtOpcUa.Server.Phase7; @@ -102,6 +105,9 @@ builder.Services.AddSingleton(_ => GalaxyProxyDriverFactoryExtensions.Register(registry); FocasDriverFactoryExtensions.Register(registry); ModbusDriverFactoryExtensions.Register(registry); + AbCipDriverFactoryExtensions.Register(registry); + AbLegacyDriverFactoryExtensions.Register(registry); + S7DriverFactoryExtensions.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 ca94418..3f90d91 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 @@ -37,6 +37,9 @@ + + + -- 2.49.1