v3 Batch 1 — greenfield schema + RawPath identity rewiring #469
@@ -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';
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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 2–4 (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 2–4 land.
|
||||
- Live driver IntegrationTests + one pre-existing `AbCip_Green_AgainstSim` fixture-probe failure
|
||||
(AB CIP sim handshake — environment/fixture gate, untouched).
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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.';
|
||||
|
||||
@@ -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><Folder/…>/<Driver>/<Device>/<TagGroup/…>/<Tag></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><driver-prefix>/<Device>/<TagGroup ancestry root→leaf>/<Tag></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><Folder/…>/<Driver>/<Device>/<TagGroup/…>/<Tag></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><Folder/…>/<Driver>/<Device>/<TagGroup/…>/<Name></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,
|
||||
}
|
||||
Generated
-1208
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");
|
||||
}
|
||||
}
|
||||
}
|
||||
Generated
-1208
File diff suppressed because it is too large
Load Diff
-510
@@ -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
|
||||
";
|
||||
}
|
||||
}
|
||||
Generated
-1208
File diff suppressed because it is too large
Load Diff
-55
@@ -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;
|
||||
");
|
||||
}
|
||||
}
|
||||
Generated
-1248
File diff suppressed because it is too large
Load Diff
-49
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
-1287
File diff suppressed because it is too large
Load Diff
-46
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
-1342
File diff suppressed because it is too large
Load Diff
-62
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
-1347
File diff suppressed because it is too large
Load Diff
-37
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
-1505
File diff suppressed because it is too large
Load Diff
-91
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
-1505
File diff suppressed because it is too large
Load Diff
-172
@@ -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
|
||||
";
|
||||
}
|
||||
}
|
||||
}
|
||||
-1793
File diff suppressed because it is too large
Load Diff
-186
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
-1793
File diff suppressed because it is too large
Load Diff
-232
@@ -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
|
||||
";
|
||||
}
|
||||
}
|
||||
}
|
||||
-120
@@ -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
|
||||
";
|
||||
}
|
||||
}
|
||||
}
|
||||
-1562
File diff suppressed because it is too large
Load Diff
-1755
File diff suppressed because it is too large
Load Diff
-50
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
-1755
File diff suppressed because it is too large
Load Diff
-39
@@ -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<string></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';");
|
||||
}
|
||||
}
|
||||
}
|
||||
-1759
File diff suppressed because it is too large
Load Diff
-35
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
-1758
File diff suppressed because it is too large
Load Diff
-42
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
-1758
File diff suppressed because it is too large
Load Diff
-181
@@ -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<string></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.
|
||||
}
|
||||
}
|
||||
}
|
||||
+226
-274
@@ -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
|
||||
}
|
||||
+285
@@ -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
+209
-271
@@ -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>>= 1</c>. Any other combination (isArray absent/false,
|
||||
/// or isArray:true with arrayLength missing/invalid/<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>>= 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();
|
||||
|
||||
-107
@@ -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;
|
||||
}
|
||||
+158
@@ -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>
|
||||
|
||||
+6
-2
@@ -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
Reference in New Issue
Block a user