Merge pull request 'v3 Batch 1 — greenfield schema + RawPath identity rewiring' (#469) from v3/batch1-schema-identity into master
v2-ci / build (push) Successful in 3m43s
v2-ci / unit-tests (push) Failing after 9m31s

This commit was merged in pull request #469.
This commit is contained in:
2026-07-16 01:52:49 -04:00
352 changed files with 9299 additions and 37529 deletions
+25 -4
View File
@@ -123,13 +123,13 @@ COMMIT TRANSACTION;
-- Summary (logged by sqlcmd output)
------------------------------------------------------------------------------
-- v3: the Namespace table is retired; DriverInstance is folder-scoped (RawFolderId) and Tags
-- are device-scoped (DeviceId + optional TagGroupId). This seed only creates clusters + nodes +
-- LDAP role mappings, so the summary reflects just those. Raw-tree config (RawFolder/DriverInstance/
-- Device/TagGroup/Tag) is authored via the AdminUI /raw page in later batches.
SELECT ClusterId, Name, NodeCount, RedundancyMode FROM dbo.ServerCluster ORDER BY ClusterId;
SELECT NodeId, ClusterId, Host, OpcUaPort, ApplicationUri, ServiceLevelBase
FROM dbo.ClusterNode ORDER BY ClusterId, NodeId;
SELECT NamespaceId, ClusterId, Kind, NamespaceUri FROM dbo.Namespace ORDER BY ClusterId, NamespaceId;
SELECT DriverInstanceId, ClusterId, DriverType, NamespaceId, Name
FROM dbo.DriverInstance ORDER BY ClusterId, DriverInstanceId;
SELECT TagId, DriverInstanceId, FolderPath, Name, DataType FROM dbo.Tag ORDER BY DriverInstanceId, FolderPath, Name;
------------------------------------------------------------------------------
-- LDAP group -> AdminUI role mappings (shared dev GLAuth, 10.100.0.35)
@@ -152,3 +152,24 @@ IF NOT EXISTS (SELECT 1 FROM dbo.LdapGroupRoleMapping WHERE LdapGroup = 'OtOpcUa
VALUES (NEWID(), 'OtOpcUa-Viewers', 'Viewer', NULL, 1, SYSUTCDATETIME(), N'shared-glauth dev seed');
SELECT LdapGroup, Role, IsSystemWide FROM dbo.LdapGroupRoleMapping ORDER BY LdapGroup;
------------------------------------------------------------------------------
-- v3 Batch 1 gate: a Modbus driver in cluster MAIN so the docker-dev boot has a
-- driver to show Connected against the modbus fixture (10.100.0.35:5020). Raw tree:
-- DriverInstance(RawFolderId NULL) -> Device(endpoint in DeviceConfig) -> Tag(raw).
-- Idempotent. Address space stays DARK this batch (no variable nodes materialize).
------------------------------------------------------------------------------
SET QUOTED_IDENTIFIER ON;
SET ANSI_NULLS ON;
IF NOT EXISTS (SELECT 1 FROM dbo.DriverInstance WHERE DriverInstanceId = 'MAIN-modbus')
INSERT dbo.DriverInstance(DriverInstanceId, ClusterId, RawFolderId, Name, DriverType, DriverConfig, Enabled)
VALUES ('MAIN-modbus', 'MAIN', NULL, 'pymodbus', 'Modbus',
N'{ "TimeoutMs": 2000, "AutoReconnect": true, "Probe": { "Enabled": true, "IntervalMs": 5000, "TimeoutMs": 2000, "ProbeAddress": 0 } }', 1);
IF NOT EXISTS (SELECT 1 FROM dbo.Device WHERE DeviceId = 'MAIN-modbus-dev')
INSERT dbo.Device(DeviceId, DriverInstanceId, Name, Enabled, DeviceConfig)
VALUES ('MAIN-modbus-dev', 'MAIN-modbus', 'plc', 1, N'{ "Host": "10.100.0.35", "Port": 5020, "UnitId": 1 }');
IF NOT EXISTS (SELECT 1 FROM dbo.Tag WHERE TagId = 'MAIN-modbus-hr200')
INSERT dbo.Tag(TagId, DeviceId, TagGroupId, Name, DataType, AccessLevel, WriteIdempotent, TagConfig)
VALUES ('MAIN-modbus-hr200', 'MAIN-modbus-dev', NULL, 'HR200', 'UInt16', 'ReadWrite', 1,
N'{ "region": "HoldingRegisters", "address": 200, "dataType": "UInt16", "writable": true }');
SELECT DriverInstanceId, ClusterId, DriverType, Enabled FROM dbo.DriverInstance WHERE ClusterId='MAIN';
+11
View File
@@ -1,5 +1,16 @@
# Configuration Reference
> **v3 in progress (Batch 1 landed — schema + RawPath identity).** The config **DB schema** was
> replaced by the greenfield v3 model: a driver-centric **Raw tree**
> (`Cluster → RawFolder → DriverInstance → Device → TagGroup → Tag`) plus a reference-only **UNS**
> projection (`UnsTagReference`). A tag's identity is now its **RawPath**
> (`<Folder/…>/<Driver>/<Device>/<TagGroup/…>/<Tag>`), not a `TagConfig.FullName` blob; the
> `Namespace`/`NamespaceKind`/`EquipmentImportBatch` entities are retired, and connection endpoints
> moved from `DriverConfig` into each `Device`'s `DeviceConfig`. The two OPC UA namespaces (`ns=Raw` /
> `ns=UNS`) and the `/raw` authoring UI arrive in later v3 batches; **Batch 1 leaves the address space
> deliberately dark** (drivers connect, but no tag variables materialize yet). The `appsettings*.json`
> surface below is unchanged. Full design: `docs/plans/2026-07-15-raw-uns-two-subtree-v3-design.md`.
This is the live configuration reference for the OtOpcUa Host (`src/Server/ZB.MOM.WW.OtOpcUa.Host/`). It enumerates the `appsettings*.json` sections, the bound Options classes, and the `OTOPCUA_*` / sim-endpoint environment variables — every entry grounded in source.
Two related concerns get their own dedicated pages and are **only summarised + linked** here, not duplicated:
+68
View File
@@ -0,0 +1,68 @@
# v3 Batch 1 — greenfield schema + RawPath identity rewiring
**Branch:** `v3/batch1-schema-identity``master`
**Plan:** `docs/plans/2026-07-15-v3-batch1-schema-identity-plan.md` · **Design:** `docs/plans/2026-07-15-raw-uns-two-subtree-v3-design.md`
Greenfield, compatibility-breaking. The config schema and every identity seam now speak **RawPath**
(`<Folder/…>/<Driver>/<Device>/<TagGroup/…>/<Tag>`). The full solution builds and all macOS-safe
tests pass; the OPC UA address space is **deliberately dark** (drivers connect, no tag variables
materialize) until Batch 4.
## What changed
- **Schema (greenfield):** new `RawFolder` / `TagGroup` / `UnsTagReference`; reshaped `DriverInstance`
(`NamespaceId`, +`RawFolderId?`), `Tag` (raw-only: req `DeviceId`, +`TagGroupId?`,
`DriverInstanceId`/`EquipmentId`/`FolderPath`, kept `WriteIdempotent`), `Equipment` (driver/device
binding); `Namespace` / `NamespaceKind` / `EquipmentImportBatch` retired. Single squashed `V3Initial`
migration (stored procs audited — the vestigial generation-model procs were rewritten to v3-valid
stubs; `sp_ComputeGenerationDiff` diffs the new tables).
- **Identity:** new `Commons/Types/RawPaths.cs` helper; `TagConfigIntent` sheds `FullName`;
`EquipmentTagRefResolver<TDef>` is byName(RawPath)-only (blob-fallback deleted); new
`RawTagEntry(RawPath, TagConfig, WriteIdempotent, DeviceName)` delivery record. All 8 drivers
re-keyed: the per-driver `*EquipmentTagParser` became `*TagDefinitionFactory.FromTagConfig`, options
`Tags``RawTags`, `TagConfig` key normalization (`nodeId` / `attributeRef`; `deviceHostAddress`
dropped).
- **Endpoint → DeviceConfig:** new `DriverDeviceConfigMerger` (Commons) merges DriverConfig + per-device
DeviceConfig; multi-device drivers reconstruct their `Devices[]` from Device rows; the artifact injects
the merged config + `rawTags` at spawn.
- **Pipeline:** `ConfigComposer` snapshots the new tables; `DeploymentArtifact` flattens per-driver
`RawTagEntry` lists keyed by RawPath (via `RawPathResolver`) + emits an empty (dark) equipment-tag
plan set; `AddressSpaceComposer`/walker compose folders + VirtualTags + ScriptedAlarms only;
`DriverHostActor` maps re-keyed to RawPath; `DraftValidator` gains `RawNameInvalid` /
`HistorianTagnameTooLong` / `UnsEffectiveNameCollision`.
- **AdminUI (compile-only stubs):** Namespace pages → retired banners; driver pages dropped the
pre-declared Tags editor + NamespaceId; `UnsTreeService` tag-authoring returns "moved to the Raw tree
(/raw) in v3 Batch 2". (Batch 2/3 build the real `/raw` + reference UI.)
## Verification gate (all four items)
1. **Build + test green.** Full solution builds (0 errors). macOS-safe unit suites: Commons 196,
Core 260, Core.Abstractions 134, **Configuration 107**, Runtime 355 (42 dark-Batch-4 skips),
ControlPlane 78, OpcUaServer 335 (4 skips), AdminUI 507 (3 skips), + all 8 driver unit suites
(Modbus 315, S7 264, AbCip 342, AbLegacy 213, TwinCAT 192, FOCAS 272, OpcUaClient 138, Galaxy 313)
and the 6 driver IntegrationTests projects compile. Enum-serialization round-trip coverage preserved.
2. **Fresh-DB migration.** `V3Initial` applies clean to a scratch DB; `SchemaComplianceTests` 8/8.
3. **docker-dev boot (live).** Volume wiped → `V3Initial` applied → both central nodes reached Akka
Up → a seeded Modbus driver **spawned and connected** to the `10.100.0.35:5020` fixture
(`DriverInstance MAIN-modbus: connected`), deployment **sealed (acks=2)**; AdminUI HTTP 200 on
`/`, `/clusters`, `/uns` with 0 unhandled exceptions.
4. **Dark address space (Client.CLI browse).** Recursive browse of `opc.tcp://localhost:4840`: the raw
tag `HR200` and the driver have **0 occurrences**; the custom namespace `ns=2` holds only the empty
`OtOpcUa` root — no authored variables materialize (Batch 4 lights them up).
## AdminUI Batch-1 stubs (Batch 2/3 replace)
Namespace pages (`NamespaceEdit`, `ClusterNamespaces`) → banners; the 8 driver pages' pre-declared Tags
editors + Namespace bindings removed; `DriverIdentitySection` Namespace dropdown removed;
`UnsTreeService.CreateTagAsync`/`UpdateTagAsync` return the "moved to /raw" failure; per-equipment tag
lists empty.
## Deferred (owned by later batches, not regressions)
- `/raw` project tree + reference-picker UI, dual `ns=Raw`/`ns=UNS` namespaces + tag-variable
materialization, `{{equip}}` reference-relative resolution — Batches 24 (address space intentionally
dark now).
- User-facing docs beyond the `Configuration.md` v3 note (they describe the not-yet-built UI/address
space) — updated as Batches 24 land.
- Live driver IntegrationTests + one pre-existing `AbCip_Green_AgainstSim` fixture-probe failure
(AB CIP sim handshake — environment/fixture gate, untouched).
+43 -97
View File
@@ -1,128 +1,74 @@
-- AB CIP e2e smoke seed — closes #211 (umbrella #209).
-- AB CIP (Logix) e2e smoke seed — v3 schema (Raw tree: DriverInstance → Device → Tag; UNS by ref).
--
-- One-cluster seed pointing at the ab_server ControlLogix fixture
-- (`docker compose -f tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/Docker/docker-compose.yml --profile controllogix up -d`).
-- Publishes a single `TestDINT:DInt` tag under NodeId `ns=<N>;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"`.
-- Idempotent DELETE-and-recreate pointing at the CIP fixture (ab://127.0.0.1:44818/1,0). v3: the
-- ConfigGeneration/Namespace model + sp_PublishGeneration are gone; the per-device HostAddress moved
-- from DriverConfig.Devices[] into the Device's DeviceConfig (the deviceHostAddress tag key is dropped
-- — the tag's DeviceId FK owns placement now).
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 @DevId nvarchar(64) = 'abcip-smoke-dev';
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';
DECLARE @EqUuid uniqueidentifier = 'ABC17000-ABC1-45E0-9ABC-17000ABC1700';
DECLARE @Tag nvarchar(64) = 'abcip-smoke-tag-dint';
DECLARE @RefId nvarchar(64) = 'abcip-smoke-ref-dint';
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;
DELETE FROM dbo.UnsTagReference WHERE UnsTagReferenceId = @RefId;
DELETE FROM dbo.Tag WHERE TagId = @Tag;
DELETE FROM dbo.Equipment WHERE EquipmentId = @EqId;
DELETE FROM dbo.UnsLine WHERE UnsLineId = @LineId;
DELETE FROM dbo.UnsArea WHERE UnsAreaId = @AreaId;
DELETE FROM dbo.Device WHERE DeviceId = @DevId;
DELETE FROM dbo.DriverInstance WHERE DriverInstanceId = @DrvId;
DELETE FROM dbo.ClusterNodeCredential WHERE NodeId = @NodeId;
DELETE FROM dbo.ClusterNode WHERE NodeId = @NodeId;
DELETE FROM dbo.ServerCluster WHERE ClusterId = @ClusterId;
DELETE FROM dbo.ClusterNodeCredential WHERE Kind = 'SqlLogin' AND Value = 'sa';
INSERT dbo.ServerCluster(ClusterId, Name, Enterprise, Site, NodeCount, RedundancyMode, Enabled, CreatedBy)
VALUES (@ClusterId, 'AB CIP Smoke', 'zb', 'lab', 1, 'None', 1, 'abcip-smoke');
VALUES (@ClusterId, 'AbCip Smoke', 'zb', 'lab', 1, 'None', 1, 'abcip-smoke');
INSERT dbo.ClusterNode(NodeId, ClusterId, RedundancyRole, Host, OpcUaPort, DashboardPort,
INSERT dbo.ClusterNode(NodeId, ClusterId, Host, OpcUaPort, DashboardPort,
ApplicationUri, ServiceLevelBase, Enabled, CreatedBy)
VALUES (@NodeId, @ClusterId, 'Primary', 'localhost', 4840, 15050,
'urn:OtOpcUa:abcip-smoke-node', 200, 1, 'abcip-smoke');
VALUES (@NodeId, @ClusterId, 'localhost', 4840, 15050, 'urn:OtOpcUa:abcip-smoke-node', 200, 1, 'abcip-smoke');
INSERT dbo.ClusterNodeCredential(NodeId, Kind, Value, Enabled, CreatedBy)
VALUES (@NodeId, 'SqlLogin', 'sa', 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
}
]
-- DriverConfig keeps protocol/channel settings; the per-device Devices[] list is retired in favor
-- of Device rows carrying HostAddress in DeviceConfig.
INSERT dbo.DriverInstance(DriverInstanceId, ClusterId, RawFolderId, Name, DriverType, DriverConfig, Enabled)
VALUES (@DrvId, @ClusterId, NULL, 'logix-smoke', 'AbCip', N'{
"EnableControllerBrowse": false,
"ProbeTimeoutSeconds": 5
}', 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);
-- DeviceConfig shape finalized against reworked options in Wave B (WP5).
INSERT dbo.Device(DeviceId, DriverInstanceId, Name, Enabled, DeviceConfig)
VALUES (@DevId, @DrvId, 'plc', 1, N'{ "HostAddress": "ab://127.0.0.1:44818/1,0" }');
EXEC dbo.sp_PublishGeneration @ClusterId = @ClusterId, @DraftGenerationId = @Gen,
@Notes = N'AB CIP smoke — task #211';
INSERT dbo.Tag(TagId, DeviceId, TagGroupId, Name, DataType, AccessLevel, WriteIdempotent, TagConfig)
VALUES (@Tag, @DevId, NULL, 'TestDINT', 'DInt', 'ReadWrite', 1,
N'{ "address": "TestDINT", "dataType": "DInt" }');
INSERT dbo.UnsArea(UnsAreaId, ClusterId, Name) VALUES (@AreaId, @ClusterId, 'lab-floor');
INSERT dbo.UnsLine(UnsLineId, UnsAreaId, Name) VALUES (@LineId, @AreaId, 'abcip-line');
INSERT dbo.Equipment(EquipmentId, EquipmentUuid, UnsLineId, Name, MachineCode, Enabled)
VALUES (@EqId, @EqUuid, @LineId, 'logix-sim', 'abcip-001', 1);
INSERT dbo.UnsTagReference(UnsTagReferenceId, EquipmentId, TagId, SortOrder)
VALUES (@RefId, @EqId, @Tag, 0);
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/Server/ZB.MOM.WW.OtOpcUa.Server';
PRINT ' 4. ./scripts/e2e/test-abcip.ps1 -BridgeNodeId "ns=2;s=TestDINT"';
PRINT 'AbCip smoke seed complete (v3 schema). Cluster: ' + @ClusterId + ' Node: ' + @NodeId;
+43 -94
View File
@@ -1,125 +1,74 @@
-- AB Legacy e2e smoke seed — closes #213 (umbrella #209).
-- AB Legacy (PCCC / SLC-500, MicroLogix) e2e smoke seed — v3 schema (DriverInstance → Device → Tag).
--
-- Works against the ab_server PCCC Docker fixture (one of the slc500 /
-- micrologix / plc5 compose profiles) or real SLC 500 / MicroLogix / PLC-5
-- hardware. Default HostAddress below points at the Docker fixture with a
-- `/1,0` cip-path; libplctag's ab_server rejects empty paths before routing
-- to the PCCC dispatcher. Real hardware uses an empty path — change the
-- HostAddress to `ab://<plc-ip>:44818/` (note the trailing slash with nothing
-- after) before running the seed for that setup.
--
-- Usage:
-- docker compose -f tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.IntegrationTests/Docker/docker-compose.yml --profile slc500 up -d
-- sqlcmd -S "localhost,14330" -d OtOpcUaConfig -U sa -P "OtOpcUaDev_2026!" \
-- -i scripts/smoke/seed-ablegacy-smoke.sql
-- Idempotent DELETE-and-recreate pointing at the fixture (ab://127.0.0.1:44818/1,0). To target real
-- hardware, edit the Device's DeviceConfig HostAddress to end with /<empty> (trailing slash, no path).
-- v3: the ConfigGeneration/Namespace model + sp_PublishGeneration are gone; the per-device HostAddress
-- moved from DriverConfig.Devices[] into the Device's DeviceConfig (the deviceHostAddress tag key is
-- dropped — the tag's DeviceId FK owns placement now).
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 @DevId nvarchar(64) = 'ablegacy-smoke-dev';
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';
DECLARE @EqUuid uniqueidentifier = 'AB1E9AC0-AB1E-45E0-9AB1-E9AC0AB1E9AC';
DECLARE @Tag nvarchar(64) = 'ablegacy-smoke-tag-n7-5';
DECLARE @RefId nvarchar(64) = 'ablegacy-smoke-ref-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;
DELETE FROM dbo.UnsTagReference WHERE UnsTagReferenceId = @RefId;
DELETE FROM dbo.Tag WHERE TagId = @Tag;
DELETE FROM dbo.Equipment WHERE EquipmentId = @EqId;
DELETE FROM dbo.UnsLine WHERE UnsLineId = @LineId;
DELETE FROM dbo.UnsArea WHERE UnsAreaId = @AreaId;
DELETE FROM dbo.Device WHERE DeviceId = @DevId;
DELETE FROM dbo.DriverInstance WHERE DriverInstanceId = @DrvId;
DELETE FROM dbo.ClusterNodeCredential WHERE NodeId = @NodeId;
DELETE FROM dbo.ClusterNode WHERE NodeId = @NodeId;
DELETE FROM dbo.ServerCluster WHERE ClusterId = @ClusterId;
DELETE FROM dbo.ClusterNodeCredential WHERE Kind = 'SqlLogin' AND Value = 'sa';
INSERT dbo.ServerCluster(ClusterId, Name, Enterprise, Site, NodeCount, RedundancyMode, Enabled, CreatedBy)
VALUES (@ClusterId, 'AB Legacy Smoke', 'zb', 'lab', 1, 'None', 1, 'ablegacy-smoke');
VALUES (@ClusterId, 'AbLegacy Smoke', 'zb', 'lab', 1, 'None', 1, 'ablegacy-smoke');
INSERT dbo.ClusterNode(NodeId, ClusterId, RedundancyRole, Host, OpcUaPort, DashboardPort,
INSERT dbo.ClusterNode(NodeId, ClusterId, Host, OpcUaPort, DashboardPort,
ApplicationUri, ServiceLevelBase, Enabled, CreatedBy)
VALUES (@NodeId, @ClusterId, 'Primary', 'localhost', 4840, 15050,
'urn:OtOpcUa:ablegacy-smoke-node', 200, 1, 'ablegacy-smoke');
VALUES (@NodeId, @ClusterId, 'localhost', 4840, 15050, 'urn:OtOpcUa:ablegacy-smoke-node', 200, 1, 'ablegacy-smoke');
INSERT dbo.ClusterNodeCredential(NodeId, Kind, Value, Enabled, CreatedBy)
VALUES (@NodeId, 'SqlLogin', 'sa', 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://127.0.0.1:44818/1,0",
"PlcFamily": "Slc500",
"DeviceName": "slc-500"
}
],
"Probe": { "Enabled": true, "IntervalMs": 5000, "TimeoutMs": 2000, "ProbeAddress": "S:0" },
"Tags": [
{
"Name": "N7_5",
"DeviceHostAddress": "ab://127.0.0.1:44818/1,0",
"Address": "N7:5",
"DataType": "Int",
"Writable": true,
"WriteIdempotent": true
}
]
-- DriverConfig keeps protocol/channel settings; per-device HostAddress moves to DeviceConfig.
INSERT dbo.DriverInstance(DriverInstanceId, ClusterId, RawFolderId, Name, DriverType, DriverConfig, Enabled)
VALUES (@DrvId, @ClusterId, NULL, 'slc-smoke', 'AbLegacy', N'{
"ProbeTimeoutSeconds": 5,
"Probe": { "Enabled": true, "IntervalMs": 5000, "TimeoutMs": 2000, "ProbeAddress": "S:0" }
}', 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);
-- DeviceConfig shape finalized against reworked options in Wave B (WP5).
INSERT dbo.Device(DeviceId, DriverInstanceId, Name, Enabled, DeviceConfig)
VALUES (@DevId, @DrvId, 'plc', 1, N'{ "HostAddress": "ab://127.0.0.1:44818/1,0" }');
EXEC dbo.sp_PublishGeneration @ClusterId = @ClusterId, @DraftGenerationId = @Gen,
@Notes = N'AB Legacy smoke — task #213';
INSERT dbo.Tag(TagId, DeviceId, TagGroupId, Name, DataType, AccessLevel, WriteIdempotent, TagConfig)
VALUES (@Tag, @DevId, NULL, 'N7_5', 'Int', 'ReadWrite', 1,
N'{ "address": "N7:5", "dataType": "Int" }');
INSERT dbo.UnsArea(UnsAreaId, ClusterId, Name) VALUES (@AreaId, @ClusterId, 'lab-floor');
INSERT dbo.UnsLine(UnsLineId, UnsAreaId, Name) VALUES (@LineId, @AreaId, 'ablegacy-line');
INSERT dbo.Equipment(EquipmentId, EquipmentUuid, UnsLineId, Name, MachineCode, Enabled)
VALUES (@EqId, @EqUuid, @LineId, 'slc-sim', 'ablegacy-001', 1);
INSERT dbo.UnsTagReference(UnsTagReferenceId, EquipmentId, TagId, SortOrder)
VALUES (@RefId, @EqId, @Tag, 0);
COMMIT;
PRINT '';
PRINT 'AB Legacy smoke seed complete.';
PRINT ' Cluster: ' + @ClusterId;
PRINT ' Node: ' + @NodeId;
PRINT ' Generation: ' + CONVERT(nvarchar(20), @Gen);
PRINT '';
PRINT 'NOTE: default points at the ab_server slc500 Docker fixture with a /1,0';
PRINT ' cip-path (required by ab_server). For real SLC/MicroLogix/PLC-5';
PRINT ' hardware, edit the DriverConfig HostAddress to end with /<empty>';
PRINT ' e.g. "ab://<plc-ip>:44818/" and re-run this seed.';
PRINT 'AbLegacy smoke seed complete (v3 schema). Cluster: ' + @ClusterId + ' Node: ' + @NodeId;
+54 -109
View File
@@ -1,156 +1,101 @@
-- Modbus e2e smoke seed — closes #210 (umbrella #209).
-- Modbus e2e smoke seed — v3 schema (Raw tree: DriverInstance → Device → Tag; UNS by reference).
--
-- 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,
-- Idempotent — DELETE-and-recreate of one cluster's worth of Modbus test config:
-- * 1 ServerCluster ('modbus-smoke') + ClusterNode ('modbus-smoke-node') + SqlLogin credential
-- * 1 Modbus DriverInstance at the cluster root (RawFolderId NULL)
-- * 1 Device carrying the pymodbus endpoint in DeviceConfig
-- * 1 Tag at HR[200]:UInt16 under the device (HR[100] is auto-increment in standard.json,
-- unusable as a write target — the e2e script uses HR[200] for that reason)
-- * 1 UnsArea/UnsLine/Equipment + a UnsTagReference projecting the raw tag into UNS
--
-- v3 changes vs. the pre-v2 original: the ConfigGeneration/GenerationId/Namespace model and the
-- sp_PublishGeneration publish step are gone (deploy is a C# ControlPlane operation now); the
-- connection endpoint moved from DriverConfig into the Device's DeviceConfig.
--
-- 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/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests/Docker/docker-compose.yml --profile standard up -d
-- dotnet run --project src/Server/ZB.MOM.WW.OtOpcUa.Server
-- ./scripts/e2e/test-modbus.ps1 -BridgeNodeId "ns=2;s=HR200"
-- sqlcmd -S "10.100.0.35,14330" -d <cfgdb> -U sa -P "<pw>" -i scripts/smoke/seed-modbus-smoke.sql
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 @DevId nvarchar(64) = 'modbus-smoke-dev';
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';
DECLARE @RefId nvarchar(64) = 'modbus-smoke-ref-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;
DELETE FROM dbo.UnsTagReference WHERE UnsTagReferenceId = @RefId;
DELETE FROM dbo.Tag WHERE TagId = @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.Device WHERE DeviceId = @DevId;
DELETE FROM dbo.DriverInstance WHERE DriverInstanceId = @DrvId;
DELETE FROM dbo.ClusterNodeCredential WHERE NodeId = @NodeId;
DELETE FROM dbo.ClusterNode WHERE NodeId = @NodeId;
DELETE FROM dbo.ServerCluster WHERE ClusterId = @ClusterId;
-- `UX_ClusterNodeCredential_Value` is a unique index on (Kind, Value) WHERE
-- Enabled=1, so a `sa` login can only bind to one node at a time. Drop any
-- prior smoke cluster's binding before we claim the login for this one.
-- `UX_ClusterNodeCredential_Value` is a unique index on (Kind, Value) WHERE Enabled=1, so the
-- `sa` login can only bind to one node at a time. Drop any prior smoke binding first.
DELETE FROM dbo.ClusterNodeCredential WHERE Kind = 'SqlLogin' AND Value = 'sa';
-- 1. Cluster + Node.
-- 1. Cluster + Node + credential binding.
INSERT dbo.ServerCluster(ClusterId, Name, Enterprise, Site, NodeCount, RedundancyMode, Enabled, CreatedBy)
VALUES (@ClusterId, 'Modbus Smoke', 'zb', 'lab', 1, 'None', 1, 'modbus-smoke');
-- DashboardPort 15050 rather than 5000 — HttpListener on :5000 requires
-- URL-ACL reservation or admin rights on Windows (HttpListenerException 32).
-- 15000+ ports are unreserved by default. Safe to change back when deploying
-- with a netsh urlacl grant or reverse-proxy fronting :5000.
INSERT dbo.ClusterNode(NodeId, ClusterId, RedundancyRole, Host, OpcUaPort, DashboardPort,
INSERT dbo.ClusterNode(NodeId, ClusterId, Host, OpcUaPort, DashboardPort,
ApplicationUri, ServiceLevelBase, Enabled, CreatedBy)
VALUES (@NodeId, @ClusterId, 'Primary', 'localhost', 4840, 15050,
VALUES (@NodeId, @ClusterId, 'localhost', 4840, 15050,
'urn:OtOpcUa:modbus-smoke-node', 200, 1, 'modbus-smoke');
-- Bind the SQL login this smoke test connects as to the node identity. The
-- sp_GetCurrentGenerationForCluster + sp_UpdateClusterNodeGenerationState
-- sprocs raise RAISERROR('Unauthorized: caller %s is not bound to NodeId %s')
-- when this row is missing. `Kind='SqlLogin'` / `Value='sa'` matches the
-- container's SA user; rotate Value for real deployments using a non-SA login.
INSERT dbo.ClusterNodeCredential(NodeId, Kind, Value, Enabled, CreatedBy)
VALUES (@NodeId, 'SqlLogin', 'sa', 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,
-- 2. Modbus DriverInstance at the cluster root (RawFolderId NULL). DriverConfig keeps protocol/
-- channel settings only — the endpoint (Host/Port/UnitId) now lives in the Device's DeviceConfig.
INSERT dbo.DriverInstance(DriverInstanceId, ClusterId, RawFolderId, Name, DriverType, DriverConfig, Enabled)
VALUES (@DrvId, @ClusterId, NULL, 'pymodbus-smoke', 'Modbus', N'{
"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
}
]
"Probe": { "Enabled": true, "IntervalMs": 5000, "TimeoutMs": 2000, "ProbeAddress": 0 }
}', 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=<driver-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);
-- 3. Device — endpoint. DeviceConfig shape finalized against reworked options in Wave B (WP5).
INSERT dbo.Device(DeviceId, DriverInstanceId, Name, Enabled, DeviceConfig)
VALUES (@DevId, @DrvId, 'plc', 1, N'{ "Host": "127.0.0.1", "Port": 5020, "UnitId": 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';
-- 4. Raw tag under the device (identity = RawPath; TagConfig carries the driver address only).
INSERT dbo.Tag(TagId, DeviceId, TagGroupId, Name, DataType, AccessLevel, WriteIdempotent, TagConfig)
VALUES (@TagHr200, @DevId, NULL, 'HR200', 'UInt16', 'ReadWrite', 1,
N'{ "region": "HoldingRegisters", "address": 200, "dataType": "UInt16", "writable": true }');
-- 5. UNS hierarchy + a reference projecting the raw tag into the equipment.
INSERT dbo.UnsArea(UnsAreaId, ClusterId, Name) VALUES (@AreaId, @ClusterId, 'lab-floor');
INSERT dbo.UnsLine(UnsLineId, UnsAreaId, Name) VALUES (@LineId, @AreaId, 'modbus-line');
INSERT dbo.Equipment(EquipmentId, EquipmentUuid, UnsLineId, Name, MachineCode, Enabled)
VALUES (@EqId, @EqUuid, @LineId, 'modbus-sim', 'modbus-001', 1);
INSERT dbo.UnsTagReference(UnsTagReferenceId, EquipmentId, TagId, SortOrder)
VALUES (@RefId, @EqId, @TagHr200, 0);
COMMIT;
PRINT '';
PRINT 'Modbus smoke seed complete.';
PRINT ' Cluster: ' + @ClusterId;
PRINT ' Node: ' + @NodeId;
PRINT ' Generation: ' + CONVERT(nvarchar(20), @Gen);
PRINT 'Modbus smoke seed complete (v3 schema).';
PRINT ' Cluster: ' + @ClusterId + ' Node: ' + @NodeId;
PRINT '';
PRINT 'Next steps:';
PRINT ' 1. Set src/.../Server/appsettings.json Node:NodeId = "modbus-smoke-node"';
PRINT ' Node:ClusterId = "modbus-smoke"';
PRINT ' 1. Set Server appsettings Node:NodeId = "modbus-smoke-node" / 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/Server/ZB.MOM.WW.OtOpcUa.Server';
PRINT ' 4. ./scripts/e2e/test-modbus.ps1 -BridgeNodeId "ns=2;s=HR200"';
PRINT ' 4. Deploy the config via the AdminUI/ControlPlane, then ./scripts/e2e/test-modbus.ps1';
+70 -136
View File
@@ -1,186 +1,120 @@
-- Phase 7 live OPC UA E2E smoke seed (task #240).
-- Phase 7 live OPC UA E2E smoke seed — v3 schema (Raw tree + UNS-by-reference + scripting).
--
-- Idempotent — DROP-and-recreate of one cluster's worth of test config:
-- * 1 ServerCluster ('p7-smoke')
-- * 1 ClusterNode ('p7-smoke-node')
-- * 1 ConfigGeneration (created Draft, then flipped to Published at the end)
-- * 1 Namespace (Equipment kind)
-- * 1 UnsArea / UnsLine / Equipment / Tag — Tag bound to a real Galaxy attribute
-- * 1 DriverInstance (Galaxy)
-- * 1 Script + 1 VirtualTag using it
-- * 1 Script + 1 ScriptedAlarm using it
-- Idempotent DELETE-and-recreate of one cluster's worth of test config:
-- * 1 ServerCluster ('p7-smoke') + ClusterNode + SqlLogin credential
-- * 1 Galaxy (GalaxyMxGateway) DriverInstance at the cluster root + 1 Device (gateway endpoint)
-- * 1 raw Tag bound to a Galaxy attribute (identity = RawPath; address in TagConfig.attributeRef)
-- * 1 UnsArea / UnsLine / Equipment + a UnsTagReference projecting the raw tag into UNS
-- * 1 Script + 1 VirtualTag using it; 1 Script + 1 ScriptedAlarm using it
--
-- Drop & re-create deletes ALL rows scoped to the cluster (in dependency order)
-- so re-running this script after a code change starts from a clean state.
-- Table-level CHECK constraints are validated on insert; if a constraint is
-- violated this script aborts with the offending row's column.
--
-- Usage:
-- sqlcmd -S "localhost,14330" -d OtOpcUaConfig -U sa -P "OtOpcUaDev_2026!" \
-- -i scripts/smoke/seed-phase-7-smoke.sql
-- v3 changes: ConfigGeneration/GenerationId/Namespace model + sp_PublishGeneration are gone (deploy
-- is a C# ControlPlane operation); Galaxy driver type is 'GalaxyMxGateway'; the gateway endpoint
-- lives in the Device's DeviceConfig; ctx.GetTag / alarm-message tokens take a RawPath. The exact
-- Galaxy TagConfig key ('attributeRef') + scripting RawPath contract are finalized in WP2/WP3/WP4.
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) = 'p7-smoke';
DECLARE @NodeId nvarchar(64) = 'p7-smoke-node';
DECLARE @DrvId nvarchar(64) = 'p7-smoke-galaxy';
DECLARE @NsId nvarchar(64) = 'p7-smoke-ns';
DECLARE @DevId nvarchar(64) = 'p7-smoke-dev';
DECLARE @AreaId nvarchar(64) = 'p7-smoke-area';
DECLARE @LineId nvarchar(64) = 'p7-smoke-line';
DECLARE @EqId nvarchar(64) = 'p7-smoke-eq';
DECLARE @EqUuid uniqueidentifier = '5B2CF10D-5B2C-4F10-B5B2-CF10D5B2CF10';
DECLARE @TagId nvarchar(64) = 'p7-smoke-tag-source';
DECLARE @RefId nvarchar(64) = 'p7-smoke-ref-source';
DECLARE @VtScript nvarchar(64) = 'p7-smoke-script-vt';
DECLARE @AlScript nvarchar(64) = 'p7-smoke-script-al';
DECLARE @VtId nvarchar(64) = 'p7-smoke-vt-derived';
DECLARE @AlId nvarchar(64) = 'p7-smoke-al-overtemp';
-- RawPath of the source tag: <driver>/<device>/<tag> (cluster root, no folder/group).
DECLARE @RawPath nvarchar(256) = N'galaxy-smoke/plc/Source';
BEGIN TRAN;
-- Wipe any prior smoke state. Order matters: child rows first.
DELETE s FROM dbo.ScriptedAlarmState s
WHERE s.ScriptedAlarmId = @AlId;
DELETE FROM dbo.ScriptedAlarm WHERE ScriptedAlarmId = @AlId;
DELETE FROM dbo.VirtualTag WHERE VirtualTagId = @VtId;
DELETE FROM dbo.Script WHERE ScriptId IN (@VtScript, @AlScript);
DELETE FROM dbo.Tag WHERE TagId = @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;
-- Wipe prior smoke state. Order matters: child rows first.
DELETE FROM dbo.ScriptedAlarmState WHERE ScriptedAlarmId = @AlId;
DELETE FROM dbo.ScriptedAlarm WHERE ScriptedAlarmId = @AlId;
DELETE FROM dbo.VirtualTag WHERE VirtualTagId = @VtId;
DELETE FROM dbo.Script WHERE ScriptId IN (@VtScript, @AlScript);
DELETE FROM dbo.UnsTagReference WHERE UnsTagReferenceId = @RefId;
DELETE FROM dbo.Tag WHERE TagId = @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.Device WHERE DeviceId = @DevId;
DELETE FROM dbo.DriverInstance WHERE DriverInstanceId = @DrvId;
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;
DELETE FROM dbo.ClusterNode WHERE NodeId = @NodeId;
DELETE FROM dbo.ServerCluster WHERE ClusterId = @ClusterId;
DELETE FROM dbo.ClusterNodeCredential WHERE Kind = 'SqlLogin' AND Value = 'sa';
-- 1. Cluster + Node
-- 1. Cluster + Node + credential binding.
INSERT dbo.ServerCluster(ClusterId, Name, Enterprise, Site, NodeCount, RedundancyMode, Enabled, CreatedBy)
VALUES (@ClusterId, 'P7 Smoke', 'zb', 'lab', 1, 'None', 1, 'p7-smoke');
INSERT dbo.ClusterNode(NodeId, ClusterId, RedundancyRole, Host, OpcUaPort, DashboardPort,
INSERT dbo.ClusterNode(NodeId, ClusterId, Host, OpcUaPort, DashboardPort,
ApplicationUri, ServiceLevelBase, Enabled, CreatedBy)
VALUES (@NodeId, @ClusterId, 'Primary', 'localhost', 4840, 5000,
'urn:OtOpcUa:p7-smoke-node', 200, 1, 'p7-smoke');
VALUES (@NodeId, @ClusterId, 'localhost', 4840, 5000, 'urn:OtOpcUa:p7-smoke-node', 200, 1, 'p7-smoke');
-- sp_GetCurrentGenerationForCluster gates access by SUSER_SNAME() against
-- ClusterNodeCredential; without this binding the Server bootstrap fails with
-- `Unauthorized: caller sa is not bound to NodeId p7-smoke-node`. Dev Docker
-- SQL runs with `sa`; production deploys would rotate to a per-node login.
INSERT dbo.ClusterNodeCredential(NodeId, Kind, Value, Enabled, CreatedBy)
VALUES (@NodeId, N'SqlLogin', N'sa', 1, N'p7-smoke');
-- 2. Generation (created Draft, flipped to Published at the end so insert order
-- constraints (one Draft per cluster, etc.) don't fight us).
DECLARE @Gen bigint;
INSERT dbo.ConfigGeneration(ClusterId, Status, CreatedBy)
VALUES (@ClusterId, 'Draft', 'p7-smoke');
SET @Gen = SCOPE_IDENTITY();
-- 3. Namespace
INSERT dbo.Namespace(GenerationId, NamespaceId, ClusterId, Kind, NamespaceUri, Enabled)
VALUES (@Gen, @NsId, @ClusterId, 'Equipment', 'urn:p7-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, 'galaxy-line');
INSERT dbo.Equipment(GenerationId, EquipmentId, EquipmentUuid, DriverInstanceId, UnsLineId,
Name, MachineCode, Enabled)
VALUES (@Gen, @EqId, @EqUuid, @DrvId, @LineId, 'reactor-1', 'p7-rx-001', 1);
-- 5. Driver — Galaxy proxy. DriverConfig JSON tells the proxy how to reach the
-- already-running OtOpcUaGalaxyHost. Secret + pipe name match
-- .local/galaxy-host-secret.txt + the OtOpcUaGalaxyHost service env.
INSERT dbo.DriverInstance(GenerationId, DriverInstanceId, ClusterId, NamespaceId,
Name, DriverType, DriverConfig, Enabled)
VALUES (@Gen, @DrvId, @ClusterId, @NsId, 'galaxy-smoke', 'Galaxy', N'{
"DriverInstanceId": "p7-smoke-galaxy",
"PipeName": "OtOpcUaGalaxy",
"SharedSecret": "4hgDJ4jLcKXmOmD1Ara8xtE8N3R47Q2y1Xf/Eama/Fk=",
-- 2. Galaxy driver at the cluster root. DriverConfig keeps non-endpoint settings; the mxaccessgw
-- gateway endpoint lives in the Device's DeviceConfig (shape finalized in Wave B / WP5).
INSERT dbo.DriverInstance(DriverInstanceId, ClusterId, RawFolderId, Name, DriverType, DriverConfig, Enabled)
VALUES (@DrvId, @ClusterId, NULL, 'galaxy-smoke', 'GalaxyMxGateway', N'{
"ConnectTimeoutMs": 10000
}', 1);
-- 6. One driver-sourced Tag bound to the Equipment. TagConfig is the Galaxy
-- fullRef; the EquipmentNodeWalker reads the JSON's FullName field and
-- hands that string to the Galaxy driver as the MXAccess reference.
-- Default points at TestMachine_001.TestHistoryValue — the live dev-box
-- Galaxy ships it as Int32 + writable (security_classification=Operate)
-- + historized (HistoryExtension primitive), so the E2E script can
-- exercise read + write + subscribe + alarm + history against the
-- same attribute. Swap to any other writable historized attribute on
-- this Galaxy by re-running `gr/queries/attributes_extended.sql` with
-- `WHERE is_historized=1 AND security_classification > 0`.
INSERT dbo.Tag(GenerationId, TagId, DriverInstanceId, EquipmentId, Name, DataType,
AccessLevel, TagConfig, WriteIdempotent)
VALUES (@Gen, @TagId, @DrvId, @EqId, 'Source', 'Int32', 'ReadWrite',
N'{"FullName":"TestMachine_001.TestHistoryValue","DataType":"Int32"}', 0);
INSERT dbo.Device(DeviceId, DriverInstanceId, Name, Enabled, DeviceConfig)
VALUES (@DevId, @DrvId, 'plc', 1, N'{ "GatewayAddress": "http://127.0.0.1:5001", "ClientName": "p7-smoke" }');
-- 7. Scripts (SourceHash is SHA-256 of SourceCode, computed externally — using
-- a placeholder here; the engine recomputes on first use anyway).
--
-- MachineStatus predicate — the dynamic "is the machine running?" status
-- derived from the raw Source value. Boolean: true when Source > 0. Matches
-- the shape Aveva operators typically want on a machine-status tile (green
-- when above zero, otherwise grey) without needing a separate threshold
-- attribute. Rename + re-threshold here to mirror site semantics.
INSERT dbo.Script(GenerationId, ScriptId, Name, SourceCode, SourceHash, Language)
-- 3. Raw tag bound to a Galaxy attribute. Identity is its RawPath; the MXAccess reference lives in
-- TagConfig.attributeRef (v3 Galaxy address key; finalized in WP2/WP3).
INSERT dbo.Tag(TagId, DeviceId, TagGroupId, Name, DataType, AccessLevel, WriteIdempotent, TagConfig)
VALUES (@TagId, @DevId, NULL, 'Source', 'Int32', 'ReadWrite', 0,
N'{ "attributeRef": "TestMachine_001.TestHistoryValue", "dataType": "Int32" }');
-- 4. UNS hierarchy + reference into the equipment.
INSERT dbo.UnsArea(UnsAreaId, ClusterId, Name) VALUES (@AreaId, @ClusterId, 'lab-floor');
INSERT dbo.UnsLine(UnsLineId, UnsAreaId, Name) VALUES (@LineId, @AreaId, 'galaxy-line');
INSERT dbo.Equipment(EquipmentId, EquipmentUuid, UnsLineId, Name, MachineCode, Enabled)
VALUES (@EqId, @EqUuid, @LineId, 'reactor-1', 'p7-rx-001', 1);
INSERT dbo.UnsTagReference(UnsTagReferenceId, EquipmentId, TagId, SortOrder)
VALUES (@RefId, @EqId, @TagId, 0);
-- 5. Scripts. ctx.GetTag takes a RawPath in v3 (SourceHash placeholder; the engine recomputes).
INSERT dbo.Script(ScriptId, Name, SourceCode, SourceHash, Language)
VALUES
(@Gen, @VtScript, 'machine-status-predicate',
N'return System.Convert.ToInt32(ctx.GetTag("/lab-floor/galaxy-line/reactor-1/Source").Value) > 0;',
(@VtScript, 'machine-status-predicate',
N'return System.Convert.ToInt32(ctx.GetTag("' + @RawPath + N'").Value) > 0;',
'0000000000000000000000000000000000000000000000000000000000000000', 'CSharp'),
(@Gen, @AlScript, 'overtemp-predicate',
N'return System.Convert.ToInt32(ctx.GetTag("/lab-floor/galaxy-line/reactor-1/Source").Value) > 50;',
(@AlScript, 'overtemp-predicate',
N'return System.Convert.ToInt32(ctx.GetTag("' + @RawPath + N'").Value) > 50;',
'0000000000000000000000000000000000000000000000000000000000000000', 'CSharp');
-- 8. VirtualTag — MachineStatus boolean computed by Roslyn each time Source
-- changes. Historized so the dashboard can plot a running/idle timeline
-- next to the raw TestHistoryValue trend from Aveva Historian.
INSERT dbo.VirtualTag(GenerationId, VirtualTagId, EquipmentId, Name, DataType,
-- 6. VirtualTag — MachineStatus boolean computed on each Source change.
INSERT dbo.VirtualTag(VirtualTagId, EquipmentId, Name, DataType,
ScriptId, ChangeTriggered, TimerIntervalMs, Historize, Enabled)
VALUES (@Gen, @VtId, @EqId, 'MachineStatus', 'Boolean', @VtScript, 1, NULL, 1, 1);
VALUES (@VtId, @EqId, 'MachineStatus', 'Boolean', @VtScript, 1, NULL, 1, 1);
-- 9. ScriptedAlarm — Active when Source > 50.
INSERT dbo.ScriptedAlarm(GenerationId, ScriptedAlarmId, EquipmentId, Name, AlarmType,
-- 7. ScriptedAlarm — Active when Source > 50. {token} is a RawPath in v3.
INSERT dbo.ScriptedAlarm(ScriptedAlarmId, EquipmentId, Name, AlarmType,
Severity, MessageTemplate, PredicateScriptId,
HistorizeToAveva, Retain, Enabled)
VALUES (@Gen, @AlId, @EqId, 'OverTemp', 'LimitAlarm', 800,
N'Reactor source value {/lab-floor/galaxy-line/reactor-1/Source} exceeded 50',
VALUES (@AlId, @EqId, 'OverTemp', 'LimitAlarm', 800,
N'Reactor source value {' + @RawPath + N'} exceeded 50',
@AlScript, 1, 1, 1);
-- 10. Publish — flip the generation Status. sp_PublishGeneration takes
-- concurrency locks + does ExternalIdReservation merging; we drive it via
-- EXEC rather than UPDATE so the rest of the publish workflow runs.
EXEC dbo.sp_PublishGeneration @ClusterId = @ClusterId, @DraftGenerationId = @Gen,
@Notes = N'Phase 7 live smoke — task #240';
COMMIT;
PRINT '';
PRINT 'Phase 7 smoke seed complete.';
PRINT ' Cluster: ' + @ClusterId;
PRINT ' Node: ' + @NodeId + ' (set Node:NodeId in appsettings.json)';
PRINT ' Generation: ' + CONVERT(nvarchar(20), @Gen);
PRINT '';
PRINT 'Next steps:';
PRINT ' 1. Edit src/Server/ZB.MOM.WW.OtOpcUa.Server/appsettings.json:';
PRINT ' Node:NodeId = "p7-smoke-node"';
PRINT ' Node:ClusterId = "p7-smoke"';
PRINT ' 2. Edit the placeholder Galaxy attribute in dbo.Tag.TagConfig above';
PRINT ' so it points at a real attribute on this Galaxy — replace';
PRINT ' REPLACE_WITH_REAL_GALAXY_ATTRIBUTE with e.g. "Plant1.Reactor1.Temp".';
PRINT ' 3. Start the Server in a non-elevated shell so the Galaxy.Host pipe ACL';
PRINT ' accepts the connection:';
PRINT ' dotnet run --project src/Server/ZB.MOM.WW.OtOpcUa.Server';
PRINT ' 4. Validate via Client.CLI per docs/v2/implementation/phase-7-e2e-smoke.md';
PRINT 'Phase 7 smoke seed complete (v3 schema). Cluster: ' + @ClusterId + ' Node: ' + @NodeId;
PRINT 'Next: set Server Node:NodeId/ClusterId, deploy via the ControlPlane, then run the Client.CLI checks.';
+41 -94
View File
@@ -1,127 +1,74 @@
-- S7 e2e smoke seed — closes #212 (umbrella #209).
-- S7 e2e smoke seed — v3 schema (Raw tree: DriverInstance → Device → Tag; UNS by reference).
--
-- One-cluster seed pointing at the python-snap7 fixture
-- (`docker compose -f tests/Drivers/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=<N>;s=DB1_DBW0` (driver
-- sanitises the dot for browse names — see S7Driver.DiscoverAsync).
-- Idempotent DELETE-and-recreate of one cluster's worth of S7 test config pointing at the snap7
-- fixture (127.0.0.1:1102). v3: the ConfigGeneration/Namespace model + sp_PublishGeneration are
-- gone; the endpoint (Host/Port/Rack/Slot) moved from DriverConfig into the Device's DeviceConfig.
--
-- 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"`.
-- After seeding: set Server appsettings Node:NodeId/ClusterId, deploy via the ControlPlane, then
-- `./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 @DevId nvarchar(64) = 's7-smoke-dev';
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';
DECLARE @EqUuid uniqueidentifier = '5717A5E0-5717-45E0-9571-7A5E05717A5E';
DECLARE @Tag nvarchar(64) = 's7-smoke-tag-db1dbw0';
DECLARE @RefId nvarchar(64) = 's7-smoke-ref-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;
DELETE FROM dbo.UnsTagReference WHERE UnsTagReferenceId = @RefId;
DELETE FROM dbo.Tag WHERE TagId = @Tag;
DELETE FROM dbo.Equipment WHERE EquipmentId = @EqId;
DELETE FROM dbo.UnsLine WHERE UnsLineId = @LineId;
DELETE FROM dbo.UnsArea WHERE UnsAreaId = @AreaId;
DELETE FROM dbo.Device WHERE DeviceId = @DevId;
DELETE FROM dbo.DriverInstance WHERE DriverInstanceId = @DrvId;
DELETE FROM dbo.ClusterNodeCredential WHERE NodeId = @NodeId;
DELETE FROM dbo.ClusterNode WHERE NodeId = @NodeId;
DELETE FROM dbo.ServerCluster WHERE ClusterId = @ClusterId;
DELETE FROM dbo.ClusterNodeCredential WHERE Kind = 'SqlLogin' AND Value = 'sa';
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,
INSERT dbo.ClusterNode(NodeId, ClusterId, Host, OpcUaPort, DashboardPort,
ApplicationUri, ServiceLevelBase, Enabled, CreatedBy)
VALUES (@NodeId, @ClusterId, 'Primary', 'localhost', 4840, 15050,
'urn:OtOpcUa:s7-smoke-node', 200, 1, 's7-smoke');
-- Dashboard moved off :5000 (Windows URL-ACL).
VALUES (@NodeId, @ClusterId, 'localhost', 4840, 15050, 'urn:OtOpcUa:s7-smoke-node', 200, 1, 's7-smoke');
INSERT dbo.ClusterNodeCredential(NodeId, Kind, Value, Enabled, CreatedBy)
VALUES (@NodeId, 'SqlLogin', 'sa', 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
}
]
-- DriverConfig keeps protocol/channel settings; endpoint moves to DeviceConfig.
INSERT dbo.DriverInstance(DriverInstanceId, ClusterId, RawFolderId, Name, DriverType, DriverConfig, Enabled)
VALUES (@DrvId, @ClusterId, NULL, 'snap7-smoke', 'S7', N'{
"Probe": { "Enabled": true, "IntervalMs": 5000, "TimeoutMs": 2000, "ProbeAddress": "MW0" }
}', 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);
-- DeviceConfig shape finalized against reworked options in Wave B (WP5).
INSERT dbo.Device(DeviceId, DriverInstanceId, Name, Enabled, DeviceConfig)
VALUES (@DevId, @DrvId, 'plc', 1, N'{ "Host": "127.0.0.1", "Port": 1102, "Rack": 0, "Slot": 0 }');
EXEC dbo.sp_PublishGeneration @ClusterId = @ClusterId, @DraftGenerationId = @Gen,
@Notes = N'S7 smoke — task #212';
INSERT dbo.Tag(TagId, DeviceId, TagGroupId, Name, DataType, AccessLevel, WriteIdempotent, TagConfig)
VALUES (@Tag, @DevId, NULL, 'DB1_DBW0', 'Int16', 'ReadWrite', 1,
N'{ "address": "DB1.DBW0", "dataType": "Int16" }');
INSERT dbo.UnsArea(UnsAreaId, ClusterId, Name) VALUES (@AreaId, @ClusterId, 'lab-floor');
INSERT dbo.UnsLine(UnsLineId, UnsAreaId, Name) VALUES (@LineId, @AreaId, 's7-line');
INSERT dbo.Equipment(EquipmentId, EquipmentUuid, UnsLineId, Name, MachineCode, Enabled)
VALUES (@EqId, @EqUuid, @LineId, 's7-sim', 's7-001', 1);
INSERT dbo.UnsTagReference(UnsTagReferenceId, EquipmentId, TagId, SortOrder)
VALUES (@RefId, @EqId, @Tag, 0);
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/Server/ZB.MOM.WW.OtOpcUa.Server';
PRINT ' 4. ./scripts/e2e/test-s7.ps1 -BridgeNodeId "ns=2;s=DB1_DBW0" -S7Host "127.0.0.1:1102"';
PRINT 'S7 smoke seed complete (v3 schema). Cluster: ' + @ClusterId + ' Node: ' + @NodeId;
@@ -0,0 +1,99 @@
using System.Text.Json;
using System.Text.Json.Nodes;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Commons.Types;
/// <summary>
/// v3 endpoint→<c>DeviceConfig</c> merge authority. Folds a driver's <c>DriverConfig</c> JSON
/// (protocol/channel-level settings) together with its per-<c>Device</c> <c>DeviceConfig</c> JSON
/// (host/endpoint + per-device settings — the Kepware channel/device split) plus the driver's authored
/// raw tags into the SINGLE config blob every driver factory binds from. Shared by the deploy-decode
/// seam (<c>DeploymentArtifact</c>, which builds each <c>DriverInstanceSpec.DriverConfig</c>) and — in
/// Batch 2 — the browse modal (which needs the same merged shape as the browser's <c>configJson</c>),
/// so the merge lives in exactly one place.
/// <para><b>Contract.</b> Output = the parsed <c>DriverConfig</c> object with, in order:
/// <list type="number">
/// <item>When there is EXACTLY ONE device, its <c>DeviceConfig</c> keys shallow-merged up to the top
/// level (DeviceConfig wins) — so a single-endpoint driver's endpoint (<c>Host</c>/<c>Port</c>,
/// <c>EndpointUrl</c>, gateway address, …) lands where its options bind. Multiple devices do NOT
/// merge up (their endpoints are per-device, read from the array below).</item>
/// <item>A reconstructed <c>Devices</c> array — one object per device = its <c>DeviceConfig</c> keys
/// plus <c>DeviceName</c> = the device's entity <c>Name</c> (the RawPath device segment /
/// <see cref="RawTagEntry.DeviceName"/> routing key). Multi-device drivers read this; single-endpoint
/// drivers ignore the unknown key.</item>
/// <item>A <c>RawTags</c> array = the driver's authored <see cref="RawTagEntry"/> list, so
/// <c>options.RawTags</c> binds.</item>
/// </list>
/// Existing top-level protocol/channel keys survive (single-device DeviceConfig keys override on a name
/// clash — the documented Kepware precedence). Never throws on malformed member JSON: a blank/invalid
/// <c>DriverConfig</c> or <c>DeviceConfig</c> degrades to an empty object for that member.</para>
/// </summary>
public static class DriverDeviceConfigMerger
{
private static readonly JsonSerializerOptions SerializeOptions = new()
{
WriteIndented = false,
DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.Never,
};
/// <summary>The device rows feeding the merge: the entity <c>Name</c> (routing key) + schemaless <c>DeviceConfig</c> JSON.</summary>
/// <param name="Name">The device's entity <c>Name</c> — the RawPath device segment and multi-device routing key.</param>
/// <param name="DeviceConfig">The device's schemaless <c>DeviceConfig</c> JSON (endpoint + per-device settings).</param>
public readonly record struct DeviceRow(string Name, string? DeviceConfig);
/// <summary>Merge a driver's config + its devices' configs + its authored raw tags into one config blob.</summary>
/// <param name="driverConfigJson">The driver-level <c>DriverConfig</c> JSON (protocol/channel settings).</param>
/// <param name="devices">The driver's device rows, in deterministic order (the sole device merges up).</param>
/// <param name="rawTags">The driver's authored raw tags to inject as the <c>RawTags</c> array.</param>
/// <returns>The single merged config JSON string the driver factory binds from.</returns>
public static string Merge(
string? driverConfigJson,
IReadOnlyList<DeviceRow> devices,
IReadOnlyList<RawTagEntry> rawTags)
{
ArgumentNullException.ThrowIfNull(devices);
ArgumentNullException.ThrowIfNull(rawTags);
var root = ParseObject(driverConfigJson);
// (1) Exactly one device ⇒ shallow-merge its DeviceConfig up to the top level (DeviceConfig wins),
// so a single-endpoint driver's endpoint lands where its options bind. Multiple devices are
// per-device only (read from the Devices array below), so no merge-up.
if (devices.Count == 1)
{
var only = ParseObject(devices[0].DeviceConfig);
foreach (var (key, value) in only)
root[key] = value?.DeepClone();
}
// (2) Reconstruct the Devices array from ALL device rows: DeviceConfig keys + DeviceName = entity Name.
var devicesArray = new JsonArray();
foreach (var device in devices)
{
var obj = ParseObject(device.DeviceConfig);
obj["DeviceName"] = device.Name; // entity Name is the RawPath device segment + routing key
devicesArray.Add(obj);
}
root["Devices"] = devicesArray;
// (3) Inject the authored raw tags so options.RawTags binds.
root["RawTags"] = JsonSerializer.SerializeToNode(rawTags, SerializeOptions);
return root.ToJsonString(SerializeOptions);
}
/// <summary>Parse JSON into a mutable <see cref="JsonObject"/>; blank / non-object / malformed ⇒ a fresh empty object.</summary>
private static JsonObject ParseObject(string? json)
{
if (string.IsNullOrWhiteSpace(json)) return new JsonObject();
try
{
return JsonNode.Parse(json) as JsonObject ?? new JsonObject();
}
catch (JsonException)
{
return new JsonObject();
}
}
}
@@ -0,0 +1,132 @@
namespace ZB.MOM.WW.OtOpcUa.Commons.Types;
/// <summary>
/// Resolves a raw <c>Tag</c>'s v3 <c>RawPath</c> — the single identity string
/// <c>&lt;Folder/…&gt;/&lt;Driver&gt;/&lt;Device&gt;/&lt;TagGroup/…&gt;/&lt;Tag&gt;</c> — from the raw
/// topology's ancestry maps, and a driver's RawPath prefix (folders + driver name). Pure + input-shape
/// agnostic: callers build the four id→ancestry maps from whatever they have (EF entities on the
/// authoring/validation side, artifact JSON on the deploy-decode side) and both sides then agree
/// byte-for-byte because the segment order + join live ONLY here (over <see cref="RawPaths"/>).
/// <para>Every path is built through <see cref="RawPaths.Combine"/>, so segment charset (no
/// <c>/</c>, no lead/trail whitespace) is enforced in one place. A broken link (missing device / driver
/// lookup, or an invalid segment) yields <see langword="null"/> — never a throw — so a malformed
/// artifact degrades to "this tag has no RawPath" (dropped) rather than faulting a whole deploy decode.
/// Ancestry walks are depth-bounded to survive a corrupt parent cycle.</para>
/// </summary>
public sealed class RawPathResolver
{
private const int MaxAncestryDepth = 256;
private readonly IReadOnlyDictionary<string, (string? ParentId, string Name)> _folders;
private readonly IReadOnlyDictionary<string, (string? RawFolderId, string Name)> _drivers;
private readonly IReadOnlyDictionary<string, (string DriverInstanceId, string Name)> _devices;
private readonly IReadOnlyDictionary<string, (string? ParentId, string Name)> _groups;
/// <summary>Initializes a new <see cref="RawPathResolver"/> over the raw topology's ancestry maps.</summary>
/// <param name="folders"><c>RawFolderId → (ParentRawFolderId?, Name)</c>. <see langword="null"/> parent = cluster root.</param>
/// <param name="drivers"><c>DriverInstanceId → (RawFolderId?, Name)</c>. <see langword="null"/> folder = cluster root.</param>
/// <param name="devices"><c>DeviceId → (DriverInstanceId, Name)</c>.</param>
/// <param name="groups"><c>TagGroupId → (ParentTagGroupId?, Name)</c>. <see langword="null"/> parent = directly under the device.</param>
public RawPathResolver(
IReadOnlyDictionary<string, (string? ParentId, string Name)> folders,
IReadOnlyDictionary<string, (string? RawFolderId, string Name)> drivers,
IReadOnlyDictionary<string, (string DriverInstanceId, string Name)> devices,
IReadOnlyDictionary<string, (string? ParentId, string Name)> groups)
{
ArgumentNullException.ThrowIfNull(folders);
ArgumentNullException.ThrowIfNull(drivers);
ArgumentNullException.ThrowIfNull(devices);
ArgumentNullException.ThrowIfNull(groups);
_folders = folders;
_drivers = drivers;
_devices = devices;
_groups = groups;
}
/// <summary>
/// Build the RawPath prefix a driver contributes to every child tag: its folder ancestry
/// (root → leaf) followed by the driver name. Returns <see langword="null"/> when the driver id is
/// unknown or any segment/link is invalid.
/// </summary>
/// <param name="driverInstanceId">The driver instance id.</param>
/// <returns>The driver RawPath prefix, or <see langword="null"/> on a broken/unknown chain.</returns>
public string? TryBuildDriverPrefix(string driverInstanceId)
{
if (driverInstanceId is null || !_drivers.TryGetValue(driverInstanceId, out var driver)) return null;
try
{
var path = BuildFolderChain(driver.RawFolderId);
return RawPaths.Combine(path, driver.Name);
}
catch (ArgumentException)
{
return null;
}
}
/// <summary>
/// Build a raw tag's full RawPath:
/// <c>&lt;driver-prefix&gt;/&lt;Device&gt;/&lt;TagGroup ancestry root→leaf&gt;/&lt;Tag&gt;</c>.
/// Returns <see langword="null"/> when the device / driver lookup is missing or any segment/link is
/// invalid.
/// </summary>
/// <param name="deviceId">The tag's owning device id.</param>
/// <param name="tagGroupId">The tag's owning tag-group id, or <see langword="null"/> when directly under the device.</param>
/// <param name="tagName">The tag's leaf name.</param>
/// <returns>The tag's RawPath, or <see langword="null"/> on a broken/unknown chain.</returns>
public string? TryBuildTagPath(string deviceId, string? tagGroupId, string tagName)
{
if (deviceId is null || !_devices.TryGetValue(deviceId, out var device)) return null;
var driverPrefix = TryBuildDriverPrefix(device.DriverInstanceId);
if (driverPrefix is null) return null;
try
{
var path = RawPaths.Combine(driverPrefix, device.Name);
path = AppendGroupChain(path, tagGroupId);
return RawPaths.Combine(path, tagName);
}
catch (ArgumentException)
{
return null;
}
}
/// <summary>Build a folder ancestry path (root → leaf) for the given leaf folder id (null = cluster root ⇒ blank).</summary>
private string? BuildFolderChain(string? leafFolderId)
{
if (leafFolderId is null) return null;
var names = new List<string>();
var current = leafFolderId;
var depth = 0;
while (current is not null && depth++ < MaxAncestryDepth)
{
if (!_folders.TryGetValue(current, out var folder))
throw new ArgumentException($"Unknown RawFolder '{current}'.");
names.Add(folder.Name);
current = folder.ParentId;
}
names.Reverse();
return names.Count == 0 ? null : RawPaths.Build(names);
}
/// <summary>Append a tag-group ancestry path (root → leaf) onto <paramref name="parentPath"/> (null group ⇒ unchanged).</summary>
private string AppendGroupChain(string parentPath, string? leafGroupId)
{
if (leafGroupId is null) return parentPath;
var names = new List<string>();
var current = leafGroupId;
var depth = 0;
while (current is not null && depth++ < MaxAncestryDepth)
{
if (!_groups.TryGetValue(current, out var group))
throw new ArgumentException($"Unknown TagGroup '{current}'.");
names.Add(group.Name);
current = group.ParentId;
}
names.Reverse();
var path = parentPath;
foreach (var name in names)
path = RawPaths.Combine(path, name);
return path;
}
}
@@ -0,0 +1,135 @@
namespace ZB.MOM.WW.OtOpcUa.Commons.Types;
/// <summary>
/// The v3 identity authority. A <c>RawPath</c> is the cluster-scoped, slash-separated raw device
/// path — <c>&lt;Folder/…&gt;/&lt;Driver&gt;/&lt;Device&gt;/&lt;TagGroup/…&gt;/&lt;Tag&gt;</c> — and is
/// the single string used at every identity seam: <c>ns=Raw</c> NodeIds, the driver wire-reference
/// (<c>EquipmentTagRefResolver</c> key), the value fan-out / write-routing maps
/// (<c>DriverHostActor</c>), the historian default tagname + mux interest key, the native-alarm
/// <c>ConditionId</c>, and <c>ctx.GetTag</c> script paths.
/// <para>
/// RawPaths are ALWAYS built through this helper — never string-concatenated ad hoc — so the
/// separator and the segment charset are enforced in exactly one place. Comparison is
/// case-sensitive ordinal everywhere (see <see cref="Comparer"/>).
/// </para>
/// </summary>
public static class RawPaths
{
/// <summary>The single path separator character. Forbidden inside any segment.</summary>
public const char Separator = '/';
/// <summary>The path separator as a string (join/split convenience).</summary>
public const string SeparatorString = "/";
/// <summary>The one comparer for RawPaths and their segments: case-sensitive ordinal.</summary>
public static StringComparer Comparer => StringComparer.Ordinal;
/// <summary>
/// Validate a single path segment. A segment must be non-empty, contain no
/// <see cref="Separator"/>, and carry no leading/trailing whitespace (the separator and
/// surrounding whitespace would corrupt the NodeId / round-trip).
/// </summary>
/// <param name="segment">The candidate segment (a folder / driver / device / group / tag name).</param>
/// <returns>A human-readable error when invalid, or <see langword="null"/> when the segment is valid.</returns>
public static string? ValidateSegment(string? segment)
{
if (string.IsNullOrEmpty(segment))
return "Name must not be empty.";
if (segment.IndexOf(Separator) >= 0)
return $"Name must not contain '{Separator}'.";
if (char.IsWhiteSpace(segment[0]) || char.IsWhiteSpace(segment[^1]))
return "Name must not have leading or trailing whitespace.";
return null;
}
/// <summary>True when <paramref name="segment"/> passes <see cref="ValidateSegment"/>.</summary>
/// <param name="segment">The candidate segment.</param>
/// <returns><see langword="true"/> when the segment is a legal RawPath segment.</returns>
public static bool IsValidSegment(string? segment) => ValidateSegment(segment) is null;
/// <summary>
/// Build a RawPath from ordered segments (root-first). Every segment is validated via
/// <see cref="ValidateSegment"/>; an invalid or empty segment list throws.
/// </summary>
/// <param name="segments">The ordered, non-empty segments (root → leaf).</param>
/// <returns>The slash-joined RawPath.</returns>
/// <exception cref="ArgumentException">A segment is invalid, or the sequence is empty.</exception>
public static string Build(IEnumerable<string> segments)
{
ArgumentNullException.ThrowIfNull(segments);
var list = segments as IReadOnlyList<string> ?? segments.ToList();
if (list.Count == 0)
throw new ArgumentException("A RawPath must have at least one segment.", nameof(segments));
for (var i = 0; i < list.Count; i++)
{
var error = ValidateSegment(list[i]);
if (error is not null)
throw new ArgumentException($"Invalid RawPath segment at index {i} ('{list[i]}'): {error}", nameof(segments));
}
return string.Join(Separator, list);
}
/// <summary>Build a RawPath from ordered segments (root-first). See <see cref="Build(IEnumerable{string})"/>.</summary>
/// <param name="segments">The ordered, non-empty segments (root → leaf).</param>
/// <returns>The slash-joined RawPath.</returns>
public static string Build(params string[] segments) => Build((IEnumerable<string>)segments);
/// <summary>
/// Append a child segment to an existing (already-built) parent RawPath. The child is
/// validated; a blank parent yields the (validated) child alone.
/// </summary>
/// <param name="parentPath">The parent RawPath (assumed already valid), or blank for a root segment.</param>
/// <param name="childSegment">The child segment to append.</param>
/// <returns>The combined RawPath.</returns>
/// <exception cref="ArgumentException">The child segment is invalid.</exception>
public static string Combine(string? parentPath, string childSegment)
{
var error = ValidateSegment(childSegment);
if (error is not null)
throw new ArgumentException($"Invalid RawPath segment ('{childSegment}'): {error}", nameof(childSegment));
return string.IsNullOrEmpty(parentPath) ? childSegment : parentPath + Separator + childSegment;
}
/// <summary>Split a RawPath into its ordered segments (root → leaf).</summary>
/// <param name="rawPath">The RawPath to split.</param>
/// <returns>The ordered segments; a single-element array for a one-segment path.</returns>
public static IReadOnlyList<string> Split(string rawPath)
{
ArgumentNullException.ThrowIfNull(rawPath);
return rawPath.Split(Separator);
}
/// <summary>The leaf (last) segment of a RawPath.</summary>
/// <param name="rawPath">The RawPath.</param>
/// <returns>The final segment.</returns>
public static string Leaf(string rawPath)
{
ArgumentNullException.ThrowIfNull(rawPath);
var idx = rawPath.LastIndexOf(Separator);
return idx < 0 ? rawPath : rawPath[(idx + 1)..];
}
/// <summary>
/// Get the parent RawPath (everything up to, not including, the leaf). Returns
/// <see langword="false"/> for a single-segment path (no parent).
/// </summary>
/// <param name="rawPath">The RawPath.</param>
/// <param name="parent">The parent RawPath when this returns <see langword="true"/>.</param>
/// <returns><see langword="true"/> when a parent exists.</returns>
public static bool TryParent(string rawPath, out string parent)
{
ArgumentNullException.ThrowIfNull(rawPath);
var idx = rawPath.LastIndexOf(Separator);
if (idx < 0)
{
parent = string.Empty;
return false;
}
parent = rawPath[..idx];
return true;
}
}
@@ -8,28 +8,17 @@ namespace ZB.MOM.WW.OtOpcUa.Commons.Types;
/// (<c>AddressSpaceComposer</c>), the artifact-decode seam (<c>DeploymentArtifact</c>), the draft
/// gate (<c>DraftValidator</c>), and the walker (<c>EquipmentNodeWalker</c>). One
/// <see cref="JsonDocument.Parse(string)"/> per blob. Never throws.
/// <para>Two deliberate variations are carried on the record so every seam can be served from one
/// parse:</para>
/// <list type="bullet">
/// <item><description><see cref="FullName"/> is the driver-side wire reference: the top-level
/// <c>FullName</c> string when present, else the RAW blob (an equipment tag's TagConfig JSON
/// <em>is</em> its wire reference for the six protocol drivers). <c>Parse(null)</c> ⇒
/// <c>FullName == ""</c>.</description></item>
/// <item><description><see cref="ExplicitFullName"/> is the <c>FullName</c> property ONLY when
/// present-and-string, else <see langword="null"/> — the DraftValidator Galaxy-rule semantics
/// (it wants the explicit reference, not the raw-blob fallback).</description></item>
/// </list>
/// <para>Under the v3 identity contract, <c>TagConfig</c> no longer carries identity — the
/// RawPath (computed elsewhere) is the single identity string at every seam. This type parses
/// only the cross-driver PLATFORM intents (alarm / historize / array); the driver-specific
/// address fields stay opaque inside the blob and are read by the per-driver resolvers.</para>
/// </summary>
/// <param name="FullName">Top-level <c>FullName</c> string, else the raw blob (wire-reference fallback).</param>
/// <param name="ExplicitFullName">The <c>FullName</c> property when present-and-string, else <see langword="null"/>.</param>
/// <param name="Alarm">The optional native-alarm intent parsed from the <c>alarm</c> object; <see langword="null"/> ⇒ plain value variable.</param>
/// <param name="IsHistorized"><c>isHistorized</c> — true only when the value is a bool <c>true</c>.</param>
/// <param name="HistorianTagname">Non-whitespace <c>historianTagname</c> string override, else <see langword="null"/> (not trimmed).</param>
/// <param name="IsArray"><c>isArray</c> bool.</param>
/// <param name="ArrayLength"><c>arrayLength</c> honoured ONLY when <see cref="IsArray"/> and a JSON number fitting <see cref="uint"/>; else <see langword="null"/>.</param>
public sealed record TagConfigIntent(
string FullName,
string? ExplicitFullName,
TagAlarmIntent? Alarm,
bool IsHistorized,
string? HistorianTagname,
@@ -38,40 +27,33 @@ public sealed record TagConfigIntent(
{
/// <summary>
/// Parse a Tag's schemaless <c>TagConfig</c> JSON into the cross-driver platform intents.
/// Never throws: malformed JSON / non-object root / blank / null ⇒ all intents default with
/// <see cref="FullName"/> = the raw blob (or "" for null). Semantics are ported verbatim from the
/// historical <c>AddressSpaceComposer.Extract*</c> statics (the canonical copy).
/// Never throws: malformed JSON / non-object root / blank / null ⇒ all intents default.
/// Semantics are ported verbatim from the historical <c>AddressSpaceComposer.Extract*</c>
/// statics (the canonical copy).
/// </summary>
/// <param name="tagConfig">The tag's raw <c>TagConfig</c> JSON (nullable/blank tolerated).</param>
/// <returns>The parsed intents; never <see langword="null"/>.</returns>
public static TagConfigIntent Parse(string? tagConfig)
{
if (string.IsNullOrWhiteSpace(tagConfig))
return new TagConfigIntent(tagConfig ?? string.Empty, null, null, false, null, false, null);
return new TagConfigIntent(null, false, null, false, null);
try
{
using var doc = JsonDocument.Parse(tagConfig);
var root = doc.RootElement;
if (root.ValueKind != JsonValueKind.Object)
return new TagConfigIntent(tagConfig, null, null, false, null, false, null);
var explicitFullName =
root.TryGetProperty("FullName", out var fn) && fn.ValueKind == JsonValueKind.String
? fn.GetString()
: null;
var fullName = explicitFullName ?? tagConfig;
return new TagConfigIntent(null, false, null, false, null);
var (isHistorized, historianTagname) = ParseHistorize(root);
var (isArray, arrayLength) = ParseArray(root);
return new TagConfigIntent(
fullName, explicitFullName, ParseAlarm(root),
isHistorized, historianTagname, isArray, arrayLength);
ParseAlarm(root), isHistorized, historianTagname, isArray, arrayLength);
}
catch (JsonException)
{
return new TagConfigIntent(tagConfig, null, null, false, null, false, null);
return new TagConfigIntent(null, false, null, false, null);
}
}
@@ -1,6 +1,12 @@
namespace ZB.MOM.WW.OtOpcUa.Configuration.Entities;
/// <summary>Per-device row for multi-device drivers (Modbus, AB CIP). Optional for single-device drivers.</summary>
/// <summary>
/// v3: a device under a <see cref="DriverInstance"/>. Now UNIVERSAL — every driver has ≥1 device
/// (single-endpoint drivers get an auto-created default device). Endpoint/connection settings live
/// in <see cref="DeviceConfig"/> (the Kepware channel/device split: <c>DriverConfig</c> keeps
/// protocol/channel-level settings; <c>DeviceConfig</c> carries host/endpoint + per-device
/// settings). The device name is a RawPath segment.
/// </summary>
public sealed class Device
{
/// <summary>
@@ -13,12 +13,13 @@ public sealed class DriverInstance
public required string ClusterId { get; set; }
/// <summary>
/// Logical FK to <see cref="Namespace.NamespaceId"/>. Same-cluster binding enforced by
/// <c>sp_ValidateDraft</c>: Namespace.ClusterId must equal DriverInstance.ClusterId.
/// v3: logical FK to a containing <see cref="RawFolder.RawFolderId"/>; <see langword="null"/>
/// = the driver sits at the cluster root of the Raw tree. Contributes the leading segments of
/// every child tag's <c>RawPath</c>.
/// </summary>
public required string NamespaceId { get; set; }
public string? RawFolderId { get; set; }
/// <summary>Gets or sets the friendly name of this driver instance.</summary>
/// <summary>Gets or sets the friendly name of this driver instance. A RawPath segment.</summary>
public required string Name { get; set; }
/// <summary>Galaxy | Modbus | AbCip | AbLegacy | S7 | TwinCat | Focas | OpcUaClient</summary>
@@ -17,14 +17,9 @@ public sealed class Equipment
/// <summary>UUIDv4, IMMUTABLE across all generations of the same EquipmentId. Downstream-consumer join key.</summary>
public Guid EquipmentUuid { get; set; }
/// <summary>
/// Optional logical FK to the driver providing data for this equipment.
/// <c>null</c> = VirtualTag-only / driver-less equipment (no field driver).
/// </summary>
public string? DriverInstanceId { get; set; }
/// <summary>Optional logical FK to a multi-device driver's device.</summary>
public string? DeviceId { get; set; }
// v3: equipment no longer binds a driver or device. It references existing raw tags via
// UnsTagReference and hosts VirtualTags + ScriptedAlarms. The old DriverInstanceId / DeviceId
// binding columns are retired.
/// <summary>Logical FK to <see cref="UnsLine.UnsLineId"/>.</summary>
public required string UnsLineId { get; set; }
@@ -1,124 +0,0 @@
namespace ZB.MOM.WW.OtOpcUa.Configuration.Entities;
/// <summary>
/// Staged equipment-import batch per Phase 6.4 Stream B.2. Rows land in the child
/// <see cref="EquipmentImportRow"/> table under a batch header; operator reviews + either
/// drops (via <c>DropImportBatch</c>) or finalises (via <c>FinaliseImportBatch</c>) in one
/// bounded transaction. The live <c>Equipment</c> table never sees partial state.
/// </summary>
/// <remarks>
/// <para>User-scoped visibility: the preview modal only shows batches where
/// <see cref="CreatedBy"/> equals the current operator. Prevents accidental
/// cross-operator finalise during concurrent imports. An admin finalise / drop surface
/// can override this — tracked alongside the UI follow-up.</para>
///
/// <para><see cref="FinalisedAtUtc"/> stamps the moment the batch promoted from staging
/// into <c>Equipment</c>. Null = still in staging; non-null = archived / finalised.</para>
/// </remarks>
public sealed class EquipmentImportBatch
{
/// <summary>Gets or sets the unique identifier for this batch.</summary>
public Guid Id { get; set; }
/// <summary>Gets or sets the cluster identifier.</summary>
public required string ClusterId { get; set; }
/// <summary>Gets or sets the user name who created this batch.</summary>
public required string CreatedBy { get; set; }
/// <summary>Gets or sets the UTC timestamp when this batch was created.</summary>
public DateTime CreatedAtUtc { get; set; }
/// <summary>Gets or sets the total number of rows staged in this batch.</summary>
public int RowsStaged { get; set; }
/// <summary>Gets or sets the number of rows accepted in this batch.</summary>
public int RowsAccepted { get; set; }
/// <summary>Gets or sets the number of rows rejected in this batch.</summary>
public int RowsRejected { get; set; }
/// <summary>Gets or sets the UTC timestamp when this batch was finalised, or null if still in staging.</summary>
public DateTime? FinalisedAtUtc { get; set; }
/// <summary>Gets or sets the collection of staged rows in this batch.</summary>
public ICollection<EquipmentImportRow> Rows { get; set; } = [];
}
/// <summary>
/// One staged row under an <see cref="EquipmentImportBatch"/>. Mirrors the required
/// + optional columns from the CSV importer's output + an
/// <see cref="IsAccepted"/> flag + a <see cref="RejectReason"/> string the preview modal
/// renders.
/// </summary>
public sealed class EquipmentImportRow
{
/// <summary>Gets or sets the unique identifier for this row.</summary>
public Guid Id { get; set; }
/// <summary>Gets or sets the parent batch identifier.</summary>
public Guid BatchId { get; set; }
/// <summary>Gets or sets the line number in the source file.</summary>
public int LineNumberInFile { get; set; }
/// <summary>Gets or sets a value indicating whether this row was accepted.</summary>
public bool IsAccepted { get; set; }
/// <summary>Gets or sets the reason this row was rejected, if applicable.</summary>
public string? RejectReason { get; set; }
/// <summary>Gets or sets the Z tag identifier.</summary>
public required string ZTag { get; set; }
/// <summary>Gets or sets the machine code.</summary>
public required string MachineCode { get; set; }
/// <summary>Gets or sets the SAP identifier.</summary>
public required string SAPID { get; set; }
/// <summary>Gets or sets the equipment identifier.</summary>
public required string EquipmentId { get; set; }
/// <summary>Gets or sets the equipment UUID.</summary>
public required string EquipmentUuid { get; set; }
/// <summary>Gets or sets the equipment name.</summary>
public required string Name { get; set; }
/// <summary>Gets or sets the UNS area name.</summary>
public required string UnsAreaName { get; set; }
/// <summary>Gets or sets the UNS line name.</summary>
public required string UnsLineName { get; set; }
/// <summary>Gets or sets the manufacturer name.</summary>
public string? Manufacturer { get; set; }
/// <summary>Gets or sets the equipment model.</summary>
public string? Model { get; set; }
/// <summary>Gets or sets the serial number.</summary>
public string? SerialNumber { get; set; }
/// <summary>Gets or sets the hardware revision.</summary>
public string? HardwareRevision { get; set; }
/// <summary>Gets or sets the software revision.</summary>
public string? SoftwareRevision { get; set; }
/// <summary>Gets or sets the year of construction.</summary>
public string? YearOfConstruction { get; set; }
/// <summary>Gets or sets the asset location.</summary>
public string? AssetLocation { get; set; }
/// <summary>Gets or sets the manufacturer URI.</summary>
public string? ManufacturerUri { get; set; }
/// <summary>Gets or sets the device manual URI.</summary>
public string? DeviceManualUri { get; set; }
/// <summary>Gets or sets the parent batch.</summary>
public EquipmentImportBatch? Batch { get; set; }
}
@@ -1,37 +0,0 @@
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
namespace ZB.MOM.WW.OtOpcUa.Configuration.Entities;
/// <summary>
/// OPC UA namespace served by a cluster. Generation-versioned —
/// namespaces are content (affect what consumers see at the endpoint), not topology.
/// </summary>
public sealed class Namespace
{
/// <summary>Gets or sets the row identifier for this namespace.</summary>
public Guid NamespaceRowId { get; set; }
/// <summary>Stable logical ID, e.g. "LINE3-OPCUA-equipment". Globally unique in v2.</summary>
public required string NamespaceId { get; set; }
/// <summary>Gets or sets the cluster identifier.</summary>
public required string ClusterId { get; set; }
/// <summary>Gets or sets the namespace kind.</summary>
public required NamespaceKind Kind { get; set; }
/// <summary>E.g. "urn:zb:warsaw-west:equipment". Unique fleet-wide per generation.</summary>
public required string NamespaceUri { get; set; }
/// <summary>Gets or sets a value indicating whether the namespace is enabled.</summary>
public bool Enabled { get; set; } = true;
/// <summary>Gets or sets optional notes about the namespace.</summary>
public string? Notes { get; set; }
/// <summary>Optimistic concurrency token for last-write-wins detection in the v2 live-edit model.</summary>
public byte[] RowVersion { get; set; } = Array.Empty<byte>();
/// <summary>Gets or sets the associated server cluster.</summary>
public ServerCluster? Cluster { get; set; }
}
@@ -0,0 +1,30 @@
namespace ZB.MOM.WW.OtOpcUa.Configuration.Entities;
/// <summary>
/// v3 Raw-tree driver-organizing folder. Nestable (Kepware-style) directly under a cluster or
/// under another <see cref="RawFolder"/>. Folders group <see cref="DriverInstance"/> rows; they
/// contribute the leading segments of a driver's <c>RawPath</c>.
/// </summary>
public sealed class RawFolder
{
/// <summary>Gets or sets the database row identifier (PK).</summary>
public Guid RawFolderRowId { get; set; }
/// <summary>Stable logical ID, unique fleet-wide.</summary>
public required string RawFolderId { get; set; }
/// <summary>Logical FK to <see cref="ServerCluster.ClusterId"/> — the folder is cluster-rooted.</summary>
public required string ClusterId { get; set; }
/// <summary>Logical FK to a parent <see cref="RawFolder.RawFolderId"/>; <see langword="null"/> = root under the cluster.</summary>
public string? ParentRawFolderId { get; set; }
/// <summary>Sibling-unique folder name. A RawPath segment: no <c>/</c>, no lead/trail whitespace.</summary>
public required string Name { get; set; }
/// <summary>Sibling display ordering.</summary>
public int SortOrder { get; set; }
/// <summary>Optimistic concurrency token for last-write-wins detection in the v2 live-edit model.</summary>
public byte[] RowVersion { get; set; } = Array.Empty<byte>();
}
@@ -47,6 +47,6 @@ public sealed class ServerCluster
// Navigation
/// <summary>Gets or sets the collection of cluster nodes.</summary>
public ICollection<ClusterNode> Nodes { get; set; } = [];
/// <summary>Gets or sets the collection of namespaces in the cluster.</summary>
public ICollection<Namespace> Namespaces { get; set; } = [];
/// <summary>Gets or sets the collection of root-level Raw-tree folders in the cluster.</summary>
public ICollection<RawFolder> RawFolders { get; set; } = [];
}
@@ -3,46 +3,34 @@ using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
namespace ZB.MOM.WW.OtOpcUa.Configuration.Entities;
/// <summary>
/// One canonical tag (signal) in a cluster's generation. <see cref="EquipmentId"/> set ⟺ the
/// tag participates in the Equipment tree. A <c>GalaxyMxGateway</c>-bound equipment tag is an
/// ordinary equipment tag (GalaxyMxGateway is a standard Equipment-kind driver) that carries its
/// Galaxy attribute reference in <c>TagConfig.FullName</c> — there is no separate alias concept.
/// v3 raw-only tag (signal). A tag is authored ONCE, under a <see cref="Device"/> in the Raw
/// tree; its identity is its <c>RawPath</c>
/// (<c>&lt;Folder/…&gt;/&lt;Driver&gt;/&lt;Device&gt;/&lt;TagGroup/…&gt;/&lt;Name&gt;</c>). UNS
/// surfaces it by reference (<see cref="UnsTagReference"/>), never by re-authoring. The tag's
/// driver-specific address lives inside <see cref="TagConfig"/> as an ordinary field (what the
/// driver dials), not as system identity.
/// </summary>
public sealed class Tag
{
/// <summary>
/// Gets or sets the unique database row identifier for the tag.
/// </summary>
/// <summary>Gets or sets the unique database row identifier for the tag (PK).</summary>
public Guid TagRowId { get; set; }
/// <summary>
/// Gets or sets the tag identifier.
/// </summary>
/// <summary>Gets or sets the stable logical tag identifier (unique fleet-wide).</summary>
public required string TagId { get; set; }
/// <summary>
/// Gets or sets the driver instance identifier for this tag.
/// </summary>
public required string DriverInstanceId { get; set; }
/// <summary>v3: required logical FK to <see cref="Device.DeviceId"/>. Every tag lives under a device.</summary>
public required string DeviceId { get; set; }
/// <summary>
/// Gets or sets the device identifier.
/// Optional logical FK to a containing <see cref="TagGroup.TagGroupId"/>;
/// <see langword="null"/> = the tag sits directly under its device. Contributes the middle
/// segments of the tag's <c>RawPath</c>.
/// </summary>
public string? DeviceId { get; set; }
public string? TagGroupId { get; set; }
/// <summary>
/// Set when the tag belongs to an Equipment; NULL for FolderPath-scoped (namespace-root) tags.
/// </summary>
public string? EquipmentId { get; set; }
/// <summary>
/// Gets or sets the tag name.
/// </summary>
/// <summary>Gets or sets the tag name. A RawPath segment: no <c>/</c>, no lead/trail whitespace.</summary>
public required string Name { get; set; }
/// <summary>Only used when <see cref="EquipmentId"/> is NULL (FolderPath-scoped namespace tag).</summary>
public string? FolderPath { get; set; }
/// <summary>OPC UA built-in type name (Boolean / Int32 / Float / etc.).</summary>
public required string DataType { get; set; }
@@ -51,7 +39,7 @@ public sealed class Tag
/// </summary>
public required TagAccessLevel AccessLevel { get; set; }
/// <summary>Opt-in for write retry eligibility.</summary>
/// <summary>Opt-in for write retry eligibility (R2 resilience contract — retained in v3).</summary>
public bool WriteIdempotent { get; set; }
/// <summary>
@@ -0,0 +1,30 @@
namespace ZB.MOM.WW.OtOpcUa.Configuration.Entities;
/// <summary>
/// v3 Raw-tree tag-organizing group. Nestable (Kepware-style) directly under a
/// <see cref="Device"/> or under another <see cref="TagGroup"/>. Groups contribute the middle
/// segments of a tag's <c>RawPath</c> (between the device and the tag name).
/// </summary>
public sealed class TagGroup
{
/// <summary>Gets or sets the database row identifier (PK).</summary>
public Guid TagGroupRowId { get; set; }
/// <summary>Stable logical ID, unique fleet-wide.</summary>
public required string TagGroupId { get; set; }
/// <summary>Logical FK to <see cref="Device.DeviceId"/> — the group lives under a device.</summary>
public required string DeviceId { get; set; }
/// <summary>Logical FK to a parent <see cref="TagGroup.TagGroupId"/>; <see langword="null"/> = root under the device.</summary>
public string? ParentTagGroupId { get; set; }
/// <summary>Sibling-unique group name. A RawPath segment: no <c>/</c>, no lead/trail whitespace.</summary>
public required string Name { get; set; }
/// <summary>Sibling display ordering.</summary>
public int SortOrder { get; set; }
/// <summary>Optimistic concurrency token for last-write-wins detection in the v2 live-edit model.</summary>
public byte[] RowVersion { get; set; } = Array.Empty<byte>();
}
@@ -0,0 +1,35 @@
namespace ZB.MOM.WW.OtOpcUa.Configuration.Entities;
/// <summary>
/// v3 UNS projection row: a reference from an <see cref="Equipment"/> to an existing raw
/// <see cref="Tag"/>. UNS authoring is reference-only — equipment no longer authors
/// <c>TagConfig</c> or binds a driver; it points at raw tags (optionally under a display-name
/// override). The backing raw tag is the single value source; the UNS node fans out from it.
/// </summary>
public sealed class UnsTagReference
{
/// <summary>Gets or sets the database row identifier (PK).</summary>
public Guid UnsTagReferenceRowId { get; set; }
/// <summary>Stable logical ID, unique fleet-wide.</summary>
public required string UnsTagReferenceId { get; set; }
/// <summary>Logical FK to <see cref="Equipment.EquipmentId"/> — the referencing equipment.</summary>
public required string EquipmentId { get; set; }
/// <summary>Logical FK to <see cref="Tag.TagId"/> — the backing raw tag (same cluster as the equipment).</summary>
public required string TagId { get; set; }
/// <summary>
/// Optional UNS display-name override; <see langword="null"/> = use the raw tag's <c>Name</c>.
/// The effective name must be unique within the equipment across references, VirtualTags, and
/// ScriptedAlarms (enforced at authoring and at the deploy gate).
/// </summary>
public string? DisplayNameOverride { get; set; }
/// <summary>Sibling display ordering within the equipment's Tags list.</summary>
public int SortOrder { get; set; }
/// <summary>Optimistic concurrency token for last-write-wins detection in the v2 live-edit model.</summary>
public byte[] RowVersion { get; set; } = Array.Empty<byte>();
}
@@ -1,19 +0,0 @@
namespace ZB.MOM.WW.OtOpcUa.Configuration.Enums;
/// <summary>OPC UA namespace kind. One of each kind per cluster per generation.</summary>
public enum NamespaceKind
{
/// <summary>
/// Equipment namespace — raw signals from native-protocol drivers (Modbus, AB CIP, AB Legacy,
/// S7, TwinCAT, FOCAS, and OpcUaClient when gatewaying raw equipment). UNS 5-level hierarchy
/// applies.
/// </summary>
Equipment,
/// <summary>
/// Reserved for future replay driver per handoff §"Digital Twin Touchpoints" — not populated
/// in v2.0 but enum value reserved so the schema does not need to change when the replay
/// driver lands.
/// </summary>
Simulated,
}
File diff suppressed because it is too large Load Diff
@@ -1,811 +0,0 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ZB.MOM.WW.OtOpcUa.Configuration.Migrations
{
/// <inheritdoc />
public partial class InitialSchema : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "ConfigAuditLog",
columns: table => new
{
AuditId = table.Column<long>(type: "bigint", nullable: false)
.Annotation("SqlServer:Identity", "1, 1"),
Timestamp = table.Column<DateTime>(type: "datetime2(3)", nullable: false, defaultValueSql: "SYSUTCDATETIME()"),
Principal = table.Column<string>(type: "nvarchar(128)", maxLength: 128, nullable: false),
EventType = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: false),
ClusterId = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: true),
NodeId = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: true),
GenerationId = table.Column<long>(type: "bigint", nullable: true),
DetailsJson = table.Column<string>(type: "nvarchar(max)", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_ConfigAuditLog", x => x.AuditId);
table.CheckConstraint("CK_ConfigAuditLog_DetailsJson_IsJson", "DetailsJson IS NULL OR ISJSON(DetailsJson) = 1");
});
migrationBuilder.CreateTable(
name: "ExternalIdReservation",
columns: table => new
{
ReservationId = table.Column<Guid>(type: "uniqueidentifier", nullable: false, defaultValueSql: "NEWSEQUENTIALID()"),
Kind = table.Column<string>(type: "nvarchar(16)", maxLength: 16, nullable: false),
Value = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: false),
EquipmentUuid = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
ClusterId = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: false),
FirstPublishedAt = table.Column<DateTime>(type: "datetime2(3)", nullable: false, defaultValueSql: "SYSUTCDATETIME()"),
FirstPublishedBy = table.Column<string>(type: "nvarchar(128)", maxLength: 128, nullable: false),
LastPublishedAt = table.Column<DateTime>(type: "datetime2(3)", nullable: false, defaultValueSql: "SYSUTCDATETIME()"),
ReleasedAt = table.Column<DateTime>(type: "datetime2(3)", nullable: true),
ReleasedBy = table.Column<string>(type: "nvarchar(128)", maxLength: 128, nullable: true),
ReleaseReason = table.Column<string>(type: "nvarchar(512)", maxLength: 512, nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_ExternalIdReservation", x => x.ReservationId);
});
migrationBuilder.CreateTable(
name: "ServerCluster",
columns: table => new
{
ClusterId = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: false),
Name = table.Column<string>(type: "nvarchar(128)", maxLength: 128, nullable: false),
Enterprise = table.Column<string>(type: "nvarchar(32)", maxLength: 32, nullable: false),
Site = table.Column<string>(type: "nvarchar(32)", maxLength: 32, nullable: false),
NodeCount = table.Column<byte>(type: "tinyint", nullable: false),
RedundancyMode = table.Column<string>(type: "nvarchar(16)", maxLength: 16, nullable: false),
Enabled = table.Column<bool>(type: "bit", nullable: false),
Notes = table.Column<string>(type: "nvarchar(1024)", maxLength: 1024, nullable: true),
CreatedAt = table.Column<DateTime>(type: "datetime2(3)", nullable: false, defaultValueSql: "SYSUTCDATETIME()"),
CreatedBy = table.Column<string>(type: "nvarchar(128)", maxLength: 128, nullable: false),
ModifiedAt = table.Column<DateTime>(type: "datetime2(3)", nullable: true),
ModifiedBy = table.Column<string>(type: "nvarchar(128)", maxLength: 128, nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_ServerCluster", x => x.ClusterId);
table.CheckConstraint("CK_ServerCluster_RedundancyMode_NodeCount", "((NodeCount = 1 AND RedundancyMode = 'None') OR (NodeCount = 2 AND RedundancyMode IN ('Warm', 'Hot')))");
});
migrationBuilder.CreateTable(
name: "ClusterNode",
columns: table => new
{
NodeId = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: false),
ClusterId = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: false),
RedundancyRole = table.Column<string>(type: "nvarchar(16)", maxLength: 16, nullable: false),
Host = table.Column<string>(type: "nvarchar(255)", maxLength: 255, nullable: false),
OpcUaPort = table.Column<int>(type: "int", nullable: false),
DashboardPort = table.Column<int>(type: "int", nullable: false),
ApplicationUri = table.Column<string>(type: "nvarchar(256)", maxLength: 256, nullable: false),
ServiceLevelBase = table.Column<byte>(type: "tinyint", nullable: false),
DriverConfigOverridesJson = table.Column<string>(type: "nvarchar(max)", nullable: true),
Enabled = table.Column<bool>(type: "bit", nullable: false),
LastSeenAt = table.Column<DateTime>(type: "datetime2(3)", nullable: true),
CreatedAt = table.Column<DateTime>(type: "datetime2(3)", nullable: false, defaultValueSql: "SYSUTCDATETIME()"),
CreatedBy = table.Column<string>(type: "nvarchar(128)", maxLength: 128, nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_ClusterNode", x => x.NodeId);
table.ForeignKey(
name: "FK_ClusterNode_ServerCluster_ClusterId",
column: x => x.ClusterId,
principalTable: "ServerCluster",
principalColumn: "ClusterId",
onDelete: ReferentialAction.Restrict);
});
migrationBuilder.CreateTable(
name: "ConfigGeneration",
columns: table => new
{
GenerationId = table.Column<long>(type: "bigint", nullable: false)
.Annotation("SqlServer:Identity", "1, 1"),
ClusterId = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: false),
Status = table.Column<string>(type: "nvarchar(16)", maxLength: 16, nullable: false),
ParentGenerationId = table.Column<long>(type: "bigint", nullable: true),
PublishedAt = table.Column<DateTime>(type: "datetime2(3)", nullable: true),
PublishedBy = table.Column<string>(type: "nvarchar(128)", maxLength: 128, nullable: true),
Notes = table.Column<string>(type: "nvarchar(1024)", maxLength: 1024, nullable: true),
CreatedAt = table.Column<DateTime>(type: "datetime2(3)", nullable: false, defaultValueSql: "SYSUTCDATETIME()"),
CreatedBy = table.Column<string>(type: "nvarchar(128)", maxLength: 128, nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_ConfigGeneration", x => x.GenerationId);
table.ForeignKey(
name: "FK_ConfigGeneration_ConfigGeneration_ParentGenerationId",
column: x => x.ParentGenerationId,
principalTable: "ConfigGeneration",
principalColumn: "GenerationId",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_ConfigGeneration_ServerCluster_ClusterId",
column: x => x.ClusterId,
principalTable: "ServerCluster",
principalColumn: "ClusterId",
onDelete: ReferentialAction.Restrict);
});
migrationBuilder.CreateTable(
name: "ClusterNodeCredential",
columns: table => new
{
CredentialId = table.Column<Guid>(type: "uniqueidentifier", nullable: false, defaultValueSql: "NEWSEQUENTIALID()"),
NodeId = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: false),
Kind = table.Column<string>(type: "nvarchar(32)", maxLength: 32, nullable: false),
Value = table.Column<string>(type: "nvarchar(512)", maxLength: 512, nullable: false),
Enabled = table.Column<bool>(type: "bit", nullable: false),
RotatedAt = table.Column<DateTime>(type: "datetime2(3)", nullable: true),
CreatedAt = table.Column<DateTime>(type: "datetime2(3)", nullable: false, defaultValueSql: "SYSUTCDATETIME()"),
CreatedBy = table.Column<string>(type: "nvarchar(128)", maxLength: 128, nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_ClusterNodeCredential", x => x.CredentialId);
table.ForeignKey(
name: "FK_ClusterNodeCredential_ClusterNode_NodeId",
column: x => x.NodeId,
principalTable: "ClusterNode",
principalColumn: "NodeId",
onDelete: ReferentialAction.Restrict);
});
migrationBuilder.CreateTable(
name: "ClusterNodeGenerationState",
columns: table => new
{
NodeId = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: false),
CurrentGenerationId = table.Column<long>(type: "bigint", nullable: true),
LastAppliedAt = table.Column<DateTime>(type: "datetime2(3)", nullable: true),
LastAppliedStatus = table.Column<string>(type: "nvarchar(16)", maxLength: 16, nullable: true),
LastAppliedError = table.Column<string>(type: "nvarchar(2048)", maxLength: 2048, nullable: true),
LastSeenAt = table.Column<DateTime>(type: "datetime2(3)", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_ClusterNodeGenerationState", x => x.NodeId);
table.ForeignKey(
name: "FK_ClusterNodeGenerationState_ClusterNode_NodeId",
column: x => x.NodeId,
principalTable: "ClusterNode",
principalColumn: "NodeId",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_ClusterNodeGenerationState_ConfigGeneration_CurrentGenerationId",
column: x => x.CurrentGenerationId,
principalTable: "ConfigGeneration",
principalColumn: "GenerationId",
onDelete: ReferentialAction.Restrict);
});
migrationBuilder.CreateTable(
name: "Device",
columns: table => new
{
DeviceRowId = table.Column<Guid>(type: "uniqueidentifier", nullable: false, defaultValueSql: "NEWSEQUENTIALID()"),
GenerationId = table.Column<long>(type: "bigint", nullable: false),
DeviceId = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: true),
DriverInstanceId = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: false),
Name = table.Column<string>(type: "nvarchar(128)", maxLength: 128, nullable: false),
Enabled = table.Column<bool>(type: "bit", nullable: false),
DeviceConfig = table.Column<string>(type: "nvarchar(max)", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Device", x => x.DeviceRowId);
table.CheckConstraint("CK_Device_DeviceConfig_IsJson", "ISJSON(DeviceConfig) = 1");
table.ForeignKey(
name: "FK_Device_ConfigGeneration_GenerationId",
column: x => x.GenerationId,
principalTable: "ConfigGeneration",
principalColumn: "GenerationId",
onDelete: ReferentialAction.Restrict);
});
migrationBuilder.CreateTable(
name: "DriverInstance",
columns: table => new
{
DriverInstanceRowId = table.Column<Guid>(type: "uniqueidentifier", nullable: false, defaultValueSql: "NEWSEQUENTIALID()"),
GenerationId = table.Column<long>(type: "bigint", nullable: false),
DriverInstanceId = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: true),
ClusterId = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: false),
NamespaceId = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: false),
Name = table.Column<string>(type: "nvarchar(128)", maxLength: 128, nullable: false),
DriverType = table.Column<string>(type: "nvarchar(32)", maxLength: 32, nullable: false),
Enabled = table.Column<bool>(type: "bit", nullable: false),
DriverConfig = table.Column<string>(type: "nvarchar(max)", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_DriverInstance", x => x.DriverInstanceRowId);
table.CheckConstraint("CK_DriverInstance_DriverConfig_IsJson", "ISJSON(DriverConfig) = 1");
table.ForeignKey(
name: "FK_DriverInstance_ConfigGeneration_GenerationId",
column: x => x.GenerationId,
principalTable: "ConfigGeneration",
principalColumn: "GenerationId",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_DriverInstance_ServerCluster_ClusterId",
column: x => x.ClusterId,
principalTable: "ServerCluster",
principalColumn: "ClusterId",
onDelete: ReferentialAction.Restrict);
});
migrationBuilder.CreateTable(
name: "Equipment",
columns: table => new
{
EquipmentRowId = table.Column<Guid>(type: "uniqueidentifier", nullable: false, defaultValueSql: "NEWSEQUENTIALID()"),
GenerationId = table.Column<long>(type: "bigint", nullable: false),
EquipmentId = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: true),
EquipmentUuid = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
DriverInstanceId = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: false),
DeviceId = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: true),
UnsLineId = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: false),
Name = table.Column<string>(type: "nvarchar(32)", maxLength: 32, nullable: false),
MachineCode = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: false),
ZTag = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: true),
SAPID = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: true),
Manufacturer = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: true),
Model = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: true),
SerialNumber = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: true),
HardwareRevision = table.Column<string>(type: "nvarchar(32)", maxLength: 32, nullable: true),
SoftwareRevision = table.Column<string>(type: "nvarchar(32)", maxLength: 32, nullable: true),
YearOfConstruction = table.Column<short>(type: "smallint", nullable: true),
AssetLocation = table.Column<string>(type: "nvarchar(256)", maxLength: 256, nullable: true),
ManufacturerUri = table.Column<string>(type: "nvarchar(512)", maxLength: 512, nullable: true),
DeviceManualUri = table.Column<string>(type: "nvarchar(512)", maxLength: 512, nullable: true),
EquipmentClassRef = table.Column<string>(type: "nvarchar(128)", maxLength: 128, nullable: true),
Enabled = table.Column<bool>(type: "bit", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Equipment", x => x.EquipmentRowId);
table.ForeignKey(
name: "FK_Equipment_ConfigGeneration_GenerationId",
column: x => x.GenerationId,
principalTable: "ConfigGeneration",
principalColumn: "GenerationId",
onDelete: ReferentialAction.Restrict);
});
migrationBuilder.CreateTable(
name: "Namespace",
columns: table => new
{
NamespaceRowId = table.Column<Guid>(type: "uniqueidentifier", nullable: false, defaultValueSql: "NEWSEQUENTIALID()"),
GenerationId = table.Column<long>(type: "bigint", nullable: false),
NamespaceId = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: true),
ClusterId = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: false),
Kind = table.Column<string>(type: "nvarchar(32)", maxLength: 32, nullable: false),
NamespaceUri = table.Column<string>(type: "nvarchar(256)", maxLength: 256, nullable: false),
Enabled = table.Column<bool>(type: "bit", nullable: false),
Notes = table.Column<string>(type: "nvarchar(1024)", maxLength: 1024, nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_Namespace", x => x.NamespaceRowId);
table.ForeignKey(
name: "FK_Namespace_ConfigGeneration_GenerationId",
column: x => x.GenerationId,
principalTable: "ConfigGeneration",
principalColumn: "GenerationId",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_Namespace_ServerCluster_ClusterId",
column: x => x.ClusterId,
principalTable: "ServerCluster",
principalColumn: "ClusterId",
onDelete: ReferentialAction.Restrict);
});
migrationBuilder.CreateTable(
name: "NodeAcl",
columns: table => new
{
NodeAclRowId = table.Column<Guid>(type: "uniqueidentifier", nullable: false, defaultValueSql: "NEWSEQUENTIALID()"),
GenerationId = table.Column<long>(type: "bigint", nullable: false),
NodeAclId = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: true),
ClusterId = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: false),
LdapGroup = table.Column<string>(type: "nvarchar(256)", maxLength: 256, nullable: false),
ScopeKind = table.Column<string>(type: "nvarchar(16)", maxLength: 16, nullable: false),
ScopeId = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: true),
PermissionFlags = table.Column<int>(type: "int", nullable: false),
Notes = table.Column<string>(type: "nvarchar(512)", maxLength: 512, nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_NodeAcl", x => x.NodeAclRowId);
table.ForeignKey(
name: "FK_NodeAcl_ConfigGeneration_GenerationId",
column: x => x.GenerationId,
principalTable: "ConfigGeneration",
principalColumn: "GenerationId",
onDelete: ReferentialAction.Restrict);
});
migrationBuilder.CreateTable(
name: "PollGroup",
columns: table => new
{
PollGroupRowId = table.Column<Guid>(type: "uniqueidentifier", nullable: false, defaultValueSql: "NEWSEQUENTIALID()"),
GenerationId = table.Column<long>(type: "bigint", nullable: false),
PollGroupId = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: true),
DriverInstanceId = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: false),
Name = table.Column<string>(type: "nvarchar(128)", maxLength: 128, nullable: false),
IntervalMs = table.Column<int>(type: "int", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_PollGroup", x => x.PollGroupRowId);
table.CheckConstraint("CK_PollGroup_IntervalMs_Min", "IntervalMs >= 50");
table.ForeignKey(
name: "FK_PollGroup_ConfigGeneration_GenerationId",
column: x => x.GenerationId,
principalTable: "ConfigGeneration",
principalColumn: "GenerationId",
onDelete: ReferentialAction.Restrict);
});
migrationBuilder.CreateTable(
name: "Tag",
columns: table => new
{
TagRowId = table.Column<Guid>(type: "uniqueidentifier", nullable: false, defaultValueSql: "NEWSEQUENTIALID()"),
GenerationId = table.Column<long>(type: "bigint", nullable: false),
TagId = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: true),
DriverInstanceId = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: false),
DeviceId = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: true),
EquipmentId = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: true),
Name = table.Column<string>(type: "nvarchar(128)", maxLength: 128, nullable: false),
FolderPath = table.Column<string>(type: "nvarchar(512)", maxLength: 512, nullable: true),
DataType = table.Column<string>(type: "nvarchar(32)", maxLength: 32, nullable: false),
AccessLevel = table.Column<string>(type: "nvarchar(16)", maxLength: 16, nullable: false),
WriteIdempotent = table.Column<bool>(type: "bit", nullable: false),
PollGroupId = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: true),
TagConfig = table.Column<string>(type: "nvarchar(max)", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Tag", x => x.TagRowId);
table.CheckConstraint("CK_Tag_TagConfig_IsJson", "ISJSON(TagConfig) = 1");
table.ForeignKey(
name: "FK_Tag_ConfigGeneration_GenerationId",
column: x => x.GenerationId,
principalTable: "ConfigGeneration",
principalColumn: "GenerationId",
onDelete: ReferentialAction.Restrict);
});
migrationBuilder.CreateTable(
name: "UnsArea",
columns: table => new
{
UnsAreaRowId = table.Column<Guid>(type: "uniqueidentifier", nullable: false, defaultValueSql: "NEWSEQUENTIALID()"),
GenerationId = table.Column<long>(type: "bigint", nullable: false),
UnsAreaId = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: true),
ClusterId = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: false),
Name = table.Column<string>(type: "nvarchar(32)", maxLength: 32, nullable: false),
Notes = table.Column<string>(type: "nvarchar(512)", maxLength: 512, nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_UnsArea", x => x.UnsAreaRowId);
table.ForeignKey(
name: "FK_UnsArea_ConfigGeneration_GenerationId",
column: x => x.GenerationId,
principalTable: "ConfigGeneration",
principalColumn: "GenerationId",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_UnsArea_ServerCluster_ClusterId",
column: x => x.ClusterId,
principalTable: "ServerCluster",
principalColumn: "ClusterId",
onDelete: ReferentialAction.Restrict);
});
migrationBuilder.CreateTable(
name: "UnsLine",
columns: table => new
{
UnsLineRowId = table.Column<Guid>(type: "uniqueidentifier", nullable: false, defaultValueSql: "NEWSEQUENTIALID()"),
GenerationId = table.Column<long>(type: "bigint", nullable: false),
UnsLineId = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: true),
UnsAreaId = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: false),
Name = table.Column<string>(type: "nvarchar(32)", maxLength: 32, nullable: false),
Notes = table.Column<string>(type: "nvarchar(512)", maxLength: 512, nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_UnsLine", x => x.UnsLineRowId);
table.ForeignKey(
name: "FK_UnsLine_ConfigGeneration_GenerationId",
column: x => x.GenerationId,
principalTable: "ConfigGeneration",
principalColumn: "GenerationId",
onDelete: ReferentialAction.Restrict);
});
migrationBuilder.CreateIndex(
name: "UX_ClusterNode_ApplicationUri",
table: "ClusterNode",
column: "ApplicationUri",
unique: true);
migrationBuilder.CreateIndex(
name: "UX_ClusterNode_Primary_Per_Cluster",
table: "ClusterNode",
column: "ClusterId",
unique: true,
filter: "[RedundancyRole] = 'Primary'");
migrationBuilder.CreateIndex(
name: "IX_ClusterNodeCredential_NodeId",
table: "ClusterNodeCredential",
columns: new[] { "NodeId", "Enabled" });
migrationBuilder.CreateIndex(
name: "UX_ClusterNodeCredential_Value",
table: "ClusterNodeCredential",
columns: new[] { "Kind", "Value" },
unique: true,
filter: "[Enabled] = 1");
migrationBuilder.CreateIndex(
name: "IX_ClusterNodeGenerationState_Generation",
table: "ClusterNodeGenerationState",
column: "CurrentGenerationId");
migrationBuilder.CreateIndex(
name: "IX_ConfigAuditLog_Cluster_Time",
table: "ConfigAuditLog",
columns: new[] { "ClusterId", "Timestamp" },
descending: new[] { false, true });
migrationBuilder.CreateIndex(
name: "IX_ConfigAuditLog_Generation",
table: "ConfigAuditLog",
column: "GenerationId",
filter: "[GenerationId] IS NOT NULL");
migrationBuilder.CreateIndex(
name: "IX_ConfigGeneration_Cluster_Published",
table: "ConfigGeneration",
columns: new[] { "ClusterId", "Status", "GenerationId" },
descending: new[] { false, false, true })
.Annotation("SqlServer:Include", new[] { "PublishedAt" });
migrationBuilder.CreateIndex(
name: "IX_ConfigGeneration_ParentGenerationId",
table: "ConfigGeneration",
column: "ParentGenerationId");
migrationBuilder.CreateIndex(
name: "UX_ConfigGeneration_Draft_Per_Cluster",
table: "ConfigGeneration",
column: "ClusterId",
unique: true,
filter: "[Status] = 'Draft'");
migrationBuilder.CreateIndex(
name: "IX_Device_Generation_Driver",
table: "Device",
columns: new[] { "GenerationId", "DriverInstanceId" });
migrationBuilder.CreateIndex(
name: "UX_Device_Generation_LogicalId",
table: "Device",
columns: new[] { "GenerationId", "DeviceId" },
unique: true,
filter: "[DeviceId] IS NOT NULL");
migrationBuilder.CreateIndex(
name: "IX_DriverInstance_ClusterId",
table: "DriverInstance",
column: "ClusterId");
migrationBuilder.CreateIndex(
name: "IX_DriverInstance_Generation_Cluster",
table: "DriverInstance",
columns: new[] { "GenerationId", "ClusterId" });
migrationBuilder.CreateIndex(
name: "IX_DriverInstance_Generation_Namespace",
table: "DriverInstance",
columns: new[] { "GenerationId", "NamespaceId" });
migrationBuilder.CreateIndex(
name: "UX_DriverInstance_Generation_LogicalId",
table: "DriverInstance",
columns: new[] { "GenerationId", "DriverInstanceId" },
unique: true,
filter: "[DriverInstanceId] IS NOT NULL");
migrationBuilder.CreateIndex(
name: "IX_Equipment_Generation_Driver",
table: "Equipment",
columns: new[] { "GenerationId", "DriverInstanceId" });
migrationBuilder.CreateIndex(
name: "IX_Equipment_Generation_Line",
table: "Equipment",
columns: new[] { "GenerationId", "UnsLineId" });
migrationBuilder.CreateIndex(
name: "IX_Equipment_Generation_MachineCode",
table: "Equipment",
columns: new[] { "GenerationId", "MachineCode" });
migrationBuilder.CreateIndex(
name: "IX_Equipment_Generation_SAPID",
table: "Equipment",
columns: new[] { "GenerationId", "SAPID" },
filter: "[SAPID] IS NOT NULL");
migrationBuilder.CreateIndex(
name: "IX_Equipment_Generation_ZTag",
table: "Equipment",
columns: new[] { "GenerationId", "ZTag" },
filter: "[ZTag] IS NOT NULL");
migrationBuilder.CreateIndex(
name: "UX_Equipment_Generation_LinePath",
table: "Equipment",
columns: new[] { "GenerationId", "UnsLineId", "Name" },
unique: true);
migrationBuilder.CreateIndex(
name: "UX_Equipment_Generation_LogicalId",
table: "Equipment",
columns: new[] { "GenerationId", "EquipmentId" },
unique: true,
filter: "[EquipmentId] IS NOT NULL");
migrationBuilder.CreateIndex(
name: "UX_Equipment_Generation_Uuid",
table: "Equipment",
columns: new[] { "GenerationId", "EquipmentUuid" },
unique: true);
migrationBuilder.CreateIndex(
name: "IX_ExternalIdReservation_Equipment",
table: "ExternalIdReservation",
column: "EquipmentUuid");
migrationBuilder.CreateIndex(
name: "UX_ExternalIdReservation_KindValue_Active",
table: "ExternalIdReservation",
columns: new[] { "Kind", "Value" },
unique: true,
filter: "[ReleasedAt] IS NULL");
migrationBuilder.CreateIndex(
name: "IX_Namespace_ClusterId",
table: "Namespace",
column: "ClusterId");
migrationBuilder.CreateIndex(
name: "IX_Namespace_Generation_Cluster",
table: "Namespace",
columns: new[] { "GenerationId", "ClusterId" });
migrationBuilder.CreateIndex(
name: "UX_Namespace_Generation_Cluster_Kind",
table: "Namespace",
columns: new[] { "GenerationId", "ClusterId", "Kind" },
unique: true);
migrationBuilder.CreateIndex(
name: "UX_Namespace_Generation_LogicalId",
table: "Namespace",
columns: new[] { "GenerationId", "NamespaceId" },
unique: true,
filter: "[NamespaceId] IS NOT NULL");
migrationBuilder.CreateIndex(
name: "UX_Namespace_Generation_LogicalId_Cluster",
table: "Namespace",
columns: new[] { "GenerationId", "NamespaceId", "ClusterId" },
unique: true,
filter: "[NamespaceId] IS NOT NULL");
migrationBuilder.CreateIndex(
name: "UX_Namespace_Generation_NamespaceUri",
table: "Namespace",
columns: new[] { "GenerationId", "NamespaceUri" },
unique: true);
migrationBuilder.CreateIndex(
name: "IX_NodeAcl_Generation_Cluster",
table: "NodeAcl",
columns: new[] { "GenerationId", "ClusterId" });
migrationBuilder.CreateIndex(
name: "IX_NodeAcl_Generation_Group",
table: "NodeAcl",
columns: new[] { "GenerationId", "LdapGroup" });
migrationBuilder.CreateIndex(
name: "IX_NodeAcl_Generation_Scope",
table: "NodeAcl",
columns: new[] { "GenerationId", "ScopeKind", "ScopeId" },
filter: "[ScopeId] IS NOT NULL");
migrationBuilder.CreateIndex(
name: "UX_NodeAcl_Generation_GroupScope",
table: "NodeAcl",
columns: new[] { "GenerationId", "ClusterId", "LdapGroup", "ScopeKind", "ScopeId" },
unique: true,
filter: "[ScopeId] IS NOT NULL");
migrationBuilder.CreateIndex(
name: "UX_NodeAcl_Generation_LogicalId",
table: "NodeAcl",
columns: new[] { "GenerationId", "NodeAclId" },
unique: true,
filter: "[NodeAclId] IS NOT NULL");
migrationBuilder.CreateIndex(
name: "IX_PollGroup_Generation_Driver",
table: "PollGroup",
columns: new[] { "GenerationId", "DriverInstanceId" });
migrationBuilder.CreateIndex(
name: "UX_PollGroup_Generation_LogicalId",
table: "PollGroup",
columns: new[] { "GenerationId", "PollGroupId" },
unique: true,
filter: "[PollGroupId] IS NOT NULL");
migrationBuilder.CreateIndex(
name: "IX_ServerCluster_Site",
table: "ServerCluster",
column: "Site");
migrationBuilder.CreateIndex(
name: "UX_ServerCluster_Name",
table: "ServerCluster",
column: "Name",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_Tag_Generation_Driver_Device",
table: "Tag",
columns: new[] { "GenerationId", "DriverInstanceId", "DeviceId" });
migrationBuilder.CreateIndex(
name: "IX_Tag_Generation_Equipment",
table: "Tag",
columns: new[] { "GenerationId", "EquipmentId" },
filter: "[EquipmentId] IS NOT NULL");
migrationBuilder.CreateIndex(
name: "UX_Tag_Generation_EquipmentPath",
table: "Tag",
columns: new[] { "GenerationId", "EquipmentId", "Name" },
unique: true,
filter: "[EquipmentId] IS NOT NULL");
migrationBuilder.CreateIndex(
name: "UX_Tag_Generation_FolderPath",
table: "Tag",
columns: new[] { "GenerationId", "DriverInstanceId", "FolderPath", "Name" },
unique: true,
filter: "[EquipmentId] IS NULL");
migrationBuilder.CreateIndex(
name: "UX_Tag_Generation_LogicalId",
table: "Tag",
columns: new[] { "GenerationId", "TagId" },
unique: true,
filter: "[TagId] IS NOT NULL");
migrationBuilder.CreateIndex(
name: "IX_UnsArea_ClusterId",
table: "UnsArea",
column: "ClusterId");
migrationBuilder.CreateIndex(
name: "IX_UnsArea_Generation_Cluster",
table: "UnsArea",
columns: new[] { "GenerationId", "ClusterId" });
migrationBuilder.CreateIndex(
name: "UX_UnsArea_Generation_ClusterName",
table: "UnsArea",
columns: new[] { "GenerationId", "ClusterId", "Name" },
unique: true);
migrationBuilder.CreateIndex(
name: "UX_UnsArea_Generation_LogicalId",
table: "UnsArea",
columns: new[] { "GenerationId", "UnsAreaId" },
unique: true,
filter: "[UnsAreaId] IS NOT NULL");
migrationBuilder.CreateIndex(
name: "IX_UnsLine_Generation_Area",
table: "UnsLine",
columns: new[] { "GenerationId", "UnsAreaId" });
migrationBuilder.CreateIndex(
name: "UX_UnsLine_Generation_AreaName",
table: "UnsLine",
columns: new[] { "GenerationId", "UnsAreaId", "Name" },
unique: true);
migrationBuilder.CreateIndex(
name: "UX_UnsLine_Generation_LogicalId",
table: "UnsLine",
columns: new[] { "GenerationId", "UnsLineId" },
unique: true,
filter: "[UnsLineId] IS NOT NULL");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "ClusterNodeCredential");
migrationBuilder.DropTable(
name: "ClusterNodeGenerationState");
migrationBuilder.DropTable(
name: "ConfigAuditLog");
migrationBuilder.DropTable(
name: "Device");
migrationBuilder.DropTable(
name: "DriverInstance");
migrationBuilder.DropTable(
name: "Equipment");
migrationBuilder.DropTable(
name: "ExternalIdReservation");
migrationBuilder.DropTable(
name: "Namespace");
migrationBuilder.DropTable(
name: "NodeAcl");
migrationBuilder.DropTable(
name: "PollGroup");
migrationBuilder.DropTable(
name: "Tag");
migrationBuilder.DropTable(
name: "UnsArea");
migrationBuilder.DropTable(
name: "UnsLine");
migrationBuilder.DropTable(
name: "ClusterNode");
migrationBuilder.DropTable(
name: "ConfigGeneration");
migrationBuilder.DropTable(
name: "ServerCluster");
}
}
}
File diff suppressed because it is too large Load Diff
@@ -1,510 +0,0 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ZB.MOM.WW.OtOpcUa.Configuration.Migrations;
/// <summary>
/// Stored procedures per <c>config-db-schema.md §"Stored Procedures"</c>. All node + admin DB
/// access funnels through these — direct table writes are revoked in the AuthorizationGrants
/// migration that follows. CREATE OR ALTER style so procs version with the schema.
/// </summary>
public partial class StoredProcedures : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.Sql(Procs.GetCurrentGenerationForCluster);
migrationBuilder.Sql(Procs.GetGenerationContent);
migrationBuilder.Sql(Procs.RegisterNodeGenerationApplied);
migrationBuilder.Sql(Procs.ValidateDraft);
migrationBuilder.Sql(Procs.PublishGeneration);
migrationBuilder.Sql(Procs.RollbackToGeneration);
migrationBuilder.Sql(Procs.ComputeGenerationDiff);
migrationBuilder.Sql(Procs.ReleaseExternalIdReservation);
}
protected override void Down(MigrationBuilder migrationBuilder)
{
foreach (var name in new[]
{
"sp_ReleaseExternalIdReservation", "sp_ComputeGenerationDiff", "sp_RollbackToGeneration",
"sp_PublishGeneration", "sp_ValidateDraft", "sp_RegisterNodeGenerationApplied",
"sp_GetGenerationContent", "sp_GetCurrentGenerationForCluster",
})
{
migrationBuilder.Sql($"IF OBJECT_ID(N'dbo.{name}', N'P') IS NOT NULL DROP PROCEDURE dbo.{name};");
}
}
private static class Procs
{
public const string GetCurrentGenerationForCluster = @"
CREATE OR ALTER PROCEDURE dbo.sp_GetCurrentGenerationForCluster
@NodeId nvarchar(64),
@ClusterId nvarchar(64)
AS
BEGIN
SET NOCOUNT ON;
DECLARE @Caller nvarchar(128) = SUSER_SNAME();
IF NOT EXISTS (
SELECT 1 FROM dbo.ClusterNodeCredential
WHERE NodeId = @NodeId AND Value = @Caller AND Enabled = 1)
BEGIN
RAISERROR('Unauthorized: caller %s is not bound to NodeId %s', 16, 1, @Caller, @NodeId);
RETURN;
END
IF NOT EXISTS (
SELECT 1 FROM dbo.ClusterNode
WHERE NodeId = @NodeId AND ClusterId = @ClusterId AND Enabled = 1)
BEGIN
RAISERROR('Forbidden: NodeId %s does not belong to ClusterId %s', 16, 1, @NodeId, @ClusterId);
RETURN;
END
SELECT TOP 1 GenerationId, ClusterId, Status, PublishedAt, PublishedBy, Notes
FROM dbo.ConfigGeneration
WHERE ClusterId = @ClusterId AND Status = 'Published'
ORDER BY GenerationId DESC;
END
";
public const string GetGenerationContent = @"
CREATE OR ALTER PROCEDURE dbo.sp_GetGenerationContent
@NodeId nvarchar(64),
@GenerationId bigint
AS
BEGIN
SET NOCOUNT ON;
DECLARE @Caller nvarchar(128) = SUSER_SNAME();
DECLARE @ClusterId nvarchar(64);
SELECT @ClusterId = ClusterId FROM dbo.ConfigGeneration WHERE GenerationId = @GenerationId;
IF @ClusterId IS NULL
BEGIN
RAISERROR('GenerationId %I64d not found', 16, 1, @GenerationId);
RETURN;
END
IF NOT EXISTS (
SELECT 1
FROM dbo.ClusterNodeCredential c
JOIN dbo.ClusterNode n ON n.NodeId = c.NodeId
WHERE c.NodeId = @NodeId AND c.Value = @Caller AND c.Enabled = 1
AND n.ClusterId = @ClusterId AND n.Enabled = 1)
BEGIN
RAISERROR('Forbidden: caller %s not bound to a node in ClusterId %s', 16, 1, @Caller, @ClusterId);
RETURN;
END
SELECT * FROM dbo.Namespace WHERE GenerationId = @GenerationId;
SELECT * FROM dbo.UnsArea WHERE GenerationId = @GenerationId;
SELECT * FROM dbo.UnsLine WHERE GenerationId = @GenerationId;
SELECT * FROM dbo.DriverInstance WHERE GenerationId = @GenerationId;
SELECT * FROM dbo.Device WHERE GenerationId = @GenerationId;
SELECT * FROM dbo.Equipment WHERE GenerationId = @GenerationId;
SELECT * FROM dbo.PollGroup WHERE GenerationId = @GenerationId;
SELECT * FROM dbo.Tag WHERE GenerationId = @GenerationId;
SELECT * FROM dbo.NodeAcl WHERE GenerationId = @GenerationId;
END
";
public const string RegisterNodeGenerationApplied = @"
CREATE OR ALTER PROCEDURE dbo.sp_RegisterNodeGenerationApplied
@NodeId nvarchar(64),
@GenerationId bigint,
@Status nvarchar(16),
@Error nvarchar(max) = NULL
AS
BEGIN
SET NOCOUNT ON;
DECLARE @Caller nvarchar(128) = SUSER_SNAME();
IF NOT EXISTS (
SELECT 1 FROM dbo.ClusterNodeCredential
WHERE NodeId = @NodeId AND Value = @Caller AND Enabled = 1)
BEGIN
RAISERROR('Unauthorized: caller %s is not bound to NodeId %s', 16, 1, @Caller, @NodeId);
RETURN;
END
MERGE dbo.ClusterNodeGenerationState AS tgt
USING (SELECT @NodeId AS NodeId) AS src ON tgt.NodeId = src.NodeId
WHEN MATCHED THEN UPDATE SET
CurrentGenerationId = @GenerationId,
LastAppliedAt = SYSUTCDATETIME(),
LastAppliedStatus = @Status,
LastAppliedError = @Error,
LastSeenAt = SYSUTCDATETIME()
WHEN NOT MATCHED THEN INSERT
(NodeId, CurrentGenerationId, LastAppliedAt, LastAppliedStatus, LastAppliedError, LastSeenAt)
VALUES (@NodeId, @GenerationId, SYSUTCDATETIME(), @Status, @Error, SYSUTCDATETIME());
-- Build DetailsJson via STRING_ESCAPE so a @Status containing a double-quote/backslash cannot
-- produce malformed JSON (which would fail CK_ConfigAuditLog_DetailsJson_IsJson and abort the
-- transaction) or inject extra JSON structure into the audit record.
INSERT dbo.ConfigAuditLog (Principal, EventType, NodeId, GenerationId, DetailsJson)
VALUES (@Caller, 'NodeApplied', @NodeId, @GenerationId,
CONCAT('{""status"":""', STRING_ESCAPE(@Status, 'json'), '""}'));
END
";
public const string ValidateDraft = @"
CREATE OR ALTER PROCEDURE dbo.sp_ValidateDraft
@DraftGenerationId bigint
AS
BEGIN
SET NOCOUNT ON;
DECLARE @ClusterId nvarchar(64);
DECLARE @Status nvarchar(16);
SELECT @ClusterId = ClusterId, @Status = Status
FROM dbo.ConfigGeneration WHERE GenerationId = @DraftGenerationId;
IF @ClusterId IS NULL
BEGIN
RAISERROR('GenerationId %I64d not found', 16, 1, @DraftGenerationId);
RETURN;
END
IF @Status <> 'Draft'
BEGIN
RAISERROR('GenerationId %I64d is not in Draft status (current=%s)', 16, 1, @DraftGenerationId, @Status);
RETURN;
END
IF EXISTS (
SELECT 1 FROM dbo.Tag t
LEFT JOIN dbo.DriverInstance d ON d.GenerationId = t.GenerationId AND d.DriverInstanceId = t.DriverInstanceId
WHERE t.GenerationId = @DraftGenerationId AND d.DriverInstanceId IS NULL)
BEGIN
RAISERROR('Draft has tags with unresolved DriverInstanceId', 16, 1);
RETURN;
END
IF EXISTS (
SELECT 1 FROM dbo.Tag t
LEFT JOIN dbo.Device dv ON dv.GenerationId = t.GenerationId AND dv.DeviceId = t.DeviceId
WHERE t.GenerationId = @DraftGenerationId AND t.DeviceId IS NOT NULL AND dv.DeviceId IS NULL)
BEGIN
RAISERROR('Draft has tags with unresolved DeviceId', 16, 1);
RETURN;
END
IF EXISTS (
SELECT 1 FROM dbo.Tag t
LEFT JOIN dbo.PollGroup pg ON pg.GenerationId = t.GenerationId AND pg.PollGroupId = t.PollGroupId
WHERE t.GenerationId = @DraftGenerationId AND t.PollGroupId IS NOT NULL AND pg.PollGroupId IS NULL)
BEGIN
RAISERROR('Draft has tags with unresolved PollGroupId', 16, 1);
RETURN;
END
IF EXISTS (
SELECT 1
FROM dbo.DriverInstance di
JOIN dbo.Namespace ns ON ns.GenerationId = di.GenerationId AND ns.NamespaceId = di.NamespaceId
WHERE di.GenerationId = @DraftGenerationId
AND ns.ClusterId <> di.ClusterId)
BEGIN
INSERT dbo.ConfigAuditLog (Principal, EventType, ClusterId, GenerationId)
VALUES (SUSER_SNAME(), 'CrossClusterNamespaceAttempt', @ClusterId, @DraftGenerationId);
RAISERROR('BadCrossClusterNamespaceBinding: namespace and driver must belong to the same cluster', 16, 1);
RETURN;
END
IF EXISTS (
SELECT 1
FROM dbo.Equipment draft
JOIN dbo.Equipment prior
ON prior.EquipmentId = draft.EquipmentId
AND prior.EquipmentUuid <> draft.EquipmentUuid
AND prior.GenerationId <> draft.GenerationId
JOIN dbo.ConfigGeneration pg ON pg.GenerationId = prior.GenerationId
WHERE draft.GenerationId = @DraftGenerationId
AND pg.ClusterId = @ClusterId)
BEGIN
RAISERROR('EquipmentUuid immutability violated for an EquipmentId that existed in a prior generation', 16, 1);
RETURN;
END
IF EXISTS (
SELECT 1
FROM dbo.Equipment draft
JOIN dbo.ExternalIdReservation r
ON r.Kind = 'ZTag' AND r.Value = draft.ZTag AND r.ReleasedAt IS NULL
AND r.EquipmentUuid <> draft.EquipmentUuid
WHERE draft.GenerationId = @DraftGenerationId AND draft.ZTag IS NOT NULL)
BEGIN
RAISERROR('BadDuplicateExternalIdentifier: a ZTag in the draft is reserved by a different EquipmentUuid', 16, 1);
RETURN;
END
IF EXISTS (
SELECT 1
FROM dbo.Equipment draft
JOIN dbo.ExternalIdReservation r
ON r.Kind = 'SAPID' AND r.Value = draft.SAPID AND r.ReleasedAt IS NULL
AND r.EquipmentUuid <> draft.EquipmentUuid
WHERE draft.GenerationId = @DraftGenerationId AND draft.SAPID IS NOT NULL)
BEGIN
RAISERROR('BadDuplicateExternalIdentifier: a SAPID in the draft is reserved by a different EquipmentUuid', 16, 1);
RETURN;
END
END
";
public const string PublishGeneration = @"
CREATE OR ALTER PROCEDURE dbo.sp_PublishGeneration
@ClusterId nvarchar(64),
@DraftGenerationId bigint,
@Notes nvarchar(1024) = NULL
AS
BEGIN
SET NOCOUNT ON;
SET XACT_ABORT ON;
-- Transaction-nesting awareness: if a caller (e.g. sp_RollbackToGeneration) already
-- holds a transaction, we use SAVE TRANSACTION so our failure path rolls back only to
-- the savepoint instead of issuing a bare ROLLBACK that wipes the caller's transaction
-- (which sets @@TRANCOUNT = 0 and causes error 3902 on the caller's subsequent COMMIT).
DECLARE @OwnsTxn bit = 0;
DECLARE @SaveName nvarchar(32) = N'sp_PublishGeneration';
IF @@TRANCOUNT = 0
BEGIN
BEGIN TRANSACTION;
SET @OwnsTxn = 1;
END
ELSE
BEGIN
SAVE TRANSACTION sp_PublishGeneration;
END
DECLARE @Lock nvarchar(255) = N'OtOpcUa_Publish_' + @ClusterId;
DECLARE @LockResult int;
EXEC @LockResult = sp_getapplock @Resource = @Lock, @LockMode = 'Exclusive', @LockTimeout = 0;
IF @LockResult < 0
BEGIN
RAISERROR('PublishConflict: another publish is in progress for cluster %s', 16, 1, @ClusterId);
IF @OwnsTxn = 1 ROLLBACK;
ELSE ROLLBACK TRANSACTION sp_PublishGeneration;
RETURN;
END
-- sp_ValidateDraft signals every rejection with RAISERROR(..., 16, 1) — a severity-16 error is
-- NOT batch-aborting and SET XACT_ABORT ON does not abort the transaction for it, so without a
-- TRY/CATCH control would return here and the draft would publish despite failed validation.
-- Catch the validation error, roll back the publish transaction (only to our savepoint when a
-- caller owns the outer transaction), and re-raise so the caller sees the real validation failure.
BEGIN TRY
EXEC dbo.sp_ValidateDraft @DraftGenerationId = @DraftGenerationId;
END TRY
BEGIN CATCH
IF @OwnsTxn = 1 ROLLBACK;
ELSE ROLLBACK TRANSACTION sp_PublishGeneration;
THROW;
END CATCH
MERGE dbo.ExternalIdReservation AS tgt
USING (
SELECT 'ZTag' AS Kind, ZTag AS Value, EquipmentUuid
FROM dbo.Equipment
WHERE GenerationId = @DraftGenerationId AND ZTag IS NOT NULL
UNION ALL
SELECT 'SAPID', SAPID, EquipmentUuid
FROM dbo.Equipment
WHERE GenerationId = @DraftGenerationId AND SAPID IS NOT NULL
) AS src
ON tgt.Kind = src.Kind AND tgt.Value = src.Value AND tgt.EquipmentUuid = src.EquipmentUuid
WHEN MATCHED THEN UPDATE SET LastPublishedAt = SYSUTCDATETIME()
WHEN NOT MATCHED BY TARGET THEN
INSERT (Kind, Value, EquipmentUuid, ClusterId, FirstPublishedBy, LastPublishedAt)
VALUES (src.Kind, src.Value, src.EquipmentUuid, @ClusterId, SUSER_SNAME(), SYSUTCDATETIME());
UPDATE dbo.ConfigGeneration
SET Status = 'Superseded'
WHERE ClusterId = @ClusterId AND Status = 'Published';
UPDATE dbo.ConfigGeneration
SET Status = 'Published',
PublishedAt = SYSUTCDATETIME(),
PublishedBy = SUSER_SNAME(),
Notes = ISNULL(@Notes, Notes)
WHERE GenerationId = @DraftGenerationId AND ClusterId = @ClusterId AND Status = 'Draft';
IF @@ROWCOUNT = 0
BEGIN
RAISERROR('Draft %I64d for cluster %s not in Draft status (was it already published?)', 16, 1, @DraftGenerationId, @ClusterId);
IF @OwnsTxn = 1 ROLLBACK;
ELSE ROLLBACK TRANSACTION sp_PublishGeneration;
RETURN;
END
INSERT dbo.ConfigAuditLog (Principal, EventType, ClusterId, GenerationId)
VALUES (SUSER_SNAME(), 'Published', @ClusterId, @DraftGenerationId);
IF @OwnsTxn = 1 COMMIT;
END
";
public const string RollbackToGeneration = @"
CREATE OR ALTER PROCEDURE dbo.sp_RollbackToGeneration
@ClusterId nvarchar(64),
@TargetGenerationId bigint,
@Notes nvarchar(1024) = NULL
AS
BEGIN
SET NOCOUNT ON;
SET XACT_ABORT ON;
BEGIN TRANSACTION;
IF NOT EXISTS (
SELECT 1 FROM dbo.ConfigGeneration
WHERE GenerationId = @TargetGenerationId AND ClusterId = @ClusterId
AND Status IN ('Published', 'Superseded'))
BEGIN
RAISERROR('Target generation %I64d not found or not rollback-eligible', 16, 1, @TargetGenerationId);
ROLLBACK; RETURN;
END
DECLARE @NewGenId bigint;
INSERT dbo.ConfigGeneration (ClusterId, Status, CreatedAt, CreatedBy, PublishedAt, PublishedBy, Notes)
VALUES (@ClusterId, 'Draft', SYSUTCDATETIME(), SUSER_SNAME(), NULL, NULL,
ISNULL(@Notes, CONCAT('Rollback clone of generation ', @TargetGenerationId)));
SET @NewGenId = SCOPE_IDENTITY();
INSERT dbo.Namespace (GenerationId, NamespaceId, ClusterId, Kind, NamespaceUri, Enabled, Notes)
SELECT @NewGenId, NamespaceId, ClusterId, Kind, NamespaceUri, Enabled, Notes FROM dbo.Namespace WHERE GenerationId = @TargetGenerationId;
INSERT dbo.UnsArea (GenerationId, UnsAreaId, ClusterId, Name, Notes)
SELECT @NewGenId, UnsAreaId, ClusterId, Name, Notes FROM dbo.UnsArea WHERE GenerationId = @TargetGenerationId;
INSERT dbo.UnsLine (GenerationId, UnsLineId, UnsAreaId, Name, Notes)
SELECT @NewGenId, UnsLineId, UnsAreaId, Name, Notes FROM dbo.UnsLine WHERE GenerationId = @TargetGenerationId;
INSERT dbo.DriverInstance (GenerationId, DriverInstanceId, ClusterId, NamespaceId, Name, DriverType, Enabled, DriverConfig)
SELECT @NewGenId, DriverInstanceId, ClusterId, NamespaceId, Name, DriverType, Enabled, DriverConfig FROM dbo.DriverInstance WHERE GenerationId = @TargetGenerationId;
INSERT dbo.Device (GenerationId, DeviceId, DriverInstanceId, Name, Enabled, DeviceConfig)
SELECT @NewGenId, DeviceId, DriverInstanceId, Name, Enabled, DeviceConfig FROM dbo.Device WHERE GenerationId = @TargetGenerationId;
INSERT dbo.Equipment (GenerationId, EquipmentId, EquipmentUuid, DriverInstanceId, DeviceId, UnsLineId, Name, MachineCode, ZTag, SAPID, Manufacturer, Model, SerialNumber, HardwareRevision, SoftwareRevision, YearOfConstruction, AssetLocation, ManufacturerUri, DeviceManualUri, EquipmentClassRef, Enabled)
SELECT @NewGenId, EquipmentId, EquipmentUuid, DriverInstanceId, DeviceId, UnsLineId, Name, MachineCode, ZTag, SAPID, Manufacturer, Model, SerialNumber, HardwareRevision, SoftwareRevision, YearOfConstruction, AssetLocation, ManufacturerUri, DeviceManualUri, EquipmentClassRef, Enabled FROM dbo.Equipment WHERE GenerationId = @TargetGenerationId;
INSERT dbo.PollGroup (GenerationId, PollGroupId, DriverInstanceId, Name, IntervalMs)
SELECT @NewGenId, PollGroupId, DriverInstanceId, Name, IntervalMs FROM dbo.PollGroup WHERE GenerationId = @TargetGenerationId;
INSERT dbo.Tag (GenerationId, TagId, DriverInstanceId, DeviceId, EquipmentId, Name, FolderPath, DataType, AccessLevel, WriteIdempotent, PollGroupId, TagConfig)
SELECT @NewGenId, TagId, DriverInstanceId, DeviceId, EquipmentId, Name, FolderPath, DataType, AccessLevel, WriteIdempotent, PollGroupId, TagConfig FROM dbo.Tag WHERE GenerationId = @TargetGenerationId;
INSERT dbo.NodeAcl (GenerationId, NodeAclId, ClusterId, LdapGroup, ScopeKind, ScopeId, PermissionFlags, Notes)
SELECT @NewGenId, NodeAclId, ClusterId, LdapGroup, ScopeKind, ScopeId, PermissionFlags, Notes FROM dbo.NodeAcl WHERE GenerationId = @TargetGenerationId;
EXEC dbo.sp_PublishGeneration @ClusterId = @ClusterId, @DraftGenerationId = @NewGenId, @Notes = @Notes;
-- @TargetGenerationId is a bigint, but build the JSON value via an explicit numeric CONVERT so
-- the emitted token is always a bare JSON number — never reliant on implicit string coercion.
INSERT dbo.ConfigAuditLog (Principal, EventType, ClusterId, GenerationId, DetailsJson)
VALUES (SUSER_SNAME(), 'RolledBack', @ClusterId, @NewGenId,
CONCAT('{""rolledBackTo"":', CONVERT(nvarchar(20), CONVERT(bigint, @TargetGenerationId)), '}'));
COMMIT;
END
";
public const string ComputeGenerationDiff = @"
CREATE OR ALTER PROCEDURE dbo.sp_ComputeGenerationDiff
@FromGenerationId bigint,
@ToGenerationId bigint
AS
BEGIN
SET NOCOUNT ON;
CREATE TABLE #diff (TableName nvarchar(32), LogicalId nvarchar(64), ChangeKind nvarchar(16));
WITH f AS (SELECT NamespaceId AS LogicalId, CHECKSUM(NamespaceUri, Kind, Enabled, Notes) AS Sig FROM dbo.Namespace WHERE GenerationId = @FromGenerationId),
t AS (SELECT NamespaceId AS LogicalId, CHECKSUM(NamespaceUri, Kind, Enabled, Notes) AS Sig FROM dbo.Namespace WHERE GenerationId = @ToGenerationId)
INSERT #diff
SELECT 'Namespace', CONVERT(nvarchar(64), COALESCE(f.LogicalId, t.LogicalId)),
CASE WHEN f.LogicalId IS NULL THEN 'Added'
WHEN t.LogicalId IS NULL THEN 'Removed'
WHEN f.Sig <> t.Sig THEN 'Modified'
ELSE 'Unchanged' END
FROM f FULL OUTER JOIN t ON f.LogicalId = t.LogicalId
WHERE f.LogicalId IS NULL OR t.LogicalId IS NULL OR f.Sig <> t.Sig;
WITH f AS (SELECT DriverInstanceId AS LogicalId, CHECKSUM(ClusterId, NamespaceId, Name, DriverType, Enabled, CONVERT(varchar(max), DriverConfig)) AS Sig FROM dbo.DriverInstance WHERE GenerationId = @FromGenerationId),
t AS (SELECT DriverInstanceId AS LogicalId, CHECKSUM(ClusterId, NamespaceId, Name, DriverType, Enabled, CONVERT(varchar(max), DriverConfig)) AS Sig FROM dbo.DriverInstance WHERE GenerationId = @ToGenerationId)
INSERT #diff
SELECT 'DriverInstance', CONVERT(nvarchar(64), COALESCE(f.LogicalId, t.LogicalId)),
CASE WHEN f.LogicalId IS NULL THEN 'Added'
WHEN t.LogicalId IS NULL THEN 'Removed'
WHEN f.Sig <> t.Sig THEN 'Modified'
ELSE 'Unchanged' END
FROM f FULL OUTER JOIN t ON f.LogicalId = t.LogicalId
WHERE f.LogicalId IS NULL OR t.LogicalId IS NULL OR f.Sig <> t.Sig;
WITH f AS (SELECT EquipmentId AS LogicalId, CHECKSUM(EquipmentUuid, DriverInstanceId, UnsLineId, Name, MachineCode, ZTag, SAPID, EquipmentClassRef, Manufacturer, Model, SerialNumber) AS Sig FROM dbo.Equipment WHERE GenerationId = @FromGenerationId),
t AS (SELECT EquipmentId AS LogicalId, CHECKSUM(EquipmentUuid, DriverInstanceId, UnsLineId, Name, MachineCode, ZTag, SAPID, EquipmentClassRef, Manufacturer, Model, SerialNumber) AS Sig FROM dbo.Equipment WHERE GenerationId = @ToGenerationId)
INSERT #diff
SELECT 'Equipment', CONVERT(nvarchar(64), COALESCE(f.LogicalId, t.LogicalId)),
CASE WHEN f.LogicalId IS NULL THEN 'Added'
WHEN t.LogicalId IS NULL THEN 'Removed'
WHEN f.Sig <> t.Sig THEN 'Modified'
ELSE 'Unchanged' END
FROM f FULL OUTER JOIN t ON f.LogicalId = t.LogicalId
WHERE f.LogicalId IS NULL OR t.LogicalId IS NULL OR f.Sig <> t.Sig;
WITH f AS (SELECT TagId AS LogicalId, CHECKSUM(DriverInstanceId, DeviceId, EquipmentId, PollGroupId, FolderPath, Name, DataType, AccessLevel, WriteIdempotent, CONVERT(varchar(max), TagConfig)) AS Sig FROM dbo.Tag WHERE GenerationId = @FromGenerationId),
t AS (SELECT TagId AS LogicalId, CHECKSUM(DriverInstanceId, DeviceId, EquipmentId, PollGroupId, FolderPath, Name, DataType, AccessLevel, WriteIdempotent, CONVERT(varchar(max), TagConfig)) AS Sig FROM dbo.Tag WHERE GenerationId = @ToGenerationId)
INSERT #diff
SELECT 'Tag', CONVERT(nvarchar(64), COALESCE(f.LogicalId, t.LogicalId)),
CASE WHEN f.LogicalId IS NULL THEN 'Added'
WHEN t.LogicalId IS NULL THEN 'Removed'
WHEN f.Sig <> t.Sig THEN 'Modified'
ELSE 'Unchanged' END
FROM f FULL OUTER JOIN t ON f.LogicalId = t.LogicalId
WHERE f.LogicalId IS NULL OR t.LogicalId IS NULL OR f.Sig <> t.Sig;
SELECT TableName, LogicalId, ChangeKind FROM #diff;
DROP TABLE #diff;
END
";
public const string ReleaseExternalIdReservation = @"
CREATE OR ALTER PROCEDURE dbo.sp_ReleaseExternalIdReservation
@Kind nvarchar(16),
@Value nvarchar(64),
@ReleaseReason nvarchar(512)
AS
BEGIN
SET NOCOUNT ON;
SET XACT_ABORT ON;
IF @ReleaseReason IS NULL OR LEN(@ReleaseReason) = 0
BEGIN
RAISERROR('ReleaseReason is required', 16, 1);
RETURN;
END
UPDATE dbo.ExternalIdReservation
SET ReleasedAt = SYSUTCDATETIME(),
ReleasedBy = SUSER_SNAME(),
ReleaseReason = @ReleaseReason
WHERE Kind = @Kind AND Value = @Value AND ReleasedAt IS NULL;
IF @@ROWCOUNT = 0
BEGIN
RAISERROR('No active reservation found for (%s, %s)', 16, 1, @Kind, @Value);
RETURN;
END
-- Escape both caller-supplied values via STRING_ESCAPE so quotes/backslashes cannot break the
-- JSON document or inject additional structure into the audit record.
INSERT dbo.ConfigAuditLog (Principal, EventType, DetailsJson)
VALUES (SUSER_SNAME(), 'ExternalIdReleased',
CONCAT('{""kind"":""', STRING_ESCAPE(@Kind, 'json'),
'"",""value"":""', STRING_ESCAPE(@Value, 'json'), '""}'));
END
";
}
}
@@ -1,55 +0,0 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ZB.MOM.WW.OtOpcUa.Configuration.Migrations;
/// <summary>
/// Creates the two DB roles per <c>config-db-schema.md §"Authorization Model"</c> and grants
/// EXECUTE on the appropriate stored procedures. Deliberately grants no direct table DML — all
/// writes funnel through the procs, which authenticate via <c>SUSER_SNAME()</c>.
/// Principals (SQL logins, gMSA users, cert-mapped users) are provisioned by the DBA outside
/// this migration and then added to one of the two roles.
/// </summary>
public partial class AuthorizationGrants : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.Sql(@"
IF DATABASE_PRINCIPAL_ID('OtOpcUaNode') IS NULL
CREATE ROLE OtOpcUaNode;
IF DATABASE_PRINCIPAL_ID('OtOpcUaAdmin') IS NULL
CREATE ROLE OtOpcUaAdmin;
");
migrationBuilder.Sql(@"
GRANT EXECUTE ON OBJECT::dbo.sp_GetCurrentGenerationForCluster TO OtOpcUaNode;
GRANT EXECUTE ON OBJECT::dbo.sp_GetGenerationContent TO OtOpcUaNode;
GRANT EXECUTE ON OBJECT::dbo.sp_RegisterNodeGenerationApplied TO OtOpcUaNode;
GRANT EXECUTE ON OBJECT::dbo.sp_GetCurrentGenerationForCluster TO OtOpcUaAdmin;
GRANT EXECUTE ON OBJECT::dbo.sp_GetGenerationContent TO OtOpcUaAdmin;
GRANT EXECUTE ON OBJECT::dbo.sp_ValidateDraft TO OtOpcUaAdmin;
GRANT EXECUTE ON OBJECT::dbo.sp_PublishGeneration TO OtOpcUaAdmin;
GRANT EXECUTE ON OBJECT::dbo.sp_RollbackToGeneration TO OtOpcUaAdmin;
GRANT EXECUTE ON OBJECT::dbo.sp_ComputeGenerationDiff TO OtOpcUaAdmin;
GRANT EXECUTE ON OBJECT::dbo.sp_ReleaseExternalIdReservation TO OtOpcUaAdmin;
DENY UPDATE, DELETE, INSERT ON SCHEMA::dbo TO OtOpcUaNode;
DENY UPDATE, DELETE, INSERT ON SCHEMA::dbo TO OtOpcUaAdmin;
DENY SELECT ON SCHEMA::dbo TO OtOpcUaNode;
-- Admins may SELECT for reporting views in the future — grant views explicitly, not the schema.
DENY SELECT ON SCHEMA::dbo TO OtOpcUaAdmin;
");
}
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.Sql(@"
IF DATABASE_PRINCIPAL_ID('OtOpcUaNode') IS NOT NULL
DROP ROLE OtOpcUaNode;
IF DATABASE_PRINCIPAL_ID('OtOpcUaAdmin') IS NOT NULL
DROP ROLE OtOpcUaAdmin;
");
}
}
@@ -1,49 +0,0 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ZB.MOM.WW.OtOpcUa.Configuration.Migrations
{
/// <inheritdoc />
public partial class AddDriverHostStatus : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "DriverHostStatus",
columns: table => new
{
NodeId = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: false),
DriverInstanceId = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: false),
HostName = table.Column<string>(type: "nvarchar(256)", maxLength: 256, nullable: false),
State = table.Column<string>(type: "nvarchar(16)", maxLength: 16, nullable: false),
StateChangedUtc = table.Column<DateTime>(type: "datetime2(3)", nullable: false),
LastSeenUtc = table.Column<DateTime>(type: "datetime2(3)", nullable: false),
Detail = table.Column<string>(type: "nvarchar(1024)", maxLength: 1024, nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_DriverHostStatus", x => new { x.NodeId, x.DriverInstanceId, x.HostName });
});
migrationBuilder.CreateIndex(
name: "IX_DriverHostStatus_LastSeen",
table: "DriverHostStatus",
column: "LastSeenUtc");
migrationBuilder.CreateIndex(
name: "IX_DriverHostStatus_Node",
table: "DriverHostStatus",
column: "NodeId");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "DriverHostStatus");
}
}
}
@@ -1,46 +0,0 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ZB.MOM.WW.OtOpcUa.Configuration.Migrations
{
/// <inheritdoc />
public partial class AddDriverInstanceResilienceStatus : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "DriverInstanceResilienceStatus",
columns: table => new
{
DriverInstanceId = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: false),
HostName = table.Column<string>(type: "nvarchar(256)", maxLength: 256, nullable: false),
LastCircuitBreakerOpenUtc = table.Column<DateTime>(type: "datetime2(3)", nullable: true),
ConsecutiveFailures = table.Column<int>(type: "int", nullable: false),
CurrentBulkheadDepth = table.Column<int>(type: "int", nullable: false),
LastRecycleUtc = table.Column<DateTime>(type: "datetime2(3)", nullable: true),
BaselineFootprintBytes = table.Column<long>(type: "bigint", nullable: false),
CurrentFootprintBytes = table.Column<long>(type: "bigint", nullable: false),
LastSampledUtc = table.Column<DateTime>(type: "datetime2(3)", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_DriverInstanceResilienceStatus", x => new { x.DriverInstanceId, x.HostName });
});
migrationBuilder.CreateIndex(
name: "IX_DriverResilience_LastSampled",
table: "DriverInstanceResilienceStatus",
column: "LastSampledUtc");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "DriverInstanceResilienceStatus");
}
}
}
@@ -1,62 +0,0 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ZB.MOM.WW.OtOpcUa.Configuration.Migrations
{
/// <inheritdoc />
public partial class AddLdapGroupRoleMapping : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "LdapGroupRoleMapping",
columns: table => new
{
Id = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
LdapGroup = table.Column<string>(type: "nvarchar(512)", maxLength: 512, nullable: false),
Role = table.Column<string>(type: "nvarchar(32)", maxLength: 32, nullable: false),
ClusterId = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: true),
IsSystemWide = table.Column<bool>(type: "bit", nullable: false),
CreatedAtUtc = table.Column<DateTime>(type: "datetime2(3)", nullable: false),
Notes = table.Column<string>(type: "nvarchar(512)", maxLength: 512, nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_LdapGroupRoleMapping", x => x.Id);
table.ForeignKey(
name: "FK_LdapGroupRoleMapping_ServerCluster_ClusterId",
column: x => x.ClusterId,
principalTable: "ServerCluster",
principalColumn: "ClusterId",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_LdapGroupRoleMapping_ClusterId",
table: "LdapGroupRoleMapping",
column: "ClusterId");
migrationBuilder.CreateIndex(
name: "IX_LdapGroupRoleMapping_Group",
table: "LdapGroupRoleMapping",
column: "LdapGroup");
migrationBuilder.CreateIndex(
name: "UX_LdapGroupRoleMapping_Group_Cluster",
table: "LdapGroupRoleMapping",
columns: new[] { "LdapGroup", "ClusterId" },
unique: true,
filter: "[ClusterId] IS NOT NULL");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "LdapGroupRoleMapping");
}
}
}
@@ -1,37 +0,0 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ZB.MOM.WW.OtOpcUa.Configuration.Migrations
{
/// <inheritdoc />
public partial class AddDriverInstanceResilienceConfig : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "ResilienceConfig",
table: "DriverInstance",
type: "nvarchar(max)",
nullable: true);
migrationBuilder.AddCheckConstraint(
name: "CK_DriverInstance_ResilienceConfig_IsJson",
table: "DriverInstance",
sql: "ResilienceConfig IS NULL OR ISJSON(ResilienceConfig) = 1");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropCheckConstraint(
name: "CK_DriverInstance_ResilienceConfig_IsJson",
table: "DriverInstance");
migrationBuilder.DropColumn(
name: "ResilienceConfig",
table: "DriverInstance");
}
}
}
@@ -1,91 +0,0 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ZB.MOM.WW.OtOpcUa.Configuration.Migrations
{
/// <inheritdoc />
public partial class AddEquipmentImportBatch : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "EquipmentImportBatch",
columns: table => new
{
Id = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
ClusterId = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: false),
CreatedBy = table.Column<string>(type: "nvarchar(128)", maxLength: 128, nullable: false),
CreatedAtUtc = table.Column<DateTime>(type: "datetime2(3)", nullable: false),
RowsStaged = table.Column<int>(type: "int", nullable: false),
RowsAccepted = table.Column<int>(type: "int", nullable: false),
RowsRejected = table.Column<int>(type: "int", nullable: false),
FinalisedAtUtc = table.Column<DateTime>(type: "datetime2(3)", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_EquipmentImportBatch", x => x.Id);
});
migrationBuilder.CreateTable(
name: "EquipmentImportRow",
columns: table => new
{
Id = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
BatchId = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
LineNumberInFile = table.Column<int>(type: "int", nullable: false),
IsAccepted = table.Column<bool>(type: "bit", nullable: false),
RejectReason = table.Column<string>(type: "nvarchar(512)", maxLength: 512, nullable: true),
ZTag = table.Column<string>(type: "nvarchar(128)", maxLength: 128, nullable: false),
MachineCode = table.Column<string>(type: "nvarchar(128)", maxLength: 128, nullable: false),
SAPID = table.Column<string>(type: "nvarchar(128)", maxLength: 128, nullable: false),
EquipmentId = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: false),
EquipmentUuid = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: false),
Name = table.Column<string>(type: "nvarchar(128)", maxLength: 128, nullable: false),
UnsAreaName = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: false),
UnsLineName = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: false),
Manufacturer = table.Column<string>(type: "nvarchar(256)", maxLength: 256, nullable: true),
Model = table.Column<string>(type: "nvarchar(256)", maxLength: 256, nullable: true),
SerialNumber = table.Column<string>(type: "nvarchar(256)", maxLength: 256, nullable: true),
HardwareRevision = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: true),
SoftwareRevision = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: true),
YearOfConstruction = table.Column<string>(type: "nvarchar(8)", maxLength: 8, nullable: true),
AssetLocation = table.Column<string>(type: "nvarchar(512)", maxLength: 512, nullable: true),
ManufacturerUri = table.Column<string>(type: "nvarchar(512)", maxLength: 512, nullable: true),
DeviceManualUri = table.Column<string>(type: "nvarchar(512)", maxLength: 512, nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_EquipmentImportRow", x => x.Id);
table.ForeignKey(
name: "FK_EquipmentImportRow_EquipmentImportBatch_BatchId",
column: x => x.BatchId,
principalTable: "EquipmentImportBatch",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_EquipmentImportBatch_Creator_Finalised",
table: "EquipmentImportBatch",
columns: new[] { "CreatedBy", "FinalisedAtUtc" });
migrationBuilder.CreateIndex(
name: "IX_EquipmentImportRow_Batch",
table: "EquipmentImportRow",
column: "BatchId");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "EquipmentImportRow");
migrationBuilder.DropTable(
name: "EquipmentImportBatch");
}
}
}
@@ -1,172 +0,0 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ZB.MOM.WW.OtOpcUa.Configuration.Migrations
{
/// <summary>
/// Extends <c>dbo.sp_ComputeGenerationDiff</c> to emit <c>NodeAcl</c> rows alongside the
/// existing Namespace/DriverInstance/Equipment/Tag output — closes the final slice of
/// task #196 (DiffViewer ACL section). Logical id for NodeAcl is a composite
/// <c>LdapGroup|ScopeKind|ScopeId</c> triple so a Change row surfaces whether the grant
/// shifted permissions, moved scope, or was added/removed outright.
/// </summary>
/// <inheritdoc />
public partial class ExtendComputeGenerationDiffWithNodeAcl : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.Sql(Procs.ComputeGenerationDiffV2);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.Sql(Procs.ComputeGenerationDiffV1);
}
private static class Procs
{
/// <summary>V2 — adds the NodeAcl section to the diff output.</summary>
public const string ComputeGenerationDiffV2 = @"
CREATE OR ALTER PROCEDURE dbo.sp_ComputeGenerationDiff
@FromGenerationId bigint,
@ToGenerationId bigint
AS
BEGIN
SET NOCOUNT ON;
CREATE TABLE #diff (TableName nvarchar(32), LogicalId nvarchar(128), ChangeKind nvarchar(16));
WITH f AS (SELECT NamespaceId AS LogicalId, CHECKSUM(NamespaceUri, Kind, Enabled, Notes) AS Sig FROM dbo.Namespace WHERE GenerationId = @FromGenerationId),
t AS (SELECT NamespaceId AS LogicalId, CHECKSUM(NamespaceUri, Kind, Enabled, Notes) AS Sig FROM dbo.Namespace WHERE GenerationId = @ToGenerationId)
INSERT #diff
SELECT 'Namespace', CONVERT(nvarchar(128), COALESCE(f.LogicalId, t.LogicalId)),
CASE WHEN f.LogicalId IS NULL THEN 'Added'
WHEN t.LogicalId IS NULL THEN 'Removed'
WHEN f.Sig <> t.Sig THEN 'Modified'
ELSE 'Unchanged' END
FROM f FULL OUTER JOIN t ON f.LogicalId = t.LogicalId
WHERE f.LogicalId IS NULL OR t.LogicalId IS NULL OR f.Sig <> t.Sig;
WITH f AS (SELECT DriverInstanceId AS LogicalId, CHECKSUM(ClusterId, NamespaceId, Name, DriverType, Enabled, CONVERT(varchar(max), DriverConfig)) AS Sig FROM dbo.DriverInstance WHERE GenerationId = @FromGenerationId),
t AS (SELECT DriverInstanceId AS LogicalId, CHECKSUM(ClusterId, NamespaceId, Name, DriverType, Enabled, CONVERT(varchar(max), DriverConfig)) AS Sig FROM dbo.DriverInstance WHERE GenerationId = @ToGenerationId)
INSERT #diff
SELECT 'DriverInstance', CONVERT(nvarchar(128), COALESCE(f.LogicalId, t.LogicalId)),
CASE WHEN f.LogicalId IS NULL THEN 'Added'
WHEN t.LogicalId IS NULL THEN 'Removed'
WHEN f.Sig <> t.Sig THEN 'Modified'
ELSE 'Unchanged' END
FROM f FULL OUTER JOIN t ON f.LogicalId = t.LogicalId
WHERE f.LogicalId IS NULL OR t.LogicalId IS NULL OR f.Sig <> t.Sig;
WITH f AS (SELECT EquipmentId AS LogicalId, CHECKSUM(EquipmentUuid, DriverInstanceId, UnsLineId, Name, MachineCode, ZTag, SAPID, EquipmentClassRef, Manufacturer, Model, SerialNumber) AS Sig FROM dbo.Equipment WHERE GenerationId = @FromGenerationId),
t AS (SELECT EquipmentId AS LogicalId, CHECKSUM(EquipmentUuid, DriverInstanceId, UnsLineId, Name, MachineCode, ZTag, SAPID, EquipmentClassRef, Manufacturer, Model, SerialNumber) AS Sig FROM dbo.Equipment WHERE GenerationId = @ToGenerationId)
INSERT #diff
SELECT 'Equipment', CONVERT(nvarchar(128), COALESCE(f.LogicalId, t.LogicalId)),
CASE WHEN f.LogicalId IS NULL THEN 'Added'
WHEN t.LogicalId IS NULL THEN 'Removed'
WHEN f.Sig <> t.Sig THEN 'Modified'
ELSE 'Unchanged' END
FROM f FULL OUTER JOIN t ON f.LogicalId = t.LogicalId
WHERE f.LogicalId IS NULL OR t.LogicalId IS NULL OR f.Sig <> t.Sig;
WITH f AS (SELECT TagId AS LogicalId, CHECKSUM(DriverInstanceId, DeviceId, EquipmentId, PollGroupId, FolderPath, Name, DataType, AccessLevel, WriteIdempotent, CONVERT(varchar(max), TagConfig)) AS Sig FROM dbo.Tag WHERE GenerationId = @FromGenerationId),
t AS (SELECT TagId AS LogicalId, CHECKSUM(DriverInstanceId, DeviceId, EquipmentId, PollGroupId, FolderPath, Name, DataType, AccessLevel, WriteIdempotent, CONVERT(varchar(max), TagConfig)) AS Sig FROM dbo.Tag WHERE GenerationId = @ToGenerationId)
INSERT #diff
SELECT 'Tag', CONVERT(nvarchar(128), COALESCE(f.LogicalId, t.LogicalId)),
CASE WHEN f.LogicalId IS NULL THEN 'Added'
WHEN t.LogicalId IS NULL THEN 'Removed'
WHEN f.Sig <> t.Sig THEN 'Modified'
ELSE 'Unchanged' END
FROM f FULL OUTER JOIN t ON f.LogicalId = t.LogicalId
WHERE f.LogicalId IS NULL OR t.LogicalId IS NULL OR f.Sig <> t.Sig;
-- NodeAcl section. Logical id is the (LdapGroup, ScopeKind, ScopeId) triple so the diff
-- distinguishes same row with new permissions (Modified via CHECKSUM on PermissionFlags + Notes)
-- from a scope move (which surfaces as Added + Removed of different logical ids).
WITH f AS (
SELECT CONVERT(nvarchar(128), LdapGroup + '|' + CONVERT(nvarchar(16), ScopeKind) + '|' + ISNULL(ScopeId, '(cluster)')) AS LogicalId,
CHECKSUM(ClusterId, PermissionFlags, Notes) AS Sig
FROM dbo.NodeAcl WHERE GenerationId = @FromGenerationId),
t AS (
SELECT CONVERT(nvarchar(128), LdapGroup + '|' + CONVERT(nvarchar(16), ScopeKind) + '|' + ISNULL(ScopeId, '(cluster)')) AS LogicalId,
CHECKSUM(ClusterId, PermissionFlags, Notes) AS Sig
FROM dbo.NodeAcl WHERE GenerationId = @ToGenerationId)
INSERT #diff
SELECT 'NodeAcl', COALESCE(f.LogicalId, t.LogicalId),
CASE WHEN f.LogicalId IS NULL THEN 'Added'
WHEN t.LogicalId IS NULL THEN 'Removed'
WHEN f.Sig <> t.Sig THEN 'Modified'
ELSE 'Unchanged' END
FROM f FULL OUTER JOIN t ON f.LogicalId = t.LogicalId
WHERE f.LogicalId IS NULL OR t.LogicalId IS NULL OR f.Sig <> t.Sig;
SELECT TableName, LogicalId, ChangeKind FROM #diff;
DROP TABLE #diff;
END
";
/// <summary>V1 — exact proc shipped in migration 20260417215224_StoredProcedures. Restored on Down().</summary>
public const string ComputeGenerationDiffV1 = @"
CREATE OR ALTER PROCEDURE dbo.sp_ComputeGenerationDiff
@FromGenerationId bigint,
@ToGenerationId bigint
AS
BEGIN
SET NOCOUNT ON;
CREATE TABLE #diff (TableName nvarchar(32), LogicalId nvarchar(64), ChangeKind nvarchar(16));
WITH f AS (SELECT NamespaceId AS LogicalId, CHECKSUM(NamespaceUri, Kind, Enabled, Notes) AS Sig FROM dbo.Namespace WHERE GenerationId = @FromGenerationId),
t AS (SELECT NamespaceId AS LogicalId, CHECKSUM(NamespaceUri, Kind, Enabled, Notes) AS Sig FROM dbo.Namespace WHERE GenerationId = @ToGenerationId)
INSERT #diff
SELECT 'Namespace', CONVERT(nvarchar(64), COALESCE(f.LogicalId, t.LogicalId)),
CASE WHEN f.LogicalId IS NULL THEN 'Added'
WHEN t.LogicalId IS NULL THEN 'Removed'
WHEN f.Sig <> t.Sig THEN 'Modified'
ELSE 'Unchanged' END
FROM f FULL OUTER JOIN t ON f.LogicalId = t.LogicalId
WHERE f.LogicalId IS NULL OR t.LogicalId IS NULL OR f.Sig <> t.Sig;
WITH f AS (SELECT DriverInstanceId AS LogicalId, CHECKSUM(ClusterId, NamespaceId, Name, DriverType, Enabled, CONVERT(varchar(max), DriverConfig)) AS Sig FROM dbo.DriverInstance WHERE GenerationId = @FromGenerationId),
t AS (SELECT DriverInstanceId AS LogicalId, CHECKSUM(ClusterId, NamespaceId, Name, DriverType, Enabled, CONVERT(varchar(max), DriverConfig)) AS Sig FROM dbo.DriverInstance WHERE GenerationId = @ToGenerationId)
INSERT #diff
SELECT 'DriverInstance', CONVERT(nvarchar(64), COALESCE(f.LogicalId, t.LogicalId)),
CASE WHEN f.LogicalId IS NULL THEN 'Added'
WHEN t.LogicalId IS NULL THEN 'Removed'
WHEN f.Sig <> t.Sig THEN 'Modified'
ELSE 'Unchanged' END
FROM f FULL OUTER JOIN t ON f.LogicalId = t.LogicalId
WHERE f.LogicalId IS NULL OR t.LogicalId IS NULL OR f.Sig <> t.Sig;
WITH f AS (SELECT EquipmentId AS LogicalId, CHECKSUM(EquipmentUuid, DriverInstanceId, UnsLineId, Name, MachineCode, ZTag, SAPID, EquipmentClassRef, Manufacturer, Model, SerialNumber) AS Sig FROM dbo.Equipment WHERE GenerationId = @FromGenerationId),
t AS (SELECT EquipmentId AS LogicalId, CHECKSUM(EquipmentUuid, DriverInstanceId, UnsLineId, Name, MachineCode, ZTag, SAPID, EquipmentClassRef, Manufacturer, Model, SerialNumber) AS Sig FROM dbo.Equipment WHERE GenerationId = @ToGenerationId)
INSERT #diff
SELECT 'Equipment', CONVERT(nvarchar(64), COALESCE(f.LogicalId, t.LogicalId)),
CASE WHEN f.LogicalId IS NULL THEN 'Added'
WHEN t.LogicalId IS NULL THEN 'Removed'
WHEN f.Sig <> t.Sig THEN 'Modified'
ELSE 'Unchanged' END
FROM f FULL OUTER JOIN t ON f.LogicalId = t.LogicalId
WHERE f.LogicalId IS NULL OR t.LogicalId IS NULL OR f.Sig <> t.Sig;
WITH f AS (SELECT TagId AS LogicalId, CHECKSUM(DriverInstanceId, DeviceId, EquipmentId, PollGroupId, FolderPath, Name, DataType, AccessLevel, WriteIdempotent, CONVERT(varchar(max), TagConfig)) AS Sig FROM dbo.Tag WHERE GenerationId = @FromGenerationId),
t AS (SELECT TagId AS LogicalId, CHECKSUM(DriverInstanceId, DeviceId, EquipmentId, PollGroupId, FolderPath, Name, DataType, AccessLevel, WriteIdempotent, CONVERT(varchar(max), TagConfig)) AS Sig FROM dbo.Tag WHERE GenerationId = @ToGenerationId)
INSERT #diff
SELECT 'Tag', CONVERT(nvarchar(64), COALESCE(f.LogicalId, t.LogicalId)),
CASE WHEN f.LogicalId IS NULL THEN 'Added'
WHEN t.LogicalId IS NULL THEN 'Removed'
WHEN f.Sig <> t.Sig THEN 'Modified'
ELSE 'Unchanged' END
FROM f FULL OUTER JOIN t ON f.LogicalId = t.LogicalId
WHERE f.LogicalId IS NULL OR t.LogicalId IS NULL OR f.Sig <> t.Sig;
SELECT TableName, LogicalId, ChangeKind FROM #diff;
DROP TABLE #diff;
END
";
}
}
}
@@ -1,186 +0,0 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ZB.MOM.WW.OtOpcUa.Configuration.Migrations
{
/// <inheritdoc />
public partial class AddPhase7ScriptingTables : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "Script",
columns: table => new
{
ScriptRowId = table.Column<Guid>(type: "uniqueidentifier", nullable: false, defaultValueSql: "NEWSEQUENTIALID()"),
GenerationId = table.Column<long>(type: "bigint", nullable: false),
ScriptId = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: true),
Name = table.Column<string>(type: "nvarchar(128)", maxLength: 128, nullable: false),
SourceCode = table.Column<string>(type: "nvarchar(max)", nullable: false),
SourceHash = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: false),
Language = table.Column<string>(type: "nvarchar(16)", maxLength: 16, nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Script", x => x.ScriptRowId);
table.ForeignKey(
name: "FK_Script_ConfigGeneration_GenerationId",
column: x => x.GenerationId,
principalTable: "ConfigGeneration",
principalColumn: "GenerationId",
onDelete: ReferentialAction.Restrict);
});
migrationBuilder.CreateTable(
name: "ScriptedAlarm",
columns: table => new
{
ScriptedAlarmRowId = table.Column<Guid>(type: "uniqueidentifier", nullable: false, defaultValueSql: "NEWSEQUENTIALID()"),
GenerationId = table.Column<long>(type: "bigint", nullable: false),
ScriptedAlarmId = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: true),
EquipmentId = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: false),
Name = table.Column<string>(type: "nvarchar(128)", maxLength: 128, nullable: false),
AlarmType = table.Column<string>(type: "nvarchar(32)", maxLength: 32, nullable: false),
Severity = table.Column<int>(type: "int", nullable: false),
MessageTemplate = table.Column<string>(type: "nvarchar(1024)", maxLength: 1024, nullable: false),
PredicateScriptId = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: false),
HistorizeToAveva = table.Column<bool>(type: "bit", nullable: false),
Retain = table.Column<bool>(type: "bit", nullable: false),
Enabled = table.Column<bool>(type: "bit", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_ScriptedAlarm", x => x.ScriptedAlarmRowId);
table.CheckConstraint("CK_ScriptedAlarm_AlarmType", "AlarmType IN ('AlarmCondition','LimitAlarm','OffNormalAlarm','DiscreteAlarm')");
table.CheckConstraint("CK_ScriptedAlarm_Severity_Range", "Severity BETWEEN 1 AND 1000");
table.ForeignKey(
name: "FK_ScriptedAlarm_ConfigGeneration_GenerationId",
column: x => x.GenerationId,
principalTable: "ConfigGeneration",
principalColumn: "GenerationId",
onDelete: ReferentialAction.Restrict);
});
migrationBuilder.CreateTable(
name: "ScriptedAlarmState",
columns: table => new
{
ScriptedAlarmId = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: false),
EnabledState = table.Column<string>(type: "nvarchar(16)", maxLength: 16, nullable: false),
AckedState = table.Column<string>(type: "nvarchar(16)", maxLength: 16, nullable: false),
ConfirmedState = table.Column<string>(type: "nvarchar(16)", maxLength: 16, nullable: false),
ShelvingState = table.Column<string>(type: "nvarchar(16)", maxLength: 16, nullable: false),
ShelvingExpiresUtc = table.Column<DateTime>(type: "datetime2(3)", nullable: true),
LastAckUser = table.Column<string>(type: "nvarchar(128)", maxLength: 128, nullable: true),
LastAckComment = table.Column<string>(type: "nvarchar(1024)", maxLength: 1024, nullable: true),
LastAckUtc = table.Column<DateTime>(type: "datetime2(3)", nullable: true),
LastConfirmUser = table.Column<string>(type: "nvarchar(128)", maxLength: 128, nullable: true),
LastConfirmComment = table.Column<string>(type: "nvarchar(1024)", maxLength: 1024, nullable: true),
LastConfirmUtc = table.Column<DateTime>(type: "datetime2(3)", nullable: true),
CommentsJson = table.Column<string>(type: "nvarchar(max)", nullable: false),
UpdatedAtUtc = table.Column<DateTime>(type: "datetime2(3)", nullable: false, defaultValueSql: "SYSUTCDATETIME()")
},
constraints: table =>
{
table.PrimaryKey("PK_ScriptedAlarmState", x => x.ScriptedAlarmId);
table.CheckConstraint("CK_ScriptedAlarmState_CommentsJson_IsJson", "ISJSON(CommentsJson) = 1");
});
migrationBuilder.CreateTable(
name: "VirtualTag",
columns: table => new
{
VirtualTagRowId = table.Column<Guid>(type: "uniqueidentifier", nullable: false, defaultValueSql: "NEWSEQUENTIALID()"),
GenerationId = table.Column<long>(type: "bigint", nullable: false),
VirtualTagId = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: true),
EquipmentId = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: false),
Name = table.Column<string>(type: "nvarchar(128)", maxLength: 128, nullable: false),
DataType = table.Column<string>(type: "nvarchar(32)", maxLength: 32, nullable: false),
ScriptId = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: false),
ChangeTriggered = table.Column<bool>(type: "bit", nullable: false),
TimerIntervalMs = table.Column<int>(type: "int", nullable: true),
Historize = table.Column<bool>(type: "bit", nullable: false),
Enabled = table.Column<bool>(type: "bit", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_VirtualTag", x => x.VirtualTagRowId);
table.CheckConstraint("CK_VirtualTag_TimerInterval_Min", "TimerIntervalMs IS NULL OR TimerIntervalMs >= 50");
table.CheckConstraint("CK_VirtualTag_Trigger_AtLeastOne", "ChangeTriggered = 1 OR TimerIntervalMs IS NOT NULL");
table.ForeignKey(
name: "FK_VirtualTag_ConfigGeneration_GenerationId",
column: x => x.GenerationId,
principalTable: "ConfigGeneration",
principalColumn: "GenerationId",
onDelete: ReferentialAction.Restrict);
});
migrationBuilder.CreateIndex(
name: "IX_Script_Generation_SourceHash",
table: "Script",
columns: new[] { "GenerationId", "SourceHash" });
migrationBuilder.CreateIndex(
name: "UX_Script_Generation_LogicalId",
table: "Script",
columns: new[] { "GenerationId", "ScriptId" },
unique: true,
filter: "[ScriptId] IS NOT NULL");
migrationBuilder.CreateIndex(
name: "IX_ScriptedAlarm_Generation_Script",
table: "ScriptedAlarm",
columns: new[] { "GenerationId", "PredicateScriptId" });
migrationBuilder.CreateIndex(
name: "UX_ScriptedAlarm_Generation_EquipmentPath",
table: "ScriptedAlarm",
columns: new[] { "GenerationId", "EquipmentId", "Name" },
unique: true);
migrationBuilder.CreateIndex(
name: "UX_ScriptedAlarm_Generation_LogicalId",
table: "ScriptedAlarm",
columns: new[] { "GenerationId", "ScriptedAlarmId" },
unique: true,
filter: "[ScriptedAlarmId] IS NOT NULL");
migrationBuilder.CreateIndex(
name: "IX_VirtualTag_Generation_Script",
table: "VirtualTag",
columns: new[] { "GenerationId", "ScriptId" });
migrationBuilder.CreateIndex(
name: "UX_VirtualTag_Generation_EquipmentPath",
table: "VirtualTag",
columns: new[] { "GenerationId", "EquipmentId", "Name" },
unique: true);
migrationBuilder.CreateIndex(
name: "UX_VirtualTag_Generation_LogicalId",
table: "VirtualTag",
columns: new[] { "GenerationId", "VirtualTagId" },
unique: true,
filter: "[VirtualTagId] IS NOT NULL");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "Script");
migrationBuilder.DropTable(
name: "ScriptedAlarm");
migrationBuilder.DropTable(
name: "ScriptedAlarmState");
migrationBuilder.DropTable(
name: "VirtualTag");
}
}
}
@@ -1,232 +0,0 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ZB.MOM.WW.OtOpcUa.Configuration.Migrations
{
/// <summary>
/// Phase 7 follow-up (task #241) — extends <c>dbo.sp_ComputeGenerationDiff</c> to emit
/// Script / VirtualTag / ScriptedAlarm rows alongside the existing Namespace /
/// DriverInstance / Equipment / Tag / NodeAcl output. Admin DiffViewer now shows
/// Phase 7 changes between generations.
/// </summary>
/// <remarks>
/// Logical ids: ScriptId, VirtualTagId, ScriptedAlarmId — stable across generations
/// so a Script whose source changes surfaces as Modified (CHECKSUM picks up the
/// SourceHash delta) while a renamed script surfaces as Modified on Name alone.
/// ScriptedAlarmState is deliberately excluded — it's not generation-scoped, so
/// diffing it between generations is meaningless.
/// </remarks>
/// <inheritdoc />
public partial class ExtendComputeGenerationDiffWithPhase7 : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.Sql(Procs.ComputeGenerationDiffV3);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.Sql(Procs.ComputeGenerationDiffV2);
}
private static class Procs
{
/// <summary>V3 — adds Script / VirtualTag / ScriptedAlarm sections.</summary>
public const string ComputeGenerationDiffV3 = @"
CREATE OR ALTER PROCEDURE dbo.sp_ComputeGenerationDiff
@FromGenerationId bigint,
@ToGenerationId bigint
AS
BEGIN
SET NOCOUNT ON;
CREATE TABLE #diff (TableName nvarchar(32), LogicalId nvarchar(128), ChangeKind nvarchar(16));
WITH f AS (SELECT NamespaceId AS LogicalId, CHECKSUM(NamespaceUri, Kind, Enabled, Notes) AS Sig FROM dbo.Namespace WHERE GenerationId = @FromGenerationId),
t AS (SELECT NamespaceId AS LogicalId, CHECKSUM(NamespaceUri, Kind, Enabled, Notes) AS Sig FROM dbo.Namespace WHERE GenerationId = @ToGenerationId)
INSERT #diff
SELECT 'Namespace', CONVERT(nvarchar(128), COALESCE(f.LogicalId, t.LogicalId)),
CASE WHEN f.LogicalId IS NULL THEN 'Added'
WHEN t.LogicalId IS NULL THEN 'Removed'
WHEN f.Sig <> t.Sig THEN 'Modified'
ELSE 'Unchanged' END
FROM f FULL OUTER JOIN t ON f.LogicalId = t.LogicalId
WHERE f.LogicalId IS NULL OR t.LogicalId IS NULL OR f.Sig <> t.Sig;
WITH f AS (SELECT DriverInstanceId AS LogicalId, CHECKSUM(ClusterId, NamespaceId, Name, DriverType, Enabled, CONVERT(varchar(max), DriverConfig)) AS Sig FROM dbo.DriverInstance WHERE GenerationId = @FromGenerationId),
t AS (SELECT DriverInstanceId AS LogicalId, CHECKSUM(ClusterId, NamespaceId, Name, DriverType, Enabled, CONVERT(varchar(max), DriverConfig)) AS Sig FROM dbo.DriverInstance WHERE GenerationId = @ToGenerationId)
INSERT #diff
SELECT 'DriverInstance', CONVERT(nvarchar(128), COALESCE(f.LogicalId, t.LogicalId)),
CASE WHEN f.LogicalId IS NULL THEN 'Added'
WHEN t.LogicalId IS NULL THEN 'Removed'
WHEN f.Sig <> t.Sig THEN 'Modified'
ELSE 'Unchanged' END
FROM f FULL OUTER JOIN t ON f.LogicalId = t.LogicalId
WHERE f.LogicalId IS NULL OR t.LogicalId IS NULL OR f.Sig <> t.Sig;
WITH f AS (SELECT EquipmentId AS LogicalId, CHECKSUM(EquipmentUuid, DriverInstanceId, UnsLineId, Name, MachineCode, ZTag, SAPID, EquipmentClassRef, Manufacturer, Model, SerialNumber) AS Sig FROM dbo.Equipment WHERE GenerationId = @FromGenerationId),
t AS (SELECT EquipmentId AS LogicalId, CHECKSUM(EquipmentUuid, DriverInstanceId, UnsLineId, Name, MachineCode, ZTag, SAPID, EquipmentClassRef, Manufacturer, Model, SerialNumber) AS Sig FROM dbo.Equipment WHERE GenerationId = @ToGenerationId)
INSERT #diff
SELECT 'Equipment', CONVERT(nvarchar(128), COALESCE(f.LogicalId, t.LogicalId)),
CASE WHEN f.LogicalId IS NULL THEN 'Added'
WHEN t.LogicalId IS NULL THEN 'Removed'
WHEN f.Sig <> t.Sig THEN 'Modified'
ELSE 'Unchanged' END
FROM f FULL OUTER JOIN t ON f.LogicalId = t.LogicalId
WHERE f.LogicalId IS NULL OR t.LogicalId IS NULL OR f.Sig <> t.Sig;
WITH f AS (SELECT TagId AS LogicalId, CHECKSUM(DriverInstanceId, DeviceId, EquipmentId, PollGroupId, FolderPath, Name, DataType, AccessLevel, WriteIdempotent, CONVERT(varchar(max), TagConfig)) AS Sig FROM dbo.Tag WHERE GenerationId = @FromGenerationId),
t AS (SELECT TagId AS LogicalId, CHECKSUM(DriverInstanceId, DeviceId, EquipmentId, PollGroupId, FolderPath, Name, DataType, AccessLevel, WriteIdempotent, CONVERT(varchar(max), TagConfig)) AS Sig FROM dbo.Tag WHERE GenerationId = @ToGenerationId)
INSERT #diff
SELECT 'Tag', CONVERT(nvarchar(128), COALESCE(f.LogicalId, t.LogicalId)),
CASE WHEN f.LogicalId IS NULL THEN 'Added'
WHEN t.LogicalId IS NULL THEN 'Removed'
WHEN f.Sig <> t.Sig THEN 'Modified'
ELSE 'Unchanged' END
FROM f FULL OUTER JOIN t ON f.LogicalId = t.LogicalId
WHERE f.LogicalId IS NULL OR t.LogicalId IS NULL OR f.Sig <> t.Sig;
WITH f AS (
SELECT CONVERT(nvarchar(128), LdapGroup + '|' + CONVERT(nvarchar(16), ScopeKind) + '|' + ISNULL(ScopeId, '(cluster)')) AS LogicalId,
CHECKSUM(ClusterId, PermissionFlags, Notes) AS Sig
FROM dbo.NodeAcl WHERE GenerationId = @FromGenerationId),
t AS (
SELECT CONVERT(nvarchar(128), LdapGroup + '|' + CONVERT(nvarchar(16), ScopeKind) + '|' + ISNULL(ScopeId, '(cluster)')) AS LogicalId,
CHECKSUM(ClusterId, PermissionFlags, Notes) AS Sig
FROM dbo.NodeAcl WHERE GenerationId = @ToGenerationId)
INSERT #diff
SELECT 'NodeAcl', COALESCE(f.LogicalId, t.LogicalId),
CASE WHEN f.LogicalId IS NULL THEN 'Added'
WHEN t.LogicalId IS NULL THEN 'Removed'
WHEN f.Sig <> t.Sig THEN 'Modified'
ELSE 'Unchanged' END
FROM f FULL OUTER JOIN t ON f.LogicalId = t.LogicalId
WHERE f.LogicalId IS NULL OR t.LogicalId IS NULL OR f.Sig <> t.Sig;
-- Phase 7 Script section. CHECKSUM picks up source changes via SourceHash + rename
-- via Name; Language future-proofs for non-C# engines. Same Name + same Source =
-- Unchanged (identical hash).
WITH f AS (SELECT ScriptId AS LogicalId, CHECKSUM(Name, SourceHash, Language) AS Sig FROM dbo.Script WHERE GenerationId = @FromGenerationId),
t AS (SELECT ScriptId AS LogicalId, CHECKSUM(Name, SourceHash, Language) AS Sig FROM dbo.Script WHERE GenerationId = @ToGenerationId)
INSERT #diff
SELECT 'Script', CONVERT(nvarchar(128), COALESCE(f.LogicalId, t.LogicalId)),
CASE WHEN f.LogicalId IS NULL THEN 'Added'
WHEN t.LogicalId IS NULL THEN 'Removed'
WHEN f.Sig <> t.Sig THEN 'Modified'
ELSE 'Unchanged' END
FROM f FULL OUTER JOIN t ON f.LogicalId = t.LogicalId
WHERE f.LogicalId IS NULL OR t.LogicalId IS NULL OR f.Sig <> t.Sig;
-- Phase 7 VirtualTag section.
WITH f AS (SELECT VirtualTagId AS LogicalId, CHECKSUM(EquipmentId, Name, DataType, ScriptId, ChangeTriggered, TimerIntervalMs, Historize, Enabled) AS Sig FROM dbo.VirtualTag WHERE GenerationId = @FromGenerationId),
t AS (SELECT VirtualTagId AS LogicalId, CHECKSUM(EquipmentId, Name, DataType, ScriptId, ChangeTriggered, TimerIntervalMs, Historize, Enabled) AS Sig FROM dbo.VirtualTag WHERE GenerationId = @ToGenerationId)
INSERT #diff
SELECT 'VirtualTag', CONVERT(nvarchar(128), COALESCE(f.LogicalId, t.LogicalId)),
CASE WHEN f.LogicalId IS NULL THEN 'Added'
WHEN t.LogicalId IS NULL THEN 'Removed'
WHEN f.Sig <> t.Sig THEN 'Modified'
ELSE 'Unchanged' END
FROM f FULL OUTER JOIN t ON f.LogicalId = t.LogicalId
WHERE f.LogicalId IS NULL OR t.LogicalId IS NULL OR f.Sig <> t.Sig;
-- Phase 7 ScriptedAlarm section. ScriptedAlarmState (operator ack trail) is
-- logical-id keyed outside the generation scope + intentionally excluded here
-- diffing ack state between generations is semantically meaningless.
WITH f AS (SELECT ScriptedAlarmId AS LogicalId, CHECKSUM(EquipmentId, Name, AlarmType, Severity, MessageTemplate, PredicateScriptId, HistorizeToAveva, Retain, Enabled) AS Sig FROM dbo.ScriptedAlarm WHERE GenerationId = @FromGenerationId),
t AS (SELECT ScriptedAlarmId AS LogicalId, CHECKSUM(EquipmentId, Name, AlarmType, Severity, MessageTemplate, PredicateScriptId, HistorizeToAveva, Retain, Enabled) AS Sig FROM dbo.ScriptedAlarm WHERE GenerationId = @ToGenerationId)
INSERT #diff
SELECT 'ScriptedAlarm', CONVERT(nvarchar(128), COALESCE(f.LogicalId, t.LogicalId)),
CASE WHEN f.LogicalId IS NULL THEN 'Added'
WHEN t.LogicalId IS NULL THEN 'Removed'
WHEN f.Sig <> t.Sig THEN 'Modified'
ELSE 'Unchanged' END
FROM f FULL OUTER JOIN t ON f.LogicalId = t.LogicalId
WHERE f.LogicalId IS NULL OR t.LogicalId IS NULL OR f.Sig <> t.Sig;
SELECT TableName, LogicalId, ChangeKind FROM #diff;
DROP TABLE #diff;
END
";
/// <summary>V2 — restores the pre-Phase-7 proc on Down().</summary>
public const string ComputeGenerationDiffV2 = @"
CREATE OR ALTER PROCEDURE dbo.sp_ComputeGenerationDiff
@FromGenerationId bigint,
@ToGenerationId bigint
AS
BEGIN
SET NOCOUNT ON;
CREATE TABLE #diff (TableName nvarchar(32), LogicalId nvarchar(128), ChangeKind nvarchar(16));
WITH f AS (SELECT NamespaceId AS LogicalId, CHECKSUM(NamespaceUri, Kind, Enabled, Notes) AS Sig FROM dbo.Namespace WHERE GenerationId = @FromGenerationId),
t AS (SELECT NamespaceId AS LogicalId, CHECKSUM(NamespaceUri, Kind, Enabled, Notes) AS Sig FROM dbo.Namespace WHERE GenerationId = @ToGenerationId)
INSERT #diff
SELECT 'Namespace', CONVERT(nvarchar(128), COALESCE(f.LogicalId, t.LogicalId)),
CASE WHEN f.LogicalId IS NULL THEN 'Added'
WHEN t.LogicalId IS NULL THEN 'Removed'
WHEN f.Sig <> t.Sig THEN 'Modified'
ELSE 'Unchanged' END
FROM f FULL OUTER JOIN t ON f.LogicalId = t.LogicalId
WHERE f.LogicalId IS NULL OR t.LogicalId IS NULL OR f.Sig <> t.Sig;
WITH f AS (SELECT DriverInstanceId AS LogicalId, CHECKSUM(ClusterId, NamespaceId, Name, DriverType, Enabled, CONVERT(varchar(max), DriverConfig)) AS Sig FROM dbo.DriverInstance WHERE GenerationId = @FromGenerationId),
t AS (SELECT DriverInstanceId AS LogicalId, CHECKSUM(ClusterId, NamespaceId, Name, DriverType, Enabled, CONVERT(varchar(max), DriverConfig)) AS Sig FROM dbo.DriverInstance WHERE GenerationId = @ToGenerationId)
INSERT #diff
SELECT 'DriverInstance', CONVERT(nvarchar(128), COALESCE(f.LogicalId, t.LogicalId)),
CASE WHEN f.LogicalId IS NULL THEN 'Added'
WHEN t.LogicalId IS NULL THEN 'Removed'
WHEN f.Sig <> t.Sig THEN 'Modified'
ELSE 'Unchanged' END
FROM f FULL OUTER JOIN t ON f.LogicalId = t.LogicalId
WHERE f.LogicalId IS NULL OR t.LogicalId IS NULL OR f.Sig <> t.Sig;
WITH f AS (SELECT EquipmentId AS LogicalId, CHECKSUM(EquipmentUuid, DriverInstanceId, UnsLineId, Name, MachineCode, ZTag, SAPID, EquipmentClassRef, Manufacturer, Model, SerialNumber) AS Sig FROM dbo.Equipment WHERE GenerationId = @FromGenerationId),
t AS (SELECT EquipmentId AS LogicalId, CHECKSUM(EquipmentUuid, DriverInstanceId, UnsLineId, Name, MachineCode, ZTag, SAPID, EquipmentClassRef, Manufacturer, Model, SerialNumber) AS Sig FROM dbo.Equipment WHERE GenerationId = @ToGenerationId)
INSERT #diff
SELECT 'Equipment', CONVERT(nvarchar(128), COALESCE(f.LogicalId, t.LogicalId)),
CASE WHEN f.LogicalId IS NULL THEN 'Added'
WHEN t.LogicalId IS NULL THEN 'Removed'
WHEN f.Sig <> t.Sig THEN 'Modified'
ELSE 'Unchanged' END
FROM f FULL OUTER JOIN t ON f.LogicalId = t.LogicalId
WHERE f.LogicalId IS NULL OR t.LogicalId IS NULL OR f.Sig <> t.Sig;
WITH f AS (SELECT TagId AS LogicalId, CHECKSUM(DriverInstanceId, DeviceId, EquipmentId, PollGroupId, FolderPath, Name, DataType, AccessLevel, WriteIdempotent, CONVERT(varchar(max), TagConfig)) AS Sig FROM dbo.Tag WHERE GenerationId = @FromGenerationId),
t AS (SELECT TagId AS LogicalId, CHECKSUM(DriverInstanceId, DeviceId, EquipmentId, PollGroupId, FolderPath, Name, DataType, AccessLevel, WriteIdempotent, CONVERT(varchar(max), TagConfig)) AS Sig FROM dbo.Tag WHERE GenerationId = @ToGenerationId)
INSERT #diff
SELECT 'Tag', CONVERT(nvarchar(128), COALESCE(f.LogicalId, t.LogicalId)),
CASE WHEN f.LogicalId IS NULL THEN 'Added'
WHEN t.LogicalId IS NULL THEN 'Removed'
WHEN f.Sig <> t.Sig THEN 'Modified'
ELSE 'Unchanged' END
FROM f FULL OUTER JOIN t ON f.LogicalId = t.LogicalId
WHERE f.LogicalId IS NULL OR t.LogicalId IS NULL OR f.Sig <> t.Sig;
WITH f AS (
SELECT CONVERT(nvarchar(128), LdapGroup + '|' + CONVERT(nvarchar(16), ScopeKind) + '|' + ISNULL(ScopeId, '(cluster)')) AS LogicalId,
CHECKSUM(ClusterId, PermissionFlags, Notes) AS Sig
FROM dbo.NodeAcl WHERE GenerationId = @FromGenerationId),
t AS (
SELECT CONVERT(nvarchar(128), LdapGroup + '|' + CONVERT(nvarchar(16), ScopeKind) + '|' + ISNULL(ScopeId, '(cluster)')) AS LogicalId,
CHECKSUM(ClusterId, PermissionFlags, Notes) AS Sig
FROM dbo.NodeAcl WHERE GenerationId = @ToGenerationId)
INSERT #diff
SELECT 'NodeAcl', COALESCE(f.LogicalId, t.LogicalId),
CASE WHEN f.LogicalId IS NULL THEN 'Added'
WHEN t.LogicalId IS NULL THEN 'Removed'
WHEN f.Sig <> t.Sig THEN 'Modified'
ELSE 'Unchanged' END
FROM f FULL OUTER JOIN t ON f.LogicalId = t.LogicalId
WHERE f.LogicalId IS NULL OR t.LogicalId IS NULL OR f.Sig <> t.Sig;
SELECT TableName, LogicalId, ChangeKind FROM #diff;
DROP TABLE #diff;
END
";
}
}
}
@@ -1,120 +0,0 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ZB.MOM.WW.OtOpcUa.Configuration.Migrations
{
/// <summary>
/// Admin-008: adds <c>@ReleasedBy</c> parameter to
/// <c>dbo.sp_ReleaseExternalIdReservation</c> so the operator principal name (the LDAP
/// sign-in) is recorded in <c>ExternalIdReservation.ReleasedBy</c> and the
/// <c>ConfigAuditLog.Principal</c> column.
///
/// Prior to this migration the proc used <c>SUSER_SNAME()</c> for both columns, which
/// recorded the shared SQL service account rather than the Admin-UI operator who performed
/// the release — making the audit trail useless for attribution. The stored proc now
/// accepts <c>@ReleasedBy nvarchar(128)</c> and uses it for both columns; validation
/// rejects a null/empty value the same way <c>@ReleaseReason</c> is validated.
/// </summary>
/// <inheritdoc />
public partial class AddReleasedByToReleaseExternalIdReservation : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.Sql(Procs.ReleaseExternalIdReservationV2);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.Sql(Procs.ReleaseExternalIdReservationV1);
}
private static class Procs
{
/// <summary>V2 — accepts <c>@ReleasedBy</c> for proper operator attribution.</summary>
public const string ReleaseExternalIdReservationV2 = @"
CREATE OR ALTER PROCEDURE dbo.sp_ReleaseExternalIdReservation
@Kind nvarchar(16),
@Value nvarchar(64),
@ReleaseReason nvarchar(512),
@ReleasedBy nvarchar(128)
AS
BEGIN
SET NOCOUNT ON;
SET XACT_ABORT ON;
IF @ReleaseReason IS NULL OR LEN(@ReleaseReason) = 0
BEGIN
RAISERROR('ReleaseReason is required', 16, 1);
RETURN;
END
IF @ReleasedBy IS NULL OR LEN(@ReleasedBy) = 0
BEGIN
RAISERROR('ReleasedBy is required', 16, 1);
RETURN;
END
UPDATE dbo.ExternalIdReservation
SET ReleasedAt = SYSUTCDATETIME(),
ReleasedBy = @ReleasedBy,
ReleaseReason = @ReleaseReason
WHERE Kind = @Kind AND Value = @Value AND ReleasedAt IS NULL;
IF @@ROWCOUNT = 0
BEGIN
RAISERROR('No active reservation found for (%s, %s)', 16, 1, @Kind, @Value);
RETURN;
END
-- Escape all caller-supplied values via STRING_ESCAPE so quotes/backslashes cannot break the
-- JSON document or inject additional structure into the audit record.
INSERT dbo.ConfigAuditLog (Principal, EventType, DetailsJson)
VALUES (@ReleasedBy, 'ExternalIdReleased',
CONCAT('{""kind"":""', STRING_ESCAPE(@Kind, 'json'),
'"",""value"":""', STRING_ESCAPE(@Value, 'json'), '""}'));
END
";
/// <summary>V1 — original proc (uses SUSER_SNAME() for attribution). Restored on Down().</summary>
public const string ReleaseExternalIdReservationV1 = @"
CREATE OR ALTER PROCEDURE dbo.sp_ReleaseExternalIdReservation
@Kind nvarchar(16),
@Value nvarchar(64),
@ReleaseReason nvarchar(512)
AS
BEGIN
SET NOCOUNT ON;
SET XACT_ABORT ON;
IF @ReleaseReason IS NULL OR LEN(@ReleaseReason) = 0
BEGIN
RAISERROR('ReleaseReason is required', 16, 1);
RETURN;
END
UPDATE dbo.ExternalIdReservation
SET ReleasedAt = SYSUTCDATETIME(),
ReleasedBy = SUSER_SNAME(),
ReleaseReason = @ReleaseReason
WHERE Kind = @Kind AND Value = @Value AND ReleasedAt IS NULL;
IF @@ROWCOUNT = 0
BEGIN
RAISERROR('No active reservation found for (%s, %s)', 16, 1, @Kind, @Value);
RETURN;
END
-- Escape both caller-supplied values via STRING_ESCAPE so quotes/backslashes cannot break the
-- JSON document or inject additional structure into the audit record.
INSERT dbo.ConfigAuditLog (Principal, EventType, DetailsJson)
VALUES (SUSER_SNAME(), 'ExternalIdReleased',
CONCAT('{""kind"":""', STRING_ESCAPE(@Kind, 'json'),
'"",""value"":""', STRING_ESCAPE(@Value, 'json'), '""}'));
END
";
}
}
}
@@ -1,50 +0,0 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ZB.MOM.WW.OtOpcUa.Configuration.Migrations
{
/// <inheritdoc />
public partial class AddConfigAuditLogEventIdColumns : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<Guid>(
name: "CorrelationId",
table: "ConfigAuditLog",
type: "uniqueidentifier",
nullable: true);
migrationBuilder.AddColumn<Guid>(
name: "EventId",
table: "ConfigAuditLog",
type: "uniqueidentifier",
nullable: true);
migrationBuilder.CreateIndex(
name: "UX_ConfigAuditLog_EventId",
table: "ConfigAuditLog",
column: "EventId",
unique: true,
filter: "[EventId] IS NOT NULL");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropIndex(
name: "UX_ConfigAuditLog_EventId",
table: "ConfigAuditLog");
migrationBuilder.DropColumn(
name: "CorrelationId",
table: "ConfigAuditLog");
migrationBuilder.DropColumn(
name: "EventId",
table: "ConfigAuditLog");
}
}
}
@@ -1,39 +0,0 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ZB.MOM.WW.OtOpcUa.Configuration.Migrations
{
/// <summary>
/// Task 1.7 — canonicalizes the control-plane admin role VALUES persisted in the
/// <c>LdapGroupRoleMapping.Role</c> column. The column stores the <c>AdminRole</c> enum
/// member name as a string (EF <c>HasConversion&lt;string&gt;</c>, <c>nvarchar(32)</c>);
/// renaming the enum members (<c>ConfigViewer → Viewer</c>, <c>ConfigEditor → Designer</c>,
/// <c>FleetAdmin → Administrator</c>) therefore requires rewriting existing rows so the C#
/// enum and the stored strings stay in sync.
/// </summary>
/// <remarks>
/// This is a pure DATA migration: the schema (column type, length, indexes) is unchanged,
/// so the model snapshot is byte-identical to the prior migration. The new canonical strings
/// ("Viewer" = 6, "Designer" = 8, "Administrator" = 13 chars) all fit the existing
/// <c>nvarchar(32)</c> column. Enforcement semantics are preserved — it is a rename only.
/// </remarks>
public partial class CanonicalizeAdminRoles : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.Sql("UPDATE [LdapGroupRoleMapping] SET [Role] = N'Viewer' WHERE [Role] = N'ConfigViewer';");
migrationBuilder.Sql("UPDATE [LdapGroupRoleMapping] SET [Role] = N'Designer' WHERE [Role] = N'ConfigEditor';");
migrationBuilder.Sql("UPDATE [LdapGroupRoleMapping] SET [Role] = N'Administrator' WHERE [Role] = N'FleetAdmin';");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.Sql("UPDATE [LdapGroupRoleMapping] SET [Role] = N'FleetAdmin' WHERE [Role] = N'Administrator';");
migrationBuilder.Sql("UPDATE [LdapGroupRoleMapping] SET [Role] = N'ConfigEditor' WHERE [Role] = N'Designer';");
migrationBuilder.Sql("UPDATE [LdapGroupRoleMapping] SET [Role] = N'ConfigViewer' WHERE [Role] = N'Viewer';");
}
}
}
@@ -1,35 +0,0 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ZB.MOM.WW.OtOpcUa.Configuration.Migrations
{
/// <summary>
/// Task 2.2 — adds the nullable <c>Outcome</c> column to <c>ConfigAuditLog</c> for the
/// canonical <c>ZB.MOM.WW.Audit.AuditOutcome</c> (stored as its enum member name,
/// <c>nvarchar(16)</c>, mirroring how <c>AdminRole</c> is persisted). Purely additive:
/// nullable with no backfill, so existing rows and the bespoke stored-procedure audit
/// path (which does not derive an outcome) keep writing NULL.
/// </summary>
public partial class AddConfigAuditLogOutcome : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "Outcome",
table: "ConfigAuditLog",
type: "nvarchar(16)",
maxLength: 16,
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "Outcome",
table: "ConfigAuditLog");
}
}
}
@@ -1,42 +0,0 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ZB.MOM.WW.OtOpcUa.Configuration.Migrations
{
/// <inheritdoc />
public partial class NullableEquipmentDriverInstanceId : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AlterColumn<string>(
name: "DriverInstanceId",
table: "Equipment",
type: "nvarchar(64)",
maxLength: 64,
nullable: true,
oldClrType: typeof(string),
oldType: "nvarchar(64)",
oldMaxLength: 64);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
// WARNING: this rollback converts any existing NULL (driver-less) rows to "" for DriverInstanceId.
// Only safe when no driver-less equipment exists in the database.
migrationBuilder.AlterColumn<string>(
name: "DriverInstanceId",
table: "Equipment",
type: "nvarchar(64)",
maxLength: 64,
nullable: false,
defaultValue: "",
oldClrType: typeof(string),
oldType: "nvarchar(64)",
oldMaxLength: 64,
oldNullable: true);
}
}
}
@@ -1,181 +0,0 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ZB.MOM.WW.OtOpcUa.Configuration.Migrations
{
/// <summary>
/// Forward-only DATA-cleanup migration for the Galaxy clean break: the
/// <c>NamespaceKind.SystemPlatform</c> enum member was removed (Galaxy is now an
/// Equipment-kind driver). <c>Namespace.Kind</c> is persisted as a string via
/// <c>HasConversion&lt;string&gt;</c>, so any leftover row with <c>Kind = 'SystemPlatform'</c>
/// would throw at materialisation time (no matching enum member) the moment the new code
/// reads the table. This deletes those namespaces plus everything that hung off them.
/// </summary>
/// <remarks>
/// <para>
/// There is NO schema/model change in this migration — the model snapshot is unchanged.
/// The unique index <c>UX_Namespace_Cluster_Kind</c> (unique on <c>ClusterId, Kind</c>) is
/// intentionally KEPT: <c>Simulated</c> remains a reserved kind, so one-of-each-kind-per-cluster
/// is still the correct constraint.
/// </para>
/// <para>
/// The cross-table relationships in this graph are LOGICAL foreign keys (string Id columns,
/// e.g. <c>DriverInstance.NamespaceId</c> → <c>Namespace.NamespaceId</c>,
/// <c>Tag.DriverInstanceId</c> → <c>DriverInstance.DriverInstanceId</c>) enforced by
/// <c>sp_ValidateDraft</c>, NOT physical DB FK constraints — only the <c>*.ClusterId</c>
/// relationships are real DB FKs. So no FK constraint can be violated here. The deletes are
/// nonetheless ordered child→parent so no orphaned rows are left behind, and every statement
/// is keyed off the set of SystemPlatform namespaces, so on a DB with zero such rows every
/// DELETE simply affects 0 rows (idempotent / safe to run repeatedly).
/// </para>
/// <para>
/// Two tables carry LOGICAL refs with no physical FK and so were easy to miss: <c>NodeAcl</c>
/// (per-scope grants keyed by <c>ScopeKind</c> + <c>ScopeId</c>) is cleaned for the
/// Namespace/Equipment/FolderSegment/Tag scopes that get deleted, and
/// <c>ExternalIdReservation</c> (fleet-wide ZTag/SAPID reservations keyed by
/// <c>EquipmentUuid</c>) is RELEASED — not deleted — for the affected equipment so the
/// reserved external IDs free up while the audit row survives. Both run before the entity
/// rows they reference are removed so the id subqueries still resolve.
/// </para>
/// </remarks>
/// <inheritdoc />
public partial class CleanupSystemPlatformNamespaces : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
// Delete in child -> parent order. Everything is keyed off the set of namespaces whose
// Kind = 'SystemPlatform' (the string form persisted by HasConversion<string>), funnelled
// through the DriverInstances that lived in those namespaces.
migrationBuilder.Sql(@"
-- Defensive: SystemPlatform namespaces historically held only EquipmentId-NULL mirror tags, but a
-- stray equipment-scoped tag / Equipment could be bound to a SystemPlatform-namespace driver. Clean
-- both, plus anything that FKs into the affected Equipment (VirtualTag / ScriptedAlarm / state).
-- Logical-reference cleanup that must run FIRST -------------------------------------------
-- NodeAcl rows carry purely LOGICAL scope refs (ScopeKind + ScopeId, no physical FK). When the
-- scoped entity is deleted below the ScopeId dangles and the ACL evaluator would still match it,
-- silently granting/denying on a node that no longer exists. ScopeKind persists as a string via
-- HasConversion<string>, so the values below ('Namespace'/'Equipment'/'FolderSegment'/'Tag') are
-- the literal enum member names. These deletes must read the entity-id subqueries while those rows
-- still exist, hence they run before the entity DELETEs further down.
-- Namespace-scoped grants pointing at the SystemPlatform namespaces themselves.
DELETE FROM [NodeAcl]
WHERE [ScopeKind] = 'Namespace'
AND [ScopeId] IN (
SELECT [NamespaceId] FROM [Namespace] WHERE [Kind] = 'SystemPlatform');
-- Equipment- and FolderSegment-scoped grants pointing at Equipment that hangs off the affected
-- DriverInstances (both share Equipment.EquipmentId as the ScopeId).
DELETE FROM [NodeAcl]
WHERE [ScopeKind] IN ('Equipment', 'FolderSegment')
AND [ScopeId] IN (
SELECT [EquipmentId] FROM [Equipment]
WHERE [DriverInstanceId] IN (
SELECT [DriverInstanceId] FROM [DriverInstance]
WHERE [NamespaceId] IN (
SELECT [NamespaceId] FROM [Namespace] WHERE [Kind] = 'SystemPlatform')));
-- Tag-scoped grants pointing at Tags bound to the affected DriverInstances (ScopeId = Tag.TagId).
DELETE FROM [NodeAcl]
WHERE [ScopeKind] = 'Tag'
AND [ScopeId] IN (
SELECT [TagId] FROM [Tag]
WHERE [DriverInstanceId] IN (
SELECT [DriverInstanceId] FROM [DriverInstance]
WHERE [NamespaceId] IN (
SELECT [NamespaceId] FROM [Namespace] WHERE [Kind] = 'SystemPlatform')));
-- ExternalIdReservation is NOT generation-versioned and must outlive equipment deletion as a
-- RELEASE, not a delete: active rows (ReleasedAt IS NULL) hold a fleet-wide unique reservation of
-- the ZTag/SAPID under a filtered unique index. If we left them active after dropping the
-- Equipment, the same external IDs could never be reused. Release them so the IDs free up while the
-- audit row survives. Run BEFORE the Equipment DELETE so the EquipmentUuid subquery still resolves.
UPDATE [ExternalIdReservation]
SET [ReleasedAt] = SYSUTCDATETIME(),
[ReleasedBy] = 'CleanupSystemPlatformNamespaces',
[ReleaseReason] = 'Equipment removed by SystemPlatform-namespace cleanup migration (retired NamespaceKind).'
WHERE [ReleasedAt] IS NULL
AND [EquipmentUuid] IN (
SELECT [EquipmentUuid] FROM [Equipment]
WHERE [DriverInstanceId] IN (
SELECT [DriverInstanceId] FROM [DriverInstance]
WHERE [NamespaceId] IN (
SELECT [NamespaceId] FROM [Namespace] WHERE [Kind] = 'SystemPlatform')));
-- Grandchildren of the affected Equipment ------------------------------------------------
-- ScriptedAlarmState is keyed by ScriptedAlarm.ScriptedAlarmId; remove before its ScriptedAlarm.
DELETE FROM [ScriptedAlarmState]
WHERE [ScriptedAlarmId] IN (
SELECT [ScriptedAlarmId] FROM [ScriptedAlarm]
WHERE [EquipmentId] IN (
SELECT [EquipmentId] FROM [Equipment]
WHERE [DriverInstanceId] IN (
SELECT [DriverInstanceId] FROM [DriverInstance]
WHERE [NamespaceId] IN (
SELECT [NamespaceId] FROM [Namespace] WHERE [Kind] = 'SystemPlatform'))));
DELETE FROM [ScriptedAlarm]
WHERE [EquipmentId] IN (
SELECT [EquipmentId] FROM [Equipment]
WHERE [DriverInstanceId] IN (
SELECT [DriverInstanceId] FROM [DriverInstance]
WHERE [NamespaceId] IN (
SELECT [NamespaceId] FROM [Namespace] WHERE [Kind] = 'SystemPlatform')));
DELETE FROM [VirtualTag]
WHERE [EquipmentId] IN (
SELECT [EquipmentId] FROM [Equipment]
WHERE [DriverInstanceId] IN (
SELECT [DriverInstanceId] FROM [DriverInstance]
WHERE [NamespaceId] IN (
SELECT [NamespaceId] FROM [Namespace] WHERE [Kind] = 'SystemPlatform')));
-- Children of the affected DriverInstances ------------------------------------------------
-- Tags bound to those drivers: both EquipmentId-NULL mirror tags AND any equipment-scoped tag.
DELETE FROM [Tag]
WHERE [DriverInstanceId] IN (
SELECT [DriverInstanceId] FROM [DriverInstance]
WHERE [NamespaceId] IN (
SELECT [NamespaceId] FROM [Namespace] WHERE [Kind] = 'SystemPlatform'));
DELETE FROM [PollGroup]
WHERE [DriverInstanceId] IN (
SELECT [DriverInstanceId] FROM [DriverInstance]
WHERE [NamespaceId] IN (
SELECT [NamespaceId] FROM [Namespace] WHERE [Kind] = 'SystemPlatform'));
DELETE FROM [Device]
WHERE [DriverInstanceId] IN (
SELECT [DriverInstanceId] FROM [DriverInstance]
WHERE [NamespaceId] IN (
SELECT [NamespaceId] FROM [Namespace] WHERE [Kind] = 'SystemPlatform'));
DELETE FROM [Equipment]
WHERE [DriverInstanceId] IN (
SELECT [DriverInstanceId] FROM [DriverInstance]
WHERE [NamespaceId] IN (
SELECT [NamespaceId] FROM [Namespace] WHERE [Kind] = 'SystemPlatform'));
-- The DriverInstances themselves ----------------------------------------------------------
DELETE FROM [DriverInstance]
WHERE [NamespaceId] IN (
SELECT [NamespaceId] FROM [Namespace] WHERE [Kind] = 'SystemPlatform');
-- The SystemPlatform namespaces (the rows that would crash EF on read) --------------------
DELETE FROM [Namespace] WHERE [Kind] = 'SystemPlatform';
");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
// Forward-only data cleanup: the deleted SystemPlatform namespaces and their dependent
// rows cannot be reconstructed (the data is gone, and SystemPlatform is no longer a valid
// NamespaceKind). Intentionally a no-op rather than a throw, so rolling back a LATER
// migration does not blow up on this one.
}
}
}
@@ -12,8 +12,8 @@ using ZB.MOM.WW.OtOpcUa.Configuration;
namespace ZB.MOM.WW.OtOpcUa.Configuration.Migrations
{
[DbContext(typeof(OtOpcUaConfigDbContext))]
[Migration("20260526081556_V2HostingAlignment")]
partial class V2HostingAlignment
[Migration("20260715230637_V3Initial")]
partial class V3Initial
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
@@ -168,9 +168,15 @@ namespace ZB.MOM.WW.OtOpcUa.Configuration.Migrations
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.Property<Guid?>("CorrelationId")
.HasColumnType("uniqueidentifier");
b.Property<string>("DetailsJson")
.HasColumnType("nvarchar(max)");
b.Property<Guid?>("EventId")
.HasColumnType("uniqueidentifier");
b.Property<string>("EventType")
.IsRequired()
.HasMaxLength(64)
@@ -183,6 +189,10 @@ namespace ZB.MOM.WW.OtOpcUa.Configuration.Migrations
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.Property<string>("Outcome")
.HasMaxLength(16)
.HasColumnType("nvarchar(16)");
b.Property<string>("Principal")
.IsRequired()
.HasMaxLength(128)
@@ -195,6 +205,11 @@ namespace ZB.MOM.WW.OtOpcUa.Configuration.Migrations
b.HasKey("AuditId");
b.HasIndex("EventId")
.IsUnique()
.HasDatabaseName("UX_ConfigAuditLog_EventId")
.HasFilter("[EventId] IS NOT NULL");
b.HasIndex("GenerationId")
.HasDatabaseName("IX_ConfigAuditLog_Generation")
.HasFilter("[GenerationId] IS NOT NULL");
@@ -361,6 +376,10 @@ namespace ZB.MOM.WW.OtOpcUa.Configuration.Migrations
b.HasIndex("DriverInstanceId")
.HasDatabaseName("IX_Device_Driver");
b.HasIndex("DriverInstanceId", "Name")
.IsUnique()
.HasDatabaseName("UX_Device_Driver_Name");
b.ToTable("Device", null, t =>
{
t.HasCheckConstraint("CK_Device_DeviceConfig_IsJson", "ISJSON(DeviceConfig) = 1");
@@ -440,8 +459,7 @@ namespace ZB.MOM.WW.OtOpcUa.Configuration.Migrations
.HasMaxLength(128)
.HasColumnType("nvarchar(128)");
b.Property<string>("NamespaceId")
.IsRequired()
b.Property<string>("RawFolderId")
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
@@ -464,8 +482,18 @@ namespace ZB.MOM.WW.OtOpcUa.Configuration.Migrations
.HasDatabaseName("UX_DriverInstance_LogicalId")
.HasFilter("[DriverInstanceId] IS NOT NULL");
b.HasIndex("NamespaceId")
.HasDatabaseName("IX_DriverInstance_Namespace");
b.HasIndex("RawFolderId")
.HasDatabaseName("IX_DriverInstance_RawFolder");
b.HasIndex("ClusterId", "Name")
.IsUnique()
.HasDatabaseName("UX_DriverInstance_ClusterRoot_Name")
.HasFilter("[RawFolderId] IS NULL");
b.HasIndex("ClusterId", "RawFolderId", "Name")
.IsUnique()
.HasDatabaseName("UX_DriverInstance_Folder_Name")
.HasFilter("[RawFolderId] IS NOT NULL");
b.ToTable("DriverInstance", null, t =>
{
@@ -525,19 +553,10 @@ namespace ZB.MOM.WW.OtOpcUa.Configuration.Migrations
.HasMaxLength(256)
.HasColumnType("nvarchar(256)");
b.Property<string>("DeviceId")
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.Property<string>("DeviceManualUri")
.HasMaxLength(512)
.HasColumnType("nvarchar(512)");
b.Property<string>("DriverInstanceId")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.Property<bool>("Enabled")
.HasColumnType("bit");
@@ -610,9 +629,6 @@ namespace ZB.MOM.WW.OtOpcUa.Configuration.Migrations
b.HasKey("EquipmentRowId");
b.HasIndex("DriverInstanceId")
.HasDatabaseName("IX_Equipment_Driver");
b.HasIndex("EquipmentId")
.IsUnique()
.HasDatabaseName("UX_Equipment_LogicalId")
@@ -643,148 +659,6 @@ namespace ZB.MOM.WW.OtOpcUa.Configuration.Migrations
b.ToTable("Equipment", (string)null);
});
modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.EquipmentImportBatch", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier");
b.Property<string>("ClusterId")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.Property<DateTime>("CreatedAtUtc")
.HasColumnType("datetime2(3)");
b.Property<string>("CreatedBy")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("nvarchar(128)");
b.Property<DateTime?>("FinalisedAtUtc")
.HasColumnType("datetime2(3)");
b.Property<int>("RowsAccepted")
.HasColumnType("int");
b.Property<int>("RowsRejected")
.HasColumnType("int");
b.Property<int>("RowsStaged")
.HasColumnType("int");
b.HasKey("Id");
b.HasIndex("CreatedBy", "FinalisedAtUtc")
.HasDatabaseName("IX_EquipmentImportBatch_Creator_Finalised");
b.ToTable("EquipmentImportBatch", (string)null);
});
modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.EquipmentImportRow", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier");
b.Property<string>("AssetLocation")
.HasMaxLength(512)
.HasColumnType("nvarchar(512)");
b.Property<Guid>("BatchId")
.HasColumnType("uniqueidentifier");
b.Property<string>("DeviceManualUri")
.HasMaxLength(512)
.HasColumnType("nvarchar(512)");
b.Property<string>("EquipmentId")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.Property<string>("EquipmentUuid")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.Property<string>("HardwareRevision")
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.Property<bool>("IsAccepted")
.HasColumnType("bit");
b.Property<int>("LineNumberInFile")
.HasColumnType("int");
b.Property<string>("MachineCode")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("nvarchar(128)");
b.Property<string>("Manufacturer")
.HasMaxLength(256)
.HasColumnType("nvarchar(256)");
b.Property<string>("ManufacturerUri")
.HasMaxLength(512)
.HasColumnType("nvarchar(512)");
b.Property<string>("Model")
.HasMaxLength(256)
.HasColumnType("nvarchar(256)");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("nvarchar(128)");
b.Property<string>("RejectReason")
.HasMaxLength(512)
.HasColumnType("nvarchar(512)");
b.Property<string>("SAPID")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("nvarchar(128)");
b.Property<string>("SerialNumber")
.HasMaxLength(256)
.HasColumnType("nvarchar(256)");
b.Property<string>("SoftwareRevision")
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.Property<string>("UnsAreaName")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.Property<string>("UnsLineName")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.Property<string>("YearOfConstruction")
.HasMaxLength(8)
.HasColumnType("nvarchar(8)");
b.Property<string>("ZTag")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("nvarchar(128)");
b.HasKey("Id");
b.HasIndex("BatchId")
.HasDatabaseName("IX_EquipmentImportRow_Batch");
b.ToTable("EquipmentImportRow", (string)null);
});
modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ExternalIdReservation", b =>
{
b.Property<Guid>("ReservationId")
@@ -894,66 +768,6 @@ namespace ZB.MOM.WW.OtOpcUa.Configuration.Migrations
b.ToTable("LdapGroupRoleMapping", (string)null);
});
modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.Namespace", b =>
{
b.Property<Guid>("NamespaceRowId")
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier")
.HasDefaultValueSql("NEWSEQUENTIALID()");
b.Property<string>("ClusterId")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.Property<bool>("Enabled")
.HasColumnType("bit");
b.Property<string>("Kind")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("nvarchar(32)");
b.Property<string>("NamespaceId")
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.Property<string>("NamespaceUri")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("nvarchar(256)");
b.Property<string>("Notes")
.HasMaxLength(1024)
.HasColumnType("nvarchar(1024)");
b.Property<byte[]>("RowVersion")
.IsConcurrencyToken()
.IsRequired()
.ValueGeneratedOnAddOrUpdate()
.HasColumnType("rowversion");
b.HasKey("NamespaceRowId");
b.HasIndex("ClusterId")
.HasDatabaseName("IX_Namespace_Cluster");
b.HasIndex("NamespaceId")
.IsUnique()
.HasDatabaseName("UX_Namespace_LogicalId")
.HasFilter("[NamespaceId] IS NOT NULL");
b.HasIndex("NamespaceUri")
.IsUnique()
.HasDatabaseName("UX_Namespace_NamespaceUri");
b.HasIndex("ClusterId", "Kind")
.IsUnique()
.HasDatabaseName("UX_Namespace_Cluster_Kind");
b.ToTable("Namespace", (string)null);
});
modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.NodeAcl", b =>
{
b.Property<Guid>("NodeAclRowId")
@@ -1109,6 +923,63 @@ namespace ZB.MOM.WW.OtOpcUa.Configuration.Migrations
});
});
modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.RawFolder", b =>
{
b.Property<Guid>("RawFolderRowId")
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier")
.HasDefaultValueSql("NEWSEQUENTIALID()");
b.Property<string>("ClusterId")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("nvarchar(128)");
b.Property<string>("ParentRawFolderId")
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.Property<string>("RawFolderId")
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.Property<byte[]>("RowVersion")
.IsConcurrencyToken()
.IsRequired()
.ValueGeneratedOnAddOrUpdate()
.HasColumnType("rowversion");
b.Property<int>("SortOrder")
.HasColumnType("int");
b.HasKey("RawFolderRowId");
b.HasIndex("ClusterId")
.HasDatabaseName("IX_RawFolder_Cluster");
b.HasIndex("RawFolderId")
.IsUnique()
.HasDatabaseName("UX_RawFolder_LogicalId")
.HasFilter("[RawFolderId] IS NOT NULL");
b.HasIndex("ClusterId", "Name")
.IsUnique()
.HasDatabaseName("UX_RawFolder_ClusterRoot_Name")
.HasFilter("[ParentRawFolderId] IS NULL");
b.HasIndex("ClusterId", "ParentRawFolderId", "Name")
.IsUnique()
.HasDatabaseName("UX_RawFolder_Parent_Name")
.HasFilter("[ParentRawFolderId] IS NOT NULL");
b.ToTable("RawFolder", (string)null);
});
modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.Script", b =>
{
b.Property<Guid>("ScriptRowId")
@@ -1388,22 +1259,10 @@ namespace ZB.MOM.WW.OtOpcUa.Configuration.Migrations
.HasColumnType("nvarchar(32)");
b.Property<string>("DeviceId")
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.Property<string>("DriverInstanceId")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.Property<string>("EquipmentId")
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.Property<string>("FolderPath")
.HasMaxLength(512)
.HasColumnType("nvarchar(512)");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(128)
@@ -1423,6 +1282,10 @@ namespace ZB.MOM.WW.OtOpcUa.Configuration.Migrations
.IsRequired()
.HasColumnType("nvarchar(max)");
b.Property<string>("TagGroupId")
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.Property<string>("TagId")
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
@@ -1432,27 +1295,23 @@ namespace ZB.MOM.WW.OtOpcUa.Configuration.Migrations
b.HasKey("TagRowId");
b.HasIndex("EquipmentId")
.HasDatabaseName("IX_Tag_Equipment")
.HasFilter("[EquipmentId] IS NOT NULL");
b.HasIndex("TagId")
.IsUnique()
.HasDatabaseName("UX_Tag_LogicalId")
.HasFilter("[TagId] IS NOT NULL");
b.HasIndex("DriverInstanceId", "DeviceId")
.HasDatabaseName("IX_Tag_Driver_Device");
b.HasIndex("EquipmentId", "Name")
b.HasIndex("DeviceId", "Name")
.IsUnique()
.HasDatabaseName("UX_Tag_EquipmentPath")
.HasFilter("[EquipmentId] IS NOT NULL");
.HasDatabaseName("UX_Tag_Device_Name")
.HasFilter("[TagGroupId] IS NULL");
b.HasIndex("DriverInstanceId", "FolderPath", "Name")
b.HasIndex("DeviceId", "TagGroupId")
.HasDatabaseName("IX_Tag_Device");
b.HasIndex("DeviceId", "TagGroupId", "Name")
.IsUnique()
.HasDatabaseName("UX_Tag_FolderPath")
.HasFilter("[EquipmentId] IS NULL");
.HasDatabaseName("UX_Tag_Group_Name")
.HasFilter("[TagGroupId] IS NOT NULL");
b.ToTable("Tag", null, t =>
{
@@ -1460,6 +1319,63 @@ namespace ZB.MOM.WW.OtOpcUa.Configuration.Migrations
});
});
modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.TagGroup", b =>
{
b.Property<Guid>("TagGroupRowId")
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier")
.HasDefaultValueSql("NEWSEQUENTIALID()");
b.Property<string>("DeviceId")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("nvarchar(128)");
b.Property<string>("ParentTagGroupId")
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.Property<byte[]>("RowVersion")
.IsConcurrencyToken()
.IsRequired()
.ValueGeneratedOnAddOrUpdate()
.HasColumnType("rowversion");
b.Property<int>("SortOrder")
.HasColumnType("int");
b.Property<string>("TagGroupId")
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.HasKey("TagGroupRowId");
b.HasIndex("DeviceId")
.HasDatabaseName("IX_TagGroup_Device");
b.HasIndex("TagGroupId")
.IsUnique()
.HasDatabaseName("UX_TagGroup_LogicalId")
.HasFilter("[TagGroupId] IS NOT NULL");
b.HasIndex("DeviceId", "Name")
.IsUnique()
.HasDatabaseName("UX_TagGroup_DeviceRoot_Name")
.HasFilter("[ParentTagGroupId] IS NULL");
b.HasIndex("DeviceId", "ParentTagGroupId", "Name")
.IsUnique()
.HasDatabaseName("UX_TagGroup_Parent_Name")
.HasFilter("[ParentTagGroupId] IS NOT NULL");
b.ToTable("TagGroup", (string)null);
});
modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.UnsArea", b =>
{
b.Property<Guid>("UnsAreaRowId")
@@ -1556,6 +1472,60 @@ namespace ZB.MOM.WW.OtOpcUa.Configuration.Migrations
b.ToTable("UnsLine", (string)null);
});
modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.UnsTagReference", b =>
{
b.Property<Guid>("UnsTagReferenceRowId")
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier")
.HasDefaultValueSql("NEWSEQUENTIALID()");
b.Property<string>("DisplayNameOverride")
.HasMaxLength(128)
.HasColumnType("nvarchar(128)");
b.Property<string>("EquipmentId")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.Property<byte[]>("RowVersion")
.IsConcurrencyToken()
.IsRequired()
.ValueGeneratedOnAddOrUpdate()
.HasColumnType("rowversion");
b.Property<int>("SortOrder")
.HasColumnType("int");
b.Property<string>("TagId")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.Property<string>("UnsTagReferenceId")
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.HasKey("UnsTagReferenceRowId");
b.HasIndex("EquipmentId")
.HasDatabaseName("IX_UnsTagReference_Equipment");
b.HasIndex("TagId")
.HasDatabaseName("IX_UnsTagReference_Tag");
b.HasIndex("UnsTagReferenceId")
.IsUnique()
.HasDatabaseName("UX_UnsTagReference_LogicalId")
.HasFilter("[UnsTagReferenceId] IS NOT NULL");
b.HasIndex("EquipmentId", "TagId")
.IsUnique()
.HasDatabaseName("UX_UnsTagReference_Equip_Tag");
b.ToTable("UnsTagReference", (string)null);
});
modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.VirtualTag", b =>
{
b.Property<Guid>("VirtualTagRowId")
@@ -1660,17 +1630,6 @@ namespace ZB.MOM.WW.OtOpcUa.Configuration.Migrations
b.Navigation("Cluster");
});
modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.EquipmentImportRow", b =>
{
b.HasOne("ZB.MOM.WW.OtOpcUa.Configuration.Entities.EquipmentImportBatch", "Batch")
.WithMany("Rows")
.HasForeignKey("BatchId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Batch");
});
modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.LdapGroupRoleMapping", b =>
{
b.HasOne("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ServerCluster", "Cluster")
@@ -1681,17 +1640,6 @@ namespace ZB.MOM.WW.OtOpcUa.Configuration.Migrations
b.Navigation("Cluster");
});
modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.Namespace", b =>
{
b.HasOne("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ServerCluster", "Cluster")
.WithMany("Namespaces")
.HasForeignKey("ClusterId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("Cluster");
});
modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.NodeDeploymentState", b =>
{
b.HasOne("ZB.MOM.WW.OtOpcUa.Configuration.Entities.Deployment", "Deployment")
@@ -1711,6 +1659,15 @@ namespace ZB.MOM.WW.OtOpcUa.Configuration.Migrations
b.Navigation("Node");
});
modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.RawFolder", b =>
{
b.HasOne("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ServerCluster", null)
.WithMany("RawFolders")
.HasForeignKey("ClusterId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
});
modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.UnsArea", b =>
{
b.HasOne("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ServerCluster", "Cluster")
@@ -1727,16 +1684,11 @@ namespace ZB.MOM.WW.OtOpcUa.Configuration.Migrations
b.Navigation("Credentials");
});
modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.EquipmentImportBatch", b =>
{
b.Navigation("Rows");
});
modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ServerCluster", b =>
{
b.Navigation("Namespaces");
b.Navigation("Nodes");
b.Navigation("RawFolders");
});
#pragma warning restore 612, 618
}
@@ -0,0 +1,285 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ZB.MOM.WW.OtOpcUa.Configuration.Migrations
{
/// <summary>
/// Hand-authored SQL squashed into <c>V3Initial</c> from the retired per-migration proc files
/// (<c>*_StoredProcedures.cs</c>, <c>*_ExtendComputeGenerationDiffWith*.cs</c>) and
/// <c>AuthorizationGrants.cs</c>.
/// </summary>
/// <remarks>
/// <para>
/// <b>Port audit (v3 WP1).</b> The v1 stored procs were built entirely on the
/// <c>ConfigGeneration</c> / <c>GenerationId</c> / <c>ClusterNodeGenerationState</c>
/// generation model, which <c>V2HostingAlignment</c> (2026-05-26) dropped outright — so
/// those procs have been vestigial since v2 (SQL Server's deferred name resolution let them
/// keep existing while referencing phantom tables). No production code calls them; the v2/v3
/// deploy + diff path is entirely C# (ControlPlane <c>ConfigComposer</c>,
/// <c>AddressSpaceChangeClassifier</c>, <c>DraftValidator</c>). Their only consumer is
/// <c>AuthorizationTests</c>, which exercises the DB-role EXECUTE grants and expects
/// RAISERROR / permission failures — never a successful data operation.
/// </para>
/// <para>
/// Under greenfield v3 the two-role authorization model (<c>OtOpcUaNode</c> /
/// <c>OtOpcUaAdmin</c> + EXECUTE grants) is <b>retained</b> as a real security feature, so the
/// eight proc <b>names</b> must exist for the grants + tests to bind. Each proc is rewritten
/// here to be schema-valid against the v3 schema (references NO dropped table/column):
/// </para>
/// <list type="bullet">
/// <item><c>sp_GetCurrentGenerationForCluster</c> keeps its <c>ClusterNodeCredential</c> +
/// <c>ClusterNode</c> "Unauthorized" auth check (both tables retained), then RAISERRORs a
/// v3-retirement message — preserving the exact behavior AuthorizationTests asserts.</item>
/// <item><c>sp_PublishGeneration</c> is granted to <c>OtOpcUaAdmin</c> only and RAISERRORs a
/// retirement message (never the word "permission"), so the Node-denied vs Admin-allowed
/// grant assertions still hold.</item>
/// <item><c>sp_ComputeGenerationDiff</c> is rewritten to reference the v3 tables
/// (RawFolder / DriverInstance / Device / TagGroup / Tag / Equipment / UnsTagReference /
/// NodeAcl) as a schema-valid current-state signature inventory. A genuine
/// generation-to-generation diff is architecturally impossible now (no GenerationId column);
/// the real diff lives in the C# ControlPlane.</item>
/// <item><c>sp_ReleaseExternalIdReservation</c> is ported verbatim — it only touches retained
/// tables (<c>ExternalIdReservation</c>, <c>ConfigAuditLog</c>).</item>
/// </list>
/// </remarks>
public partial class V3Initial
{
private static void CreateStoredProcedures(MigrationBuilder migrationBuilder)
{
migrationBuilder.Sql(GetCurrentGenerationForCluster);
migrationBuilder.Sql(GetGenerationContent);
migrationBuilder.Sql(RegisterNodeGenerationApplied);
migrationBuilder.Sql(ValidateDraft);
migrationBuilder.Sql(PublishGeneration);
migrationBuilder.Sql(RollbackToGeneration);
migrationBuilder.Sql(ComputeGenerationDiff);
migrationBuilder.Sql(ReleaseExternalIdReservation);
}
private static void DropStoredProcedures(MigrationBuilder migrationBuilder)
{
foreach (var name in new[]
{
"sp_ReleaseExternalIdReservation", "sp_ComputeGenerationDiff", "sp_RollbackToGeneration",
"sp_PublishGeneration", "sp_ValidateDraft", "sp_RegisterNodeGenerationApplied",
"sp_GetGenerationContent", "sp_GetCurrentGenerationForCluster",
})
{
migrationBuilder.Sql($"IF OBJECT_ID(N'dbo.{name}', N'P') IS NOT NULL DROP PROCEDURE dbo.{name};");
}
}
private static void CreateAuthorizationGrants(MigrationBuilder migrationBuilder)
{
migrationBuilder.Sql(@"
IF DATABASE_PRINCIPAL_ID('OtOpcUaNode') IS NULL
CREATE ROLE OtOpcUaNode;
IF DATABASE_PRINCIPAL_ID('OtOpcUaAdmin') IS NULL
CREATE ROLE OtOpcUaAdmin;
");
migrationBuilder.Sql(@"
GRANT EXECUTE ON OBJECT::dbo.sp_GetCurrentGenerationForCluster TO OtOpcUaNode;
GRANT EXECUTE ON OBJECT::dbo.sp_GetGenerationContent TO OtOpcUaNode;
GRANT EXECUTE ON OBJECT::dbo.sp_RegisterNodeGenerationApplied TO OtOpcUaNode;
GRANT EXECUTE ON OBJECT::dbo.sp_GetCurrentGenerationForCluster TO OtOpcUaAdmin;
GRANT EXECUTE ON OBJECT::dbo.sp_GetGenerationContent TO OtOpcUaAdmin;
GRANT EXECUTE ON OBJECT::dbo.sp_ValidateDraft TO OtOpcUaAdmin;
GRANT EXECUTE ON OBJECT::dbo.sp_PublishGeneration TO OtOpcUaAdmin;
GRANT EXECUTE ON OBJECT::dbo.sp_RollbackToGeneration TO OtOpcUaAdmin;
GRANT EXECUTE ON OBJECT::dbo.sp_ComputeGenerationDiff TO OtOpcUaAdmin;
GRANT EXECUTE ON OBJECT::dbo.sp_ReleaseExternalIdReservation TO OtOpcUaAdmin;
DENY UPDATE, DELETE, INSERT ON SCHEMA::dbo TO OtOpcUaNode;
DENY UPDATE, DELETE, INSERT ON SCHEMA::dbo TO OtOpcUaAdmin;
DENY SELECT ON SCHEMA::dbo TO OtOpcUaNode;
-- Admins may SELECT for reporting views in the future grant views explicitly, not the schema.
DENY SELECT ON SCHEMA::dbo TO OtOpcUaAdmin;
");
}
private static void DropAuthorizationGrants(MigrationBuilder migrationBuilder)
{
migrationBuilder.Sql(@"
IF DATABASE_PRINCIPAL_ID('OtOpcUaNode') IS NOT NULL
DROP ROLE OtOpcUaNode;
IF DATABASE_PRINCIPAL_ID('OtOpcUaAdmin') IS NOT NULL
DROP ROLE OtOpcUaAdmin;
");
}
// ------------------------------------------------------------------------------------------
// v3 retirement stubs. The generation model is gone (see class remarks); these preserve the
// authorization surface + the AuthorizationTests behaviors while referencing only v3 tables.
// ------------------------------------------------------------------------------------------
private const string GetCurrentGenerationForCluster = @"
CREATE OR ALTER PROCEDURE dbo.sp_GetCurrentGenerationForCluster
@NodeId nvarchar(64),
@ClusterId nvarchar(64)
AS
BEGIN
SET NOCOUNT ON;
-- Auth surface retained verbatim (ClusterNodeCredential + ClusterNode are v3 tables). An
-- unbound caller still gets 'Unauthorized', which AuthorizationTests asserts.
DECLARE @Caller nvarchar(128) = SUSER_SNAME();
IF NOT EXISTS (
SELECT 1 FROM dbo.ClusterNodeCredential
WHERE NodeId = @NodeId AND Value = @Caller AND Enabled = 1)
BEGIN
RAISERROR('Unauthorized: caller %s is not bound to NodeId %s', 16, 1, @Caller, @NodeId);
RETURN;
END
IF NOT EXISTS (
SELECT 1 FROM dbo.ClusterNode
WHERE NodeId = @NodeId AND ClusterId = @ClusterId AND Enabled = 1)
BEGIN
RAISERROR('Forbidden: NodeId %s does not belong to ClusterId %s', 16, 1, @NodeId, @ClusterId);
RETURN;
END
RAISERROR('sp_GetCurrentGenerationForCluster is retired under v3: config/deploy state moved to the C# ControlPlane (Deployment table).', 16, 1);
END
";
private const string GetGenerationContent = @"
CREATE OR ALTER PROCEDURE dbo.sp_GetGenerationContent
@NodeId nvarchar(64),
@GenerationId bigint
AS
BEGIN
SET NOCOUNT ON;
RAISERROR('sp_GetGenerationContent is retired under v3: generation content is sealed into Deployment.ArtifactBlob by the C# ControlPlane.', 16, 1);
END
";
private const string RegisterNodeGenerationApplied = @"
CREATE OR ALTER PROCEDURE dbo.sp_RegisterNodeGenerationApplied
@NodeId nvarchar(64),
@GenerationId bigint,
@Status nvarchar(16),
@Error nvarchar(max) = NULL
AS
BEGIN
SET NOCOUNT ON;
RAISERROR('sp_RegisterNodeGenerationApplied is retired under v3: node-apply state is tracked in NodeDeploymentState by the C# ControlPlane.', 16, 1);
END
";
private const string ValidateDraft = @"
CREATE OR ALTER PROCEDURE dbo.sp_ValidateDraft
@DraftGenerationId bigint
AS
BEGIN
SET NOCOUNT ON;
RAISERROR('sp_ValidateDraft is retired under v3: draft validation runs in the managed DraftValidator (Configuration.Validation).', 16, 1);
END
";
private const string PublishGeneration = @"
CREATE OR ALTER PROCEDURE dbo.sp_PublishGeneration
@ClusterId nvarchar(64),
@DraftGenerationId bigint,
@Notes nvarchar(1024) = NULL
AS
BEGIN
SET NOCOUNT ON;
-- Message intentionally omits the word 'permission' so AuthorizationTests can distinguish a
-- grant failure (Node role) from a body-level error (Admin role reaching the proc body).
RAISERROR('sp_PublishGeneration is retired under v3: publishing is done by the C# ControlPlane (ConfigComposer + Deployment).', 16, 1);
END
";
private const string RollbackToGeneration = @"
CREATE OR ALTER PROCEDURE dbo.sp_RollbackToGeneration
@ClusterId nvarchar(64),
@TargetGenerationId bigint,
@Notes nvarchar(1024) = NULL
AS
BEGIN
SET NOCOUNT ON;
RAISERROR('sp_RollbackToGeneration is retired under v3: rollback is a C# ControlPlane operation over the Deployment history.', 16, 1);
END
";
// Rewritten to the v3 schema. There is no GenerationId to diff (the per-generation table model
// was dropped in v2), so this emits a current-state signature inventory across the v3 tables —
// referencing RawFolder, DriverInstance, Device, TagGroup, Tag, Equipment, UnsTagReference, and
// NodeAcl. The genuine generation-to-generation diff now lives in the C# ControlPlane
// (AddressSpaceChangeClassifier); this proc is retained only to carry the OtOpcUaAdmin EXECUTE
// grant and to keep the v3 table surface referenced from SQL.
private const string ComputeGenerationDiff = @"
CREATE OR ALTER PROCEDURE dbo.sp_ComputeGenerationDiff
@FromGenerationId bigint,
@ToGenerationId bigint
AS
BEGIN
SET NOCOUNT ON;
-- @FromGenerationId / @ToGenerationId are ignored: the v3 config tables are single live-state
-- (no GenerationId column). Emit a current-state signature per row so the shape (TableName,
-- LogicalId, Sig) still round-trips through any legacy caller.
SELECT TableName, LogicalId, Sig FROM (
SELECT 'RawFolder' AS TableName, RawFolderId AS LogicalId, CHECKSUM(ClusterId, ParentRawFolderId, Name, SortOrder) AS Sig FROM dbo.RawFolder
UNION ALL
SELECT 'DriverInstance', DriverInstanceId, CHECKSUM(ClusterId, RawFolderId, Name, DriverType, Enabled, CONVERT(varchar(max), DriverConfig)) FROM dbo.DriverInstance
UNION ALL
SELECT 'Device', DeviceId, CHECKSUM(DriverInstanceId, Name, Enabled, CONVERT(varchar(max), DeviceConfig)) FROM dbo.Device
UNION ALL
SELECT 'TagGroup', TagGroupId, CHECKSUM(DeviceId, ParentTagGroupId, Name, SortOrder) FROM dbo.TagGroup
UNION ALL
SELECT 'Tag', TagId, CHECKSUM(DeviceId, TagGroupId, PollGroupId, Name, DataType, AccessLevel, WriteIdempotent, CONVERT(varchar(max), TagConfig)) FROM dbo.Tag
UNION ALL
SELECT 'Equipment', EquipmentId, CHECKSUM(EquipmentUuid, UnsLineId, Name, MachineCode, ZTag, SAPID, EquipmentClassRef, Manufacturer, Model, SerialNumber) FROM dbo.Equipment
UNION ALL
SELECT 'UnsTagReference', UnsTagReferenceId, CHECKSUM(EquipmentId, TagId, DisplayNameOverride, SortOrder) FROM dbo.UnsTagReference
UNION ALL
SELECT 'NodeAcl', NodeAclId, CHECKSUM(ClusterId, LdapGroup, ScopeKind, ScopeId, PermissionFlags, Notes) FROM dbo.NodeAcl
) AS inventory;
END
";
// Ported verbatim — touches only retained tables (ExternalIdReservation, ConfigAuditLog).
private const string ReleaseExternalIdReservation = @"
CREATE OR ALTER PROCEDURE dbo.sp_ReleaseExternalIdReservation
@Kind nvarchar(16),
@Value nvarchar(64),
@ReleaseReason nvarchar(512)
AS
BEGIN
SET NOCOUNT ON;
SET XACT_ABORT ON;
IF @ReleaseReason IS NULL OR LEN(@ReleaseReason) = 0
BEGIN
RAISERROR('ReleaseReason is required', 16, 1);
RETURN;
END
UPDATE dbo.ExternalIdReservation
SET ReleasedAt = SYSUTCDATETIME(),
ReleasedBy = SUSER_SNAME(),
ReleaseReason = @ReleaseReason
WHERE Kind = @Kind AND Value = @Value AND ReleasedAt IS NULL;
IF @@ROWCOUNT = 0
BEGIN
RAISERROR('No active reservation found for (%s, %s)', 16, 1, @Kind, @Value);
RETURN;
END
-- Escape both caller-supplied values via STRING_ESCAPE so quotes/backslashes cannot break the
-- JSON document or inject additional structure into the audit record.
INSERT dbo.ConfigAuditLog (Principal, EventType, DetailsJson)
VALUES (SUSER_SNAME(), 'ExternalIdReleased',
CONCAT('{""kind"":""', STRING_ESCAPE(@Kind, 'json'),
'"",""value"":""', STRING_ESCAPE(@Value, 'json'), '""}'));
END
";
}
}
File diff suppressed because it is too large Load Diff
@@ -373,6 +373,10 @@ namespace ZB.MOM.WW.OtOpcUa.Configuration.Migrations
b.HasIndex("DriverInstanceId")
.HasDatabaseName("IX_Device_Driver");
b.HasIndex("DriverInstanceId", "Name")
.IsUnique()
.HasDatabaseName("UX_Device_Driver_Name");
b.ToTable("Device", null, t =>
{
t.HasCheckConstraint("CK_Device_DeviceConfig_IsJson", "ISJSON(DeviceConfig) = 1");
@@ -452,8 +456,7 @@ namespace ZB.MOM.WW.OtOpcUa.Configuration.Migrations
.HasMaxLength(128)
.HasColumnType("nvarchar(128)");
b.Property<string>("NamespaceId")
.IsRequired()
b.Property<string>("RawFolderId")
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
@@ -476,8 +479,18 @@ namespace ZB.MOM.WW.OtOpcUa.Configuration.Migrations
.HasDatabaseName("UX_DriverInstance_LogicalId")
.HasFilter("[DriverInstanceId] IS NOT NULL");
b.HasIndex("NamespaceId")
.HasDatabaseName("IX_DriverInstance_Namespace");
b.HasIndex("RawFolderId")
.HasDatabaseName("IX_DriverInstance_RawFolder");
b.HasIndex("ClusterId", "Name")
.IsUnique()
.HasDatabaseName("UX_DriverInstance_ClusterRoot_Name")
.HasFilter("[RawFolderId] IS NULL");
b.HasIndex("ClusterId", "RawFolderId", "Name")
.IsUnique()
.HasDatabaseName("UX_DriverInstance_Folder_Name")
.HasFilter("[RawFolderId] IS NOT NULL");
b.ToTable("DriverInstance", null, t =>
{
@@ -537,18 +550,10 @@ namespace ZB.MOM.WW.OtOpcUa.Configuration.Migrations
.HasMaxLength(256)
.HasColumnType("nvarchar(256)");
b.Property<string>("DeviceId")
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.Property<string>("DeviceManualUri")
.HasMaxLength(512)
.HasColumnType("nvarchar(512)");
b.Property<string>("DriverInstanceId")
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.Property<bool>("Enabled")
.HasColumnType("bit");
@@ -621,9 +626,6 @@ namespace ZB.MOM.WW.OtOpcUa.Configuration.Migrations
b.HasKey("EquipmentRowId");
b.HasIndex("DriverInstanceId")
.HasDatabaseName("IX_Equipment_Driver");
b.HasIndex("EquipmentId")
.IsUnique()
.HasDatabaseName("UX_Equipment_LogicalId")
@@ -654,148 +656,6 @@ namespace ZB.MOM.WW.OtOpcUa.Configuration.Migrations
b.ToTable("Equipment", (string)null);
});
modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.EquipmentImportBatch", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier");
b.Property<string>("ClusterId")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.Property<DateTime>("CreatedAtUtc")
.HasColumnType("datetime2(3)");
b.Property<string>("CreatedBy")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("nvarchar(128)");
b.Property<DateTime?>("FinalisedAtUtc")
.HasColumnType("datetime2(3)");
b.Property<int>("RowsAccepted")
.HasColumnType("int");
b.Property<int>("RowsRejected")
.HasColumnType("int");
b.Property<int>("RowsStaged")
.HasColumnType("int");
b.HasKey("Id");
b.HasIndex("CreatedBy", "FinalisedAtUtc")
.HasDatabaseName("IX_EquipmentImportBatch_Creator_Finalised");
b.ToTable("EquipmentImportBatch", (string)null);
});
modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.EquipmentImportRow", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier");
b.Property<string>("AssetLocation")
.HasMaxLength(512)
.HasColumnType("nvarchar(512)");
b.Property<Guid>("BatchId")
.HasColumnType("uniqueidentifier");
b.Property<string>("DeviceManualUri")
.HasMaxLength(512)
.HasColumnType("nvarchar(512)");
b.Property<string>("EquipmentId")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.Property<string>("EquipmentUuid")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.Property<string>("HardwareRevision")
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.Property<bool>("IsAccepted")
.HasColumnType("bit");
b.Property<int>("LineNumberInFile")
.HasColumnType("int");
b.Property<string>("MachineCode")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("nvarchar(128)");
b.Property<string>("Manufacturer")
.HasMaxLength(256)
.HasColumnType("nvarchar(256)");
b.Property<string>("ManufacturerUri")
.HasMaxLength(512)
.HasColumnType("nvarchar(512)");
b.Property<string>("Model")
.HasMaxLength(256)
.HasColumnType("nvarchar(256)");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("nvarchar(128)");
b.Property<string>("RejectReason")
.HasMaxLength(512)
.HasColumnType("nvarchar(512)");
b.Property<string>("SAPID")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("nvarchar(128)");
b.Property<string>("SerialNumber")
.HasMaxLength(256)
.HasColumnType("nvarchar(256)");
b.Property<string>("SoftwareRevision")
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.Property<string>("UnsAreaName")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.Property<string>("UnsLineName")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.Property<string>("YearOfConstruction")
.HasMaxLength(8)
.HasColumnType("nvarchar(8)");
b.Property<string>("ZTag")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("nvarchar(128)");
b.HasKey("Id");
b.HasIndex("BatchId")
.HasDatabaseName("IX_EquipmentImportRow_Batch");
b.ToTable("EquipmentImportRow", (string)null);
});
modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ExternalIdReservation", b =>
{
b.Property<Guid>("ReservationId")
@@ -905,66 +765,6 @@ namespace ZB.MOM.WW.OtOpcUa.Configuration.Migrations
b.ToTable("LdapGroupRoleMapping", (string)null);
});
modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.Namespace", b =>
{
b.Property<Guid>("NamespaceRowId")
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier")
.HasDefaultValueSql("NEWSEQUENTIALID()");
b.Property<string>("ClusterId")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.Property<bool>("Enabled")
.HasColumnType("bit");
b.Property<string>("Kind")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("nvarchar(32)");
b.Property<string>("NamespaceId")
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.Property<string>("NamespaceUri")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("nvarchar(256)");
b.Property<string>("Notes")
.HasMaxLength(1024)
.HasColumnType("nvarchar(1024)");
b.Property<byte[]>("RowVersion")
.IsConcurrencyToken()
.IsRequired()
.ValueGeneratedOnAddOrUpdate()
.HasColumnType("rowversion");
b.HasKey("NamespaceRowId");
b.HasIndex("ClusterId")
.HasDatabaseName("IX_Namespace_Cluster");
b.HasIndex("NamespaceId")
.IsUnique()
.HasDatabaseName("UX_Namespace_LogicalId")
.HasFilter("[NamespaceId] IS NOT NULL");
b.HasIndex("NamespaceUri")
.IsUnique()
.HasDatabaseName("UX_Namespace_NamespaceUri");
b.HasIndex("ClusterId", "Kind")
.IsUnique()
.HasDatabaseName("UX_Namespace_Cluster_Kind");
b.ToTable("Namespace", (string)null);
});
modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.NodeAcl", b =>
{
b.Property<Guid>("NodeAclRowId")
@@ -1120,6 +920,63 @@ namespace ZB.MOM.WW.OtOpcUa.Configuration.Migrations
});
});
modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.RawFolder", b =>
{
b.Property<Guid>("RawFolderRowId")
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier")
.HasDefaultValueSql("NEWSEQUENTIALID()");
b.Property<string>("ClusterId")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("nvarchar(128)");
b.Property<string>("ParentRawFolderId")
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.Property<string>("RawFolderId")
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.Property<byte[]>("RowVersion")
.IsConcurrencyToken()
.IsRequired()
.ValueGeneratedOnAddOrUpdate()
.HasColumnType("rowversion");
b.Property<int>("SortOrder")
.HasColumnType("int");
b.HasKey("RawFolderRowId");
b.HasIndex("ClusterId")
.HasDatabaseName("IX_RawFolder_Cluster");
b.HasIndex("RawFolderId")
.IsUnique()
.HasDatabaseName("UX_RawFolder_LogicalId")
.HasFilter("[RawFolderId] IS NOT NULL");
b.HasIndex("ClusterId", "Name")
.IsUnique()
.HasDatabaseName("UX_RawFolder_ClusterRoot_Name")
.HasFilter("[ParentRawFolderId] IS NULL");
b.HasIndex("ClusterId", "ParentRawFolderId", "Name")
.IsUnique()
.HasDatabaseName("UX_RawFolder_Parent_Name")
.HasFilter("[ParentRawFolderId] IS NOT NULL");
b.ToTable("RawFolder", (string)null);
});
modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.Script", b =>
{
b.Property<Guid>("ScriptRowId")
@@ -1399,22 +1256,10 @@ namespace ZB.MOM.WW.OtOpcUa.Configuration.Migrations
.HasColumnType("nvarchar(32)");
b.Property<string>("DeviceId")
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.Property<string>("DriverInstanceId")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.Property<string>("EquipmentId")
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.Property<string>("FolderPath")
.HasMaxLength(512)
.HasColumnType("nvarchar(512)");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(128)
@@ -1434,6 +1279,10 @@ namespace ZB.MOM.WW.OtOpcUa.Configuration.Migrations
.IsRequired()
.HasColumnType("nvarchar(max)");
b.Property<string>("TagGroupId")
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.Property<string>("TagId")
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
@@ -1443,27 +1292,23 @@ namespace ZB.MOM.WW.OtOpcUa.Configuration.Migrations
b.HasKey("TagRowId");
b.HasIndex("EquipmentId")
.HasDatabaseName("IX_Tag_Equipment")
.HasFilter("[EquipmentId] IS NOT NULL");
b.HasIndex("TagId")
.IsUnique()
.HasDatabaseName("UX_Tag_LogicalId")
.HasFilter("[TagId] IS NOT NULL");
b.HasIndex("DriverInstanceId", "DeviceId")
.HasDatabaseName("IX_Tag_Driver_Device");
b.HasIndex("EquipmentId", "Name")
b.HasIndex("DeviceId", "Name")
.IsUnique()
.HasDatabaseName("UX_Tag_EquipmentPath")
.HasFilter("[EquipmentId] IS NOT NULL");
.HasDatabaseName("UX_Tag_Device_Name")
.HasFilter("[TagGroupId] IS NULL");
b.HasIndex("DriverInstanceId", "FolderPath", "Name")
b.HasIndex("DeviceId", "TagGroupId")
.HasDatabaseName("IX_Tag_Device");
b.HasIndex("DeviceId", "TagGroupId", "Name")
.IsUnique()
.HasDatabaseName("UX_Tag_FolderPath")
.HasFilter("[EquipmentId] IS NULL");
.HasDatabaseName("UX_Tag_Group_Name")
.HasFilter("[TagGroupId] IS NOT NULL");
b.ToTable("Tag", null, t =>
{
@@ -1471,6 +1316,63 @@ namespace ZB.MOM.WW.OtOpcUa.Configuration.Migrations
});
});
modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.TagGroup", b =>
{
b.Property<Guid>("TagGroupRowId")
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier")
.HasDefaultValueSql("NEWSEQUENTIALID()");
b.Property<string>("DeviceId")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("nvarchar(128)");
b.Property<string>("ParentTagGroupId")
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.Property<byte[]>("RowVersion")
.IsConcurrencyToken()
.IsRequired()
.ValueGeneratedOnAddOrUpdate()
.HasColumnType("rowversion");
b.Property<int>("SortOrder")
.HasColumnType("int");
b.Property<string>("TagGroupId")
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.HasKey("TagGroupRowId");
b.HasIndex("DeviceId")
.HasDatabaseName("IX_TagGroup_Device");
b.HasIndex("TagGroupId")
.IsUnique()
.HasDatabaseName("UX_TagGroup_LogicalId")
.HasFilter("[TagGroupId] IS NOT NULL");
b.HasIndex("DeviceId", "Name")
.IsUnique()
.HasDatabaseName("UX_TagGroup_DeviceRoot_Name")
.HasFilter("[ParentTagGroupId] IS NULL");
b.HasIndex("DeviceId", "ParentTagGroupId", "Name")
.IsUnique()
.HasDatabaseName("UX_TagGroup_Parent_Name")
.HasFilter("[ParentTagGroupId] IS NOT NULL");
b.ToTable("TagGroup", (string)null);
});
modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.UnsArea", b =>
{
b.Property<Guid>("UnsAreaRowId")
@@ -1567,6 +1469,60 @@ namespace ZB.MOM.WW.OtOpcUa.Configuration.Migrations
b.ToTable("UnsLine", (string)null);
});
modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.UnsTagReference", b =>
{
b.Property<Guid>("UnsTagReferenceRowId")
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier")
.HasDefaultValueSql("NEWSEQUENTIALID()");
b.Property<string>("DisplayNameOverride")
.HasMaxLength(128)
.HasColumnType("nvarchar(128)");
b.Property<string>("EquipmentId")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.Property<byte[]>("RowVersion")
.IsConcurrencyToken()
.IsRequired()
.ValueGeneratedOnAddOrUpdate()
.HasColumnType("rowversion");
b.Property<int>("SortOrder")
.HasColumnType("int");
b.Property<string>("TagId")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.Property<string>("UnsTagReferenceId")
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.HasKey("UnsTagReferenceRowId");
b.HasIndex("EquipmentId")
.HasDatabaseName("IX_UnsTagReference_Equipment");
b.HasIndex("TagId")
.HasDatabaseName("IX_UnsTagReference_Tag");
b.HasIndex("UnsTagReferenceId")
.IsUnique()
.HasDatabaseName("UX_UnsTagReference_LogicalId")
.HasFilter("[UnsTagReferenceId] IS NOT NULL");
b.HasIndex("EquipmentId", "TagId")
.IsUnique()
.HasDatabaseName("UX_UnsTagReference_Equip_Tag");
b.ToTable("UnsTagReference", (string)null);
});
modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.VirtualTag", b =>
{
b.Property<Guid>("VirtualTagRowId")
@@ -1671,17 +1627,6 @@ namespace ZB.MOM.WW.OtOpcUa.Configuration.Migrations
b.Navigation("Cluster");
});
modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.EquipmentImportRow", b =>
{
b.HasOne("ZB.MOM.WW.OtOpcUa.Configuration.Entities.EquipmentImportBatch", "Batch")
.WithMany("Rows")
.HasForeignKey("BatchId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Batch");
});
modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.LdapGroupRoleMapping", b =>
{
b.HasOne("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ServerCluster", "Cluster")
@@ -1692,17 +1637,6 @@ namespace ZB.MOM.WW.OtOpcUa.Configuration.Migrations
b.Navigation("Cluster");
});
modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.Namespace", b =>
{
b.HasOne("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ServerCluster", "Cluster")
.WithMany("Namespaces")
.HasForeignKey("ClusterId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("Cluster");
});
modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.NodeDeploymentState", b =>
{
b.HasOne("ZB.MOM.WW.OtOpcUa.Configuration.Entities.Deployment", "Deployment")
@@ -1722,6 +1656,15 @@ namespace ZB.MOM.WW.OtOpcUa.Configuration.Migrations
b.Navigation("Node");
});
modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.RawFolder", b =>
{
b.HasOne("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ServerCluster", null)
.WithMany("RawFolders")
.HasForeignKey("ClusterId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
});
modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.UnsArea", b =>
{
b.HasOne("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ServerCluster", "Cluster")
@@ -1738,16 +1681,11 @@ namespace ZB.MOM.WW.OtOpcUa.Configuration.Migrations
b.Navigation("Credentials");
});
modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.EquipmentImportBatch", b =>
{
b.Navigation("Rows");
});
modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ServerCluster", b =>
{
b.Navigation("Namespaces");
b.Navigation("Nodes");
b.Navigation("RawFolders");
});
#pragma warning restore 612, 618
}
@@ -18,8 +18,12 @@ public sealed class OtOpcUaConfigDbContext(DbContextOptions<OtOpcUaConfigDbConte
public DbSet<ClusterNode> ClusterNodes => Set<ClusterNode>();
/// <summary>Gets the DbSet of cluster node credentials.</summary>
public DbSet<ClusterNodeCredential> ClusterNodeCredentials => Set<ClusterNodeCredential>();
/// <summary>Gets the DbSet of namespaces.</summary>
public DbSet<Namespace> Namespaces => Set<Namespace>();
/// <summary>Gets the DbSet of v3 Raw-tree driver-organizing folders.</summary>
public DbSet<RawFolder> RawFolders => Set<RawFolder>();
/// <summary>Gets the DbSet of v3 Raw-tree tag groups (nestable under a device).</summary>
public DbSet<TagGroup> TagGroups => Set<TagGroup>();
/// <summary>Gets the DbSet of v3 UNS tag references (equipment → raw tag projection).</summary>
public DbSet<UnsTagReference> UnsTagReferences => Set<UnsTagReference>();
/// <summary>Gets the DbSet of UNS areas.</summary>
public DbSet<UnsArea> UnsAreas => Set<UnsArea>();
/// <summary>Gets the DbSet of UNS lines.</summary>
@@ -46,10 +50,6 @@ public sealed class OtOpcUaConfigDbContext(DbContextOptions<OtOpcUaConfigDbConte
public DbSet<DriverInstanceResilienceStatus> DriverInstanceResilienceStatuses => Set<DriverInstanceResilienceStatus>();
/// <summary>Gets the DbSet of LDAP group role mappings.</summary>
public DbSet<LdapGroupRoleMapping> LdapGroupRoleMappings => Set<LdapGroupRoleMapping>();
/// <summary>Gets the DbSet of equipment import batches.</summary>
public DbSet<EquipmentImportBatch> EquipmentImportBatches => Set<EquipmentImportBatch>();
/// <summary>Gets the DbSet of equipment import rows.</summary>
public DbSet<EquipmentImportRow> EquipmentImportRows => Set<EquipmentImportRow>();
/// <summary>Gets the DbSet of scripts.</summary>
public DbSet<Script> Scripts => Set<Script>();
/// <summary>Gets the DbSet of virtual tags.</summary>
@@ -79,13 +79,15 @@ public sealed class OtOpcUaConfigDbContext(DbContextOptions<OtOpcUaConfigDbConte
ConfigureServerCluster(modelBuilder);
ConfigureClusterNode(modelBuilder);
ConfigureClusterNodeCredential(modelBuilder);
ConfigureNamespace(modelBuilder);
ConfigureRawFolder(modelBuilder);
ConfigureUnsArea(modelBuilder);
ConfigureUnsLine(modelBuilder);
ConfigureDriverInstance(modelBuilder);
ConfigureDevice(modelBuilder);
ConfigureTagGroup(modelBuilder);
ConfigureEquipment(modelBuilder);
ConfigureTag(modelBuilder);
ConfigureUnsTagReference(modelBuilder);
ConfigurePollGroup(modelBuilder);
ConfigureNodeAcl(modelBuilder);
ConfigureConfigAuditLog(modelBuilder);
@@ -93,7 +95,6 @@ public sealed class OtOpcUaConfigDbContext(DbContextOptions<OtOpcUaConfigDbConte
ConfigureDriverHostStatus(modelBuilder);
ConfigureDriverInstanceResilienceStatus(modelBuilder);
ConfigureLdapGroupRoleMapping(modelBuilder);
ConfigureEquipmentImportBatch(modelBuilder);
ConfigureScript(modelBuilder);
ConfigureVirtualTag(modelBuilder);
ConfigureScriptedAlarm(modelBuilder);
@@ -183,32 +184,39 @@ public sealed class OtOpcUaConfigDbContext(DbContextOptions<OtOpcUaConfigDbConte
});
}
private static void ConfigureNamespace(ModelBuilder modelBuilder)
private static void ConfigureRawFolder(ModelBuilder modelBuilder)
{
modelBuilder.Entity<Namespace>(e =>
modelBuilder.Entity<RawFolder>(e =>
{
e.ToTable("Namespace");
e.HasKey(x => x.NamespaceRowId);
e.Property(x => x.NamespaceRowId).HasDefaultValueSql("NEWSEQUENTIALID()");
e.Property(x => x.NamespaceId).HasMaxLength(64);
e.ToTable("RawFolder");
e.HasKey(x => x.RawFolderRowId);
e.Property(x => x.RawFolderRowId).HasDefaultValueSql("NEWSEQUENTIALID()");
e.Property(x => x.RawFolderId).HasMaxLength(64);
e.Property(x => x.ClusterId).HasMaxLength(64);
e.Property(x => x.Kind).HasConversion<string>().HasMaxLength(32);
e.Property(x => x.NamespaceUri).HasMaxLength(256);
e.Property(x => x.Notes).HasMaxLength(1024);
e.Property(x => x.ParentRawFolderId).HasMaxLength(64);
e.Property(x => x.Name).HasMaxLength(128);
e.Property(x => x.RowVersion).IsRowVersion();
e.HasOne(x => x.Cluster).WithMany(c => c.Namespaces)
// Cluster-rooted. Restrict so a cluster with folders cannot be deleted out from under
// them (matches the OnDelete(Restrict) convention across the config schema). Child FKs
// (DriverInstance.RawFolderId, RawFolder.ParentRawFolderId) are logical-only, so
// non-empty-folder-delete is enforced at the service layer in later batches.
e.HasOne<ServerCluster>().WithMany(c => c.RawFolders)
.HasForeignKey(x => x.ClusterId)
.OnDelete(DeleteBehavior.Restrict);
e.HasIndex(x => new { x.ClusterId, x.Kind }).IsUnique()
.HasDatabaseName("UX_Namespace_Cluster_Kind");
e.HasIndex(x => x.NamespaceUri).IsUnique()
.HasDatabaseName("UX_Namespace_NamespaceUri");
e.HasIndex(x => x.NamespaceId).IsUnique()
.HasDatabaseName("UX_Namespace_LogicalId");
e.HasIndex(x => x.ClusterId)
.HasDatabaseName("IX_Namespace_Cluster");
e.HasIndex(x => x.RawFolderId).IsUnique().HasDatabaseName("UX_RawFolder_LogicalId");
e.HasIndex(x => x.ClusterId).HasDatabaseName("IX_RawFolder_Cluster");
// Sibling-name uniqueness. Two filtered indexes cover the nullable ParentRawFolderId:
// one for nested folders (Parent NOT NULL), one for cluster-root folders (Parent NULL),
// mirroring the Tag filtered-index convention.
e.HasIndex(x => new { x.ClusterId, x.ParentRawFolderId, x.Name }).IsUnique()
.HasFilter("[ParentRawFolderId] IS NOT NULL")
.HasDatabaseName("UX_RawFolder_Parent_Name");
e.HasIndex(x => new { x.ClusterId, x.Name }).IsUnique()
.HasFilter("[ParentRawFolderId] IS NULL")
.HasDatabaseName("UX_RawFolder_ClusterRoot_Name");
});
}
@@ -267,7 +275,7 @@ public sealed class OtOpcUaConfigDbContext(DbContextOptions<OtOpcUaConfigDbConte
e.Property(x => x.DriverInstanceRowId).HasDefaultValueSql("NEWSEQUENTIALID()");
e.Property(x => x.DriverInstanceId).HasMaxLength(64);
e.Property(x => x.ClusterId).HasMaxLength(64);
e.Property(x => x.NamespaceId).HasMaxLength(64);
e.Property(x => x.RawFolderId).HasMaxLength(64);
e.Property(x => x.Name).HasMaxLength(128);
e.Property(x => x.DriverType).HasMaxLength(32);
e.Property(x => x.DriverConfig).HasColumnType("nvarchar(max)");
@@ -277,8 +285,18 @@ public sealed class OtOpcUaConfigDbContext(DbContextOptions<OtOpcUaConfigDbConte
e.HasOne(x => x.Cluster).WithMany().HasForeignKey(x => x.ClusterId).OnDelete(DeleteBehavior.Restrict);
e.HasIndex(x => x.ClusterId).HasDatabaseName("IX_DriverInstance_Cluster");
e.HasIndex(x => x.NamespaceId).HasDatabaseName("IX_DriverInstance_Namespace");
e.HasIndex(x => x.RawFolderId).HasDatabaseName("IX_DriverInstance_RawFolder");
e.HasIndex(x => x.DriverInstanceId).IsUnique().HasDatabaseName("UX_DriverInstance_LogicalId");
// v3 sibling-name uniqueness (a RawPath segment). Two filtered indexes cover the nullable
// RawFolderId: nested drivers keyed by (ClusterId, RawFolderId, Name), cluster-root drivers
// by (ClusterId, Name).
e.HasIndex(x => new { x.ClusterId, x.RawFolderId, x.Name }).IsUnique()
.HasFilter("[RawFolderId] IS NOT NULL")
.HasDatabaseName("UX_DriverInstance_Folder_Name");
e.HasIndex(x => new { x.ClusterId, x.Name }).IsUnique()
.HasFilter("[RawFolderId] IS NULL")
.HasDatabaseName("UX_DriverInstance_ClusterRoot_Name");
});
}
@@ -300,6 +318,38 @@ public sealed class OtOpcUaConfigDbContext(DbContextOptions<OtOpcUaConfigDbConte
e.HasIndex(x => x.DriverInstanceId).HasDatabaseName("IX_Device_Driver");
e.HasIndex(x => x.DeviceId).IsUnique().HasDatabaseName("UX_Device_LogicalId");
// v3 sibling-name uniqueness within a driver (a RawPath segment).
e.HasIndex(x => new { x.DriverInstanceId, x.Name }).IsUnique()
.HasDatabaseName("UX_Device_Driver_Name");
});
}
private static void ConfigureTagGroup(ModelBuilder modelBuilder)
{
modelBuilder.Entity<TagGroup>(e =>
{
e.ToTable("TagGroup");
e.HasKey(x => x.TagGroupRowId);
e.Property(x => x.TagGroupRowId).HasDefaultValueSql("NEWSEQUENTIALID()");
e.Property(x => x.TagGroupId).HasMaxLength(64);
e.Property(x => x.DeviceId).HasMaxLength(64);
e.Property(x => x.ParentTagGroupId).HasMaxLength(64);
e.Property(x => x.Name).HasMaxLength(128);
e.Property(x => x.RowVersion).IsRowVersion();
e.HasIndex(x => x.TagGroupId).IsUnique().HasDatabaseName("UX_TagGroup_LogicalId");
e.HasIndex(x => x.DeviceId).HasDatabaseName("IX_TagGroup_Device");
// Sibling-name uniqueness. Two filtered indexes cover the nullable ParentTagGroupId:
// nested groups keyed by (DeviceId, ParentTagGroupId, Name), device-root groups by
// (DeviceId, Name). DeviceId/ParentTagGroupId are logical FKs (no navigation); non-empty
// group deletes are blocked at the service layer in later batches.
e.HasIndex(x => new { x.DeviceId, x.ParentTagGroupId, x.Name }).IsUnique()
.HasFilter("[ParentTagGroupId] IS NOT NULL")
.HasDatabaseName("UX_TagGroup_Parent_Name");
e.HasIndex(x => new { x.DeviceId, x.Name }).IsUnique()
.HasFilter("[ParentTagGroupId] IS NULL")
.HasDatabaseName("UX_TagGroup_DeviceRoot_Name");
});
}
@@ -311,8 +361,6 @@ public sealed class OtOpcUaConfigDbContext(DbContextOptions<OtOpcUaConfigDbConte
e.HasKey(x => x.EquipmentRowId);
e.Property(x => x.EquipmentRowId).HasDefaultValueSql("NEWSEQUENTIALID()");
e.Property(x => x.EquipmentId).HasMaxLength(64);
e.Property(x => x.DriverInstanceId).HasMaxLength(64);
e.Property(x => x.DeviceId).HasMaxLength(64);
e.Property(x => x.UnsLineId).HasMaxLength(64);
e.Property(x => x.Name).HasMaxLength(32);
e.Property(x => x.MachineCode).HasMaxLength(64);
@@ -329,7 +377,6 @@ public sealed class OtOpcUaConfigDbContext(DbContextOptions<OtOpcUaConfigDbConte
e.Property(x => x.EquipmentClassRef).HasMaxLength(128);
e.Property(x => x.RowVersion).IsRowVersion();
e.HasIndex(x => x.DriverInstanceId).HasDatabaseName("IX_Equipment_Driver");
e.HasIndex(x => x.UnsLineId).HasDatabaseName("IX_Equipment_Line");
e.HasIndex(x => x.EquipmentId).IsUnique().HasDatabaseName("UX_Equipment_LogicalId");
e.HasIndex(x => new { x.UnsLineId, x.Name }).IsUnique().HasDatabaseName("UX_Equipment_LinePath");
@@ -351,28 +398,29 @@ public sealed class OtOpcUaConfigDbContext(DbContextOptions<OtOpcUaConfigDbConte
e.HasKey(x => x.TagRowId);
e.Property(x => x.TagRowId).HasDefaultValueSql("NEWSEQUENTIALID()");
e.Property(x => x.TagId).HasMaxLength(64);
e.Property(x => x.DriverInstanceId).HasMaxLength(64);
e.Property(x => x.DeviceId).HasMaxLength(64);
e.Property(x => x.EquipmentId).HasMaxLength(64);
// v3: raw-only tag. Required DeviceId FK; nullable TagGroupId. EquipmentId/FolderPath/
// DriverInstanceId are retired — identity is the RawPath (device + optional group + name).
e.Property(x => x.DeviceId).HasMaxLength(64).IsRequired();
e.Property(x => x.TagGroupId).HasMaxLength(64);
e.Property(x => x.Name).HasMaxLength(128);
e.Property(x => x.FolderPath).HasMaxLength(512);
e.Property(x => x.DataType).HasMaxLength(32);
e.Property(x => x.AccessLevel).HasConversion<string>().HasMaxLength(16);
e.Property(x => x.PollGroupId).HasMaxLength(64);
e.Property(x => x.TagConfig).HasColumnType("nvarchar(max)");
e.Property(x => x.RowVersion).IsRowVersion();
e.HasIndex(x => new { x.DriverInstanceId, x.DeviceId }).HasDatabaseName("IX_Tag_Driver_Device");
e.HasIndex(x => x.EquipmentId)
.HasFilter("[EquipmentId] IS NOT NULL")
.HasDatabaseName("IX_Tag_Equipment");
e.HasIndex(x => new { x.DeviceId, x.TagGroupId }).HasDatabaseName("IX_Tag_Device");
e.HasIndex(x => x.TagId).IsUnique().HasDatabaseName("UX_Tag_LogicalId");
e.HasIndex(x => new { x.EquipmentId, x.Name }).IsUnique()
.HasFilter("[EquipmentId] IS NOT NULL")
.HasDatabaseName("UX_Tag_EquipmentPath");
e.HasIndex(x => new { x.DriverInstanceId, x.FolderPath, x.Name }).IsUnique()
.HasFilter("[EquipmentId] IS NULL")
.HasDatabaseName("UX_Tag_FolderPath");
// Sibling-name uniqueness (a RawPath segment). Two filtered indexes cover the nullable
// TagGroupId: grouped tags keyed by (DeviceId, TagGroupId, Name), device-root tags by
// (DeviceId, Name).
e.HasIndex(x => new { x.DeviceId, x.TagGroupId, x.Name }).IsUnique()
.HasFilter("[TagGroupId] IS NOT NULL")
.HasDatabaseName("UX_Tag_Group_Name");
e.HasIndex(x => new { x.DeviceId, x.Name }).IsUnique()
.HasFilter("[TagGroupId] IS NULL")
.HasDatabaseName("UX_Tag_Device_Name");
});
}
@@ -567,51 +615,28 @@ public sealed class OtOpcUaConfigDbContext(DbContextOptions<OtOpcUaConfigDbConte
});
}
private static void ConfigureEquipmentImportBatch(ModelBuilder modelBuilder)
private static void ConfigureUnsTagReference(ModelBuilder modelBuilder)
{
modelBuilder.Entity<EquipmentImportBatch>(e =>
modelBuilder.Entity<UnsTagReference>(e =>
{
e.ToTable("EquipmentImportBatch");
e.HasKey(x => x.Id);
e.Property(x => x.ClusterId).HasMaxLength(64);
e.Property(x => x.CreatedBy).HasMaxLength(128);
e.Property(x => x.CreatedAtUtc).HasColumnType("datetime2(3)");
e.Property(x => x.FinalisedAtUtc).HasColumnType("datetime2(3)");
// Admin preview modal filters by user; finalise / drop both hit this index.
e.HasIndex(x => new { x.CreatedBy, x.FinalisedAtUtc })
.HasDatabaseName("IX_EquipmentImportBatch_Creator_Finalised");
});
modelBuilder.Entity<EquipmentImportRow>(e =>
{
e.ToTable("EquipmentImportRow");
e.HasKey(x => x.Id);
e.Property(x => x.ZTag).HasMaxLength(128);
e.Property(x => x.MachineCode).HasMaxLength(128);
e.Property(x => x.SAPID).HasMaxLength(128);
e.ToTable("UnsTagReference");
e.HasKey(x => x.UnsTagReferenceRowId);
e.Property(x => x.UnsTagReferenceRowId).HasDefaultValueSql("NEWSEQUENTIALID()");
e.Property(x => x.UnsTagReferenceId).HasMaxLength(64);
e.Property(x => x.EquipmentId).HasMaxLength(64);
e.Property(x => x.EquipmentUuid).HasMaxLength(64);
e.Property(x => x.Name).HasMaxLength(128);
e.Property(x => x.UnsAreaName).HasMaxLength(64);
e.Property(x => x.UnsLineName).HasMaxLength(64);
e.Property(x => x.Manufacturer).HasMaxLength(256);
e.Property(x => x.Model).HasMaxLength(256);
e.Property(x => x.SerialNumber).HasMaxLength(256);
e.Property(x => x.HardwareRevision).HasMaxLength(64);
e.Property(x => x.SoftwareRevision).HasMaxLength(64);
e.Property(x => x.YearOfConstruction).HasMaxLength(8);
e.Property(x => x.AssetLocation).HasMaxLength(512);
e.Property(x => x.ManufacturerUri).HasMaxLength(512);
e.Property(x => x.DeviceManualUri).HasMaxLength(512);
e.Property(x => x.RejectReason).HasMaxLength(512);
e.Property(x => x.TagId).HasMaxLength(64);
e.Property(x => x.DisplayNameOverride).HasMaxLength(128);
e.Property(x => x.RowVersion).IsRowVersion();
e.HasOne(x => x.Batch)
.WithMany(b => b.Rows)
.HasForeignKey(x => x.BatchId)
.OnDelete(DeleteBehavior.Cascade);
e.HasIndex(x => x.UnsTagReferenceId).IsUnique().HasDatabaseName("UX_UnsTagReference_LogicalId");
e.HasIndex(x => x.EquipmentId).HasDatabaseName("IX_UnsTagReference_Equipment");
e.HasIndex(x => x.TagId).HasDatabaseName("IX_UnsTagReference_Tag");
e.HasIndex(x => x.BatchId).HasDatabaseName("IX_EquipmentImportRow_Batch");
// One reference per (equipment, raw tag). EquipmentId/TagId are logical FKs (no
// navigation); the "raw-tag delete blocked while referenced" rule is enforced at the
// service layer in later batches.
e.HasIndex(x => new { x.EquipmentId, x.TagId }).IsUnique()
.HasDatabaseName("UX_UnsTagReference_Equip_Tag");
});
}
@@ -25,24 +25,36 @@ public sealed class DraftSnapshot
/// </summary>
public string? Site { get; init; }
/// <summary>Gets the list of OPC UA namespaces.</summary>
public IReadOnlyList<Namespace> Namespaces { get; init; } = [];
// v3: the Namespace entity is retired (the two OPC UA namespaces are implicit). The Raw tree
// (RawFolders → DriverInstances → Devices → TagGroups → Tags) + the UNS projection
// (UnsTagReferences) feed the v3 validator rules (raw-name charset, historized-tagname length, UNS
// effective-leaf uniqueness).
/// <summary>Gets the list of Raw-tree folders (drive the raw-name charset rule + RawPath computation).</summary>
public IReadOnlyList<RawFolder> RawFolders { get; init; } = [];
/// <summary>Gets the list of driver instances.</summary>
public IReadOnlyList<DriverInstance> DriverInstances { get; init; } = [];
/// <summary>Gets the list of devices.</summary>
public IReadOnlyList<Device> Devices { get; init; } = [];
/// <summary>Gets the list of tag-groups (drive the raw-name charset rule + RawPath computation).</summary>
public IReadOnlyList<TagGroup> TagGroups { get; init; } = [];
/// <summary>Gets the list of UNS areas.</summary>
public IReadOnlyList<UnsArea> UnsAreas { get; init; } = [];
/// <summary>Gets the list of UNS lines.</summary>
public IReadOnlyList<UnsLine> UnsLines { get; init; } = [];
/// <summary>Gets the list of equipment.</summary>
public IReadOnlyList<Equipment> Equipment { get; init; } = [];
/// <summary>Gets the list of tags.</summary>
/// <summary>Gets the list of raw tags.</summary>
public IReadOnlyList<Tag> Tags { get; init; } = [];
/// <summary>Equipment-bound VirtualTags (script-derived signals). Shares the equipment NodeId space
/// with Tags; the collision rule checks both.</summary>
/// <summary>Gets the UNS tag references (raw tag → equipment projection). Drives the UNS effective-leaf
/// uniqueness rule (effective name = DisplayNameOverride else the backing raw tag's Name).</summary>
public IReadOnlyList<UnsTagReference> UnsTagReferences { get; init; } = [];
/// <summary>Equipment-bound VirtualTags (script-derived signals). Part of the UNS effective-leaf
/// uniqueness set within an equipment (references + VirtualTags + ScriptedAlarms).</summary>
public IReadOnlyList<VirtualTag> VirtualTags { get; init; } = [];
/// <summary>Equipment-bound scripted alarms. Part of the UNS effective-leaf uniqueness set.</summary>
public IReadOnlyList<ScriptedAlarm> ScriptedAlarms { get; init; } = [];
/// <summary>Gets the list of poll groups.</summary>
public IReadOnlyList<PollGroup> PollGroups { get; init; } = [];
@@ -36,14 +36,20 @@ public static class DraftSnapshotFactory
// EquipmentUuid checks run in separate validator passes — no rule here reads these fields.
GenerationId = 0, // generation model dropped; placeholder (no rule reads it)
ClusterId = string.Empty, // global snapshot; rules compare entity ClusterId fields, not this
Namespaces = await db.Namespaces.AsNoTracking().ToListAsync(ct),
// v3: Namespace entity retired — no namespace list to materialize. The Raw tree
// (RawFolders/TagGroups) + UnsTagReferences feed the v3 rules (charset, tagname length,
// effective-leaf uniqueness).
RawFolders = await db.RawFolders.AsNoTracking().ToListAsync(ct),
DriverInstances = await db.DriverInstances.AsNoTracking().ToListAsync(ct),
Devices = await db.Devices.AsNoTracking().ToListAsync(ct),
TagGroups = await db.TagGroups.AsNoTracking().ToListAsync(ct),
UnsAreas = await db.UnsAreas.AsNoTracking().ToListAsync(ct),
UnsLines = await db.UnsLines.AsNoTracking().ToListAsync(ct),
Equipment = await db.Equipment.AsNoTracking().ToListAsync(ct),
Tags = await db.Tags.AsNoTracking().ToListAsync(ct),
UnsTagReferences = await db.UnsTagReferences.AsNoTracking().ToListAsync(ct),
VirtualTags = await db.VirtualTags.AsNoTracking().ToListAsync(ct),
ScriptedAlarms = await db.ScriptedAlarms.AsNoTracking().ToListAsync(ct),
PollGroups = await db.PollGroups.AsNoTracking().ToListAsync(ct),
PriorEquipment = [], // intentional: no prior-generation table to diff against at this level
ActiveReservations = await db.ExternalIdReservations
@@ -30,57 +30,112 @@ public static class DraftValidator
ValidateUnsSegments(draft, errors);
ValidatePathLength(draft, errors);
ValidateEquipmentUuidImmutability(draft, errors);
ValidateSameClusterNamespaceBinding(draft, errors);
ValidateReservationPreflight(draft, errors);
ValidateEquipmentIdDerivation(draft, errors);
ValidateNoEquipmentSignalNameCollision(draft, errors);
ValidateGalaxyTagFullName(draft, errors);
// v3 rules (WP4). The retired rules ValidateSameClusterNamespaceBinding (Namespace entity gone)
// and ValidateGalaxyTagFullName (blob-as-identity gone) are replaced by:
ValidateRawNameCharset(draft, errors);
ValidateHistorizedTagnameLength(draft, errors);
ValidateUnsEffectiveLeafUniqueness(draft, errors);
return errors;
}
private static void ValidateGalaxyTagFullName(DraftSnapshot draft, List<ValidationError> errors)
/// <summary>Builds the shared <see cref="RawPathResolver"/> from the draft's raw topology so the
/// tagname-length rule computes the SAME RawPath the deploy artifact injects into drivers.</summary>
private static RawPathResolver BuildRawPathResolver(DraftSnapshot draft)
{
var typeByDriver = draft.DriverInstances
.ToDictionary(d => d.DriverInstanceId, d => d.DriverType, StringComparer.Ordinal);
var folders = draft.RawFolders.GroupBy(f => f.RawFolderId, StringComparer.Ordinal)
.ToDictionary(g => g.Key, g => ((string?)g.First().ParentRawFolderId, g.First().Name), StringComparer.Ordinal);
var drivers = draft.DriverInstances.GroupBy(d => d.DriverInstanceId, StringComparer.Ordinal)
.ToDictionary(g => g.Key, g => ((string?)g.First().RawFolderId, g.First().Name), StringComparer.Ordinal);
var devices = draft.Devices.GroupBy(d => d.DeviceId, StringComparer.Ordinal)
.ToDictionary(g => g.Key, g => (g.First().DriverInstanceId, g.First().Name), StringComparer.Ordinal);
var groups = draft.TagGroups.GroupBy(t => t.TagGroupId, StringComparer.Ordinal)
.ToDictionary(g => g.Key, g => ((string?)g.First().ParentTagGroupId, g.First().Name), StringComparer.Ordinal);
return new RawPathResolver(folders, drivers, devices, groups);
}
/// <summary>v3: every raw Name (RawFolder / DriverInstance / Device / TagGroup / Tag) must be a legal
/// RawPath segment — no <c>/</c>, no leading/trailing whitespace, non-empty — because <c>/</c> is the
/// RawPath separator and would corrupt the NodeId. Uses <see cref="RawPaths.ValidateSegment"/>, the same
/// charset authority the path builder enforces.</summary>
private static void ValidateRawNameCharset(DraftSnapshot draft, List<ValidationError> errors)
{
void Check(string kind, string id, string name)
{
var error = RawPaths.ValidateSegment(name);
if (error is not null)
errors.Add(new("RawNameInvalid", $"{kind} name '{name}' is not a legal RawPath segment: {error}", id));
}
foreach (var f in draft.RawFolders) Check("RawFolder", f.RawFolderId, f.Name);
foreach (var d in draft.DriverInstances) Check("DriverInstance", d.DriverInstanceId, d.Name);
foreach (var d in draft.Devices) Check("Device", d.DeviceId, d.Name);
foreach (var g in draft.TagGroups) Check("TagGroup", g.TagGroupId, g.Name);
foreach (var t in draft.Tags) Check("Tag", t.TagId, t.Name);
}
/// <summary>v3: a historized tag's EFFECTIVE historian tagname (the <c>historianTagname</c> override
/// else the tag's RawPath) must be ≤ 255 chars — the live-verified AVEVA historian limit (256 rejected).
/// A longer name is a deploy error telling the author to set a shorter <c>historianTagname</c> override,
/// never a silent truncation.</summary>
private static void ValidateHistorizedTagnameLength(DraftSnapshot draft, List<ValidationError> errors)
{
const int MaxHistorianTagname = 255;
var resolver = BuildRawPathResolver(draft);
foreach (var t in draft.Tags)
{
if (t.EquipmentId is null) continue;
if (!typeByDriver.TryGetValue(t.DriverInstanceId, out var dtype) || dtype != "GalaxyMxGateway")
continue;
// The Galaxy rule wants the EXPLICIT reference, not the raw-blob fallback — so it reads
// TagConfigIntent.ExplicitFullName (the "FullName" property only when present-and-string,
// else null), preserving the historical null-on-absent semantics by construction (R2-11).
if (string.IsNullOrWhiteSpace(TagConfigIntent.Parse(t.TagConfig).ExplicitFullName))
errors.Add(new("GalaxyTagMissingReference",
$"Galaxy tag '{t.TagId}' on equipment '{t.EquipmentId}' is missing a Galaxy reference (TagConfig.FullName)",
t.TagId));
var intent = TagConfigIntent.Parse(t.TagConfig);
if (!intent.IsHistorized) continue;
// Effective tagname = explicit override else the computed RawPath (null when the chain is broken —
// a separate rule/charset check flags that; nothing to length-check here).
var effective = intent.HistorianTagname ?? resolver.TryBuildTagPath(t.DeviceId, t.TagGroupId, t.Name);
if (effective is null || effective.Length <= MaxHistorianTagname) continue;
errors.Add(new("HistorianTagnameTooLong",
$"tag '{t.TagId}' historized effective tagname is {effective.Length} chars (max {MaxHistorianTagname}); " +
"set a shorter 'historianTagname' override in its TagConfig",
t.TagId));
}
}
private static void ValidateNoEquipmentSignalNameCollision(DraftSnapshot draft, List<ValidationError> errors)
/// <summary>v3: within an equipment, the EFFECTIVE leaf name must be unique across its UnsTagReferences
/// (DisplayNameOverride else the backing raw tag's Name), its VirtualTags (Name), and its ScriptedAlarms
/// (Name) — the three share the equipment's UNS NodeId space (<c>{EquipmentId}/{EffectiveName}</c>).
/// Enforced here at the deploy gate (as well as at authoring) so a raw-tag rename that induces a clash the
/// authoring check never saw is caught before it ships.</summary>
private static void ValidateUnsEffectiveLeafUniqueness(DraftSnapshot draft, List<ValidationError> errors)
{
// Materialiser NodeId key: "{EquipmentId}[/{FolderPath}]/{Name}". Tag (EquipmentId != null) and
// VirtualTag share this space with no DB cross-table uniqueness, so the same key from both collides.
static string Key(string eq, string? folder, string name) =>
string.IsNullOrWhiteSpace(folder) ? $"{eq}/{name}" : $"{eq}/{folder}/{name}";
var tagNameById = draft.Tags.GroupBy(t => t.TagId, StringComparer.Ordinal)
.ToDictionary(g => g.Key, g => g.First().Name, StringComparer.Ordinal);
var signals = draft.Tags
.Where(t => t.EquipmentId is not null)
.Select(t => (Key: Key(t.EquipmentId!, t.FolderPath, t.Name), Eq: t.EquipmentId!, t.Name))
// VirtualTag has no FolderPath column today — null is correct here; update if it ever gains one.
.Concat(draft.VirtualTags
.Select(v => (Key: Key(v.EquipmentId, null, v.Name), Eq: v.EquipmentId, v.Name)));
foreach (var g in signals.GroupBy(s => s.Key, StringComparer.Ordinal))
// (EquipmentId, EffectiveName) → source descriptions, so a collision names both sources.
var byLeaf = new Dictionary<(string Eq, string Name), List<string>>();
void Add(string equipmentId, string? effectiveName, string source)
{
var items = g.ToList();
if (items.Count <= 1) continue;
var f = items[0];
errors.Add(new("EquipmentSignalNameCollision",
$"{items.Count} signals collide on OPC UA NodeId '{g.Key}' (equipment '{f.Eq}', name '{f.Name}'); " +
"a Name must be unique across Tag and VirtualTag within an equipment+folder",
f.Eq));
if (string.IsNullOrEmpty(effectiveName)) return;
var key = (equipmentId, effectiveName);
if (!byLeaf.TryGetValue(key, out var list)) byLeaf[key] = list = new List<string>();
list.Add(source);
}
foreach (var r in draft.UnsTagReferences)
{
var effective = r.DisplayNameOverride ?? (tagNameById.TryGetValue(r.TagId, out var n) ? n : null);
Add(r.EquipmentId, effective, $"reference '{r.UnsTagReferenceId}'");
}
foreach (var v in draft.VirtualTags)
Add(v.EquipmentId, v.Name, $"VirtualTag '{v.VirtualTagId}'");
foreach (var a in draft.ScriptedAlarms)
Add(a.EquipmentId, a.Name, $"ScriptedAlarm '{a.ScriptedAlarmId}'");
foreach (var ((eq, name), sources) in byLeaf)
{
if (sources.Count <= 1) continue;
errors.Add(new("UnsEffectiveNameCollision",
$"{sources.Count} UNS signals collide on effective name '{name}' within equipment '{eq}': " +
string.Join(", ", sources) + "; effective names must be unique across references, VirtualTags, and ScriptedAlarms",
eq));
}
}
@@ -149,27 +204,6 @@ public static class DraftValidator
}
}
private static void ValidateSameClusterNamespaceBinding(DraftSnapshot draft, List<ValidationError> errors)
{
var nsById = draft.Namespaces.ToDictionary(n => n.NamespaceId);
foreach (var di in draft.DriverInstances)
{
if (!nsById.TryGetValue(di.NamespaceId, out var ns))
{
errors.Add(new("NamespaceUnresolved",
$"DriverInstance '{di.DriverInstanceId}' references unknown NamespaceId '{di.NamespaceId}'",
di.DriverInstanceId));
continue;
}
if (ns.ClusterId != di.ClusterId)
errors.Add(new("BadCrossClusterNamespaceBinding",
$"DriverInstance '{di.DriverInstanceId}' is in cluster '{di.ClusterId}' but references namespace in cluster '{ns.ClusterId}'",
di.DriverInstanceId));
}
}
private static void ValidateReservationPreflight(DraftSnapshot draft, List<ValidationError> errors)
{
var activeByKindValue = draft.ActiveReservations
@@ -84,7 +84,6 @@ public sealed class DriverTypeRegistry
/// <summary>Per-driver-type metadata used by the Core, validator, and Admin UI.</summary>
/// <param name="TypeName">Driver type name (matches <c>DriverInstance.DriverType</c> column values).</param>
/// <param name="AllowedNamespaceKinds">Which namespace kinds this driver type may be bound to.</param>
/// <param name="DriverConfigJsonSchema">JSON Schema (Draft 2020-12) the driver's <c>DriverConfig</c> column must validate against.</param>
/// <param name="DeviceConfigJsonSchema">JSON Schema for <c>DeviceConfig</c> (multi-device drivers); null if the driver has no device layer.</param>
/// <param name="TagConfigJsonSchema">JSON Schema for <c>TagConfig</c>; required for every driver since every driver has tags.</param>
@@ -95,24 +94,11 @@ public sealed class DriverTypeRegistry
/// hybrid-formula constants, and whether process-level <c>MemoryRecycle</c> / scheduled-
/// recycle protections apply (Tier C only). Every registered driver type must declare one.
/// </param>
// v3: AllowedNamespaceKinds retired with the Namespace entity — the two OPC UA namespaces (Raw/UNS)
// are implicit, so a driver type no longer declares a namespace-kind compatibility bitmask.
public sealed record DriverTypeMetadata(
string TypeName,
NamespaceKindCompatibility AllowedNamespaceKinds,
string DriverConfigJsonSchema,
string? DeviceConfigJsonSchema,
string TagConfigJsonSchema,
DriverTier Tier);
/// <summary>Bitmask of namespace kinds a driver type may populate.</summary>
[Flags]
public enum NamespaceKindCompatibility
{
/// <summary>Driver does not populate any namespace (invalid; should never appear in registry).</summary>
None = 0,
/// <summary>Driver may populate Equipment-kind namespaces (UNS path, Equipment rows).</summary>
Equipment = 1,
/// <summary>Driver may populate the future Simulated namespace (replay driver — not in v2.0).</summary>
Simulated = 4,
}
@@ -1,53 +1,66 @@
using System.Collections.Concurrent;
using System.Diagnostics.CodeAnalysis;
namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions;
/// <summary>
/// Resolves a driver subscription/read/write <c>fullReference</c> to a driver tag-definition,
/// bridging the two authoring models: a legacy authored tag-table entry (looked up by name)
/// OR an equipment tag whose reference is its raw <c>TagConfig</c> JSON (parsed on first use
/// and cached). Negative results are cached too, so a genuinely-unknown reference is parsed once.
/// v3: resolves a driver subscription/read/write reference — which is now always a
/// <c>RawPath</c> — to the driver's internal tag-definition. The lookup is a single authored-table
/// hit: each deploy hands the driver its authored raw tags keyed by RawPath (see
/// <see cref="RawTagEntry"/>), the driver builds a <c>RawPath → TDef</c> table from them, and this
/// resolver wraps that table's lookup. The pre-v3 blob-parse fallback (an equipment tag whose
/// reference WAS its raw <c>TagConfig</c> JSON) is retired: a miss is a miss — <see cref="TryResolve"/>
/// returns <see langword="false"/> and the driver surfaces Bad quality, never a parse attempt.
/// </summary>
/// <typeparam name="TDef">The driver's internal tag-definition type.</typeparam>
public sealed class EquipmentTagRefResolver<TDef> where TDef : class
{
private readonly Func<string, TDef?> _byName;
private readonly Func<string, TDef?> _parseRef;
private readonly ConcurrentDictionary<string, TDef?> _cache = new(StringComparer.Ordinal);
private readonly Func<string, TDef?> _byRawPath;
/// <summary>Initializes a new instance of the <see cref="EquipmentTagRefResolver{TDef}"/> class.</summary>
/// <param name="byName">Authored tag-table lookup (returns null on miss).</param>
/// <param name="parseRef">Parses an equipment-tag reference (TagConfig JSON) into a transient def, or null.</param>
public EquipmentTagRefResolver(Func<string, TDef?> byName, Func<string, TDef?> parseRef)
/// <param name="byRawPath">The driver's authored-table lookup (RawPath → definition; null on miss).</param>
public EquipmentTagRefResolver(Func<string, TDef?> byRawPath)
{
ArgumentNullException.ThrowIfNull(byName);
ArgumentNullException.ThrowIfNull(parseRef);
_byName = byName;
_parseRef = parseRef;
ArgumentNullException.ThrowIfNull(byRawPath);
_byRawPath = byRawPath;
}
/// <summary>True when <paramref name="fullReference"/> resolves to a def (authored or equipment).</summary>
/// <param name="fullReference">The wire reference handed to the driver.</param>
/// <summary>True when <paramref name="rawPath"/> resolves to an authored tag-definition.</summary>
/// <param name="rawPath">The RawPath wire reference handed to the driver.</param>
/// <param name="def">
/// The resolved tag-definition when this returns <see langword="true"/>. When this returns
/// <see langword="false"/> the value is undefined — callers must not use it.
/// <c>[MaybeNullWhen(false)]</c> informs the nullable-reference-types (NRT) analyser so
/// callers in NRT-enabled contexts do not need to suppress warnings.
/// </param>
/// <returns><see langword="true"/> when a definition was found.</returns>
public bool TryResolve(string fullReference, [MaybeNullWhen(false)] out TDef def)
public bool TryResolve(string rawPath, [MaybeNullWhen(false)] out TDef def)
{
var authored = _byName(fullReference);
if (authored is not null) { def = authored; return true; }
var resolved = _cache.GetOrAdd(fullReference, _parseRef);
if (resolved is not null) { def = resolved; return true; }
def = default;
return false;
def = _byRawPath(rawPath);
return def is not null;
}
/// <summary>Drops the transient-parse cache (call on driver reinitialise so a config change re-parses).</summary>
public void Clear() => _cache.Clear();
/// <summary>
/// No-op retained for call-site compatibility. v3 holds no transient parse cache — the driver
/// rebuilds its authored <c>RawPath → TDef</c> table on reinitialise, and this resolver reads
/// the driver's live table by closure, so there is nothing to clear here.
/// </summary>
public void Clear()
{
}
}
/// <summary>
/// The unit of tag delivery from the deploy artifact to a driver: one authored raw
/// <c>Tag</c>, identified by its <see cref="RawPath"/> (the v3 identity), carrying the
/// driver-specific address blob (<see cref="TagConfig"/>) the driver dials and the platform flags
/// the driver needs. A driver maps each entry to its typed definition (keyed by RawPath) via its
/// own <c>TagConfig → definition</c> factory at table-build time.
/// </summary>
/// <param name="RawPath">The tag's cluster-scoped RawPath — the driver wire reference / identity.</param>
/// <param name="TagConfig">The tag's schemaless driver-specific <c>TagConfig</c> JSON (the dialled address).</param>
/// <param name="WriteIdempotent">Whether writes to this tag are safe to replay (R2 resilience contract).</param>
/// <param name="DeviceName">
/// The name of the <c>Device</c> this tag lives under (the RawPath's device segment). Multi-device
/// drivers route the tag to its device/connection by this name; single-device drivers ignore it.
/// Populated by the deploy artifact (which built the RawPath and therefore knows the device);
/// defaults to empty for callers that don't need device routing.
/// </param>
public sealed record RawTagEntry(string RawPath, string TagConfig, bool WriteIdempotent, string DeviceName = "");
@@ -1,80 +1,37 @@
using ZB.MOM.WW.OtOpcUa.Commons.Types;
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Core.OpcUa;
/// <summary>
/// Materializes the canonical Unified Namespace browse tree for an Equipment-kind
/// <see cref="Configuration.Entities.Namespace"/> from the Config DB's
/// <c>UnsArea</c> / <c>UnsLine</c> / <c>Equipment</c> / <c>Tag</c> rows. Runs during
/// address-space build per <see cref="IDriver"/> whose
/// <c>Namespace.Kind = Equipment</c>; non-Equipment namespaces are
/// exempt and reach this walker only indirectly through
/// <see cref="ITagDiscovery.DiscoverAsync"/>.
/// Materializes the canonical Unified Namespace browse tree (Area → Line → Equipment) for a
/// cluster from its <c>UnsArea</c> / <c>UnsLine</c> / <c>Equipment</c> rows, plus the
/// per-equipment VirtualTag + ScriptedAlarm nodes.
/// <para>
/// <b>v3:</b> equipment no longer binds a driver or authors tags. Raw tags are surfaced into
/// equipment by <c>UnsTagReference</c> and materialized as the UNS-namespace fan-out of their
/// backing raw nodes — that projection lands with the dual-namespace address space (Batch 4),
/// NOT here. This walker therefore emits the equipment hierarchy + VirtualTags + ScriptedAlarms
/// only; raw-tag reference variables are intentionally absent (the address space is deliberately
/// dark until Batch 4). It remains a pure, SDK-free helper retained for unit-test support; real
/// server deployments compose through the <c>AddressSpaceComposer → Applier → Sink → NodeManager</c>
/// chain.
/// </para>
/// </summary>
/// <remarks>
/// <para>
/// <b>Composition strategy.</b> Accepted Option A — Config
/// primary. The walker treats the supplied <see cref="EquipmentNamespaceContent"/>
/// snapshot as the authoritative published surface. Every Equipment row becomes a
/// folder node at the UNS level-5 segment; every <see cref="Tag"/> bound to an
/// Equipment (non-null <see cref="Tag.EquipmentId"/>) becomes a variable node under
/// it. Driver-discovered tags that have no Config-DB row are not added by this
/// walker — the ITagDiscovery path continues to exist for FolderPath-scoped tags +
/// for enrichment, but Equipment-kind composition is fully Tag-row-driven.
/// </para>
///
/// <para>
/// <b>Under each Equipment node.</b> Five identifier properties
/// (<c>EquipmentId</c>, <c>EquipmentUuid</c>, <c>MachineCode</c>, <c>ZTag</c>,
/// <c>SAPID</c>) are added as OPC UA properties — external systems (ERP, SAP PM)
/// resolve equipment by whichever identifier they natively use without a sidecar.
/// <see cref="IdentificationFolderBuilder.Build"/> materializes the OPC 40010
/// Identification sub-folder with the nine fields when at least one
/// is non-null; when all nine are null the sub-folder is omitted rather than
/// appearing empty.
/// </para>
///
/// <para>
/// <b>Address resolution.</b> Variable nodes carry the driver-side full reference
/// in <see cref="DriverAttributeInfo.FullName"/> copied from <c>Tag.TagConfig</c>
/// (the wire-level address JSON blob whose interpretation is driver-specific). At
/// runtime the dispatch layer routes Read/Write calls through the configured
/// capability invoker; an unreachable address surfaces as an OPC UA Bad status via
/// the natural driver-read failure path, NOT as a build-time reject. The ADR calls
/// this "BadNotFound placeholder" behavior — legible to operators via their Admin
/// UI + OPC UA client inspection of node status.
/// </para>
///
/// <para>
/// <b>Pure function.</b> This class has no dependency on the OPC UA SDK, no
/// Config-DB access, no state. It consumes pre-loaded EF Core rows + streams calls
/// into the supplied <see cref="IAddressSpaceBuilder"/>. The server-side wiring
/// (load snapshot → invoke walker → per-tag capability probe) lives in the Task B
/// PR alongside <c>NodeScopeResolver</c>'s Config-DB join.
/// </para>
/// </remarks>
public static class EquipmentNodeWalker
{
/// <summary>
/// Walk <paramref name="content"/> into <paramref name="namespaceBuilder"/>.
/// The builder is scoped to the Equipment-kind namespace root; the walker emits
/// Area → Line → Equipment folders under it, then identifier properties + the
/// Identification sub-folder + variable nodes per bound Tag under each Equipment.
/// Walk <paramref name="content"/> into <paramref name="namespaceBuilder"/>: Area → Line →
/// Equipment folders, identifier properties + the OPC 40010 Identification sub-folder per
/// equipment, and the equipment's VirtualTag + ScriptedAlarm variable nodes.
/// </summary>
/// <param name="namespaceBuilder">
/// The builder scoped to the Equipment-kind namespace root. Caller is responsible for
/// creating this (e.g. <c>rootBuilder.Folder(namespace.NamespaceId, namespace.NamespaceUri)</c>).
/// </param>
/// <param name="content">Pre-loaded + pre-filtered rows for a single published generation.</param>
/// <param name="namespaceBuilder">The builder scoped to the UNS namespace root.</param>
/// <param name="content">Pre-loaded + pre-filtered rows for a single cluster.</param>
public static void Walk(IAddressSpaceBuilder namespaceBuilder, EquipmentNamespaceContent content)
{
ArgumentNullException.ThrowIfNull(namespaceBuilder);
ArgumentNullException.ThrowIfNull(content);
// Group lines by area + equipment by line + tags by equipment up-front. Avoids an
// O(N·M) re-scan at each UNS level on large fleets.
var linesByArea = content.Lines
.GroupBy(l => l.UnsAreaId, StringComparer.OrdinalIgnoreCase)
.ToDictionary(g => g.Key, g => g.OrderBy(l => l.Name, StringComparer.Ordinal).ToList(), StringComparer.OrdinalIgnoreCase);
@@ -83,11 +40,6 @@ public static class EquipmentNodeWalker
.GroupBy(e => e.UnsLineId, StringComparer.OrdinalIgnoreCase)
.ToDictionary(g => g.Key, g => g.OrderBy(e => e.Name, StringComparer.Ordinal).ToList(), StringComparer.OrdinalIgnoreCase);
var tagsByEquipment = content.Tags
.Where(t => !string.IsNullOrEmpty(t.EquipmentId))
.GroupBy(t => t.EquipmentId!, StringComparer.OrdinalIgnoreCase)
.ToDictionary(g => g.Key, g => g.OrderBy(t => t.Name, StringComparer.Ordinal).ToList(), StringComparer.OrdinalIgnoreCase);
var virtualTagsByEquipment = (content.VirtualTags ?? [])
.Where(v => v.Enabled)
.GroupBy(v => v.EquipmentId, StringComparer.OrdinalIgnoreCase)
@@ -114,10 +66,6 @@ public static class EquipmentNodeWalker
AddIdentifierProperties(equipmentBuilder, equipment);
IdentificationFolderBuilder.Build(equipmentBuilder, equipment);
if (tagsByEquipment.TryGetValue(equipment.EquipmentId, out var equipmentTags))
foreach (var tag in equipmentTags)
AddTagVariable(equipmentBuilder, tag);
if (virtualTagsByEquipment.TryGetValue(equipment.EquipmentId, out var vTags))
foreach (var vtag in vTags)
AddVirtualTagVariable(equipmentBuilder, vtag);
@@ -131,11 +79,9 @@ public static class EquipmentNodeWalker
}
/// <summary>
/// Adds the five operator-facing identifiers as OPC UA properties
/// on the Equipment node. EquipmentId + EquipmentUuid are always populated;
/// MachineCode is required per <see cref="Equipment"/>; ZTag + SAPID are nullable in
/// the data model so they're skipped when null to avoid empty-string noise in the
/// browse tree.
/// Adds the operator-facing identifiers as OPC UA properties on the Equipment node.
/// EquipmentId + EquipmentUuid + MachineCode are always populated; ZTag + SAPID are skipped
/// when null to avoid empty-string noise in the browse tree.
/// </summary>
private static void AddIdentifierProperties(IAddressSpaceBuilder equipmentBuilder, Equipment equipment)
{
@@ -149,67 +95,13 @@ public static class EquipmentNodeWalker
}
/// <summary>
/// Emit a single Tag row as an <see cref="IAddressSpaceBuilder.Variable"/>. The driver
/// full reference lives in <c>Tag.TagConfig</c> (wire-level address, driver-specific
/// JSON blob); the variable node's data type derives from <c>Tag.DataType</c>.
/// Unreachable-address behavior: the variable is created; the
/// driver's natural Read failure surfaces an OPC UA Bad status at runtime.
/// </summary>
private static void AddTagVariable(IAddressSpaceBuilder equipmentBuilder, Tag tag)
{
// SecurityClass and IsHistorized are intentionally not extracted from TagConfig here.
// In production, EquipmentNodeWalker.Walk is superseded by the
// AddressSpaceComposer → AddressSpaceApplier → Sink → NodeManager chain, which reads
// both fields from TagConfig JSON directly (via DeploymentArtifact.ExtractTagHistorize
// and the SecurityClassification column). This walker is retained for unit-test support
// only; real server deployments never invoke Walk to build live nodes.
var attr = new DriverAttributeInfo(
FullName: ExtractFullName(tag.TagConfig),
DriverDataType: ParseDriverDataType(tag.DataType),
IsArray: false,
ArrayDim: null,
SecurityClass: SecurityClassification.FreeAccess,
IsHistorized: false);
equipmentBuilder.Variable(tag.Name, tag.Name, attr);
}
/// <summary>
/// Cross-driver TagConfig convention — the Config DB's <c>CK_Tag_TagConfig_IsJson</c>
/// check constraint requires TagConfig to be a JSON object, and every shipped driver
/// (Galaxy / Modbus / AB CIP / S7 / FOCAS / TwinCAT / ABLegacy) stores the wire-level
/// address in a top-level <c>FullName</c> field. Extracting it here keeps the walker
/// driver-agnostic while giving the driver the plain address string its backend
/// expects at read-time — the raw JSON would otherwise be passed verbatim to
/// <c>IReadable.ReadAsync</c> and the driver would fail to resolve the tag.
/// </summary>
/// <remarks>
/// Falls back to the raw <paramref name="tagConfig"/> if it doesn't parse as JSON or
/// the <c>FullName</c> field is absent. This preserves the pre-refactor behaviour for
/// any legacy row that slipped past the check constraint or any future driver that
/// wants an opaque non-JSON reference.
/// </remarks>
/// <param name="tagConfig">The tag configuration JSON or string.</param>
/// <returns>The extracted <c>FullName</c> value, or the raw <paramref name="tagConfig"/> if it can't be extracted.</returns>
internal static string ExtractFullName(string tagConfig) =>
TagConfigIntent.Parse(tagConfig).FullName;
/// <summary>
/// Parse <see cref="Tag.DataType"/> (stored as the <see cref="DriverDataType"/> enum
/// name string) into the enum value. Unknown names fall back to
/// <see cref="DriverDataType.String"/> so a one-off driver-specific type doesn't
/// abort the whole walk; the underlying driver still sees the original TagConfig
/// address + can surface its own typed value via the OPC UA variant at read time.
/// Parse a data-type name (stored as the <see cref="DriverDataType"/> enum name) into the
/// enum value; unknown names fall back to <see cref="DriverDataType.String"/>.
/// </summary>
private static DriverDataType ParseDriverDataType(string raw) =>
Enum.TryParse<DriverDataType>(raw, ignoreCase: true, out var parsed) ? parsed : DriverDataType.String;
/// <summary>
/// Emit a <see cref="VirtualTag"/> row as a <see cref="NodeSourceKind.Virtual"/>
/// variable node. <c>FullName</c> doubles as the UNS path Phase 7's VirtualTagEngine
/// addresses its engine-side entries by. The <c>VirtualTagId</c> discriminator lets
/// the DriverNodeManager dispatch Reads/Subscribes to the engine rather than any
/// driver.
/// </summary>
/// <summary>Emit a <see cref="VirtualTag"/> row as a <see cref="NodeSourceKind.Virtual"/> variable node.</summary>
private static void AddVirtualTagVariable(IAddressSpaceBuilder equipmentBuilder, VirtualTag vtag)
{
var attr = new DriverAttributeInfo(
@@ -227,14 +119,7 @@ public static class EquipmentNodeWalker
equipmentBuilder.Variable(vtag.Name, vtag.Name, attr);
}
/// <summary>
/// Emit a <see cref="ScriptedAlarm"/> row as a <see cref="NodeSourceKind.ScriptedAlarm"/>
/// variable node. The OPC UA Part 9 alarm-condition materialization happens at the
/// node-manager level (which wires the concrete <c>AlarmConditionState</c> subclass
/// per <see cref="ScriptedAlarm.AlarmType"/>); this walker provides the browse-level
/// anchor + the <see cref="DriverAttributeInfo.IsAlarm"/> flag that triggers that
/// materialization path.
/// </summary>
/// <summary>Emit a <see cref="ScriptedAlarm"/> row as a <see cref="NodeSourceKind.ScriptedAlarm"/> variable node.</summary>
private static void AddScriptedAlarmVariable(IAddressSpaceBuilder equipmentBuilder, ScriptedAlarm alarm)
{
var attr = new DriverAttributeInfo(
@@ -254,15 +139,14 @@ public static class EquipmentNodeWalker
}
/// <summary>
/// Pre-loaded + pre-filtered snapshot of one Equipment-kind namespace's worth of Config
/// DB rows. All four collections are scoped to the same
/// <see cref="Configuration.Entities.Namespace"/> row. The walker assumes this filter
/// was applied by the caller + does no cross-namespace validation.
/// Pre-loaded + pre-filtered snapshot of one cluster's UNS content (Area / Line / Equipment +
/// per-equipment VirtualTags / ScriptedAlarms). v3: raw-tag references (<c>UnsTagReference</c>)
/// are materialized by the Batch-4 dual-namespace fan-out, not by this walker, so they are not
/// part of this snapshot.
/// </summary>
public sealed record EquipmentNamespaceContent(
IReadOnlyList<UnsArea> Areas,
IReadOnlyList<UnsLine> Lines,
IReadOnlyList<Equipment> Equipment,
IReadOnlyList<Tag> Tags,
IReadOnlyList<VirtualTag>? VirtualTags = null,
IReadOnlyList<ScriptedAlarm>? ScriptedAlarms = null);
@@ -1,4 +1,5 @@
using CliFx.Attributes;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.Cli.Common;
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Cli;
@@ -48,7 +49,10 @@ public abstract class AbCipCommandBase : DriverCommandBase
/// <summary>
/// Build an <see cref="AbCipDriverOptions"/> with the device + tag list a subclass
/// supplies. Probe + alarm projection are disabled — CLI runs are one-shot; the
/// probe loop would race the operator's own reads.
/// probe loop would race the operator's own reads. Each typed tag is serialised to the v3
/// <see cref="RawTagEntry"/> shape (RawPath = tag name, TagConfig blob via
/// <see cref="AbCipTagDefinitionFactory.ToTagConfig"/>, DeviceName = the tag's device routing key)
/// — the same seam the deploy artifact uses.
/// </summary>
/// <param name="tags">The list of tag definitions to include in the options.</param>
/// <returns>The constructed <see cref="AbCipDriverOptions"/>.</returns>
@@ -58,7 +62,11 @@ public abstract class AbCipCommandBase : DriverCommandBase
HostAddress: Gateway,
PlcFamily: Family,
DeviceName: $"cli-{Family}")],
Tags = tags,
RawTags = [.. tags.Select(t => new RawTagEntry(
RawPath: t.Name,
TagConfig: AbCipTagDefinitionFactory.ToTagConfig(t),
WriteIdempotent: t.WriteIdempotent,
DeviceName: t.DeviceHostAddress))],
Timeout = Timeout,
Probe = new AbCipProbeOptions { Enabled = false },
EnableControllerBrowse = false,
@@ -1,4 +1,5 @@
using CliFx.Attributes;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.PlcFamilies;
using ZB.MOM.WW.OtOpcUa.Driver.Cli.Common;
@@ -37,7 +38,12 @@ public abstract class AbLegacyCommandBase : DriverCommandBase
/// <summary>
/// Build an <see cref="AbLegacyDriverOptions"/> with the device + tag list a subclass
/// supplies. Probe disabled for CLI one-shot runs.
/// supplies. The CLI authors typed <see cref="AbLegacyTagDefinition"/>s from operator flags;
/// v3 delivers tags to the driver as <see cref="RawTagEntry"/> records, so each typed def is
/// serialised back to its <c>TagConfig</c> blob via
/// <see cref="AbLegacyTagDefinitionFactory.ToTagConfig"/> (RawPath = the def's <c>Name</c>,
/// DeviceName = the def's <c>DeviceHostAddress</c> — the CLI's single device). Probe disabled
/// for CLI one-shot runs.
/// </summary>
/// <param name="tags">The tag definitions to include in the options.</param>
/// <returns>Configured AB Legacy driver options.</returns>
@@ -47,7 +53,8 @@ public abstract class AbLegacyCommandBase : DriverCommandBase
HostAddress: Gateway,
PlcFamily: PlcType,
DeviceName: $"cli-{PlcType}")],
Tags = tags,
RawTags = [.. tags.Select(t => new RawTagEntry(
t.Name, AbLegacyTagDefinitionFactory.ToTagConfig(t), t.WriteIdempotent, DeviceName: t.DeviceHostAddress))],
Timeout = Timeout,
Probe = new AbLegacyProbeOptions { Enabled = false },
};
@@ -1,4 +1,5 @@
using CliFx.Attributes;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.Cli.Common;
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Cli;
@@ -54,7 +55,11 @@ public abstract class FocasCommandBase : DriverCommandBase
HostAddress: HostAddress,
DeviceName: $"cli-{CncHost}:{CncPort}",
Series: Series)],
Tags = tags,
// v3: turn each typed def into the RawTagEntry delivery unit — TagConfig blob via
// FocasTagDefinitionFactory.ToTagConfig, keyed by the def's synthesised name (RawPath), routed to
// the device by the def's DeviceHostAddress (which the driver matches against Devices).
RawTags = [.. tags.Select(t => new RawTagEntry(
t.Name, FocasTagDefinitionFactory.ToTagConfig(t), t.WriteIdempotent, t.DeviceHostAddress))],
Timeout = Timeout,
Probe = new FocasProbeOptions { Enabled = false },
};
@@ -1,5 +1,6 @@
using CliFx.Attributes;
using CliFx.Exceptions;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.Cli.Common;
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.Cli;
@@ -43,9 +44,12 @@ public abstract class ModbusCommandBase : DriverCommandBase
/// <summary>
/// Construct a <see cref="ModbusDriverOptions"/> with the endpoint fields this base
/// collected + whatever <paramref name="tags"/> the subclass declares. Probe is
/// disabled — CLI runs are one-shot, the probe loop would race the operator's
/// command against its own keep-alive reads.
/// collected + whatever <paramref name="tags"/> the subclass declares. The CLI holds typed
/// definitions (synthesised from operator flags), so each is serialised back to a
/// <see cref="RawTagEntry"/> — the v3 delivery unit — via
/// <see cref="ModbusTagDefinitionFactory.ToTagConfig"/>, keyed by the def's synthesised name as
/// its RawPath. Probe is disabled — CLI runs are one-shot, the probe loop would race the
/// operator's command against its own keep-alive reads.
/// </summary>
/// <param name="tags">The tag definitions to include in the options.</param>
/// <returns>A <see cref="ModbusDriverOptions"/> ready to hand to the driver constructor.</returns>
@@ -56,7 +60,7 @@ public abstract class ModbusCommandBase : DriverCommandBase
UnitId = UnitId,
Timeout = Timeout,
AutoReconnect = !DisableAutoReconnect,
Tags = tags,
RawTags = [.. tags.Select(t => new RawTagEntry(t.Name, ModbusTagDefinitionFactory.ToTagConfig(t), t.WriteIdempotent))],
Probe = new ModbusProbeOptions { Enabled = false },
};
@@ -1,5 +1,6 @@
using CliFx.Attributes;
using CliFx.Exceptions;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.Cli.Common;
namespace ZB.MOM.WW.OtOpcUa.Driver.S7.Cli;
@@ -48,8 +49,11 @@ public abstract class S7CommandBase : DriverCommandBase
/// <summary>
/// Build an <see cref="S7DriverOptions"/> with the endpoint fields this base
/// collected + whatever <paramref name="tags"/> the subclass declares. Probe
/// disabled — CLI runs are one-shot.
/// collected + whatever <paramref name="tags"/> the subclass declares. Each typed
/// definition is serialised to its <c>TagConfig</c> blob via
/// <see cref="S7TagDefinitionFactory.ToTagConfig"/> and packaged as a
/// <see cref="RawTagEntry"/> (RawPath = the def's <c>Name</c>) — the v3 delivery shape the
/// driver rebuilds definitions from. Probe disabled — CLI runs are one-shot.
/// </summary>
/// <param name="tags">The tag definitions to include in the options.</param>
/// <returns>The constructed <see cref="S7DriverOptions"/>.</returns>
@@ -61,7 +65,7 @@ public abstract class S7CommandBase : DriverCommandBase
Rack = Rack,
Slot = Slot,
Timeout = Timeout,
Tags = tags,
RawTags = [.. tags.Select(t => new RawTagEntry(t.Name, S7TagDefinitionFactory.ToTagConfig(t), t.WriteIdempotent))],
Probe = new S7ProbeOptions { Enabled = false },
};
@@ -45,7 +45,7 @@ public sealed class BrowseCommand : TwinCATCommandBase
var options = new TwinCATDriverOptions
{
Devices = [new TwinCATDeviceOptions(Gateway, $"cli-{AmsNetId}:{AmsPort}")],
Tags = [],
RawTags = [],
Timeout = Timeout,
Probe = new TwinCATProbeOptions { Enabled = false },
UseNativeNotifications = true,
@@ -1,4 +1,5 @@
using CliFx.Attributes;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Cli;
@@ -20,8 +21,13 @@ public abstract class TwinCATTagCommandBase : TwinCATCommandBase
/// <summary>
/// Build a <see cref="TwinCATDriverOptions"/> with the AMS target this base collected +
/// the tag list a subclass supplies. Probe disabled, controller-browse disabled,
/// native notifications toggled by <see cref="PollOnly"/>.
/// the tag list a subclass supplies. The CLI holds typed definitions (synthesised from
/// operator flags), so each is serialised back to a <see cref="RawTagEntry"/> — the v3
/// delivery unit — via <see cref="TwinCATTagDefinitionFactory.ToTagConfig"/>, keyed by the
/// def's synthesised name as its RawPath, with the def's <c>DeviceHostAddress</c> carried as
/// the entry's <see cref="RawTagEntry.DeviceName"/> so the driver routes it to the sole
/// device (matched by host). Probe disabled, controller-browse disabled, native
/// notifications toggled by <see cref="PollOnly"/>.
/// </summary>
/// <param name="tags">Tag definitions for the driver.</param>
/// <returns>The built <see cref="TwinCATDriverOptions"/>.</returns>
@@ -30,7 +36,8 @@ public abstract class TwinCATTagCommandBase : TwinCATCommandBase
Devices = [new TwinCATDeviceOptions(
HostAddress: Gateway,
DeviceName: $"cli-{AmsNetId}:{AmsPort}")],
Tags = tags,
RawTags = [.. tags.Select(t => new RawTagEntry(
t.Name, TwinCATTagDefinitionFactory.ToTagConfig(t), t.WriteIdempotent, t.DeviceHostAddress))],
Timeout = Timeout,
Probe = new TwinCATProbeOptions { Enabled = false },
UseNativeNotifications = !PollOnly,
@@ -1,10 +1,12 @@
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip;
/// <summary>
/// AB CIP / EtherNet-IP driver configuration, bound from the driver's <c>DriverConfig</c>
/// JSON at <c>DriverHost.RegisterAsync</c>. One instance supports N devices (PLCs) behind
/// the same driver; per-device routing is keyed on <see cref="AbCipDeviceOptions.HostAddress"/>
/// via <c>IPerCallHostResolver</c>.
/// the same driver; per-device routing is keyed on the tag's device routing key (the RawPath's
/// device segment, delivered on <see cref="RawTagEntry.DeviceName"/>).
/// </summary>
public sealed class AbCipDriverOptions
{
@@ -12,14 +14,23 @@ public sealed class AbCipDriverOptions
/// PLCs this driver instance talks to. Each device contributes its own <see cref="AbCipHostAddress"/>
/// string as the <c>hostName</c> key used by resilience pipelines and the Admin UI.
/// </summary>
/// <remarks>
/// Wave C moves the device's live connection host into the <c>Device</c> row's <c>DeviceConfig</c>;
/// for Wave B the list stays bound from <c>DriverConfig</c> as today, and a tag routes to its device
/// by matching <see cref="RawTagEntry.DeviceName"/> against the device's name (<see cref="AbCipDeviceOptions.DeviceName"/>)
/// or its <see cref="AbCipDeviceOptions.HostAddress"/>.
/// </remarks>
public IReadOnlyList<AbCipDeviceOptions> Devices { get; init; } = [];
/// <summary>
/// Pre-declared tag map across all devices. Pre-declared tags always emit during
/// discovery; opt in to controller-side discovery via
/// Authored raw tags this driver serves. The deploy artifact hands each authored raw
/// <c>Tag</c> as a <see cref="RawTagEntry"/> (RawPath identity + driver <c>TagConfig</c> blob +
/// WriteIdempotent flag + DeviceName routing key); the driver maps each through
/// <see cref="AbCipTagDefinitionFactory.FromTagConfig"/> into its RawPath → definition table.
/// Pre-declared tags always emit during discovery; opt in to controller-side discovery via
/// <see cref="EnableControllerBrowse"/>.
/// </summary>
public IReadOnlyList<AbCipTagDefinition> Tags { get; init; } = [];
public IReadOnlyList<RawTagEntry> RawTags { get; init; } = [];
/// <summary>Per-device probe settings. Falls back to defaults when omitted.</summary>
public AbCipProbeOptions Probe { get; init; } = new();
@@ -111,8 +122,13 @@ public sealed record AbCipDeviceOptions(
/// <summary>
/// One AB-backed OPC UA variable. Mirrors the <c>ModbusTagDefinition</c> shape.
/// </summary>
/// <param name="Name">Tag name; becomes the OPC UA browse name and full reference.</param>
/// <param name="DeviceHostAddress">Which device (<see cref="AbCipDeviceOptions.HostAddress"/>) this tag lives on.</param>
/// <param name="Name">Tag name; becomes the OPC UA browse name and full reference (the RawPath in v3).</param>
/// <param name="DeviceHostAddress">The tag's <b>device routing key</b> — the RawPath's device segment,
/// threaded in from <see cref="RawTagEntry.DeviceName"/> by the driver at table-build time (the mapper
/// leaves it empty; device placement is no longer in the TagConfig blob). Resolved against the driver's
/// devices by matching <see cref="AbCipDeviceOptions.DeviceName"/> or <see cref="AbCipDeviceOptions.HostAddress"/>.
/// TODO(v3 WaveC): once the connection host moves to the Device row's DeviceConfig this is a pure logical
/// device name; the name still reads <c>DeviceHostAddress</c> to avoid churn across the driver + CLI.</param>
/// <param name="TagPath">Logix symbolic path (controller or program scope).</param>
/// <param name="DataType">Logix atomic type, or <see cref="AbCipDataType.Structure"/> for UDT-typed tags.</param>
/// <param name="Writable">When <c>true</c> and the tag's ExternalAccess permits writes, IWritable routes writes here.</param>
@@ -1,123 +0,0 @@
using System.Text.Json;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip;
/// <summary>Parses an equipment tag's <c>TagConfig</c> JSON (the shape authored by the AdminUI
/// <c>AbCipTagConfigModel</c>) into a transient <see cref="AbCipTagDefinition"/> whose
/// <see cref="AbCipTagDefinition.Name"/> equals the reference string itself, so a value the
/// driver publishes back keys the runtime's forward router correctly.</summary>
public static class AbCipEquipmentTagParser
{
/// <summary>Attempts to parse an equipment-tag reference into a transient definition.</summary>
/// <param name="reference">The equipment tag's TagConfig JSON (also used as the def identity).</param>
/// <param name="def">The transient definition when parsing succeeds.</param>
/// <returns><see langword="true"/> when <paramref name="reference"/> is an AbCip TagConfig object.</returns>
/// <remarks>
/// <see cref="AbCipTagDefinition.Writable"/> is read from the optional <c>"writable"</c>
/// boolean field in the TagConfig JSON; it defaults to <c>true</c> when the field is absent,
/// matching the record's documented default and the behaviour of pre-declared tags. Operators
/// who need a read-only OPC UA surface can author <c>"writable":false</c> in the TagConfig;
/// the PLC's ExternalAccess attribute remains the effective write gate at the wire level.
/// </remarks>
public static bool TryParse(string reference, out AbCipTagDefinition def)
{
def = null!;
// Authored tag names never start with '{' (AdminUI name validation), so a leading brace marks an equipment-tag TagConfig blob.
if (string.IsNullOrWhiteSpace(reference) || reference[0] != '{') return false;
try
{
using var doc = JsonDocument.Parse(reference);
var root = doc.RootElement;
// AbCip is a symbolic driver: the mandatory addressing field is the Logix tag path.
if (root.ValueKind != JsonValueKind.Object
|| !root.TryGetProperty("tagPath", out var tagPathEl)
|| tagPathEl.ValueKind != JsonValueKind.String)
return false;
var tagPath = tagPathEl.GetString();
if (string.IsNullOrWhiteSpace(tagPath)) return false;
var deviceHostAddress = ReadString(root, "deviceHostAddress");
// A "dataType":"Structure" input is accepted and produces a Structure-typed definition
// with Members:null. The driver treats this as a black-box dotted-path read: libplctag
// resolves the full tag path (e.g. "Motor.Speed") without enumerating UDT members.
// The address space emits a placeholder String variable; UDT member declarations are
// not supported in the equipment-tag flow.
// Strict enum read (R2-11 Phase C): a typo'd dataType rejects the tag (→ BadNodeIdUnknown)
// instead of silently defaulting to a wrong-width Good.
if (!TagConfigJson.TryReadEnumStrict(root, "dataType", AbCipDataType.DInt, out var dataType)) return false;
// Review I-1 — an equipment tag is an ARRAY ⟺ isArray:true AND arrayLength >= 1. A
// 1-element array (isArray:true, arrayLength:1) is a VALID 1-element array — the
// foundation materialises a [1] OPC UA array node — so it must read as an array, not a
// scalar. ElementCount can't carry the signal (a scalar and a 1-element array both
// have a count of 1), so the explicit IsArray flag does.
var (isArray, elementCount) = ReadArrayShape(root);
// "writable" defaults to true when absent — matches AbCipTagDefinition.Writable default.
// Now via the shared TagConfigJson.ReadWritable (explicit-false-only), byte-identical to the
// hand-rolled read it replaces.
var writable = TagConfigJson.ReadWritable(root);
def = new AbCipTagDefinition(
Name: reference, DeviceHostAddress: deviceHostAddress, TagPath: tagPath,
DataType: dataType, Writable: writable, ElementCount: elementCount, IsArray: isArray);
return true;
}
catch (JsonException) { return false; }
catch (FormatException) { return false; }
catch (InvalidOperationException) { return false; }
}
/// <summary>
/// Resolve the 1-D array shape from an <c>isArray</c> / <c>arrayLength</c> pair (the foundation
/// contract carrier). The canonical rule: the tag is an ARRAY ⟺ <c>isArray</c> is truthy AND
/// <c>arrayLength</c> is a number <c>&gt;= 1</c>. Any other combination (isArray absent/false,
/// or isArray:true with arrayLength missing/invalid/&lt;1) is a SCALAR and returns
/// <c>(IsArray: false, ElementCount: 1)</c>. This matches every other driver (Modbus, S7,
/// TwinCAT, AbLegacy) which also return scalar for that degenerate input.
/// </summary>
private static (bool IsArray, int ElementCount) ReadArrayShape(JsonElement o)
{
var isArray = o.TryGetProperty("isArray", out var a) && a.ValueKind == JsonValueKind.True;
if (!isArray) return (false, 1);
if (o.TryGetProperty("arrayLength", out var len)
&& len.ValueKind == JsonValueKind.Number
&& len.TryGetInt32(out var n)
&& n >= 1)
return (true, n);
// isArray:true but arrayLength missing/invalid/<1 — canonical rule: scalar (matches all other drivers).
return (false, 1);
}
/// <summary>
/// Deploy-time inspection (05/CONV-2): warns on a present-but-invalid <c>dataType</c> (silently
/// defaulted by the lenient runtime) and on a structurally unparseable TagConfig. Empty when clean
/// or not an equipment-tag object. Never throws.
/// </summary>
/// <param name="reference">The equipment tag's TagConfig JSON.</param>
/// <returns>The warnings; empty when clean.</returns>
public static IReadOnlyList<string> Inspect(string reference)
{
var warnings = new List<string>();
if (string.IsNullOrWhiteSpace(reference) || reference[0] != '{') return warnings;
try
{
using var doc = JsonDocument.Parse(reference);
var root = doc.RootElement;
if (root.ValueKind != JsonValueKind.Object)
{
warnings.Add("AbCip TagConfig root is not a JSON object — the tag will not resolve (BadNodeIdUnknown).");
return warnings;
}
var w = TagConfigJson.DescribeInvalidEnum<AbCipDataType>(root, "dataType");
if (w is not null) warnings.Add(w);
}
catch (JsonException)
{
warnings.Add("AbCip TagConfig is not valid JSON — the tag will not resolve (BadNodeIdUnknown).");
}
return warnings;
}
private static string ReadString(JsonElement o, string name)
=> o.TryGetProperty(name, out var e) && e.ValueKind == JsonValueKind.String
? e.GetString() ?? "" : "";
}
@@ -0,0 +1,226 @@
using System.Text.Json;
using System.Text.Json.Nodes;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip;
/// <summary>
/// v3 pure mapper: turns an authored raw tag's <c>TagConfig</c> JSON (the shape produced by the
/// AdminUI <c>AbCipTagConfigModel</c>) into an <see cref="AbCipTagDefinition"/>. Under v3 a tag's
/// identity is its <b>RawPath</b> (a cluster-scoped slash path), not the address blob — so the
/// produced definition's <see cref="AbCipTagDefinition.Name"/> is the RawPath the driver was handed,
/// which is exactly the wire reference the driver's <c>RawPath → def</c> resolver keys on. The driver
/// builds that table by mapping each <see cref="RawTagEntry"/> the deploy artifact delivers through
/// <see cref="FromTagConfig"/>; <see cref="ToTagConfig"/> is the inverse, used by authoring paths (the
/// Client CLI + the driver-test <c>AbCipRawTags</c> helper) that hold a typed definition and need to
/// synthesise the <c>TagConfig</c> blob for a <see cref="RawTagEntry"/>.
/// </summary>
/// <remarks>
/// <para>
/// <b>Device placement is NOT in the blob.</b> The pre-v3 parser read a <c>deviceHostAddress</c>
/// key off the TagConfig; v3 drops it — a tag's device is the RawPath's device segment, delivered
/// out-of-band on <see cref="RawTagEntry.DeviceName"/> and threaded onto the definition's
/// <see cref="AbCipTagDefinition.DeviceHostAddress"/> (the device routing key) by the driver at
/// table-build time. <see cref="FromTagConfig"/> therefore leaves that field empty.
/// </para>
/// <para>
/// <b>Array / member / safety round-trip.</b> Today's production authoring surface
/// (<c>AbCipTagConfigModel</c>) emits only <c>tagPath</c> / <c>dataType</c> / <c>writable</c>, so a
/// real equipment tag maps to a scalar atomic definition. The additional <c>isArray</c> /
/// <c>arrayLength</c> / <c>safetyTag</c> / <c>members</c> keys are read (and emitted by
/// <see cref="ToTagConfig"/>) so the retired pre-declared-tag coverage — arrays and declared UDT
/// member fan-out — survives the migration to the RawPath seam. They are additive: absent keys map
/// to the scalar / no-member / non-safety defaults.
/// </para>
/// </remarks>
public static class AbCipTagDefinitionFactory
{
/// <summary>
/// Maps an authored <c>TagConfig</c> object to a typed definition keyed by <paramref name="rawPath"/>.
/// The input is always an authored TagConfig JSON object (there is no name-vs-blob heuristic in v3).
/// The mandatory addressing field is the Logix <c>tagPath</c>; <c>dataType</c> is read STRICTLY — a
/// present-but-invalid (typo'd) value rejects the whole tag (returns <see langword="false"/> ⇒ the
/// driver surfaces <c>BadNodeIdUnknown</c>) rather than silently defaulting to a wrong-width Good.
/// <see cref="AbCipTagDefinition.DeviceHostAddress"/> and <see cref="AbCipTagDefinition.WriteIdempotent"/>
/// are NOT read from the blob — the driver threads them in from the tag's <see cref="RawTagEntry"/>.
/// </summary>
/// <param name="tagConfig">The authored equipment-tag TagConfig JSON.</param>
/// <param name="rawPath">The tag's RawPath — becomes the definition's identity (<c>Name</c>).</param>
/// <param name="def">The mapped definition when this returns <see langword="true"/>.</param>
/// <returns><see langword="true"/> when <paramref name="tagConfig"/> is a valid AbCip address object.</returns>
/// <remarks>
/// <see cref="AbCipTagDefinition.Writable"/> is read from the optional <c>"writable"</c> boolean;
/// it defaults to <c>true</c> when absent, matching the record's documented default. Operators who
/// need a read-only OPC UA surface can author <c>"writable":false</c>; the PLC's ExternalAccess
/// attribute remains the effective write gate at the wire level.
/// </remarks>
public static bool FromTagConfig(string tagConfig, string rawPath, out AbCipTagDefinition def)
{
def = null!;
if (string.IsNullOrWhiteSpace(tagConfig)) return false;
try
{
using var doc = JsonDocument.Parse(tagConfig);
var root = doc.RootElement;
// AbCip is a symbolic driver: the mandatory addressing field is the Logix tag path.
if (root.ValueKind != JsonValueKind.Object
|| !root.TryGetProperty("tagPath", out var tagPathEl)
|| tagPathEl.ValueKind != JsonValueKind.String)
return false;
var tagPath = tagPathEl.GetString();
if (string.IsNullOrWhiteSpace(tagPath)) return false;
// Strict enum read (R2-11 Phase C): a typo'd dataType rejects the tag (→ BadNodeIdUnknown)
// instead of silently defaulting to a wrong-width Good.
if (!TagConfigJson.TryReadEnumStrict(root, "dataType", AbCipDataType.DInt, out var dataType)) return false;
// Review I-1 — an equipment tag is an ARRAY ⟺ isArray:true AND arrayLength >= 1. A 1-element
// array is a VALID 1-element array (the foundation materialises a [1] OPC UA array node).
var (isArray, elementCount) = ReadArrayShape(root);
// "writable" defaults to true when absent — matches AbCipTagDefinition.Writable default.
var writable = TagConfigJson.ReadWritable(root);
var safetyTag = ReadBool(root, "safetyTag");
// Optional declared UDT member fan-out (round-trips the retired pre-declared coverage). A
// malformed member (missing name / typo'd dataType) rejects the whole tag, mirroring the
// strict top-level contract.
IReadOnlyList<AbCipStructureMember>? members = null;
if (root.TryGetProperty("members", out var membersEl) && membersEl.ValueKind == JsonValueKind.Array)
{
var list = new List<AbCipStructureMember>();
foreach (var m in membersEl.EnumerateArray())
{
if (m.ValueKind != JsonValueKind.Object
|| !m.TryGetProperty("name", out var nameEl)
|| nameEl.ValueKind != JsonValueKind.String
|| string.IsNullOrWhiteSpace(nameEl.GetString()))
return false;
if (!TagConfigJson.TryReadEnumStrict(m, "dataType", AbCipDataType.DInt, out var memberType)) return false;
var (memberIsArray, memberCount) = ReadArrayShape(m);
list.Add(new AbCipStructureMember(
Name: nameEl.GetString()!,
DataType: memberType,
Writable: ReadBoolDefaultTrue(m, "writable"),
WriteIdempotent: ReadBool(m, "writeIdempotent"),
ElementCount: memberCount,
IsArray: memberIsArray));
}
if (list.Count > 0) members = list;
}
def = new AbCipTagDefinition(
Name: rawPath, DeviceHostAddress: "", TagPath: tagPath!,
DataType: dataType, Writable: writable, WriteIdempotent: false,
Members: members, SafetyTag: safetyTag, ElementCount: elementCount, IsArray: isArray);
return true;
}
catch (JsonException) { return false; }
catch (FormatException) { return false; }
catch (InvalidOperationException) { return false; }
}
/// <summary>
/// Inverse of <see cref="FromTagConfig"/>: serialises a typed definition back to the camelCase
/// <c>TagConfig</c> JSON the mapper reads. Used by authoring paths (the Client CLI, the driver-test
/// <c>AbCipRawTags</c> helper) that construct an <see cref="AbCipTagDefinition"/> and need the
/// <c>TagConfig</c> string to hand the driver as a <see cref="RawTagEntry"/>. Enum values are written
/// as their name strings; optional fields are emitted only when non-default so the blob stays minimal.
/// <see cref="AbCipTagDefinition.DeviceHostAddress"/> (device routing key) and
/// <see cref="AbCipTagDefinition.WriteIdempotent"/> are NOT emitted — they travel on the
/// <see cref="RawTagEntry"/>, not inside the blob.
/// </summary>
/// <param name="def">The definition to serialise.</param>
/// <returns>The camelCase TagConfig JSON string.</returns>
public static string ToTagConfig(AbCipTagDefinition def)
{
ArgumentNullException.ThrowIfNull(def);
var o = new JsonObject
{
["tagPath"] = def.TagPath,
["dataType"] = def.DataType.ToString(),
["writable"] = def.Writable,
};
if (def.IsArray)
{
o["isArray"] = true;
o["arrayLength"] = Math.Max(1, def.ElementCount);
}
if (def.SafetyTag) o["safetyTag"] = true;
if (def.Members is { Count: > 0 } members)
{
var arr = new JsonArray();
foreach (var m in members)
{
var mo = new JsonObject
{
["name"] = m.Name,
["dataType"] = m.DataType.ToString(),
["writable"] = m.Writable,
};
if (m.WriteIdempotent) mo["writeIdempotent"] = true;
if (m.IsArray)
{
mo["isArray"] = true;
mo["arrayLength"] = Math.Max(1, m.ElementCount);
}
arr.Add(mo);
}
o["members"] = arr;
}
return o.ToJsonString();
}
/// <summary>
/// Resolve the 1-D array shape from an <c>isArray</c> / <c>arrayLength</c> pair (the foundation
/// contract carrier). The canonical rule: the tag is an ARRAY ⟺ <c>isArray</c> is truthy AND
/// <c>arrayLength</c> is a number <c>&gt;= 1</c>. Any other combination is a SCALAR and returns
/// <c>(IsArray: false, ElementCount: 1)</c> — matching every other driver.
/// </summary>
private static (bool IsArray, int ElementCount) ReadArrayShape(JsonElement o)
{
var isArray = o.TryGetProperty("isArray", out var a) && a.ValueKind == JsonValueKind.True;
if (!isArray) return (false, 1);
if (o.TryGetProperty("arrayLength", out var len)
&& len.ValueKind == JsonValueKind.Number
&& len.TryGetInt32(out var n)
&& n >= 1)
return (true, n);
// isArray:true but arrayLength missing/invalid/<1 — canonical rule: scalar (matches all other drivers).
return (false, 1);
}
/// <summary>
/// Deploy-time inspection (05/CONV-2): warns on a present-but-invalid <c>dataType</c> (silently
/// defaulted by the lenient runtime) and on a structurally unparseable TagConfig. Empty when clean
/// or not an equipment-tag object. Never throws.
/// </summary>
/// <param name="reference">The equipment tag's TagConfig JSON.</param>
/// <returns>The warnings; empty when clean.</returns>
public static IReadOnlyList<string> Inspect(string reference)
{
var warnings = new List<string>();
if (string.IsNullOrWhiteSpace(reference) || reference[0] != '{') return warnings;
try
{
using var doc = JsonDocument.Parse(reference);
var root = doc.RootElement;
if (root.ValueKind != JsonValueKind.Object)
{
warnings.Add("AbCip TagConfig root is not a JSON object — the tag will not resolve (BadNodeIdUnknown).");
return warnings;
}
var w = TagConfigJson.DescribeInvalidEnum<AbCipDataType>(root, "dataType");
if (w is not null) warnings.Add(w);
}
catch (JsonException)
{
warnings.Add("AbCip TagConfig is not valid JSON — the tag will not resolve (BadNodeIdUnknown).");
}
return warnings;
}
private static bool ReadBool(JsonElement o, string name)
=> o.TryGetProperty(name, out var e) && e.ValueKind == JsonValueKind.True;
private static bool ReadBoolDefaultTrue(JsonElement o, string name)
=> !o.TryGetProperty(name, out var e) || e.ValueKind != JsonValueKind.False;
}
@@ -82,12 +82,23 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
_discoveredUdtShapes[UdtShapeKey(deviceHostAddress, structName)] = shape;
private readonly PollGroupEngine _poll;
// Devices keyed by host address — the primary registry (GetDeviceState, discovery, probe, diagnostics).
private readonly Dictionary<string, DeviceState> _devices = new(StringComparer.OrdinalIgnoreCase);
private readonly Dictionary<string, AbCipTagDefinition> _tagsByName = new(StringComparer.OrdinalIgnoreCase);
// Tag-routing index: a tag's device routing key (DeviceName ?? HostAddress, plus the HostAddress alias)
// → its DeviceState. A tag resolves to its device by DeviceName-or-host so multi-device routing keys on
// the RawPath's device segment. TODO(v3 WaveC): collapses to a pure DeviceName index once the connection
// host moves into the Device row's DeviceConfig.
private readonly Dictionary<string, DeviceState> _devicesByRoutingKey = new(StringComparer.OrdinalIgnoreCase);
// v3 authored RawPath → definition table (parents + fanned-out UDT members), keyed case-sensitive
// ordinal. Built in InitializeAsync from _options.RawTags via the pure mapper.
private readonly Dictionary<string, AbCipTagDefinition> _tagsByRawPath = new(StringComparer.Ordinal);
// Top-level authored definitions (parents + flat tags, NOT synthesised members), in author order —
// the source DiscoverAsync groups by device. Members are synthesised inside the discovery/read paths.
private readonly List<AbCipTagDefinition> _declaredTags = new();
// Resolves a read/write/subscribe fullReference to a tag definition, bridging the two
// authoring models: an authored tag-table entry (by name) OR an equipment tag whose
// reference is its raw TagConfig JSON (parsed once via AbCipEquipmentTagParser, cached).
// Resolves a read/write/subscribe fullReference (always a RawPath in v3) to a tag definition by a
// single hit on the authored _tagsByRawPath table; a miss is a miss (the pre-v3 blob-parse fallback
// is retired — see EquipmentTagRefResolver).
private readonly EquipmentTagRefResolver<AbCipTagDefinition> _resolver;
private readonly ILogger<AbCipDriver> _logger;
@@ -128,8 +139,7 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
_templateReaderFactory = templateReaderFactory ?? new LibplctagTemplateReaderFactory();
_logger = logger ?? NullLogger<AbCipDriver>.Instance;
_resolver = new EquipmentTagRefResolver<AbCipTagDefinition>(
r => _tagsByName.TryGetValue(r, out var t) ? t : null,
r => AbCipEquipmentTagParser.TryParse(r, out var d) ? d : null);
r => _tagsByRawPath.TryGetValue(r, out var t) ? t : null);
_poll = new PollGroupEngine(
reader: ReadAsync,
onChange: (handle, tagRef, snapshot) =>
@@ -248,7 +258,7 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
if (!string.IsNullOrWhiteSpace(driverConfigJson))
{
var parsed = AbCipDriverFactoryExtensions.ParseOptions(_driverInstanceId, driverConfigJson);
if (parsed.Devices.Count > 0 || parsed.Tags.Count > 0)
if (parsed.Devices.Count > 0 || parsed.RawTags.Count > 0)
{
_options = parsed;
_alarmProjection = new AbCipAlarmProjection(this, _options.AlarmPollInterval, _logger);
@@ -261,18 +271,39 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
?? throw new InvalidOperationException(
$"AbCip device has invalid HostAddress '{device.HostAddress}' — expected 'ab://gateway[:port]/cip-path'.");
var profile = AbCipPlcFamilyProfile.ForFamily(device.PlcFamily);
_devices[device.HostAddress] = new DeviceState(addr, device, profile);
var state = new DeviceState(addr, device, profile);
_devices[device.HostAddress] = state;
// Route index: a tag reaches this device by matching its DeviceName routing key OR its
// host address. Devices with no explicit DeviceName are identified purely by host today.
_devicesByRoutingKey[device.HostAddress] = state;
if (!string.IsNullOrEmpty(device.DeviceName))
_devicesByRoutingKey[device.DeviceName] = state;
}
foreach (var tag in _options.Tags)
// Build the RawPath → definition table from the authored raw tags. Each entry's TagConfig blob
// is mapped by the pure factory; the entry's DeviceName (routing key) + WriteIdempotent flag are
// threaded onto the def (they live on the RawTagEntry, not inside the blob). A mapper miss is
// skipped (logged), never thrown — a bad tag must not fail the whole driver init.
foreach (var entry in _options.RawTags)
{
// Duplicate-key check: a collision means two configured tags have the same name.
if (!AbCipTagDefinitionFactory.FromTagConfig(entry.TagConfig, entry.RawPath, out var mapped))
{
_logger.LogWarning(
"AbCip tag config did not map to a definition; skipping. Driver={DriverInstanceId} RawPath={RawPath}",
_driverInstanceId, entry.RawPath);
continue;
}
var tag = mapped with { DeviceHostAddress = entry.DeviceName, WriteIdempotent = entry.WriteIdempotent };
// Duplicate-key check: a collision means two authored tags share the same RawPath.
// Fail fast at init time with a diagnostic rather than silently clobbering.
if (_tagsByName.TryGetValue(tag.Name, out var existingTag))
if (_tagsByRawPath.TryGetValue(tag.Name, out var existingTag))
throw new InvalidOperationException(
$"AbCip tag name collision: '{tag.Name}' is declared more than once. " +
$"Existing entry DeviceHostAddress='{existingTag.DeviceHostAddress}', " +
$"TagPath='{existingTag.TagPath}'. Rename or remove the duplicate.");
_tagsByName[tag.Name] = tag;
_tagsByRawPath[tag.Name] = tag;
_declaredTags.Add(tag);
if (tag.DataType == AbCipDataType.Structure && tag.Members is { Count: > 0 })
{
@@ -295,12 +326,12 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
// Member fan-out duplicate check: a member-path collision means two
// configured structure tags produce the same member path, or a member
// name collides with an independently-declared tag.
if (_tagsByName.TryGetValue(memberTag.Name, out var existingMember))
if (_tagsByRawPath.TryGetValue(memberTag.Name, out var existingMember))
throw new InvalidOperationException(
$"AbCip tag name collision: '{memberTag.Name}' is produced by both " +
$"'{tag.Name}.{member.Name}' (member fan-out) and an existing tag " +
$"'{existingMember.Name}'. Rename one of the configured tags to resolve.");
_tagsByName[memberTag.Name] = memberTag;
_tagsByRawPath[memberTag.Name] = memberTag;
}
}
}
@@ -383,8 +414,10 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
state.DisposeHandles();
}
_devices.Clear();
_tagsByName.Clear();
_resolver.Clear(); // drop transient equipment-tag parses so a config change re-parses
_devicesByRoutingKey.Clear();
_tagsByRawPath.Clear();
_declaredTags.Clear();
_resolver.Clear(); // no-op in v3 (the resolver reads the live table by closure); kept for symmetry
_health = new DriverHealth(DriverState.Unknown, _health.LastSuccessfulRead, null);
}
@@ -502,19 +535,19 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
// ---- IPerCallHostResolver ----
/// <summary>
/// Resolve a read/write/subscribe reference to the device host that keys its per-host
/// resilience (bulkhead / circuit breaker). CONV-4: routes through
/// <see cref="EquipmentTagRefResolver{TDef}"/> so an equipment-tag reference (raw TagConfig
/// JSON) resolves to its OWN authored device rather than the first-device fallback — else a
/// broken device B would trip device A's breaker. The resolver caches parses, so this adds
/// no per-call cost after first use.
/// Resolve a read/write/subscribe reference (a RawPath in v3) to the device host that keys its
/// per-host resilience (bulkhead / circuit breaker). CONV-4: routes through the authored table so a
/// tag resolves to its OWN device's host rather than the first-device fallback — else a broken
/// device B would trip device A's breaker. The tag's device routing key is resolved to the device's
/// actual host address (the resilience key), matching by DeviceName-or-host.
/// </summary>
/// <param name="fullReference">The tag reference (authored name or equipment-tag JSON).</param>
/// <param name="fullReference">The tag RawPath reference.</param>
/// <returns>The device host key.</returns>
public string ResolveHost(string fullReference)
{
if (_resolver.TryResolve(fullReference, out var def) && !string.IsNullOrEmpty(def.DeviceHostAddress))
return def.DeviceHostAddress;
if (_resolver.TryResolve(fullReference, out var def)
&& _devicesByRoutingKey.TryGetValue(def.DeviceHostAddress, out var state))
return state.Options.HostAddress;
return _options.Devices.FirstOrDefault()?.HostAddress ?? DriverInstanceId;
}
@@ -536,7 +569,7 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
// grouping is itself gated behind EnableDeclarationOnlyUdtGrouping — Studio 5000 may
// reorder UDT members vs declaration order, so the fast path is opt-in only.
var plan = AbCipUdtReadPlanner.Build(
fullReferences, _tagsByName, _options.EnableDeclarationOnlyUdtGrouping);
fullReferences, _tagsByRawPath, _options.EnableDeclarationOnlyUdtGrouping);
foreach (var group in plan.Groups)
await ReadGroupAsync(group, results, now, cancellationToken).ConfigureAwait(false);
@@ -564,7 +597,7 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
results[fb.OriginalIndex] = new DataValueSnapshot(null, AbCipStatusMapper.BadNotSupported, null, now);
return;
}
if (!_devices.TryGetValue(def.DeviceHostAddress, out var device))
if (!_devicesByRoutingKey.TryGetValue(def.DeviceHostAddress, out var device))
{
results[fb.OriginalIndex] = new DataValueSnapshot(null, AbCipStatusMapper.BadNodeIdUnknown, null, now);
return;
@@ -646,7 +679,7 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
{
var parent = group.ParentDefinition;
if (!_devices.TryGetValue(parent.DeviceHostAddress, out var device))
if (!_devicesByRoutingKey.TryGetValue(parent.DeviceHostAddress, out var device))
{
StampGroupStatus(group, results, now, AbCipStatusMapper.BadNodeIdUnknown);
return;
@@ -736,7 +769,7 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
results[i] = new WriteResult(AbCipStatusMapper.BadNotWritable);
continue;
}
if (!_devices.TryGetValue(def.DeviceHostAddress, out var device))
if (!_devicesByRoutingKey.TryGetValue(def.DeviceHostAddress, out var device))
{
results[i] = new WriteResult(AbCipStatusMapper.BadNodeIdUnknown);
continue;
@@ -1017,9 +1050,9 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
// Pre-declared tags — always emitted; the primary config path. UDT tags with declared
// Members fan out into a sub-folder + one Variable per member instead of a single
// Structure Variable (Structure has no useful scalar value + member-addressable paths
// are what downstream consumers actually want).
var preDeclared = _options.Tags.Where(t =>
string.Equals(t.DeviceHostAddress, device.HostAddress, StringComparison.OrdinalIgnoreCase));
// are what downstream consumers actually want). A tag reaches this device when its
// device routing key matches the device's DeviceName or its host address.
var preDeclared = _declaredTags.Where(t => RoutesToDevice(t, device));
foreach (var tag in preDeclared)
{
if (AbCipSystemTagFilter.IsSystemTag(tag.Name)) continue;
@@ -1235,6 +1268,14 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
// a legacy definition carrying only ElementCount > 1 stays an array for back-compat.
private static bool IsArrayTag(AbCipTagDefinition tag) => tag.IsArray || tag.ElementCount > 1;
// A tag routes to a device when its device routing key (DeviceHostAddress, threaded from the
// RawTagEntry.DeviceName) matches the device's DeviceName or its host address. TODO(v3 WaveC):
// collapses to a DeviceName-only match once the connection host lives on the Device row's DeviceConfig.
private static bool RoutesToDevice(AbCipTagDefinition tag, AbCipDeviceOptions device) =>
string.Equals(tag.DeviceHostAddress, device.HostAddress, StringComparison.OrdinalIgnoreCase)
|| (!string.IsNullOrEmpty(device.DeviceName)
&& string.Equals(tag.DeviceHostAddress, device.DeviceName, StringComparison.OrdinalIgnoreCase));
private static DriverAttributeInfo ToAttributeInfo(AbCipTagDefinition tag) => new(
FullName: tag.Name,
DriverDataType: tag.DataType.ToDriverDataType(),
@@ -1,5 +1,5 @@
using System.Text.Json;
using System.Text.Json.Serialization;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Core.Hosting;
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip;
@@ -68,9 +68,11 @@ public static class AbCipDriverFactoryExtensions
AllowPacking: d.AllowPacking,
ConnectionSize: d.ConnectionSize))]
: [],
Tags = dto.Tags is { Count: > 0 }
? [.. dto.Tags.Select(t => BuildTag(t, driverInstanceId))]
: [],
// v3: the deploy artifact (Wave C) populates RawTags with the cluster's authored raw AbCip
// tags (RawPath + TagConfig blob + WriteIdempotent + DeviceName). The driver maps each entry
// through AbCipTagDefinitionFactory.FromTagConfig at Initialize; the legacy pre-declared DTO
// tag path (structured Tags[] → BuildTag) is retired.
RawTags = dto.RawTags is { Count: > 0 } ? [.. dto.RawTags] : [],
Probe = new AbCipProbeOptions
{
Enabled = dto.Probe?.Enabled ?? true,
@@ -100,32 +102,6 @@ public static class AbCipDriverFactoryExtensions
private static TimeSpan PositiveTimeoutOrDefault(int? ms, int defaultMs) =>
TimeSpan.FromMilliseconds(ms is int v and > 0 ? v : defaultMs);
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<AbCipDataType>(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<AbCipDataType>(m.DataType, t.Name, driverInstanceId,
$"Members[{m.Name}].DataType"),
Writable: m.Writable ?? true,
WriteIdempotent: m.WriteIdempotent ?? false,
ElementCount: m.ElementCount is > 0 ? m.ElementCount.Value : 1,
IsArray: m.IsArray))]
: null,
SafetyTag: t.SafetyTag ?? false,
ElementCount: t.ElementCount is > 0 ? t.ElementCount.Value : 1,
IsArray: t.IsArray);
private static T ParseEnum<T>(string? raw, string? tagName, string driverInstanceId, string field,
T? fallback = null) where T : struct, Enum
{
@@ -182,9 +158,10 @@ public static class AbCipDriverFactoryExtensions
public List<AbCipDeviceDto>? Devices { get; init; }
/// <summary>
/// Gets or sets the list of tags to monitor.
/// Gets or sets the authored raw tags (RawPath + TagConfig blob + WriteIdempotent + DeviceName)
/// the deploy artifact delivers. The driver maps each through the tag-definition factory at Initialize.
/// </summary>
public List<AbCipTagDto>? Tags { get; init; }
public List<RawTagEntry>? RawTags { get; init; }
/// <summary>
/// Gets or sets the probe configuration.
@@ -220,100 +197,6 @@ public static class AbCipDriverFactoryExtensions
public int? ConnectionSize { get; init; }
}
internal sealed class AbCipTagDto
{
/// <summary>
/// Gets or sets the tag name.
/// </summary>
public string? Name { get; init; }
/// <summary>
/// Gets or sets the device host address.
/// </summary>
public string? DeviceHostAddress { get; init; }
/// <summary>
/// Gets or sets the tag path.
/// </summary>
public string? TagPath { get; init; }
/// <summary>
/// Gets or sets the data type.
/// </summary>
public string? DataType { get; init; }
/// <summary>
/// Gets or sets whether the tag is writable.
/// </summary>
public bool? Writable { get; init; }
/// <summary>
/// Gets or sets whether write is idempotent.
/// </summary>
public bool? WriteIdempotent { get; init; }
/// <summary>
/// Gets or sets the list of structure members.
/// </summary>
public List<AbCipMemberDto>? Members { get; init; }
/// <summary>
/// Gets or sets whether this is a safety tag.
/// </summary>
public bool? SafetyTag { get; init; }
/// <summary>
/// Gets or sets the explicit 1-D array signal — <c>true</c> ⟺ the tag is an array (even a
/// 1-element one). Mirrors <c>AbCipTagDefinition.IsArray</c>; optional (absent ⇒ scalar).
/// </summary>
[JsonPropertyName("isArray")]
public bool IsArray { get; init; }
/// <summary>
/// Gets or sets the number of elements for a 1-D array tag; <c>1</c> (or absent) for a scalar.
/// Mirrors <c>AbCipTagDefinition.ElementCount</c>; only positive values are honoured.
/// </summary>
[JsonPropertyName("elementCount")]
public int? ElementCount { get; init; }
}
internal sealed class AbCipMemberDto
{
/// <summary>
/// Gets or sets the member name.
/// </summary>
public string? Name { get; init; }
/// <summary>
/// Gets or sets the data type.
/// </summary>
public string? DataType { get; init; }
/// <summary>
/// Gets or sets whether the member is writable.
/// </summary>
public bool? Writable { get; init; }
/// <summary>
/// Gets or sets whether write is idempotent.
/// </summary>
public bool? WriteIdempotent { get; init; }
/// <summary>
/// Gets or sets the explicit 1-D array signal for the member — <c>true</c> ⟺ the member is
/// an array (even a 1-element one). Mirrors <c>AbCipStructureMember.IsArray</c>; optional.
/// </summary>
[JsonPropertyName("isArray")]
public bool IsArray { get; init; }
/// <summary>
/// Gets or sets the number of elements for a 1-D array member; <c>1</c> (or absent) for a
/// scalar. Mirrors <c>AbCipStructureMember.ElementCount</c>; only positive values are honoured.
/// </summary>
[JsonPropertyName("elementCount")]
public int? ElementCount { get; init; }
}
internal sealed class AbCipProbeDto
{
/// <summary>
@@ -89,14 +89,22 @@ public sealed class AbCipDriverProbe : IDriverProbe
// Forward Open to confirm the remote endpoint speaks CIP, not just TCP.
var profile = AbCipPlcFamilyProfile.ForFamily(firstDevice?.PlcFamily ?? AbCipPlcFamily.ControlLogix);
// Prefer the first declared tag path as the probe tag name (it must exist on the PLC).
// Fall back to a benign system attribute that is present on all ControlLogix/CompactLogix
// controllers (@raw_cpu_type); still returns ErrorNotFound on Micro800 / MicroLogix, which
// is sufficient to confirm CIP reachability.
var tagName = opts.Tags.FirstOrDefault(
t => string.Equals(t.DeviceHostAddress, firstDevice?.HostAddress, StringComparison.OrdinalIgnoreCase))
?.TagPath
?? opts.Tags.FirstOrDefault()?.TagPath
// Prefer the first authored tag path as the probe tag name (it must exist on the PLC). v3: the
// tags arrive as RawTagEntry blobs — map each through the pure factory to recover its TagPath +
// device routing key. Fall back to a benign system attribute present on all ControlLogix /
// CompactLogix controllers (@raw_cpu_type); still returns ErrorNotFound on Micro800 / MicroLogix,
// which is sufficient to confirm CIP reachability.
var mappedTags = opts.RawTags
.Select(e => AbCipTagDefinitionFactory.FromTagConfig(e.TagConfig, e.RawPath, out var d)
? (Ok: true, Device: e.DeviceName, d.TagPath)
: (Ok: false, Device: e.DeviceName, TagPath: null!))
.Where(x => x.Ok)
.ToList();
var tagName = mappedTags.FirstOrDefault(
x => string.Equals(x.Device, firstDevice?.HostAddress, StringComparison.OrdinalIgnoreCase)
|| (firstDevice?.DeviceName is { } dn && string.Equals(x.Device, dn, StringComparison.OrdinalIgnoreCase)))
.TagPath
?? mappedTags.FirstOrDefault().TagPath
?? "@raw_cpu_type";
var p = new AbCipTagCreateParams(
@@ -18,9 +18,10 @@ public static class AbCipUdtReadPlanner
{
/// <summary>
/// Split <paramref name="requests"/> into whole-UDT groups + per-tag leftovers.
/// <paramref name="tagsByName"/> is the driver's <c>_tagsByName</c> map — both parent
/// UDT rows and their fanned-out member rows live there. Lookup is OrdinalIgnoreCase
/// to match the driver's dictionary semantics. When
/// <paramref name="tagsByName"/> is the driver's <c>_tagsByRawPath</c> map (RawPath → def) —
/// both parent UDT rows and their fanned-out member rows live there. Tag lookup honours the
/// passed dictionary's comparer (v3: case-sensitive Ordinal RawPaths); the internal
/// parent-name grouping is OrdinalIgnoreCase. When
/// <paramref name="enableDeclarationOnlyGrouping"/> is <c>false</c> no groups are
/// formed — every reference goes to the per-tag fallback path so member decoding never
/// relies on declaration-order offsets that may not match the controller layout.
@@ -1,4 +1,5 @@
using System.ComponentModel.DataAnnotations;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.PlcFamilies;
namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy;
@@ -14,8 +15,15 @@ public sealed class AbLegacyDriverOptions
/// <summary>Gets or sets the list of PCCC devices to connect to.</summary>
public IReadOnlyList<AbLegacyDeviceOptions> Devices { get; init; } = [];
/// <summary>Gets or sets the list of PCCC tag definitions.</summary>
public IReadOnlyList<AbLegacyTagDefinition> Tags { get; init; } = [];
/// <summary>
/// Authored raw tags this driver serves. The deploy artifact hands each authored raw
/// <c>Tag</c> as a <see cref="RawTagEntry"/> (RawPath identity + driver <c>TagConfig</c> blob +
/// WriteIdempotent flag + owning <see cref="RawTagEntry.DeviceName"/>); the driver maps each
/// through <see cref="AbLegacyTagDefinitionFactory.FromTagConfig"/> into its RawPath → definition
/// table, routing each to its device by <see cref="RawTagEntry.DeviceName"/>. AbLegacy has no
/// discovery protocol — the driver serves exactly these.
/// </summary>
public IReadOnlyList<RawTagEntry> RawTags { get; init; } = [];
/// <summary>Gets or sets the probe (connectivity check) options.</summary>
public AbLegacyProbeOptions Probe { get; init; } = new();
@@ -1,107 +0,0 @@
using System.Text.Json;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy;
/// <summary>Parses an equipment tag's <c>TagConfig</c> JSON (the shape authored by the AdminUI
/// <c>AbLegacyTagConfigModel</c>) into a transient <see cref="AbLegacyTagDefinition"/> whose
/// <see cref="AbLegacyTagDefinition.Name"/> equals the reference string itself, so a value the
/// driver publishes back keys the runtime's forward router correctly.</summary>
public static class AbLegacyEquipmentTagParser
{
/// <summary>Attempts to parse an equipment-tag reference into a transient definition.</summary>
/// <param name="reference">The equipment tag's TagConfig JSON (also used as the def identity).</param>
/// <param name="def">The transient definition when parsing succeeds.</param>
/// <returns><see langword="true"/> when <paramref name="reference"/> is an AbLegacy address object.</returns>
/// <remarks>
/// <para>
/// When <c>isArray</c> is the JSON literal <see langword="true"/> but <c>arrayLength</c> is
/// absent, zero, or negative, the result is silently a <b>scalar</b>
/// (<see cref="AbLegacyTagDefinition.ArrayLength"/> is <see langword="null"/>).
/// A valid positive <c>arrayLength</c> is required to produce an array tag; <c>isArray:true</c>
/// alone is not sufficient. This is intentional: a stale length behind a cleared or absent
/// <c>isArray</c> flag must never produce an orphan array tag that mismatches its scalar OPC UA
/// node (see in-source comment, review C-2).
/// </para>
/// </remarks>
public static bool TryParse(string reference, out AbLegacyTagDefinition def)
{
def = null!;
// Authored tag names never start with '{' (AdminUI name validation), so a leading brace marks an equipment-tag TagConfig blob.
if (string.IsNullOrWhiteSpace(reference) || reference[0] != '{') return false;
try
{
using var doc = JsonDocument.Parse(reference);
var root = doc.RootElement;
if (root.ValueKind != JsonValueKind.Object
|| !root.TryGetProperty("address", out var addr)
|| addr.ValueKind != JsonValueKind.String)
return false;
var address = addr.GetString();
if (string.IsNullOrWhiteSpace(address)) return false;
// Strict enum read (R2-11 Phase C): a typo'd dataType rejects the tag (→ BadNodeIdUnknown)
// instead of silently defaulting to a wrong-width Good.
if (!TagConfigJson.TryReadEnumStrict(root, "dataType", AbLegacyDataType.Int, out var dataType)) return false;
var deviceHostAddress = ReadString(root, "deviceHostAddress");
int? arrayLength = null;
if (IsArrayFlag(root))
{
var rawLength = ReadInt(root, "arrayLength");
if (rawLength >= 1)
arrayLength = Math.Min(rawLength, AbLegacyArray.MaxElements);
}
// "writable" defaults to true when absent (today's value) — makes read-only equipment tags
// authorable (UNDER-6).
var writable = TagConfigJson.ReadWritable(root);
def = new AbLegacyTagDefinition(
Name: reference, DeviceHostAddress: deviceHostAddress, Address: address,
DataType: dataType, Writable: writable, ArrayLength: arrayLength);
return true;
}
catch (JsonException) { return false; }
catch (FormatException) { return false; }
catch (InvalidOperationException) { return false; }
}
/// <summary>
/// Deploy-time inspection (05/CONV-2): warns on a present-but-invalid <c>dataType</c> (silently
/// defaulted by the lenient runtime) and on a structurally unparseable TagConfig. Empty when clean
/// or not an equipment-tag object. Never throws.
/// </summary>
/// <param name="reference">The equipment tag's TagConfig JSON.</param>
/// <returns>The warnings; empty when clean.</returns>
public static IReadOnlyList<string> Inspect(string reference)
{
var warnings = new List<string>();
if (string.IsNullOrWhiteSpace(reference) || reference[0] != '{') return warnings;
try
{
using var doc = JsonDocument.Parse(reference);
var root = doc.RootElement;
if (root.ValueKind != JsonValueKind.Object)
{
warnings.Add("AbLegacy TagConfig root is not a JSON object — the tag will not resolve (BadNodeIdUnknown).");
return warnings;
}
var w = TagConfigJson.DescribeInvalidEnum<AbLegacyDataType>(root, "dataType");
if (w is not null) warnings.Add(w);
}
catch (JsonException)
{
warnings.Add("AbLegacy TagConfig is not valid JSON — the tag will not resolve (BadNodeIdUnknown).");
}
return warnings;
}
private static string ReadString(JsonElement o, string name)
=> o.TryGetProperty(name, out var e) && e.ValueKind == JsonValueKind.String
? e.GetString() ?? "" : "";
private static int ReadInt(JsonElement o, string name)
=> o.TryGetProperty(name, out var e) && e.ValueKind == JsonValueKind.Number
&& e.TryGetInt32(out var v) ? v : 0;
// True only when the `isArray` property is the JSON literal true. Absent or false → scalar.
private static bool IsArrayFlag(JsonElement o)
=> o.TryGetProperty("isArray", out var e) && e.ValueKind == JsonValueKind.True;
}
@@ -0,0 +1,158 @@
using System.Text.Json;
using System.Text.Json.Nodes;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy;
/// <summary>
/// v3 pure mapper: turns an authored raw tag's <c>TagConfig</c> JSON (the shape produced by the
/// AdminUI <c>AbLegacyTagConfigModel</c>) into an <see cref="AbLegacyTagDefinition"/>. Under v3 a
/// tag's identity is its <b>RawPath</b> (a cluster-scoped slash path), not the address blob — so the
/// produced definition's <see cref="AbLegacyTagDefinition.Name"/> is the RawPath the driver was
/// handed, which is exactly the wire reference the driver's <c>RawPath → def</c> resolver keys on.
/// The driver builds that table by mapping each <see cref="RawTagEntry"/> the deploy artifact
/// delivers through <see cref="FromTagConfig"/>; <see cref="ToTagConfig"/> is the inverse, used by
/// authoring paths (the Client CLI) that hold a typed definition and need to synthesise the
/// <c>TagConfig</c> blob for a <see cref="RawTagEntry"/>.
/// </summary>
/// <remarks>
/// <para>
/// <b>Device routing is out of band.</b> The AB Legacy driver is multi-device, but a tag's
/// device no longer travels inside the <c>TagConfig</c> blob — the <c>deviceHostAddress</c> key
/// is DROPPED here. The deploy artifact carries the tag's device on the
/// <see cref="RawTagEntry.DeviceName"/> segment instead; the driver threads that name onto the
/// definition (resolving it to a configured device) at table-build time.
/// </para>
/// </remarks>
public static class AbLegacyTagDefinitionFactory
{
/// <summary>
/// Maps an authored <c>TagConfig</c> object to a typed definition keyed by <paramref name="rawPath"/>.
/// The input is always an authored TagConfig JSON object (there is no name-vs-blob heuristic in v3).
/// The <c>dataType</c> enum is read STRICTLY — a present-but-invalid (typo'd) value rejects the whole
/// tag (returns <see langword="false"/> ⇒ the driver surfaces <c>BadNodeIdUnknown</c>) rather than
/// silently defaulting to a wrong-width Good. <see cref="AbLegacyTagDefinition.WriteIdempotent"/> is
/// NOT read from the blob — it is a platform flag the caller threads in from the tag's
/// <see cref="RawTagEntry.WriteIdempotent"/>. <see cref="AbLegacyTagDefinition.DeviceHostAddress"/>
/// is left empty here — device routing travels on <see cref="RawTagEntry.DeviceName"/> and the driver
/// resolves it at table-build time.
/// </summary>
/// <param name="tagConfig">The authored equipment-tag TagConfig JSON.</param>
/// <param name="rawPath">The tag's RawPath — becomes the definition's identity (<c>Name</c>).</param>
/// <param name="def">The mapped definition when this returns <see langword="true"/>.</param>
/// <returns><see langword="true"/> when <paramref name="tagConfig"/> is a valid AbLegacy address object.</returns>
/// <remarks>
/// <para>
/// When <c>isArray</c> is the JSON literal <see langword="true"/> but <c>arrayLength</c> is
/// absent, zero, or negative, the result is silently a <b>scalar</b>
/// (<see cref="AbLegacyTagDefinition.ArrayLength"/> is <see langword="null"/>).
/// A valid positive <c>arrayLength</c> is required to produce an array tag; <c>isArray:true</c>
/// alone is not sufficient. This is intentional: a stale length behind a cleared or absent
/// <c>isArray</c> flag must never produce an orphan array tag that mismatches its scalar OPC UA
/// node (see in-source comment, review C-2).
/// </para>
/// </remarks>
public static bool FromTagConfig(string tagConfig, string rawPath, out AbLegacyTagDefinition def)
{
def = null!;
if (string.IsNullOrWhiteSpace(tagConfig)) return false;
try
{
using var doc = JsonDocument.Parse(tagConfig);
var root = doc.RootElement;
if (root.ValueKind != JsonValueKind.Object
|| !root.TryGetProperty("address", out var addr)
|| addr.ValueKind != JsonValueKind.String)
return false;
var address = addr.GetString();
if (string.IsNullOrWhiteSpace(address)) return false;
// Strict enum read (R2-11 Phase C): a typo'd dataType rejects the tag (→ BadNodeIdUnknown)
// instead of silently defaulting to a wrong-width Good.
if (!TagConfigJson.TryReadEnumStrict(root, "dataType", AbLegacyDataType.Int, out var dataType)) return false;
int? arrayLength = null;
if (IsArrayFlag(root))
{
var rawLength = ReadInt(root, "arrayLength");
if (rawLength >= 1)
arrayLength = Math.Min(rawLength, AbLegacyArray.MaxElements);
}
// "writable" defaults to true when absent (today's value) — makes read-only equipment tags
// authorable (UNDER-6).
var writable = TagConfigJson.ReadWritable(root);
def = new AbLegacyTagDefinition(
Name: rawPath, DeviceHostAddress: "", Address: address,
DataType: dataType, Writable: writable, WriteIdempotent: false, ArrayLength: arrayLength);
return true;
}
catch (JsonException) { return false; }
catch (FormatException) { return false; }
catch (InvalidOperationException) { return false; }
}
/// <summary>
/// Inverse of <see cref="FromTagConfig"/>: serialises a typed definition back to the camelCase
/// <c>TagConfig</c> JSON the mapper reads. Used by authoring paths (the Client CLI) that construct
/// an <see cref="AbLegacyTagDefinition"/> from operator flags and need the <c>TagConfig</c> string
/// to hand the driver as a <see cref="RawTagEntry"/>. The enum value is written as its name string;
/// the optional array fields are emitted only for a real array tag. <c>WriteIdempotent</c> and
/// <c>deviceHostAddress</c> are NOT emitted — they travel on the <see cref="RawTagEntry"/>
/// (<see cref="RawTagEntry.WriteIdempotent"/> / <see cref="RawTagEntry.DeviceName"/>), not inside
/// the blob.
/// </summary>
/// <param name="def">The definition to serialise.</param>
/// <returns>The camelCase TagConfig JSON string.</returns>
public static string ToTagConfig(AbLegacyTagDefinition def)
{
ArgumentNullException.ThrowIfNull(def);
var o = new JsonObject
{
["address"] = def.Address,
["dataType"] = def.DataType.ToString(),
["writable"] = def.Writable,
};
if (def.ArrayLength is { } len)
{
o["isArray"] = true;
o["arrayLength"] = len;
}
return o.ToJsonString();
}
/// <summary>
/// Deploy-time inspection (05/CONV-2): warns on a present-but-invalid <c>dataType</c> (silently
/// defaulted by the lenient runtime) and on a structurally unparseable TagConfig. Empty when clean
/// or not an equipment-tag object. Never throws.
/// </summary>
/// <param name="reference">The equipment tag's TagConfig JSON.</param>
/// <returns>The warnings; empty when clean.</returns>
public static IReadOnlyList<string> Inspect(string reference)
{
var warnings = new List<string>();
if (string.IsNullOrWhiteSpace(reference) || reference[0] != '{') return warnings;
try
{
using var doc = JsonDocument.Parse(reference);
var root = doc.RootElement;
if (root.ValueKind != JsonValueKind.Object)
{
warnings.Add("AbLegacy TagConfig root is not a JSON object — the tag will not resolve (BadNodeIdUnknown).");
return warnings;
}
var w = TagConfigJson.DescribeInvalidEnum<AbLegacyDataType>(root, "dataType");
if (w is not null) warnings.Add(w);
}
catch (JsonException)
{
warnings.Add("AbLegacy TagConfig is not valid JSON — the tag will not resolve (BadNodeIdUnknown).");
}
return warnings;
}
private static int ReadInt(JsonElement o, string name)
=> o.TryGetProperty(name, out var e) && e.ValueKind == JsonValueKind.Number
&& e.TryGetInt32(out var v) ? v : 0;
// True only when the `isArray` property is the JSON literal true. Absent or false → scalar.
private static bool IsArrayFlag(JsonElement o)
=> o.TryGetProperty("isArray", out var e) && e.ValueKind == JsonValueKind.True;
}
@@ -19,11 +19,13 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
private readonly ILogger<AbLegacyDriver> _logger;
private readonly PollGroupEngine _poll;
private readonly Dictionary<string, DeviceState> _devices = new(StringComparer.OrdinalIgnoreCase);
private readonly Dictionary<string, AbLegacyTagDefinition> _tagsByName = new(StringComparer.OrdinalIgnoreCase);
// Resolves a read/write/subscribe fullReference to a tag definition, bridging the two
// authoring models: an authored tag-table entry (by name) OR an equipment tag whose
// reference is its raw TagConfig JSON (parsed once via AbLegacyEquipmentTagParser, cached).
// v3 authored-tag table, keyed by RawPath (the tag identity + driver wire reference). RawPath is
// case-sensitive ordinal. Built in InitializeAsync from _options.RawTags via the pure mapper.
private readonly Dictionary<string, AbLegacyTagDefinition> _tagsByRawPath = new(StringComparer.Ordinal);
// Resolves a read/write/subscribe RawPath reference to its tag definition via a single hit on the
// authored _tagsByRawPath table (v3: the reference is always a RawPath; a miss is a miss).
private readonly EquipmentTagRefResolver<AbLegacyTagDefinition> _resolver;
// volatile: _health is read by GetHealth() on any thread while ReadAsync / WriteAsync /
@@ -60,8 +62,7 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
_tagFactory = tagFactory ?? new LibplctagLegacyTagFactory();
_logger = logger ?? NullLogger<AbLegacyDriver>.Instance;
_resolver = new EquipmentTagRefResolver<AbLegacyTagDefinition>(
r => _tagsByName.TryGetValue(r, out var t) ? t : null,
r => AbLegacyEquipmentTagParser.TryParse(r, out var d) ? d : null);
r => _tagsByRawPath.TryGetValue(r, out var t) ? t : null);
_poll = new PollGroupEngine(
reader: ReadAsync,
onChange: (handle, tagRef, snapshot) =>
@@ -107,13 +108,32 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
var profile = AbLegacyPlcFamilyProfile.ForFamily(device.PlcFamily);
_devices[device.HostAddress] = new DeviceState(addr, device, profile);
}
foreach (var tag in _options.Tags) _tagsByName[tag.Name] = tag;
// Build the RawPath → definition table from the authored raw tags. Each entry's TagConfig
// blob is mapped by the pure factory; the entry's WriteIdempotent flag is threaded onto the
// def (it lives on the RawTagEntry, not inside the blob), and its DeviceName is resolved to
// the owning device's host so the existing DeviceHostAddress-keyed routing continues to work.
// A mapper miss is skipped (logged), never thrown — a bad tag must not fail the whole init.
foreach (var entry in _options.RawTags)
{
if (!AbLegacyTagDefinitionFactory.FromTagConfig(entry.TagConfig, entry.RawPath, out var def))
{
_logger.LogWarning(
"AbLegacy tag config did not map to a definition; skipping. Driver={DriverInstanceId} RawPath={RawPath}",
_driverInstanceId, entry.RawPath);
continue;
}
_tagsByRawPath[entry.RawPath] = def with
{
WriteIdempotent = entry.WriteIdempotent,
DeviceHostAddress = ResolveDeviceHost(entry.DeviceName),
};
}
// Validate tag types against their device's family profile. Long (32-bit integer)
// and String (ST-file) are not supported by all PCCC families; reject them early
// so a misconfigured tag fails at init time with a clear message rather than
// surfacing an opaque comms error at runtime.
foreach (var tag in _options.Tags)
foreach (var tag in _tagsByRawPath.Values)
{
if (!_devices.TryGetValue(tag.DeviceHostAddress, out var deviceForTag)) continue;
var profile = deviceForTag.Profile;
@@ -157,7 +177,7 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
state.DisposeRuntimes();
}
_devices.Clear();
_tagsByName.Clear();
_tagsByRawPath.Clear();
_resolver.Clear(); // drop transient equipment-tag parses so a config change re-parses
throw;
}
@@ -183,7 +203,7 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
state.DisposeRuntimes();
}
_devices.Clear();
_tagsByName.Clear();
_tagsByRawPath.Clear();
_resolver.Clear(); // drop transient equipment-tag parses so a config change re-parses
_health = new DriverHealth(DriverState.Unknown, _health.LastSuccessfulRead, null);
}
@@ -415,7 +435,7 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
{
var label = device.DeviceName ?? device.HostAddress;
var deviceFolder = root.Folder(device.HostAddress, label);
var tagsForDevice = _options.Tags.Where(t =>
var tagsForDevice = _tagsByRawPath.Values.Where(t =>
string.Equals(t.DeviceHostAddress, device.HostAddress, StringComparison.OrdinalIgnoreCase));
foreach (var tag in tagsForDevice)
{
@@ -522,6 +542,42 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
new HostStatusChangedEventArgs(state.Options.HostAddress, old, newState));
}
/// <summary>
/// Resolve the <see cref="RawTagEntry.DeviceName"/> a raw tag carries to the host-address key of
/// the device it should route to. The AB Legacy driver is multi-device and keys its
/// <c>_devices</c> table by <c>HostAddress</c>, so this maps the tag's owning device name to that
/// key: it matches the name against each configured <see cref="AbLegacyDeviceOptions"/> by its
/// <see cref="AbLegacyDeviceOptions.DeviceName"/> first, then by <c>HostAddress</c> as a fallback,
/// and returns the matched device's <c>HostAddress</c>. When the name matches nothing but the
/// driver has exactly one device, that sole device is used (single-device deployments never need
/// to name their tag's device). Otherwise returns empty — the tag is unroutable and its
/// read/write surfaces <c>BadNodeIdUnknown</c>.
/// </summary>
/// <remarks>
/// TODO(v3 WaveC): the live device host address must ultimately come from the owning
/// <c>Device</c> row's <c>DeviceConfig</c> (resolved by the deploy artifact / bootstrapper) rather
/// than from a configured <see cref="AbLegacyDriverOptions.Devices"/> entry. Until Wave C threads
/// the Device row through, the best available option is to route by matching the delivered
/// <see cref="RawTagEntry.DeviceName"/> against <c>_options.Devices</c> and reusing that entry's
/// <c>HostAddress</c> as the connection key.
/// </remarks>
/// <param name="deviceName">The owning device name delivered on the <see cref="RawTagEntry"/>.</param>
/// <returns>The matched device's <c>HostAddress</c>; empty when unroutable.</returns>
private string ResolveDeviceHost(string deviceName)
{
if (!string.IsNullOrEmpty(deviceName))
{
foreach (var d in _options.Devices)
if (string.Equals(d.DeviceName, deviceName, StringComparison.OrdinalIgnoreCase))
return d.HostAddress;
foreach (var d in _options.Devices)
if (string.Equals(d.HostAddress, deviceName, StringComparison.OrdinalIgnoreCase))
return d.HostAddress;
}
// Best-available fallback: a single-device driver routes every tag to its sole device.
return _options.Devices.Count == 1 ? _options.Devices[0].HostAddress : "";
}
// ---- IPerCallHostResolver ----
/// <summary>
@@ -744,7 +800,7 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
state.DisposeRuntimes();
}
_devices.Clear();
_tagsByName.Clear();
_tagsByRawPath.Clear();
_resolver.Clear(); // drop transient equipment-tag parses so a config change re-parses
_health = new DriverHealth(DriverState.Unknown, _health.LastSuccessfulRead, null);
}
@@ -1,6 +1,7 @@
using System.Text.Json;
using System.Text.Json.Serialization;
using Microsoft.Extensions.Logging;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Core.Hosting;
using ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.PlcFamilies;
@@ -66,19 +67,11 @@ public static class AbLegacyDriverFactoryExtensions
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<AbLegacyDataType>(t.DataType, driverInstanceId, "DataType",
tagName: t.Name),
Writable: t.Writable ?? true,
WriteIdempotent: t.WriteIdempotent ?? false))]
: [],
// v3: the deploy artifact (Wave C) populates rawTags with the cluster's authored raw AbLegacy
// tags (RawPath + TagConfig blob + WriteIdempotent + owning DeviceName). The driver maps each
// RawTagEntry's TagConfig blob through AbLegacyTagDefinitionFactory at Initialize and routes it
// to its device by DeviceName; the legacy pre-declared DTO tag path is retired.
RawTags = dto.RawTags is { Count: > 0 } ? [.. dto.RawTags] : [],
Probe = new AbLegacyProbeOptions
{
Enabled = dto.Probe?.Enabled ?? true,
@@ -145,9 +138,11 @@ public static class AbLegacyDriverFactoryExtensions
public List<AbLegacyDeviceDto>? Devices { get; init; }
/// <summary>
/// Gets or sets the list of tags to monitor.
/// Gets or sets the authored raw tags (RawPath + TagConfig blob + WriteIdempotent + owning
/// DeviceName) the deploy artifact delivers. The driver maps each through
/// <see cref="AbLegacyTagDefinitionFactory.FromTagConfig"/> at Initialize.
/// </summary>
public List<AbLegacyTagDto>? Tags { get; init; }
public List<RawTagEntry>? RawTags { get; init; }
/// <summary>
/// Gets or sets the probe configuration.
@@ -173,39 +168,6 @@ public static class AbLegacyDriverFactoryExtensions
public string? DeviceName { get; init; }
}
internal sealed class AbLegacyTagDto
{
/// <summary>
/// Gets or sets the tag name.
/// </summary>
public string? Name { get; init; }
/// <summary>
/// Gets or sets the device host address.
/// </summary>
public string? DeviceHostAddress { get; init; }
/// <summary>
/// Gets or sets the tag address.
/// </summary>
public string? Address { get; init; }
/// <summary>
/// Gets or sets the data type.
/// </summary>
public string? DataType { get; init; }
/// <summary>
/// Gets or sets whether the tag is writable.
/// </summary>
public bool? Writable { get; init; }
/// <summary>
/// Gets or sets whether write is idempotent.
/// </summary>
public bool? WriteIdempotent { get; init; }
}
internal sealed class AbLegacyProbeDto
{
/// <summary>
@@ -93,10 +93,7 @@ public sealed class AbLegacyDriverProbe : IDriverProbe
// S:0 is present on all PCCC PLCs that support the status file, so a "not found" response
// still proves PCCC connectivity; a successful read is the happy path.
var tagName = opts.Probe.ProbeAddress
?? opts.Tags.FirstOrDefault(
t => string.Equals(t.DeviceHostAddress, firstDevice?.HostAddress, StringComparison.OrdinalIgnoreCase))
?.Address
?? opts.Tags.FirstOrDefault()?.Address
?? FirstProbeableTagAddress(opts, firstDevice)
?? "S:0";
var p = new AbLegacyTagCreateParams(
@@ -187,4 +184,30 @@ public sealed class AbLegacyDriverProbe : IDriverProbe
var parsed = AbLegacyHostAddress.TryParse(firstDevice.HostAddress);
return (parsed, firstDevice);
}
/// <summary>
/// Pick a PCCC address to probe from the authored raw tags: prefer a tag owned by the device
/// being probed, else any authored tag. v3 tags carry no typed address — the RawTagEntry's
/// <c>TagConfig</c> blob is mapped through <see cref="AbLegacyTagDefinitionFactory.FromTagConfig"/>
/// to recover the PCCC address. Returns <see langword="null"/> when no tag maps, so the caller
/// falls back to the status file (<c>S:0</c>).
/// </summary>
/// <param name="opts">The deserialised driver options.</param>
/// <param name="firstDevice">The device being probed (matched by DeviceName / HostAddress).</param>
/// <returns>A probeable PCCC address, or <see langword="null"/>.</returns>
private static string? FirstProbeableTagAddress(AbLegacyDriverOptions opts, AbLegacyDeviceOptions? firstDevice)
{
static string? Address(RawTagEntry e) =>
AbLegacyTagDefinitionFactory.FromTagConfig(e.TagConfig, e.RawPath, out var d) ? d.Address : null;
foreach (var e in opts.RawTags)
if ((string.Equals(e.DeviceName, firstDevice?.DeviceName, StringComparison.OrdinalIgnoreCase)
|| string.Equals(e.DeviceName, firstDevice?.HostAddress, StringComparison.OrdinalIgnoreCase))
&& Address(e) is { } matched)
return matched;
foreach (var e in opts.RawTags)
if (Address(e) is { } any)
return any;
return null;
}
}
@@ -1,4 +1,5 @@
using System.ComponentModel.DataAnnotations;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
@@ -10,8 +11,16 @@ public sealed class FocasDriverOptions
{
/// <summary>Gets the list of configured CNC devices.</summary>
public IReadOnlyList<FocasDeviceOptions> Devices { get; init; } = [];
/// <summary>Gets the list of FOCAS tag definitions.</summary>
public IReadOnlyList<FocasTagDefinition> Tags { get; init; } = [];
/// <summary>
/// Authored raw tags this driver serves. The deploy artifact hands each authored raw
/// <c>Tag</c> as a <see cref="RawTagEntry"/> (RawPath identity + driver <c>TagConfig</c> blob +
/// WriteIdempotent flag + <see cref="RawTagEntry.DeviceName"/>); the driver maps each through
/// <see cref="FocasTagDefinitionFactory.FromTagConfig"/> into its RawPath → definition table,
/// routing each tag to its device by <see cref="RawTagEntry.DeviceName"/>. FOCAS has no online
/// tag-discovery for user tags — the driver serves exactly these (plus the fixed-node tree).
/// </summary>
public IReadOnlyList<RawTagEntry> RawTags { get; init; } = [];
/// <summary>Gets the probe options.</summary>
public FocasProbeOptions Probe { get; init; } = new();
/// <summary>Gets the timeout duration for operations.</summary>
@@ -1,87 +0,0 @@
using System.Text.Json;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
/// <summary>Parses an equipment tag's <c>TagConfig</c> JSON (the shape authored by the AdminUI
/// <c>FocasTagConfigModel</c>) into a transient <see cref="FocasTagDefinition"/> whose
/// <see cref="FocasTagDefinition.Name"/> equals the reference string itself, so a value the
/// driver publishes back keys the runtime's forward router correctly.</summary>
public static class FocasEquipmentTagParser
{
/// <summary>Attempts to parse an equipment-tag reference into a transient definition.</summary>
/// <param name="reference">The equipment tag's TagConfig JSON (also used as the def identity).</param>
/// <param name="def">The transient definition when parsing succeeds.</param>
/// <returns><see langword="true"/> when <paramref name="reference"/> is a FOCAS address object.</returns>
public static bool TryParse(string reference, out FocasTagDefinition def)
{
def = null!;
// Authored tag names never start with '{' (AdminUI name validation), so a leading brace marks an equipment-tag TagConfig blob.
if (string.IsNullOrWhiteSpace(reference) || reference[0] != '{') return false;
try
{
using var doc = JsonDocument.Parse(reference);
var root = doc.RootElement;
// The address string is FOCAS's sole mandatory address field (FocasTagConfigModel.Validate).
if (root.ValueKind != JsonValueKind.Object
|| !root.TryGetProperty("address", out var addr)
|| addr.ValueKind != JsonValueKind.String)
return false;
var address = addr.GetString();
if (string.IsNullOrWhiteSpace(address)) return false;
// Strict enum read (R2-11 Phase C): a typo'd dataType rejects the tag (→ BadNodeIdUnknown)
// instead of silently defaulting to a wrong-width Good.
if (!TagConfigJson.TryReadEnumStrict(root, "dataType", FocasDataType.Int32, out var dataType)) return false;
var deviceHostAddress = ReadString(root, "deviceHostAddress") ?? "";
def = new FocasTagDefinition(
Name: reference, DeviceHostAddress: deviceHostAddress, Address: address,
// FOCAS equipment tags are FORCED read-only (05/UNDER-1): the only wire client
// (WireFocasClient.cs:71-73) returns BadNotWritable for EVERY address, so advertising a
// writable node is a lie — a write that used to reach the backend and always fail with
// BadNotWritable now fails at the driver seam with the same family of Bad status (no
// successful operation changes). Any authored `writable:true` is ignored + warned by
// Inspect. Honour the key here once PMC writes ship in the wire client.
DataType: dataType, Writable: false);
return true;
}
catch (JsonException) { return false; }
catch (FormatException) { return false; }
catch (InvalidOperationException) { return false; }
}
/// <summary>
/// Deploy-time inspection (05/CONV-2, 05/UNDER-1): warns on a present-but-invalid <c>dataType</c>
/// (silently defaulted by the lenient runtime), on an authored <c>writable:true</c> (FOCAS writes
/// are unsupported — the tag is forced read-only), and on a structurally unparseable TagConfig.
/// Empty when clean or not an equipment-tag object. Never throws.
/// </summary>
/// <param name="reference">The equipment tag's TagConfig JSON.</param>
/// <returns>The warnings; empty when clean.</returns>
public static IReadOnlyList<string> Inspect(string reference)
{
var warnings = new List<string>();
if (string.IsNullOrWhiteSpace(reference) || reference[0] != '{') return warnings;
try
{
using var doc = JsonDocument.Parse(reference);
var root = doc.RootElement;
if (root.ValueKind != JsonValueKind.Object)
{
warnings.Add("FOCAS TagConfig root is not a JSON object — the tag will not resolve (BadNodeIdUnknown).");
return warnings;
}
var w = TagConfigJson.DescribeInvalidEnum<FocasDataType>(root, "dataType");
if (w is not null) warnings.Add(w);
if (root.TryGetProperty("writable", out var wr) && wr.ValueKind == JsonValueKind.True)
warnings.Add("FOCAS writes unsupported — 'writable:true' is ignored; the tag is forced read-only until PMC writes exist.");
}
catch (JsonException)
{
warnings.Add("FOCAS TagConfig is not valid JSON — the tag will not resolve (BadNodeIdUnknown).");
}
return warnings;
}
private static string? ReadString(JsonElement o, string name)
=> o.TryGetProperty(name, out var e) && e.ValueKind == JsonValueKind.String ? e.GetString() : null;
}
@@ -0,0 +1,129 @@
using System.Text.Json;
using System.Text.Json.Nodes;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
/// <summary>
/// v3 pure mapper: turns an authored raw tag's <c>TagConfig</c> JSON (the shape produced by the
/// AdminUI <c>FocasTagConfigModel</c>) into a <see cref="FocasTagDefinition"/>. Under v3 a tag's
/// identity is its <b>RawPath</b> (a cluster-scoped slash path), not the address blob — so the
/// produced definition's <see cref="FocasTagDefinition.Name"/> is the RawPath the driver was handed,
/// which is exactly the wire reference the driver's <c>RawPath → def</c> resolver keys on. The driver
/// builds that table by mapping each <see cref="RawTagEntry"/> the deploy artifact delivers through
/// <see cref="FromTagConfig"/>; <see cref="ToTagConfig"/> is the inverse, used by authoring paths (the
/// Client CLI) that hold a typed definition and need to synthesise the <c>TagConfig</c> blob for a
/// <see cref="RawTagEntry"/>.
/// </summary>
public static class FocasTagDefinitionFactory
{
/// <summary>
/// Maps an authored <c>TagConfig</c> object to a typed definition keyed by <paramref name="rawPath"/>.
/// The input is always an authored TagConfig JSON object (there is no name-vs-blob heuristic in v3).
/// The <c>dataType</c> enum is read STRICTLY — a present-but-invalid (typo'd) value rejects the whole
/// tag (returns <see langword="false"/> ⇒ the driver surfaces <c>BadNodeIdUnknown</c>) rather than
/// silently defaulting to a wrong-width Good. The FOCAS TagConfig carries <b>no device host</b> — the
/// driver resolves the device from the tag's <see cref="RawTagEntry.DeviceName"/>, so the produced
/// definition's <see cref="FocasTagDefinition.DeviceHostAddress"/> is left empty here and filled in by
/// the driver at table-build time. Writability lives on the Tag entity / node ACL (the authored FOCAS
/// TagConfig has no <c>writable</c> key), so an absent key ⇒ read-only; an explicit <c>writable:true</c>
/// is honoured for the driver's internal write gate (the wire itself is still read-only).
/// <see cref="FocasTagDefinition.WriteIdempotent"/> is NOT read from the blob — it is a platform flag
/// the caller threads in from the tag's <see cref="RawTagEntry.WriteIdempotent"/>.
/// </summary>
/// <param name="tagConfig">The authored equipment-tag TagConfig JSON.</param>
/// <param name="rawPath">The tag's RawPath — becomes the definition's identity (<c>Name</c>).</param>
/// <param name="def">The mapped definition when this returns <see langword="true"/>.</param>
/// <returns><see langword="true"/> when <paramref name="tagConfig"/> is a valid FOCAS address object.</returns>
public static bool FromTagConfig(string tagConfig, string rawPath, out FocasTagDefinition def)
{
def = null!;
if (string.IsNullOrWhiteSpace(tagConfig)) return false;
try
{
using var doc = JsonDocument.Parse(tagConfig);
var root = doc.RootElement;
// The address string is FOCAS's sole mandatory address field (FocasTagConfigModel.Validate).
if (root.ValueKind != JsonValueKind.Object
|| !root.TryGetProperty("address", out var addr)
|| addr.ValueKind != JsonValueKind.String)
return false;
var address = addr.GetString();
if (string.IsNullOrWhiteSpace(address)) return false;
// Strict enum read (R2-11 Phase C): a typo'd dataType rejects the tag (→ BadNodeIdUnknown)
// instead of silently defaulting to a wrong-width Good.
if (!TagConfigJson.TryReadEnumStrict(root, "dataType", FocasDataType.Int32, out var dataType)) return false;
// FOCAS authored tags carry no `writable` key (writability lives on the Tag entity / node ACL):
// absent ⇒ read-only. An explicit writable:true is honoured for the driver's internal write gate
// (default-false, so a missing key stays read-only — the note is preserved).
var writable = root.TryGetProperty("writable", out var wr) && wr.ValueKind == JsonValueKind.True;
def = new FocasTagDefinition(
Name: rawPath,
// The device is resolved by the driver from RawTagEntry.DeviceName; empty here.
DeviceHostAddress: "",
Address: address,
DataType: dataType,
Writable: writable,
WriteIdempotent: false);
return true;
}
catch (JsonException) { return false; }
catch (FormatException) { return false; }
catch (InvalidOperationException) { return false; }
}
/// <summary>
/// Inverse of <see cref="FromTagConfig"/>: serialises a typed definition back to the camelCase
/// <c>TagConfig</c> JSON the mapper reads. Used by authoring paths (the Client CLI) that construct a
/// <see cref="FocasTagDefinition"/> from operator flags and need the <c>TagConfig</c> string to hand
/// the driver as a <see cref="RawTagEntry"/>. The enum is written as its name string. Neither the
/// device host (which travels on <see cref="RawTagEntry.DeviceName"/>) nor <c>WriteIdempotent</c>
/// (which travels on the <see cref="RawTagEntry"/>) is emitted.
/// </summary>
/// <param name="def">The definition to serialise.</param>
/// <returns>The camelCase TagConfig JSON string.</returns>
public static string ToTagConfig(FocasTagDefinition def)
{
ArgumentNullException.ThrowIfNull(def);
var o = new JsonObject
{
["address"] = def.Address,
["dataType"] = def.DataType.ToString(),
["writable"] = def.Writable,
};
return o.ToJsonString();
}
/// <summary>
/// Deploy-time inspection (05/CONV-2, 05/UNDER-1): warns on a present-but-invalid <c>dataType</c>
/// (silently defaulted by the lenient runtime), on an authored <c>writable:true</c> (the FOCAS wire
/// is read-only — <c>WriteAsync</c> always returns <c>BadNotWritable</c>), and on a structurally
/// unparseable TagConfig. Empty when clean or not an equipment-tag object. Never throws.
/// </summary>
/// <param name="reference">The equipment tag's TagConfig JSON.</param>
/// <returns>The warnings; empty when clean.</returns>
public static IReadOnlyList<string> Inspect(string reference)
{
var warnings = new List<string>();
if (string.IsNullOrWhiteSpace(reference) || reference[0] != '{') return warnings;
try
{
using var doc = JsonDocument.Parse(reference);
var root = doc.RootElement;
if (root.ValueKind != JsonValueKind.Object)
{
warnings.Add("FOCAS TagConfig root is not a JSON object — the tag will not resolve (BadNodeIdUnknown).");
return warnings;
}
var w = TagConfigJson.DescribeInvalidEnum<FocasDataType>(root, "dataType");
if (w is not null) warnings.Add(w);
if (root.TryGetProperty("writable", out var wr) && wr.ValueKind == JsonValueKind.True)
warnings.Add("FOCAS wire is read-only — an authored 'writable:true' still fails at the wire with BadNotWritable.");
}
catch (JsonException)
{
warnings.Add("FOCAS TagConfig is not valid JSON — the tag will not resolve (BadNodeIdUnknown).");
}
return warnings;
}
}
@@ -27,10 +27,11 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
private readonly PollGroupEngine _poll;
private readonly ILogger<FocasDriver> _logger;
private readonly Dictionary<string, DeviceState> _devices = new(StringComparer.OrdinalIgnoreCase);
private readonly Dictionary<string, FocasTagDefinition> _tagsByName = new(StringComparer.OrdinalIgnoreCase);
// Resolves a read/write fullReference to a tag definition, bridging the two authoring
// models: an authored tag-table entry (by name) OR an equipment tag whose reference is
// its raw TagConfig JSON (parsed once via FocasEquipmentTagParser, cached).
// v3: RawPath → definition, case-sensitive ordinal. Built in InitializeAsync from _options.RawTags
// via the pure FocasTagDefinitionFactory mapper (the reference is always a RawPath).
private readonly Dictionary<string, FocasTagDefinition> _tagsByRawPath = new(StringComparer.Ordinal);
// Resolves a read/write fullReference (always a RawPath in v3) to a tag definition by a single
// authored-table hit. A miss is a miss — the driver surfaces BadNodeIdUnknown, never a blob-parse.
private readonly EquipmentTagRefResolver<FocasTagDefinition> _resolver;
// Per-tag-name cache of the FocasAddress parsed once at InitializeAsync. ReadAsync /
// WriteAsync look up the pre-parsed value instead of re-parsing tag.Address on every hot
@@ -67,8 +68,7 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
_clientFactory = clientFactory ?? new Wire.WireFocasClientFactory();
_logger = logger ?? NullLogger<FocasDriver>.Instance;
_resolver = new EquipmentTagRefResolver<FocasTagDefinition>(
r => _tagsByName.TryGetValue(r, out var t) ? t : null,
ResolveEquipmentTagRef);
r => _tagsByRawPath.TryGetValue(r, out var t) ? t : null);
_poll = new PollGroupEngine(
reader: ReadAsync,
onChange: (handle, tagRef, snapshot) =>
@@ -118,31 +118,61 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
$"FOCAS device has invalid HostAddress '{device.HostAddress}' — expected 'focas://{{ip}}[:{{port}}]'.");
_devices[device.HostAddress] = new DeviceState(addr, device);
}
// Pre-flight: validate every tag's address against the declared CNC
// series so misconfigured addresses fail at init (clear config error)
// instead of producing BadOutOfRange on every read at runtime.
// Series=Unknown short-circuits the matrix; pre-matrix configs stay permissive.
foreach (var tag in _options.Tags)
// Build the RawPath → definition table from the authored raw tags. Each entry's TagConfig
// blob is mapped by the pure factory; the entry's WriteIdempotent flag + DeviceName are
// threaded onto the def (they live on the RawTagEntry, not inside the blob). Per-tag misses
// are tolerant (skipped + logged → BadNodeIdUnknown at read, "a miss is a miss") — a single
// bad tag must not fail the whole driver init. The ONE hard failure is an unknown device
// segment: a tag routed to a device not in the Devices list is a driver-level config error
// that fails fast (Driver.FOCAS-003).
foreach (var entry in _options.RawTags)
{
var parsed = FocasAddress.TryParse(tag.Address)
?? throw new InvalidOperationException(
$"FOCAS tag '{tag.Name}' has invalid Address '{tag.Address}'. " +
$"Expected forms: R100, R100.3, PARAM:1815/0, MACRO:500.");
if (!_devices.TryGetValue(tag.DeviceHostAddress, out var device))
if (!FocasTagDefinitionFactory.FromTagConfig(entry.TagConfig, entry.RawPath, out var def))
{
_logger.LogWarning(
"FOCAS tag config did not map to a definition; skipping. Driver={DriverInstanceId} RawPath={RawPath}",
_driverInstanceId, entry.RawPath);
continue;
}
// TODO(v3 WaveC): the live device host must come from the tag's Device row's DeviceConfig
// (keyed by the RawPath's device segment). Until Wave C threads DeviceConfig through, recover
// the host by matching the entry's DeviceName against the configured Devices list.
var deviceHost = ResolveDeviceHost(entry.DeviceName);
if (deviceHost is null)
throw new InvalidOperationException(
$"FOCAS tag '{tag.Name}' references device '{tag.DeviceHostAddress}' " +
$"FOCAS tag '{entry.RawPath}' references device '{entry.DeviceName}' " +
$"which is not in the Devices list. Check for a typo (e.g. wrong port or hostname).");
if (!_devices.TryGetValue(deviceHost, out var device))
throw new InvalidOperationException(
$"FOCAS tag '{entry.RawPath}' references device '{entry.DeviceName}' " +
$"which is not in the Devices list. Check for a typo (e.g. wrong port or hostname).");
// Per-tag address / capability-matrix validation is tolerant: an unparseable address or a
// capability-matrix violation skips the tag (→ BadNodeIdUnknown at read) rather than failing
// the whole init (R2-11 05/UNDER-6 — the same pre-flight, surfacing a resolver miss).
var parsed = FocasAddress.TryParse(def.Address);
if (parsed is null)
{
_logger.LogWarning(
"FOCAS tag has invalid Address '{Address}'; skipping. Driver={DriverInstanceId} RawPath={RawPath}",
def.Address, _driverInstanceId, entry.RawPath);
continue;
}
if (FocasCapabilityMatrix.Validate(device.Options.Series, parsed) is { } reason)
{
throw new InvalidOperationException(
$"FOCAS tag '{tag.Name}' ({tag.Address}) rejected by capability matrix: {reason}");
_logger.LogWarning(
"FOCAS tag '{RawPath}' ({Address}) rejected by capability matrix: {Reason}; skipping. Driver={DriverInstanceId}",
entry.RawPath, def.Address, reason, _driverInstanceId);
continue;
}
_tagsByName[tag.Name] = tag;
_tagsByRawPath[entry.RawPath] = def with
{
DeviceHostAddress = deviceHost,
WriteIdempotent = entry.WriteIdempotent,
};
// Cache the parsed FocasAddress so ReadAsync / WriteAsync don't re-parse on every
// hot-path call. The address string has already been validated
// by FocasAddress.TryParse above; reusing the parsed record avoids per-tick allocs
// on subscription pollers.
_parsedAddressesByTagName[tag.Name] = parsed;
// hot-path call. The address string has already been validated by FocasAddress.TryParse
// above; reusing the parsed record avoids per-tick allocs on subscription pollers.
_parsedAddressesByTagName[entry.RawPath] = parsed;
}
if (_options.Probe.Enabled)
@@ -223,8 +253,8 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
state.DisposeClient();
}
_devices.Clear();
_tagsByName.Clear();
_resolver.Clear(); // drop transient equipment-tag parses so a config change re-parses
_tagsByRawPath.Clear();
_resolver.Clear(); // no-op in v3 (the resolver reads the live table by closure); kept for symmetry
_parsedAddressesByTagName.Clear();
Volatile.Write(ref _health, new DriverHealth(DriverState.Unknown, Volatile.Read(ref _health).LastSuccessfulRead, null));
}
@@ -254,18 +284,22 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
internal bool IsParsedAddressCached(string reference) =>
_parsedAddressesByTagName.ContainsKey(reference);
// Resolves an equipment-tag TagConfig reference into a FocasTagDefinition, applying the SAME
// capability pre-flight authored tags get at InitializeAsync (:105-119) — R2-11 (05/UNDER-6). A
// reference whose address is unparseable, whose bound device is unknown, or whose address violates
// the device series' capability matrix returns null ⇒ resolver miss ⇒ BadNodeIdUnknown at read time,
// instead of a wrong-status read reaching the wire. The negative is cached by the resolver.
private FocasTagDefinition? ResolveEquipmentTagRef(string reference)
// TODO(v3 WaveC): the live device host must come from the tag's Device row's DeviceConfig (keyed by
// the RawPath's device segment). Until Wave C threads DeviceConfig through, recover the host by matching
// the RawTagEntry's DeviceName against the configured Devices list (by DeviceName, falling back to
// HostAddress; and, for a single-device driver, an empty DeviceName routes to the sole device). Returns
// null when no device matches — the caller fails the tag fast (Driver.FOCAS-003).
private string? ResolveDeviceHost(string deviceName)
{
if (!FocasEquipmentTagParser.TryParse(reference, out var def)) return null;
var parsed = FocasAddress.TryParse(def.Address);
if (parsed is null) return null;
if (!_devices.TryGetValue(def.DeviceHostAddress, out var device)) return null;
return FocasCapabilityMatrix.Validate(device.Options.Series, parsed) is null ? def : null;
foreach (var d in _options.Devices)
{
if (string.Equals(d.DeviceName ?? d.HostAddress, deviceName, StringComparison.OrdinalIgnoreCase)
|| string.Equals(d.HostAddress, deviceName, StringComparison.OrdinalIgnoreCase))
return d.HostAddress;
}
if (string.IsNullOrEmpty(deviceName) && _options.Devices.Count == 1)
return _options.Devices[0].HostAddress;
return null;
}
// Resolves a tag definition to its parsed FocasAddress, caching the result so that
@@ -516,7 +550,7 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
}
}
var tagsForDevice = _options.Tags.Where(t =>
var tagsForDevice = _tagsByRawPath.Values.Where(t =>
string.Equals(t.DeviceHostAddress, device.HostAddress, StringComparison.OrdinalIgnoreCase));
foreach (var tag in tagsForDevice)
{
@@ -1,5 +1,6 @@
using System.Text.Json;
using System.Text.Json.Serialization;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Core.Hosting;
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Wire;
@@ -68,18 +69,11 @@ public static class FocasDriverFactoryExtensions
Series: ParseSeries(d.Series ?? dto.Series),
PositionDecimalPlaces: d.PositionDecimalPlaces ?? 0))]
: [],
Tags = dto.Tags is { Count: > 0 }
? [.. dto.Tags.Select(t => new FocasTagDefinition(
Name: t.Name ?? throw new InvalidOperationException(
$"FOCAS config for '{driverInstanceId}' has a tag missing Name"),
DeviceHostAddress: t.DeviceHostAddress ?? throw new InvalidOperationException(
$"FOCAS tag '{t.Name}' in '{driverInstanceId}' missing DeviceHostAddress"),
Address: t.Address ?? throw new InvalidOperationException(
$"FOCAS tag '{t.Name}' in '{driverInstanceId}' missing Address"),
DataType: ParseDataType(t.DataType, t.Name!, driverInstanceId),
Writable: t.Writable ?? true,
WriteIdempotent: t.WriteIdempotent ?? false))]
: [],
// v3: the deploy artifact (Wave C) populates rawTags with the cluster's authored raw FOCAS
// tags (RawPath + TagConfig blob + WriteIdempotent + DeviceName). The driver maps each
// RawTagEntry's TagConfig blob through FocasTagDefinitionFactory at Initialize and routes it to
// its device by DeviceName; the legacy pre-declared DTO tag path is retired.
RawTags = dto.RawTags is { Count: > 0 } ? [.. dto.RawTags] : [],
Probe = new FocasProbeOptions
{
Enabled = dto.Probe?.Enabled ?? true,
@@ -190,18 +184,6 @@ public static class FocasDriverFactoryExtensions
$"FOCAS Series '{raw}' is not one of {string.Join(", ", Enum.GetNames<FocasCncSeries>())}");
}
private static FocasDataType ParseDataType(string? raw, string tagName, string driverInstanceId)
{
if (string.IsNullOrWhiteSpace(raw))
throw new InvalidOperationException(
$"FOCAS tag '{tagName}' in '{driverInstanceId}' missing DataType");
return Enum.TryParse<FocasDataType>(raw, ignoreCase: true, out var dt)
? dt
: throw new InvalidOperationException(
$"FOCAS tag '{tagName}' has unknown DataType '{raw}'. " +
$"Expected one of {string.Join(", ", Enum.GetNames<FocasDataType>())}");
}
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNameCaseInsensitive = true,
@@ -254,8 +236,11 @@ public static class FocasDriverFactoryExtensions
/// <summary>Gets or sets the list of CNC devices to manage.</summary>
public List<FocasDeviceDto>? Devices { get; init; }
/// <summary>Gets or sets the list of FOCAS tags to poll.</summary>
public List<FocasTagDto>? Tags { get; init; }
/// <summary>
/// Gets or sets the authored raw tags (RawPath + TagConfig blob + WriteIdempotent + DeviceName)
/// the driver serves. Bound verbatim into <see cref="FocasDriverOptions.RawTags"/>.
/// </summary>
public List<RawTagEntry>? RawTags { get; init; }
/// <summary>Gets or sets the probe configuration options.</summary>
public FocasProbeDto? Probe { get; init; }
@@ -290,27 +275,6 @@ public static class FocasDriverFactoryExtensions
public int? PositionDecimalPlaces { get; init; }
}
internal sealed class FocasTagDto
{
/// <summary>Gets or sets the tag name.</summary>
public string? Name { get; init; }
/// <summary>Gets or sets the hostname or IP address of the CNC device for this tag.</summary>
public string? DeviceHostAddress { get; init; }
/// <summary>Gets or sets the FOCAS address or path for this tag.</summary>
public string? Address { get; init; }
/// <summary>Gets or sets the data type for this tag.</summary>
public string? DataType { get; init; }
/// <summary>Gets or sets a value indicating whether this tag is writable.</summary>
public bool? Writable { get; init; }
/// <summary>Gets or sets a value indicating whether writes to this tag are idempotent.</summary>
public bool? WriteIdempotent { get; init; }
}
internal sealed class FocasProbeDto
{
/// <summary>Gets or sets a value indicating whether probing is enabled.</summary>
@@ -1,4 +1,5 @@
using System.ComponentModel.DataAnnotations;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Config;
@@ -26,6 +27,18 @@ public sealed record GalaxyDriverOptions(
[Display(Name = "Probe timeout (seconds)", Description = "Connection test timeout. Default 30s.", GroupName = "Diagnostics")]
[Range(1, 60)]
public int ProbeTimeoutSeconds { get; init; } = 30;
/// <summary>
/// v3 authored raw tags this driver serves. The deploy artifact hands each authored raw
/// <c>Tag</c> as a <see cref="RawTagEntry"/> — RawPath identity (the driver wire reference)
/// + driver <c>TagConfig</c> blob (carrying the Galaxy dialled address under the camelCase
/// key <c>attributeRef</c> = <c>tag_name.AttributeName</c>) + the WriteIdempotent flag. The
/// driver builds a <c>RawPath → attributeRef</c> table from these: reads/writes/subscribes
/// arrive keyed by RawPath and the driver dials the mapped attributeRef to MXAccess. Empty
/// when no tags are authored — the driver then treats every reference as its own dialled
/// address (identity), preserving pre-v3 behaviour for the live-browse discovery path.
/// </summary>
public IReadOnlyList<RawTagEntry> RawTags { get; init; } = [];
}
/// <summary>
@@ -5,9 +5,13 @@
<ImplicitUsings>enable</ImplicitUsings>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
</PropertyGroup>
<!-- NO ProjectReference. The only PackageReference is the logging abstraction -->
<!-- needed by GalaxySecretRef.ResolveApiKey's optional warning logger. -->
<ItemGroup>
<!-- v3 RawPath identity: GalaxyDriverOptions.RawTags is IReadOnlyList<RawTagEntry>,
delivered by the deploy artifact. RawTagEntry lives in the zero-dependency
Core.Abstractions leaf beside EquipmentTagRefResolver — same reference Modbus.Contracts
takes for its RawTags. Core.Abstractions has no deps, so no cycle. -->
<ProjectReference Include="..\..\Core\ZB.MOM.WW.OtOpcUa.Core.Abstractions\ZB.MOM.WW.OtOpcUa.Core.Abstractions.csproj" />
<!-- Logging abstraction needed by GalaxySecretRef.ResolveApiKey's optional warning logger. -->
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
</ItemGroup>
</Project>
@@ -32,24 +32,36 @@ internal static class AlarmRefBuilder
/// <summary>
/// Build an <see cref="AlarmConditionInfo"/> for an alarm-bearing attribute with all
/// five sub-attribute references populated. <paramref name="fullReference"/> is the
/// attribute's full reference (e.g. <c>"Tank1.Level.HiHi"</c>); the convention prefixes
/// each suffix to it.
/// five sub-attribute references populated.
/// <para>
/// v3 splits identity from dialling: <paramref name="conditionReference"/> is the alarm's
/// <b>RawPath</b> — the condition's stable identity, surfaced as
/// <see cref="AlarmConditionInfo.SourceName"/> and matched against the
/// <c>ConditionId</c> the driver reports on every transition and against the value
/// fan-out key. <paramref name="dialReference"/> is the Galaxy <b>attributeRef</b>
/// (<c>tag_name.AttributeName</c>, e.g. <c>"Tank1.Level.HiHi"</c>) — the address MXAccess
/// understands; the five live-state / ack sub-refs prefix each suffix onto it so the
/// server's <c>AlarmConditionService</c> subscribes + acks the dialled attributes. In the
/// identity case (no RawPath mapping) both arguments are the same string, reproducing the
/// pre-v3 shape exactly.
/// </para>
/// </summary>
/// <param name="fullReference">The full reference of the alarm-bearing attribute.</param>
/// <param name="conditionReference">The alarm condition's RawPath identity (= <see cref="AlarmConditionInfo.SourceName"/>).</param>
/// <param name="dialReference">The Galaxy attributeRef the five sub-refs are dialled from.</param>
/// <param name="initialSeverity">The initial alarm severity level.</param>
/// <param name="initialDescription">The initial alarm description.</param>
/// <returns>The populated <see cref="AlarmConditionInfo"/> with all five sub-attribute references.</returns>
public static AlarmConditionInfo Build(
string fullReference,
string conditionReference,
string dialReference,
AlarmSeverity initialSeverity = AlarmSeverity.Medium,
string? initialDescription = null) => new(
SourceName: fullReference,
SourceName: conditionReference,
InitialSeverity: initialSeverity,
InitialDescription: initialDescription,
InAlarmRef: fullReference + InAlarmSuffix,
PriorityRef: fullReference + PrioritySuffix,
DescAttrNameRef: fullReference + DescAttrNameSuffix,
AckedRef: fullReference + AckedSuffix,
AckMsgWriteRef: fullReference + AckMsgSuffix);
InAlarmRef: dialReference + InAlarmSuffix,
PriorityRef: dialReference + PrioritySuffix,
DescAttrNameRef: dialReference + DescAttrNameSuffix,
AckedRef: dialReference + AckedSuffix,
AckMsgWriteRef: dialReference + AckMsgSuffix);
}
@@ -31,11 +31,28 @@ public sealed class GalaxyDiscoverer
{
private readonly IGalaxyHierarchySource _source;
// v3 RawPath keying. Given a discovered Galaxy attributeRef (tag_name.AttributeName), this
// resolves the authored RawTagEntry the deploy artifact delivered for it (keyed by attributeRef),
// or null when the attribute is not authored. When present, the emitted node reference
// (DriverAttributeInfo.FullName) becomes the entry's RawPath — the v3 wire identity the server
// hands back on read/write/subscribe — and WriteIdempotent flows off the entry. When absent (no
// mapping, or the live-browse path with no authored tags) the reference falls back to the
// attributeRef, reproducing the pre-v3 shape exactly.
private readonly Func<string, RawTagEntry?>? _rawTagByAttributeRef;
/// <summary>Initializes a new GalaxyDiscoverer with the specified hierarchy source.</summary>
/// <param name="source">The Galaxy hierarchy source to use for discovery.</param>
public GalaxyDiscoverer(IGalaxyHierarchySource source)
/// <param name="rawTagByAttributeRef">
/// Optional v3 resolver mapping a discovered Galaxy attributeRef to its authored
/// <see cref="RawTagEntry"/> (RawPath + WriteIdempotent). Null (or a null return) leaves the
/// emitted reference as the attributeRef — the identity / live-browse fallback.
/// </param>
public GalaxyDiscoverer(
IGalaxyHierarchySource source,
Func<string, RawTagEntry?>? rawTagByAttributeRef = null)
{
_source = source ?? throw new ArgumentNullException(nameof(source));
_rawTagByAttributeRef = rawTagByAttributeRef;
}
/// <summary>
@@ -79,12 +96,20 @@ public sealed class GalaxyDiscoverer
{
if (string.IsNullOrEmpty(attr.AttributeName)) continue;
var fullReference = !string.IsNullOrEmpty(attr.FullTagReference)
// attributeRef = the Galaxy dialled address (tag_name.AttributeName) MXAccess reads/writes.
var attributeRef = !string.IsNullOrEmpty(attr.FullTagReference)
? StripArraySuffix(attr.FullTagReference)
: obj.TagName + "." + attr.AttributeName;
// v3: the node's driver reference (FullName) is the authored RawPath when this
// attribute is authored; otherwise it degrades to the attributeRef (identity). The
// WriteIdempotent platform flag travels on the RawTagEntry, not inside the blob.
var entry = _rawTagByAttributeRef?.Invoke(attributeRef);
var rawPath = entry?.RawPath ?? attributeRef;
var writeIdempotent = entry?.WriteIdempotent ?? false;
var info = new DriverAttributeInfo(
FullName: fullReference,
FullName: rawPath,
DriverDataType: DataTypeMap.Map(attr.MxDataType),
IsArray: attr.IsArray,
ArrayDim: attr.IsArray && attr.ArrayDimensionPresent && attr.ArrayDimension > 0
@@ -92,15 +117,18 @@ public sealed class GalaxyDiscoverer
: null,
SecurityClass: SecurityMap.Map(attr.SecurityClassification),
IsHistorized: attr.IsHistorized,
IsAlarm: attr.IsAlarm);
IsAlarm: attr.IsAlarm,
WriteIdempotent: writeIdempotent);
var handle = folder.Variable(attr.AttributeName, attr.AttributeName, info);
// Alarm-bearing attributes ship the full sub-attribute ref set so the server's
// AlarmConditionService can subscribe + ack-write without re-deriving the names.
// AlarmConditionService can subscribe + ack-write without re-deriving the names. The
// condition identity (SourceName / ConditionId) is the RawPath — matching the value
// fan-out key — while the five live-state sub-refs dial off the Galaxy attributeRef.
if (attr.IsAlarm)
{
handle.MarkAsAlarmCondition(AlarmRefBuilder.Build(fullReference));
handle.MarkAsAlarmCondition(AlarmRefBuilder.Build(rawPath, attributeRef));
}
}
}
@@ -1,3 +1,4 @@
using System.Text.Json;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using ZB.MOM.WW.MxGateway.Client;
@@ -64,6 +65,19 @@ public sealed class GalaxyDriver
private readonly System.Collections.Concurrent.ConcurrentDictionary<string, SecurityClassification>
_securityByFullRef = new(StringComparer.OrdinalIgnoreCase);
// v3 RawPath keying. The server hands every read/write/subscribe reference as a RawPath; MXAccess
// dials the Galaxy attributeRef (tag_name.AttributeName). These two tables — built once in the ctor
// from options.RawTags (each entry's TagConfig carries the camelCase "attributeRef" key) — bridge the
// two identity domains at the driver boundary:
// _attributeRefByRawPath : RawPath -> attributeRef (forward dial: read/write/subscribe/ack)
// _rawTagByAttributeRef : attributeRef -> RawTagEntry (reverse + WriteIdempotent: discovery,
// OnDataChange, alarm ConditionId)
// Both fall back to identity on a miss, so the live-browse discovery path (no authored tags) and any
// synthetic alarm sub-ref (attributeRef.InAlarm, never an authored RawPath) dial straight through
// unchanged — reproducing pre-v3 behaviour exactly.
private readonly Dictionary<string, string> _attributeRefByRawPath;
private readonly Dictionary<string, RawTagEntry> _rawTagByAttributeRef;
// PR 4.4 — subscription lifecycle. The pump consumes the gw event stream and fans
// out OnDataChange events to every registered driver subscription via the registry's
// reverse map. The subscriber is the test seam — production uses
@@ -182,10 +196,85 @@ public sealed class GalaxyDriver
_alarmAcknowledger = alarmAcknowledger;
_alarmFeed = alarmFeed;
// Build the v3 RawPath ⇄ attributeRef tables from the authored raw tags. Empty when no tags
// are authored (live-browse path), leaving every translation an identity no-op.
(_attributeRefByRawPath, _rawTagByAttributeRef) = BuildRawTagTables(_options.RawTags, _logger);
// Forward the aggregator's transitions through IHostConnectivityProbe.
_hostStatuses.OnHostStatusChanged += (_, args) => OnHostStatusChanged?.Invoke(this, args);
}
/// <summary>
/// Build the RawPath → attributeRef and attributeRef → <see cref="RawTagEntry"/> tables from the
/// authored raw tags. Each entry's <see cref="RawTagEntry.TagConfig"/> carries the Galaxy dialled
/// address under the camelCase key <c>attributeRef</c>. Entries whose blob has no usable
/// <c>attributeRef</c> are skipped (they can't be dialled). On a duplicate attributeRef (two
/// authored RawPaths pointing at the same Galaxy attribute) the first entry wins the reverse map —
/// the value fan-out / alarm ConditionId then surfaces under that first RawPath; the forward map
/// still dials both. Case-insensitive to match the driver's reference handling.
/// </summary>
private static (Dictionary<string, string> Forward, Dictionary<string, RawTagEntry> Reverse)
BuildRawTagTables(IReadOnlyList<RawTagEntry> rawTags, ILogger logger)
{
var forward = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
var reverse = new Dictionary<string, RawTagEntry>(StringComparer.OrdinalIgnoreCase);
foreach (var entry in rawTags)
{
if (string.IsNullOrEmpty(entry.RawPath)) continue;
var attributeRef = TryReadAttributeRef(entry.TagConfig);
if (string.IsNullOrEmpty(attributeRef))
{
logger.LogWarning(
"GalaxyDriver raw tag {RawPath} has no 'attributeRef' in its TagConfig — skipped (cannot dial MXAccess).",
entry.RawPath);
continue;
}
forward[entry.RawPath] = attributeRef;
reverse.TryAdd(attributeRef, entry); // first-wins on a shared attributeRef
}
return (forward, reverse);
}
/// <summary>
/// Read the Galaxy dialled address from a tag's <c>TagConfig</c> JSON — the camelCase
/// <c>attributeRef</c> key (v3 rename of the pre-v3 PascalCase <c>FullName</c>). Case-insensitive
/// on the property name for authoring tolerance; returns null on an absent/blank/non-string value
/// or unparseable blob (the entry is then skipped, never dialled).
/// </summary>
private static string? TryReadAttributeRef(string tagConfigJson)
{
if (string.IsNullOrWhiteSpace(tagConfigJson)) return null;
try
{
using var doc = JsonDocument.Parse(tagConfigJson);
if (doc.RootElement.ValueKind != JsonValueKind.Object) return null;
foreach (var prop in doc.RootElement.EnumerateObject())
{
if (!prop.NameEquals("attributeRef")
&& !string.Equals(prop.Name, "attributeRef", StringComparison.OrdinalIgnoreCase))
{
continue;
}
if (prop.Value.ValueKind != JsonValueKind.String) return null;
var v = prop.Value.GetString();
return string.IsNullOrWhiteSpace(v) ? null : v;
}
}
catch (JsonException)
{
// Malformed blob — treat as un-dialable; BuildRawTagTables logs the skip.
}
return null;
}
/// <summary>RawPath → Galaxy attributeRef for MXAccess dialling; identity on a miss.</summary>
private string ToAttributeRef(string rawPath) =>
_attributeRefByRawPath.GetValueOrDefault(rawPath, rawPath);
/// <summary>Galaxy attributeRef → RawPath (the server-facing identity); identity on a miss.</summary>
private string ToRawPath(string attributeRef) =>
_rawTagByAttributeRef.TryGetValue(attributeRef, out var e) ? e.RawPath : attributeRef;
/// <inheritdoc />
public string DriverInstanceId => _driverInstanceId;
@@ -525,11 +614,18 @@ public sealed class GalaxyDriver
/// <summary>
/// Compare two <see cref="GalaxyDriverOptions"/> for runtime equivalence — every
/// field that drives gw session shape, address space, or reconnect behaviour
/// must match. Records get value-equality from the language, so a direct
/// equality check is enough.
/// field that drives gw session shape or reconnect behaviour must match. Compared
/// field-wise (the nested records supply value-equality) and <b>excluding
/// <see cref="GalaxyDriverOptions.RawTags"/></b>: the authored tag set is address-space
/// state re-applied through rediscovery, not a session-teardown trigger, and a record's
/// synthesized equality would compare the two <c>RawTags</c> lists by reference anyway.
/// </summary>
private static bool OptionsAreEquivalent(GalaxyDriverOptions a, GalaxyDriverOptions b) => a == b;
private static bool OptionsAreEquivalent(GalaxyDriverOptions a, GalaxyDriverOptions b) =>
a.Gateway == b.Gateway
&& a.MxAccess == b.MxAccess
&& a.Repository == b.Repository
&& a.Reconnect == b.Reconnect
&& a.ProbeTimeoutSeconds == b.ProbeTimeoutSeconds;
/// <inheritdoc />
public Task ShutdownAsync(CancellationToken cancellationToken)
@@ -596,7 +692,11 @@ public sealed class GalaxyDriver
// newly added $WinPlatform / $AppEngine objects start advising their ScanState attribute.
var capturingBuilder = new SecurityCapturingBuilder(builder, _securityByFullRef);
var source = _hierarchySource ??= BuildDefaultHierarchySource();
var discoverer = new GalaxyDiscoverer(source);
// Thread the v3 reverse resolver so the discoverer emits each authored attribute's RawPath as
// its node reference (FullName) — the security map SecurityCapturingBuilder captures is then
// keyed by RawPath, matching what WriteAsync resolves against.
var discoverer = new GalaxyDiscoverer(
source, attributeRef => _rawTagByAttributeRef.GetValueOrDefault(attributeRef));
await discoverer.DiscoverAsync(capturingBuilder, cancellationToken).ConfigureAwait(false);
if (_probeWatcher is not null)
@@ -625,10 +725,16 @@ public sealed class GalaxyDriver
ArgumentNullException.ThrowIfNull(fullReferences);
if (fullReferences.Count == 0) return Task.FromResult<IReadOnlyList<DataValueSnapshot>>([]);
// v3: the server addresses by RawPath; translate to the Galaxy attributeRef MXAccess dials.
// Read returns positionally, so no reverse translation is needed on the result. An unmapped
// RawPath dials as-is (identity) — the gateway rejects an unknown address → Bad, same as today.
var dialledRefs = new string[fullReferences.Count];
for (var i = 0; i < fullReferences.Count; i++) dialledRefs[i] = ToAttributeRef(fullReferences[i]);
if (_dataReader is not null)
{
// Test-only path — tests inject a canned reader via the internal ctor.
return _dataReader.ReadAsync(fullReferences, cancellationToken);
return _dataReader.ReadAsync(dialledRefs, cancellationToken);
}
if (_subscriber is null)
@@ -638,7 +744,7 @@ public sealed class GalaxyDriver
"Either inject a test seam via the internal ctor or call InitializeAsync against a real gateway.");
}
return ReadViaSubscribeOnceAsync(fullReferences, cancellationToken);
return ReadViaSubscribeOnceAsync(dialledRefs, cancellationToken);
}
/// <summary>
@@ -800,7 +906,15 @@ public sealed class GalaxyDriver
"issuing writes.");
}
return _dataWriter.WriteAsync(writes, ResolveSecurity, cancellationToken);
// v3: WriteRequest.FullReference is a RawPath. Translate to the Galaxy attributeRef the writer
// dials to MXAccess. Security was captured at discovery keyed by RawPath (= the node's FullName),
// so the resolver the writer invokes with the DIALLED attributeRef must reverse it back to RawPath
// before the lookup. Both legs are identity on a miss, so an un-authored tag behaves as before.
var dialledWrites = new List<WriteRequest>(writes.Count);
foreach (var w in writes) dialledWrites.Add(w with { FullReference = ToAttributeRef(w.FullReference) });
return _dataWriter.WriteAsync(
dialledWrites, attributeRef => ResolveSecurity(ToRawPath(attributeRef)), cancellationToken);
}
// ===== ISubscribable (PR 4.4) =====
@@ -830,6 +944,12 @@ public sealed class GalaxyDriver
return new GalaxySubscriptionHandle(subscriptionId);
}
// v3: the server subscribes by RawPath; the registry + gateway operate on the Galaxy attributeRef.
// Translate here (identity on a miss — a synthetic alarm sub-ref or live-browse tag dials straight
// through). The value fan-out reverse-translates back to RawPath in OnPumpDataChange.
var dialledRefs = new string[fullReferences.Count];
for (var i = 0; i < fullReferences.Count; i++) dialledRefs[i] = ToAttributeRef(fullReferences[i]);
// PR 6.3 — when the caller doesn't set a publishing interval (TimeSpan.Zero or
// negative), fall back to the configured MxAccess.PublishingIntervalMs. The
// server's UA subscription publishingInterval drives this in production; tests
@@ -837,7 +957,7 @@ public sealed class GalaxyDriver
var requested = (int)Math.Max(0, publishingInterval.TotalMilliseconds);
var bufferedIntervalMs = requested > 0 ? requested : _options.MxAccess.PublishingIntervalMs;
var results = await _subscriber
.SubscribeBulkAsync(fullReferences, bufferedIntervalMs, cancellationToken)
.SubscribeBulkAsync(dialledRefs, bufferedIntervalMs, cancellationToken)
.ConfigureAwait(false);
// Build the binding list in input order. Failed entries (gw rejected the tag) are
@@ -846,11 +966,13 @@ public sealed class GalaxyDriver
// surface lands in PR 5.3's parity tests.
// Index results once, correlate in O(1) per reference rather than
// FirstOrDefault inside the loop (O(n²) on the 50k-tag path).
// Bindings key on the dialled attributeRef — that's what the gw returns as the tag address and
// what the EventPump fans out on. OnPumpDataChange maps it back to RawPath for public consumers.
var resultIndex = BuildResultIndex(results);
var bindings = new List<TagBinding>(fullReferences.Count);
for (var i = 0; i < fullReferences.Count; i++)
var bindings = new List<TagBinding>(dialledRefs.Length);
for (var i = 0; i < dialledRefs.Length; i++)
{
var fullRef = fullReferences[i];
var fullRef = dialledRefs[i];
var hasMatch = resultIndex.TryGetValue(fullRef, out var match);
var itemHandle = hasMatch && match is { WasSuccessful: true } ? match.ItemHandle : 0;
bindings.Add(new TagBinding(fullRef, itemHandle));
@@ -1042,12 +1164,14 @@ public sealed class GalaxyDriver
// worker's STA queue.
foreach (var ack in acknowledgements)
{
// ConditionId carries the alarm full reference for the Galaxy driver —
// SourceNodeId is the OPC UA browse path, which the gateway can't address.
// The server-side condition state pairs them through AlarmConditionService.
var alarmFullReference = !string.IsNullOrEmpty(ack.ConditionId)
// ConditionId carries the alarm's RawPath (v3) for the Galaxy driver — SourceNodeId is the
// OPC UA browse path, which the gateway can't address. Translate the RawPath back to the
// Galaxy attributeRef the gateway dials (identity on a miss). The server-side condition state
// pairs them through AlarmConditionService.
var conditionReference = !string.IsNullOrEmpty(ack.ConditionId)
? ack.ConditionId
: ack.SourceNodeId;
var alarmFullReference = ToAttributeRef(conditionReference);
await _alarmAcknowledger.AcknowledgeAsync(
alarmFullReference,
ack.Comment ?? string.Empty,
@@ -1091,10 +1215,16 @@ public sealed class GalaxyDriver
transition.TransitionKind, transition.AlarmFullReference, subCount, handle is not null);
if (handle is null) return;
// v3: the ConditionId the driver reports must be the tag's RawPath — the same key the value
// fan-out uses (OnPumpDataChange) and the identity the discoverer stamped as SourceName — so the
// server-side condition state correlates the alarm to its node and a subsequent ack round-trips
// back through AcknowledgeAsync. The feed reports the Galaxy attributeRef; reverse-map it (identity
// on a miss).
var conditionRawPath = ToRawPath(transition.AlarmFullReference);
var args = new AlarmEventArgs(
SubscriptionHandle: handle,
SourceNodeId: transition.SourceObjectReference,
ConditionId: transition.AlarmFullReference,
ConditionId: conditionRawPath,
AlarmType: transition.AlarmTypeName,
Message: transition.Description,
Severity: transition.SeverityBucket,
@@ -1132,8 +1262,19 @@ public sealed class GalaxyDriver
/// </summary>
private void OnPumpDataChange(object? sender, DataChangeEventArgs args)
{
OnDataChange?.Invoke(this, args);
// v3: the pump fans out keyed by the dialled attributeRef; the server correlates values to nodes
// by RawPath (the node's FullName). Reverse-map before surfacing to public OnDataChange consumers.
// Identity on a miss, so a live-browse tag surfaces under its attributeRef unchanged.
var publicArgs = args;
var rawPath = ToRawPath(args.FullReference);
if (!string.Equals(rawPath, args.FullReference, StringComparison.Ordinal))
{
publicArgs = args with { FullReference = rawPath };
}
OnDataChange?.Invoke(this, publicArgs);
// Probe watching stays in the attributeRef domain — ScanState attributes are discovered platform
// attributes, never authored RawPaths, so use the untranslated reference here.
if (_probeWatcher is not null
&& args.FullReference.EndsWith(PerPlatformProbeWatcher.ProbeSuffix, StringComparison.OrdinalIgnoreCase))
{
@@ -1,6 +1,7 @@
using System.Text.Json;
using System.Text.Json.Serialization;
using Microsoft.Extensions.Logging;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Core.Hosting;
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Config;
@@ -79,7 +80,13 @@ public static class GalaxyDriverFactoryExtensions
Reconnect: new GalaxyReconnectOptions(
InitialBackoffMs: dto.Reconnect?.InitialBackoffMs ?? 500,
MaxBackoffMs: dto.Reconnect?.MaxBackoffMs ?? 30_000,
ReplayOnSessionLost: dto.Reconnect?.ReplayOnSessionLost ?? true));
ReplayOnSessionLost: dto.Reconnect?.ReplayOnSessionLost ?? true))
{
// v3: the deploy artifact delivers the authored raw tags (RawPath identity + TagConfig blob
// carrying the camelCase "attributeRef" + WriteIdempotent flag). The driver builds its
// RawPath → attributeRef table from these. Empty when the config omits them.
RawTags = dto.RawTags ?? [],
};
return new GalaxyDriver(driverInstanceId, options, loggerFactory?.CreateLogger<GalaxyDriver>());
}
@@ -102,6 +109,12 @@ public static class GalaxyDriverFactoryExtensions
public RepositoryDto? Repository { get; init; }
/// <summary>Gets or sets the reconnect configuration.</summary>
public ReconnectDto? Reconnect { get; init; }
/// <summary>
/// Gets or sets the v3 authored raw tags. Each <see cref="RawTagEntry"/> carries the RawPath
/// identity, the driver <c>TagConfig</c> blob (with the Galaxy <c>attributeRef</c>), and the
/// WriteIdempotent flag. Bound straight through to <see cref="GalaxyDriverOptions.RawTags"/>.
/// </summary>
public List<RawTagEntry>? RawTags { get; init; }
}
/// <summary>Gateway configuration section.</summary>
@@ -1,11 +1,13 @@
using System.ComponentModel.DataAnnotations;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus;
/// <summary>
/// Modbus TCP driver configuration. Bound from the driver's <c>DriverConfig</c> JSON at
/// <c>DriverHost.RegisterAsync</c>. Every register the driver exposes appears in
/// <see cref="Tags"/>; names become the OPC UA browse name + full reference.
/// <c>DriverHost.RegisterAsync</c>. The tags the driver serves are delivered as authored raw
/// tags in <see cref="RawTags"/>; the driver maps each through
/// <see cref="ModbusTagDefinitionFactory.FromTagConfig"/> to build its RawPath → definition table.
/// </summary>
public sealed class ModbusDriverOptions
{
@@ -18,8 +20,14 @@ public sealed class ModbusDriverOptions
/// <summary>Gets the communication timeout duration.</summary>
public TimeSpan Timeout { get; init; } = TimeSpan.FromSeconds(2);
/// <summary>Pre-declared tag map. Modbus has no discovery protocol — the driver returns exactly these.</summary>
public IReadOnlyList<ModbusTagDefinition> Tags { get; init; } = [];
/// <summary>
/// Authored raw tags this driver serves. The deploy artifact hands each authored raw
/// <c>Tag</c> as a <see cref="RawTagEntry"/> (RawPath identity + driver <c>TagConfig</c> blob +
/// WriteIdempotent flag); the driver maps each through
/// <see cref="ModbusTagDefinitionFactory.FromTagConfig"/> into its RawPath → definition table.
/// Modbus has no discovery protocol — the driver serves exactly these.
/// </summary>
public IReadOnlyList<RawTagEntry> RawTags { get; init; } = [];
/// <summary>
/// Background connectivity-probe settings. When <see cref="ModbusProbeOptions.Enabled"/>

Some files were not shown because too many files have changed in this diff Show More