Compare commits
39 Commits
898c7365c4
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 123ddc3f40 | |||
| 0f38f48679 | |||
| 0e93587b1c | |||
| 4cc0215bb4 | |||
| 4b9bcddbcc | |||
| 2193d9f2e4 | |||
| 8b7ea3213d | |||
| 240d7aa8aa | |||
| 95295210b0 | |||
| e3155fbbf5 | |||
| 47d148daf9 | |||
| 755ae1aa3f | |||
| 549c656489 | |||
| 28c2866710 | |||
| 154171f48c | |||
| 4dc2ad8084 | |||
| 1919a8e538 | |||
| b95b99ba8e | |||
| ee12568cab | |||
| 4ad540376d | |||
| 57a5bb3c60 | |||
| b295b24419 | |||
| e27eb77187 | |||
| 585f827ef3 | |||
| be1df2d1e5 | |||
| 9b5a96876e | |||
| fcc7ed698e | |||
| 4a8c9badce | |||
| 650e27075f | |||
| 132e7b63f6 | |||
| 13bd9820b0 | |||
| 1d90bf3611 | |||
| 20be5416b9 | |||
| 47e9cd56ef | |||
| 2f38b5b285 | |||
| 20100c36ad | |||
| eed6617784 | |||
| 8d0f60ec51 | |||
| e787aa572b |
@@ -77,6 +77,10 @@
|
||||
<PackageVersion Include="Moq" Version="4.20.72" />
|
||||
<PackageVersion Include="Novell.Directory.Ldap.NETStandard" Version="3.6.0" />
|
||||
<PackageVersion Include="OPCFoundation.NetStandard.Opc.Ua.Client" Version="1.5.378.106" />
|
||||
<!-- Core carries Opc.Ua.StatusCodes, the oracle StatusCodeParityTests checks the drivers'
|
||||
hard-coded uint constants against. Referenced by that TEST project only — the drivers
|
||||
themselves stay SDK-free by design and keep spelling status codes as bare uints. -->
|
||||
<PackageVersion Include="OPCFoundation.NetStandard.Opc.Ua.Core" Version="1.5.378.106" />
|
||||
<PackageVersion Include="OPCFoundation.NetStandard.Opc.Ua.Configuration" Version="1.5.378.106" />
|
||||
<PackageVersion Include="OPCFoundation.NetStandard.Opc.Ua.Server" Version="1.5.378.106" />
|
||||
<!-- OpenTelemetry.Api < 1.15.3 has GHSA-g94r-2vxg-569j (header-parsing memory DoS). The trio
|
||||
|
||||
@@ -272,6 +272,12 @@ services:
|
||||
OTOPCUA_ROLES: "admin,driver,cluster-MAIN"
|
||||
ASPNETCORE_URLS: "http://+:9000"
|
||||
ConnectionStrings__ConfigDb: "Server=sql,1433;Database=OtOpcUa;User Id=sa;Password=OtOpcUa!Dev123;TrustServerCertificate=True;"
|
||||
# Sql poll driver — the named connection string a Sql driver instance references by
|
||||
# `connectionStringRef: "DevSql"`. Resolved in-process by SqlConnectionStringResolver (driver
|
||||
# node) and SqlDriverBrowser (AdminUI browse), env-only, never persisted to ConfigDb. Points at
|
||||
# the rig's own `sql` container, a SEPARATE `DevSql` database seeded with dbo.TagValues (KeyValue)
|
||||
# + dbo.LatestStatus (WideRow). Committed-dev-secret exception, same as ConfigDb above.
|
||||
Sql__ConnectionStrings__DevSql: "Server=sql,1433;Database=DevSql;User Id=sa;Password=OtOpcUa!Dev123;TrustServerCertificate=True;"
|
||||
Cluster__Hostname: "0.0.0.0"
|
||||
Cluster__Port: "4053"
|
||||
Cluster__PublicHostname: "central-1"
|
||||
@@ -391,6 +397,12 @@ services:
|
||||
OTOPCUA_ROLES: "admin,driver,cluster-MAIN"
|
||||
ASPNETCORE_URLS: "http://+:9000"
|
||||
ConnectionStrings__ConfigDb: "Server=sql,1433;Database=OtOpcUa;User Id=sa;Password=OtOpcUa!Dev123;TrustServerCertificate=True;"
|
||||
# Sql poll driver — the named connection string a Sql driver instance references by
|
||||
# `connectionStringRef: "DevSql"`. Resolved in-process by SqlConnectionStringResolver (driver
|
||||
# node) and SqlDriverBrowser (AdminUI browse), env-only, never persisted to ConfigDb. Points at
|
||||
# the rig's own `sql` container, a SEPARATE `DevSql` database seeded with dbo.TagValues (KeyValue)
|
||||
# + dbo.LatestStatus (WideRow). Committed-dev-secret exception, same as ConfigDb above.
|
||||
Sql__ConnectionStrings__DevSql: "Server=sql,1433;Database=DevSql;User Id=sa;Password=OtOpcUa!Dev123;TrustServerCertificate=True;"
|
||||
Cluster__Hostname: "0.0.0.0"
|
||||
Cluster__Port: "4053"
|
||||
Cluster__PublicHostname: "central-2"
|
||||
|
||||
@@ -106,6 +106,28 @@ IF NOT EXISTS (SELECT 1 FROM dbo.ClusterNode WHERE NodeId = 'site-b-2:4053')
|
||||
(NodeId, ClusterId, Host, OpcUaPort, DashboardPort, AkkaPort, GrpcPort, ApplicationUri, ServiceLevelBase, Enabled, CreatedBy)
|
||||
VALUES ('site-b-2:4053', 'SITE-B', 'site-b-2', 4840, 8081, 4053, 4056, 'urn:OtOpcUa:site-b-2', 150, 1, 'docker-dev-seed');
|
||||
|
||||
------------------------------------------------------------------------------
|
||||
-- Backfill: GrpcPort on rows that predate the column (#493)
|
||||
--
|
||||
-- Every insert above is INSERT-if-not-exists, which is the right shape for a re-runnable seed
|
||||
-- but means a *new nullable* column never reaches rows created by an earlier version of this
|
||||
-- file. On a persisted volume that is exactly what happened when Phase 5 added GrpcPort: the
|
||||
-- six ClusterNode rows already existed, so they kept NULL, TelemetryNodeSource found no dial
|
||||
-- targets, and TelemetryDial:Mode=Grpc silently connected to nothing (throttled Warning per
|
||||
-- node — the code degrades gracefully, so nothing failed loudly).
|
||||
--
|
||||
-- Backfills NULL only. It must not overwrite a port an operator set deliberately on a
|
||||
-- long-lived dev volume; a NULL is the unambiguous "this row predates the column" marker.
|
||||
--
|
||||
-- AkkaPort deliberately has no equivalent here: it is NOT NULL with a default of 4053, so
|
||||
-- pre-existing rows already carry a usable value and there is nothing to backfill.
|
||||
------------------------------------------------------------------------------
|
||||
|
||||
UPDATE dbo.ClusterNode
|
||||
SET GrpcPort = 4056 -- matches Telemetry__GrpcListenPort on all six nodes in docker-compose.yml
|
||||
WHERE GrpcPort IS NULL
|
||||
AND CreatedBy = 'docker-dev-seed';
|
||||
|
||||
------------------------------------------------------------------------------
|
||||
-- Galaxy MxAccess gateway — INTENTIONALLY NOT SEEDED (retired 2026-06-15)
|
||||
--
|
||||
|
||||
@@ -102,6 +102,45 @@ Persisted scope per plan decision #14: `Enabled`, `Acked`, `Confirmed`, `Shelvin
|
||||
|
||||
Every mutation the state machine produces is immediately persisted inside the engine's `_evalGate` semaphore, so the store's view is always consistent with the in-memory state.
|
||||
|
||||
### Post-(re)load condition-node re-assert (#487)
|
||||
|
||||
A deploy that triggers a **full address-space rebuild** clears `_alarmConditions` and re-materialises every
|
||||
condition node fresh — inactive, acked, confirmed, unshelved. The engine, reloading in parallel, restores each
|
||||
alarm's persisted state and re-derives `Active` from the predicate, so it ends up *correct*; but a still-active
|
||||
alarm has **no transition to report**, so `LoadAsync` yields `EmissionKind.None` and `OnEngineEmission` drops it.
|
||||
Nothing then writes the node. Before this fix, an active alarm with static dependencies read **normal** to every
|
||||
OPC UA client after such a deploy, until its next real transition — which for a static-dependency alarm may
|
||||
never come.
|
||||
|
||||
`ScriptedAlarmHostActor.ReassertConditionNodes` closes it. After each load completes it reads
|
||||
`ScriptedAlarmEngine.GetProjections()` — a plain read returning `ScriptedAlarmProjection` (condition state +
|
||||
the definition's severity + the resolved message template + last-observed worst-input quality) — and sends one
|
||||
`OpcUaPublishActor.AlarmStateUpdate` per alarm that is **not** in the Part 9 no-event position.
|
||||
|
||||
Four properties are load-bearing:
|
||||
|
||||
- **Node-only.** It sends `AlarmStateUpdate` and nothing else — never the `alerts` topic, never the telemetry
|
||||
hub. `alerts` is the historization path, so an alerts row per alarm per deploy would append a duplicate
|
||||
historian / AVEVA record every time anyone deploys. This is why the re-assert reads a projection instead of
|
||||
re-emitting: `ScriptedAlarmProjection` carries no `EmissionKind`, so there is nothing for the emission
|
||||
machinery — or a future refactor — to mistake for a transition.
|
||||
- **Only non-normal alarms.** An alarm in the no-event position (enabled, inactive, acked, confirmed,
|
||||
unshelved) already matches what the materialise built, so it is skipped. That keeps a never-fired alarm
|
||||
byte-identical to a deploy without this pass — including its Message, which the materialise seeds with the
|
||||
alarm's display name — and writes nothing at all on the common all-normal deploy.
|
||||
- **Ordering.** `DriverHostActor` tells `RebuildAddressSpace` to the publish actor *before* it tells
|
||||
`ApplyScriptedAlarms` to the host, and the re-assert runs later still (after an awaited `LoadAsync` pipes
|
||||
back). Both are local `Tell`s, which enqueue synchronously, so the re-materialise is already queued ahead of
|
||||
these writes.
|
||||
- **No duplicate Part 9 events.** `WriteAlarmCondition`'s delta-gate compares the incoming snapshot against
|
||||
the node's *current* state: a re-assert onto a freshly materialised (normal) node is a genuine delta and
|
||||
fires exactly one condition event — correct, since the rebuild reset what clients see — while a re-assert
|
||||
onto a surgically-preserved node that already holds the same state is suppressed. The write is ungated by
|
||||
redundancy role, like every other node write here; the Secondary keeps its address space warm for failover.
|
||||
|
||||
The timestamp carried is the condition's own `LastTransitionUtc`, **not** the deploy instant, so a client
|
||||
reading `Time` / `ReceiveTime` after a rebuild still sees when the alarm actually went active.
|
||||
|
||||
## Source integration
|
||||
|
||||
`ScriptedAlarmSource` (`ScriptedAlarmSource.cs`) adapts the engine to the driver-agnostic `IAlarmSource` interface. The existing `AlarmSurfaceInvoker` + `GenericDriverNodeManager` fan-out consumes it the same way it consumes Galaxy / AB CIP / FOCAS sources — there is no scripted-alarm-specific code path in the server plumbing. From that point on, the flow into `AlarmConditionState` nodes, the `AlarmAck` session check, and the Historian sink is shared — see [AlarmTracking.md](AlarmTracking.md).
|
||||
|
||||
@@ -128,6 +128,39 @@ transports. ScadaBridge itself has since closed that gap with this identical
|
||||
interceptor pattern, so Phase 5 ships authenticated from the start rather than deferring auth to a
|
||||
later hardening pass. See the design doc's superseded note for the full history.
|
||||
|
||||
## Upgrading an existing deployment: populate `ClusterNode.GrpcPort` first (#493)
|
||||
|
||||
`GrpcPort` is a **nullable column added by Phase 5**, and nothing backfills it onto rows that
|
||||
already existed. On an upgraded deployment every `ClusterNode` row therefore carries `NULL`,
|
||||
central finds **no dial targets**, and flipping `TelemetryDial:Mode` to `Grpc` connects to
|
||||
nothing. Fresh installs are unaffected. `AkkaPort` did not have this problem only because it is
|
||||
`NOT NULL` with a default of 4053.
|
||||
|
||||
Nothing fails loudly, which is what makes this worth stating up front — the code degrades
|
||||
gracefully, once per node, throttled:
|
||||
|
||||
```
|
||||
ClusterNode <id> has no GrpcPort; it exposes no telemetry stream and is skipped in this dial refresh
|
||||
(further skips of this node are silent)
|
||||
```
|
||||
|
||||
Other symptoms: no `ESTABLISHED` sockets on the telemetry port (a `LISTEN` but nothing else in
|
||||
`docker exec <node> cat /proc/net/tcp6`), and the `/alerts` / `/script-log` pill never turning
|
||||
live in `Grpc` mode.
|
||||
|
||||
**Before flipping any node to `Grpc`**, set each driver node's `GrpcPort` to that node's own
|
||||
`Telemetry:GrpcListenPort` — via the AdminUI node editor, or directly:
|
||||
|
||||
```sql
|
||||
UPDATE dbo.ClusterNode SET GrpcPort = <that node's Telemetry:GrpcListenPort> WHERE NodeId = '<node>';
|
||||
```
|
||||
|
||||
There is deliberately **no EF data migration** backfilling this. The correct value is that node's
|
||||
own listener port, which is per-deployment configuration the database cannot derive; a migration
|
||||
could only invent one, and a *wrong* dial target is worse than the `NULL` the dialer already skips
|
||||
explicitly. The docker-dev seed does backfill (`GrpcPort IS NULL AND CreatedBy = 'docker-dev-seed'`
|
||||
→ 4056) because there the port is fixed by `docker-compose.yml` and therefore actually knowable.
|
||||
|
||||
## Reconnect and reliability
|
||||
|
||||
Central's `TelemetryDialSupervisor` (an admin-role actor) runs **one reconnecting dialer per
|
||||
|
||||
@@ -271,7 +271,7 @@ public sealed class GatewayQualityMapperTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData(192, 0x00000000u)] // Good
|
||||
[InlineData(216, 0x00D80000u)] // Good_LocalOverride
|
||||
[InlineData(216, 0x00960000u)] // Good_LocalOverride
|
||||
[InlineData(64, 0x40000000u)] // Uncertain
|
||||
[InlineData(0, 0x80000000u)] // Bad
|
||||
[InlineData(8, 0x808A0000u)] // Bad_NotConnected
|
||||
@@ -307,7 +307,7 @@ public sealed class SampleMapperTests
|
||||
{
|
||||
var a = new HistorianAggregateSample { Tag = "T", /* Value unset */ EndTime = Ts(2026,1,1,0,0,0) };
|
||||
var snap = SampleMapper.ToAggregateSnapshot(a);
|
||||
Assert.Equal(0x800E0000u, snap.StatusCode); // BadNoData
|
||||
Assert.Equal(0x809B0000u, snap.StatusCode); // BadNoData
|
||||
Assert.Null(snap.Value);
|
||||
}
|
||||
// Ts(...) builds a Google.Protobuf.WellKnownTypes.Timestamp from UTC parts.
|
||||
@@ -323,7 +323,7 @@ public sealed class SampleMapperTests
|
||||
- `StatusCode`: `GatewayQualityMapper.Map((byte)s.OpcQuality)` (prefer `opc_quality`; if zero/unset and `quality` carries the OPC-DA byte, fall back to `quality` — match whatever the gateway populates; document the choice).
|
||||
- `SourceTimestampUtc`: `s.Timestamp.ToDateTime()` (UTC kind).
|
||||
- `ServerTimestampUtc`: `DateTime.UtcNow`.
|
||||
- `SampleMapper.ToAggregateSnapshot(HistorianAggregateSample)` → null aggregate value ⇒ `StatusCode 0x800E0000` (BadNoData), non-null ⇒ Good (`0x00000000`) with the value; `SourceTimestampUtc` ← the bucket end/start timestamp (match the Wonderware `ToAggregateSnapshots` convention — it stamps the bucket timestamp). Provide `IReadOnlyList<>` batch helpers too.
|
||||
- `SampleMapper.ToAggregateSnapshot(HistorianAggregateSample)` → null aggregate value ⇒ `StatusCode 0x809B0000` (BadNoData), non-null ⇒ Good (`0x00000000`) with the value; `SourceTimestampUtc` ← the bucket end/start timestamp (match the Wonderware `ToAggregateSnapshots` convention — it stamps the bucket timestamp). Provide `IReadOnlyList<>` batch helpers too.
|
||||
|
||||
**Step 4: run, expect PASS.**
|
||||
|
||||
|
||||
@@ -98,7 +98,7 @@ Pure function in `.Contracts` (shared by driver, browser-commit, and the typed e
|
||||
| `EVENT` free text (`Program`, `Block`, `Message`) | `String` |
|
||||
| `CONDITION` | `String` (state word; §3.6) |
|
||||
|
||||
**Quality mapping (`DataValueSnapshot.StatusCode`):** an observation value of `UNAVAILABLE` (MTConnect's explicit no-data sentinel) → **`BadNoCommunication` (`0x80310000u`)** with a null value, not the literal string — this is *the* UNAVAILABLE code, used everywhere in this design (read, subscribe, and the §8 fixtures). Rationale + fleet fit: semantically, `UNAVAILABLE` means the Agent is reachable but has no device-backed value (the adapter↔device link is down or the item has never reported) — exactly OPC UA's `BadNoCommunication` ("communication to the data source has failed"). The fleet grep shows no competing convention for this case: every existing driver's `BadCommunicationError` (`0x80050000u`, Modbus/FOCAS/TwinCAT/OpcUaClient/S7/AbCip/AbLegacy/Galaxy) marks the **driver's own transport failure** to its peer — a different failure (and the code this driver also uses when the *Agent* HTTP call fails, per §7) — while `BadNoData` (`0x800E0000u`) is historian-domain only (`Historian.Gateway/Mapping/SampleMapper.cs`). `BadNoCommunication` is already in the CLI's `SnapshotFormatter` name table (`src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.Cli.Common/SnapshotFormatter.cs`), so it renders by name, not hex. Declare it as a `private const uint` in `MTConnectDriver` (the Modbus `StatusBadCommunicationError` pattern). Missing dataItem / empty condition → `Bad`. `observation.timestamp` → `SourceTimestamp` (not a separate node).
|
||||
**Quality mapping (`DataValueSnapshot.StatusCode`):** an observation value of `UNAVAILABLE` (MTConnect's explicit no-data sentinel) → **`BadNoCommunication` (`0x80310000u`)** with a null value, not the literal string — this is *the* UNAVAILABLE code, used everywhere in this design (read, subscribe, and the §8 fixtures). Rationale + fleet fit: semantically, `UNAVAILABLE` means the Agent is reachable but has no device-backed value (the adapter↔device link is down or the item has never reported) — exactly OPC UA's `BadNoCommunication` ("communication to the data source has failed"). The fleet grep shows no competing convention for this case: every existing driver's `BadCommunicationError` (`0x80050000u`, Modbus/FOCAS/TwinCAT/OpcUaClient/S7/AbCip/AbLegacy/Galaxy) marks the **driver's own transport failure** to its peer — a different failure (and the code this driver also uses when the *Agent* HTTP call fails, per §7) — while `BadNoData` (`0x809B0000u`) is historian-domain only (`Historian.Gateway/Mapping/SampleMapper.cs`). `BadNoCommunication` is already in the CLI's `SnapshotFormatter` name table (`src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.Cli.Common/SnapshotFormatter.cs`), so it renders by name, not hex. Declare it as a `private const uint` in `MTConnectDriver` (the Modbus `StatusBadCommunicationError` pattern). Missing dataItem / empty condition → `Bad`. `observation.timestamp` → `SourceTimestamp` (not a separate node).
|
||||
|
||||
### 3.4 `IReadable` — `/current`
|
||||
|
||||
|
||||
@@ -588,6 +588,40 @@ SQL Server client works cross-platform, so `Sql` runs on macOS dev too (unlike G
|
||||
`dialect.QuoteIdentifier` (which escapes/rejects embedded quote characters). A table/column string
|
||||
in a `TagConfig` is validated against the live catalog (or an allow-list) before it's ever quoted
|
||||
into text; an unknown identifier rejects the tag (→ `BadNodeIdUnknown`) rather than executing.
|
||||
|
||||
**Implemented (Gitea #496).** `SqlCatalogGate` + `SqlCatalogLoader`, run from
|
||||
`SqlDriver.InitializeAsync` after the liveness check and before the driver reports Healthy. Shape,
|
||||
and the decisions worth knowing before touching it:
|
||||
|
||||
- **The catalog's spelling is substituted, not merely checked.** An accepted identifier is rewritten
|
||||
to the string the catalog returned, so the text quoted into a poll query is one this driver read
|
||||
back out of `ListSchemas`/`ListTables`/`ListColumns` — not operator input. Matching is
|
||||
exact-ordinal first, then a *unique* case-insensitive hit (SQL Server's default collation is CI, so
|
||||
case variants have always worked and rejecting them would break valid configs); an ambiguous CI
|
||||
match under a case-sensitive collation is refused rather than guessed, because picking one would
|
||||
publish another column's data under the operator's node.
|
||||
- **Charset check first, catalog lookup second.** Each identifier goes through `QuoteIdentifier` for
|
||||
its rejection rules *before* being looked up, so a name carrying a control or Unicode format
|
||||
character is rejected **without its value being echoed** into a log line (Trojan-Source). A name
|
||||
that passes is safe to render, which is why catalog-miss messages *do* name it — an operator
|
||||
hunting a typo has to see what they wrote.
|
||||
- **Rejection is per-tag and keeps the node.** The rejected tag is dropped from the *polled* table
|
||||
but stays in the *authored* table, so it still materializes as a node and reads
|
||||
`BadNodeIdUnknown` (§8.1's specified outcome) instead of vanishing from the address space. Every
|
||||
drop is logged at Warning with the tag, the field and the reason.
|
||||
- **A catalog that cannot be read faults Initialize; it does not reject every tag.** An unreadable
|
||||
catalog is the *absence* of evidence about the tags, not evidence against them — rejecting all of
|
||||
them would serve a confidently-empty address space and send the operator hunting typos that do not
|
||||
exist. Zero visible schemas is treated the same way, because that is what a missing GRANT looks
|
||||
like. Faulting lands `DriverInstanceActor` in Reconnecting with its retry timer running.
|
||||
- **Bounded load:** one schema-list query, one default-schema scalar (`ISqlDialect.DefaultSchemaSql`,
|
||||
added for this — an unqualified `TagValues` must resolve in whatever schema the *server* says, not
|
||||
a guessed `dbo`), then one `ListTables` per distinct authored schema and one `ListColumns` per
|
||||
distinct authored relation. It never enumerates the whole catalog, and the load runs under the same
|
||||
wall-clock bound as the liveness check (R2-01 / STAB-14).
|
||||
- **Accepted v1 limitation:** a 3-part `db.schema.table` (or a linked-server name) addresses a
|
||||
catalog this connection cannot enumerate, so it cannot be allow-listed and is rejected with a
|
||||
message pointing at the fix — expose the data through a view in the connected database.
|
||||
- **Treat any code path that builds SQL by string-concatenating a tag field as a defect** — enforce
|
||||
with a review checklist item + a unit test that feeds a malicious `keyValue`/`table`
|
||||
(`'; DROP TABLE …`) and asserts it either binds harmlessly (value) or is rejected (identifier),
|
||||
@@ -607,8 +641,15 @@ comment) and the env-overridable ConfigDb connection string:
|
||||
file. Direct env read (not `IConfiguration`) is deliberate: the factory registry materializes
|
||||
drivers via a static `(id, json)` closure (Modbus pattern) with no `IConfiguration` in reach, so
|
||||
this keeps the factory shape unchanged.
|
||||
- If an inline `connectionString` is ever permitted (dev convenience only), the AdminUI **redacts** it
|
||||
in display/logging and flags it dev-only.
|
||||
- **An inline `connectionString` is not permitted at all** — the earlier "dev convenience, redacted in
|
||||
the UI" carve-out is withdrawn (Gitea #498). Redaction only hides a credential that has *already been
|
||||
written* to the config DB and replicated to every node's artifact cache. `SqlDriverConfigDto` has no
|
||||
such property and `UnmappedMemberHandling.Skip` drops the key on read, but that protects only the read
|
||||
path; config blobs are schemaless JSON columns, so nothing stopped the key being **written**.
|
||||
`DraftValidator.ValidateSqlConnectionStringNotPersisted` now fails the deploy
|
||||
(`SqlConnectionStringPersisted`) for a `connectionString` key at the top level of a Sql driver's
|
||||
`DriverConfig` **or** of the `DeviceConfig` of any device beneath it (the two are merged before the DTO
|
||||
sees them). The key is matched case-insensitively, and the error text never echoes the value.
|
||||
- **Never log the resolved connection string.** Log only provider + server host + database name.
|
||||
- Prefer integrated/managed auth where the estate supports it (`Integrated Security=true` / Azure AD /
|
||||
Kerberos) so no password transits config at all.
|
||||
|
||||
@@ -94,7 +94,14 @@ dual-namespace + event-delivery), Runtime 377 (+ VT reassert regression), AdminU
|
||||
|
||||
## Documented follow-ups (non-blocking)
|
||||
|
||||
- **Scripted-alarm redeploy recovery (CONFIRMED pre-existing bug, deferred — deserves its own careful fix). Filed as issue #487.**
|
||||
- **Scripted-alarm redeploy recovery — FIXED (issue #487), see `docs/ScriptedAlarms.md` §"Post-(re)load
|
||||
condition-node re-assert".** Landed as the proposed shape below: `ScriptedAlarmHostActor.ReassertConditionNodes`
|
||||
in `OnAlarmsLoaded`, node-only, never the `alerts` topic. Two deviations from the sketch, both deliberate:
|
||||
it reads a new `ScriptedAlarmEngine.GetProjections()` rather than `GetState(alarmId)` + `LoadedAlarmIds`
|
||||
(a projection also carries the definition's severity, the resolved message template, and the last-observed
|
||||
worst-input quality — `GetState` alone would have clobbered severity/message on the node); and it skips
|
||||
alarms sitting in the Part 9 no-event position, so a deploy where nothing is in alarm writes no condition
|
||||
nodes at all. The original deferral text follows for context.
|
||||
The scripted-alarm Part 9 condition node has the same redeploy-reset race the VT `ReassertValue` fix closed:
|
||||
`RebuildAddressSpace` clears `_alarmConditions` and `MaterialiseScriptedAlarms` recreates each condition
|
||||
fresh/normal, but `ScriptedAlarmEngine.LoadAsync` reloads from the persisted state and yields
|
||||
|
||||
@@ -30,7 +30,7 @@ it reaches 📝.
|
||||
| Wave | Deliverable | Design | Impl. plan | Status | Effort | Fixture / hardware |
|
||||
|---|---|---|---|---|---|---|
|
||||
| **0** | Universal Discover-backed browser | [design](2026-07-15-universal-discovery-browser-design.md) | [plan](2026-07-15-universal-discovery-browser-implementation.md) · [tasks](2026-07-15-universal-discovery-browser-implementation.md.tasks.json) | 🟡 **Live gate open** | S–M | none (retrofits shipped drivers) |
|
||||
| **1** | Modbus RTU (extend Modbus) | [design](2026-07-15-modbus-rtu-driver-design.md) | [plan](2026-07-24-modbus-rtu-driver.md) · [tasks](2026-07-24-modbus-rtu-driver.md.tasks.json) | 📝 **Plan ready** (11 tasks) | **S (lowest)** | `pymodbus` `rtu_over_tcp` — CI-simulatable, no new infra |
|
||||
| **1** | Modbus RTU (extend Modbus) | [design](2026-07-15-modbus-rtu-driver-design.md) | [plan](2026-07-24-modbus-rtu-driver.md) · [tasks](2026-07-24-modbus-rtu-driver.md.tasks.json) | 🟢 **Built — live gate PASSED** (11 tasks, branch `feat/modbus-rtu-driver`, pending merge) | **S (lowest)** | `pymodbus` `rtu_over_tcp` — CI-simulatable, no new infra |
|
||||
| **1** | SQL poll | [design](2026-07-15-sql-poll-driver-design.md) | [plan](2026-07-24-sql-poll-driver.md) · [tasks](2026-07-24-sql-poll-driver.md.tasks.json) | 📝 **Plan ready** (22 tasks) | S–M | **existing central SQL Server** `10.100.0.35,14330` + SQLite unit |
|
||||
| **2** | MTConnect Agent | [design](2026-07-15-mtconnect-driver-design.md) | [plan](2026-07-24-mtconnect-driver.md) · [tasks](2026-07-24-mtconnect-driver.md.tasks.json) | 📝 **Plan ready** (23 tasks) | S–M | Dockerized `mtconnect/cppagent` — CI-simulatable |
|
||||
| **2** | MQTT / Sparkplug B | [design](2026-07-15-mqtt-sparkplug-driver-design.md) | [plan](2026-07-24-mqtt-sparkplug-driver.md) · [tasks](2026-07-24-mqtt-sparkplug-driver.md.tasks.json) | 📝 **Plan ready** (27 tasks, P1+P2) | M–L | Mosquitto/EMQX + C# Sparkplug sim — CI-simulatable |
|
||||
@@ -92,16 +92,30 @@ marginal cost.
|
||||
Both are **fully CI-simulatable with zero new hardware**; Modbus RTU needs no new infra at all, and
|
||||
SQL poll reuses the always-on central SQL Server. This is the recommended next build.
|
||||
|
||||
### Modbus RTU — 📝 Plan ready
|
||||
### Modbus RTU — 🟢 Built, live gate PASSED (branch `feat/modbus-rtu-driver`, pending merge)
|
||||
- **Design:** [`2026-07-15-modbus-rtu-driver-design.md`](2026-07-15-modbus-rtu-driver-design.md)
|
||||
- **Implementation plan:** [`2026-07-24-modbus-rtu-driver.md`](2026-07-24-modbus-rtu-driver.md) · [`.tasks.json`](2026-07-24-modbus-rtu-driver.md.tasks.json) — **11 tasks** (1 trivial / 5 small / 2 standard / 3 high-risk).
|
||||
- **Implementation plan:** [`2026-07-24-modbus-rtu-driver.md`](2026-07-24-modbus-rtu-driver.md) · [`.tasks.json`](2026-07-24-modbus-rtu-driver.md.tasks.json) — **11 tasks** (1 trivial / 5 small / 2 standard / 3 high-risk), all complete via subagent-driven-development.
|
||||
- **Scope:** extend the existing Modbus driver with a `Transport` selector (RTU CRC-16 + FC-aware
|
||||
length, no TxId) + a `rtu_over_tcp` `pymodbus` docker profile + the AdminUI selector.
|
||||
**Direct-serial transport is descoped** (user, 2026-07-15) — RTU-over-TCP via a serial→Ethernet
|
||||
gateway is the only shipped mode, so there is no serial hardware gate.
|
||||
- **Fixture:** add an `rtu_over_tcp` profile to the existing Modbus integration fixture. First P1
|
||||
step is confirming the pymodbus simulator exposes the RTU framer on a TCP server.
|
||||
- **Effort:** **S — the lowest on the roadmap.** Recommended first.
|
||||
- **Fixture:** added the `rtu_over_tcp` profile (`framer=rtu`, host `:5021`, co-runs with `standard`)
|
||||
to the Modbus integration fixture. **Unknown confirmed:** pymodbus 3.13's simulator DOES honour
|
||||
`framer=rtu` on a TCP server (wire-probed a pure RTU FC03 response, no MBAP) — so the simple profile
|
||||
path was taken; **no stdlib fallback server was needed.**
|
||||
- **Verification (all green):** unit suite **344/344**; full solution build 0 errors; the 2 RTU
|
||||
integration tests pass **live** against the real `framer=rtu` fixture (read HR5→5, write+readback
|
||||
HR200←4242). Per-task review chain (spec + code reviewers) + a final whole-branch review, all
|
||||
approved-to-merge.
|
||||
- **Live `/run` gate — PASSED (2026-07-24, docker-dev rebuilt from this branch):**
|
||||
- AdminUI `/raw` Modbus **driver** config modal renders the new **Transport** selector (with the
|
||||
operator hint) — set to `RtuOverTcp`, saved.
|
||||
- **Device Test Connect → `OK · 23 MS`** — the probe built a `ModbusRtuOverTcpTransport` via the
|
||||
factory and spoke **FC03 over real RTU framing** to `10.100.0.35:5021`.
|
||||
- Authored raw tags HR5 (r/o) + HR200 (r/w) → **deployment `06ec1632` Sealed** (all 6 nodes acked).
|
||||
- Client.CLI on `opc.tcp://localhost:4840` (`multi-role`/`password`): **read** `ns=2;s=rtu-gate/Device1/HR5`
|
||||
→ **5, Good**; **write** `ns=2;s=rtu-gate/Device1/HR200` = 4242 → readback **4242, Good**.
|
||||
- **Effort:** **S — the lowest on the roadmap.** Delivered first, as recommended.
|
||||
|
||||
### SQL poll — 📝 Plan ready
|
||||
- **Design:** [`2026-07-15-sql-poll-driver-design.md`](2026-07-15-sql-poll-driver-design.md)
|
||||
|
||||
@@ -41,9 +41,77 @@ public static class DraftValidator
|
||||
ValidateUnsEffectiveLeafUniqueness(draft, errors);
|
||||
ValidateEquipReferenceResolution(draft, errors);
|
||||
ValidateCalculationTags(draft, errors);
|
||||
ValidateSqlConnectionStringNotPersisted(draft, errors);
|
||||
return errors;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The <c>Sql</c> driver's "a pasted literal connection string is never persisted" guarantee, enforced
|
||||
/// at the deploy gate (Gitea #498).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>A Sql driver names its credentials indirectly — <c>connectionStringRef</c> resolves from the
|
||||
/// environment / secret store at Initialize — so a deployed artifact carries no database password.
|
||||
/// Until now that guarantee rested entirely on the <b>read</b> path: <c>SqlDriverConfigDto</c> has no
|
||||
/// <c>connectionString</c> property and <c>UnmappedMemberHandling.Skip</c> drops the key on
|
||||
/// deserialization. Nothing stopped the key being <b>written</b>. Config blobs are schemaless JSON
|
||||
/// columns, and the fallback authoring pattern for a driver without a typed form is a raw-JSON
|
||||
/// textarea, so an operator pasting <c>{"connectionString":"Server=…;Password=…"}</c> would put a live
|
||||
/// credential in the ConfigDb — persisted, replicated to every node's artifact cache, and readable by
|
||||
/// anyone with config access — while the runtime silently ignored it and the driver failed to connect.
|
||||
/// Discarding a secret on read is not the same as refusing to store it.</para>
|
||||
/// <para><b>Two of the three surfaces are checked here.</b> The third — a node's
|
||||
/// <see cref="ClusterNode.DriverConfigOverridesJson"/> — is not reachable from
|
||||
/// <see cref="DraftSnapshot"/> and is gated at its save instead (#499); see
|
||||
/// <see cref="SqlCredentialGuard"/> for why a deploy gate is the wrong instrument for a value that
|
||||
/// never enters the artifact.</para>
|
||||
/// <para>Checked on <b>both</b> config surfaces a Sql driver reads: the instance's
|
||||
/// <see cref="DriverInstance.DriverConfig"/> and the <see cref="Device.DeviceConfig"/> of every device
|
||||
/// beneath it, because the two are merged before the DTO sees them — a credential pasted into the
|
||||
/// device blob lands in exactly the same place.</para>
|
||||
/// <para>The key is matched <b>case-insensitively</b>: <c>System.Text.Json</c> binds
|
||||
/// <c>ConnectionString</c> to a <c>connectionString</c> property by default, so a case variant is the
|
||||
/// same key, not a different one. Only the top level is scanned — the DTO is flat, so a nested
|
||||
/// occurrence cannot bind and is not the credential-shaped mistake this rule exists to catch.</para>
|
||||
/// <para><b>The message never echoes the value.</b> Validation errors reach the AdminUI, the deploy
|
||||
/// log and the audit trail; repeating the offending string there would leak the very credential the
|
||||
/// rule is refusing to store.</para>
|
||||
/// </remarks>
|
||||
private static void ValidateSqlConnectionStringNotPersisted(DraftSnapshot draft, List<ValidationError> errors)
|
||||
{
|
||||
const string ForbiddenKey = SqlCredentialGuard.ForbiddenKey;
|
||||
|
||||
var sqlInstanceIds = draft.DriverInstances
|
||||
.Where(d => string.Equals(d.DriverType, Core.Abstractions.DriverTypeNames.Sql, StringComparison.Ordinal))
|
||||
.Select(d => d.DriverInstanceId)
|
||||
.ToHashSet(StringComparer.Ordinal);
|
||||
if (sqlInstanceIds.Count == 0) return;
|
||||
|
||||
foreach (var d in draft.DriverInstances)
|
||||
{
|
||||
if (!sqlInstanceIds.Contains(d.DriverInstanceId)) continue;
|
||||
if (!SqlCredentialGuard.CarriesLiteralConnectionString(d.DriverConfig)) continue;
|
||||
errors.Add(new("SqlConnectionStringPersisted",
|
||||
$"Sql driver instance '{d.DriverInstanceId}' has a '{ForbiddenKey}' key in its DriverConfig. " +
|
||||
"A Sql driver must name its credentials indirectly via 'connectionStringRef', which resolves " +
|
||||
"from the environment / secret store at Initialize; a literal connection string here would be " +
|
||||
"stored in the config database and replicated to every node, and the runtime ignores it anyway. " +
|
||||
"Remove the key and set 'connectionStringRef'.",
|
||||
d.DriverInstanceId));
|
||||
}
|
||||
|
||||
foreach (var dev in draft.Devices)
|
||||
{
|
||||
if (!sqlInstanceIds.Contains(dev.DriverInstanceId)) continue;
|
||||
if (!SqlCredentialGuard.CarriesLiteralConnectionString(dev.DeviceConfig)) continue;
|
||||
errors.Add(new("SqlConnectionStringPersisted",
|
||||
$"Device '{dev.DeviceId}' on Sql driver instance '{dev.DriverInstanceId}' has a " +
|
||||
$"'{ForbiddenKey}' key in its DeviceConfig. DeviceConfig is merged onto DriverConfig before " +
|
||||
"the driver reads it, so this is the same leak: use 'connectionStringRef' on the driver instead.",
|
||||
dev.DeviceId));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>WP7 Calculation-driver deploy gates. For every tag bound to a <c>Calculation</c> driver:
|
||||
/// <list type="number">
|
||||
/// <item><b>scriptId existence</b> — the tag's <c>TagConfig.scriptId</c> must be present and resolve to
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
using System.Text.Json;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Configuration.Validation;
|
||||
|
||||
/// <summary>
|
||||
/// The one place that decides whether a persisted config blob carries a literal Sql
|
||||
/// <c>connectionString</c> (Gitea #498 / #499).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// A <c>Sql</c> driver names its credentials indirectly — <c>connectionStringRef</c> resolves from
|
||||
/// the environment / secret store at Initialize — so nothing persisted should ever contain a
|
||||
/// database password. The typed <c>SqlDriverConfigDto</c> already drops a <c>connectionString</c>
|
||||
/// key on <b>read</b>, but discarding a secret on read is not the same as refusing to store it:
|
||||
/// config blobs are schemaless JSON columns authored through raw-JSON textareas, so the key can
|
||||
/// be written even though the runtime ignores it.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// There are <b>three</b> surfaces a Sql driver's config is persisted on, and they need different
|
||||
/// enforcement points:
|
||||
/// <list type="bullet">
|
||||
/// <item><c>DriverInstance.DriverConfig</c> and <c>Device.DeviceConfig</c> — both in the
|
||||
/// deployed artifact, both visible to <c>DraftSnapshot</c>, both gated by
|
||||
/// <c>DraftValidator</c>.</item>
|
||||
/// <item><c>ClusterNode.DriverConfigOverridesJson</c> — the per-node override map. It is
|
||||
/// <b>not</b> in <c>DraftSnapshot</c>, and deliberately does not go there: adding a
|
||||
/// <c>ClusterNode</c> collection would widen the snapshot (and every builder of one) for a
|
||||
/// single rule about a value that is not part of the artifact at all. More to the point, a
|
||||
/// deploy gate is the wrong instrument here — the artifact never carries node overrides, so
|
||||
/// blocking a deploy would not stop the credential being stored. It is already in the
|
||||
/// database by then. This surface is therefore gated at the <b>save</b>, which is the only
|
||||
/// point where "refuse to store it" is literally true.</item>
|
||||
/// </list>
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Deliberately narrow.</b> This checks the <c>Sql</c> driver's <c>connectionString</c> key
|
||||
/// only — not a general "credential-shaped key" sweep over every driver type. A broader rule
|
||||
/// would start refusing configs that are legitimate today for drivers that never made this
|
||||
/// guarantee, turning defence-in-depth into a regression. Widen it per driver, as each driver
|
||||
/// gains an indirect-credential contract of its own.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public static class SqlCredentialGuard
|
||||
{
|
||||
/// <summary>The config key a Sql driver must never persist. Matched case-insensitively.</summary>
|
||||
public const string ForbiddenKey = "connectionString";
|
||||
|
||||
/// <summary>
|
||||
/// Whether <paramref name="configJson"/> is a JSON object carrying <see cref="ForbiddenKey"/> at
|
||||
/// its top level.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Matched <b>case-insensitively</b>: <c>System.Text.Json</c> binds <c>ConnectionString</c> to a
|
||||
/// <c>connectionString</c> property by default, so a case variant is the same key, not a
|
||||
/// different one. Only the top level is scanned — the DTO is flat, so a nested occurrence cannot
|
||||
/// bind and is not the credential-shaped mistake this exists to catch.
|
||||
/// </remarks>
|
||||
/// <param name="configJson">The config blob to inspect.</param>
|
||||
/// <returns><see langword="true"/> when the key is present at the top level.</returns>
|
||||
public static bool CarriesLiteralConnectionString(string? configJson)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(configJson)) return false;
|
||||
try
|
||||
{
|
||||
using var doc = JsonDocument.Parse(configJson);
|
||||
return doc.RootElement.ValueKind == JsonValueKind.Object
|
||||
&& HasTopLevelKey(doc.RootElement, ForbiddenKey);
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
// A malformed blob simply has no keys. Shaping the config JSON is another rule's job, and
|
||||
// throwing here would turn a formatting mistake into a credential-guard failure.
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The <c>DriverInstanceId</c>s in a <c>ClusterNode.DriverConfigOverridesJson</c> map whose
|
||||
/// override object carries a literal connection string, restricted to instances that are actually
|
||||
/// Sql drivers.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The override map is shaped <c>{ "<DriverInstanceId>": { …driver config keys… } }</c> and is
|
||||
/// merged onto the cluster-level <c>DriverConfig</c>, so a credential pasted into one lands in
|
||||
/// exactly the place <see cref="CarriesLiteralConnectionString"/> already refuses on the driver
|
||||
/// itself.
|
||||
/// </remarks>
|
||||
/// <param name="overridesJson">The node's override map. Null / blank / malformed yields no
|
||||
/// violations.</param>
|
||||
/// <param name="sqlDriverInstanceIds">The ids of driver instances whose <c>DriverType</c> is
|
||||
/// <c>Sql</c>. Keys outside this set are ignored — a non-Sql driver never made the indirect-credential
|
||||
/// guarantee, so flagging it would be a regression, not defence in depth.</param>
|
||||
/// <returns>The offending driver-instance ids, in document order; empty when there are none.</returns>
|
||||
public static IReadOnlyList<string> FindNodeOverrideViolations(
|
||||
string? overridesJson, IReadOnlyCollection<string> sqlDriverInstanceIds)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(overridesJson) || sqlDriverInstanceIds.Count == 0)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
var sqlIds = sqlDriverInstanceIds as IReadOnlySet<string>
|
||||
?? sqlDriverInstanceIds.ToHashSet(StringComparer.Ordinal);
|
||||
|
||||
try
|
||||
{
|
||||
using var doc = JsonDocument.Parse(overridesJson);
|
||||
if (doc.RootElement.ValueKind != JsonValueKind.Object) return [];
|
||||
|
||||
List<string>? offenders = null;
|
||||
foreach (var entry in doc.RootElement.EnumerateObject())
|
||||
{
|
||||
if (!sqlIds.Contains(entry.Name)) continue;
|
||||
if (entry.Value.ValueKind != JsonValueKind.Object) continue;
|
||||
if (!HasTopLevelKey(entry.Value, ForbiddenKey)) continue;
|
||||
|
||||
(offenders ??= []).Add(entry.Name);
|
||||
}
|
||||
|
||||
return (IReadOnlyList<string>?)offenders ?? [];
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Whether <paramref name="obj"/> carries <paramref name="key"/> at its top level,
|
||||
/// case-insensitively.</summary>
|
||||
private static bool HasTopLevelKey(JsonElement obj, string key)
|
||||
{
|
||||
foreach (var property in obj.EnumerateObject())
|
||||
{
|
||||
if (string.Equals(property.Name, key, StringComparison.OrdinalIgnoreCase)) return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -53,6 +53,9 @@ public static class DriverTypeNames
|
||||
/// <summary>Calculation pseudo-driver — tags computed by C# scripts over other tags' live values.</summary>
|
||||
public const string Calculation = "Calculation";
|
||||
|
||||
/// <summary>Read-only SQL Server table/view poller — publishes columns as OPC UA variables.</summary>
|
||||
public const string Sql = "Sql";
|
||||
|
||||
/// <summary>
|
||||
/// Every driver-type string declared above, for callers that need to enumerate
|
||||
/// the full set (e.g. validation of an authored <c>DriverType</c>).
|
||||
@@ -68,5 +71,6 @@ public static class DriverTypeNames
|
||||
OpcUaClient,
|
||||
Galaxy,
|
||||
Calculation,
|
||||
Sql,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -334,6 +334,48 @@ public sealed class ScriptedAlarmEngine : IDisposable
|
||||
public IReadOnlyCollection<AlarmConditionState> GetAllStates()
|
||||
=> _alarms.Values.Select(a => a.Condition).ToArray();
|
||||
|
||||
/// <summary>
|
||||
/// #487 — the currently-held condition of every loaded alarm, packaged with the definition's
|
||||
/// severity, the rendered message template and the last-observed input quality, i.e. everything
|
||||
/// a caller needs to <b>project</b> the alarm onto an OPC UA condition node without waiting for
|
||||
/// the next transition.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>This is a read, not an emission.</b> It deliberately returns a distinct type rather
|
||||
/// than a <see cref="ScriptedAlarmEvent"/>, and it never touches <see cref="OnEvent"/>. A
|
||||
/// still-active alarm produces <see cref="EmissionKind.None"/> at load (there is no
|
||||
/// transition to report), so the transition stream cannot carry the current state — that is
|
||||
/// exactly why the caller needs this. Routing these through the emission path instead would
|
||||
/// append a duplicate historian / <c>alerts</c> row on every deploy, so the shape here is
|
||||
/// intentionally one the emission machinery cannot consume.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Synchronization.</b> Reads <c>_alarms</c> and <c>_valueCache</c> — both
|
||||
/// <see cref="ConcurrentDictionary{TKey, TValue}"/> — without taking <c>_evalGate</c>, the
|
||||
/// same posture as <see cref="GetState"/> / <see cref="GetAllStates"/>. Each projection is
|
||||
/// individually coherent; the set as a whole is not an atomic snapshot across a concurrent
|
||||
/// re-evaluation. That is sufficient for the node-projection use: an evaluation racing this
|
||||
/// read publishes its own transition through the normal path.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
/// <returns>One projection per loaded alarm; empty before the first <see cref="LoadAsync"/>.</returns>
|
||||
public IReadOnlyList<ScriptedAlarmProjection> GetProjections()
|
||||
{
|
||||
var projections = new List<ScriptedAlarmProjection>(_alarms.Count);
|
||||
foreach (var (alarmId, state) in _alarms)
|
||||
{
|
||||
projections.Add(new ScriptedAlarmProjection(
|
||||
AlarmId: alarmId,
|
||||
Severity: state.Definition.Severity,
|
||||
Message: MessageTemplate.Resolve(state.Definition.MessageTemplate, TryLookup),
|
||||
Condition: state.Condition,
|
||||
WorstInputStatusCode: LastWorstStatus(alarmId)));
|
||||
}
|
||||
|
||||
return projections;
|
||||
}
|
||||
|
||||
/// <summary>Acknowledges the specified alarm on behalf of the given user.</summary>
|
||||
/// <param name="alarmId">The alarm identifier.</param>
|
||||
/// <param name="user">The user performing the acknowledgment.</param>
|
||||
@@ -974,6 +1016,26 @@ public sealed record ScriptedAlarmEvent(
|
||||
// the top-2 severity bits. Default 0u == Good keeps every existing constructor call unchanged.
|
||||
uint WorstInputStatusCode = 0u);
|
||||
|
||||
/// <summary>
|
||||
/// #487 — a loaded alarm's current condition, ready to be projected onto its OPC UA node.
|
||||
/// Produced by <see cref="ScriptedAlarmEngine.GetProjections"/> on demand; it is <b>not</b> an
|
||||
/// emission and never travels the <see cref="ScriptedAlarmEngine.OnEvent"/> path, so it can never
|
||||
/// become an <c>alerts</c> row or a historian record. Deliberately a separate type from
|
||||
/// <see cref="ScriptedAlarmEvent"/> — it carries no <see cref="EmissionKind"/>, so there is nothing
|
||||
/// for a future refactor to mistake for a transition.
|
||||
/// </summary>
|
||||
/// <param name="AlarmId">The alarm identifier (also its OPC UA condition NodeId).</param>
|
||||
/// <param name="Severity">The definition's severity bucket.</param>
|
||||
/// <param name="Message">The message template resolved against the current value cache.</param>
|
||||
/// <param name="Condition">The Part 9 condition state the engine currently holds.</param>
|
||||
/// <param name="WorstInputStatusCode">The last-observed worst OPC UA StatusCode across the alarm's inputs.</param>
|
||||
public sealed record ScriptedAlarmProjection(
|
||||
string AlarmId,
|
||||
AlarmSeverity Severity,
|
||||
string Message,
|
||||
AlarmConditionState Condition,
|
||||
uint WorstInputStatusCode);
|
||||
|
||||
/// <summary>
|
||||
/// Upstream source abstraction — intentionally identical shape to the virtual-tag
|
||||
/// engine's so Stream G can compose them behind one driver bridge.
|
||||
|
||||
@@ -35,7 +35,7 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip;
|
||||
public static class AbCipStatusMapper
|
||||
{
|
||||
public const uint Good = 0u;
|
||||
public const uint GoodMoreData = 0x00A70000u;
|
||||
public const uint GoodMoreData = 0x00A60000u;
|
||||
public const uint BadInternalError = 0x80020000u;
|
||||
public const uint BadNodeIdUnknown = 0x80340000u;
|
||||
public const uint BadNotWritable = 0x803B0000u;
|
||||
@@ -44,7 +44,7 @@ public static class AbCipStatusMapper
|
||||
public const uint BadDeviceFailure = 0x808B0000u;
|
||||
public const uint BadCommunicationError = 0x80050000u;
|
||||
public const uint BadTimeout = 0x800A0000u;
|
||||
public const uint BadTypeMismatch = 0x80730000u;
|
||||
public const uint BadTypeMismatch = 0x80740000u;
|
||||
|
||||
/// <summary>Map a CIP general-status byte to an OPC UA StatusCode.</summary>
|
||||
/// <param name="status">The CIP general-status byte value.</param>
|
||||
|
||||
@@ -10,7 +10,7 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy;
|
||||
public static class AbLegacyStatusMapper
|
||||
{
|
||||
public const uint Good = 0u;
|
||||
public const uint GoodMoreData = 0x00A70000u;
|
||||
public const uint GoodMoreData = 0x00A60000u;
|
||||
public const uint BadInternalError = 0x80020000u;
|
||||
public const uint BadNodeIdUnknown = 0x80340000u;
|
||||
public const uint BadNotWritable = 0x803B0000u;
|
||||
@@ -19,7 +19,7 @@ public static class AbLegacyStatusMapper
|
||||
public const uint BadDeviceFailure = 0x808B0000u;
|
||||
public const uint BadCommunicationError = 0x80050000u;
|
||||
public const uint BadTimeout = 0x800A0000u;
|
||||
public const uint BadTypeMismatch = 0x80730000u;
|
||||
public const uint BadTypeMismatch = 0x80740000u;
|
||||
|
||||
/// <summary>
|
||||
/// Map a libplctag return/status code to an OPC UA StatusCode. The integer passed here
|
||||
|
||||
@@ -17,7 +17,7 @@ public static class FocasStatusMapper
|
||||
public const uint BadDeviceFailure = 0x808B0000u;
|
||||
public const uint BadCommunicationError = 0x80050000u;
|
||||
public const uint BadTimeout = 0x800A0000u;
|
||||
public const uint BadTypeMismatch = 0x80730000u;
|
||||
public const uint BadTypeMismatch = 0x80740000u;
|
||||
|
||||
/// <summary>
|
||||
/// Map common FWLIB <c>EW_*</c> return codes. The values below match Fanuc's published
|
||||
|
||||
@@ -856,7 +856,7 @@ public sealed class GalaxyDriver
|
||||
{
|
||||
rejectedTcs.TrySetResult(new DataValueSnapshot(
|
||||
Value: null,
|
||||
StatusCode: 0x80000000u, // Bad
|
||||
StatusCode: StatusCodeMap.Bad,
|
||||
SourceTimestampUtc: null,
|
||||
ServerTimestampUtc: DateTime.UtcNow));
|
||||
}
|
||||
@@ -875,7 +875,7 @@ public sealed class GalaxyDriver
|
||||
{
|
||||
tcs.TrySetResult(new DataValueSnapshot(
|
||||
Value: null,
|
||||
StatusCode: 0x800B0000u, // BadTimeout
|
||||
StatusCode: StatusCodeMap.BadTimeout,
|
||||
SourceTimestampUtc: null,
|
||||
ServerTimestampUtc: DateTime.UtcNow));
|
||||
}
|
||||
|
||||
@@ -26,14 +26,23 @@ internal static class StatusCodeMap
|
||||
{
|
||||
// OPC UA Part 4 standard StatusCodes — top-byte categories are 0x00 (Good),
|
||||
// 0x40 (Uncertain), 0x80 (Bad). Specific codes layer onto the category byte.
|
||||
//
|
||||
// The substatus nibbles are NOT derivable from the OPC DA quality byte this mapper consumes —
|
||||
// they are an unrelated OPC UA enumeration and have to be looked up. Five constants here were
|
||||
// originally written as though the DA byte could be shifted into the substatus position, which
|
||||
// produced values naming entirely different UA codes (Gitea #497): UncertainLastUsableValue held
|
||||
// UncertainDataSubNormal's value, UncertainSubNormal held UncertainNoCommunicationLastUsableValue's,
|
||||
// and GoodLocalOverride / UncertainSensorNotAccurate / UncertainEngineeringUnitsExceeded held values
|
||||
// that are not OPC UA status codes at all. StatusCodeParityTests now checks every constant below
|
||||
// against the pinned SDK's Opc.Ua.StatusCodes.
|
||||
|
||||
public const uint Good = 0x00000000u;
|
||||
public const uint GoodLocalOverride = 0x00D80000u;
|
||||
public const uint GoodLocalOverride = 0x00960000u;
|
||||
public const uint Uncertain = 0x40000000u;
|
||||
public const uint UncertainLastUsableValue = 0x40A40000u;
|
||||
public const uint UncertainSensorNotAccurate = 0x408D0000u;
|
||||
public const uint UncertainEngineeringUnitsExceeded = 0x408E0000u;
|
||||
public const uint UncertainSubNormal = 0x408F0000u;
|
||||
public const uint UncertainLastUsableValue = 0x40900000u;
|
||||
public const uint UncertainSensorNotAccurate = 0x40930000u;
|
||||
public const uint UncertainEngineeringUnitsExceeded = 0x40940000u;
|
||||
public const uint UncertainSubNormal = 0x40950000u;
|
||||
public const uint Bad = 0x80000000u;
|
||||
public const uint BadConfigurationError = 0x80890000u;
|
||||
public const uint BadNotConnected = 0x808A0000u;
|
||||
@@ -44,6 +53,14 @@ internal static class StatusCodeMap
|
||||
public const uint BadWaitingForInitialData = 0x80320000u;
|
||||
public const uint BadInternalError = 0x80020000u;
|
||||
|
||||
/// <summary>
|
||||
/// Fills a still-pending read when the caller's token fires before the gateway answers. Named here
|
||||
/// rather than written inline at the call site so <c>StatusCodeParityTests</c> can reflect over it —
|
||||
/// an inline literal is invisible to that guard, which is exactly how this constant spent its life
|
||||
/// as <c>0x800B0000</c> (<c>BadServiceUnsupported</c>) under a <c>// BadTimeout</c> comment.
|
||||
/// </summary>
|
||||
public const uint BadTimeout = 0x800A0000u;
|
||||
|
||||
/// <summary>
|
||||
/// Map a raw OPC DA quality byte (the low byte of an OPC DA <c>OpcQuality</c> ushort,
|
||||
/// which is what Wonderware Historian + MXAccess surface as <c>OPCITEMSTATE.qLong</c>'s
|
||||
|
||||
+18
-18
@@ -18,29 +18,29 @@ internal static class GatewayQualityMapper
|
||||
public static uint Map(byte q) => q switch
|
||||
{
|
||||
// Good family (192+)
|
||||
192 => 0x00000000u, // Good
|
||||
216 => 0x00D80000u, // Good_LocalOverride
|
||||
192 => HistorianStatusCodes.Good,
|
||||
216 => HistorianStatusCodes.GoodLocalOverride,
|
||||
|
||||
// Uncertain family (64-191)
|
||||
64 => 0x40000000u, // Uncertain
|
||||
68 => 0x40900000u, // Uncertain_LastUsableValue
|
||||
80 => 0x40930000u, // Uncertain_SensorNotAccurate
|
||||
84 => 0x40940000u, // Uncertain_EngineeringUnitsExceeded
|
||||
88 => 0x40950000u, // Uncertain_SubNormal
|
||||
64 => HistorianStatusCodes.Uncertain,
|
||||
68 => HistorianStatusCodes.UncertainLastUsableValue,
|
||||
80 => HistorianStatusCodes.UncertainSensorNotAccurate,
|
||||
84 => HistorianStatusCodes.UncertainEngineeringUnitsExceeded,
|
||||
88 => HistorianStatusCodes.UncertainSubNormal,
|
||||
|
||||
// Bad family (0-63)
|
||||
0 => 0x80000000u, // Bad
|
||||
4 => 0x80890000u, // Bad_ConfigurationError
|
||||
8 => 0x808A0000u, // Bad_NotConnected
|
||||
12 => 0x808B0000u, // Bad_DeviceFailure
|
||||
16 => 0x808C0000u, // Bad_SensorFailure
|
||||
20 => 0x80050000u, // Bad_CommunicationError
|
||||
24 => 0x808D0000u, // Bad_OutOfService
|
||||
32 => 0x80320000u, // Bad_WaitingForInitialData
|
||||
0 => HistorianStatusCodes.Bad,
|
||||
4 => HistorianStatusCodes.BadConfigurationError,
|
||||
8 => HistorianStatusCodes.BadNotConnected,
|
||||
12 => HistorianStatusCodes.BadDeviceFailure,
|
||||
16 => HistorianStatusCodes.BadSensorFailure,
|
||||
20 => HistorianStatusCodes.BadCommunicationError,
|
||||
24 => HistorianStatusCodes.BadOutOfService,
|
||||
32 => HistorianStatusCodes.BadWaitingForInitialData,
|
||||
|
||||
// Unknown — fall back to category bucket so callers still get something usable.
|
||||
_ when q >= 192 => 0x00000000u,
|
||||
_ when q >= 64 => 0x40000000u,
|
||||
_ => 0x80000000u,
|
||||
_ when q >= 192 => HistorianStatusCodes.Good,
|
||||
_ when q >= 64 => HistorianStatusCodes.Uncertain,
|
||||
_ => HistorianStatusCodes.Bad,
|
||||
};
|
||||
}
|
||||
|
||||
+75
@@ -0,0 +1,75 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Mapping;
|
||||
|
||||
/// <summary>
|
||||
/// The OPC UA status codes this driver publishes, as named constants.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>The driver layer is deliberately free of an OPC UA SDK reference, so status codes are spelled
|
||||
/// as bare <c>uint</c>s. The cost of that is a value nobody checks against the name it is written
|
||||
/// under — the defect class Gitea #497 found in six places across four drivers, one of them the
|
||||
/// <c>BadNoData</c> in <see cref="SampleMapper"/> that was really <c>BadServerHalted</c>.</para>
|
||||
/// <para><b>Named, not inline, on purpose.</b> <c>StatusCodeParityTests</c> guards these by reflecting
|
||||
/// over <c>const uint</c> fields and comparing each against <c>Opc.Ua.StatusCodes</c> in the pinned SDK.
|
||||
/// A literal written at a call site is invisible to that guard, which is exactly how
|
||||
/// <see cref="GatewayQualityMapper"/> kept an incorrect <c>Good_LocalOverride</c> through every test it
|
||||
/// had. Add a constant here rather than a literal in a <c>switch</c> arm.</para>
|
||||
/// </remarks>
|
||||
internal static class HistorianStatusCodes
|
||||
{
|
||||
// ---- Good family ----
|
||||
|
||||
/// <summary>The value is good; no qualification.</summary>
|
||||
public const uint Good = 0x00000000u;
|
||||
|
||||
/// <summary>The value has been overridden locally (OPC DA quality 216).</summary>
|
||||
public const uint GoodLocalOverride = 0x00960000u;
|
||||
|
||||
// ---- Uncertain family ----
|
||||
|
||||
/// <summary>The value is uncertain; no specific reason.</summary>
|
||||
public const uint Uncertain = 0x40000000u;
|
||||
|
||||
/// <summary>Communication has failed; the last known value is returned (OPC DA quality 68).</summary>
|
||||
public const uint UncertainLastUsableValue = 0x40900000u;
|
||||
|
||||
/// <summary>The sensor is known not to be accurate (OPC DA quality 80).</summary>
|
||||
public const uint UncertainSensorNotAccurate = 0x40930000u;
|
||||
|
||||
/// <summary>The value is outside the engineering-unit range for the sensor (OPC DA quality 84).</summary>
|
||||
public const uint UncertainEngineeringUnitsExceeded = 0x40940000u;
|
||||
|
||||
/// <summary>The value is derived from fewer sources than required (OPC DA quality 88).</summary>
|
||||
public const uint UncertainSubNormal = 0x40950000u;
|
||||
|
||||
// ---- Bad family ----
|
||||
|
||||
/// <summary>The value is bad; no specific reason.</summary>
|
||||
public const uint Bad = 0x80000000u;
|
||||
|
||||
/// <summary>A configuration problem prevents the value being produced (OPC DA quality 4).</summary>
|
||||
public const uint BadConfigurationError = 0x80890000u;
|
||||
|
||||
/// <summary>The source is not connected (OPC DA quality 8).</summary>
|
||||
public const uint BadNotConnected = 0x808A0000u;
|
||||
|
||||
/// <summary>The device reported a failure (OPC DA quality 12).</summary>
|
||||
public const uint BadDeviceFailure = 0x808B0000u;
|
||||
|
||||
/// <summary>The sensor reported a failure (OPC DA quality 16).</summary>
|
||||
public const uint BadSensorFailure = 0x808C0000u;
|
||||
|
||||
/// <summary>Communication with the source failed (OPC DA quality 20).</summary>
|
||||
public const uint BadCommunicationError = 0x80050000u;
|
||||
|
||||
/// <summary>The source is out of service (OPC DA quality 24).</summary>
|
||||
public const uint BadOutOfService = 0x808D0000u;
|
||||
|
||||
/// <summary>No initial value has arrived from the source yet (OPC DA quality 32).</summary>
|
||||
public const uint BadWaitingForInitialData = 0x80320000u;
|
||||
|
||||
/// <summary>
|
||||
/// The historian returned no data for the requested tag/interval. Distinct from a transport
|
||||
/// failure: the query succeeded and the answer was empty.
|
||||
/// </summary>
|
||||
public const uint BadNoData = 0x809B0000u;
|
||||
}
|
||||
@@ -10,8 +10,8 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Mapping;
|
||||
/// </summary>
|
||||
internal static class SampleMapper
|
||||
{
|
||||
private const uint StatusGood = 0x00000000u;
|
||||
private const uint StatusBadNoData = 0x800E0000u;
|
||||
private const uint StatusGood = HistorianStatusCodes.Good;
|
||||
private const uint StatusBadNoData = HistorianStatusCodes.BadNoData;
|
||||
|
||||
/// <summary>OPC DA "Good" family floor — a quality byte at/above this carries usable data.</summary>
|
||||
private const byte GoodQualityFloor = 192;
|
||||
|
||||
@@ -130,6 +130,14 @@ public sealed class ModbusDriverOptions
|
||||
/// </summary>
|
||||
public MelsecFamily MelsecSubFamily { get; init; } = MelsecFamily.Q_L_iQR;
|
||||
|
||||
/// <summary>
|
||||
/// Wire transport selector. <see cref="ModbusTransportMode.Tcp"/> (default) is the
|
||||
/// historical Modbus TCP/MBAP framing. <see cref="ModbusTransportMode.RtuOverTcp"/>
|
||||
/// frames Modbus RTU PDUs (CRC16, no MBAP header) over the same TCP socket — for
|
||||
/// serial-to-Ethernet gateways that bridge RS-485 without re-encapsulating to MBAP.
|
||||
/// </summary>
|
||||
public ModbusTransportMode Transport { get; init; } = ModbusTransportMode.Tcp;
|
||||
|
||||
/// <summary>
|
||||
/// When <c>true</c>, the driver suppresses redundant writes: if the most recent
|
||||
/// successful write to a tag carried value V and a new write of V arrives, the second
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus;
|
||||
|
||||
/// <summary>
|
||||
/// Wire transport for a Modbus driver instance. <see cref="Tcp"/> = Modbus/TCP (MBAP + TxId);
|
||||
/// <see cref="RtuOverTcp"/> = RTU framing (address + CRC-16, no MBAP) tunnelled over a socket to
|
||||
/// a serial→Ethernet gateway. Default <see cref="Tcp"/> — existing configs omit the field.
|
||||
/// A direct-serial <c>Rtu</c> member is intentionally NOT reserved here (descoped 2026-07-15);
|
||||
/// do not number-squat it.
|
||||
/// </summary>
|
||||
public enum ModbusTransportMode
|
||||
{
|
||||
/// <summary>Modbus/TCP — 7-byte MBAP header + transaction id (the historical default).</summary>
|
||||
Tcp,
|
||||
|
||||
/// <summary>RTU framing (<c>[addr][PDU][CRC-16]</c>) tunnelled over a socket to a serial→Ethernet gateway.</summary>
|
||||
RtuOverTcp,
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus;
|
||||
|
||||
/// <summary>
|
||||
/// CRC-16/MODBUS helper: reflected polynomial <c>0xA001</c>, initial value <c>0xFFFF</c>.
|
||||
/// On the wire the CRC is appended low byte first, high byte second — <see cref="Append"/>
|
||||
/// does that; <see cref="Compute"/> just returns the 16-bit value. Byte-stream-agnostic:
|
||||
/// no socket or framing knowledge lives here.
|
||||
/// </summary>
|
||||
public static class ModbusCrc
|
||||
{
|
||||
/// <summary>Computes the CRC-16/MODBUS checksum over <paramref name="data"/>.</summary>
|
||||
/// <param name="data">The bytes to checksum (e.g. the RTU frame minus the trailing CRC).</param>
|
||||
/// <returns>The 16-bit CRC value.</returns>
|
||||
public static ushort Compute(ReadOnlySpan<byte> data)
|
||||
{
|
||||
ushort crc = 0xFFFF;
|
||||
foreach (var b in data)
|
||||
{
|
||||
crc ^= b;
|
||||
for (var i = 0; i < 8; i++)
|
||||
{
|
||||
var carry = (crc & 0x0001) != 0;
|
||||
crc >>= 1;
|
||||
if (carry)
|
||||
{
|
||||
crc ^= 0xA001;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return crc;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns <paramref name="body"/> with its CRC-16/MODBUS appended, low byte first.
|
||||
/// </summary>
|
||||
/// <param name="body">The frame body to checksum.</param>
|
||||
/// <returns>A new array: <paramref name="body"/> followed by <c>[crc & 0xFF, crc >> 8]</c>.</returns>
|
||||
public static byte[] Append(ReadOnlySpan<byte> body)
|
||||
{
|
||||
var crc = Compute(body);
|
||||
var result = new byte[body.Length + 2];
|
||||
body.CopyTo(result);
|
||||
result[^2] = (byte)(crc & 0xFF);
|
||||
result[^1] = (byte)(crc >> 8);
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -94,7 +94,8 @@ public sealed class ModbusDriver
|
||||
/// <summary>Initializes a new Modbus TCP driver with the specified options and transport factory.</summary>
|
||||
/// <param name="options">Driver configuration options.</param>
|
||||
/// <param name="driverInstanceId">Unique identifier for this driver instance.</param>
|
||||
/// <param name="transportFactory">Factory to create the Modbus transport; defaults to ModbusTcpTransport.</param>
|
||||
/// <param name="transportFactory">Factory to create the Modbus transport; defaults to
|
||||
/// <see cref="ModbusTransportFactory.Create"/>, which honours <see cref="ModbusDriverOptions.Transport"/>.</param>
|
||||
/// <param name="logger">Logger instance; defaults to null logger if not provided.</param>
|
||||
public ModbusDriver(ModbusDriverOptions options, string driverInstanceId,
|
||||
Func<ModbusDriverOptions, IModbusTransport>? transportFactory = null,
|
||||
@@ -107,11 +108,7 @@ public sealed class ModbusDriver
|
||||
_resolver = new EquipmentTagRefResolver<ModbusTagDefinition>(
|
||||
r => _tagsByRawPath.TryGetValue(r, out var t) ? t : null);
|
||||
_transportFactory = transportFactory
|
||||
?? (o => new ModbusTcpTransport(
|
||||
o.Host, o.Port, o.Timeout, o.AutoReconnect,
|
||||
keepAlive: o.KeepAlive,
|
||||
idleDisconnect: o.IdleDisconnectTimeout,
|
||||
reconnect: o.Reconnect));
|
||||
?? (o => ModbusTransportFactory.Create(o));
|
||||
_poll = new PollGroupEngine(
|
||||
reader: ReadAsync,
|
||||
onChange: (handle, tagRef, snapshot) =>
|
||||
|
||||
@@ -74,6 +74,8 @@ public static class ModbusDriverFactoryExtensions
|
||||
: ParseEnum<ModbusFamily>(dto.Family, "<driver-level>", driverInstanceId, "Family"),
|
||||
MelsecSubFamily = dto.MelsecSubFamily is null ? MelsecFamily.Q_L_iQR
|
||||
: ParseEnum<MelsecFamily>(dto.MelsecSubFamily, "<driver-level>", driverInstanceId, "MelsecSubFamily"),
|
||||
Transport = dto.Transport is null ? ModbusTransportMode.Tcp
|
||||
: ParseEnum<ModbusTransportMode>(dto.Transport, "<driver-level>", driverInstanceId, "Transport"),
|
||||
AutoProhibitReprobeInterval = dto.AutoProhibitReprobeMs is { } reprobeMs ? TimeSpan.FromMilliseconds(reprobeMs) : null,
|
||||
AutoReconnect = dto.AutoReconnect ?? true,
|
||||
// v3: the deploy artifact (Wave C) populates rawTags with the cluster's authored raw Modbus
|
||||
@@ -159,6 +161,8 @@ public static class ModbusDriverFactoryExtensions
|
||||
public string? Family { get; init; }
|
||||
/// <summary>Gets or sets the Melsec subfamily.</summary>
|
||||
public string? MelsecSubFamily { get; init; }
|
||||
/// <summary>Gets or sets the wire transport mode (Tcp / RtuOverTcp). Null defaults to Tcp.</summary>
|
||||
public string? Transport { get; init; }
|
||||
/// <summary>Gets or sets the automatic prohibition reprobei interval in milliseconds.</summary>
|
||||
public int? AutoProhibitReprobeMs { get; init; }
|
||||
/// <summary>Gets or sets a value indicating whether automatic reconnection is enabled.</summary>
|
||||
|
||||
@@ -29,15 +29,19 @@ public sealed class ModbusDriverProbe : IDriverProbe
|
||||
/// <inheritdoc />
|
||||
public string DriverType => "Modbus";
|
||||
|
||||
/// <summary>The parsed probe target — host/port/unitId + the effective connection timeout, all read
|
||||
/// from the SAME factory DTO shape (<c>ModbusDriverConfigDto</c>) the driver factory parses, so
|
||||
/// <c>timeoutMs</c> binds identically to the factory (R2-11, 05/CONV-2; the OpcUaClient parity rule).</summary>
|
||||
internal readonly record struct ProbeTarget(string Host, int Port, byte UnitId, TimeSpan Timeout);
|
||||
/// <summary>The parsed probe target — host/port/unitId + the effective connection timeout + the wire
|
||||
/// transport mode, all read from the SAME factory DTO shape (<c>ModbusDriverConfigDto</c>) the driver
|
||||
/// factory parses, so <c>timeoutMs</c> binds identically to the factory (R2-11, 05/CONV-2; the
|
||||
/// OpcUaClient parity rule) and an <c>RtuOverTcp</c>-authored config probes over RTU framing —
|
||||
/// matching how the driver will actually run.</summary>
|
||||
internal readonly record struct ProbeTarget(string Host, int Port, byte UnitId, TimeSpan Timeout, ModbusTransportMode Transport);
|
||||
|
||||
/// <summary>Parse the driver config JSON into a <see cref="ProbeTarget"/> using the factory DTO shape.
|
||||
/// Returns a null target + an error message when the JSON is invalid or has no host/port. The effective
|
||||
/// timeout mirrors the factory (<c>TimeSpan.FromMilliseconds(dto.TimeoutMs ?? …)</c>); when the config
|
||||
/// omits <c>timeoutMs</c> the caller's <paramref name="fallbackTimeout"/> is used.</summary>
|
||||
/// omits <c>timeoutMs</c> the caller's <paramref name="fallbackTimeout"/> is used. <c>Transport</c>
|
||||
/// mirrors the factory's null-defaults-to-Tcp convention (<see cref="ModbusDriverFactoryExtensions"/>);
|
||||
/// an unrecognized value is reported as a probe-target error rather than silently falling back.</summary>
|
||||
/// <param name="configJson">The driver config JSON (factory DTO shape).</param>
|
||||
/// <param name="fallbackTimeout">The timeout used when the config omits <c>timeoutMs</c>.</param>
|
||||
/// <returns>The parsed target, or a null target with an error string.</returns>
|
||||
@@ -52,8 +56,15 @@ public sealed class ModbusDriverProbe : IDriverProbe
|
||||
if (string.IsNullOrWhiteSpace(dto.Host) || port <= 0)
|
||||
return (null, "Config has no host/port to probe.");
|
||||
|
||||
var transportMode = ModbusTransportMode.Tcp;
|
||||
if (dto.Transport is not null && !Enum.TryParse(dto.Transport, ignoreCase: true, out transportMode))
|
||||
{
|
||||
return (null, $"Config has unknown Transport '{dto.Transport}'. " +
|
||||
$"Expected one of {string.Join(", ", Enum.GetNames<ModbusTransportMode>())}");
|
||||
}
|
||||
|
||||
var timeout = dto.TimeoutMs is { } ms && ms > 0 ? TimeSpan.FromMilliseconds(ms) : fallbackTimeout;
|
||||
return (new ProbeTarget(dto.Host!, port, dto.UnitId ?? 1, timeout), null);
|
||||
return (new ProbeTarget(dto.Host!, port, dto.UnitId ?? 1, timeout, transportMode), null);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -72,9 +83,17 @@ public sealed class ModbusDriverProbe : IDriverProbe
|
||||
using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||||
cts.CancelAfter(timeout);
|
||||
|
||||
// Phase 1 — TCP connect (using ModbusTcpTransport which handles IPv4 preference).
|
||||
// Phase 1 — TCP connect, over whichever transport the config's Transport mode selects
|
||||
// (ModbusTransportFactory is the single mapping point — same switch the live driver uses).
|
||||
// autoReconnect=false: this is a one-shot probe, no retry loops.
|
||||
var transport = new ModbusTcpTransport(host, port, timeout, autoReconnect: false);
|
||||
var transport = ModbusTransportFactory.Create(new ModbusDriverOptions
|
||||
{
|
||||
Host = host,
|
||||
Port = port,
|
||||
Timeout = timeout,
|
||||
Transport = t.Transport,
|
||||
AutoReconnect = false,
|
||||
});
|
||||
await using (transport.ConfigureAwait(false))
|
||||
{
|
||||
try
|
||||
|
||||
@@ -0,0 +1,187 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus;
|
||||
|
||||
/// <summary>
|
||||
/// Modbus RTU framing: builds a request ADU (<c>[unitId] + PDU + CRC</c>) and deframes a
|
||||
/// response back to its bare PDU. Byte-stream-agnostic — it operates on a <see cref="Stream"/>
|
||||
/// and knows nothing about sockets or serial ports, so a future serial transport can reuse it.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The single genuinely-new correctness risk of the RTU path: an RTU frame carries <b>no
|
||||
/// length field</b> (unlike TCP's MBAP header), so the response size must be derived from
|
||||
/// the function code. After reading <c>addr(1)</c> + <c>fc(1)</c>, the remaining byte count
|
||||
/// is one of three shapes:
|
||||
/// </para>
|
||||
/// <list type="table">
|
||||
/// <listheader><term>Shape</term><description>Trailing bytes after <c>addr,fc</c></description></listheader>
|
||||
/// <item>
|
||||
/// <term>Exception (<c>fc & 0x80</c>)</term>
|
||||
/// <description><c>excCode(1)</c> + <c>CRC(2)</c></description>
|
||||
/// </item>
|
||||
/// <item>
|
||||
/// <term>Read (FC 01/02/03/04)</term>
|
||||
/// <description><c>byteCount(1)</c> then <c>byteCount</c> data bytes + <c>CRC(2)</c></description>
|
||||
/// </item>
|
||||
/// <item>
|
||||
/// <term>Write echo (FC 05/06/15/16)</term>
|
||||
/// <description>fixed <c>4</c> bytes + <c>CRC(2)</c></description>
|
||||
/// </item>
|
||||
/// </list>
|
||||
/// <para>
|
||||
/// The trailing CRC is validated over <c>[addr, ...pdu]</c> (everything except the two CRC
|
||||
/// bytes) <b>before</b> the frame is interpreted — so a corrupt exception frame surfaces as a
|
||||
/// desync, never as a bogus <see cref="ModbusException"/>. A CRC mismatch or a short read
|
||||
/// (truncation / stream closed mid-frame) throws <see cref="ModbusTransportDesyncException"/>;
|
||||
/// a CRC-valid exception PDU throws <see cref="ModbusException"/> carrying the original
|
||||
/// function code (<c>fc & 0x7F</c>) and exception code.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Once the CRC passes, the response's address byte is validated against the addressed unit.
|
||||
/// On an RS-485 multi-drop bus a CRC-valid reply from the <b>wrong</b> slave would otherwise be
|
||||
/// silently accepted as the addressed unit's data; such a frame is a unit-mismatch
|
||||
/// <see cref="ModbusTransportDesyncException"/>. This ordering is deliberate — a corrupt frame
|
||||
/// stays a CRC/desync failure, and only a coherent-but-mis-addressed frame becomes a
|
||||
/// unit-mismatch desync.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public static class ModbusRtuFraming
|
||||
{
|
||||
/// <summary>
|
||||
/// Builds an RTU request ADU: the unit id, the PDU, and the trailing CRC-16/MODBUS
|
||||
/// (appended low byte first).
|
||||
/// </summary>
|
||||
/// <param name="unitId">The RTU slave/unit address.</param>
|
||||
/// <param name="pdu">The bare PDU (function code + data).</param>
|
||||
/// <returns>A new array: <c>[unitId, ...pdu, crcLo, crcHi]</c>.</returns>
|
||||
public static byte[] BuildAdu(byte unitId, ReadOnlySpan<byte> pdu)
|
||||
{
|
||||
var frame = new byte[1 + pdu.Length];
|
||||
frame[0] = unitId;
|
||||
pdu.CopyTo(frame.AsSpan(1));
|
||||
return ModbusCrc.Append(frame);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reads a single RTU response frame from <paramref name="stream"/> and returns its bare PDU
|
||||
/// (<c>[fc, ...data]</c>), with the leading unit-id and trailing CRC stripped.
|
||||
/// </summary>
|
||||
/// <param name="stream">The byte stream to read the response from.</param>
|
||||
/// <param name="expectedUnit">
|
||||
/// The unit id the request was addressed to. The response's address byte is validated against
|
||||
/// it after the CRC passes; a mismatch is a unit-mismatch desync.
|
||||
/// </param>
|
||||
/// <param name="ct">A token to cancel the read.</param>
|
||||
/// <returns>The bare response PDU (function code followed by its data).</returns>
|
||||
/// <exception cref="ModbusTransportDesyncException">
|
||||
/// The frame was truncated (stream closed mid-frame), its trailing CRC did not validate, or its
|
||||
/// address byte did not match <paramref name="expectedUnit"/>.
|
||||
/// </exception>
|
||||
/// <exception cref="ModbusException">
|
||||
/// The frame was a CRC-valid Modbus exception PDU (high bit set on the function code).
|
||||
/// </exception>
|
||||
public static async Task<byte[]> ReadResponsePduAsync(
|
||||
Stream stream, byte expectedUnit, CancellationToken ct)
|
||||
{
|
||||
// Header: addr(1) + fc(1). The function code selects how many more bytes to read.
|
||||
var header = new byte[2];
|
||||
await ReadExactlyAsync(stream, header, ct).ConfigureAwait(false);
|
||||
var fc = header[1];
|
||||
|
||||
int trailing; // bytes after addr+fc, up to and including the 2 CRC bytes.
|
||||
if ((fc & 0x80) != 0)
|
||||
{
|
||||
// Exception frame: excCode(1) + CRC(2).
|
||||
trailing = 1 + 2;
|
||||
}
|
||||
else if (fc is 0x01 or 0x02 or 0x03 or 0x04)
|
||||
{
|
||||
// Read response: byteCount(1) then byteCount data bytes + CRC(2).
|
||||
var bc = new byte[1];
|
||||
await ReadExactlyAsync(stream, bc, ct).ConfigureAwait(false);
|
||||
var byteCount = bc[0];
|
||||
var rest = new byte[byteCount + 2];
|
||||
await ReadExactlyAsync(stream, rest, ct).ConfigureAwait(false);
|
||||
return ValidateAndStrip(expectedUnit, header, bc, rest);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Fixed 4-byte echo: correct for the write FCs this driver emits (05/06/0F/10). A future
|
||||
// variable-length response FC outside 01-04 (e.g. FC23) would be mis-sized here and
|
||||
// surface as a desync — revisit the FC-shape table if the driver starts emitting one.
|
||||
trailing = 4 + 2;
|
||||
}
|
||||
|
||||
var tail = new byte[trailing];
|
||||
await ReadExactlyAsync(stream, tail, ct).ConfigureAwait(false);
|
||||
return ValidateAndStrip(expectedUnit, header, ReadOnlySpan<byte>.Empty, tail);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Assembles the full frame from its pieces, validates the trailing CRC over everything
|
||||
/// except the CRC bytes, then validates the response address against
|
||||
/// <paramref name="expectedUnit"/> and strips <c>addr</c> + <c>CRC</c> to return the bare PDU.
|
||||
/// A CRC-valid exception PDU throws <see cref="ModbusException"/>.
|
||||
/// </summary>
|
||||
private static byte[] ValidateAndStrip(
|
||||
byte expectedUnit, ReadOnlySpan<byte> header, ReadOnlySpan<byte> middle, ReadOnlySpan<byte> tail)
|
||||
{
|
||||
// Reassemble the whole frame: header(addr,fc) + middle(optional byteCount) + tail.
|
||||
var frame = new byte[header.Length + middle.Length + tail.Length];
|
||||
header.CopyTo(frame);
|
||||
middle.CopyTo(frame.AsSpan(header.Length));
|
||||
tail.CopyTo(frame.AsSpan(header.Length + middle.Length));
|
||||
|
||||
// CRC covers everything except the trailing 2 CRC bytes.
|
||||
var body = frame.AsSpan(0, frame.Length - 2);
|
||||
var expected = ModbusCrc.Compute(body);
|
||||
var actual = (ushort)(frame[^2] | (frame[^1] << 8));
|
||||
if (actual != expected)
|
||||
throw new ModbusTransportDesyncException(
|
||||
ModbusTransportDesyncException.DesyncReason.CrcMismatch,
|
||||
$"Modbus RTU CRC mismatch: computed {expected:X4} got {actual:X4}");
|
||||
|
||||
// Unit-address validation runs only AFTER the CRC passes: a corrupt frame is a CRC/desync
|
||||
// failure, and only a coherent-but-mis-addressed frame (a CRC-valid reply from the wrong
|
||||
// RS-485 slave) is a unit-mismatch desync. Never silently accept another unit's data.
|
||||
var addr = frame[0];
|
||||
if (addr != expectedUnit)
|
||||
throw new ModbusTransportDesyncException(
|
||||
ModbusTransportDesyncException.DesyncReason.UnitMismatch,
|
||||
$"Modbus RTU unit mismatch: expected {expectedUnit} got {addr}");
|
||||
|
||||
// Bare PDU = frame minus leading addr and trailing CRC.
|
||||
var pdu = body[1..].ToArray();
|
||||
|
||||
// Exception PDU: high bit set on the function code. The CRC already validated, so this is a
|
||||
// coherent protocol-level error — surface the ORIGINAL function code (fc & 0x7F).
|
||||
if ((pdu[0] & 0x80) != 0)
|
||||
{
|
||||
var fc = (byte)(pdu[0] & 0x7F);
|
||||
var exCode = pdu[1];
|
||||
throw new ModbusException(fc, exCode, $"Modbus exception fc={fc} code={exCode}");
|
||||
}
|
||||
|
||||
return pdu;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fills <paramref name="buf"/> completely from <paramref name="stream"/>, throwing a
|
||||
/// truncation desync if the stream ends first. Mirrors
|
||||
/// <c>ModbusTcpTransport.ReadExactlyAsync</c>, but normalises the end-of-stream to a
|
||||
/// <see cref="ModbusTransportDesyncException"/> so a length-less RTU frame that arrives
|
||||
/// short is classified as a desync rather than a bare <see cref="EndOfStreamException"/>.
|
||||
/// </summary>
|
||||
private static async Task ReadExactlyAsync(Stream stream, byte[] buf, CancellationToken ct)
|
||||
{
|
||||
var read = 0;
|
||||
while (read < buf.Length)
|
||||
{
|
||||
var n = await stream.ReadAsync(buf.AsMemory(read), ct).ConfigureAwait(false);
|
||||
if (n == 0)
|
||||
throw new ModbusTransportDesyncException(
|
||||
ModbusTransportDesyncException.DesyncReason.TruncatedFrame,
|
||||
$"Modbus RTU frame truncated: expected {buf.Length} bytes, got {read}");
|
||||
read += n;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
using System.Net.Sockets;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus;
|
||||
|
||||
/// <summary>
|
||||
/// Concrete Modbus RTU-over-TCP transport. Composes <see cref="ModbusSocketLifecycle"/> for the
|
||||
/// connect / reconnect / keep-alive / idle machinery and <see cref="ModbusRtuFraming"/> for the
|
||||
/// wire layout — the same lifecycle <see cref="ModbusTcpTransport"/> uses, but with CRC framing
|
||||
/// instead of MBAP.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Delta from <see cref="ModbusTcpTransport"/>: an RTU ADU is <c>[unitId][PDU][CRC]</c> with
|
||||
/// <b>no MBAP header and no transaction id</b>. Because there is no TxId to correlate
|
||||
/// interleaved responses, at most one transaction may be on the bus at a time — the
|
||||
/// <see cref="_gate"/> single-flight is therefore <b>mandatory</b>, not merely a
|
||||
/// diagnostics convenience.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Survives mid-transaction socket drops the same way the TCP transport does: a socket-level
|
||||
/// failure (<see cref="IOException"/> / <see cref="SocketException"/> /
|
||||
/// <see cref="EndOfStreamException"/>, and the <see cref="ModbusTransportDesyncException"/>
|
||||
/// that derives from <see cref="IOException"/>) triggers a single reconnect-then-retry; a
|
||||
/// second failure bubbles up so the driver's health surface reflects the real state.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Every transaction runs under a per-op deadline (a linked
|
||||
/// <see cref="CancellationTokenSource.CancelAfter(TimeSpan)"/>, design §7 / R2-01) so a
|
||||
/// frozen gateway can never wedge a poll: the deadline firing is normalised to a
|
||||
/// <see cref="ModbusTransportDesyncException"/> and tears the socket down so the next attempt
|
||||
/// reconnects. A <b>caller</b> cancellation is distinguished from that timeout and propagates
|
||||
/// as a plain <see cref="OperationCanceledException"/> with no teardown.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The constructor is connection-free; all socket I/O happens in <see cref="ConnectAsync"/> /
|
||||
/// <see cref="SendAsync"/>. The <see cref="ForTest"/> seam injects a pre-connected
|
||||
/// <see cref="Stream"/> (an in-memory duplex fake) so the send/receive orchestration,
|
||||
/// single-flight, and deadline can be exercised with no socket — in that path
|
||||
/// <see cref="_life"/> is <see langword="null"/> and the idle / reconnect machinery is
|
||||
/// skipped (a raw injected stream cannot reconnect).
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class ModbusRtuOverTcpTransport : IModbusTransport
|
||||
{
|
||||
private readonly ModbusSocketLifecycle? _life;
|
||||
private readonly Stream? _testStream;
|
||||
private readonly TimeSpan _timeout;
|
||||
private readonly SemaphoreSlim _gate = new(1, 1);
|
||||
private bool _disposed;
|
||||
|
||||
/// <summary>Initializes a new instance of the <see cref="ModbusRtuOverTcpTransport"/> class.</summary>
|
||||
/// <param name="host">The host address or hostname of the Modbus gateway.</param>
|
||||
/// <param name="port">The TCP port of the Modbus gateway.</param>
|
||||
/// <param name="timeout">The timeout for socket operations and the per-op response deadline.</param>
|
||||
/// <param name="autoReconnect">Whether to automatically reconnect on socket failures.</param>
|
||||
/// <param name="keepAlive">Optional keep-alive configuration for the socket.</param>
|
||||
/// <param name="idleDisconnect">Optional duration after which an idle socket is disconnected.</param>
|
||||
/// <param name="reconnect">Optional reconnect backoff configuration.</param>
|
||||
public ModbusRtuOverTcpTransport(
|
||||
string host, int port, TimeSpan timeout, bool autoReconnect = true,
|
||||
ModbusKeepAliveOptions? keepAlive = null,
|
||||
TimeSpan? idleDisconnect = null,
|
||||
ModbusReconnectOptions? reconnect = null)
|
||||
{
|
||||
_timeout = timeout;
|
||||
_life = new ModbusSocketLifecycle(host, port, timeout, autoReconnect, keepAlive, idleDisconnect, reconnect);
|
||||
}
|
||||
|
||||
private ModbusRtuOverTcpTransport(Stream testStream, TimeSpan timeout)
|
||||
{
|
||||
_timeout = timeout;
|
||||
_testStream = testStream;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Test seam: builds a transport over a pre-connected, in-memory duplex <paramref name="stream"/>,
|
||||
/// bypassing <see cref="ConnectAsync"/> and the idle / reconnect machinery so the send/receive
|
||||
/// orchestration, single-flight, and per-op deadline can be exercised with no real socket.
|
||||
/// </summary>
|
||||
/// <param name="stream">A pre-connected duplex stream standing in for the socket.</param>
|
||||
/// <param name="timeout">The per-op response deadline.</param>
|
||||
/// <returns>A transport driving <paramref name="stream"/> directly.</returns>
|
||||
internal static ModbusRtuOverTcpTransport ForTest(Stream stream, TimeSpan timeout) => new(stream, timeout);
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task ConnectAsync(CancellationToken ct) =>
|
||||
// The ForTest seam is handed a pre-connected stream — nothing to dial.
|
||||
_life is null ? Task.CompletedTask : _life.ConnectAsync(ct);
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<byte[]> SendAsync(byte unitId, byte[] pdu, CancellationToken ct)
|
||||
{
|
||||
if (_disposed) throw new ObjectDisposedException(nameof(ModbusRtuOverTcpTransport));
|
||||
if (CurrentStream is null) throw new InvalidOperationException("Transport not connected");
|
||||
|
||||
// Single-flight: RTU carries no transaction id, so at most one transaction may be on the bus
|
||||
// at a time — serialise every send/receive under the gate.
|
||||
await _gate.WaitAsync(ct).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
// Proactive idle-disconnect (real socket only): if the socket has been quiet longer than
|
||||
// the configured threshold, tear it down + reconnect before this PDU lands. Defends
|
||||
// against silent NAT / firewall reaping.
|
||||
if (_life is not null && _life.ShouldReconnectForIdle())
|
||||
{
|
||||
await _life.TearDownAsync().ConfigureAwait(false);
|
||||
await _life.ConnectWithBackoffAsync(ct).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var result = await SendOnceAsync(unitId, pdu, ct).ConfigureAwait(false);
|
||||
_life?.MarkSuccess();
|
||||
return result;
|
||||
}
|
||||
catch (Exception ex) when (_life is not null && _life.AutoReconnect && ModbusSocketLifecycle.IsSocketLevelFailure(ex))
|
||||
{
|
||||
// Mid-transaction drop: tear down the dead socket, reconnect (with backoff if
|
||||
// configured), resend. Single retry — a second failure propagates so health/status
|
||||
// reflect reality. Never reached in the ForTest seam (a raw injected stream has no
|
||||
// lifecycle to reconnect).
|
||||
await _life.TearDownAsync().ConfigureAwait(false);
|
||||
await _life.ConnectWithBackoffAsync(ct).ConfigureAwait(false);
|
||||
var result = await SendOnceAsync(unitId, pdu, ct).ConfigureAwait(false);
|
||||
_life.MarkSuccess();
|
||||
return result;
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
_gate.Release();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>The live stream: the lifecycle's <see cref="NetworkStream"/>, or the injected test stream.</summary>
|
||||
private Stream? CurrentStream => _life?.Stream ?? _testStream;
|
||||
|
||||
/// <summary>
|
||||
/// Executes exactly one RTU transaction on the current stream: build the ADU, write + flush,
|
||||
/// read back one deframed response PDU. See the class remarks for the desync / timeout /
|
||||
/// caller-cancel handling.
|
||||
/// </summary>
|
||||
private async Task<byte[]> SendOnceAsync(byte unitId, byte[] pdu, CancellationToken ct)
|
||||
{
|
||||
var stream = CurrentStream;
|
||||
if (stream is null) throw new InvalidOperationException("Transport not connected");
|
||||
|
||||
// RTU ADU: [unitId] + PDU + CRC — no MBAP header, no TxId.
|
||||
var adu = ModbusRtuFraming.BuildAdu(unitId, pdu);
|
||||
|
||||
using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||||
cts.CancelAfter(_timeout);
|
||||
try
|
||||
{
|
||||
await stream.WriteAsync(adu.AsMemory(), cts.Token).ConfigureAwait(false);
|
||||
await stream.FlushAsync(cts.Token).ConfigureAwait(false);
|
||||
|
||||
// Framing owns the length-less RTU deframe + CRC + unit-address validation. A CRC-valid
|
||||
// exception PDU throws ModbusException (socket still coherent — propagates, no teardown);
|
||||
// truncation / CRC / unit-mismatch throw ModbusTransportDesyncException.
|
||||
return await ModbusRtuFraming.ReadResponsePduAsync(stream, unitId, cts.Token).ConfigureAwait(false);
|
||||
}
|
||||
catch (ModbusTransportDesyncException)
|
||||
{
|
||||
// Framing violation: the stream is desynchronized — never reuse it.
|
||||
if (_life is not null) await _life.TearDownAsync().ConfigureAwait(false);
|
||||
throw;
|
||||
}
|
||||
catch (OperationCanceledException) when (!ct.IsCancellationRequested)
|
||||
{
|
||||
// Per-op timeout fired (NOT the caller). The response is unknown / may still land later
|
||||
// and corrupt the next read — tear the socket down and normalise as a desync so the
|
||||
// reconnect retry / status mapping engages.
|
||||
if (_life is not null) await _life.TearDownAsync().ConfigureAwait(false);
|
||||
throw new ModbusTransportDesyncException(
|
||||
ModbusTransportDesyncException.DesyncReason.Timeout,
|
||||
$"Modbus per-op response timeout after {_timeout.TotalMilliseconds:0}ms");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Asynchronously disposes the transport and underlying socket resources.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
if (_disposed) return;
|
||||
_disposed = true;
|
||||
if (_life is not null) await _life.DisposeAsync().ConfigureAwait(false);
|
||||
_gate.Dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
using System.Net.Sockets;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus;
|
||||
|
||||
/// <summary>
|
||||
/// Owns the raw TCP socket lifecycle shared by every Modbus-over-TCP transport (MBAP and
|
||||
/// RTU-over-TCP alike): the IPv4-preference connect, OS-level keep-alive, geometric-backoff
|
||||
/// reconnect, idle-disconnect tracking, and teardown. It exposes the current
|
||||
/// <see cref="NetworkStream"/> so the composing transport can drive its own framing on top —
|
||||
/// the lifecycle knows nothing about MBAP / RTU wire layout.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Extracted verbatim from <see cref="ModbusTcpTransport"/> so the connect / reconnect /
|
||||
/// keep-alive / idle behaviour is written once and composed by both transports. The
|
||||
/// constructor is connection-free — all socket I/O happens in <see cref="ConnectAsync"/> /
|
||||
/// <see cref="ConnectWithBackoffAsync"/>.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Why keep-alive matters for DL205/DL260: the AutomationDirect H2-ECOM100 does NOT send
|
||||
/// TCP keepalives per <c>docs/v2/dl205.md</c> §behavioral-oddities, so any NAT/firewall
|
||||
/// between the gateway and PLC can silently close an idle socket after 2-5 minutes.
|
||||
/// Enabling OS-level <c>SO_KEEPALIVE</c> lets the driver's own side detect a stuck socket
|
||||
/// in reasonable time even when the application is mostly idle.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class ModbusSocketLifecycle : IAsyncDisposable
|
||||
{
|
||||
private readonly string _host;
|
||||
private readonly int _port;
|
||||
private readonly TimeSpan _timeout;
|
||||
private readonly bool _autoReconnect;
|
||||
private readonly ModbusKeepAliveOptions _keepAlive;
|
||||
private readonly TimeSpan? _idleDisconnect;
|
||||
private readonly ModbusReconnectOptions _reconnect;
|
||||
private TcpClient? _client;
|
||||
private NetworkStream? _stream;
|
||||
private DateTime _lastSuccessUtc = DateTime.UtcNow;
|
||||
|
||||
/// <summary>Initializes a new instance of the <see cref="ModbusSocketLifecycle"/> class.</summary>
|
||||
/// <param name="host">The host address or hostname of the Modbus server.</param>
|
||||
/// <param name="port">The TCP port of the Modbus server.</param>
|
||||
/// <param name="timeout">The timeout for socket operations.</param>
|
||||
/// <param name="autoReconnect">Whether to automatically reconnect on socket failures.</param>
|
||||
/// <param name="keepAlive">Optional keep-alive configuration for the socket.</param>
|
||||
/// <param name="idleDisconnect">Optional duration after which an idle socket is disconnected.</param>
|
||||
/// <param name="reconnect">Optional reconnect backoff configuration.</param>
|
||||
public ModbusSocketLifecycle(
|
||||
string host, int port, TimeSpan timeout, bool autoReconnect = true,
|
||||
ModbusKeepAliveOptions? keepAlive = null,
|
||||
TimeSpan? idleDisconnect = null,
|
||||
ModbusReconnectOptions? reconnect = null)
|
||||
{
|
||||
_host = host;
|
||||
_port = port;
|
||||
_timeout = timeout;
|
||||
_autoReconnect = autoReconnect;
|
||||
_keepAlive = keepAlive ?? new ModbusKeepAliveOptions();
|
||||
_idleDisconnect = idleDisconnect;
|
||||
_reconnect = reconnect ?? new ModbusReconnectOptions();
|
||||
}
|
||||
|
||||
/// <summary>Gets the current live <see cref="NetworkStream"/>, or <see langword="null"/> when not connected.</summary>
|
||||
public NetworkStream? Stream => _stream;
|
||||
|
||||
/// <summary>Gets a value indicating whether auto-reconnect on socket failures is enabled.</summary>
|
||||
public bool AutoReconnect => _autoReconnect;
|
||||
|
||||
/// <summary>Establishes a connection to the Modbus server, preferring IPv4.</summary>
|
||||
/// <param name="ct">A cancellation token to observe for cancellation.</param>
|
||||
/// <returns>A task representing the asynchronous connection operation.</returns>
|
||||
public async Task ConnectAsync(CancellationToken ct)
|
||||
{
|
||||
// Resolve the host explicitly + prefer IPv4. .NET's TcpClient default-constructor is
|
||||
// dual-stack (IPv6 first, fallback to IPv4) — but most Modbus TCP devices (PLCs and
|
||||
// simulators like pymodbus) bind 0.0.0.0 only, so the IPv6 attempt times out and we
|
||||
// burn the entire ConnectAsync budget before even trying IPv4. Resolving first +
|
||||
// dialing the IPv4 address directly sidesteps that.
|
||||
var addresses = await System.Net.Dns.GetHostAddressesAsync(_host, ct).ConfigureAwait(false);
|
||||
var ipv4 = System.Linq.Enumerable.FirstOrDefault(addresses,
|
||||
a => a.AddressFamily == System.Net.Sockets.AddressFamily.InterNetwork);
|
||||
var target = ipv4 ?? (addresses.Length > 0 ? addresses[0] : System.Net.IPAddress.Loopback);
|
||||
|
||||
_client = new TcpClient(target.AddressFamily);
|
||||
EnableKeepAlive(_client, _keepAlive);
|
||||
|
||||
using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||||
cts.CancelAfter(_timeout);
|
||||
await _client.ConnectAsync(target, _port, cts.Token).ConfigureAwait(false);
|
||||
_stream = _client.GetStream();
|
||||
_lastSuccessUtc = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Connect attempt with the configured geometric backoff. The first attempt fires after
|
||||
/// <see cref="ModbusReconnectOptions.InitialDelay"/> (default zero — immediate); each
|
||||
/// subsequent attempt sleeps for the previous delay times <c>BackoffMultiplier</c>,
|
||||
/// capped at <c>MaxDelay</c>. Caller's cancellation token aborts the loop.
|
||||
/// </summary>
|
||||
/// <param name="ct">A cancellation token to observe for cancellation.</param>
|
||||
/// <returns>A task representing the asynchronous reconnect operation.</returns>
|
||||
public async Task ConnectWithBackoffAsync(CancellationToken ct)
|
||||
{
|
||||
var delay = _reconnect.InitialDelay;
|
||||
var attempt = 0;
|
||||
while (true)
|
||||
{
|
||||
if (delay > TimeSpan.Zero)
|
||||
await Task.Delay(delay, ct).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
await ConnectAsync(ct).ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
catch (Exception ex) when (IsSocketLevelFailure(ex) && _autoReconnect)
|
||||
{
|
||||
attempt++;
|
||||
// Geometric growth, capped. Use Math.Min on ticks so we don't overflow with
|
||||
// pathological multipliers / long deployments.
|
||||
var nextTicks = (long)(Math.Max(delay.Ticks, TimeSpan.FromMilliseconds(100).Ticks) * _reconnect.BackoffMultiplier);
|
||||
delay = TimeSpan.FromTicks(Math.Min(nextTicks, _reconnect.MaxDelay.Ticks));
|
||||
if (attempt >= 10)
|
||||
{
|
||||
// Bail after 10 attempts to surface persistent failure to the caller. With
|
||||
// the default backoff (1s base, 2.0x mult, 30s cap) this is roughly 4 minutes
|
||||
// of attempts; with InitialDelay=0 it's immediate up to the same cap.
|
||||
throw;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reports whether the socket has been idle longer than the configured idle-disconnect
|
||||
/// threshold and should be proactively torn down + reconnected before the next transaction.
|
||||
/// Always <see langword="false"/> when no idle-disconnect timeout is configured.
|
||||
/// </summary>
|
||||
/// <returns><see langword="true"/> when the idle threshold has been exceeded.</returns>
|
||||
public bool ShouldReconnectForIdle() =>
|
||||
_idleDisconnect.HasValue && DateTime.UtcNow - _lastSuccessUtc > _idleDisconnect.Value;
|
||||
|
||||
/// <summary>Records that a transaction just succeeded, resetting the idle-disconnect clock.</summary>
|
||||
public void MarkSuccess() => _lastSuccessUtc = DateTime.UtcNow;
|
||||
|
||||
/// <summary>Tears down the current socket + stream, leaving the lifecycle ready to reconnect.</summary>
|
||||
/// <returns>A task representing the asynchronous teardown operation.</returns>
|
||||
public async Task TearDownAsync()
|
||||
{
|
||||
try { if (_stream is not null) await _stream.DisposeAsync().ConfigureAwait(false); }
|
||||
catch { /* best-effort */ }
|
||||
_stream = null;
|
||||
try { _client?.Dispose(); } catch { }
|
||||
_client = null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Enable SO_KEEPALIVE with aggressive probe timing. DL205/DL260 doesn't send keepalives
|
||||
/// itself; having the OS probe the socket every ~30s lets the driver notice a dead PLC
|
||||
/// or broken NAT path long before the default 2-hour Windows idle timeout fires.
|
||||
/// Non-fatal if the underlying OS rejects the option (some older Linux / container
|
||||
/// sandboxes don't expose the fine-grained timing levers — the driver still works,
|
||||
/// application-level probe still detects problems).
|
||||
/// </summary>
|
||||
private static void EnableKeepAlive(TcpClient client, ModbusKeepAliveOptions opts)
|
||||
{
|
||||
if (!opts.Enabled) return;
|
||||
try
|
||||
{
|
||||
client.Client.SetSocketOption(SocketOptionLevel.Socket, SocketOptionName.KeepAlive, true);
|
||||
// A TimeSpan < 1s previously truncated to 0 via the int cast,
|
||||
// which Windows / Linux interpret as "use the default" — silently defeating the
|
||||
// configured keep-alive timing. Round up to at least 1 second so a sub-second
|
||||
// configuration still produces a real keep-alive cadence. Negative values are
|
||||
// also clamped to 1 to avoid surfacing as OS errors.
|
||||
client.Client.SetSocketOption(SocketOptionLevel.Tcp, SocketOptionName.TcpKeepAliveTime,
|
||||
ClampToWholeSeconds(opts.Time));
|
||||
client.Client.SetSocketOption(SocketOptionLevel.Tcp, SocketOptionName.TcpKeepAliveInterval,
|
||||
ClampToWholeSeconds(opts.Interval));
|
||||
client.Client.SetSocketOption(SocketOptionLevel.Tcp, SocketOptionName.TcpKeepAliveRetryCount, opts.RetryCount);
|
||||
}
|
||||
catch { /* best-effort; older OSes may not expose the granular knobs */ }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Cast a <see cref="TimeSpan"/> to a whole number of seconds with a
|
||||
/// minimum of 1 — protects callers from the int-cast truncation that turned 500 ms
|
||||
/// keep-alive timing into "use the default" on most OSes.
|
||||
/// </summary>
|
||||
/// <param name="ts">The timespan to clamp to whole seconds.</param>
|
||||
/// <returns>The clamped duration expressed as a whole number of seconds, never less than 1.</returns>
|
||||
internal static int ClampToWholeSeconds(TimeSpan ts)
|
||||
{
|
||||
var seconds = (int)Math.Ceiling(ts.TotalSeconds);
|
||||
return seconds < 1 ? 1 : seconds;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Distinguish socket-layer failures (eligible for reconnect-and-retry) from
|
||||
/// protocol-layer failures (must propagate — retrying the same PDU won't help if the
|
||||
/// PLC just returned exception 02 Illegal Data Address).
|
||||
/// </summary>
|
||||
/// <param name="ex">The exception to classify.</param>
|
||||
/// <returns><see langword="true"/> when the exception is a socket-level failure.</returns>
|
||||
internal static bool IsSocketLevelFailure(Exception ex) =>
|
||||
ex is EndOfStreamException
|
||||
|| ex is IOException
|
||||
|| ex is SocketException
|
||||
|| ex is ObjectDisposedException;
|
||||
|
||||
/// <summary>Asynchronously disposes the underlying socket + stream resources.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (_stream is not null) await _stream.DisposeAsync().ConfigureAwait(false);
|
||||
}
|
||||
catch { /* best-effort */ }
|
||||
_client?.Dispose();
|
||||
}
|
||||
}
|
||||
@@ -3,13 +3,19 @@ using System.Net.Sockets;
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus;
|
||||
|
||||
/// <summary>
|
||||
/// Concrete Modbus TCP transport. Wraps a single <see cref="TcpClient"/> and serializes
|
||||
/// requests so at most one transaction is in-flight at a time — Modbus servers typically
|
||||
/// support concurrent transactions, but the single-flight model keeps the wire trace
|
||||
/// Concrete Modbus TCP transport. Wraps a single socket (owned by <see cref="ModbusSocketLifecycle"/>)
|
||||
/// and serializes requests so at most one transaction is in-flight at a time — Modbus servers
|
||||
/// typically support concurrent transactions, but the single-flight model keeps the wire trace
|
||||
/// easy to diagnose and avoids interleaved-response correlation bugs.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Owns the MBAP framing (<see cref="SendOnceAsync"/>: 7-byte header + transaction-id
|
||||
/// pairing) and composes <see cref="ModbusSocketLifecycle"/> for the connect / reconnect /
|
||||
/// keep-alive / idle-disconnect machinery — the same lifecycle the RTU-over-TCP transport
|
||||
/// reuses with different framing.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Survives mid-transaction socket drops: when a send/read fails with a socket-level
|
||||
/// error (<see cref="IOException"/>, <see cref="SocketException"/>, <see cref="EndOfStreamException"/>)
|
||||
/// the transport disposes the dead socket, reconnects, and retries the PDU exactly
|
||||
@@ -26,19 +32,11 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus;
|
||||
/// </remarks>
|
||||
public sealed class ModbusTcpTransport : IModbusTransport
|
||||
{
|
||||
private readonly string _host;
|
||||
private readonly int _port;
|
||||
private readonly ModbusSocketLifecycle _life;
|
||||
private readonly TimeSpan _timeout;
|
||||
private readonly bool _autoReconnect;
|
||||
private readonly ModbusKeepAliveOptions _keepAlive;
|
||||
private readonly TimeSpan? _idleDisconnect;
|
||||
private readonly ModbusReconnectOptions _reconnect;
|
||||
private readonly SemaphoreSlim _gate = new(1, 1);
|
||||
private TcpClient? _client;
|
||||
private NetworkStream? _stream;
|
||||
private ushort _nextTx;
|
||||
private bool _disposed;
|
||||
private DateTime _lastSuccessUtc = DateTime.UtcNow;
|
||||
|
||||
/// <summary>Initializes a new instance of the <see cref="ModbusTcpTransport"/> class.</summary>
|
||||
/// <param name="host">The host address or hostname of the Modbus server.</param>
|
||||
@@ -54,84 +52,18 @@ public sealed class ModbusTcpTransport : IModbusTransport
|
||||
TimeSpan? idleDisconnect = null,
|
||||
ModbusReconnectOptions? reconnect = null)
|
||||
{
|
||||
_host = host;
|
||||
_port = port;
|
||||
_timeout = timeout;
|
||||
_autoReconnect = autoReconnect;
|
||||
_keepAlive = keepAlive ?? new ModbusKeepAliveOptions();
|
||||
_idleDisconnect = idleDisconnect;
|
||||
_reconnect = reconnect ?? new ModbusReconnectOptions();
|
||||
_life = new ModbusSocketLifecycle(host, port, timeout, autoReconnect, keepAlive, idleDisconnect, reconnect);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task ConnectAsync(CancellationToken ct)
|
||||
{
|
||||
// Resolve the host explicitly + prefer IPv4. .NET's TcpClient default-constructor is
|
||||
// dual-stack (IPv6 first, fallback to IPv4) — but most Modbus TCP devices (PLCs and
|
||||
// simulators like pymodbus) bind 0.0.0.0 only, so the IPv6 attempt times out and we
|
||||
// burn the entire ConnectAsync budget before even trying IPv4. Resolving first +
|
||||
// dialing the IPv4 address directly sidesteps that.
|
||||
var addresses = await System.Net.Dns.GetHostAddressesAsync(_host, ct).ConfigureAwait(false);
|
||||
var ipv4 = System.Linq.Enumerable.FirstOrDefault(addresses,
|
||||
a => a.AddressFamily == System.Net.Sockets.AddressFamily.InterNetwork);
|
||||
var target = ipv4 ?? (addresses.Length > 0 ? addresses[0] : System.Net.IPAddress.Loopback);
|
||||
|
||||
_client = new TcpClient(target.AddressFamily);
|
||||
EnableKeepAlive(_client, _keepAlive);
|
||||
|
||||
using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||||
cts.CancelAfter(_timeout);
|
||||
await _client.ConnectAsync(target, _port, cts.Token).ConfigureAwait(false);
|
||||
_stream = _client.GetStream();
|
||||
_lastSuccessUtc = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Enable SO_KEEPALIVE with aggressive probe timing. DL205/DL260 doesn't send keepalives
|
||||
/// itself; having the OS probe the socket every ~30s lets the driver notice a dead PLC
|
||||
/// or broken NAT path long before the default 2-hour Windows idle timeout fires.
|
||||
/// Non-fatal if the underlying OS rejects the option (some older Linux / container
|
||||
/// sandboxes don't expose the fine-grained timing levers — the driver still works,
|
||||
/// application-level probe still detects problems).
|
||||
/// </summary>
|
||||
private static void EnableKeepAlive(TcpClient client, ModbusKeepAliveOptions opts)
|
||||
{
|
||||
if (!opts.Enabled) return;
|
||||
try
|
||||
{
|
||||
client.Client.SetSocketOption(SocketOptionLevel.Socket, SocketOptionName.KeepAlive, true);
|
||||
// A TimeSpan < 1s previously truncated to 0 via the int cast,
|
||||
// which Windows / Linux interpret as "use the default" — silently defeating the
|
||||
// configured keep-alive timing. Round up to at least 1 second so a sub-second
|
||||
// configuration still produces a real keep-alive cadence. Negative values are
|
||||
// also clamped to 1 to avoid surfacing as OS errors.
|
||||
client.Client.SetSocketOption(SocketOptionLevel.Tcp, SocketOptionName.TcpKeepAliveTime,
|
||||
ClampToWholeSeconds(opts.Time));
|
||||
client.Client.SetSocketOption(SocketOptionLevel.Tcp, SocketOptionName.TcpKeepAliveInterval,
|
||||
ClampToWholeSeconds(opts.Interval));
|
||||
client.Client.SetSocketOption(SocketOptionLevel.Tcp, SocketOptionName.TcpKeepAliveRetryCount, opts.RetryCount);
|
||||
}
|
||||
catch { /* best-effort; older OSes may not expose the granular knobs */ }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Cast a <see cref="TimeSpan"/> to a whole number of seconds with a
|
||||
/// minimum of 1 — protects callers from the int-cast truncation that turned 500 ms
|
||||
/// keep-alive timing into "use the default" on most OSes.
|
||||
/// </summary>
|
||||
/// <param name="ts">The timespan to clamp to whole seconds.</param>
|
||||
/// <returns>The clamped duration expressed as a whole number of seconds, never less than 1.</returns>
|
||||
internal static int ClampToWholeSeconds(TimeSpan ts)
|
||||
{
|
||||
var seconds = (int)Math.Ceiling(ts.TotalSeconds);
|
||||
return seconds < 1 ? 1 : seconds;
|
||||
}
|
||||
public Task ConnectAsync(CancellationToken ct) => _life.ConnectAsync(ct);
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<byte[]> SendAsync(byte unitId, byte[] pdu, CancellationToken ct)
|
||||
{
|
||||
if (_disposed) throw new ObjectDisposedException(nameof(ModbusTcpTransport));
|
||||
if (_stream is null) throw new InvalidOperationException("Transport not connected");
|
||||
if (_life.Stream is null) throw new InvalidOperationException("Transport not connected");
|
||||
|
||||
await _gate.WaitAsync(ct).ConfigureAwait(false);
|
||||
try
|
||||
@@ -140,27 +72,27 @@ public sealed class ModbusTcpTransport : IModbusTransport
|
||||
// threshold, tear it down + reconnect before this PDU lands. Defends against silent
|
||||
// NAT / firewall reaping where the socket looks alive locally but the upstream side
|
||||
// dropped it minutes ago.
|
||||
if (_idleDisconnect.HasValue && DateTime.UtcNow - _lastSuccessUtc > _idleDisconnect.Value)
|
||||
if (_life.ShouldReconnectForIdle())
|
||||
{
|
||||
await TearDownAsync().ConfigureAwait(false);
|
||||
await ConnectWithBackoffAsync(ct).ConfigureAwait(false);
|
||||
await _life.TearDownAsync().ConfigureAwait(false);
|
||||
await _life.ConnectWithBackoffAsync(ct).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var result = await SendOnceAsync(unitId, pdu, ct).ConfigureAwait(false);
|
||||
_lastSuccessUtc = DateTime.UtcNow;
|
||||
_life.MarkSuccess();
|
||||
return result;
|
||||
}
|
||||
catch (Exception ex) when (_autoReconnect && IsSocketLevelFailure(ex))
|
||||
catch (Exception ex) when (_life.AutoReconnect && ModbusSocketLifecycle.IsSocketLevelFailure(ex))
|
||||
{
|
||||
// Mid-transaction drop: tear down the dead socket, reconnect (with backoff if
|
||||
// configured), resend. Single retry — if it fails again, let it propagate so
|
||||
// health/status reflect reality.
|
||||
await TearDownAsync().ConfigureAwait(false);
|
||||
await ConnectWithBackoffAsync(ct).ConfigureAwait(false);
|
||||
await _life.TearDownAsync().ConfigureAwait(false);
|
||||
await _life.ConnectWithBackoffAsync(ct).ConfigureAwait(false);
|
||||
var result = await SendOnceAsync(unitId, pdu, ct).ConfigureAwait(false);
|
||||
_lastSuccessUtc = DateTime.UtcNow;
|
||||
_life.MarkSuccess();
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -170,43 +102,6 @@ public sealed class ModbusTcpTransport : IModbusTransport
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Connect attempt with the configured geometric backoff. The first attempt fires after
|
||||
/// <see cref="ModbusReconnectOptions.InitialDelay"/> (default zero — immediate); each
|
||||
/// subsequent attempt sleeps for the previous delay times <c>BackoffMultiplier</c>,
|
||||
/// capped at <c>MaxDelay</c>. Caller's cancellation token aborts the loop.
|
||||
/// </summary>
|
||||
private async Task ConnectWithBackoffAsync(CancellationToken ct)
|
||||
{
|
||||
var delay = _reconnect.InitialDelay;
|
||||
var attempt = 0;
|
||||
while (true)
|
||||
{
|
||||
if (delay > TimeSpan.Zero)
|
||||
await Task.Delay(delay, ct).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
await ConnectAsync(ct).ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
catch (Exception ex) when (IsSocketLevelFailure(ex) && _autoReconnect)
|
||||
{
|
||||
attempt++;
|
||||
// Geometric growth, capped. Use Math.Min on ticks so we don't overflow with
|
||||
// pathological multipliers / long deployments.
|
||||
var nextTicks = (long)(Math.Max(delay.Ticks, TimeSpan.FromMilliseconds(100).Ticks) * _reconnect.BackoffMultiplier);
|
||||
delay = TimeSpan.FromTicks(Math.Min(nextTicks, _reconnect.MaxDelay.Ticks));
|
||||
if (attempt >= 10)
|
||||
{
|
||||
// Bail after 10 attempts to surface persistent failure to the caller. With
|
||||
// the default backoff (1s base, 2.0x mult, 30s cap) this is roughly 4 minutes
|
||||
// of attempts; with InitialDelay=0 it's immediate up to the same cap.
|
||||
throw;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Executes exactly one Modbus transaction on the current socket.
|
||||
/// </summary>
|
||||
@@ -230,7 +125,8 @@ public sealed class ModbusTcpTransport : IModbusTransport
|
||||
/// </remarks>
|
||||
private async Task<byte[]> SendOnceAsync(byte unitId, byte[] pdu, CancellationToken ct)
|
||||
{
|
||||
if (_stream is null) throw new InvalidOperationException("Transport not connected");
|
||||
var stream = _life.Stream;
|
||||
if (stream is null) throw new InvalidOperationException("Transport not connected");
|
||||
var txId = ++_nextTx;
|
||||
|
||||
// MBAP: [TxId(2)][Proto=0(2)][Length(2)][UnitId(1)] + PDU
|
||||
@@ -248,11 +144,11 @@ public sealed class ModbusTcpTransport : IModbusTransport
|
||||
cts.CancelAfter(_timeout);
|
||||
try
|
||||
{
|
||||
await _stream.WriteAsync(adu.AsMemory(), cts.Token).ConfigureAwait(false);
|
||||
await _stream.FlushAsync(cts.Token).ConfigureAwait(false);
|
||||
await stream.WriteAsync(adu.AsMemory(), cts.Token).ConfigureAwait(false);
|
||||
await stream.FlushAsync(cts.Token).ConfigureAwait(false);
|
||||
|
||||
var header = new byte[7];
|
||||
await ReadExactlyAsync(_stream, header, cts.Token).ConfigureAwait(false);
|
||||
await ReadExactlyAsync(stream, header, cts.Token).ConfigureAwait(false);
|
||||
var respTxId = (ushort)((header[0] << 8) | header[1]);
|
||||
if (respTxId != txId)
|
||||
throw new ModbusTransportDesyncException(
|
||||
@@ -264,7 +160,7 @@ public sealed class ModbusTcpTransport : IModbusTransport
|
||||
ModbusTransportDesyncException.DesyncReason.TruncatedFrame,
|
||||
$"Modbus response length too small: {respLen}");
|
||||
var respPdu = new byte[respLen - 1];
|
||||
await ReadExactlyAsync(_stream, respPdu, cts.Token).ConfigureAwait(false);
|
||||
await ReadExactlyAsync(stream, respPdu, cts.Token).ConfigureAwait(false);
|
||||
|
||||
// Exception PDU: function code has high bit set. This is a well-formed protocol-level
|
||||
// error — the socket is still coherent, so it MUST propagate without teardown.
|
||||
@@ -280,7 +176,7 @@ public sealed class ModbusTcpTransport : IModbusTransport
|
||||
catch (ModbusTransportDesyncException)
|
||||
{
|
||||
// Framing violation: the socket is desynchronized — never reuse it.
|
||||
await TearDownAsync().ConfigureAwait(false);
|
||||
await _life.TearDownAsync().ConfigureAwait(false);
|
||||
throw;
|
||||
}
|
||||
catch (OperationCanceledException) when (!ct.IsCancellationRequested)
|
||||
@@ -288,33 +184,13 @@ public sealed class ModbusTcpTransport : IModbusTransport
|
||||
// Per-op timeout fired (NOT the caller). The response is unknown / may still land later
|
||||
// and corrupt the next read — tear the socket down and normalize as a desync so the
|
||||
// reconnect retry / status mapping engages.
|
||||
await TearDownAsync().ConfigureAwait(false);
|
||||
await _life.TearDownAsync().ConfigureAwait(false);
|
||||
throw new ModbusTransportDesyncException(
|
||||
ModbusTransportDesyncException.DesyncReason.Timeout,
|
||||
$"Modbus per-op response timeout after {_timeout.TotalMilliseconds:0}ms");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Distinguish socket-layer failures (eligible for reconnect-and-retry) from
|
||||
/// protocol-layer failures (must propagate — retrying the same PDU won't help if the
|
||||
/// PLC just returned exception 02 Illegal Data Address).
|
||||
/// </summary>
|
||||
private static bool IsSocketLevelFailure(Exception ex) =>
|
||||
ex is EndOfStreamException
|
||||
|| ex is IOException
|
||||
|| ex is SocketException
|
||||
|| ex is ObjectDisposedException;
|
||||
|
||||
private async Task TearDownAsync()
|
||||
{
|
||||
try { if (_stream is not null) await _stream.DisposeAsync().ConfigureAwait(false); }
|
||||
catch { /* best-effort */ }
|
||||
_stream = null;
|
||||
try { _client?.Dispose(); } catch { }
|
||||
_client = null;
|
||||
}
|
||||
|
||||
private static async Task ReadExactlyAsync(Stream s, byte[] buf, CancellationToken ct)
|
||||
{
|
||||
var read = 0;
|
||||
@@ -332,12 +208,7 @@ public sealed class ModbusTcpTransport : IModbusTransport
|
||||
{
|
||||
if (_disposed) return;
|
||||
_disposed = true;
|
||||
try
|
||||
{
|
||||
if (_stream is not null) await _stream.DisposeAsync().ConfigureAwait(false);
|
||||
}
|
||||
catch { /* best-effort */ }
|
||||
_client?.Dispose();
|
||||
await _life.DisposeAsync().ConfigureAwait(false);
|
||||
_gate.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus;
|
||||
|
||||
/// <summary>
|
||||
/// Raised when a Modbus TCP transaction leaves the single-flight socket in an unknown /
|
||||
/// Raised when a Modbus transaction leaves the single-flight socket in an unknown /
|
||||
/// desynchronized state — a per-op response timeout, or a framing violation (transaction-id
|
||||
/// mismatch or truncated MBAP length). Unlike a Modbus <em>exception PDU</em> (a well-formed
|
||||
/// mismatch, truncated frame, RTU CRC mismatch, or RTU unit-address mismatch). Unlike a Modbus
|
||||
/// <em>exception PDU</em> (a well-formed
|
||||
/// protocol-level error where the socket is still coherent and the request must simply
|
||||
/// propagate), a desync means bytes may still be in flight or half-read, so the socket is
|
||||
/// unusable and MUST be torn down before this is thrown.
|
||||
@@ -27,8 +28,20 @@ public sealed class ModbusTransportDesyncException : IOException
|
||||
/// <summary>The response MBAP transaction id did not match the request's.</summary>
|
||||
TxIdMismatch,
|
||||
|
||||
/// <summary>The response MBAP length field was below the mandatory minimum (truncated frame).</summary>
|
||||
/// <summary>
|
||||
/// The frame ended before it was complete — the MBAP length field was below the mandatory
|
||||
/// minimum (TCP), or the stream closed mid-frame while reading a length-less RTU frame.
|
||||
/// </summary>
|
||||
TruncatedFrame,
|
||||
|
||||
/// <summary>The RTU frame's trailing CRC-16 did not validate over the received bytes.</summary>
|
||||
CrcMismatch,
|
||||
|
||||
/// <summary>
|
||||
/// A CRC-valid RTU frame carried a slave/unit address other than the one addressed — on an
|
||||
/// RS-485 multi-drop bus, a reply from the wrong slave.
|
||||
/// </summary>
|
||||
UnitMismatch,
|
||||
}
|
||||
|
||||
/// <summary>Gets the reason the socket was classified desynchronized.</summary>
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus;
|
||||
|
||||
/// <summary>
|
||||
/// Single mapping point from <see cref="ModbusDriverOptions"/> to the concrete
|
||||
/// <see cref="IModbusTransport"/> its <see cref="ModbusDriverOptions.Transport"/> selects.
|
||||
/// Consumed by both <see cref="ModbusDriver"/>'s default transport-factory closure and the
|
||||
/// AdminUI Test-Connect probe, so the <see cref="ModbusTransportMode"/> switch lives in
|
||||
/// exactly one place.
|
||||
/// </summary>
|
||||
public static class ModbusTransportFactory
|
||||
{
|
||||
/// <summary>
|
||||
/// Builds the transport <paramref name="options"/>.<see cref="ModbusDriverOptions.Transport"/>
|
||||
/// selects, threading every shared connection setting (Host/Port/Timeout/AutoReconnect/
|
||||
/// KeepAlive/IdleDisconnectTimeout/Reconnect) through to whichever concrete transport is built.
|
||||
/// </summary>
|
||||
/// <param name="options">Driver configuration options.</param>
|
||||
/// <returns>A <see cref="ModbusRtuOverTcpTransport"/> when <see cref="ModbusTransportMode.RtuOverTcp"/>
|
||||
/// is selected; a <see cref="ModbusTcpTransport"/> when <see cref="ModbusTransportMode.Tcp"/> is selected.</returns>
|
||||
/// <exception cref="ArgumentOutOfRangeException">
|
||||
/// <paramref name="options"/>.<see cref="ModbusDriverOptions.Transport"/> is not a recognized
|
||||
/// <see cref="ModbusTransportMode"/> member — fail loudly rather than silently falling back to TCP/MBAP.
|
||||
/// </exception>
|
||||
public static IModbusTransport Create(ModbusDriverOptions options)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
return options.Transport switch
|
||||
{
|
||||
ModbusTransportMode.RtuOverTcp => new ModbusRtuOverTcpTransport(
|
||||
options.Host, options.Port, options.Timeout, options.AutoReconnect,
|
||||
keepAlive: options.KeepAlive,
|
||||
idleDisconnect: options.IdleDisconnectTimeout,
|
||||
reconnect: options.Reconnect),
|
||||
ModbusTransportMode.Tcp => new ModbusTcpTransport(
|
||||
options.Host, options.Port, options.Timeout, options.AutoReconnect,
|
||||
keepAlive: options.KeepAlive,
|
||||
idleDisconnect: options.IdleDisconnectTimeout,
|
||||
reconnect: options.Reconnect),
|
||||
_ => throw new ArgumentOutOfRangeException(
|
||||
nameof(options), options.Transport, "Unknown Modbus transport mode."),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -14,11 +14,13 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Sql;
|
||||
/// parameterized in SQL, so the few that must appear as text are emitted through
|
||||
/// <see cref="QuoteIdentifier"/>. Any code path that builds SQL by concatenating an authored tag field
|
||||
/// <em>without</em> going through <see cref="QuoteIdentifier"/> is a defect (design §8.1).</para>
|
||||
/// <para><b>Not yet implemented:</b> design §8.1 also specifies that an authored table/column is
|
||||
/// validated against the live catalog before it is ever quoted into text, so that an unknown identifier
|
||||
/// rejects the tag. No such gate exists yet — an identifier reaches <see cref="QuoteIdentifier"/>
|
||||
/// straight from the authored <c>TagConfig</c> blob, so <b>quoting is currently the only defence</b>,
|
||||
/// not a backstop behind an upstream filter. Scrutinise it accordingly.</para>
|
||||
/// <para><b>Quoting is the backstop, not the only defence.</b> Design §8.1's catalog gate is
|
||||
/// implemented: <see cref="SqlCatalogGate"/> resolves every authored table/column against the live
|
||||
/// catalog at Initialize and <b>replaces it with the catalog's own spelling</b>, so an identifier
|
||||
/// reaching <see cref="QuoteIdentifier"/> on the poll path is a string this driver read back out of
|
||||
/// <see cref="ListSchemasSql"/> / <see cref="ListTablesSql"/> / <see cref="ListColumnsSql"/> — not
|
||||
/// operator input. An identifier that resolves to nothing rejects its tag (which then publishes
|
||||
/// <c>BadNodeIdUnknown</c>) rather than being quoted into a query against a nonexistent object.</para>
|
||||
/// <para>Public because <c>Driver.Sql.Browser</c> consumes it — the catalog SQL <em>is</em> the browse
|
||||
/// engine, so it is shared rather than duplicated. Implementations own their provider package;
|
||||
/// <b>no provider-specific type appears in this signature</b> (<see cref="Factory"/> is the abstract
|
||||
@@ -89,6 +91,20 @@ public interface ISqlDialect
|
||||
/// <summary>Catalog query listing schemas — the browser's <c>RootAsync</c> level. Takes no parameters.</summary>
|
||||
string ListSchemasSql { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Scalar query returning the schema an <b>unqualified</b> object name resolves to for the connecting
|
||||
/// principal — T-SQL's <c>SELECT SCHEMA_NAME()</c>. Takes no parameters.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>Needed by <see cref="SqlCatalogGate"/>: a tag authored as <c>TagValues</c> rather than
|
||||
/// <c>dbo.TagValues</c> has to be looked up in <em>some</em> schema, and guessing <c>dbo</c> would be a
|
||||
/// silent lie on any estate that maps its service accounts to their own default schema. Asking the
|
||||
/// server is the only answer that matches how the poll query itself will resolve the name.</para>
|
||||
/// <para>It is a <em>query</em> rather than a name because the answer is per-connection, not per-dialect
|
||||
/// — the same dialect resolves differently for two different logins.</para>
|
||||
/// </remarks>
|
||||
string DefaultSchemaSql { get; }
|
||||
|
||||
/// <summary>Catalog query listing tables + views in one schema — the browser's schema-expand level. Bind <c>@schema</c>.</summary>
|
||||
string ListTablesSql { get; }
|
||||
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Sql;
|
||||
|
||||
/// <summary>
|
||||
/// An immutable snapshot of the parts of a database's catalog the authored tags actually name — the
|
||||
/// allow-list design §8.1 requires identifiers to come from.
|
||||
/// <para><b>This type holds catalog strings only.</b> Every name in it was read back out of
|
||||
/// <see cref="ISqlDialect.ListSchemasSql"/> / <see cref="ISqlDialect.ListTablesSql"/> /
|
||||
/// <see cref="ISqlDialect.ListColumnsSql"/>; nothing an operator typed is ever stored here. That is what
|
||||
/// lets <see cref="SqlCatalogGate"/> hand the planner catalog spellings rather than authored ones, so the
|
||||
/// identifier text in an emitted query is a string the database gave us.</para>
|
||||
/// <para><b>Deliberately partial.</b> Only the schemas/tables the authored tags name are loaded — a
|
||||
/// driver polling three tables must not enumerate a warehouse's ten thousand. A name absent from this
|
||||
/// snapshot therefore means "not found <em>when we looked</em>", which is exactly the question the gate
|
||||
/// asks.</para>
|
||||
/// </summary>
|
||||
public sealed class SqlCatalog
|
||||
{
|
||||
private readonly IReadOnlyList<string> _schemas;
|
||||
|
||||
/// <summary>Canonical schema → canonical table names in it.</summary>
|
||||
private readonly IReadOnlyDictionary<string, IReadOnlyList<string>> _tablesBySchema;
|
||||
|
||||
/// <summary>Canonical <c>schema.table</c> → that relation's canonical column names.</summary>
|
||||
private readonly IReadOnlyDictionary<string, IReadOnlyList<string>> _columnsByTable;
|
||||
|
||||
/// <summary>Constructs a catalog snapshot.</summary>
|
||||
/// <param name="defaultSchema">The schema an unqualified object name resolves to for this connection.</param>
|
||||
/// <param name="schemas">Every schema the connection can see.</param>
|
||||
/// <param name="tablesBySchema">Canonical schema → its tables/views.</param>
|
||||
/// <param name="columnsByTable">Canonical <c>schema.table</c> → its columns.</param>
|
||||
internal SqlCatalog(
|
||||
string defaultSchema,
|
||||
IReadOnlyList<string> schemas,
|
||||
IReadOnlyDictionary<string, IReadOnlyList<string>> tablesBySchema,
|
||||
IReadOnlyDictionary<string, IReadOnlyList<string>> columnsByTable)
|
||||
{
|
||||
DefaultSchema = defaultSchema;
|
||||
_schemas = schemas;
|
||||
_tablesBySchema = tablesBySchema;
|
||||
_columnsByTable = columnsByTable;
|
||||
}
|
||||
|
||||
/// <summary>The schema an unqualified authored object name is resolved in.</summary>
|
||||
public string DefaultSchema { get; }
|
||||
|
||||
/// <summary>Resolves an authored schema name to the catalog's own spelling.</summary>
|
||||
/// <param name="authored">The authored schema, or null/blank for the default schema.</param>
|
||||
/// <param name="canonical">The catalog's spelling, when resolved.</param>
|
||||
/// <returns><see langword="true"/> when the schema exists (or the default schema is used).</returns>
|
||||
public bool TryResolveSchema(string? authored, out string canonical)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(authored))
|
||||
{
|
||||
canonical = DefaultSchema;
|
||||
return true;
|
||||
}
|
||||
|
||||
return TryMatch(_schemas, authored, out canonical);
|
||||
}
|
||||
|
||||
/// <summary>Resolves an authored table/view name within a canonical schema.</summary>
|
||||
/// <param name="canonicalSchema">The schema, already resolved through <see cref="TryResolveSchema"/>.</param>
|
||||
/// <param name="authored">The authored table or view name.</param>
|
||||
/// <param name="canonical">The catalog's spelling, when resolved.</param>
|
||||
/// <returns><see langword="true"/> when the relation exists in that schema.</returns>
|
||||
public bool TryResolveTable(string canonicalSchema, string authored, out string canonical)
|
||||
{
|
||||
canonical = string.Empty;
|
||||
return _tablesBySchema.TryGetValue(canonicalSchema, out var tables)
|
||||
&& TryMatch(tables, authored, out canonical);
|
||||
}
|
||||
|
||||
/// <summary>Resolves an authored column name within a canonical relation.</summary>
|
||||
/// <param name="canonicalSchema">The resolved schema.</param>
|
||||
/// <param name="canonicalTable">The resolved table or view.</param>
|
||||
/// <param name="authored">The authored column name.</param>
|
||||
/// <param name="canonical">The catalog's spelling, when resolved.</param>
|
||||
/// <returns><see langword="true"/> when the column exists on that relation.</returns>
|
||||
public bool TryResolveColumn(
|
||||
string canonicalSchema, string canonicalTable, string authored, out string canonical)
|
||||
{
|
||||
canonical = string.Empty;
|
||||
return _columnsByTable.TryGetValue(QualifiedKey(canonicalSchema, canonicalTable), out var columns)
|
||||
&& TryMatch(columns, authored, out canonical);
|
||||
}
|
||||
|
||||
/// <summary>The dictionary key for a canonical relation.</summary>
|
||||
/// <param name="schema">The canonical schema.</param>
|
||||
/// <param name="table">The canonical table.</param>
|
||||
/// <returns>The composite key.</returns>
|
||||
internal static string QualifiedKey(string schema, string table) => schema + "." + table;
|
||||
|
||||
/// <summary>
|
||||
/// Matches an authored name against catalog spellings: an exact (ordinal) hit wins, otherwise a
|
||||
/// <b>unique</b> case-insensitive hit is accepted and its catalog spelling returned.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para><b>Why case-insensitive at all.</b> SQL Server's default collation is case-insensitive, so
|
||||
/// <c>num_value</c> and <c>NUM_VALUE</c> genuinely name the same column and both work today. Matching
|
||||
/// ordinally would reject configurations that have always been valid — a gate that breaks working
|
||||
/// deployments is not defence in depth.</para>
|
||||
/// <para><b>Why exact-first, and why ambiguity fails.</b> On a case-<em>sensitive</em> collation a
|
||||
/// relation may legitimately carry both <c>Value</c> and <c>value</c>. Preferring the exact match keeps
|
||||
/// such a config resolving to what the operator wrote, and refusing to choose between two
|
||||
/// case-insensitive candidates is the only safe answer — silently picking one would publish a different
|
||||
/// column's data under the operator's node, which is worse than rejecting the tag.</para>
|
||||
/// </remarks>
|
||||
/// <param name="candidates">The catalog spellings to match against.</param>
|
||||
/// <param name="authored">The authored name.</param>
|
||||
/// <param name="canonical">The catalog's spelling, when matched.</param>
|
||||
/// <returns><see langword="true"/> on an unambiguous match.</returns>
|
||||
internal static bool TryMatch(IReadOnlyList<string> candidates, string authored, out string canonical)
|
||||
{
|
||||
foreach (var candidate in candidates)
|
||||
{
|
||||
if (!string.Equals(candidate, authored, StringComparison.Ordinal)) continue;
|
||||
canonical = candidate;
|
||||
return true;
|
||||
}
|
||||
|
||||
canonical = string.Empty;
|
||||
var matches = 0;
|
||||
foreach (var candidate in candidates)
|
||||
{
|
||||
if (!string.Equals(candidate, authored, StringComparison.OrdinalIgnoreCase)) continue;
|
||||
if (++matches > 1)
|
||||
{
|
||||
canonical = string.Empty;
|
||||
return false;
|
||||
}
|
||||
|
||||
canonical = candidate;
|
||||
}
|
||||
|
||||
return matches == 1;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,241 @@
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Sql;
|
||||
|
||||
/// <summary>
|
||||
/// One tag the gate refused, and why. Carried rather than logged in place so the caller decides the log
|
||||
/// shape and the caller's tests can assert on the reason.
|
||||
/// </summary>
|
||||
/// <param name="RawPath">The rejected tag's identity — its RawPath, which is trusted structure, not blob content.</param>
|
||||
/// <param name="Field">The <see cref="SqlTagDefinition"/> field that failed (e.g. <c>ValueColumn</c>).</param>
|
||||
/// <param name="Reason">An operator-actionable explanation, safe to log.</param>
|
||||
public sealed record SqlCatalogRejection(string RawPath, string Field, string Reason);
|
||||
|
||||
/// <summary>The outcome of applying a <see cref="SqlCatalog"/> to a set of authored tags.</summary>
|
||||
/// <param name="Accepted">
|
||||
/// The surviving definitions, <b>rewritten to catalog spellings</b>. Safe to hand to
|
||||
/// <see cref="SqlGroupPlanner"/>.
|
||||
/// </param>
|
||||
/// <param name="Rejected">Every tag the gate refused, in input order.</param>
|
||||
public sealed record SqlCatalogGateResult(
|
||||
IReadOnlyList<SqlTagDefinition> Accepted,
|
||||
IReadOnlyList<SqlCatalogRejection> Rejected);
|
||||
|
||||
/// <summary>
|
||||
/// Design §8.1's identifier gate: validates every authored table/column against the live catalog
|
||||
/// <b>before</b> it can be quoted into SQL text, and replaces it with the catalog's own spelling.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para><b>What this closes.</b> Until this existed, <see cref="ISqlDialect.QuoteIdentifier"/> was the
|
||||
/// sole defence: an identifier went from the authored <c>TagConfig</c> blob straight into a command text,
|
||||
/// bracket-quoted. The residual risk was bounded — a hostile name is quoted into one nonexistent object,
|
||||
/// the query fails after the connection opens, and the tag Bad-codes — but "bounded" is not "filtered",
|
||||
/// and the design promised a filter. With the gate in place the identifier text in an emitted query is a
|
||||
/// string this driver read back out of the catalog, so quoting is a backstop behind an allow-list rather
|
||||
/// than the whole story.</para>
|
||||
/// <para><b>Charset first, catalog second.</b> Each identifier is passed through
|
||||
/// <see cref="ISqlDialect.QuoteIdentifier"/> before it is looked up, purely for its rejection rules
|
||||
/// (control characters, Unicode format characters, over-length). That ordering is deliberate: a name that
|
||||
/// fails the charset check is rejected <em>without its value being echoed</em>, so nothing carrying a bidi
|
||||
/// override or a NUL can reach a log line through this type's rejection messages. A name that passes has
|
||||
/// been proven safe to render, which is why the catalog-miss messages can afford to name it — and they
|
||||
/// must, or an operator has no way to find the typo.</para>
|
||||
/// <para><b>Rejection is per-tag, never driver-wide.</b> A tag naming a dropped column is dropped from the
|
||||
/// driver's table, so its RawPath resolves to nothing and it publishes
|
||||
/// <see cref="SqlStatusCodes.BadNodeIdUnknown"/> — design §8.1's specified outcome — while every other tag
|
||||
/// on that database keeps polling. This mirrors the "one malformed blob must not take the whole driver
|
||||
/// down" rule the tag-table build already follows.</para>
|
||||
/// </remarks>
|
||||
public static class SqlCatalogGate
|
||||
{
|
||||
/// <summary>
|
||||
/// The most name parts this gate can validate. A three-part <c>db.schema.table</c> (or a
|
||||
/// four-part linked-server name) addresses a catalog this connection's <c>INFORMATION_SCHEMA</c>
|
||||
/// cannot see, so it cannot be allow-listed.
|
||||
/// </summary>
|
||||
private const int MaxNameParts = 2;
|
||||
|
||||
/// <summary>
|
||||
/// Applies <paramref name="catalog"/> to <paramref name="definitions"/>, returning the accepted tags
|
||||
/// with catalog-spelled identifiers and the rejected ones with reasons.
|
||||
/// </summary>
|
||||
/// <param name="definitions">The authored definitions, as parsed from their <c>TagConfig</c> blobs.</param>
|
||||
/// <param name="catalog">The catalog snapshot to validate against.</param>
|
||||
/// <param name="dialect">Supplies the identifier charset rules via <see cref="ISqlDialect.QuoteIdentifier"/>.</param>
|
||||
/// <returns>The accepted and rejected sets.</returns>
|
||||
/// <exception cref="ArgumentNullException">A required argument is null.</exception>
|
||||
public static SqlCatalogGateResult Apply(
|
||||
IEnumerable<SqlTagDefinition> definitions, SqlCatalog catalog, ISqlDialect dialect)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(definitions);
|
||||
ArgumentNullException.ThrowIfNull(catalog);
|
||||
ArgumentNullException.ThrowIfNull(dialect);
|
||||
|
||||
var accepted = new List<SqlTagDefinition>();
|
||||
var rejected = new List<SqlCatalogRejection>();
|
||||
|
||||
foreach (var definition in definitions)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(definition);
|
||||
if (TryCanonicalize(definition, catalog, dialect, out var canonical, out var rejection))
|
||||
accepted.Add(canonical!);
|
||||
else
|
||||
rejected.Add(rejection!);
|
||||
}
|
||||
|
||||
return new SqlCatalogGateResult(accepted, rejected);
|
||||
}
|
||||
|
||||
/// <summary>Resolves one definition's identifiers, or explains the first failure.</summary>
|
||||
private static bool TryCanonicalize(
|
||||
SqlTagDefinition definition,
|
||||
SqlCatalog catalog,
|
||||
ISqlDialect dialect,
|
||||
out SqlTagDefinition? canonical,
|
||||
out SqlCatalogRejection? rejection)
|
||||
{
|
||||
canonical = null;
|
||||
rejection = null;
|
||||
|
||||
if (!TryResolveRelation(definition, catalog, dialect, out var schema, out var table, out rejection))
|
||||
return false;
|
||||
|
||||
// Every identifier-bearing field, paired with its name for the rejection message. A field the tag's
|
||||
// model does not use is null and simply skipped — the planner enforces which are required.
|
||||
var resolved = new Dictionary<string, string?>(StringComparer.Ordinal);
|
||||
foreach (var (field, authored) in new (string Field, string? Authored)[]
|
||||
{
|
||||
(nameof(SqlTagDefinition.KeyColumn), definition.KeyColumn),
|
||||
(nameof(SqlTagDefinition.ValueColumn), definition.ValueColumn),
|
||||
(nameof(SqlTagDefinition.TimestampColumn), definition.TimestampColumn),
|
||||
(nameof(SqlTagDefinition.ColumnName), definition.ColumnName),
|
||||
(nameof(SqlTagDefinition.RowSelectorColumn), definition.RowSelectorColumn),
|
||||
(nameof(SqlTagDefinition.RowSelectorTopByTimestamp), definition.RowSelectorTopByTimestamp),
|
||||
})
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(authored))
|
||||
{
|
||||
resolved[field] = authored;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!IsRenderableIdentifier(authored, dialect))
|
||||
{
|
||||
rejection = new SqlCatalogRejection(definition.Name, field,
|
||||
$"the authored {field} is not a usable identifier (it is over-long, or contains control " +
|
||||
"or Unicode format characters); the value is withheld from this message because it " +
|
||||
"cannot be safely rendered");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!catalog.TryResolveColumn(schema, table, authored, out var column))
|
||||
{
|
||||
rejection = new SqlCatalogRejection(definition.Name, field,
|
||||
$"column '{authored}' does not exist on '{schema}.{table}' (or matches more than one " +
|
||||
"column under a case-sensitive collation)");
|
||||
return false;
|
||||
}
|
||||
|
||||
resolved[field] = column;
|
||||
}
|
||||
|
||||
canonical = definition with
|
||||
{
|
||||
Table = SqlCatalog.QualifiedKey(schema, table),
|
||||
KeyColumn = resolved[nameof(SqlTagDefinition.KeyColumn)],
|
||||
ValueColumn = resolved[nameof(SqlTagDefinition.ValueColumn)],
|
||||
TimestampColumn = resolved[nameof(SqlTagDefinition.TimestampColumn)],
|
||||
ColumnName = resolved[nameof(SqlTagDefinition.ColumnName)],
|
||||
RowSelectorColumn = resolved[nameof(SqlTagDefinition.RowSelectorColumn)],
|
||||
RowSelectorTopByTimestamp = resolved[nameof(SqlTagDefinition.RowSelectorTopByTimestamp)],
|
||||
};
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>Splits and resolves the authored <c>table</c> to a canonical schema + relation.</summary>
|
||||
private static bool TryResolveRelation(
|
||||
SqlTagDefinition definition,
|
||||
SqlCatalog catalog,
|
||||
ISqlDialect dialect,
|
||||
out string schema,
|
||||
out string table,
|
||||
out SqlCatalogRejection? rejection)
|
||||
{
|
||||
schema = string.Empty;
|
||||
table = string.Empty;
|
||||
rejection = null;
|
||||
const string Field = nameof(SqlTagDefinition.Table);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(definition.Table))
|
||||
{
|
||||
rejection = new SqlCatalogRejection(definition.Name, Field,
|
||||
"the tag has no 'table'; author the table or view to read");
|
||||
return false;
|
||||
}
|
||||
|
||||
var parts = definition.Table.Split('.');
|
||||
if (parts.Length > MaxNameParts)
|
||||
{
|
||||
rejection = new SqlCatalogRejection(definition.Name, Field,
|
||||
$"'{definition.Table}' is a {parts.Length}-part name. Only 'table' and 'schema.table' can be " +
|
||||
"validated against this connection's catalog, so a cross-database or linked-server name " +
|
||||
"cannot be allow-listed; expose the data through a view in this database instead");
|
||||
return false;
|
||||
}
|
||||
|
||||
var authoredSchema = parts.Length == MaxNameParts ? parts[0] : null;
|
||||
var authoredTable = parts[^1];
|
||||
|
||||
foreach (var part in parts)
|
||||
{
|
||||
if (IsRenderableIdentifier(part, dialect)) continue;
|
||||
rejection = new SqlCatalogRejection(definition.Name, Field,
|
||||
"the authored table name has a part that is not a usable identifier (empty, over-long, or " +
|
||||
"containing control or Unicode format characters); the value is withheld from this message " +
|
||||
"because it cannot be safely rendered");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!catalog.TryResolveSchema(authoredSchema, out schema))
|
||||
{
|
||||
rejection = new SqlCatalogRejection(definition.Name, Field,
|
||||
$"schema '{authoredSchema}' does not exist (or matches more than one schema under a " +
|
||||
"case-sensitive collation)");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!catalog.TryResolveTable(schema, authoredTable, out table))
|
||||
{
|
||||
rejection = new SqlCatalogRejection(definition.Name, Field,
|
||||
$"table or view '{authoredTable}' does not exist in schema '{schema}'" +
|
||||
(authoredSchema is null
|
||||
? $" (the connection's default schema — qualify the name as 'schema.{authoredTable}' if it lives elsewhere)"
|
||||
: string.Empty));
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// True when the dialect's quoting rules accept <paramref name="identifier"/> — i.e. it is safe both to
|
||||
/// embed in SQL and to render into a log line or an AdminUI label.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Uses <see cref="ISqlDialect.QuoteIdentifier"/> as the charset authority rather than duplicating its
|
||||
/// rules, so the two can never disagree about what a legal identifier is. The quoted result is
|
||||
/// discarded: only whether it threw is interesting here.
|
||||
/// </remarks>
|
||||
private static bool IsRenderableIdentifier(string identifier, ISqlDialect dialect)
|
||||
{
|
||||
try
|
||||
{
|
||||
dialect.QuoteIdentifier(identifier);
|
||||
return true;
|
||||
}
|
||||
catch (ArgumentException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
using System.Data;
|
||||
using System.Data.Common;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Sql;
|
||||
|
||||
/// <summary>
|
||||
/// Loads the <see cref="SqlCatalog"/> slice the authored tags need, over one connection, using only the
|
||||
/// dialect's catalog SQL (design §8.1).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para><b>Bounded by what is authored, not by what exists.</b> One query for the schema list, one for
|
||||
/// the default schema, then one <see cref="ISqlDialect.ListTablesSql"/> per distinct authored schema and
|
||||
/// one <see cref="ISqlDialect.ListColumnsSql"/> per distinct authored relation. A driver polling three
|
||||
/// tables issues a handful of round-trips at Initialize and none thereafter; it never enumerates a
|
||||
/// warehouse.</para>
|
||||
/// <para><b>Every parameter is bound.</b> Authored names reach the catalog queries as
|
||||
/// <c>@schema</c>/<c>@table</c> parameters — the same discipline the schema browser follows — so loading
|
||||
/// the allow-list cannot itself be an injection vector. No identifier is quoted into text on this path.</para>
|
||||
/// <para><b>Failures throw; they do not degrade to an empty catalog.</b> An empty catalog would reject
|
||||
/// every tag, which is indistinguishable at the OPC UA surface from a database whose objects were all
|
||||
/// dropped. A caller that cannot read the catalog has not learned that the tags are invalid — it has
|
||||
/// learned nothing — and must fail its Initialize so the driver retries rather than serving a
|
||||
/// confidently-empty address space.</para>
|
||||
/// </remarks>
|
||||
internal static class SqlCatalogLoader
|
||||
{
|
||||
/// <summary>Column alias <see cref="ISqlDialect.ListSchemasSql"/> projects.</summary>
|
||||
private const string SchemaColumn = "TABLE_SCHEMA";
|
||||
|
||||
/// <summary>Column alias <see cref="ISqlDialect.ListTablesSql"/> projects.</summary>
|
||||
private const string TableNameColumn = "TABLE_NAME";
|
||||
|
||||
/// <summary>Column alias <see cref="ISqlDialect.ListColumnsSql"/> projects.</summary>
|
||||
private const string ColumnNameColumn = "COLUMN_NAME";
|
||||
|
||||
/// <summary>
|
||||
/// Reads the catalog slice covering <paramref name="authoredTables"/>.
|
||||
/// </summary>
|
||||
/// <param name="connection">An <b>already-open</b> connection. Not disposed here; the caller owns it.</param>
|
||||
/// <param name="dialect">Supplies the catalog SQL.</param>
|
||||
/// <param name="authoredTables">The distinct authored <c>table</c> strings, as written in the blobs.</param>
|
||||
/// <param name="commandTimeout">Per-command server-side backstop.</param>
|
||||
/// <param name="cancellationToken">Cancellation token for the operation.</param>
|
||||
/// <returns>The loaded catalog.</returns>
|
||||
/// <exception cref="InvalidOperationException">The connection can see no schemas at all.</exception>
|
||||
internal static async Task<SqlCatalog> LoadAsync(
|
||||
DbConnection connection,
|
||||
ISqlDialect dialect,
|
||||
IReadOnlyCollection<string> authoredTables,
|
||||
TimeSpan commandTimeout,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var timeoutSeconds = Math.Max(1, (int)Math.Ceiling(commandTimeout.TotalSeconds));
|
||||
|
||||
var schemas = await QueryColumnAsync(
|
||||
connection, dialect.ListSchemasSql, SchemaColumn,
|
||||
static _ => { }, timeoutSeconds, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// Zero schemas is a permissions or visibility symptom, never a real database. Rejecting every tag on
|
||||
// that basis would report an authoring fault for what is actually a grant problem, and would send the
|
||||
// operator to the wrong system — the same misdiagnosis the driver's health classification avoids.
|
||||
if (schemas.Count == 0)
|
||||
throw new InvalidOperationException(
|
||||
"the catalog returned no schemas at all; the connecting principal most likely cannot read " +
|
||||
"the catalog views, which is a grant problem rather than a tag-authoring one");
|
||||
|
||||
var defaultSchema = await ReadDefaultSchemaAsync(
|
||||
connection, dialect, timeoutSeconds, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var tablesBySchema = new Dictionary<string, IReadOnlyList<string>>(StringComparer.Ordinal);
|
||||
var columnsByTable = new Dictionary<string, IReadOnlyList<string>>(StringComparer.Ordinal);
|
||||
|
||||
// Resolving here goes through SqlCatalog.TryMatch — the SAME matcher the gate will use — so the
|
||||
// loader and the gate can never disagree about which relation an authored name resolved to. Loading
|
||||
// one table and validating against another would be a silent wrong-column defect.
|
||||
foreach (var authored in authoredTables)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(authored)) continue;
|
||||
|
||||
var parts = authored.Split('.');
|
||||
if (parts.Length > 2) continue; // the gate rejects these by name; nothing to load.
|
||||
|
||||
var authoredTable = parts[^1];
|
||||
string schema;
|
||||
if (parts.Length == 2)
|
||||
{
|
||||
if (!SqlCatalog.TryMatch(schemas, parts[0], out schema)) continue;
|
||||
}
|
||||
else
|
||||
{
|
||||
schema = defaultSchema;
|
||||
}
|
||||
|
||||
if (!tablesBySchema.TryGetValue(schema, out var tables))
|
||||
{
|
||||
tables = await QueryColumnAsync(
|
||||
connection, dialect.ListTablesSql, TableNameColumn,
|
||||
command => Bind(command, "@schema", schema),
|
||||
timeoutSeconds, cancellationToken).ConfigureAwait(false);
|
||||
tablesBySchema[schema] = tables;
|
||||
}
|
||||
|
||||
if (!SqlCatalog.TryMatch(tables, authoredTable, out var table)) continue;
|
||||
|
||||
var key = SqlCatalog.QualifiedKey(schema, table);
|
||||
if (columnsByTable.ContainsKey(key)) continue;
|
||||
|
||||
columnsByTable[key] = await QueryColumnAsync(
|
||||
connection, dialect.ListColumnsSql, ColumnNameColumn,
|
||||
command =>
|
||||
{
|
||||
Bind(command, "@schema", schema);
|
||||
Bind(command, "@table", table);
|
||||
},
|
||||
timeoutSeconds, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
return new SqlCatalog(defaultSchema, schemas, tablesBySchema, columnsByTable);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reads the connection's default schema, falling back to the sole visible schema when the dialect's
|
||||
/// query answers null/blank.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// A null answer is possible (a login with no default schema mapped). Falling back to the single
|
||||
/// visible schema keeps the common one-schema database working; with several visible schemas there is
|
||||
/// no defensible guess, so unqualified names simply will not resolve and the gate says so by name.
|
||||
/// </remarks>
|
||||
private static async Task<string> ReadDefaultSchemaAsync(
|
||||
DbConnection connection, ISqlDialect dialect, int timeoutSeconds, CancellationToken cancellationToken)
|
||||
{
|
||||
var command = connection.CreateCommand();
|
||||
await using (command.ConfigureAwait(false))
|
||||
{
|
||||
command.CommandText = dialect.DefaultSchemaSql;
|
||||
command.CommandTimeout = timeoutSeconds;
|
||||
var value = await command.ExecuteScalarAsync(cancellationToken).ConfigureAwait(false);
|
||||
var schema = value is null or DBNull ? null : value.ToString();
|
||||
return string.IsNullOrWhiteSpace(schema) ? string.Empty : schema;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Runs one catalog query and projects a single string column from every row.</summary>
|
||||
private static async Task<IReadOnlyList<string>> QueryColumnAsync(
|
||||
DbConnection connection,
|
||||
string sql,
|
||||
string columnName,
|
||||
Action<DbCommand> bind,
|
||||
int timeoutSeconds,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var results = new List<string>();
|
||||
|
||||
var command = connection.CreateCommand();
|
||||
await using (command.ConfigureAwait(false))
|
||||
{
|
||||
command.CommandText = sql;
|
||||
command.CommandTimeout = timeoutSeconds;
|
||||
bind(command);
|
||||
|
||||
var reader = await command
|
||||
.ExecuteReaderAsync(CommandBehavior.SingleResult, cancellationToken).ConfigureAwait(false);
|
||||
await using (reader.ConfigureAwait(false))
|
||||
{
|
||||
var ordinal = -1;
|
||||
while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
if (ordinal < 0) ordinal = reader.GetOrdinal(columnName);
|
||||
if (reader.IsDBNull(ordinal)) continue;
|
||||
var value = reader.GetValue(ordinal)?.ToString();
|
||||
if (!string.IsNullOrWhiteSpace(value)) results.Add(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/// <summary>Binds one catalog-query parameter — the only way an authored name reaches the database here.</summary>
|
||||
private static void Bind(DbCommand command, string name, string value)
|
||||
{
|
||||
var parameter = command.CreateParameter();
|
||||
parameter.ParameterName = name;
|
||||
parameter.DbType = DbType.String;
|
||||
parameter.Value = value;
|
||||
command.Parameters.Add(parameter);
|
||||
}
|
||||
}
|
||||
@@ -29,14 +29,12 @@ public sealed class SqlDriver
|
||||
: IDriver, ITagDiscovery, IReadable, ISubscribable, IHostConnectivityProbe, IAsyncDisposable
|
||||
{
|
||||
/// <summary>
|
||||
/// The driver-type string this driver registers under.
|
||||
/// <para><b>Deliberately a local constant, not <c>DriverTypeNames.Sql</c>.</b>
|
||||
/// <c>DriverTypeNamesGuardTests</c> asserts bidirectional parity between the constants and the
|
||||
/// driver factories actually registered in the process, so the constant may only be added in the
|
||||
/// same change that wires the factory (this task ships no factory — see the plan's Task 11, which
|
||||
/// adds <c>DriverTypeNames.Sql</c> and repoints this constant at it).</para>
|
||||
/// The driver-type string this driver registers under — <see cref="DriverTypeNames.Sql"/>, the single
|
||||
/// source of truth the deploy pipeline stores in <c>DriverInstance.DriverType</c> and every dispatch
|
||||
/// map keys by. Chained off the shared constant (Task 11 wired the factory + probe, so
|
||||
/// <c>DriverTypeNamesGuardTests</c>' bidirectional-parity check is satisfied).
|
||||
/// </summary>
|
||||
public const string DriverTypeName = "Sql";
|
||||
public const string DriverTypeName = DriverTypeNames.Sql;
|
||||
|
||||
/// <summary>Shown for a connection string that names no recognisable server.</summary>
|
||||
private const string UnknownEndpoint = "(unknown sql endpoint)";
|
||||
@@ -76,7 +74,23 @@ public sealed class SqlDriver
|
||||
private IReadOnlyDictionary<string, SqlTagDefinition> _tagsByRawPath =
|
||||
new Dictionary<string, SqlTagDefinition>(StringComparer.Ordinal);
|
||||
|
||||
/// <summary>Resolves a read/subscribe RawPath to its authored definition — a single table hit, or a miss.</summary>
|
||||
/// <summary>
|
||||
/// The subset of <see cref="_tagsByRawPath"/> that survived the design §8.1 catalog gate, with every
|
||||
/// identifier rewritten to the catalog's own spelling. <b>This — not the authored table — is what a
|
||||
/// read or a poll resolves against</b>, so an identifier that never appeared in the catalog can never
|
||||
/// reach a query.
|
||||
/// <para><b>Why the two tables are separate.</b> A tag the gate rejected must still <em>exist</em> as a
|
||||
/// node: §8.1's specified outcome is that it publishes
|
||||
/// <see cref="SqlStatusCodes.BadNodeIdUnknown"/>, and a status code can only be published by a node
|
||||
/// that is there. Dropping rejected tags from the authored table too would delete the node instead,
|
||||
/// turning a diagnosable Bad quality into a silently missing address-space entry — a strictly worse
|
||||
/// failure for the operator trying to find their typo.</para>
|
||||
/// <para>Swapped atomically, for the same reason the authored table is.</para>
|
||||
/// </summary>
|
||||
private IReadOnlyDictionary<string, SqlTagDefinition> _polledByRawPath =
|
||||
new Dictionary<string, SqlTagDefinition>(StringComparer.Ordinal);
|
||||
|
||||
/// <summary>Resolves a read/subscribe RawPath to its <b>catalog-validated</b> definition, or a miss.</summary>
|
||||
private readonly EquipmentTagRefResolver<SqlTagDefinition> _resolver;
|
||||
|
||||
/// <summary>
|
||||
@@ -184,8 +198,18 @@ public sealed class SqlDriver
|
||||
/// <summary>The current authored-tag snapshot. Read through a barrier — <see cref="BuildTagTable"/> swaps it.</summary>
|
||||
private IReadOnlyDictionary<string, SqlTagDefinition> Tags => Volatile.Read(ref _tagsByRawPath);
|
||||
|
||||
/// <summary>The resolver's lookup: RawPath → authored definition, or null on a miss.</summary>
|
||||
private SqlTagDefinition? Lookup(string rawPath) => Tags.GetValueOrDefault(rawPath);
|
||||
/// <summary>
|
||||
/// The catalog-validated snapshot. Read through a barrier — <see cref="ApplyCatalogGateAsync"/> swaps it.
|
||||
/// </summary>
|
||||
private IReadOnlyDictionary<string, SqlTagDefinition> PolledTags => Volatile.Read(ref _polledByRawPath);
|
||||
|
||||
/// <summary>
|
||||
/// The resolver's lookup: RawPath → <b>catalog-validated</b> definition, or null on a miss.
|
||||
/// <para>Deliberately reads <see cref="PolledTags"/> rather than <see cref="Tags"/>: a tag the gate
|
||||
/// rejected must miss here so the reader publishes
|
||||
/// <see cref="SqlStatusCodes.BadNodeIdUnknown"/>, exactly as design §8.1 specifies.</para>
|
||||
/// </summary>
|
||||
private SqlTagDefinition? Lookup(string rawPath) => PolledTags.GetValueOrDefault(rawPath);
|
||||
|
||||
// ---- IDriver lifecycle ----
|
||||
|
||||
@@ -198,11 +222,16 @@ public sealed class SqlDriver
|
||||
/// I/O, and on failure this method records <see cref="DriverState.Faulted"/> <b>and rethrows</b> —
|
||||
/// <c>DriverInstanceActor</c> reads a throw as InitializeFailed and lands in Reconnecting with its
|
||||
/// retry timer running, which is exactly the recovery a database that is merely down needs.</para>
|
||||
/// <para>The liveness check is then followed by the design §8.1 <b>catalog gate</b>
|
||||
/// (<see cref="ApplyCatalogGateAsync"/>), which is the second and last piece of I/O. It runs after
|
||||
/// liveness because it needs a working connection, and before the driver reports Healthy because the
|
||||
/// tag table it publishes must be the validated one — a poll must never see an unvalidated
|
||||
/// identifier.</para>
|
||||
/// </summary>
|
||||
/// <param name="driverConfigJson">The driver configuration JSON (unused; see remarks).</param>
|
||||
/// <param name="cancellationToken">Cancellation token for the operation.</param>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
/// <exception cref="InvalidOperationException">The database could not be reached.</exception>
|
||||
/// <exception cref="InvalidOperationException">The database could not be reached, or its catalog could not be read.</exception>
|
||||
public async Task InitializeAsync(string driverConfigJson, CancellationToken cancellationToken)
|
||||
{
|
||||
WriteHealth(new DriverHealth(DriverState.Initializing, null, null));
|
||||
@@ -236,6 +265,36 @@ public sealed class SqlDriver
|
||||
throw new InvalidOperationException(message, ex);
|
||||
}
|
||||
|
||||
// Separate try from liveness so the operator surface names the stage that actually failed: "could
|
||||
// not reach the database" and "reached it but could not read its catalog" send an operator to
|
||||
// different places, and the second is usually a GRANT rather than an outage.
|
||||
try
|
||||
{
|
||||
await ApplyCatalogGateAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
WriteHealth(new DriverHealth(DriverState.Unknown, null, null));
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Same discipline as above: exception TYPE only on the operator surface, full exception to the
|
||||
// log sink. A catalog read that fails is NOT evidence the tags are wrong — it is the absence of
|
||||
// evidence — so this faults the driver (and retries) rather than rejecting every tag and serving
|
||||
// a confidently-empty address space.
|
||||
var message =
|
||||
$"Sql driver '{_driverInstanceId}' reached {Endpoint} but could not read its catalog to " +
|
||||
$"validate authored tables/columns: {ex.GetType().Name}. Check that the connecting principal " +
|
||||
"can read the catalog views.";
|
||||
_logger.LogError(ex,
|
||||
"Sql driver {DriverInstanceId} catalog validation against {Endpoint} failed.",
|
||||
_driverInstanceId, Endpoint);
|
||||
WriteHealth(new DriverHealth(DriverState.Faulted, null, message));
|
||||
TransitionHostTo(HostState.Stopped);
|
||||
throw new InvalidOperationException(message, ex);
|
||||
}
|
||||
|
||||
WriteHealth(new DriverHealth(DriverState.Healthy, DateTime.UtcNow, null));
|
||||
TransitionHostTo(HostState.Running);
|
||||
_logger.LogInformation(
|
||||
@@ -492,15 +551,17 @@ public sealed class SqlDriver
|
||||
// A subscribed-read outage surfaces as a DbException the reader threw, which ReadAsync has ALREADY
|
||||
// classified — good, endpoint-bearing LastError + host Stopped — before rethrowing. The poll engine
|
||||
// then routes that same exception here. Re-degrading would overwrite the good LastError with a worse,
|
||||
// guidance-free one (and, for a provider whose text echoes credentials, re-open the C1 leak) and log
|
||||
// the outage a second time. So skip the connection class ReadAsync owns; the only exceptions that
|
||||
// reach past this guard are the engine's own contract-violation throws — an InvalidOperationException
|
||||
// carrying no provider text — which nothing else classifies.
|
||||
// guidance-free one and log the outage a second time, so skip the connection class ReadAsync owns.
|
||||
if (ex is DbException) return;
|
||||
|
||||
// Whatever reaches here — an engine contract-violation, or a non-DbException the provider raised
|
||||
// (e.g. an ArgumentException from a malformed connection string, which the parser echoes with
|
||||
// credential fragments) — the operator surface carries only the exception TYPE, never ex.Message.
|
||||
// The full exception, with its text, still reaches the log SINK via the exception parameter. This is
|
||||
// the same unconditional rule InitializeAsync/ReadAsync follow; do not weaken it to a type check.
|
||||
_logger.LogWarning(ex, "Sql poll failed. Driver={DriverInstanceId} Endpoint={Endpoint}",
|
||||
_driverInstanceId, Endpoint);
|
||||
Degrade(ex.Message);
|
||||
Degrade($"Sql driver '{_driverInstanceId}' poll failed: {ex.GetType().Name}.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -594,6 +655,94 @@ public sealed class SqlDriver
|
||||
}
|
||||
|
||||
Volatile.Write(ref _tagsByRawPath, table);
|
||||
|
||||
// Nothing is pollable until the catalog gate has validated it. Clearing here (rather than leaving a
|
||||
// previous generation's validated table in place) means a Reinitialize whose gate then fails cannot
|
||||
// keep polling the OLD identifiers against a database whose schema may be exactly what changed.
|
||||
Volatile.Write(ref _polledByRawPath, new Dictionary<string, SqlTagDefinition>(StringComparer.Ordinal));
|
||||
}
|
||||
|
||||
// ---- catalog gate (design §8.1) ----
|
||||
|
||||
/// <summary>
|
||||
/// Validates every authored identifier against the live catalog and republishes the tag table with
|
||||
/// catalog spellings, dropping the tags that do not resolve (design §8.1).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para><b>Why this must run before the driver reports Healthy.</b> The table this swaps in is what
|
||||
/// every poll resolves against. Running it later — or in the background — would leave a window in
|
||||
/// which an unvalidated identifier could be quoted into a query, which is the entire hole the gate
|
||||
/// exists to close.</para>
|
||||
/// <para><b>A dropped tag is not a driver fault.</b> It disappears from the table, so its RawPath
|
||||
/// resolves to nothing and it publishes <see cref="SqlStatusCodes.BadNodeIdUnknown"/> — one operator
|
||||
/// typo must not stop the other tags on that database, the same rule
|
||||
/// <see cref="BuildTagTable"/> follows for a malformed blob. Each drop is logged at Warning with the
|
||||
/// tag, the field and the reason, because a node that silently goes Bad with nothing in the log is
|
||||
/// unsupportable.</para>
|
||||
/// <para><b>No tags ⇒ no I/O.</b> A driver with nothing authored has nothing to validate, and issuing
|
||||
/// catalog queries to prove that would be a round-trip that can only fail.</para>
|
||||
/// <para>Bounded by the same wall-clock discipline as the liveness check (R2-01 / STAB-14): the work
|
||||
/// runs on the thread pool so a provider implementing the async path synchronously blocks a pool
|
||||
/// thread rather than wedging Initialize forever with no retry.</para>
|
||||
/// </remarks>
|
||||
/// <param name="cancellationToken">The caller's token.</param>
|
||||
/// <returns>A task that completes when the tag table has been validated and republished.</returns>
|
||||
private async Task ApplyCatalogGateAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var authored = Tags;
|
||||
if (authored.Count == 0) return;
|
||||
|
||||
var tables = authored.Values
|
||||
.Select(definition => definition.Table)
|
||||
.Where(table => !string.IsNullOrWhiteSpace(table))
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.ToArray();
|
||||
|
||||
var catalog = await RunBoundedAsync(
|
||||
token => LoadCatalogAsync(tables, token),
|
||||
_options.OperationTimeout,
|
||||
"the catalog queries did not return",
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var result = SqlCatalogGate.Apply(authored.Values, catalog, _dialect);
|
||||
|
||||
foreach (var rejection in result.Rejected)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Sql tag '{Tag}' rejected by the catalog gate: {Field} — {Reason}. It will publish "
|
||||
+ "BadNodeIdUnknown until the tag or the database is corrected. Driver={DriverInstanceId}",
|
||||
rejection.RawPath, rejection.Field, rejection.Reason, _driverInstanceId);
|
||||
}
|
||||
|
||||
var validated = new Dictionary<string, SqlTagDefinition>(result.Accepted.Count, StringComparer.Ordinal);
|
||||
foreach (var definition in result.Accepted) validated[definition.Name] = definition;
|
||||
|
||||
// Only the POLLED table is replaced. The authored table is left intact so every authored tag keeps
|
||||
// its node and a rejected one publishes BadNodeIdUnknown rather than vanishing (see _polledByRawPath).
|
||||
Volatile.Write(ref _polledByRawPath, validated);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Sql driver {DriverInstanceId} catalog gate accepted {Accepted} of {Total} authored tag(s) "
|
||||
+ "across {Tables} table(s) on {Endpoint}.",
|
||||
_driverInstanceId, result.Accepted.Count, authored.Count, tables.Length, Endpoint);
|
||||
}
|
||||
|
||||
/// <summary>Opens one connection and reads the catalog slice the authored tables need.</summary>
|
||||
private async Task<SqlCatalog> LoadCatalogAsync(
|
||||
IReadOnlyCollection<string> authoredTables, CancellationToken cancellationToken)
|
||||
{
|
||||
var connection = _factory.CreateConnection()
|
||||
?? throw new InvalidOperationException(
|
||||
$"the {_dialect.Provider} provider factory returned no connection.");
|
||||
|
||||
await using (connection.ConfigureAwait(false))
|
||||
{
|
||||
connection.ConnectionString = _connectionString;
|
||||
await connection.OpenAsync(cancellationToken).ConfigureAwait(false);
|
||||
return await SqlCatalogLoader
|
||||
.LoadAsync(connection, _dialect, authoredTables, _options.CommandTimeout, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- liveness ----
|
||||
@@ -611,18 +760,50 @@ public sealed class SqlDriver
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">The caller's token.</param>
|
||||
/// <returns>A task that completes when the database has answered.</returns>
|
||||
private async Task VerifyLivenessAsync(CancellationToken cancellationToken)
|
||||
private Task VerifyLivenessAsync(CancellationToken cancellationToken) =>
|
||||
RunBoundedAsync(
|
||||
async token => { await PingAsync(token).ConfigureAwait(false); return true; },
|
||||
_options.CommandTimeout,
|
||||
"the liveness statement did not return",
|
||||
cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Runs <paramref name="work"/> under a hard wall-clock <paramref name="budget"/>, on the thread pool,
|
||||
/// converting a breach into a <see cref="TimeoutException"/> worded from <paramref name="what"/>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>The R2-01 / STAB-14 shape, shared by the two pieces of I/O Initialize performs. It exists
|
||||
/// because a token is not a deadline: some ADO.NET providers implement the async path synchronously,
|
||||
/// and a wedged socket can hang <em>inside</em> the provider's own cancellation handshake. Without an
|
||||
/// independent wall clock, <c>DriverInstanceActor</c>'s init task would never complete and the driver
|
||||
/// would sit in Connecting forever with no retry — the exact frozen-peer wedge the S7 driver shipped.</para>
|
||||
/// <para>Both the linked CTS deadline and an outer <see cref="TaskExtensions"/> wait are used, because
|
||||
/// each covers a case the other does not: the CTS handles a provider that <em>does</em> honour
|
||||
/// cancellation, and the outer wait handles one that does not. An abandoned attempt keeps ownership of
|
||||
/// its own connection and disposes it; <see cref="Detach"/> observes its eventual fault.</para>
|
||||
/// </remarks>
|
||||
/// <typeparam name="T">The work's result type.</typeparam>
|
||||
/// <param name="work">The operation to bound. Receives the deadline-linked token.</param>
|
||||
/// <param name="budget">The wall-clock allowance.</param>
|
||||
/// <param name="what">Sentence fragment naming the operation, e.g. "the catalog queries did not return".</param>
|
||||
/// <param name="cancellationToken">The caller's token.</param>
|
||||
/// <returns>The work's result.</returns>
|
||||
/// <exception cref="TimeoutException">The budget elapsed first.</exception>
|
||||
private static async Task<T> RunBoundedAsync<T>(
|
||||
Func<CancellationToken, Task<T>> work,
|
||||
TimeSpan budget,
|
||||
string what,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var budget = _options.CommandTimeout;
|
||||
var deadline = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||
Task work;
|
||||
Task<T> running;
|
||||
try
|
||||
{
|
||||
deadline.CancelAfter(budget);
|
||||
work = Task.Run(
|
||||
running = Task.Run(
|
||||
async () =>
|
||||
{
|
||||
try { await PingAsync(deadline.Token).ConfigureAwait(false); }
|
||||
try { return await work(deadline.Token).ConfigureAwait(false); }
|
||||
finally { deadline.Dispose(); }
|
||||
},
|
||||
CancellationToken.None);
|
||||
@@ -636,20 +817,18 @@ public sealed class SqlDriver
|
||||
|
||||
try
|
||||
{
|
||||
await work.WaitAsync(budget, cancellationToken).ConfigureAwait(false);
|
||||
return await running.WaitAsync(budget, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (TimeoutException)
|
||||
{
|
||||
Detach(work);
|
||||
throw new TimeoutException(
|
||||
$"the liveness statement did not return within {(int)budget.TotalMilliseconds} ms.");
|
||||
Detach(running);
|
||||
throw new TimeoutException($"{what} within {(int)budget.TotalMilliseconds} ms.");
|
||||
}
|
||||
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
// Our deadline fired and the provider DID honour the linked token.
|
||||
Detach(work);
|
||||
throw new TimeoutException(
|
||||
$"the liveness statement did not return within {(int)budget.TotalMilliseconds} ms.");
|
||||
Detach(running);
|
||||
throw new TimeoutException($"{what} within {(int)budget.TotalMilliseconds} ms.");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -752,6 +931,7 @@ public sealed class SqlDriver
|
||||
{
|
||||
await _poll.DisposeAsync().ConfigureAwait(false);
|
||||
Volatile.Write(ref _tagsByRawPath, new Dictionary<string, SqlTagDefinition>(StringComparer.Ordinal));
|
||||
Volatile.Write(ref _polledByRawPath, new Dictionary<string, SqlTagDefinition>(StringComparer.Ordinal));
|
||||
_resolver.Clear(); // no-op in v3 (the resolver reads the live table by closure); kept for symmetry
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,12 +29,9 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Sql;
|
||||
public static class SqlDriverFactoryExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// The driver-type string this factory registers under.
|
||||
/// <para>Chained off <see cref="SqlDriver.DriverTypeName"/> rather than <c>DriverTypeNames.Sql</c>:
|
||||
/// <c>DriverTypeNamesGuardTests</c> asserts bidirectional parity between the shared constants and the
|
||||
/// factories actually registered in the process, so the constant may only be added in the change that
|
||||
/// also wires this factory into the Host (the plan's Task 11, which repoints both at
|
||||
/// <c>DriverTypeNames.Sql</c>).</para>
|
||||
/// The driver-type string this factory registers under — chained off
|
||||
/// <see cref="SqlDriver.DriverTypeName"/>, which is
|
||||
/// <see cref="ZB.MOM.WW.OtOpcUa.Core.Abstractions.DriverTypeNames.Sql"/>.
|
||||
/// </summary>
|
||||
public const string DriverTypeName = SqlDriver.DriverTypeName;
|
||||
|
||||
|
||||
@@ -44,6 +44,12 @@ public sealed class SqlServerDialect : ISqlDialect
|
||||
public string ListSchemasSql =>
|
||||
"SELECT DISTINCT TABLE_SCHEMA FROM INFORMATION_SCHEMA.TABLES ORDER BY TABLE_SCHEMA";
|
||||
|
||||
/// <summary>
|
||||
/// <c>SCHEMA_NAME()</c> with no argument returns the calling principal's default schema — the one an
|
||||
/// unqualified object name resolves in, which is precisely the question the catalog gate asks.
|
||||
/// </summary>
|
||||
public string DefaultSchemaSql => "SELECT SCHEMA_NAME()";
|
||||
|
||||
/// <inheritdoc/>
|
||||
public string ListTablesSql =>
|
||||
"SELECT TABLE_NAME, TABLE_TYPE FROM INFORMATION_SCHEMA.TABLES " +
|
||||
@@ -70,9 +76,11 @@ public sealed class SqlServerDialect : ISqlDialect
|
||||
/// control-character rule.</para>
|
||||
/// <para>The rejection messages deliberately do <b>not</b> echo the offending value — it is untrusted
|
||||
/// input and this exception's text reaches the driver log.</para>
|
||||
/// <para><b>This is currently the only defence, not a backstop.</b> Design §8.1 specifies that an
|
||||
/// identifier is validated against the live catalog before reaching here; that gate is not implemented
|
||||
/// yet, so an authored <c>TagConfig</c> table/column arrives unfiltered.</para>
|
||||
/// <para><b>This is a backstop, not the only defence.</b> Design §8.1's catalog gate
|
||||
/// (<see cref="SqlCatalogGate"/>) resolves an authored table/column against the live catalog at
|
||||
/// Initialize and substitutes the catalog's own spelling, so on the poll path the value arriving here
|
||||
/// is a string read back out of the catalog. The rules below still run — the gate uses them as its own
|
||||
/// charset filter, and they must hold for any future caller that has no catalog to check against.</para>
|
||||
/// </remarks>
|
||||
/// <param name="ident">The bare, unquoted identifier part.</param>
|
||||
/// <returns>The bracket-quoted identifier.</returns>
|
||||
|
||||
@@ -19,9 +19,9 @@ public static class TwinCATStatusMapper
|
||||
public const uint BadDeviceFailure = 0x808B0000u;
|
||||
public const uint BadCommunicationError = 0x80050000u;
|
||||
public const uint BadTimeout = 0x800A0000u;
|
||||
public const uint BadTypeMismatch = 0x80730000u;
|
||||
public const uint BadOutOfService = 0x80BE0000u;
|
||||
public const uint BadInvalidState = 0x80350000u;
|
||||
public const uint BadTypeMismatch = 0x80740000u;
|
||||
public const uint BadOutOfService = 0x808D0000u;
|
||||
public const uint BadInvalidState = 0x80AF0000u;
|
||||
|
||||
// ---- AdsErrorCode numeric values (confirmed from Beckhoff.TwinCAT.Ads 7.0.172) ----
|
||||
|
||||
|
||||
@@ -81,7 +81,7 @@ else
|
||||
<tr>
|
||||
<td><span class="mono small">@c.Subject</span></td>
|
||||
<td><span class="mono small">@c.Issuer</span></td>
|
||||
<td><span class="mono small">@c.Thumbprint[..16]…</span></td>
|
||||
<td><span class="mono small">@DisplayText.Abbreviate(c.Thumbprint, 16)</span></td>
|
||||
<td>@c.NotBefore.ToString("u")</td>
|
||||
<td>@c.NotAfter.ToString("u")</td>
|
||||
@if (store.Kind is StoreKind.Trusted or StoreKind.Rejected)
|
||||
|
||||
+1
-1
@@ -58,7 +58,7 @@ else
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="kv"><span class="k">Revision</span><span class="v mono">@_lastDeployment.RevisionHash[..16]…</span></div>
|
||||
<div class="kv"><span class="k">Revision</span><span class="v mono">@DisplayText.Abbreviate(_lastDeployment.RevisionHash, 16)</span></div>
|
||||
<div class="kv"><span class="k">Status</span><span class="v">@_lastDeployment.Status</span></div>
|
||||
<div class="kv"><span class="k">Created</span><span class="v">@_lastDeployment.CreatedAtUtc.ToString("u")</span></div>
|
||||
@if (_lastDeployment.SealedAtUtc is not null)
|
||||
|
||||
@@ -9,6 +9,8 @@
|
||||
@using System.ComponentModel.DataAnnotations
|
||||
@using ZB.MOM.WW.OtOpcUa.Configuration
|
||||
@using ZB.MOM.WW.OtOpcUa.Configuration.Entities
|
||||
@using ZB.MOM.WW.OtOpcUa.Configuration.Validation
|
||||
@using ZB.MOM.WW.OtOpcUa.Core.Abstractions
|
||||
@inject IDbContextFactory<OtOpcUaConfigDbContext> DbFactory
|
||||
@inject NavigationManager Nav
|
||||
@inject AuthenticationStateProvider AuthState
|
||||
@@ -34,6 +36,10 @@ else
|
||||
{
|
||||
<EditForm Model="_form" OnValidSubmit="SubmitAsync" FormName="nodeEdit">
|
||||
<DataAnnotationsValidator />
|
||||
@* Without this, a validation failure suppresses OnValidSubmit with NO feedback whatsoever —
|
||||
the Save button simply does nothing and the operator has no way to tell why. That is how the
|
||||
NodeId-regex defect above stayed invisible. *@
|
||||
<ValidationSummary class="alert alert-danger py-2 px-3" />
|
||||
<section class="panel rise" style="animation-delay:.02s">
|
||||
<div class="panel-head">Identity</div>
|
||||
<div style="padding:1rem">
|
||||
@@ -178,6 +184,33 @@ else
|
||||
}
|
||||
|
||||
await using var db = await DbFactory.CreateDbContextAsync();
|
||||
|
||||
// #499 — the third Sql-credential surface. DriverConfigOverridesJson is merged onto the
|
||||
// cluster-level DriverConfig, so a literal connectionString pasted here leaks exactly as one
|
||||
// pasted into the driver itself. The deploy gate cannot see it (DraftSnapshot carries no
|
||||
// ClusterNode rows) and would be the wrong place anyway: node overrides never enter the
|
||||
// artifact, so by the time a deploy ran the credential would already be stored. Refuse the
|
||||
// save instead. The message names the driver instance but NEVER the value.
|
||||
if (!string.IsNullOrWhiteSpace(_form.DriverConfigOverridesJson))
|
||||
{
|
||||
var sqlDriverIds = await db.DriverInstances
|
||||
.Where(d => d.ClusterId == ClusterId && d.DriverType == DriverTypeNames.Sql)
|
||||
.Select(d => d.DriverInstanceId)
|
||||
.ToListAsync();
|
||||
|
||||
var offenders = SqlCredentialGuard.FindNodeOverrideViolations(
|
||||
_form.DriverConfigOverridesJson, sqlDriverIds);
|
||||
if (offenders.Count > 0)
|
||||
{
|
||||
_error =
|
||||
$"Driver config overrides carry a '{SqlCredentialGuard.ForbiddenKey}' for Sql driver " +
|
||||
$"instance(s) {string.Join(", ", offenders)}. A Sql driver must name its credentials " +
|
||||
"indirectly via 'connectionStringRef', which resolves from the environment / secret " +
|
||||
"store at Initialize; a literal connection string here would be stored in the config " +
|
||||
"database, and the runtime ignores it anyway. Remove the key.";
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (IsNew)
|
||||
{
|
||||
if (await db.ClusterNodes.AnyAsync(n => n.NodeId == _form.NodeId))
|
||||
@@ -194,7 +227,7 @@ else
|
||||
OpcUaPort = _form.OpcUaPort,
|
||||
DashboardPort = _form.DashboardPort,
|
||||
ApplicationUri = _form.ApplicationUri,
|
||||
ServiceLevelBase = _form.ServiceLevelBase,
|
||||
ServiceLevelBase = (byte)_form.ServiceLevelBase,
|
||||
Enabled = _form.Enabled,
|
||||
DriverConfigOverridesJson = string.IsNullOrWhiteSpace(_form.DriverConfigOverridesJson) ? null : _form.DriverConfigOverridesJson,
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
@@ -210,7 +243,7 @@ else
|
||||
entity.OpcUaPort = _form.OpcUaPort;
|
||||
entity.DashboardPort = _form.DashboardPort;
|
||||
entity.ApplicationUri = _form.ApplicationUri;
|
||||
entity.ServiceLevelBase = _form.ServiceLevelBase;
|
||||
entity.ServiceLevelBase = (byte)_form.ServiceLevelBase;
|
||||
entity.Enabled = _form.Enabled;
|
||||
entity.DriverConfigOverridesJson = string.IsNullOrWhiteSpace(_form.DriverConfigOverridesJson) ? null : _form.DriverConfigOverridesJson;
|
||||
}
|
||||
@@ -254,14 +287,26 @@ else
|
||||
|
||||
private sealed class FormModel
|
||||
{
|
||||
[Required, RegularExpression("^[A-Za-z0-9_-]+$")]
|
||||
// The ':' and '.' are REQUIRED, not permissive. Every NodeId in this system is "host:port" —
|
||||
// ClusterRoleInfo and ConfigPublishCoordinator derive it as member.Address.Host:Port, the seed
|
||||
// writes "central-1:4053", and NodeDeploymentState.NodeId is FK-bound to it. The previous
|
||||
// pattern forbade the colon, so EVERY existing node failed validation and OnValidSubmit never
|
||||
// ran: the Save button did nothing at all, silently. Hostnames may also be dotted
|
||||
// (line3-opc-a.plant.local:4053).
|
||||
[Required, RegularExpression("^[A-Za-z0-9_.:-]+$")]
|
||||
public string NodeId { get; set; } = "";
|
||||
[Required] public string Host { get; set; } = "";
|
||||
[Range(1, 65535)] public int OpcUaPort { get; set; } = 4840;
|
||||
[Range(1, 65535)] public int DashboardPort { get; set; } = 8081;
|
||||
[Required, RegularExpression("^urn:[A-Za-z0-9_:./-]+$")]
|
||||
public string ApplicationUri { get; set; } = "";
|
||||
[Range(0, 255)] public byte ServiceLevelBase { get; set; } = 200;
|
||||
// int, NOT byte. Blazor's InputNumber<T> rejects System.Byte outright — its static
|
||||
// constructor throws "The type 'System.Byte' is not a supported numeric type", which escapes
|
||||
// BuildRenderTree unhandled and takes the WHOLE page to an HTTP 500. Binding this field to
|
||||
// the entity's own byte type meant /clusters/{id}/nodes/{nodeId} and .../nodes/new never
|
||||
// rendered at all. The Range attribute still holds it to a byte's domain, and SubmitAsync
|
||||
// casts on the way to the entity, so the stored type is unchanged.
|
||||
[Range(0, 255)] public int ServiceLevelBase { get; set; } = 200;
|
||||
public bool Enabled { get; set; } = true;
|
||||
public string? DriverConfigOverridesJson { get; set; }
|
||||
}
|
||||
|
||||
@@ -55,7 +55,7 @@
|
||||
{
|
||||
<tr>
|
||||
<td><code>@Short(d.DeploymentId)</code></td>
|
||||
<td><code>@d.RevisionHash[..12]…</code></td>
|
||||
<td><code>@DisplayText.Abbreviate(d.RevisionHash, 12)</code></td>
|
||||
<td>@d.Status</td>
|
||||
<td>@d.CreatedBy</td>
|
||||
<td>@d.CreatedAtUtc.ToString("u")</td>
|
||||
@@ -113,7 +113,7 @@
|
||||
_lastSuccess = result.Outcome == StartDeploymentOutcome.Accepted;
|
||||
_lastMessage = result.Outcome switch
|
||||
{
|
||||
StartDeploymentOutcome.Accepted => $"Deployment {Short(result.DeploymentId!.Value.Value)} dispatched (rev {result.RevisionHash!.Value.Value[..12]}…).",
|
||||
StartDeploymentOutcome.Accepted => $"Deployment {Short(result.DeploymentId!.Value.Value)} dispatched (rev {DisplayText.Abbreviate(result.RevisionHash!.Value.Value, 12)}).",
|
||||
StartDeploymentOutcome.AnotherDeploymentInFlight => result.Message ?? "Another deployment is already in flight.",
|
||||
StartDeploymentOutcome.NoChanges => "No changes detected since the last sealed deployment.",
|
||||
_ => result.Message ?? "Deployment rejected.",
|
||||
|
||||
@@ -38,7 +38,7 @@ else
|
||||
<span class="mono">@s.ScriptId</span>
|
||||
· <span>@s.Name</span>
|
||||
· <span class="chip chip-idle ms-1">@s.Language</span>
|
||||
<span class="text-muted small ms-2 mono">hash=@s.SourceHash[..12]…</span>
|
||||
<span class="text-muted small ms-2 mono">hash=@DisplayText.Abbreviate(s.SourceHash, 12)</span>
|
||||
</summary>
|
||||
<div style="padding:0 1rem 1rem">
|
||||
<div class="d-flex mb-2">
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared;
|
||||
|
||||
/// <summary>
|
||||
/// Rendering helpers for operator-facing text that comes out of the database.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>Why this exists (#504).</b> Several pages abbreviated a hash / thumbprint with the range
|
||||
/// operator — <c>@s.SourceHash[..12]…</c> — which throws
|
||||
/// <see cref="ArgumentOutOfRangeException"/> the moment the value is shorter than the slice.
|
||||
/// In Blazor that escapes <c>BuildRenderTree</c> unhandled and takes the <b>whole page</b> to an
|
||||
/// HTTP 500, not just the offending row. Nothing enforces a minimum length at the schema, the
|
||||
/// entity, or the render layer, so any hand-written / seeded / migrated row is a live page-kill.
|
||||
/// The docker-dev seed proved it: its placeholder <c>SourceHash</c> values (<c>h1</c>,
|
||||
/// <c>h-abs-hr200</c>) made <c>/scripts</c> a hard 500 on every stock bring-up.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Use <see cref="Abbreviate"/> for <b>every</b> such prefix. It never throws, and it appends the
|
||||
/// ellipsis only when it actually truncated — a 2-character hash renders as <c>h1</c>, not
|
||||
/// <c>h1…</c>, so the display never implies more text than exists.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public static class DisplayText
|
||||
{
|
||||
/// <summary>Placeholder rendered for a null / blank value. Matches the em-dash convention the
|
||||
/// Admin UI already uses for "nothing to show" cells.</summary>
|
||||
public const string Empty = "—";
|
||||
|
||||
/// <summary>
|
||||
/// The first <paramref name="maxLength"/> characters of <paramref name="value"/>, followed by an
|
||||
/// ellipsis when (and only when) characters were dropped.
|
||||
/// </summary>
|
||||
/// <param name="value">The value to abbreviate. May be null, blank, or shorter than
|
||||
/// <paramref name="maxLength"/> — none of which throw.</param>
|
||||
/// <param name="maxLength">Maximum number of characters to keep. Values below 1 are treated as 1,
|
||||
/// so a caller cannot turn a display bug into a crash.</param>
|
||||
/// <returns><see cref="Empty"/> for a null/blank value; the value verbatim when it already fits;
|
||||
/// otherwise the truncated prefix plus an ellipsis.</returns>
|
||||
public static string Abbreviate(string? value, int maxLength)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value)) return Empty;
|
||||
if (maxLength < 1) maxLength = 1;
|
||||
return value.Length <= maxLength ? value : string.Concat(value.AsSpan(0, maxLength), "…");
|
||||
}
|
||||
}
|
||||
@@ -57,6 +57,9 @@
|
||||
case DriverTypeNames.Galaxy:
|
||||
<GalaxyDriverForm @bind-DriverConfigJson="_driverConfigJson" @bind-ResilienceConfig="_resilienceConfig" />
|
||||
break;
|
||||
case DriverTypeNames.Sql:
|
||||
<SqlDriverForm @bind-DriverConfigJson="_driverConfigJson" @bind-ResilienceConfig="_resilienceConfig" />
|
||||
break;
|
||||
default:
|
||||
<div class="alert alert-warning">No typed config form for driver type <span class="mono">@_driverType</span>.</div>
|
||||
break;
|
||||
|
||||
+1
@@ -36,6 +36,7 @@
|
||||
<option value="Focas">Focas</option>
|
||||
<option value="OpcUaClient">OpcUaClient</option>
|
||||
<option value="GalaxyMxGateway">Galaxy</option>
|
||||
<option value="Sql">Sql</option>
|
||||
</InputSelect>
|
||||
<div class="form-text">Cannot be changed after creation — drives the actor type that owns this instance.</div>
|
||||
</div>
|
||||
|
||||
+15
@@ -27,6 +27,16 @@
|
||||
}
|
||||
</InputSelect>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">Transport</label>
|
||||
<InputSelect @bind-Value="_form.Transport" @bind-Value:after="EmitAsync" class="form-select form-select-sm">
|
||||
@foreach (var e in Enum.GetValues<ModbusTransportMode>())
|
||||
{
|
||||
<option value="@e">@e</option>
|
||||
}
|
||||
</InputSelect>
|
||||
<div class="form-text">RtuOverTcp = talk raw RTU frames to a serial→Ethernet gateway; must match the gateway's mode.</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">MELSEC sub-family</label>
|
||||
<InputSelect @bind-Value="_form.MelsecSubFamily" @bind-Value:after="EmitAsync" class="form-select form-select-sm">
|
||||
@@ -290,6 +300,9 @@
|
||||
public ModbusFamily Family { get; set; } = ModbusFamily.Generic;
|
||||
public MelsecFamily MelsecSubFamily { get; set; } = MelsecFamily.Q_L_iQR;
|
||||
|
||||
// Wire transport (Tcp = Modbus/TCP MBAP; RtuOverTcp = RTU framing over a serial→Ethernet gateway)
|
||||
public ModbusTransportMode Transport { get; set; } = ModbusTransportMode.Tcp;
|
||||
|
||||
// Transport flags
|
||||
public bool AutoReconnect { get; set; } = true;
|
||||
public int IdleDisconnectTimeoutSeconds { get; set; } = 0;
|
||||
@@ -337,6 +350,7 @@
|
||||
TimeoutSeconds = (int)o.Timeout.TotalSeconds,
|
||||
Family = o.Family,
|
||||
MelsecSubFamily = o.MelsecSubFamily,
|
||||
Transport = o.Transport,
|
||||
AutoReconnect = o.AutoReconnect,
|
||||
IdleDisconnectTimeoutSeconds = o.IdleDisconnectTimeout.HasValue ? (int)o.IdleDisconnectTimeout.Value.TotalSeconds : 0,
|
||||
MaxRegistersPerRead = o.MaxRegistersPerRead,
|
||||
@@ -387,6 +401,7 @@
|
||||
MaxReadGap = (ushort)Math.Clamp(MaxReadGap, 0, 65535),
|
||||
Family = Family,
|
||||
MelsecSubFamily = MelsecSubFamily,
|
||||
Transport = Transport,
|
||||
WriteOnChangeOnly = WriteOnChangeOnly,
|
||||
AutoReconnect = AutoReconnect,
|
||||
KeepAlive = new ModbusKeepAliveOptions
|
||||
|
||||
+198
@@ -0,0 +1,198 @@
|
||||
@* Embeddable read-only Sql driver (connection/dialect) config form body. There is NO per-device
|
||||
endpoint — the whole database connection is named indirectly by ConnectionStringRef (an env-var name,
|
||||
resolved at Initialize), so no connection string is ever authored into the config. Hosted by
|
||||
DriverConfigModal. Exposes GetConfigJson() for the parent to pull at save time and fires
|
||||
DriverConfigJsonChanged for @bind support. Enums serialize by NAME (JsonStringEnumConverter) so the
|
||||
factory's string-typed deserialize accepts them. *@
|
||||
@using System.Text.Json
|
||||
@using System.Text.Json.Nodes
|
||||
@using System.Text.Json.Serialization
|
||||
@using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers
|
||||
@using ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts
|
||||
|
||||
@* Connection *@
|
||||
<section class="panel rise mt-3" style="animation-delay:.06s">
|
||||
<div class="panel-head">Database connection</div>
|
||||
<div style="padding:1rem">
|
||||
<div class="row g-3">
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">Provider</label>
|
||||
<InputSelect @bind-Value="_form.Provider" @bind-Value:after="EmitAsync" class="form-select form-select-sm">
|
||||
@* v1 constructs only SqlServer; the other SqlProvider members are reserved (P2). *@
|
||||
<option value="@SqlProvider.SqlServer">@SqlProvider.SqlServer</option>
|
||||
</InputSelect>
|
||||
<div class="form-text">Only Microsoft SQL Server is supported in v1.</div>
|
||||
</div>
|
||||
<div class="col-md-8">
|
||||
<label class="form-label">Connection string ref <span class="text-danger">*</span></label>
|
||||
<InputText @bind-Value="_form.ConnectionStringRef" @bind-Value:after="EmitAsync" class="form-control form-control-sm mono"
|
||||
placeholder="MesStaging" />
|
||||
<div class="form-text">
|
||||
Name of the <code>Sql__ConnectionStrings__<ref></code> env var set on the driver + AdminUI hosts —
|
||||
<strong>not</strong> a connection string. The credential is resolved at startup and never stored in the config.
|
||||
</div>
|
||||
@if (string.IsNullOrWhiteSpace(_form.ConnectionStringRef))
|
||||
{
|
||||
<div class="text-danger small mt-1">Connection string ref is required.</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@* Polling / timeouts *@
|
||||
<section class="panel rise mt-3" style="animation-delay:.08s">
|
||||
<div class="panel-head">Polling & timeouts</div>
|
||||
<div style="padding:1rem">
|
||||
<div class="row g-3">
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">Default poll interval (s)</label>
|
||||
<InputNumber @bind-Value="_form.DefaultPollIntervalSeconds" @bind-Value:after="EmitAsync" class="form-control form-control-sm" />
|
||||
<div class="form-text">Blank = factory default. Applied to groups without their own interval.</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">Operation timeout (s)</label>
|
||||
<InputNumber @bind-Value="_form.OperationTimeoutSeconds" @bind-Value:after="EmitAsync" class="form-control form-control-sm" />
|
||||
<div class="form-text">Client-side per-query deadline. Should exceed the command timeout.</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">Command timeout (s)</label>
|
||||
<InputNumber @bind-Value="_form.CommandTimeoutSeconds" @bind-Value:after="EmitAsync" class="form-control form-control-sm" />
|
||||
<div class="form-text">ADO.NET server-side CommandTimeout backstop.</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">Max concurrent groups</label>
|
||||
<InputNumber @bind-Value="_form.MaxConcurrentGroups" @bind-Value:after="EmitAsync" class="form-control form-control-sm" />
|
||||
<div class="form-text">Blank = factory default. Caps concurrent group queries (pool guard).</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@* Value handling *@
|
||||
<section class="panel rise mt-3" style="animation-delay:.10s">
|
||||
<div class="panel-head">Value handling</div>
|
||||
<div style="padding:1rem">
|
||||
<div class="row g-3">
|
||||
<div class="col-md-4">
|
||||
<div class="form-check form-switch mt-2">
|
||||
<InputCheckbox @bind-Value="_form.NullIsBad" @bind-Value:after="EmitAsync" class="form-check-input" id="sqlNullIsBad" />
|
||||
<label class="form-check-label" for="sqlNullIsBad">NULL cell publishes Bad</label>
|
||||
</div>
|
||||
<div class="form-text mt-0">Unchecked = factory default (a NULL cell publishes Uncertain).</div>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<div class="form-check form-switch mt-2">
|
||||
<input type="checkbox" class="form-check-input" id="sqlAllowWrites" disabled checked="false" />
|
||||
<label class="form-check-label" for="sqlAllowWrites">Allow writes</label>
|
||||
</div>
|
||||
<div class="form-text mt-0">
|
||||
<span class="badge bg-secondary">Read-only</span> v1 does not implement writes — this flag is inert and is not authored.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<DriverResilienceSection ResilienceConfig="@ResilienceConfig" ResilienceConfigChanged="OnResilienceChanged" />
|
||||
|
||||
@code {
|
||||
/// <summary>The driver-level DriverConfig JSON (connection/dialect; there is no device endpoint).</summary>
|
||||
[Parameter] public string DriverConfigJson { get; set; } = "{}";
|
||||
/// <summary>Fired (camelCase-serialized) whenever a field changes — enables @bind-DriverConfigJson.</summary>
|
||||
[Parameter] public EventCallback<string> DriverConfigJsonChanged { get; set; }
|
||||
/// <summary>The per-instance resilience-pipeline overrides JSON, or null.</summary>
|
||||
[Parameter] public string? ResilienceConfig { get; set; }
|
||||
/// <summary>Fired when the resilience overrides change.</summary>
|
||||
[Parameter] public EventCallback<string?> ResilienceConfigChanged { get; set; }
|
||||
|
||||
// camelCase + string enums + omit-nulls, matching the factory's JsonStringEnumConverter deserialize.
|
||||
// WhenWritingNull keeps absent optionals out of the blob (absent ⇒ the factory applies its default) and
|
||||
// drops the composer-owned rawTags (never authored here).
|
||||
private static readonly JsonSerializerOptions _jsonOpts = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||
WriteIndented = false,
|
||||
Converters = { new JsonStringEnumConverter() },
|
||||
};
|
||||
|
||||
private FormModel _form = new();
|
||||
private string? _lastParsed;
|
||||
|
||||
protected override void OnParametersSet()
|
||||
{
|
||||
// Re-parse only when the inbound value actually changed (don't clobber in-progress edits).
|
||||
if (!string.Equals(_lastParsed, DriverConfigJson, StringComparison.Ordinal))
|
||||
{
|
||||
var dto = TryDeserialize(DriverConfigJson) ?? new SqlDriverConfigDto();
|
||||
_form = FormModel.FromDto(dto);
|
||||
_lastParsed = DriverConfigJson;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Serializes the current connection/dialect config to camelCase JSON with enums as NAME strings.
|
||||
/// rawTags is never emitted (the composer adds it at deploy); allowWrites is never emitted (v1 read-only).</summary>
|
||||
public string GetConfigJson() => JsonSerializer.Serialize(_form.ToDto(), _jsonOpts);
|
||||
|
||||
private async Task EmitAsync()
|
||||
{
|
||||
var json = GetConfigJson();
|
||||
_lastParsed = json;
|
||||
await DriverConfigJsonChanged.InvokeAsync(json);
|
||||
}
|
||||
|
||||
private async Task OnResilienceChanged(string? r)
|
||||
{
|
||||
ResilienceConfig = r;
|
||||
await ResilienceConfigChanged.InvokeAsync(r);
|
||||
}
|
||||
|
||||
private static SqlDriverConfigDto? TryDeserialize(string json)
|
||||
{
|
||||
try { return JsonSerializer.Deserialize<SqlDriverConfigDto>(json, _jsonOpts); }
|
||||
catch { return null; }
|
||||
}
|
||||
|
||||
/// <summary>Flat mutable mirror of the driver-level fields of <see cref="SqlDriverConfigDto"/>. TimeSpans are
|
||||
/// edited as nullable whole-second ints (blank ⇒ null ⇒ the factory default). rawTags + allowWrites are not
|
||||
/// mirrored — the composer owns rawTags and v1 is read-only.</summary>
|
||||
public sealed class FormModel
|
||||
{
|
||||
public SqlProvider Provider { get; set; } = SqlProvider.SqlServer;
|
||||
public string? ConnectionStringRef { get; set; }
|
||||
public int? DefaultPollIntervalSeconds { get; set; }
|
||||
public int? OperationTimeoutSeconds { get; set; }
|
||||
public int? CommandTimeoutSeconds { get; set; }
|
||||
public int? MaxConcurrentGroups { get; set; }
|
||||
public bool NullIsBad { get; set; }
|
||||
|
||||
public static FormModel FromDto(SqlDriverConfigDto d) => new()
|
||||
{
|
||||
Provider = d.Provider,
|
||||
ConnectionStringRef = d.ConnectionStringRef,
|
||||
DefaultPollIntervalSeconds = ToSeconds(d.DefaultPollInterval),
|
||||
OperationTimeoutSeconds = ToSeconds(d.OperationTimeout),
|
||||
CommandTimeoutSeconds = ToSeconds(d.CommandTimeout),
|
||||
MaxConcurrentGroups = d.MaxConcurrentGroups,
|
||||
NullIsBad = d.NullIsBad ?? false,
|
||||
};
|
||||
|
||||
public SqlDriverConfigDto ToDto() => new()
|
||||
{
|
||||
Provider = Provider,
|
||||
ConnectionStringRef = string.IsNullOrWhiteSpace(ConnectionStringRef) ? null : ConnectionStringRef.Trim(),
|
||||
DefaultPollInterval = ToTimeSpan(DefaultPollIntervalSeconds),
|
||||
OperationTimeout = ToTimeSpan(OperationTimeoutSeconds),
|
||||
CommandTimeout = ToTimeSpan(CommandTimeoutSeconds),
|
||||
MaxConcurrentGroups = MaxConcurrentGroups,
|
||||
// Emit nullIsBad only when true; unchecked ⇒ null ⇒ the factory default (Uncertain). allowWrites
|
||||
// and rawTags are deliberately left null so they are omitted from the authored blob.
|
||||
NullIsBad = NullIsBad ? true : null,
|
||||
};
|
||||
|
||||
private static int? ToSeconds(TimeSpan? ts) => ts.HasValue ? (int)ts.Value.TotalSeconds : null;
|
||||
private static TimeSpan? ToTimeSpan(int? secs) => secs.HasValue ? TimeSpan.FromSeconds(secs.Value) : null;
|
||||
}
|
||||
}
|
||||
@@ -68,6 +68,7 @@
|
||||
("FOCAS", DriverTypeNames.FOCAS),
|
||||
("OpcUaClient", DriverTypeNames.OpcUaClient),
|
||||
("Galaxy", DriverTypeNames.Galaxy),
|
||||
("Sql", DriverTypeNames.Sql),
|
||||
("Calculation", "Calculation"),
|
||||
];
|
||||
|
||||
|
||||
+185
-9
@@ -1,12 +1,90 @@
|
||||
@* Typed TagConfig editor for the read-only Sql driver. MINIMAL PLACEHOLDER (Task 19) — this shell only
|
||||
wires the standard editor parameter contract and round-trips the config through SqlTagConfigModel so no
|
||||
edit is lost while the full UI is pending. Task 20 replaces the body with the model/table/column fields
|
||||
(KeyValue vs WideRow row-selector). Dispatched from the TagModal via TagConfigEditorMap, so it takes the
|
||||
same (ConfigJson/ConfigJsonChanged/DriverType/GetDriverConfigJson) parameter shape every typed editor
|
||||
takes; DriverType/GetDriverConfigJson are accepted for dispatch uniformity. *@
|
||||
@* Typed TagConfig editor for the read-only Sql driver. Thin shell over SqlTagConfigModel: every field edit
|
||||
goes model → ToJson → ConfigJsonChanged (the model is the single source of the blob shape + enum-name
|
||||
serialisation; never hand-composed here). The Model dropdown (KeyValue/WideRow) shows the matching field
|
||||
group; the "Build address" picker is the ASSISTED path — every field is also hand-editable. Dispatched
|
||||
from the TagModal via TagConfigEditorMap with the same (ConfigJson/ConfigJsonChanged/DriverType/
|
||||
GetDriverConfigJson) parameter shape every typed editor takes; validation is surfaced centrally by
|
||||
TagConfigValidator (save-gate), exactly as the sibling editors do — no inline validation here. *@
|
||||
@using ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors
|
||||
@using ZB.MOM.WW.OtOpcUa.Core.Abstractions
|
||||
@using ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts
|
||||
@using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers
|
||||
@using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers.Pickers
|
||||
|
||||
<div class="text-muted small">Sql tag editor — coming soon (Task 20).</div>
|
||||
<div class="row g-2">
|
||||
<div class="col-md-4"><label class="form-label">Model</label>
|
||||
<select class="form-select form-select-sm" value="@_m.Model" @onchange="@(e => Update(() => _m.Model = ParseEnum(e.Value, SqlTagModel.KeyValue)))">
|
||||
@* Query is deferred (P3) and rejected by the parser/validator — only offer the two v1 models. *@
|
||||
<option value="@SqlTagModel.KeyValue">KeyValue</option>
|
||||
<option value="@SqlTagModel.WideRow">WideRow</option>
|
||||
</select></div>
|
||||
<div class="col-md-5"><label class="form-label">Table or view</label>
|
||||
<input class="form-control form-control-sm mono" value="@_m.Table" placeholder="dbo.TagValues" @onchange="@(e => Update(() => _m.Table = NullIfBlank(e.Value)))" /></div>
|
||||
<div class="col-md-3"><label class="form-label">Type override</label>
|
||||
<select class="form-select form-select-sm" value="@(_m.Type?.ToString() ?? "")" @onchange="@(e => Update(() => _m.Type = ParseNullableEnum<DriverDataType>(e.Value)))">
|
||||
<option value="">(infer from column)</option>
|
||||
@foreach (var v in Enum.GetValues<DriverDataType>()) { <option value="@v">@v</option> }
|
||||
</select></div>
|
||||
</div>
|
||||
|
||||
@if (_m.Model == SqlTagModel.KeyValue)
|
||||
{
|
||||
<div class="row g-2 mt-1">
|
||||
<div class="col-md-4"><label class="form-label">Key column</label>
|
||||
<input class="form-control form-control-sm mono" value="@_m.KeyColumn" placeholder="tag_name" @onchange="@(e => Update(() => _m.KeyColumn = NullIfBlank(e.Value)))" /></div>
|
||||
<div class="col-md-4"><label class="form-label">Key value</label>
|
||||
<input class="form-control form-control-sm mono" value="@_m.KeyValue" placeholder="Line1.Speed" @onchange="@(e => Update(() => _m.KeyValue = e.Value?.ToString()))" />
|
||||
<div class="form-text">Bound as a parameter — never interpolated.</div></div>
|
||||
<div class="col-md-4"><label class="form-label">Value column</label>
|
||||
<input class="form-control form-control-sm mono" value="@_m.ValueColumn" placeholder="num_value" @onchange="@(e => Update(() => _m.ValueColumn = NullIfBlank(e.Value)))" /></div>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="row g-2 mt-1">
|
||||
<div class="col-md-4"><label class="form-label">Value column</label>
|
||||
<input class="form-control form-control-sm mono" value="@_m.ColumnName" placeholder="oven_temp" @onchange="@(e => Update(() => _m.ColumnName = NullIfBlank(e.Value)))" />
|
||||
<div class="form-text">The column this tag reads from the selected row.</div></div>
|
||||
<div class="col-md-4"><label class="form-label">Row selector</label>
|
||||
<select class="form-select form-select-sm" value="@_selectorMode" @onchange="@(e => OnSelectorModeChanged(e.Value?.ToString()))">
|
||||
<option value="Where">Where column = value</option>
|
||||
<option value="Newest">Newest by timestamp</option>
|
||||
</select></div>
|
||||
@if (_selectorMode == "Where")
|
||||
{
|
||||
<div class="col-md-2"><label class="form-label">Where column</label>
|
||||
<input class="form-control form-control-sm mono" value="@_m.RowSelector.WhereColumn" placeholder="station_id" @onchange="@(e => Update(() => _m.RowSelector.WhereColumn = NullIfBlank(e.Value)))" /></div>
|
||||
<div class="col-md-2"><label class="form-label">Where value</label>
|
||||
<input class="form-control form-control-sm mono" value="@_m.RowSelector.WhereValue" placeholder="7" @onchange="@(e => Update(() => _m.RowSelector.WhereValue = e.Value?.ToString()))" /></div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="col-md-4"><label class="form-label">Order-by (timestamp) column</label>
|
||||
<input class="form-control form-control-sm mono" value="@_m.RowSelector.TopByTimestamp" placeholder="sample_ts" @onchange="@(e => Update(() => _m.RowSelector.TopByTimestamp = NullIfBlank(e.Value)))" /></div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="row g-2 mt-1">
|
||||
<div class="col-md-6"><label class="form-label">Timestamp column <span class="text-muted small">(optional)</span></label>
|
||||
<input class="form-control form-control-sm mono" value="@_m.TimestampColumn" placeholder="sample_ts" @onchange="@(e => Update(() => _m.TimestampColumn = NullIfBlank(e.Value)))" />
|
||||
<div class="form-text">Source-timestamp column; blank ⇒ the driver stamps the poll time.</div></div>
|
||||
<div class="col-12 mt-1">
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary" @onclick="@(() => _showPicker = true)">Build address</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (_showPicker)
|
||||
{
|
||||
<DriverTagPicker @bind-Visible="_showPicker"
|
||||
Title="Sql tag builder"
|
||||
CurrentAddress="@_pickerAddress"
|
||||
OnPickAddress="@OnAddressPicked">
|
||||
<SqlAddressPickerBody CurrentAddress="@_pickerAddress"
|
||||
CurrentAddressChanged="@((s) => _pickerAddress = s)"
|
||||
GetConfigJson="@GetDriverConfigJson" />
|
||||
</DriverTagPicker>
|
||||
}
|
||||
|
||||
@code {
|
||||
/// <summary>The tag's current TagConfig JSON.</summary>
|
||||
@@ -23,13 +101,111 @@
|
||||
|
||||
private SqlTagConfigModel _m = new();
|
||||
private string? _lastConfigJson;
|
||||
private bool _showPicker;
|
||||
private string _pickerAddress = "";
|
||||
|
||||
// Re-parse only when the incoming JSON actually changes, so an unrelated parent re-render can't reset
|
||||
// an in-progress edit (mirrors the other typed editors).
|
||||
// Local UI discriminator for the WideRow row selector (where-pair vs newest-by-timestamp). The model
|
||||
// carries no explicit selector "mode" — it is inferred from which selector fields are populated. Kept in
|
||||
// the editor (not the model) because an operator mid-edit may have BOTH selector fields blank, a state
|
||||
// the mode can't be re-derived from; DeriveSelectorMode only overrides it when the model unambiguously
|
||||
// says otherwise, so switching to "Newest" then round-tripping doesn't snap back to "Where".
|
||||
private string _selectorMode = "Where";
|
||||
|
||||
// Re-parse only when the incoming JSON actually changes, so an unrelated parent re-render (Blazor Server
|
||||
// live-status pushes do this) can't reset the user's in-progress edits (mirrors the other typed editors).
|
||||
protected override void OnParametersSet()
|
||||
{
|
||||
if (ConfigJson == _lastConfigJson) { return; }
|
||||
_lastConfigJson = ConfigJson;
|
||||
_m = SqlTagConfigModel.FromJson(ConfigJson);
|
||||
DeriveSelectorMode();
|
||||
}
|
||||
|
||||
// Sets the row-selector mode from the model ONLY when the populated fields make it unambiguous; leaves
|
||||
// the operator's current choice untouched when both selector fields are blank (an in-progress edit).
|
||||
private void DeriveSelectorMode()
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(_m.RowSelector.TopByTimestamp) && string.IsNullOrWhiteSpace(_m.RowSelector.WhereColumn))
|
||||
{
|
||||
_selectorMode = "Newest";
|
||||
}
|
||||
else if (!string.IsNullOrWhiteSpace(_m.RowSelector.WhereColumn))
|
||||
{
|
||||
_selectorMode = "Where";
|
||||
}
|
||||
}
|
||||
|
||||
// TryParse so a bad/empty change value can never throw into the Blazor circuit — it falls back. Reads by
|
||||
// NAME (never an ordinal cast), matching the model's strict name-string serialisation.
|
||||
private static TEnum ParseEnum<TEnum>(object? v, TEnum fallback) where TEnum : struct, Enum
|
||||
=> Enum.TryParse<TEnum>(v?.ToString(), out var r) ? r : fallback;
|
||||
|
||||
// "" (the "(infer)" option) ⇒ null; a parseable NAME ⇒ that type; anything else ⇒ null. Enum round-trip
|
||||
// is by name — never an ordinal cast — so the model re-serialises `type` as its strict name string.
|
||||
private static TEnum? ParseNullableEnum<TEnum>(object? v) where TEnum : struct, Enum
|
||||
{
|
||||
var s = v?.ToString();
|
||||
if (string.IsNullOrEmpty(s)) { return null; }
|
||||
return Enum.TryParse<TEnum>(s, out var r) ? r : null;
|
||||
}
|
||||
|
||||
// Identifier fields (table/column names): a blank input means "absent" — store null so ToJson omits the
|
||||
// key. (KeyValue/WhereValue are deliberately NOT run through this: the model treats an empty-string value
|
||||
// as present-and-empty, distinct from absent, so those keep e.Value verbatim.)
|
||||
private static string? NullIfBlank(object? v)
|
||||
{
|
||||
var s = v?.ToString();
|
||||
return string.IsNullOrWhiteSpace(s) ? null : s;
|
||||
}
|
||||
|
||||
private async Task Update(Action apply)
|
||||
{
|
||||
apply();
|
||||
await ConfigJsonChanged.InvokeAsync(_m.ToJson());
|
||||
}
|
||||
|
||||
// The WideRow selector is either a where-pair OR newest-by-timestamp — never both. Clear the fields of
|
||||
// the mode being left so the composed blob carries exactly one selector (a stale where-pair would win
|
||||
// over topByTimestamp at read time, silently overriding the operator's choice).
|
||||
private async Task OnSelectorModeChanged(string? mode)
|
||||
{
|
||||
_selectorMode = mode == "Newest" ? "Newest" : "Where";
|
||||
await Update(() =>
|
||||
{
|
||||
if (_selectorMode == "Newest")
|
||||
{
|
||||
_m.RowSelector.WhereColumn = null;
|
||||
_m.RowSelector.WhereValue = null;
|
||||
}
|
||||
else
|
||||
{
|
||||
_m.RowSelector.TopByTimestamp = null;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// The Sql picker emits a COMPLETE parser-shaped TagConfig blob (not a single address token like the
|
||||
// other drivers' pickers). Parse it through the model and adopt its mapping fields onto the working
|
||||
// model — mutating _m in place preserves any platform keys already on the tag (e.g. isHistorized) that
|
||||
// the picker never authors.
|
||||
private async Task OnAddressPicked(string blob)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(blob)) { return; }
|
||||
var picked = SqlTagConfigModel.FromJson(blob);
|
||||
await Update(() =>
|
||||
{
|
||||
_m.Model = picked.Model;
|
||||
_m.Table = picked.Table;
|
||||
_m.KeyColumn = picked.KeyColumn;
|
||||
_m.KeyValue = picked.KeyValue;
|
||||
_m.ValueColumn = picked.ValueColumn;
|
||||
_m.ColumnName = picked.ColumnName;
|
||||
_m.TimestampColumn = picked.TimestampColumn;
|
||||
_m.Type = picked.Type;
|
||||
_m.RowSelector.WhereColumn = picked.RowSelector.WhereColumn;
|
||||
_m.RowSelector.WhereValue = picked.RowSelector.WhereValue;
|
||||
_m.RowSelector.TopByTimestamp = picked.RowSelector.TopByTimestamp;
|
||||
});
|
||||
DeriveSelectorMode();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.Types;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.AdminUI.ScriptAnalysis;
|
||||
@@ -40,41 +41,42 @@ public sealed record ScriptTagInfo(string Path, string Kind, string DataType, st
|
||||
|
||||
/// <summary>
|
||||
/// Default <see cref="IScriptTagCatalog"/>. Returns ONLY the resolvable keys a script may pass to
|
||||
/// <c>ctx.GetTag</c> / <c>ctx.SetVirtualTag</c> — the driver <c>FullName</c> for equipment tags,
|
||||
/// the MXAccess dot-ref for FolderPath-scoped tags (<c>EquipmentId == null</c>), and the leaf
|
||||
/// <c>Name</c> (best-effort) for virtual tags.
|
||||
/// <c>ctx.GetTag</c> / <c>ctx.SetVirtualTag</c> — in v3 that is a raw tag's <b>RawPath</b>, plus the
|
||||
/// leaf <c>Name</c> (best-effort) for virtual tags.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>Fidelity over breadth.</b> Verified: the live runtime resolves a <c>ctx.GetTag("X")</c>
|
||||
/// literal against the driver <c>FullName</c> — the resolution chain is
|
||||
/// <c>AddressSpaceComposer</c> (via <c>EquipmentScriptPaths.ExtractDependencyRefs</c>) harvesting the <c>ctx.GetTag("…")</c> literals
|
||||
/// into <c>EquipmentVirtualTagPlan.DependencyRefs</c>
|
||||
/// (<c>src/Server/…OpcUaServer/AddressSpaceComposer.cs</c>); those become
|
||||
/// <c>VirtualTagActor._dependencyRefs</c>, registered with the
|
||||
/// <c>DependencyMuxActor</c>, whose <c>_byRef</c> map is keyed by
|
||||
/// <c>DriverInstanceActor.AttributeValuePublished.FullReference</c>
|
||||
/// (<c>src/Server/…Runtime/VirtualTags/DependencyMuxActor.cs:97</c>) — and that
|
||||
/// <c>FullReference</c> is the <c>FullName</c> field extracted from <c>Tag.TagConfig</c>
|
||||
/// (see <c>Commons.Types.TagConfigIntent.Parse</c>, the shared byte-parity FullName authority).
|
||||
/// The UNS-path engine (<c>Core.VirtualTags.VirtualTagEngine</c>, keyed by a slash-joined
|
||||
/// <c>Enterprise/Site/Area/Line/Equipment/TagName</c>) is dormant — it is NOT wired into the
|
||||
/// host — so UNS browse paths never resolve at runtime and are intentionally NOT suggested.
|
||||
/// <b>Fidelity over breadth.</b> This projects exactly what the live runtime resolves, and nothing
|
||||
/// else. Verified chain: <c>AddressSpaceComposer</c> harvests the <c>ctx.GetTag("…")</c> literals
|
||||
/// (via <c>EquipmentScriptPaths.ExtractDependencyRefs</c>) into <c>DependencyRefs</c>; those become
|
||||
/// <c>VirtualTagActor._dependencyRefs</c>, registered with <c>DependencyMuxActor</c>, whose
|
||||
/// <c>_byRef</c> map is keyed (Ordinal) by <c>DriverInstanceActor.AttributeValuePublished.FullReference</c>
|
||||
/// — and in v3 a driver's wire reference <b>is the RawPath</b>
|
||||
/// (<c>DriverHostActor._nodeIdByDriverRef</c> is keyed <c>(DriverInstanceId, RawPath)</c>).
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The per-category resolvable key:
|
||||
/// <list type="bullet">
|
||||
/// <item>Equipment driver tag (<c>EquipmentId != null</c>) → the driver <c>FullName</c>
|
||||
/// extracted from <c>Tag.TagConfig</c> (the verified <c>DependencyMux</c> key).
|
||||
/// GalaxyMxGateway is a standard Equipment-kind driver, so Galaxy points resolve
|
||||
/// by this same <c>FullName</c> key.</item>
|
||||
/// <item>VirtualTag → its leaf <c>Name</c> only, as a BEST-EFFORT key (the live resolution
|
||||
/// of virtual-tag cascade/write targets is unconfirmed).</item>
|
||||
/// </list>
|
||||
/// <b>This replaced a stale v2 projection (Gitea #490).</b> The catalog used to emit
|
||||
/// <c>Tag.TagConfig.FullName</c> — the pre-v3 wire address — which since v3 is <i>not</i> the mux
|
||||
/// key. The consequence was worse than the missing feature the issue reported: completion offered
|
||||
/// paths that could never resolve at runtime, while a <b>correct</b> absolute RawPath got no
|
||||
/// completion and hovered as "not a known configured tag path". Both directions are now right.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Follow-up: surface the UNS browse path as a completion <i>detail</i> (a non-inserted hint
|
||||
/// shown alongside the resolvable key) for discoverability, rather than as an inserted value.
|
||||
/// RawPaths are built through the shared <see cref="RawPathResolver"/> — the same byte-parity
|
||||
/// authority <c>DraftValidator</c> and <c>AddressSpaceComposer</c> use — so a suggested path is
|
||||
/// identical to the one the deploy gate and the runtime compute. A tag whose ancestry chain is
|
||||
/// broken (unknown device / driver, invalid segment) resolves to <see langword="null"/> and is
|
||||
/// omitted: the runtime could not route it either, so offering it would be a lie.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The <c>{{equip}}/<RefName></c> form is served separately by
|
||||
/// <see cref="GetEquipmentReferenceNamesAsync"/>; the compose seams substitute those references to
|
||||
/// the same RawPaths listed here.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The catalog is <b>fleet-wide</b> (no cluster filter), matching the runtime's key space: the
|
||||
/// deployed artifact is fleet-wide and the mux is keyed by RawPath alone. Two clusters using the
|
||||
/// same RawPath collapse to one suggestion, which is correct — the string is what resolves.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Each call creates and disposes its own context via the pooled factory — the same pattern
|
||||
@@ -149,22 +151,43 @@ public sealed class ScriptTagCatalog(IDbContextFactory<OtOpcUaConfigDbContext> d
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Loads Tags + VirtualTags (untracked) and projects one <see cref="ScriptTagInfo"/> per row —
|
||||
/// the SINGLE source of the per-category resolvable-key projection shared by
|
||||
/// <see cref="GetPathsAsync"/> (distinct paths, prefix-filtered) and
|
||||
/// <see cref="GetTagInfoAsync"/> (exact Ordinal lookup).
|
||||
/// Loads the raw topology + Tags + VirtualTags (untracked) and projects one
|
||||
/// <see cref="ScriptTagInfo"/> per resolvable key — the SINGLE source of the projection shared by
|
||||
/// <see cref="GetPathsAsync"/> (distinct paths, prefix-filtered) and <see cref="GetTagInfoAsync"/>
|
||||
/// (exact Ordinal lookup).
|
||||
/// </summary>
|
||||
private async Task<IReadOnlyList<ScriptTagInfo>> BuildEntriesAsync(CancellationToken ct)
|
||||
{
|
||||
await using var db = await dbFactory.CreateDbContextAsync(ct);
|
||||
|
||||
// v3: Tag is Raw-only — EquipmentId / FolderPath / DriverInstanceId were retired (the
|
||||
// equipment↔Tag + driver bindings are gone). The resolvable key is the driver FullName carried
|
||||
// in TagConfig; project only the surviving columns. (The Raw/UNS tag catalog is rebuilt in
|
||||
// Batch 2/3.)
|
||||
// The raw topology, fleet-wide, in the four ancestry maps RawPathResolver takes. Building the
|
||||
// resolver here (rather than reimplementing the join) is what keeps a suggested path
|
||||
// byte-identical to the one the deploy gate and the runtime compute.
|
||||
var folders = (await db.RawFolders.AsNoTracking()
|
||||
.Select(f => new { f.RawFolderId, f.ParentRawFolderId, f.Name })
|
||||
.ToListAsync(ct))
|
||||
.ToDictionary(f => f.RawFolderId, f => ((string?)f.ParentRawFolderId, f.Name), StringComparer.Ordinal);
|
||||
|
||||
var drivers = (await db.DriverInstances.AsNoTracking()
|
||||
.Select(d => new { d.DriverInstanceId, d.RawFolderId, d.Name })
|
||||
.ToListAsync(ct))
|
||||
.ToDictionary(d => d.DriverInstanceId, d => ((string?)d.RawFolderId, d.Name), StringComparer.Ordinal);
|
||||
|
||||
var devices = (await db.Devices.AsNoTracking()
|
||||
.Select(dev => new { dev.DeviceId, dev.DriverInstanceId, dev.Name })
|
||||
.ToListAsync(ct))
|
||||
.ToDictionary(dev => dev.DeviceId, dev => (dev.DriverInstanceId, dev.Name), StringComparer.Ordinal);
|
||||
|
||||
var groups = (await db.TagGroups.AsNoTracking()
|
||||
.Select(g => new { g.TagGroupId, g.ParentTagGroupId, g.Name })
|
||||
.ToListAsync(ct))
|
||||
.ToDictionary(g => g.TagGroupId, g => ((string?)g.ParentTagGroupId, g.Name), StringComparer.Ordinal);
|
||||
|
||||
var resolver = new RawPathResolver(folders, drivers, devices, groups);
|
||||
|
||||
var tagRows = await db.Tags
|
||||
.AsNoTracking()
|
||||
.Select(t => new { t.Name, t.DataType, t.TagConfig })
|
||||
.Select(t => new { t.DeviceId, t.TagGroupId, t.Name, t.DataType })
|
||||
.ToListAsync(ct);
|
||||
|
||||
var vtagRows = await db.VirtualTags
|
||||
@@ -176,46 +199,22 @@ public sealed class ScriptTagCatalog(IDbContextFactory<OtOpcUaConfigDbContext> d
|
||||
|
||||
foreach (var t in tagRows)
|
||||
{
|
||||
// The runtime GetTag key is the driver FullName from TagConfig; fall back to Name if absent.
|
||||
var full = ExtractFullNameFromTagConfig(t.TagConfig);
|
||||
var path = string.IsNullOrWhiteSpace(full) ? t.Name : full;
|
||||
entries.Add(new ScriptTagInfo(path, "Tag", t.DataType, null));
|
||||
// v3: the runtime GetTag key IS the RawPath. A null result means a broken/unknown ancestry
|
||||
// chain — the runtime could not route that tag either, so it is omitted rather than guessed.
|
||||
var path = resolver.TryBuildTagPath(t.DeviceId, t.TagGroupId, t.Name);
|
||||
if (path is null) continue;
|
||||
|
||||
var driverInstanceId = devices.TryGetValue(t.DeviceId, out var dev) ? dev.DriverInstanceId : null;
|
||||
entries.Add(new ScriptTagInfo(path, "Tag", t.DataType, driverInstanceId));
|
||||
}
|
||||
|
||||
foreach (var v in vtagRows)
|
||||
{
|
||||
// Virtual tag — best-effort: the live resolution of virtual-tag cascade/write targets is
|
||||
// unconfirmed, so emit the leaf Name only (no UNS browse path, which never resolves).
|
||||
// unconfirmed, so emit the leaf Name only.
|
||||
entries.Add(new ScriptTagInfo(v.Name, "Virtual tag", v.DataType, null));
|
||||
}
|
||||
|
||||
return entries;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extracts the driver-side full reference from a <c>Tag.TagConfig</c> JSON blob — the
|
||||
/// top-level <c>FullName</c> string every shipped driver stores. Mirrors
|
||||
/// <c>Commons.Types.TagConfigIntent.Parse(...).FullName</c> (the shared byte-parity authority;
|
||||
/// AdminUI does not reference that assembly here). Falls back to the raw blob when it is not
|
||||
/// a JSON object carrying a string <c>FullName</c>.
|
||||
/// </summary>
|
||||
private static string ExtractFullNameFromTagConfig(string tagConfig)
|
||||
{
|
||||
// Best-effort: pull the driver FullName out of the TagConfig JSON blob if present.
|
||||
// Returns "" when absent/unparseable so the caller falls back to the tag Name — never
|
||||
// the raw blob (which would surface as a garbage completion path).
|
||||
if (string.IsNullOrWhiteSpace(tagConfig)) return "";
|
||||
try
|
||||
{
|
||||
using var doc = System.Text.Json.JsonDocument.Parse(tagConfig);
|
||||
if (doc.RootElement.ValueKind == System.Text.Json.JsonValueKind.Object
|
||||
&& doc.RootElement.TryGetProperty("FullName", out var fullName)
|
||||
&& fullName.ValueKind == System.Text.Json.JsonValueKind.String)
|
||||
{
|
||||
return fullName.GetString() ?? "";
|
||||
}
|
||||
}
|
||||
catch (System.Text.Json.JsonException) { /* fall through to tag Name */ }
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ using FocasProbe = Driver.FOCAS.FocasDriverProbe;
|
||||
using OpcUaProbe = Driver.OpcUaClient.OpcUaClientDriverProbe;
|
||||
using GalaxyProbe = Driver.Galaxy.GalaxyDriverProbe;
|
||||
using CalculationProbe = Driver.Calculation.CalculationDriverProbe;
|
||||
using SqlProbe = Driver.Sql.SqlDriverProbe;
|
||||
|
||||
/// <summary>
|
||||
/// Wires every cross-platform driver assembly's <c>Register(registry, loggerFactory)</c>
|
||||
@@ -122,6 +123,7 @@ public static class DriverFactoryBootstrap
|
||||
services.TryAddEnumerable(ServiceDescriptor.Singleton<IDriverProbe, OpcUaProbe>());
|
||||
services.TryAddEnumerable(ServiceDescriptor.Singleton<IDriverProbe, GalaxyProbe>());
|
||||
services.TryAddEnumerable(ServiceDescriptor.Singleton<IDriverProbe, CalculationProbe>());
|
||||
services.TryAddEnumerable(ServiceDescriptor.Singleton<IDriverProbe, SqlProbe>());
|
||||
|
||||
return services;
|
||||
}
|
||||
@@ -146,6 +148,7 @@ public static class DriverFactoryBootstrap
|
||||
Driver.Modbus.ModbusDriverFactoryExtensions.Register(registry, loggerFactory);
|
||||
Driver.OpcUaClient.OpcUaClientDriverFactoryExtensions.Register(registry, loggerFactory, secretResolver);
|
||||
Driver.S7.S7DriverFactoryExtensions.Register(registry);
|
||||
Driver.Sql.SqlDriverFactoryExtensions.Register(registry, loggerFactory);
|
||||
Driver.TwinCAT.TwinCATDriverFactoryExtensions.Register(registry);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -76,6 +76,7 @@
|
||||
<ProjectReference Include="..\..\Drivers\ZB.MOM.WW.OtOpcUa.Driver.Modbus\ZB.MOM.WW.OtOpcUa.Driver.Modbus.csproj"/>
|
||||
<ProjectReference Include="..\..\Drivers\ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient\ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.csproj"/>
|
||||
<ProjectReference Include="..\..\Drivers\ZB.MOM.WW.OtOpcUa.Driver.S7\ZB.MOM.WW.OtOpcUa.Driver.S7.csproj"/>
|
||||
<ProjectReference Include="..\..\Drivers\ZB.MOM.WW.OtOpcUa.Driver.Sql\ZB.MOM.WW.OtOpcUa.Driver.Sql.csproj"/>
|
||||
<ProjectReference Include="..\..\Drivers\ZB.MOM.WW.OtOpcUa.Driver.TwinCAT\ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.csproj"/>
|
||||
</ItemGroup>
|
||||
|
||||
|
||||
@@ -170,6 +170,7 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
|
||||
/// <summary>Interval between bounded post-connect re-discovery passes. Production default 2s; tests
|
||||
/// inject a tiny value so the loop runs without real-time waits.</summary>
|
||||
private readonly TimeSpan _rediscoverInterval;
|
||||
private readonly TimeSpan _healthPollInterval;
|
||||
|
||||
/// <summary>Cap on the number of post-connect re-discovery passes — a backstop so a never-stabilising
|
||||
/// (or perpetually-empty) discovered set cannot spin the loop forever. Production default 15.</summary>
|
||||
@@ -239,6 +240,9 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
|
||||
/// <param name="rediscoverDiscoverTimeout">Optional per-pass timeout for <see cref="ITagDiscovery.DiscoverAsync"/>; defaults to 30 seconds.</param>
|
||||
/// <param name="invoker">Optional Phase 6.1 resilience invoker wrapping this driver's capability
|
||||
/// calls; defaults to <see cref="NullDriverCapabilityInvoker"/> (pass-through) when not supplied.</param>
|
||||
/// <param name="healthPollInterval">Optional period of the health-poll heartbeat, which also drives the
|
||||
/// subscription reconcile (<see cref="ReconcileSubscription"/>); defaults to
|
||||
/// <see cref="HealthPollInterval"/>. Exists so a test can drive the reconcile without waiting 30 s.</param>
|
||||
/// <returns>A <see cref="Akka.Actor.Props"/> instance configured to create the wrapped <see cref="DriverInstanceActor"/>.</returns>
|
||||
public static Props Props(
|
||||
IDriver driver,
|
||||
@@ -249,7 +253,8 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
|
||||
TimeSpan? rediscoverInterval = null,
|
||||
int rediscoverMaxAttempts = DefaultRediscoverMaxAttempts,
|
||||
TimeSpan? rediscoverDiscoverTimeout = null,
|
||||
IDriverCapabilityInvoker? invoker = null) =>
|
||||
IDriverCapabilityInvoker? invoker = null,
|
||||
TimeSpan? healthPollInterval = null) =>
|
||||
Akka.Actor.Props.Create(() => new DriverInstanceActor(
|
||||
driver,
|
||||
reconnectInterval ?? DefaultReconnectInterval,
|
||||
@@ -259,7 +264,8 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
|
||||
rediscoverInterval,
|
||||
rediscoverMaxAttempts,
|
||||
rediscoverDiscoverTimeout,
|
||||
invoker));
|
||||
invoker,
|
||||
healthPollInterval));
|
||||
|
||||
/// <summary>
|
||||
/// Returns true when the driver should boot in DEV-STUB mode based on host platform and
|
||||
@@ -293,6 +299,8 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
|
||||
/// <param name="rediscoverDiscoverTimeout">Per-pass timeout for <see cref="ITagDiscovery.DiscoverAsync"/>; defaults to 30 seconds.</param>
|
||||
/// <param name="invoker">Phase 6.1 resilience invoker wrapping this driver's capability calls;
|
||||
/// defaults to <see cref="NullDriverCapabilityInvoker"/> (pass-through) when null.</param>
|
||||
/// <param name="healthPollInterval">Period of the health-poll heartbeat, which also drives the
|
||||
/// subscription reconcile; defaults to <see cref="HealthPollInterval"/> when null.</param>
|
||||
public DriverInstanceActor(
|
||||
IDriver driver,
|
||||
TimeSpan reconnectInterval,
|
||||
@@ -302,7 +310,8 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
|
||||
TimeSpan? rediscoverInterval = null,
|
||||
int rediscoverMaxAttempts = DefaultRediscoverMaxAttempts,
|
||||
TimeSpan? rediscoverDiscoverTimeout = null,
|
||||
IDriverCapabilityInvoker? invoker = null)
|
||||
IDriverCapabilityInvoker? invoker = null,
|
||||
TimeSpan? healthPollInterval = null)
|
||||
{
|
||||
_driver = driver;
|
||||
_invoker = invoker ?? NullDriverCapabilityInvoker.Instance;
|
||||
@@ -313,6 +322,7 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
|
||||
_rediscoverInterval = rediscoverInterval ?? DefaultRediscoverInterval;
|
||||
_rediscoverMaxAttempts = rediscoverMaxAttempts;
|
||||
_rediscoverDiscoverTimeout = rediscoverDiscoverTimeout ?? DefaultRediscoverDiscoverTimeout;
|
||||
_healthPollInterval = healthPollInterval ?? HealthPollInterval;
|
||||
OtOpcUaTelemetry.DriverInstanceLifecycle.Add(1,
|
||||
new KeyValuePair<string, object?>("event", startStubbed ? "spawn_stub" : "spawn"),
|
||||
new KeyValuePair<string, object?>("driver_type", driver.DriverType));
|
||||
@@ -335,7 +345,7 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
|
||||
// actor starts, before any state transition fires. Also start the periodic heartbeat so
|
||||
// long-lived Healthy drivers keep their snapshot fresh for newly-joined SignalR clients.
|
||||
PublishHealthSnapshot();
|
||||
Timers.StartPeriodicTimer("health-poll", HealthPollTick.Instance, HealthPollInterval);
|
||||
Timers.StartPeriodicTimer("health-poll", HealthPollTick.Instance, _healthPollInterval);
|
||||
}
|
||||
|
||||
private void Stubbed()
|
||||
@@ -489,7 +499,58 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
|
||||
// to Self (HandleSubscribeAsync already logged the underlying cause). Swallow it so it doesn't dead-letter.
|
||||
Receive<SubscriptionFailed>(msg =>
|
||||
_log.Debug("DriverInstance {Id}: resubscribe reported failure: {Reason}", _driverInstanceId, msg.Reason));
|
||||
Receive<HealthPollTick>(_ => PublishHealthSnapshot());
|
||||
Receive<HealthPollTick>(_ =>
|
||||
{
|
||||
PublishHealthSnapshot();
|
||||
ReconcileSubscription();
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Periodic desired-vs-actual subscription reconcile (#488), riding the existing <c>health-poll</c>
|
||||
/// tick. While Connected, a driver that has a desired subscription set but no live handle is
|
||||
/// re-subscribed.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The desired set is otherwise only (re)applied on a <b>Connected transition</b>
|
||||
/// (<see cref="ResubscribeDesired"/>) or on a deploy's <see cref="SetDesiredSubscriptions"/>.
|
||||
/// Nothing re-asserted it for a subscription that was lost while the driver <i>stayed</i>
|
||||
/// Connected — a failed subscribe whose retry never came, or a handle dropped down an error
|
||||
/// path. That is the hole this closes.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>This is a state-machine consistency check, not a probe.</b> It can only see that this
|
||||
/// actor holds no handle; it cannot detect a handle that is present but dead server-side,
|
||||
/// because <c>ISubscriptionHandle</c> is opaque (a <c>DiagnosticId</c> string and nothing
|
||||
/// else). Detecting that needs a liveness member on <c>ISubscribable</c> implemented across
|
||||
/// all eight drivers, each with different subscription mechanics — deliberately deferred
|
||||
/// until a real occurrence shows the handle outliving the subscription.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Gated on <see cref="ISubscribable"/>: a driver that cannot subscribe at all may still have
|
||||
/// been handed a desired set, and re-telling <c>Subscribe</c> to it every tick would fail
|
||||
/// every tick, forever.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// A redundant <c>Subscribe</c> is possible but harmless: a tick sitting in the mailbox ahead
|
||||
/// of an already-queued <c>Subscribe</c> observes the same "no handle" state and tells a
|
||||
/// second one. <see cref="HandleSubscribeAsync"/> is a <c>ReceiveAsync</c> (so no tick can be
|
||||
/// processed mid-subscribe) and drops any prior subscription before establishing the new one,
|
||||
/// so the duplicate costs one extra round-trip and converges. Suppressing it would need a
|
||||
/// pending-subscribe flag threaded through every self-tell site — more state than the race
|
||||
/// is worth.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private void ReconcileSubscription()
|
||||
{
|
||||
if (_driver is not ISubscribable) return;
|
||||
if (_desiredRefs.Count == 0 || _subscriptionHandle is not null) return;
|
||||
|
||||
_log.Info(
|
||||
"DriverInstance {Id}: connected with {Count} desired subscription ref(s) but no live subscription handle — re-subscribing (periodic reconcile)",
|
||||
_driverInstanceId, _desiredRefs.Count);
|
||||
Self.Tell(new Subscribe(_desiredRefs, _desiredInterval));
|
||||
}
|
||||
|
||||
private void Reconnecting()
|
||||
|
||||
@@ -288,8 +288,101 @@ public sealed class ScriptedAlarmHostActor : ReceiveActor
|
||||
// so a re-Apply with a fresh union simply supersedes the old one — no explicit unregister needed.
|
||||
_mux?.Tell(new DependencyMuxActor.RegisterInterest(msg.DepRefs, Self));
|
||||
_log.Debug("ScriptedAlarmHost: loaded; registered mux interest for {Count} dep refs", msg.DepRefs.Count);
|
||||
|
||||
ReassertConditionNodes();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// #487 — re-project every loaded alarm that is NOT in the Part 9 "no-event" position back onto
|
||||
/// its condition node, right after a (re)load.
|
||||
///
|
||||
/// <para>
|
||||
/// <b>The bug this closes.</b> A deploy that triggers a FULL address-space rebuild clears
|
||||
/// <c>_alarmConditions</c> and re-materialises every condition node fresh — inactive, acked,
|
||||
/// confirmed, unshelved. The engine, meanwhile, restores each alarm's persisted state and
|
||||
/// re-derives Active from the predicate, so a still-active alarm ends the reload
|
||||
/// <i>correctly Active in the engine</i> but produces <see cref="EmissionKind.None"/> (there
|
||||
/// is no transition to report) — and <see cref="OnEngineEmission"/> drops None. Nothing then
|
||||
/// writes the node, so an active alarm with static dependencies reads NORMAL to every OPC UA
|
||||
/// client until its next real transition, which may never come. Same shape as the VirtualTag
|
||||
/// redeploy-reset the <c>ReassertValue</c> fix closed.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// <b>Node-only, by construction.</b> This sends <see cref="OpcUaPublishActor.AlarmStateUpdate"/>
|
||||
/// and nothing else — it never publishes to the <c>alerts</c> topic and never touches the
|
||||
/// telemetry hub. That is the whole reason it reads <see cref="ScriptedAlarmEngine.GetProjections"/>
|
||||
/// (a plain read returning <see cref="ScriptedAlarmProjection"/>) rather than re-emitting: an
|
||||
/// <c>alerts</c> row per alarm per deploy would append a duplicate historian / AVEVA record
|
||||
/// every time anyone deploys — the alarm analogue of the VirtualTag M1 historian issue.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// <b>Ordering.</b> <c>DriverHostActor</c> Tells <c>RebuildAddressSpace</c> to the publish
|
||||
/// actor BEFORE it Tells <see cref="ApplyScriptedAlarms"/> here, and this runs later still —
|
||||
/// after an <c>await</c>ed <see cref="ScriptedAlarmEngine.LoadAsync"/> pipes back. Both Tells
|
||||
/// enqueue synchronously into the publish actor's local mailbox, so the re-materialise is
|
||||
/// already queued ahead of these writes: the node exists by the time they are handled.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// <b>Why only non-normal alarms.</b> An alarm sitting in the no-event position already
|
||||
/// matches what the materialise just built, so writing it would be a pure no-op on state —
|
||||
/// but it would still overwrite the node's Message (the materialise seeds it with the alarm's
|
||||
/// display name; a projection carries the resolved template). Skipping them keeps a
|
||||
/// never-fired alarm byte-identical to a deploy without this pass, and keeps the write count
|
||||
/// at zero on the overwhelmingly common all-normal deploy.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// <b>Duplicate events.</b> None. <c>WriteAlarmCondition</c>'s delta-gate compares the
|
||||
/// incoming snapshot against the node's CURRENT state, so a re-assert onto a freshly
|
||||
/// materialised (normal) node is a genuine delta and fires one Part 9 event — correct, since
|
||||
/// the rebuild reset what clients see — while a re-assert onto a surgically-preserved node
|
||||
/// that already holds the same state is suppressed. This write is ungated by redundancy role,
|
||||
/// like every other node write here: the Secondary keeps its address space warm for failover.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
private void ReassertConditionNodes()
|
||||
{
|
||||
var reasserted = 0;
|
||||
foreach (var projection in _engine.GetProjections())
|
||||
{
|
||||
if (IsInNoEventPosition(projection.Condition))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
_publishActor.Tell(new OpcUaPublishActor.AlarmStateUpdate(
|
||||
AlarmNodeId: projection.AlarmId,
|
||||
State: ToSnapshot(projection),
|
||||
// The instant the state we are re-asserting actually came about — NOT the deploy time.
|
||||
// A client reading Time/ReceiveTime after a rebuild should still see when the alarm went
|
||||
// active, not when the address space happened to be rebuilt.
|
||||
TimestampUtc: projection.Condition.LastTransitionUtc,
|
||||
Realm: AddressSpaceRealm.Uns));
|
||||
reasserted++;
|
||||
}
|
||||
|
||||
if (reasserted > 0)
|
||||
{
|
||||
_log.Info("ScriptedAlarmHost: re-asserted {Count} non-normal condition node(s) after (re)load", reasserted);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Whether <paramref name="condition"/> sits in the Part 9 "no-event" position — exactly the
|
||||
/// state <see cref="AlarmConditionState.Fresh"/> produces and a freshly materialised condition node
|
||||
/// already carries. Anything else (active, unacknowledged, unconfirmed, disabled, or shelved) is state
|
||||
/// a rebuild would silently lose, so it is what <see cref="ReassertConditionNodes"/> re-projects.</summary>
|
||||
/// <param name="condition">The engine's current condition state.</param>
|
||||
/// <returns><c>true</c> when the condition carries nothing a rebuild could lose.</returns>
|
||||
private static bool IsInNoEventPosition(AlarmConditionState condition)
|
||||
=> condition.Enabled == AlarmEnabledState.Enabled
|
||||
&& condition.Active == AlarmActiveState.Inactive
|
||||
&& condition.Acked == AlarmAckedState.Acknowledged
|
||||
&& condition.Confirmed == AlarmConfirmedState.Confirmed
|
||||
&& condition.Shelving.Kind == ShelvingKind.Unshelved;
|
||||
|
||||
private void OnDependencyChanged(VirtualTagActor.DependencyValueChanged msg)
|
||||
{
|
||||
// Feed the live value into the upstream the engine subscribes from. #478 — carry the source
|
||||
@@ -594,18 +687,38 @@ public sealed class ScriptedAlarmHostActor : ReceiveActor
|
||||
/// Severity is the OPC UA 1..1000 value <see cref="SeverityToInt"/> derives from the coarse engine
|
||||
/// bucket, cast to the <c>ushort</c> the SDK <c>SetSeverity</c> expects. Shelving's 3-way Core kind
|
||||
/// maps 1:1 onto the Commons <see cref="AlarmShelvingKind"/>.</summary>
|
||||
private static AlarmConditionSnapshot ToSnapshot(ScriptedAlarmEvent e) => new(
|
||||
Active: e.Condition.Active == AlarmActiveState.Active,
|
||||
Acknowledged: e.Condition.Acked == AlarmAckedState.Acknowledged,
|
||||
Confirmed: e.Condition.Confirmed == AlarmConfirmedState.Confirmed,
|
||||
Enabled: e.Condition.Enabled == AlarmEnabledState.Enabled,
|
||||
Shelving: MapShelving(e.Condition.Shelving.Kind),
|
||||
Severity: (ushort)SeverityToInt(e.Severity),
|
||||
Message: e.Message,
|
||||
private static AlarmConditionSnapshot ToSnapshot(ScriptedAlarmEvent e)
|
||||
=> ToSnapshot(e.Condition, e.Severity, e.Message, e.WorstInputStatusCode);
|
||||
|
||||
/// <summary>#487 — the same projection for a <see cref="ScriptedAlarmProjection"/>: a state READ used
|
||||
/// to restore a condition node after a rebuild, never an emission. Shares
|
||||
/// <see cref="ToSnapshot(AlarmConditionState, AlarmSeverity, string, uint)"/> with the transition path
|
||||
/// so a re-asserted node is byte-identical to what the alarm's own transition would have written.</summary>
|
||||
/// <param name="p">The engine projection to map.</param>
|
||||
/// <returns>The Commons snapshot the SDK sink projects onto the condition node.</returns>
|
||||
private static AlarmConditionSnapshot ToSnapshot(ScriptedAlarmProjection p)
|
||||
=> ToSnapshot(p.Condition, p.Severity, p.Message, p.WorstInputStatusCode);
|
||||
|
||||
/// <summary>The single Core → Commons condition projection, shared by the emission and re-assert
|
||||
/// paths.</summary>
|
||||
/// <param name="condition">The Part 9 condition state.</param>
|
||||
/// <param name="severity">The engine's coarse severity bucket.</param>
|
||||
/// <param name="message">The resolved condition message.</param>
|
||||
/// <param name="worstInputStatusCode">The worst OPC UA StatusCode across the script's input tags.</param>
|
||||
/// <returns>The Commons snapshot the SDK sink projects onto the condition node.</returns>
|
||||
private static AlarmConditionSnapshot ToSnapshot(
|
||||
AlarmConditionState condition, AlarmSeverity severity, string message, uint worstInputStatusCode) => new(
|
||||
Active: condition.Active == AlarmActiveState.Active,
|
||||
Acknowledged: condition.Acked == AlarmAckedState.Acknowledged,
|
||||
Confirmed: condition.Confirmed == AlarmConfirmedState.Confirmed,
|
||||
Enabled: condition.Enabled == AlarmEnabledState.Enabled,
|
||||
Shelving: MapShelving(condition.Shelving.Kind),
|
||||
Severity: (ushort)SeverityToInt(severity),
|
||||
Message: message,
|
||||
// #478 — the condition's Quality is the worst quality across the script's input tags at evaluation
|
||||
// time (carried on the event by the engine). A transition fired while an input is Uncertain projects
|
||||
// Uncertain here so the full-snapshot write doesn't clobber quality back to Good.
|
||||
Quality: QualityFromStatus(e.WorstInputStatusCode));
|
||||
Quality: QualityFromStatus(worstInputStatusCode));
|
||||
|
||||
/// <summary>Maps the Core <see cref="ShelvingKind"/> onto the Commons <see cref="AlarmShelvingKind"/>
|
||||
/// mirror (the Commons assembly can't see the Core enum).</summary>
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Validation;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Configuration.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// The Gitea #498 deploy gate: a <c>Sql</c> driver's persisted config may never carry a literal
|
||||
/// <c>connectionString</c>. The typed DTO already drops the key on <em>read</em>; these pin the
|
||||
/// <em>write</em> side, which is where a credential would actually land in the config database.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class DraftValidatorSqlSecretTests
|
||||
{
|
||||
private const string Code = "SqlConnectionStringPersisted";
|
||||
|
||||
/// <summary>A realistic leak: the literal an operator would paste into a raw-JSON config textarea.</summary>
|
||||
private const string LeakedConfig =
|
||||
"""{"provider":"SqlServer","connectionString":"Server=sql,1433;Database=Mes;User ID=sa;Password=hunter2"}""";
|
||||
|
||||
private static DriverInstance SqlDriver(string config) => new()
|
||||
{
|
||||
DriverInstanceId = "di-sql", ClusterId = "c", Name = "line3-sql",
|
||||
DriverType = "Sql", DriverConfig = config,
|
||||
};
|
||||
|
||||
private static Device Device(string driverInstanceId, string config) => new()
|
||||
{
|
||||
DeviceId = "dev-1", DriverInstanceId = driverInstanceId, Name = "Device1", DeviceConfig = config,
|
||||
};
|
||||
|
||||
private static DraftSnapshot Draft(DriverInstance driver, Device? device = null) => new()
|
||||
{
|
||||
GenerationId = 1,
|
||||
ClusterId = "c",
|
||||
DriverInstances = [driver],
|
||||
Devices = device is null ? [] : [device],
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public void Literal_connectionString_in_DriverConfig_is_a_deploy_error()
|
||||
{
|
||||
var errors = DraftValidator.Validate(Draft(SqlDriver(LeakedConfig)));
|
||||
|
||||
errors.ShouldContain(e => e.Code == Code && e.Context == "di-sql");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The error text reaches the AdminUI, the deploy log and the audit trail, so it must describe the
|
||||
/// problem without repeating the credential it is refusing to store.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Error_message_does_not_echo_the_credential()
|
||||
{
|
||||
var error = DraftValidator.Validate(Draft(SqlDriver(LeakedConfig))).First(e => e.Code == Code);
|
||||
|
||||
error.Message.ShouldNotContain("hunter2");
|
||||
error.Message.ShouldNotContain("Server=sql,1433");
|
||||
error.Message.ShouldContain("connectionStringRef");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// System.Text.Json binds <c>ConnectionString</c> to a <c>connectionString</c> property by default, so
|
||||
/// a case variant is the same key — matching it ordinally would leave the obvious bypass wide open.
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[InlineData("ConnectionString")]
|
||||
[InlineData("CONNECTIONSTRING")]
|
||||
[InlineData("connectionstring")]
|
||||
public void Key_match_is_case_insensitive(string key)
|
||||
{
|
||||
var config = $$"""{"provider":"SqlServer","{{key}}":"Server=s;Password=p"}""";
|
||||
|
||||
DraftValidator.Validate(Draft(SqlDriver(config)))
|
||||
.ShouldContain(e => e.Code == Code && e.Context == "di-sql");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// DeviceConfig is merged onto DriverConfig before the driver's DTO sees it, so a credential pasted
|
||||
/// there is the identical leak and must fail the same way.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Literal_connectionString_in_a_Sql_devices_DeviceConfig_is_a_deploy_error()
|
||||
{
|
||||
var draft = Draft(SqlDriver("""{"connectionStringRef":"DevSql"}"""), Device("di-sql", LeakedConfig));
|
||||
|
||||
DraftValidator.Validate(draft).ShouldContain(e => e.Code == Code && e.Context == "dev-1");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_properly_authored_connectionStringRef_passes()
|
||||
{
|
||||
var draft = Draft(
|
||||
SqlDriver("""{"provider":"SqlServer","connectionStringRef":"DevSql","nullIsBad":true}"""),
|
||||
Device("di-sql", """{"pollIntervalMs":1000}"""));
|
||||
|
||||
DraftValidator.Validate(draft).ShouldNotContain(e => e.Code == Code);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The rule is scoped to the Sql driver type. A non-Sql driver is not in its remit — widening the gate
|
||||
/// to every driver is a separate decision, and silently failing an unrelated driver's deploy here would
|
||||
/// be a regression, not defence in depth.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void A_non_Sql_driver_carrying_the_key_is_not_flagged_by_this_rule()
|
||||
{
|
||||
var modbus = new DriverInstance
|
||||
{
|
||||
DriverInstanceId = "di-mb", ClusterId = "c", Name = "mb",
|
||||
DriverType = "Modbus", DriverConfig = LeakedConfig,
|
||||
};
|
||||
|
||||
DraftValidator.Validate(Draft(modbus)).ShouldNotContain(e => e.Code == Code);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A device under a <em>different</em> driver must not be attributed to the Sql instance — the device
|
||||
/// scan keys off DriverInstanceId, and getting that wrong would flag innocent devices.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void A_device_under_a_non_Sql_driver_is_not_flagged()
|
||||
{
|
||||
var draft = new DraftSnapshot
|
||||
{
|
||||
GenerationId = 1,
|
||||
ClusterId = "c",
|
||||
DriverInstances =
|
||||
[
|
||||
SqlDriver("""{"connectionStringRef":"DevSql"}"""),
|
||||
new DriverInstance
|
||||
{
|
||||
DriverInstanceId = "di-mb", ClusterId = "c", Name = "mb",
|
||||
DriverType = "Modbus", DriverConfig = "{}",
|
||||
},
|
||||
],
|
||||
Devices = [Device("di-mb", LeakedConfig)],
|
||||
};
|
||||
|
||||
DraftValidator.Validate(draft).ShouldNotContain(e => e.Code == Code);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Malformed or non-object config must not throw out of the validator: shaping the JSON is another
|
||||
/// rule's job, and a parse failure here would take down every other check in the same pass.
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[InlineData("")]
|
||||
[InlineData(" ")]
|
||||
[InlineData("not json at all")]
|
||||
[InlineData("[1,2,3]")]
|
||||
[InlineData("\"connectionString\"")]
|
||||
[InlineData("{\"provider\":\"SqlServer\"")]
|
||||
public void Malformed_config_neither_throws_nor_flags(string config)
|
||||
{
|
||||
Should.NotThrow(() => DraftValidator.Validate(Draft(SqlDriver(config))))
|
||||
.ShouldNotContain(e => e.Code == Code);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Only the top level is scanned. The DTO is flat, so a nested occurrence cannot bind to anything and
|
||||
/// is not the credential-shaped mistake this rule exists to catch; flagging it would be a false
|
||||
/// positive on, say, a tag blob that happens to describe a connection string.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void A_nested_connectionString_is_not_flagged()
|
||||
{
|
||||
var config = """{"connectionStringRef":"DevSql","notes":{"connectionString":"documented elsewhere"}}""";
|
||||
|
||||
DraftValidator.Validate(Draft(SqlDriver(config))).ShouldNotContain(e => e.Code == Code);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Validation;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Configuration.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// <see cref="SqlCredentialGuard"/> — the shared "a Sql driver never persists a literal connection
|
||||
/// string" check behind both the #498 deploy gate and the #499 node-override save gate.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The node-override half is the one #499 is about: <c>ClusterNode.DriverConfigOverridesJson</c> is a
|
||||
/// map keyed by <c>DriverInstanceId</c> that is merged onto the cluster-level <c>DriverConfig</c>, and
|
||||
/// it is invisible to <c>DraftSnapshot</c> — so nothing gated it at all before.
|
||||
/// </remarks>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class SqlCredentialGuardTests
|
||||
{
|
||||
/// <summary>The literal an operator would paste into a raw-JSON textarea.</summary>
|
||||
private const string Leaked =
|
||||
"""{"provider":"SqlServer","connectionString":"Server=sql,1433;Database=Mes;User ID=sa;Password=hunter2"}""";
|
||||
|
||||
private const string Clean = """{"provider":"SqlServer","connectionStringRef":"Sql:Line3"}""";
|
||||
|
||||
// ---- CarriesLiteralConnectionString --------------------------------------------------------
|
||||
|
||||
/// <summary>The key is caught at the top level of a config blob.</summary>
|
||||
[Fact]
|
||||
public void A_literal_connectionString_is_detected()
|
||||
=> SqlCredentialGuard.CarriesLiteralConnectionString(Leaked).ShouldBeTrue();
|
||||
|
||||
/// <summary>The supported indirect form is not a violation — otherwise the rule would block the fix
|
||||
/// it tells operators to apply.</summary>
|
||||
[Fact]
|
||||
public void The_indirect_connectionStringRef_form_is_clean()
|
||||
=> SqlCredentialGuard.CarriesLiteralConnectionString(Clean).ShouldBeFalse();
|
||||
|
||||
/// <summary>System.Text.Json binds <c>ConnectionString</c> to a <c>connectionString</c> property by
|
||||
/// default, so a case variant is the same key — not a bypass.</summary>
|
||||
[Theory]
|
||||
[InlineData("""{"ConnectionString":"Server=x;Password=p"}""")]
|
||||
[InlineData("""{"CONNECTIONSTRING":"Server=x;Password=p"}""")]
|
||||
[InlineData("""{"connectionstring":"Server=x;Password=p"}""")]
|
||||
public void Case_variants_are_the_same_key(string json)
|
||||
=> SqlCredentialGuard.CarriesLiteralConnectionString(json).ShouldBeTrue();
|
||||
|
||||
/// <summary>Blank, malformed and non-object blobs simply have no keys. Shaping the config JSON is
|
||||
/// another rule's job, and throwing here would turn a formatting mistake into a credential failure.
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[InlineData(null)]
|
||||
[InlineData("")]
|
||||
[InlineData(" ")]
|
||||
[InlineData("{ not json")]
|
||||
[InlineData("[1,2,3]")]
|
||||
[InlineData("\"a string\"")]
|
||||
public void Blank_malformed_and_non_object_blobs_are_not_violations(string? json)
|
||||
=> SqlCredentialGuard.CarriesLiteralConnectionString(json).ShouldBeFalse();
|
||||
|
||||
/// <summary>Only the top level is scanned. The DTO is flat, so a nested occurrence cannot bind and is
|
||||
/// not the credential-shaped mistake this rule exists to catch.</summary>
|
||||
[Fact]
|
||||
public void A_nested_occurrence_is_not_flagged()
|
||||
=> SqlCredentialGuard
|
||||
.CarriesLiteralConnectionString("""{"nested":{"connectionString":"Server=x"}}""")
|
||||
.ShouldBeFalse();
|
||||
|
||||
// ---- FindNodeOverrideViolations (#499) -----------------------------------------------------
|
||||
|
||||
/// <summary>The #499 leak: a credential pasted into a node's per-driver override map.</summary>
|
||||
[Fact]
|
||||
public void A_credential_in_a_node_override_for_a_Sql_driver_is_a_violation()
|
||||
{
|
||||
var overrides = $$"""{"di-sql": {{Leaked}} }""";
|
||||
|
||||
SqlCredentialGuard.FindNodeOverrideViolations(overrides, ["di-sql"])
|
||||
.ShouldBe(["di-sql"]);
|
||||
}
|
||||
|
||||
/// <summary>Every offending instance is reported, not just the first — an operator fixing one at a
|
||||
/// time would otherwise need as many save attempts as there are leaks.</summary>
|
||||
[Fact]
|
||||
public void Every_offending_instance_is_reported()
|
||||
{
|
||||
var overrides = $$"""{"di-a": {{Leaked}}, "di-clean": {{Clean}}, "di-b": {{Leaked}} }""";
|
||||
|
||||
SqlCredentialGuard.FindNodeOverrideViolations(overrides, ["di-a", "di-b", "di-clean"])
|
||||
.ShouldBe(["di-a", "di-b"]);
|
||||
}
|
||||
|
||||
/// <summary>Keys outside the Sql set are ignored. A non-Sql driver never made the
|
||||
/// indirect-credential guarantee, so flagging it would break a config that is legitimate today —
|
||||
/// a regression, not defence in depth.</summary>
|
||||
[Fact]
|
||||
public void An_override_for_a_non_Sql_driver_is_ignored()
|
||||
{
|
||||
var overrides = $$"""{"di-modbus": {{Leaked}} }""";
|
||||
|
||||
SqlCredentialGuard.FindNodeOverrideViolations(overrides, ["di-sql"]).ShouldBeEmpty();
|
||||
}
|
||||
|
||||
/// <summary>The realistic override — a node-specific endpoint — must keep saving.</summary>
|
||||
[Fact]
|
||||
public void A_normal_override_is_clean()
|
||||
{
|
||||
var overrides = """{"di-sql": {"commandTimeoutSeconds": 45}}""";
|
||||
|
||||
SqlCredentialGuard.FindNodeOverrideViolations(overrides, ["di-sql"]).ShouldBeEmpty();
|
||||
}
|
||||
|
||||
/// <summary>Nothing to check when the cluster has no Sql drivers, or the node has no overrides.</summary>
|
||||
[Theory]
|
||||
[InlineData(null)]
|
||||
[InlineData("")]
|
||||
[InlineData(" ")]
|
||||
[InlineData("{ not json")]
|
||||
[InlineData("[1,2,3]")]
|
||||
public void Blank_and_malformed_override_maps_yield_no_violations(string? overrides)
|
||||
=> SqlCredentialGuard.FindNodeOverrideViolations(overrides, ["di-sql"]).ShouldBeEmpty();
|
||||
|
||||
/// <summary>An empty Sql-driver set short-circuits — there is no instance the key could belong to.</summary>
|
||||
[Fact]
|
||||
public void No_Sql_drivers_means_no_violations()
|
||||
=> SqlCredentialGuard.FindNodeOverrideViolations($$"""{"di-sql": {{Leaked}} }""", [])
|
||||
.ShouldBeEmpty();
|
||||
|
||||
/// <summary>A non-object override entry cannot carry config keys, and must not throw.</summary>
|
||||
[Fact]
|
||||
public void A_non_object_override_entry_is_skipped()
|
||||
=> SqlCredentialGuard.FindNodeOverrideViolations("""{"di-sql": "connectionString"}""", ["di-sql"])
|
||||
.ShouldBeEmpty();
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
using System.Reflection;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Guards every driver's hard-coded OPC UA status-code constant against
|
||||
/// <see cref="Opc.Ua.StatusCodes"/> — the pinned SDK is the oracle, and a constant whose
|
||||
/// <em>name</em> disagrees with its <em>value</em> fails here.
|
||||
/// <para><b>Why the drivers hard-code these at all.</b> The driver layer is deliberately SDK-free: a
|
||||
/// driver assembly must not drag in the OPC UA stack just to spell a status code, so each one declares
|
||||
/// the handful it needs as bare <c>uint</c> literals. That is a sound layering decision with one
|
||||
/// failure mode — nothing checks the literal against the name — and it is exactly the failure mode
|
||||
/// Gitea #497 found in six places across four drivers.</para>
|
||||
/// <para><b>Discovery is reflection-driven, not a hand-copied list</b> (the same convention
|
||||
/// <see cref="DriverTypeNamesGuardTests"/> uses): the test scans every
|
||||
/// <c>ZB.MOM.WW.OtOpcUa.Driver.*.dll</c> deployed to its output directory for <c>const uint</c> fields
|
||||
/// whose name reads as a status code, so a brand-new driver assembly referenced by this project is
|
||||
/// covered automatically, with no edit here.</para>
|
||||
/// <para><b>An inline literal is invisible to this guard.</b> Reflection can only see a <em>named</em>
|
||||
/// constant, so <c>StatusCode: 0x800B0000u, // BadTimeout</c> written at a call site is unguarded —
|
||||
/// which is how <c>GalaxyDriver</c> shipped <c>BadServiceUnsupported</c> under a <c>BadTimeout</c>
|
||||
/// comment. Hoist a status literal into a named constant rather than writing it at the call site.</para>
|
||||
/// </summary>
|
||||
public sealed class StatusCodeParityTests
|
||||
{
|
||||
/// <summary>
|
||||
/// Name prefixes that mark a <c>uint</c> constant as an OPC UA status code. These are the three
|
||||
/// Part 4 severity categories, so they cover the whole space by construction — a status constant
|
||||
/// that does not start with one of them is not a status constant.
|
||||
/// </summary>
|
||||
private static readonly string[] StatusPrefixes = ["Good", "Uncertain", "Bad"];
|
||||
|
||||
/// <summary>
|
||||
/// Field-name prefixes stripped before the name is looked up on <see cref="Opc.Ua.StatusCodes"/>.
|
||||
/// Drivers vary the spelling (<c>SampleMapper.StatusBadNoData</c> vs
|
||||
/// <c>SqlStatusCodes.BadTimeout</c>); the SDK member is <c>BadNoData</c> either way.
|
||||
/// </summary>
|
||||
private static readonly string[] NamePrefixesToStrip = ["Status"];
|
||||
|
||||
/// <summary>Every <c>Opc.Ua.StatusCodes</c> member, by name — the oracle this test checks against.</summary>
|
||||
private static readonly IReadOnlyDictionary<string, uint> SdkStatusCodes =
|
||||
typeof(Opc.Ua.StatusCodes)
|
||||
.GetFields(BindingFlags.Public | BindingFlags.Static)
|
||||
.Where(f => f.IsLiteral && f.FieldType == typeof(uint))
|
||||
.ToDictionary(f => f.Name, f => (uint)f.GetRawConstantValue()!, StringComparer.Ordinal);
|
||||
|
||||
/// <summary>
|
||||
/// Every status-shaped <c>const uint</c> declared anywhere in the deployed driver assemblies, as
|
||||
/// <c>(assembly, declaring type, field name, SDK member name, declared value)</c>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Non-public types and fields are included on purpose — <c>SampleMapper.StatusBadNoData</c> is a
|
||||
/// <c>private const</c> on an <c>internal</c> class, and it carried one of the #497 defects. Access
|
||||
/// modifiers say nothing about whether a value reaches an OPC UA client.
|
||||
/// </remarks>
|
||||
public static TheoryData<string, string, string, string, uint> DeclaredStatusConstants()
|
||||
{
|
||||
var data = new TheoryData<string, string, string, string, uint>();
|
||||
|
||||
var binDir = Path.GetDirectoryName(typeof(StatusCodeParityTests).Assembly.Location)!;
|
||||
foreach (var dll in Directory.GetFiles(binDir, "ZB.MOM.WW.OtOpcUa.Driver.*.dll").OrderBy(p => p, StringComparer.Ordinal))
|
||||
{
|
||||
Assembly asm;
|
||||
try { asm = Assembly.LoadFrom(dll); }
|
||||
catch { continue; }
|
||||
|
||||
Type?[] types;
|
||||
try { types = asm.GetTypes(); }
|
||||
catch (ReflectionTypeLoadException ex) { types = ex.Types; }
|
||||
|
||||
foreach (var type in types)
|
||||
{
|
||||
if (type is null) continue;
|
||||
|
||||
var fields = type.GetFields(
|
||||
BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Static | BindingFlags.DeclaredOnly);
|
||||
|
||||
foreach (var field in fields)
|
||||
{
|
||||
if (!field.IsLiteral || field.IsInitOnly || field.FieldType != typeof(uint)) continue;
|
||||
|
||||
var sdkName = StripKnownPrefix(field.Name);
|
||||
if (!StatusPrefixes.Any(p => sdkName.StartsWith(p, StringComparison.Ordinal))) continue;
|
||||
|
||||
data.Add(
|
||||
asm.GetName().Name!,
|
||||
type.FullName ?? type.Name,
|
||||
field.Name,
|
||||
sdkName,
|
||||
(uint)field.GetRawConstantValue()!);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
/// <summary>Removes a leading <c>Status</c>-style prefix so the remainder can be looked up on the SDK type.</summary>
|
||||
private static string StripKnownPrefix(string fieldName)
|
||||
{
|
||||
foreach (var prefix in NamePrefixesToStrip)
|
||||
{
|
||||
if (fieldName.Length > prefix.Length && fieldName.StartsWith(prefix, StringComparison.Ordinal))
|
||||
return fieldName[prefix.Length..];
|
||||
}
|
||||
|
||||
return fieldName;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A driver's status constant must carry the value the SDK gives the code it is named after.
|
||||
/// </summary>
|
||||
/// <param name="assembly">The declaring driver assembly (failure-message context).</param>
|
||||
/// <param name="type">The declaring type's full name (failure-message context).</param>
|
||||
/// <param name="field">The constant's name as declared.</param>
|
||||
/// <param name="sdkName">The <see cref="Opc.Ua.StatusCodes"/> member the name resolves to.</param>
|
||||
/// <param name="declared">The value the driver declares.</param>
|
||||
[Theory]
|
||||
[MemberData(nameof(DeclaredStatusConstants))]
|
||||
public void Driver_status_constant_matches_the_pinned_SDK(
|
||||
string assembly, string type, string field, string sdkName, uint declared)
|
||||
{
|
||||
SdkStatusCodes.ShouldContainKey(sdkName,
|
||||
$"{type}.{field} (in {assembly}) reads as an OPC UA status code, but '{sdkName}' is not a member " +
|
||||
"of Opc.Ua.StatusCodes. Either the constant is misnamed, or it is not a status code and should " +
|
||||
"not start with Good/Uncertain/Bad.");
|
||||
|
||||
var expected = SdkStatusCodes[sdkName];
|
||||
declared.ShouldBe(expected,
|
||||
$"{type}.{field} (in {assembly}) is declared 0x{declared:X8}, but Opc.Ua.StatusCodes.{sdkName} " +
|
||||
$"is 0x{expected:X8}" +
|
||||
(SdkStatusCodes.FirstOrDefault(kv => kv.Value == declared) is { Key: { } actual } && actual.Length > 0
|
||||
? $" — 0x{declared:X8} is actually {actual}, which is what OPC UA clients would branch on."
|
||||
: ". The declared value is not any OPC UA status code."));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Discovery must find constants in more than one driver assembly. A zero (or near-zero) here means
|
||||
/// the bin scan broke or the drivers stopped declaring these by convention — either way the theory
|
||||
/// above would pass vacuously, which is the one outcome a guard test must never do quietly.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Discovery_finds_status_constants_across_multiple_driver_assemblies()
|
||||
{
|
||||
var found = DeclaredStatusConstants();
|
||||
found.Count.ShouldBeGreaterThan(20,
|
||||
"the driver bin scan found almost no status constants — the assemblies did not deploy to bin, " +
|
||||
"or the naming convention changed");
|
||||
|
||||
var assemblies = found
|
||||
.Select(row => row.Data.Item1)
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
|
||||
assemblies.Length.ShouldBeGreaterThan(3,
|
||||
"status constants were found in fewer than four driver assemblies: " + string.Join(", ", assemblies));
|
||||
}
|
||||
}
|
||||
+7
@@ -12,6 +12,8 @@
|
||||
<ItemGroup>
|
||||
<PackageReference Include="xunit.v3"/>
|
||||
<PackageReference Include="Shouldly"/>
|
||||
<!-- StatusCodeParityTests only; supplies Opc.Ua.StatusCodes as the oracle. -->
|
||||
<PackageReference Include="OPCFoundation.NetStandard.Opc.Ua.Core"/>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk"/>
|
||||
<PackageReference Include="xunit.runner.visualstudio">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
@@ -34,6 +36,11 @@
|
||||
<ProjectReference Include="..\..\..\src\Drivers\ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient\ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.csproj"/>
|
||||
<ProjectReference Include="..\..\..\src\Drivers\ZB.MOM.WW.OtOpcUa.Driver.Galaxy\ZB.MOM.WW.OtOpcUa.Driver.Galaxy.csproj"/>
|
||||
<ProjectReference Include="..\..\..\src\Drivers\ZB.MOM.WW.OtOpcUa.Driver.Calculation\ZB.MOM.WW.OtOpcUa.Driver.Calculation.csproj"/>
|
||||
<ProjectReference Include="..\..\..\src\Drivers\ZB.MOM.WW.OtOpcUa.Driver.Sql\ZB.MOM.WW.OtOpcUa.Driver.Sql.csproj"/>
|
||||
<!-- Ships no driver factory (it is a historian backend, not an Equipment driver), so the
|
||||
DriverTypeNames guard skips it — but it does hard-code OPC UA status constants, which
|
||||
puts it in StatusCodeParityTests' scope. -->
|
||||
<ProjectReference Include="..\..\..\src\Drivers\ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway\ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.csproj"/>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
+180
@@ -0,0 +1,180 @@
|
||||
using Serilog;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Scripting;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// #487 — <see cref="ScriptedAlarmEngine.GetProjections"/>: the read that lets a caller restore an
|
||||
/// alarm's condition node after a full address-space rebuild, without waiting for the alarm's next
|
||||
/// transition (which for a still-active alarm with static dependencies may never come).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The load that motivates this is deliberately the one that emits NOTHING: an alarm whose persisted
|
||||
/// state is already Active and whose predicate is still true produces <see cref="EmissionKind.None"/>,
|
||||
/// so the transition stream cannot carry its state. These tests pin both halves — the projection does
|
||||
/// report the state, and reading it does not fire an emission.
|
||||
/// </remarks>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class ScriptedAlarmProjectionTests
|
||||
{
|
||||
private static ScriptedAlarmDefinition Alarm(
|
||||
string id = "HighTemp",
|
||||
string predicate = """return (int)ctx.GetTag("Temp").Value > 100;""",
|
||||
string msg = "Temp is {Temp}",
|
||||
AlarmSeverity sev = AlarmSeverity.High) =>
|
||||
new(AlarmId: id,
|
||||
EquipmentPath: "Plant/Line1/Reactor",
|
||||
AlarmName: id,
|
||||
Kind: AlarmKind.AlarmCondition,
|
||||
Severity: sev,
|
||||
MessageTemplate: msg,
|
||||
PredicateScriptSource: predicate);
|
||||
|
||||
/// <summary>An already-Active persisted state, as a store would hold it across a restart/redeploy.</summary>
|
||||
private static AlarmConditionState PersistedActive(
|
||||
string alarmId = "HighTemp",
|
||||
AlarmAckedState acked = AlarmAckedState.Unacknowledged,
|
||||
DateTime? lastTransitionUtc = null) =>
|
||||
new(alarmId,
|
||||
AlarmEnabledState.Enabled,
|
||||
AlarmActiveState.Active,
|
||||
acked,
|
||||
AlarmConfirmedState.Confirmed,
|
||||
ShelvingState.Unshelved,
|
||||
lastTransitionUtc ?? DateTime.UtcNow,
|
||||
LastActiveUtc: lastTransitionUtc ?? DateTime.UtcNow,
|
||||
LastClearedUtc: null,
|
||||
LastAckUtc: null, LastAckUser: null, LastAckComment: null,
|
||||
LastConfirmUtc: null, LastConfirmUser: null, LastConfirmComment: null,
|
||||
Comments: []);
|
||||
|
||||
private static ScriptedAlarmEngine Build(FakeUpstream up, IAlarmStateStore store)
|
||||
{
|
||||
var logger = new LoggerConfiguration().CreateLogger();
|
||||
return new ScriptedAlarmEngine(up, store, new ScriptLoggerFactory(logger), logger);
|
||||
}
|
||||
|
||||
/// <summary>The core of #487: a reload of a still-active alarm emits NOTHING, yet the projection
|
||||
/// reports it Active — so a caller has a way to restore the node the rebuild reset.</summary>
|
||||
[Fact]
|
||||
public async Task A_still_active_alarm_emits_nothing_on_reload_but_projects_Active()
|
||||
{
|
||||
var up = new FakeUpstream();
|
||||
up.Set("Temp", 150); // predicate still true — no transition to report
|
||||
var store = new InMemoryAlarmStateStore();
|
||||
await store.SaveAsync(PersistedActive(), TestContext.Current.CancellationToken);
|
||||
|
||||
using var eng = Build(up, store);
|
||||
var emissions = new List<ScriptedAlarmEvent>();
|
||||
eng.OnEvent += (_, e) => { lock (emissions) emissions.Add(e); }; // subscribe BEFORE the load
|
||||
|
||||
await eng.LoadAsync([Alarm()], TestContext.Current.CancellationToken);
|
||||
|
||||
// The premise. If this ever starts emitting, the bug's shape has changed and the re-assert
|
||||
// in ScriptedAlarmHostActor may have become a duplicate rather than the only source of truth.
|
||||
lock (emissions)
|
||||
{
|
||||
emissions.ShouldBeEmpty("a still-active alarm has no transition to report on reload");
|
||||
}
|
||||
|
||||
var projection = eng.GetProjections().ShouldHaveSingleItem();
|
||||
projection.AlarmId.ShouldBe("HighTemp");
|
||||
projection.Condition.Active.ShouldBe(AlarmActiveState.Active);
|
||||
projection.Condition.Acked.ShouldBe(AlarmAckedState.Unacknowledged);
|
||||
}
|
||||
|
||||
/// <summary>The projection carries the definition's severity and the message template resolved against
|
||||
/// the live value cache — so a re-asserted node is indistinguishable from one a real transition wrote.</summary>
|
||||
[Fact]
|
||||
public async Task Projection_carries_definition_severity_and_a_resolved_message()
|
||||
{
|
||||
var up = new FakeUpstream();
|
||||
up.Set("Temp", 150);
|
||||
var store = new InMemoryAlarmStateStore();
|
||||
await store.SaveAsync(PersistedActive(), TestContext.Current.CancellationToken);
|
||||
|
||||
using var eng = Build(up, store);
|
||||
await eng.LoadAsync([Alarm(sev: AlarmSeverity.Critical)], TestContext.Current.CancellationToken);
|
||||
|
||||
var projection = eng.GetProjections().ShouldHaveSingleItem();
|
||||
projection.Severity.ShouldBe(AlarmSeverity.Critical);
|
||||
projection.Message.ShouldBe("Temp is 150", "the template resolves against the current value cache");
|
||||
}
|
||||
|
||||
/// <summary>Reading projections is a pure read — it must never raise <see cref="ScriptedAlarmEngine.OnEvent"/>,
|
||||
/// or every deploy would append a duplicate historian / alerts row.</summary>
|
||||
[Fact]
|
||||
public async Task Reading_projections_never_fires_an_emission()
|
||||
{
|
||||
var up = new FakeUpstream();
|
||||
up.Set("Temp", 150);
|
||||
var store = new InMemoryAlarmStateStore();
|
||||
await store.SaveAsync(PersistedActive(), TestContext.Current.CancellationToken);
|
||||
|
||||
using var eng = Build(up, store);
|
||||
await eng.LoadAsync([Alarm()], TestContext.Current.CancellationToken);
|
||||
|
||||
var emissions = 0;
|
||||
eng.OnEvent += (_, _) => Interlocked.Increment(ref emissions);
|
||||
|
||||
for (var i = 0; i < 5; i++) eng.GetProjections().ShouldNotBeEmpty();
|
||||
|
||||
Volatile.Read(ref emissions).ShouldBe(0);
|
||||
}
|
||||
|
||||
/// <summary>An alarm that is genuinely normal projects the no-event position, so a caller can tell
|
||||
/// "nothing to restore" from "restore this".</summary>
|
||||
[Fact]
|
||||
public async Task A_normal_alarm_projects_the_no_event_position()
|
||||
{
|
||||
var up = new FakeUpstream();
|
||||
up.Set("Temp", 50); // predicate false
|
||||
using var eng = Build(up, new InMemoryAlarmStateStore());
|
||||
|
||||
await eng.LoadAsync([Alarm()], TestContext.Current.CancellationToken);
|
||||
|
||||
var projection = eng.GetProjections().ShouldHaveSingleItem();
|
||||
projection.Condition.Active.ShouldBe(AlarmActiveState.Inactive);
|
||||
projection.Condition.Acked.ShouldBe(AlarmAckedState.Acknowledged);
|
||||
projection.Condition.Enabled.ShouldBe(AlarmEnabledState.Enabled);
|
||||
projection.Condition.Shelving.Kind.ShouldBe(ShelvingKind.Unshelved);
|
||||
}
|
||||
|
||||
/// <summary>The worst-input quality rides the projection, so restoring a node whose script inputs are
|
||||
/// Bad does not clobber its Quality back to Good (#478's invariant, on the re-assert path).</summary>
|
||||
[Fact]
|
||||
public async Task Projection_carries_the_last_observed_worst_input_quality()
|
||||
{
|
||||
var up = new FakeUpstream();
|
||||
up.Set("Temp", 150);
|
||||
var store = new InMemoryAlarmStateStore();
|
||||
await store.SaveAsync(PersistedActive(), TestContext.Current.CancellationToken);
|
||||
|
||||
using var eng = Build(up, store);
|
||||
await eng.LoadAsync([Alarm()], TestContext.Current.CancellationToken);
|
||||
eng.GetProjections().ShouldHaveSingleItem().WorstInputStatusCode.ShouldBe(0u, "inputs start Good");
|
||||
|
||||
up.Push("Temp", 150, 0x80000000u); // input goes Bad — condition freezes, quality is annotated
|
||||
|
||||
// The push re-evaluates on a background worker; poll for the tracked bucket to move.
|
||||
var deadline = DateTime.UtcNow.AddSeconds(10);
|
||||
while (eng.GetProjections()[0].WorstInputStatusCode == 0u && DateTime.UtcNow < deadline)
|
||||
{
|
||||
await Task.Delay(20, TestContext.Current.CancellationToken);
|
||||
}
|
||||
|
||||
(eng.GetProjections()[0].WorstInputStatusCode >> 30).ShouldBe(2u, "a Bad input projects Bad quality");
|
||||
}
|
||||
|
||||
/// <summary>Before the first load there is nothing to project — the read is safe, not a throw.</summary>
|
||||
[Fact]
|
||||
public void Projections_are_empty_before_the_first_load()
|
||||
{
|
||||
using var eng = Build(new FakeUpstream(), new InMemoryAlarmStateStore());
|
||||
eng.GetProjections().ShouldBeEmpty();
|
||||
}
|
||||
}
|
||||
+151
-49
@@ -10,59 +10,109 @@ using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Runtime;
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests.Runtime;
|
||||
|
||||
/// <summary>
|
||||
/// PR 6.2 — pins the EventPump's bounded-channel + drop-newest behavior. We
|
||||
/// hold the dispatch loop with a slow handler so the channel fills, then verify
|
||||
/// the producer keeps reading from the gw stream and increments the
|
||||
/// <c>galaxy.events.dropped</c> counter rather than blocking.
|
||||
/// PR 6.2 — pins the EventPump's bounded-channel + drop-newest behavior. We hold the dispatch loop
|
||||
/// with a blocking handler so the channel fills, then verify the producer keeps reading from the gw
|
||||
/// stream and increments the <c>galaxy.events.dropped</c> counter rather than blocking.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// These tests observe two background loops (<c>RunAsync</c> / <c>DispatchLoopAsync</c>) through a
|
||||
/// process-wide <see cref="Meter"/>, so every wait here is a <b>presence</b> assertion polled up to
|
||||
/// <see cref="Budget"/> — never a fixed sleep. A fixed sleep is a bet that both loops get scheduled
|
||||
/// within it, and #503 is what losing that bet looks like when the box is busy running other test
|
||||
/// assemblies.
|
||||
/// </remarks>
|
||||
public sealed class EventPumpBoundedChannelTests
|
||||
{
|
||||
/// <summary>Verifies that the event pump drops newest events when the bounded channel fills and records metrics for dropped events.</summary>
|
||||
/// <summary>Upper bound on how long a poll waits for a background loop to reach its terminal state.
|
||||
/// Generous on purpose: it is spent only when something is genuinely broken, and it cannot turn a
|
||||
/// failing assertion into a passing one.</summary>
|
||||
private static readonly TimeSpan Budget = TimeSpan.FromSeconds(15);
|
||||
|
||||
/// <summary>
|
||||
/// With the dispatch loop genuinely held and the channel therefore full, the producer keeps
|
||||
/// draining the gw stream and counts every rejected write on <c>galaxy.events.dropped</c> —
|
||||
/// newest-dropped, not back-pressure.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The arrangement is staged so the arithmetic is exact rather than scheduler-dependent
|
||||
/// (#503): emit ONE event, wait until the dispatcher has actually entered the handler (so it
|
||||
/// holds that event and the channel is empty), and only then emit the remaining nine. The
|
||||
/// channel accepts <c>capacity</c> of them and the rest must be dropped —
|
||||
/// <c>10 − 1 held − 2 buffered = 7</c>, every run.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task Drops_newest_when_channel_fills_and_records_metric()
|
||||
{
|
||||
var counters = StartMeterCapture();
|
||||
const int totalEvents = 10;
|
||||
const int channelCapacity = 2;
|
||||
// One event is held inside the handler; `channelCapacity` more fit in the channel; the rest are dropped.
|
||||
const int expectedDropped = totalEvents - 1 - channelCapacity;
|
||||
|
||||
// Unique per run: the counters are filtered to this exact galaxy.client tag, which is what keeps a
|
||||
// parallel sibling test's pump out of them (#503). A literal name would work today and break the
|
||||
// day someone reuses it.
|
||||
var clientName = $"PumpTest-{Guid.NewGuid():N}";
|
||||
var counters = StartMeterCapture(clientName);
|
||||
|
||||
// A SYNCHRONOUS gate, deliberately. OnDataChange is EventHandler<T> — void-returning — so an
|
||||
// `async (_, _) => await gate` lambda is async void: it returns to the dispatch loop at the first
|
||||
// await and holds nothing. That is exactly what #503 was: the loop drained freely, drops happened
|
||||
// only when the producer happened to outrun the consumer, and the assertions rode on scheduling
|
||||
// luck. Blocking the loop's own thread is what makes "the dispatcher is held" true.
|
||||
var dispatchGate = new ManualResetEventSlim(false);
|
||||
var handlerEntered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
EventPump? pump = null;
|
||||
try
|
||||
{
|
||||
var subscriber = new ManualSubscriber();
|
||||
var registry = new SubscriptionRegistry();
|
||||
registry.Register(1, [new TagBinding("Tag.A", ItemHandle: 7)]);
|
||||
|
||||
// Tiny channel + slow handler ⇒ producer hits FullMode.Wait → TryWrite false
|
||||
// for every overflow event.
|
||||
var dispatchGate = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
await using var pump = new EventPump(
|
||||
subscriber, registry, channelCapacity: 2, clientName: "PumpTest");
|
||||
pump.OnDataChange += async (_, _) =>
|
||||
pump = new EventPump(
|
||||
subscriber, registry, channelCapacity: channelCapacity, clientName: clientName);
|
||||
pump.OnDataChange += (_, _) =>
|
||||
{
|
||||
// Block the dispatch loop until we've shoved enough events through to
|
||||
// overflow the bounded channel. Consume the gate exactly once.
|
||||
await dispatchGate.Task.ConfigureAwait(false);
|
||||
handlerEntered.TrySetResult();
|
||||
dispatchGate.Wait();
|
||||
};
|
||||
pump.Start();
|
||||
|
||||
const int totalEvents = 10;
|
||||
for (var i = 0; i < totalEvents; i++)
|
||||
// Stage 1 — one event, then wait for the dispatcher to take it and block. After this the
|
||||
// channel is provably empty and exactly one event is in flight inside the handler.
|
||||
await subscriber.EmitAsync(itemHandle: 7, value: 0);
|
||||
await handlerEntered.Task.WaitAsync(Budget);
|
||||
|
||||
// Stage 2 — the rest. `channelCapacity` are accepted; the remainder must be dropped.
|
||||
for (var i = 1; i < totalEvents; i++)
|
||||
{
|
||||
await subscriber.EmitAsync(itemHandle: 7, value: i);
|
||||
}
|
||||
// Give the producer a beat to run TryWrite for every event.
|
||||
await Task.Delay(150);
|
||||
|
||||
// Capacity 2 + 1 in-flight in the dispatcher = 3 may have been accepted; the
|
||||
// remainder should have hit the dropped counter. Don't pin exact counts —
|
||||
// the scheduler can interleave; pin the invariants instead.
|
||||
counters.Received.ShouldBeGreaterThanOrEqualTo(totalEvents);
|
||||
counters.Dropped.ShouldBeGreaterThan(0,
|
||||
"with capacity=2 and a held dispatcher we must drop at least one of 10 events");
|
||||
(counters.Received).ShouldBe(counters.Dispatched + counters.Dropped + counters.InFlight,
|
||||
"received = dispatched + dropped + (events still queued)");
|
||||
// PRESENCE assertion on the discriminator (#500's rule): poll until the drop count reaches its
|
||||
// terminal value rather than sleeping a fixed 150 ms and hoping the producer got scheduled.
|
||||
// The budget is an upper bound before giving up — spending it costs nothing on the happy path,
|
||||
// and no amount of it can make a genuinely broken pump pass. Dropped starts at 0, so this
|
||||
// cannot be satisfied by the initial state.
|
||||
await WaitUntilAsync(() => counters.Dropped == expectedDropped,
|
||||
$"expected {expectedDropped} dropped events");
|
||||
|
||||
// Release the dispatcher so DisposeAsync can drain cleanly.
|
||||
dispatchGate.TrySetResult();
|
||||
counters.Received.ShouldBe(totalEvents,
|
||||
"the producer must keep reading the gw stream through the overflow, not back-pressure");
|
||||
counters.Dropped.ShouldBe(expectedDropped);
|
||||
counters.Dispatched.ShouldBe(0,
|
||||
"the dispatch loop is still blocked in the handler, so nothing has completed dispatch — " +
|
||||
"if this is non-zero the handler is not actually holding the loop and the test proves nothing");
|
||||
}
|
||||
finally
|
||||
{
|
||||
// Release BEFORE disposing: DisposeAsync awaits the dispatch loop, and that loop is parked on
|
||||
// this gate. Releasing it here (not at the end of the try) is what keeps a failed assertion a
|
||||
// failure instead of a hang.
|
||||
dispatchGate.Set();
|
||||
if (pump is not null) await pump.DisposeAsync();
|
||||
dispatchGate.Dispose();
|
||||
counters.Dispose();
|
||||
}
|
||||
}
|
||||
@@ -110,23 +160,21 @@ public sealed class EventPumpBoundedChannelTests
|
||||
// Poll until at least one galaxy.events.received measurement tagged
|
||||
// galaxy.client=Driver-X lands in the listener, rather than using a
|
||||
// fixed delay that races under parallel test load on a busy box.
|
||||
var deadline = DateTime.UtcNow.AddSeconds(5);
|
||||
bool found = false;
|
||||
while (DateTime.UtcNow < deadline)
|
||||
{
|
||||
listener.RecordObservableInstruments();
|
||||
bool hasMatch;
|
||||
lock (captured)
|
||||
// Budget, not 5 s: the same contention that produced #503 stretches how long the
|
||||
// producer loop takes to be scheduled, and a presence poll costs nothing to over-budget.
|
||||
await WaitUntilAsync(
|
||||
() =>
|
||||
{
|
||||
hasMatch = captured.Any(c =>
|
||||
c.Instrument == "galaxy.events.received" &&
|
||||
c.Tags.Any(t => t.Key == "galaxy.client" &&
|
||||
string.Equals((string?)t.Value, "Driver-X", StringComparison.Ordinal)));
|
||||
}
|
||||
if (hasMatch) { found = true; break; }
|
||||
await Task.Delay(25);
|
||||
}
|
||||
_ = found; // assertion happens below after dispose
|
||||
listener.RecordObservableInstruments();
|
||||
lock (captured)
|
||||
{
|
||||
return captured.Any(c =>
|
||||
c.Instrument == "galaxy.events.received" &&
|
||||
c.Tags.Any(t => t.Key == "galaxy.client" &&
|
||||
string.Equals((string?)t.Value, "Driver-X", StringComparison.Ordinal)));
|
||||
}
|
||||
},
|
||||
"a galaxy.events.received measurement tagged galaxy.client=Driver-X");
|
||||
}
|
||||
|
||||
// The static Meter is shared across all EventPump instances in the test
|
||||
@@ -146,7 +194,46 @@ public sealed class EventPumpBoundedChannelTests
|
||||
ours.ShouldContain(c => c.Instrument == "galaxy.events.received");
|
||||
}
|
||||
|
||||
private static CounterCapture StartMeterCapture()
|
||||
/// <summary>Polls <paramref name="condition"/> until it holds or <see cref="Budget"/> elapses, failing
|
||||
/// with <paramref name="what"/> on timeout. The presence-assertion counterpart to a fixed delay.</summary>
|
||||
private static async Task WaitUntilAsync(Func<bool> condition, string what)
|
||||
{
|
||||
var deadline = DateTime.UtcNow + Budget;
|
||||
while (DateTime.UtcNow < deadline)
|
||||
{
|
||||
if (condition()) return;
|
||||
await Task.Delay(10);
|
||||
}
|
||||
condition().ShouldBeTrue($"timed out after {Budget.TotalSeconds:0}s waiting: {what}");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Captures the three EventPump counters for <b>one specific pump</b>, identified by its
|
||||
/// <c>galaxy.client</c> tag.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The <c>galaxy.client</c> filter is the fix for #503, and it is not optional. The pump's
|
||||
/// <see cref="Meter"/> is <b>static</b> — one instance for the whole process — and
|
||||
/// <c>InstrumentPublished</c> can only select by <i>meter</i>, so an unfiltered listener
|
||||
/// receives measurements from every <see cref="EventPump"/> alive anywhere in the assembly.
|
||||
/// xUnit runs test classes in parallel and several of them build pumps
|
||||
/// (<c>EventPumpStreamFaultTests</c>, the <c>Driver-X</c> test below), so a sibling's events
|
||||
/// landed in these counters — that is why the flake needed a busy box: it needed the two
|
||||
/// tests to overlap in time. The leak was measured directly, as <c>Received = 11</c> in a run
|
||||
/// that emitted 10.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The old <c>Received == Dispatched + Dropped + InFlight</c> assertion is what turned the
|
||||
/// leak into a failure: a foreign increment landing between its four separate
|
||||
/// <see cref="Interlocked.Read(ref long)"/> calls made the two sides disagree. Hence the
|
||||
/// reported subject, <c>counters.Received</c>.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
/// <param name="clientName">The <c>galaxy.client</c> tag value to accept. Must be unique to the
|
||||
/// calling test — pass a fresh GUID-suffixed name rather than a literal, so a future sibling test
|
||||
/// cannot silently reintroduce the leak by reusing the same name.</param>
|
||||
private static CounterCapture StartMeterCapture(string clientName)
|
||||
{
|
||||
var capture = new CounterCapture();
|
||||
var listener = new MeterListener();
|
||||
@@ -154,8 +241,20 @@ public sealed class EventPumpBoundedChannelTests
|
||||
{
|
||||
if (instr.Meter.Name == EventPump.MeterName) l.EnableMeasurementEvents(instr);
|
||||
};
|
||||
listener.SetMeasurementEventCallback<long>((instr, value, _, _) =>
|
||||
listener.SetMeasurementEventCallback<long>((instr, value, tags, _) =>
|
||||
{
|
||||
var mine = false;
|
||||
foreach (var tag in tags)
|
||||
{
|
||||
if (tag.Key == "galaxy.client" &&
|
||||
string.Equals((string?)tag.Value, clientName, StringComparison.Ordinal))
|
||||
{
|
||||
mine = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!mine) return;
|
||||
|
||||
switch (instr.Name)
|
||||
{
|
||||
case "galaxy.events.received": Interlocked.Add(ref capture._received, value); break;
|
||||
@@ -178,8 +277,11 @@ public sealed class EventPumpBoundedChannelTests
|
||||
public long Dispatched => Interlocked.Read(ref _dispatched);
|
||||
/// <summary>Gets the count of dropped events.</summary>
|
||||
public long Dropped => Interlocked.Read(ref _dropped);
|
||||
/// <summary>Gets the count of in-flight events.</summary>
|
||||
public long InFlight => Math.Max(0, Received - Dispatched - Dropped);
|
||||
// There is deliberately no InFlight member. It could only be DERIVED as
|
||||
// Received - Dispatched - Dropped, which made the old
|
||||
// `Received == Dispatched + Dropped + InFlight` assertion a tautology: it could not fail on a
|
||||
// real defect, only on a torn read across the four separate Interlocked.Read calls. Assert the
|
||||
// three measured counters directly instead (#503).
|
||||
/// <summary>Disposes the meter listener.</summary>
|
||||
public void Dispose() => Listener?.Dispose();
|
||||
}
|
||||
|
||||
@@ -19,12 +19,12 @@ public sealed class StatusCodeMapTests
|
||||
/// <param name="expected">The expected OPC UA status code.</param>
|
||||
[Theory]
|
||||
[InlineData((byte)192, 0x00000000u)] // Good
|
||||
[InlineData((byte)216, 0x00D80000u)] // Good_LocalOverride
|
||||
[InlineData((byte)216, 0x00960000u)] // Good_LocalOverride
|
||||
[InlineData((byte)64, 0x40000000u)] // Uncertain
|
||||
[InlineData((byte)68, 0x40A40000u)] // Uncertain_LastUsableValue
|
||||
[InlineData((byte)80, 0x408D0000u)] // Uncertain_SensorNotAccurate
|
||||
[InlineData((byte)84, 0x408E0000u)] // Uncertain_EngineeringUnitsExceeded
|
||||
[InlineData((byte)88, 0x408F0000u)] // Uncertain_SubNormal
|
||||
[InlineData((byte)68, 0x40900000u)] // Uncertain_LastUsableValue
|
||||
[InlineData((byte)80, 0x40930000u)] // Uncertain_SensorNotAccurate
|
||||
[InlineData((byte)84, 0x40940000u)] // Uncertain_EngineeringUnitsExceeded
|
||||
[InlineData((byte)88, 0x40950000u)] // Uncertain_SubNormal
|
||||
[InlineData((byte)0, 0x80000000u)] // Bad
|
||||
[InlineData((byte)4, 0x80890000u)] // Bad_ConfigurationError
|
||||
[InlineData((byte)8, 0x808A0000u)] // Bad_NotConnected
|
||||
@@ -132,9 +132,9 @@ public sealed class StatusCodeMapTests
|
||||
/// <param name="expected">The expected OPC DA category byte.</param>
|
||||
[Theory]
|
||||
[InlineData(0x00000000u, (byte)192)] // Good
|
||||
[InlineData(0x00D80000u, (byte)192)] // GoodLocalOverride — still Good category
|
||||
[InlineData(0x00960000u, (byte)192)] // GoodLocalOverride — still Good category
|
||||
[InlineData(0x40000000u, (byte)64)] // Uncertain
|
||||
[InlineData(0x408F0000u, (byte)64)] // UncertainSubNormal — still Uncertain category
|
||||
[InlineData(0x40950000u, (byte)64)] // UncertainSubNormal — still Uncertain category
|
||||
[InlineData(0x80000000u, (byte)0)] // Bad
|
||||
[InlineData(0x808A0000u, (byte)0)] // BadNotConnected — still Bad category
|
||||
[InlineData(0x80020000u, (byte)0)] // BadInternalError — still Bad category
|
||||
|
||||
+1
-1
@@ -7,7 +7,7 @@ public sealed class GatewayQualityMapperTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData(192, 0x00000000u)] // Good
|
||||
[InlineData(216, 0x00D80000u)] // Good_LocalOverride
|
||||
[InlineData(216, 0x00960000u)] // Good_LocalOverride
|
||||
[InlineData(64, 0x40000000u)] // Uncertain
|
||||
[InlineData(0, 0x80000000u)] // Bad
|
||||
[InlineData(8, 0x808A0000u)] // Bad_NotConnected
|
||||
|
||||
+1
-1
@@ -37,7 +37,7 @@ public sealed class SampleMapperTests
|
||||
{
|
||||
var a = new HistorianAggregateSample { Tag = "T", /* Value unset, no Good quality */ EndTime = Ts(2026, 1, 1, 0, 0, 0) };
|
||||
var snap = SampleMapper.ToAggregateSnapshot(a);
|
||||
Assert.Equal(0x800E0000u, snap.StatusCode); // BadNoData
|
||||
Assert.Equal(0x809B0000u, snap.StatusCode); // BadNoData
|
||||
Assert.Null(snap.Value);
|
||||
}
|
||||
|
||||
|
||||
@@ -21,7 +21,10 @@ COPY profiles/ /fixtures/
|
||||
# compose profile. See Docker/README.md §exception injection.
|
||||
COPY exception_injector.py /fixtures/
|
||||
|
||||
EXPOSE 5020
|
||||
# 5020 = the shared port for the standard/dl205/mitsubishi/s7_1500/exception_injection
|
||||
# profiles (only one binds it at a time); 5021 = the rtu_over_tcp profile, which co-runs
|
||||
# with standard. Image-metadata honesty only — compose's ports: mapping doesn't need EXPOSE.
|
||||
EXPOSE 5020 5021
|
||||
|
||||
# Default to the standard profile; docker-compose.yml overrides per service.
|
||||
# --http_port intentionally omitted; pymodbus 3.13's web UI binds on a
|
||||
|
||||
@@ -2,15 +2,16 @@
|
||||
|
||||
The Modbus driver's integration tests talk to a
|
||||
[`pymodbus`](https://pymodbus.readthedocs.io/) simulator running as a
|
||||
pinned Docker container. One image, per-profile service in compose, same
|
||||
port binding (`5020`) regardless of which profile is live. Docker is the
|
||||
only supported launch path — a fresh clone needs Docker Desktop and
|
||||
nothing else.
|
||||
pinned Docker container. One image, per-profile service in compose. Most
|
||||
profiles bind `:5020`, so only one of *those* runs at a time; the
|
||||
`rtu_over_tcp` profile binds `:5021` and is designed to co-run alongside
|
||||
`standard`. Docker is the only supported launch path — a fresh clone needs
|
||||
Docker Desktop and nothing else.
|
||||
|
||||
| File | Purpose |
|
||||
|---|---|
|
||||
| [`Dockerfile`](Dockerfile) | `python:3.12-slim-bookworm` + `pymodbus[simulator]==3.13.0` + every profile JSON + `exception_injector.py` |
|
||||
| [`docker-compose.yml`](docker-compose.yml) | One service per profile (`standard` / `dl205` / `mitsubishi` / `s7_1500` / `exception_injection`); all bind `:5020` so only one runs at a time |
|
||||
| [`docker-compose.yml`](docker-compose.yml) | One service per profile (`standard` / `dl205` / `mitsubishi` / `s7_1500` / `exception_injection` bind `:5020`, so only one of those runs at a time; `rtu_over_tcp` binds `:5021` and can run alongside `standard`) |
|
||||
| [`profiles/*.json`](profiles/) | Same seed-register definitions the native launcher uses — canonical source |
|
||||
| [`exception_injector.py`](exception_injector.py) | Pure-stdlib Modbus/TCP server that emits arbitrary exception codes per rule — used by the `exception_injection` profile |
|
||||
|
||||
@@ -43,10 +44,11 @@ docker compose -f tests\...\Docker\docker-compose.yml --profile dl205 up -d
|
||||
docker compose -f tests\...\Docker\docker-compose.yml --profile dl205 down
|
||||
```
|
||||
|
||||
Only one profile binds `:5020` at a time; switch by stopping the current
|
||||
service + starting another. The integration tests discriminate by a
|
||||
separate `MODBUS_SIM_PROFILE` env var so they skip correctly when the
|
||||
wrong profile is live.
|
||||
Only one of the `:5020` profiles binds at a time; switch by stopping the
|
||||
current service + starting another. (`rtu_over_tcp` is the exception — it
|
||||
binds `:5021` and co-runs with `standard`.) The integration tests
|
||||
discriminate by a separate `MODBUS_SIM_PROFILE` env var so they skip
|
||||
correctly when the wrong profile is live.
|
||||
|
||||
> **⚠️ Profile-cycling gotcha (bit us in the 2026-07 sweep — see
|
||||
> `archreview/plans/INTEGRATION-SWEEP-STATUS.md`).** Each profile is a
|
||||
|
||||
+24
@@ -78,6 +78,30 @@ services:
|
||||
"--json_file", "/fixtures/s7_1500.json"
|
||||
]
|
||||
|
||||
# RTU-over-TCP profile. Same pymodbus simulator, but the server is framed
|
||||
# RTU (framer=rtu) instead of socket/MBAP — this is what a serial→Ethernet
|
||||
# gateway presents. Binds :5021 (NOT the shared :5020) so it co-runs with
|
||||
# the `standard` profile: two families, two ports, no conflict. Serves
|
||||
# rtu_over_tcp.json (byte-for-byte standard.json + framer/port swap).
|
||||
rtu_over_tcp:
|
||||
profiles: ["rtu_over_tcp"]
|
||||
image: otopcua-pymodbus:3.13.0
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
container_name: otopcua-pymodbus-rtu_over_tcp
|
||||
labels:
|
||||
project: lmxopcua
|
||||
restart: "no"
|
||||
ports:
|
||||
- "5021:5021"
|
||||
command: [
|
||||
"pymodbus.simulator",
|
||||
"--modbus_server", "srv",
|
||||
"--modbus_device", "dev",
|
||||
"--json_file", "/fixtures/rtu_over_tcp.json"
|
||||
]
|
||||
|
||||
# Exception-injection profile. Runs the standalone pure-stdlib Modbus/TCP
|
||||
# server shipped as exception_injector.py instead of the pymodbus
|
||||
# simulator — pymodbus naturally emits only exception codes 02 + 03, and
|
||||
|
||||
+97
@@ -0,0 +1,97 @@
|
||||
{
|
||||
"_comment": "rtu_over_tcp.json — RTU-over-TCP Modbus server for the integration suite (RTU framing tunnelled over a socket, as a serial→Ethernet gateway presents it). Byte-for-byte standard.json EXCEPT the server block: framer=\"rtu\" (not \"socket\") + port 5021 (not 5020), so it co-runs with the standard profile on :5020. pymodbus 3.13's simulator honors framer=rtu on a TCP server — confirmed on the wire (controller spike, 2026-07-24): raw RTU FC03 read of HR[5] returned `01 03 02 00 05 78 47`, a pure RTU frame with no MBAP header. Layout is identical to standard.json: HR[0..31]=address-as-value, HR[100]=auto-increment, HR[200..209]=scratch, coils 1024..1055=alternating, coils 1100..1109=scratch. NOTE: pymodbus rejects unknown keys at device-list / setup level; explanatory comments live in the README + git history.",
|
||||
|
||||
"server_list": {
|
||||
"srv": {
|
||||
"comm": "tcp",
|
||||
"host": "0.0.0.0",
|
||||
"port": 5021,
|
||||
"framer": "rtu",
|
||||
"device_id": 1
|
||||
}
|
||||
},
|
||||
|
||||
"device_list": {
|
||||
"dev": {
|
||||
"setup": {
|
||||
"co size": 2048,
|
||||
"di size": 2048,
|
||||
"hr size": 2048,
|
||||
"ir size": 2048,
|
||||
"shared blocks": true,
|
||||
"type exception": false,
|
||||
"defaults": {
|
||||
"value": {"bits": 0, "uint16": 0, "uint32": 0, "float32": 0.0, "string": " "},
|
||||
"action": {"bits": null, "uint16": null, "uint32": null, "float32": null, "string": null}
|
||||
}
|
||||
},
|
||||
"invalid": [],
|
||||
"write": [
|
||||
[0, 31],
|
||||
[100, 100],
|
||||
[200, 209],
|
||||
[1024, 1055],
|
||||
[1100, 1109]
|
||||
],
|
||||
|
||||
"uint16": [
|
||||
{"addr": 0, "value": 0}, {"addr": 1, "value": 1},
|
||||
{"addr": 2, "value": 2}, {"addr": 3, "value": 3},
|
||||
{"addr": 4, "value": 4}, {"addr": 5, "value": 5},
|
||||
{"addr": 6, "value": 6}, {"addr": 7, "value": 7},
|
||||
{"addr": 8, "value": 8}, {"addr": 9, "value": 9},
|
||||
{"addr": 10, "value": 10}, {"addr": 11, "value": 11},
|
||||
{"addr": 12, "value": 12}, {"addr": 13, "value": 13},
|
||||
{"addr": 14, "value": 14}, {"addr": 15, "value": 15},
|
||||
{"addr": 16, "value": 16}, {"addr": 17, "value": 17},
|
||||
{"addr": 18, "value": 18}, {"addr": 19, "value": 19},
|
||||
{"addr": 20, "value": 20}, {"addr": 21, "value": 21},
|
||||
{"addr": 22, "value": 22}, {"addr": 23, "value": 23},
|
||||
{"addr": 24, "value": 24}, {"addr": 25, "value": 25},
|
||||
{"addr": 26, "value": 26}, {"addr": 27, "value": 27},
|
||||
{"addr": 28, "value": 28}, {"addr": 29, "value": 29},
|
||||
{"addr": 30, "value": 30}, {"addr": 31, "value": 31},
|
||||
|
||||
{"addr": 100, "value": 0,
|
||||
"action": "increment",
|
||||
"parameters": {"minval": 0, "maxval": 65535}},
|
||||
|
||||
{"addr": 200, "value": 0}, {"addr": 201, "value": 0},
|
||||
{"addr": 202, "value": 0}, {"addr": 203, "value": 0},
|
||||
{"addr": 204, "value": 0}, {"addr": 205, "value": 0},
|
||||
{"addr": 206, "value": 0}, {"addr": 207, "value": 0},
|
||||
{"addr": 208, "value": 0}, {"addr": 209, "value": 0}
|
||||
],
|
||||
|
||||
"bits": [
|
||||
{"addr": 1024, "value": 1}, {"addr": 1025, "value": 0},
|
||||
{"addr": 1026, "value": 1}, {"addr": 1027, "value": 0},
|
||||
{"addr": 1028, "value": 1}, {"addr": 1029, "value": 0},
|
||||
{"addr": 1030, "value": 1}, {"addr": 1031, "value": 0},
|
||||
{"addr": 1032, "value": 1}, {"addr": 1033, "value": 0},
|
||||
{"addr": 1034, "value": 1}, {"addr": 1035, "value": 0},
|
||||
{"addr": 1036, "value": 1}, {"addr": 1037, "value": 0},
|
||||
{"addr": 1038, "value": 1}, {"addr": 1039, "value": 0},
|
||||
{"addr": 1040, "value": 1}, {"addr": 1041, "value": 0},
|
||||
{"addr": 1042, "value": 1}, {"addr": 1043, "value": 0},
|
||||
{"addr": 1044, "value": 1}, {"addr": 1045, "value": 0},
|
||||
{"addr": 1046, "value": 1}, {"addr": 1047, "value": 0},
|
||||
{"addr": 1048, "value": 1}, {"addr": 1049, "value": 0},
|
||||
{"addr": 1050, "value": 1}, {"addr": 1051, "value": 0},
|
||||
{"addr": 1052, "value": 1}, {"addr": 1053, "value": 0},
|
||||
{"addr": 1054, "value": 1}, {"addr": 1055, "value": 0},
|
||||
|
||||
{"addr": 1100, "value": 0}, {"addr": 1101, "value": 0},
|
||||
{"addr": 1102, "value": 0}, {"addr": 1103, "value": 0},
|
||||
{"addr": 1104, "value": 0}, {"addr": 1105, "value": 0},
|
||||
{"addr": 1106, "value": 0}, {"addr": 1107, "value": 0},
|
||||
{"addr": 1108, "value": 0}, {"addr": 1109, "value": 0}
|
||||
],
|
||||
|
||||
"uint32": [],
|
||||
"float32": [],
|
||||
"string": [],
|
||||
"repeat": []
|
||||
}
|
||||
}
|
||||
}
|
||||
+82
@@ -0,0 +1,82 @@
|
||||
using System.Net.Sockets;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests;
|
||||
|
||||
/// <summary>
|
||||
/// Reachability probe for the RTU-over-TCP Modbus simulator (pymodbus in Docker with
|
||||
/// <c>framer=rtu</c>, see <c>Docker/docker-compose.yml</c> profile <c>rtu_over_tcp</c>) or a
|
||||
/// real serial→Ethernet Modbus gateway. Mirrors <see cref="ModbusSimulatorFixture"/> but reads
|
||||
/// <c>MODBUS_RTU_SIM_ENDPOINT</c> (default <c>10.100.0.35:5021</c> — the shared Docker host, a
|
||||
/// distinct port from the standard profile's <c>:5020</c> so the two fixtures co-run). TCP-connects
|
||||
/// once at fixture construction; each test checks <see cref="SkipReason"/> and calls
|
||||
/// <c>Assert.Skip</c> when the endpoint was unreachable, so a dev box without a running simulator
|
||||
/// still passes `dotnet test` cleanly.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Same one-shot-probe discipline as <see cref="ModbusSimulatorFixture"/>: the probe socket is
|
||||
/// not held for the life of the fixture (tests open their own <see cref="ModbusRtuOverTcpTransport"/>
|
||||
/// against the same endpoint), and the fixture is a collection fixture so the probe runs once per
|
||||
/// session rather than per test.
|
||||
/// </remarks>
|
||||
public sealed class ModbusRtuOverTcpFixture : IAsyncDisposable
|
||||
{
|
||||
// :5021 (not the standard profile's :5020) so the rtu_over_tcp container can co-run with the
|
||||
// standard container on the shared Docker host. 10.100.0.35 = the shared Docker host (see
|
||||
// CLAUDE.md "Docker Workflow"). Override with MODBUS_RTU_SIM_ENDPOINT to point at a real
|
||||
// serial→Ethernet Modbus gateway, or a locally-running container.
|
||||
private const string DefaultEndpoint = "10.100.0.35:5021";
|
||||
private const string EndpointEnvVar = "MODBUS_RTU_SIM_ENDPOINT";
|
||||
|
||||
/// <summary>Gets the host address of the RTU-over-TCP Modbus simulator.</summary>
|
||||
public string Host { get; }
|
||||
|
||||
/// <summary>Gets the port of the RTU-over-TCP Modbus simulator.</summary>
|
||||
public int Port { get; }
|
||||
|
||||
/// <summary>Gets the skip reason if the simulator is unreachable; otherwise null.</summary>
|
||||
public string? SkipReason { get; }
|
||||
|
||||
/// <summary>Initializes a new instance of the <see cref="ModbusRtuOverTcpFixture"/> class.</summary>
|
||||
public ModbusRtuOverTcpFixture()
|
||||
{
|
||||
var raw = Environment.GetEnvironmentVariable(EndpointEnvVar) ?? DefaultEndpoint;
|
||||
var parts = raw.Split(':', 2);
|
||||
Host = parts[0];
|
||||
Port = parts.Length == 2 && int.TryParse(parts[1], out var p) ? p : 502;
|
||||
|
||||
try
|
||||
{
|
||||
// Force IPv4 on the probe — pymodbus binds 0.0.0.0 (IPv4 only) while .NET default-resolves
|
||||
// "localhost" → IPv6 ::1 first and only then tries IPv4, surfacing as a 2s timeout under
|
||||
// .NET 10. Resolving + dialing explicit IPv4 sidesteps the dual-stack ordering. (Same
|
||||
// rationale as ModbusSimulatorFixture.)
|
||||
using var client = new TcpClient(System.Net.Sockets.AddressFamily.InterNetwork);
|
||||
var task = client.ConnectAsync(
|
||||
System.Net.Dns.GetHostAddresses(Host)
|
||||
.FirstOrDefault(a => a.AddressFamily == System.Net.Sockets.AddressFamily.InterNetwork)
|
||||
?? System.Net.IPAddress.Loopback,
|
||||
Port);
|
||||
if (!task.Wait(TimeSpan.FromSeconds(2)) || !client.Connected)
|
||||
{
|
||||
SkipReason = $"RTU-over-TCP Modbus simulator at {Host}:{Port} did not accept a TCP connection within 2s. " +
|
||||
$"Start the pymodbus Docker container (docker compose -f Docker/docker-compose.yml --profile rtu_over_tcp up -d --build) " +
|
||||
$"or override {EndpointEnvVar}, then re-run.";
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
SkipReason = $"RTU-over-TCP Modbus simulator at {Host}:{Port} unreachable: {ex.GetType().Name}: {ex.Message}. " +
|
||||
$"Start the pymodbus Docker container (docker compose -f Docker/docker-compose.yml --profile rtu_over_tcp up -d --build) " +
|
||||
$"or override {EndpointEnvVar}, then re-run.";
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
[Xunit.CollectionDefinition(Name)]
|
||||
public sealed class ModbusRtuOverTcpCollection : Xunit.ICollectionFixture<ModbusRtuOverTcpFixture>
|
||||
{
|
||||
public const string Name = "ModbusRtuOverTcp";
|
||||
}
|
||||
+94
@@ -0,0 +1,94 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests;
|
||||
|
||||
/// <summary>
|
||||
/// End-to-end round-trip against the <c>rtu_over_tcp.json</c> pymodbus profile (or a real
|
||||
/// serial→Ethernet Modbus gateway when <c>MODBUS_RTU_SIM_ENDPOINT</c> points at one), driving the
|
||||
/// full <see cref="ModbusDriver"/> + real <see cref="ModbusRtuOverTcpTransport"/> stack with
|
||||
/// <see cref="ModbusTransportMode.RtuOverTcp"/>. Proves the driver reads a seeded holding register
|
||||
/// and writes+reads-back a scratch register when the wire carries RTU framing
|
||||
/// (<c>[addr][PDU][CRC-16]</c>, no MBAP header) rather than Modbus/TCP.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// pymodbus 3.13's simulator honors <c>framer=rtu</c> on a TCP server — confirmed on the wire
|
||||
/// (controller spike, 2026-07-24): a raw RTU FC03 read of HR[5] returned
|
||||
/// <c>01 03 02 00 05 78 47</c>, a pure RTU frame with no MBAP header (data 0x0005 = 5). So the
|
||||
/// fixture is the plain pymodbus simulator with framer/port swapped — no stdlib fallback server.
|
||||
/// </remarks>
|
||||
[Collection(ModbusRtuOverTcpCollection.Name)]
|
||||
[Trait("Category", "Integration")]
|
||||
[Trait("Device", "RtuOverTcp")]
|
||||
public sealed class ModbusRtuOverTcpTests(ModbusRtuOverTcpFixture sim)
|
||||
{
|
||||
/// <summary>Reads a seeded holding register (HR[5]=5) over RTU-over-TCP framing.</summary>
|
||||
[Fact]
|
||||
public async Task Reads_a_seeded_holding_register_over_rtu_framing()
|
||||
{
|
||||
if (sim.SkipReason is not null) Assert.Skip(sim.SkipReason);
|
||||
var opts = new ModbusDriverOptions
|
||||
{
|
||||
Host = sim.Host,
|
||||
Port = sim.Port,
|
||||
UnitId = 1,
|
||||
Transport = ModbusTransportMode.RtuOverTcp,
|
||||
Timeout = TimeSpan.FromSeconds(2),
|
||||
Probe = new ModbusProbeOptions { Enabled = false },
|
||||
RawTags = ModbusRawTags.Entries([
|
||||
new ModbusTagDefinition(
|
||||
Name: "HR5",
|
||||
Region: ModbusRegion.HoldingRegisters,
|
||||
Address: 5,
|
||||
DataType: ModbusDataType.UInt16,
|
||||
Writable: false),
|
||||
]),
|
||||
};
|
||||
await using var driver = new ModbusDriver(opts, driverInstanceId: "modbus-rtu-int");
|
||||
await driver.InitializeAsync(driverConfigJson: "{}", TestContext.Current.CancellationToken);
|
||||
|
||||
var results = await driver.ReadAsync(["HR5"], TestContext.Current.CancellationToken);
|
||||
|
||||
results.Count.ShouldBe(1);
|
||||
results[0].StatusCode.ShouldBe(0u); // Good
|
||||
results[0].Value.ShouldBe((ushort)5); // HR[5] seeded = address-as-value
|
||||
}
|
||||
|
||||
/// <summary>Writes then reads back a scratch holding register (HR[200]) over RTU-over-TCP framing.</summary>
|
||||
[Fact]
|
||||
public async Task Writes_and_reads_back_a_holding_register_over_rtu_framing()
|
||||
{
|
||||
if (sim.SkipReason is not null) Assert.Skip(sim.SkipReason);
|
||||
var opts = new ModbusDriverOptions
|
||||
{
|
||||
Host = sim.Host,
|
||||
Port = sim.Port,
|
||||
UnitId = 1,
|
||||
Transport = ModbusTransportMode.RtuOverTcp,
|
||||
Timeout = TimeSpan.FromSeconds(2),
|
||||
Probe = new ModbusProbeOptions { Enabled = false },
|
||||
RawTags = ModbusRawTags.Entries([
|
||||
new ModbusTagDefinition(
|
||||
Name: "HR200",
|
||||
Region: ModbusRegion.HoldingRegisters,
|
||||
Address: 200,
|
||||
DataType: ModbusDataType.UInt16,
|
||||
Writable: true),
|
||||
]),
|
||||
};
|
||||
await using var driver = new ModbusDriver(opts, driverInstanceId: "modbus-rtu-int-w");
|
||||
await driver.InitializeAsync(driverConfigJson: "{}", TestContext.Current.CancellationToken);
|
||||
|
||||
var writes = await driver.WriteAsync(
|
||||
[new WriteRequest("HR200", (ushort)4242)],
|
||||
TestContext.Current.CancellationToken);
|
||||
writes.Count.ShouldBe(1);
|
||||
writes[0].StatusCode.ShouldBe(0u);
|
||||
|
||||
var back = await driver.ReadAsync(["HR200"], TestContext.Current.CancellationToken);
|
||||
back.Count.ShouldBe(1);
|
||||
back[0].StatusCode.ShouldBe(0u);
|
||||
back[0].Value.ShouldBe((ushort)4242);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests;
|
||||
|
||||
public sealed class ModbusCrcTests
|
||||
{
|
||||
// Known CRC-16/MODBUS vectors (init 0xFFFF, poly 0xA001). CRC returned as ushort;
|
||||
// on the wire it is appended low-byte-first.
|
||||
[Theory]
|
||||
// FC03 read HR: unit 1, addr 0, qty 1 -> CRC 0x0A84 (bytes 84 0A on the wire)
|
||||
[InlineData(new byte[] { 0x01, 0x03, 0x00, 0x00, 0x00, 0x01 }, (ushort)0x0A84)]
|
||||
// "123456789" canonical check value for CRC-16/MODBUS = 0x4B37
|
||||
[InlineData(new byte[] { 0x31,0x32,0x33,0x34,0x35,0x36,0x37,0x38,0x39 }, (ushort)0x4B37)]
|
||||
public void Compute_matches_known_vectors(byte[] frame, ushort expected)
|
||||
=> ModbusCrc.Compute(frame).ShouldBe(expected);
|
||||
|
||||
[Fact]
|
||||
public void AppendLowByteFirst_writes_lo_then_hi()
|
||||
{
|
||||
var body = new byte[] { 0x01, 0x03, 0x00, 0x00, 0x00, 0x01 };
|
||||
var withCrc = ModbusCrc.Append(body);
|
||||
withCrc.Length.ShouldBe(body.Length + 2);
|
||||
withCrc[^2].ShouldBe((byte)0x84); // CRC low byte
|
||||
withCrc[^1].ShouldBe((byte)0x0A); // CRC high byte
|
||||
}
|
||||
}
|
||||
+3
-3
@@ -14,7 +14,7 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests;
|
||||
/// (2) Sub-second <see cref="TimeSpan"/> values on <c>ModbusKeepAliveOptions.Time</c> /
|
||||
/// <c>Interval</c> — the int-cast in <c>EnableKeepAlive</c> truncated <c>500 ms</c> to
|
||||
/// <c>0</c>, which most OSes interpret as "use the default", silently defeating the
|
||||
/// configured timing. <c>ModbusTcpTransport.ClampToWholeSeconds</c> rounds up to a minimum
|
||||
/// configured timing. <c>ModbusSocketLifecycle.ClampToWholeSeconds</c> rounds up to a minimum
|
||||
/// of 1 second.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
@@ -70,7 +70,7 @@ public sealed class ModbusEdgeCaseValidationTests
|
||||
[InlineData(60_000, 60)]
|
||||
public void ClampToWholeSeconds_rounds_up_to_at_least_one_second(int ms, int expected)
|
||||
{
|
||||
ModbusTcpTransport.ClampToWholeSeconds(TimeSpan.FromMilliseconds(ms)).ShouldBe(expected);
|
||||
ModbusSocketLifecycle.ClampToWholeSeconds(TimeSpan.FromMilliseconds(ms)).ShouldBe(expected);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that negative time spans are treated as one second.</summary>
|
||||
@@ -80,6 +80,6 @@ public sealed class ModbusEdgeCaseValidationTests
|
||||
// Defensive — operators occasionally configure a negative TimeSpan thinking it disables
|
||||
// the feature. The OS would reject the negative int — clamping to 1 keeps the socket
|
||||
// valid until the operator fixes the config.
|
||||
ModbusTcpTransport.ClampToWholeSeconds(TimeSpan.FromSeconds(-5)).ShouldBe(1);
|
||||
ModbusSocketLifecycle.ClampToWholeSeconds(TimeSpan.FromSeconds(-5)).ShouldBe(1);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests;
|
||||
|
||||
public sealed class ModbusRtuFramingTests
|
||||
{
|
||||
private static MemoryStream Canned(params byte[] frame) => new(frame);
|
||||
|
||||
[Fact]
|
||||
public void BuildAdu_prefixes_unit_and_appends_crc()
|
||||
{
|
||||
var adu = ModbusRtuFraming.BuildAdu(0x01, new byte[] { 0x03, 0x00, 0x00, 0x00, 0x01 });
|
||||
adu.ShouldBe(new byte[] { 0x01, 0x03, 0x00, 0x00, 0x00, 0x01, 0x84, 0x0A });
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReadResponse_FC03_returns_bare_pdu()
|
||||
{
|
||||
// addr=01 fc=03 byteCount=02 data=00 0A + CRC(lo,hi)
|
||||
var body = new byte[] { 0x01, 0x03, 0x02, 0x00, 0x0A };
|
||||
var frame = ModbusCrc.Append(body);
|
||||
await using var s = Canned(frame);
|
||||
var pdu = await ModbusRtuFraming.ReadResponsePduAsync(s, 0x01, TestContext.Current.CancellationToken);
|
||||
pdu.ShouldBe(new byte[] { 0x03, 0x02, 0x00, 0x0A }); // fc + byteCount + data, no addr/CRC
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReadResponse_exception_frame_throws_ModbusException()
|
||||
{
|
||||
// addr=01 fc=0x83 exc=0x02 + CRC
|
||||
var frame = ModbusCrc.Append(new byte[] { 0x01, 0x83, 0x02 });
|
||||
await using var s = Canned(frame);
|
||||
var ex = await Should.ThrowAsync<ModbusException>(
|
||||
ModbusRtuFraming.ReadResponsePduAsync(s, 0x01, TestContext.Current.CancellationToken));
|
||||
ex.FunctionCode.ShouldBe((byte)0x03);
|
||||
ex.ExceptionCode.ShouldBe((byte)0x02);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReadResponse_bad_crc_throws_desync()
|
||||
{
|
||||
var frame = ModbusCrc.Append(new byte[] { 0x01, 0x03, 0x02, 0x00, 0x0A });
|
||||
frame[^1] ^= 0xFF; // corrupt CRC high byte
|
||||
await using var s = Canned(frame);
|
||||
var ex = await Should.ThrowAsync<ModbusTransportDesyncException>(
|
||||
ModbusRtuFraming.ReadResponsePduAsync(s, 0x01, TestContext.Current.CancellationToken));
|
||||
ex.Reason.ShouldBe(ModbusTransportDesyncException.DesyncReason.CrcMismatch);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReadResponse_FC06_write_echo_returns_four_byte_pdu()
|
||||
{
|
||||
// addr=01 fc=06 addr=00 07 value=00 2A + CRC -> PDU = 06 00 07 00 2A
|
||||
var frame = ModbusCrc.Append(new byte[] { 0x01, 0x06, 0x00, 0x07, 0x00, 0x2A });
|
||||
await using var s = Canned(frame);
|
||||
var pdu = await ModbusRtuFraming.ReadResponsePduAsync(s, 0x01, TestContext.Current.CancellationToken);
|
||||
pdu.ShouldBe(new byte[] { 0x06, 0x00, 0x07, 0x00, 0x2A });
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReadResponse_wrong_unit_address_throws_desync()
|
||||
{
|
||||
// CRC-valid FC03 frame addressed to unit 0x02, but we asked for 0x01 -> unit-mismatch desync.
|
||||
var frame = ModbusCrc.Append(new byte[] { 0x02, 0x03, 0x02, 0x00, 0x0A });
|
||||
await using var s = Canned(frame);
|
||||
var ex = await Should.ThrowAsync<ModbusTransportDesyncException>(
|
||||
ModbusRtuFraming.ReadResponsePduAsync(s, 0x01, TestContext.Current.CancellationToken));
|
||||
ex.Reason.ShouldBe(ModbusTransportDesyncException.DesyncReason.UnitMismatch);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReadResponse_FC03_deframes_correctly_when_delivered_in_dribbles()
|
||||
{
|
||||
// Same valid FC03 frame, but the stream hands back only 1-2 bytes per ReadAsync — proves
|
||||
// the ReadExactlyAsync accumulation loop reassembles a length-less frame across many reads.
|
||||
var frame = ModbusCrc.Append(new byte[] { 0x01, 0x03, 0x02, 0x00, 0x0A });
|
||||
await using var s = new DribbleStream(frame, chunk: 2);
|
||||
var pdu = await ModbusRtuFraming.ReadResponsePduAsync(s, 0x01, TestContext.Current.CancellationToken);
|
||||
pdu.ShouldBe(new byte[] { 0x03, 0x02, 0x00, 0x0A });
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReadResponse_stream_ends_early_throws_desync()
|
||||
{
|
||||
// Only the first 4 bytes of a 7-byte FC03 frame are available, then EOF (ReadAsync returns 0)
|
||||
// -> the truncation path in ReadExactlyAsync must surface a desync, not hang or mis-parse.
|
||||
var frame = ModbusCrc.Append(new byte[] { 0x01, 0x03, 0x02, 0x00, 0x0A });
|
||||
var truncated = frame[..4];
|
||||
await using var s = new DribbleStream(truncated, chunk: 2);
|
||||
var ex = await Should.ThrowAsync<ModbusTransportDesyncException>(
|
||||
ModbusRtuFraming.ReadResponsePduAsync(s, 0x01, TestContext.Current.CancellationToken));
|
||||
ex.Reason.ShouldBe(ModbusTransportDesyncException.DesyncReason.TruncatedFrame);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A read-only test double that dribbles its backing bytes out at most <c>chunk</c> at a time
|
||||
/// per <see cref="ReadAsync(Memory{byte}, CancellationToken)"/> call, then returns 0 (EOF)
|
||||
/// once exhausted. Exercises the partial-read accumulation loop and the truncation → desync
|
||||
/// path that a single-shot <see cref="MemoryStream"/> never reaches.
|
||||
/// </summary>
|
||||
private sealed class DribbleStream(byte[] data, int chunk) : Stream
|
||||
{
|
||||
private int _pos;
|
||||
|
||||
public override int Read(byte[] buffer, int offset, int count)
|
||||
{
|
||||
var n = Math.Min(Math.Min(chunk, count), data.Length - _pos);
|
||||
if (n <= 0) return 0;
|
||||
Array.Copy(data, _pos, buffer, offset, n);
|
||||
_pos += n;
|
||||
return n;
|
||||
}
|
||||
|
||||
public override ValueTask<int> ReadAsync(Memory<byte> buffer, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var n = Math.Min(Math.Min(chunk, buffer.Length), data.Length - _pos);
|
||||
if (n <= 0) return ValueTask.FromResult(0);
|
||||
data.AsSpan(_pos, n).CopyTo(buffer.Span);
|
||||
_pos += n;
|
||||
return ValueTask.FromResult(n);
|
||||
}
|
||||
|
||||
public override bool CanRead => true;
|
||||
public override bool CanSeek => false;
|
||||
public override bool CanWrite => false;
|
||||
public override long Length => data.Length;
|
||||
public override long Position { get => _pos; set => throw new NotSupportedException(); }
|
||||
public override void Flush() { }
|
||||
public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException();
|
||||
public override void SetLength(long value) => throw new NotSupportedException();
|
||||
public override void Write(byte[] buffer, int offset, int count) => throw new NotSupportedException();
|
||||
}
|
||||
}
|
||||
+110
@@ -0,0 +1,110 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Exercises <see cref="ModbusRtuOverTcpTransport"/>'s send/receive orchestration, single-flight,
|
||||
/// and per-op deadline against an in-memory duplex fake injected through the <c>ForTest</c> seam —
|
||||
/// no real socket. The fake records the request ADU the transport writes and replays a canned RTU
|
||||
/// response on read, or blocks forever (honouring cancellation) when <c>stall</c> is set.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class ModbusRtuOverTcpTransportTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task SendAsync_writes_rtu_adu_and_returns_deframed_pdu()
|
||||
{
|
||||
// Response the fake gateway will emit: FC03, 1 reg = 0x000A.
|
||||
var response = ModbusCrc.Append(new byte[] { 0x01, 0x03, 0x02, 0x00, 0x0A });
|
||||
var fake = new CapturingDuplexStream(response);
|
||||
|
||||
await using var transport = ModbusRtuOverTcpTransport.ForTest(fake, TimeSpan.FromSeconds(2));
|
||||
var pdu = await transport.SendAsync(0x01, new byte[] { 0x03, 0x00, 0x00, 0x00, 0x01 },
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
pdu.ShouldBe(new byte[] { 0x03, 0x02, 0x00, 0x0A });
|
||||
// The request went out as an RTU ADU: unit + pdu + CRC, NO MBAP header.
|
||||
fake.Written.ShouldBe(new byte[] { 0x01, 0x03, 0x00, 0x00, 0x00, 0x01, 0x84, 0x0A });
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendAsync_exception_pdu_surfaces_ModbusException()
|
||||
{
|
||||
var response = ModbusCrc.Append(new byte[] { 0x01, 0x83, 0x02 });
|
||||
var fake = new CapturingDuplexStream(response);
|
||||
await using var transport = ModbusRtuOverTcpTransport.ForTest(fake, TimeSpan.FromSeconds(2));
|
||||
await Should.ThrowAsync<ModbusException>(
|
||||
transport.SendAsync(0x01, new byte[] { 0x03, 0x00, 0x00, 0x00, 0x01 },
|
||||
TestContext.Current.CancellationToken));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendAsync_stalled_gateway_hits_per_op_deadline()
|
||||
{
|
||||
var fake = new CapturingDuplexStream(respondBytes: Array.Empty<byte>(), stall: true);
|
||||
await using var transport = ModbusRtuOverTcpTransport.ForTest(fake, TimeSpan.FromMilliseconds(200));
|
||||
await Should.ThrowAsync<ModbusTransportDesyncException>(
|
||||
transport.SendAsync(0x01, new byte[] { 0x03, 0x00, 0x00, 0x00, 0x01 },
|
||||
TestContext.Current.CancellationToken));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// In-memory duplex <see cref="Stream"/> test double: records everything written and replays
|
||||
/// <c>respondBytes</c> on read. When <c>stall</c> is set the read blocks until its cancellation
|
||||
/// token fires (simulating a frozen gateway) so the transport's per-op deadline is exercised.
|
||||
/// </summary>
|
||||
private sealed class CapturingDuplexStream : Stream
|
||||
{
|
||||
private readonly byte[] _response;
|
||||
private readonly bool _stall;
|
||||
private readonly MemoryStream _written = new();
|
||||
private int _readPos;
|
||||
|
||||
public CapturingDuplexStream(byte[] respondBytes, bool stall = false)
|
||||
{
|
||||
_response = respondBytes;
|
||||
_stall = stall;
|
||||
}
|
||||
|
||||
/// <summary>Gets the bytes the transport wrote to this stream (the request ADU).</summary>
|
||||
public byte[] Written => _written.ToArray();
|
||||
|
||||
public override bool CanRead => true;
|
||||
public override bool CanSeek => false;
|
||||
public override bool CanWrite => true;
|
||||
public override long Length => throw new NotSupportedException();
|
||||
public override long Position { get => throw new NotSupportedException(); set => throw new NotSupportedException(); }
|
||||
|
||||
public override async ValueTask<int> ReadAsync(Memory<byte> buffer, CancellationToken ct = default)
|
||||
{
|
||||
if (_stall)
|
||||
{
|
||||
// Frozen gateway: never answer. Block until the per-op deadline (or caller) cancels.
|
||||
var tcs = new TaskCompletionSource();
|
||||
await using (ct.Register(() => tcs.TrySetCanceled(ct)))
|
||||
await tcs.Task.ConfigureAwait(false);
|
||||
return 0; // unreachable — the await above always throws when cancelled
|
||||
}
|
||||
|
||||
var remaining = _response.Length - _readPos;
|
||||
if (remaining <= 0) return 0;
|
||||
var n = Math.Min(remaining, buffer.Length);
|
||||
_response.AsSpan(_readPos, n).CopyTo(buffer.Span);
|
||||
_readPos += n;
|
||||
return n;
|
||||
}
|
||||
|
||||
public override async ValueTask WriteAsync(ReadOnlyMemory<byte> buffer, CancellationToken ct = default)
|
||||
{
|
||||
await _written.WriteAsync(buffer, ct).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public override Task FlushAsync(CancellationToken ct) => Task.CompletedTask;
|
||||
public override void Flush() { }
|
||||
public override int Read(byte[] buffer, int offset, int count) => throw new NotSupportedException();
|
||||
public override void Write(byte[] buffer, int offset, int count) => throw new NotSupportedException();
|
||||
public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException();
|
||||
public override void SetLength(long value) => throw new NotSupportedException();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Pins the socket-lifecycle surface extracted from <see cref="ModbusTcpTransport"/> into the
|
||||
/// reusable <see cref="ModbusSocketLifecycle"/> (Task 3). The clamp helper moved with it, and a
|
||||
/// connect to a dead port must still surface the underlying socket failure.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class ModbusSocketLifecycleTests
|
||||
{
|
||||
/// <summary>Verifies the whole-seconds clamp rounds sub-second/negative durations up to a minimum of one.</summary>
|
||||
/// <param name="seconds">The input duration in seconds.</param>
|
||||
/// <param name="expected">The expected clamped value in whole seconds.</param>
|
||||
[Theory]
|
||||
[InlineData(0.4, 1)] // sub-second rounds up to 1 (the int-cast truncation guard)
|
||||
[InlineData(2.0, 2)]
|
||||
[InlineData(-5.0, 1)] // negative clamps to 1
|
||||
public void ClampToWholeSeconds_rounds_up_min_one(double seconds, int expected)
|
||||
=> ModbusSocketLifecycle.ClampToWholeSeconds(TimeSpan.FromSeconds(seconds)).ShouldBe(expected);
|
||||
|
||||
/// <summary>Verifies a connect to a dead port surfaces the underlying socket failure.</summary>
|
||||
[Fact]
|
||||
public async Task Connect_to_dead_port_surfaces_socket_failure()
|
||||
{
|
||||
var life = new ModbusSocketLifecycle("127.0.0.1", 1, TimeSpan.FromMilliseconds(300),
|
||||
autoReconnect: false);
|
||||
await Should.ThrowAsync<System.Net.Sockets.SocketException>(
|
||||
life.ConnectAsync(TestContext.Current.CancellationToken));
|
||||
}
|
||||
}
|
||||
+47
@@ -0,0 +1,47 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests;
|
||||
|
||||
public sealed class ModbusTransportConfigRoundTripTests
|
||||
{
|
||||
// Mirrors the AdminUI ModbusDriverForm serializer: camelCase + string enums.
|
||||
private static readonly JsonSerializerOptions _adminOpts = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
Converters = { new JsonStringEnumConverter() },
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public void Transport_serializes_as_a_string_not_a_number()
|
||||
{
|
||||
var opts = new ModbusDriverOptions { Host = "10.20.0.50", Port = 4001, Transport = ModbusTransportMode.RtuOverTcp };
|
||||
var json = JsonSerializer.Serialize(opts, _adminOpts);
|
||||
json.ShouldContain("\"transport\":\"RtuOverTcp\"");
|
||||
json.ShouldNotContain("\"transport\":1", Case.Sensitive);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Factory_binds_RtuOverTcp_from_string_config()
|
||||
{
|
||||
const string json = """{ "host": "10.20.0.50", "port": 4001, "transport": "RtuOverTcp" }""";
|
||||
using var driver = ModbusDriverFactoryExtensions.CreateInstance("mb-rtu", json);
|
||||
var opts = (ModbusDriverOptions)typeof(ModbusDriver)
|
||||
.GetField("_options", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance)!
|
||||
.GetValue(driver)!;
|
||||
opts.Transport.ShouldBe(ModbusTransportMode.RtuOverTcp);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Factory_defaults_Transport_to_Tcp_when_omitted()
|
||||
{
|
||||
const string json = """{ "host": "10.0.0.10" }""";
|
||||
using var driver = ModbusDriverFactoryExtensions.CreateInstance("mb-tcp", json);
|
||||
var opts = (ModbusDriverOptions)typeof(ModbusDriver)
|
||||
.GetField("_options", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance)!
|
||||
.GetValue(driver)!;
|
||||
opts.Transport.ShouldBe(ModbusTransportMode.Tcp);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests;
|
||||
|
||||
public sealed class ModbusTransportFactoryTests
|
||||
{
|
||||
[Fact]
|
||||
public void Tcp_mode_builds_tcp_transport()
|
||||
=> ModbusTransportFactory.Create(new ModbusDriverOptions { Transport = ModbusTransportMode.Tcp })
|
||||
.ShouldBeOfType<ModbusTcpTransport>();
|
||||
|
||||
[Fact]
|
||||
public void RtuOverTcp_mode_builds_rtu_transport()
|
||||
=> ModbusTransportFactory.Create(new ModbusDriverOptions { Transport = ModbusTransportMode.RtuOverTcp })
|
||||
.ShouldBeOfType<ModbusRtuOverTcpTransport>();
|
||||
|
||||
[Fact]
|
||||
public void Default_options_build_tcp_transport()
|
||||
=> ModbusTransportFactory.Create(new ModbusDriverOptions())
|
||||
.ShouldBeOfType<ModbusTcpTransport>();
|
||||
|
||||
[Fact]
|
||||
public void Unknown_transport_mode_throws()
|
||||
=> Should.Throw<ArgumentOutOfRangeException>(
|
||||
() => ModbusTransportFactory.Create(new ModbusDriverOptions { Transport = (ModbusTransportMode)999 }));
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests;
|
||||
|
||||
public sealed class ModbusTransportModeTests
|
||||
{
|
||||
[Fact]
|
||||
public void Tcp_is_the_default_zero_member()
|
||||
=> ((ModbusTransportMode)0).ShouldBe(ModbusTransportMode.Tcp);
|
||||
|
||||
[Fact]
|
||||
public void Exactly_two_members_ship_in_v1()
|
||||
=> Enum.GetNames<ModbusTransportMode>().ShouldBe(["Tcp", "RtuOverTcp"]);
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Confirms the driver's default transport-factory closure — and, by extension, the
|
||||
/// probe's transport construction — routes through <see cref="ModbusTransportFactory"/>
|
||||
/// instead of hardcoding <see cref="ModbusTcpTransport"/>, so an <c>RtuOverTcp</c>-authored
|
||||
/// config actually gets RTU framing rather than silently running as TCP/MBAP.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class ModbusTransportWiringTests
|
||||
{
|
||||
private static IModbusTransport BuildDefaultTransport(ModbusDriverOptions opts)
|
||||
{
|
||||
using var driver = new ModbusDriver(opts, "wire-test");
|
||||
var factory = (Func<ModbusDriverOptions, IModbusTransport>)typeof(ModbusDriver)
|
||||
.GetField("_transportFactory", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance)!
|
||||
.GetValue(driver)!;
|
||||
return factory(opts);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Default_closure_builds_rtu_transport_for_RtuOverTcp()
|
||||
=> BuildDefaultTransport(new ModbusDriverOptions { Transport = ModbusTransportMode.RtuOverTcp })
|
||||
.ShouldBeOfType<ModbusRtuOverTcpTransport>();
|
||||
|
||||
[Fact]
|
||||
public void Default_closure_builds_tcp_transport_for_Tcp()
|
||||
=> BuildDefaultTransport(new ModbusDriverOptions())
|
||||
.ShouldBeOfType<ModbusTcpTransport>();
|
||||
}
|
||||
@@ -475,6 +475,99 @@ public sealed class SqlServerReadTests
|
||||
|
||||
// ---- helpers ----
|
||||
|
||||
// ---- design §8.1 catalog gate (Gitea #496), against a real INFORMATION_SCHEMA ----
|
||||
|
||||
/// <summary>
|
||||
/// The gate's live proof: an authored column that does not exist is refused by the allow-list at
|
||||
/// Initialize, so it never reaches a query — and its neighbour on the same table keeps reading.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Only a real SQL Server exercises the real <c>SELECT SCHEMA_NAME()</c> + <c>INFORMATION_SCHEMA</c>
|
||||
/// path the loader depends on; the SQLite-backed suites prove the logic but not that T-SQL's catalog
|
||||
/// answers the shape the loader expects.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task CatalogGate_realSqlServer_rejectsAnUnknownColumn_andSparesItsNeighbour()
|
||||
{
|
||||
SkipUnlessLive();
|
||||
|
||||
var bogus = Tag("Sql/Line1/Bogus", new JsonObject
|
||||
{
|
||||
["driver"] = "Sql",
|
||||
["model"] = "KeyValue",
|
||||
["table"] = SqlPollServerFixture.Qualified(SqlPollServerFixture.KeyValueTable),
|
||||
["keyColumn"] = SqlPollServerFixture.KeyColumn,
|
||||
["keyValue"] = SqlPollServerFixture.PresentKey,
|
||||
["valueColumn"] = "no_such_column",
|
||||
["timestampColumn"] = SqlPollServerFixture.TimestampColumn,
|
||||
});
|
||||
|
||||
await using var driver = await StartAsync(
|
||||
KeyValue("Sql/Line1/Speed", SqlPollServerFixture.PresentKey), bogus);
|
||||
|
||||
// An authoring typo is not a database fault: the driver stays Healthy.
|
||||
driver.GetHealth().State.ShouldBe(DriverState.Healthy);
|
||||
|
||||
var snapshots = await driver.ReadAsync(
|
||||
["Sql/Line1/Speed", "Sql/Line1/Bogus"], TestContext.Current.CancellationToken);
|
||||
|
||||
SqlStatusCodes.IsGood(snapshots[0].StatusCode).ShouldBeTrue();
|
||||
snapshots[1].StatusCode.ShouldBe(SqlStatusCodes.BadNodeIdUnknown);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// T-SQL object names are case-insensitive under the default collation, so a case-variant tag has
|
||||
/// always been valid and must keep working — the gate substitutes the catalog's spelling rather than
|
||||
/// rejecting the operator's.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task CatalogGate_realSqlServer_acceptsACaseVariantIdentifierAndStillReads()
|
||||
{
|
||||
SkipUnlessLive();
|
||||
|
||||
var upper = Tag("Sql/Line1/Speed", new JsonObject
|
||||
{
|
||||
["driver"] = "Sql",
|
||||
["model"] = "KeyValue",
|
||||
["table"] = SqlPollServerFixture.Qualified(SqlPollServerFixture.KeyValueTable).ToUpperInvariant(),
|
||||
["keyColumn"] = SqlPollServerFixture.KeyColumn.ToUpperInvariant(),
|
||||
["keyValue"] = SqlPollServerFixture.PresentKey,
|
||||
["valueColumn"] = SqlPollServerFixture.ValueColumn.ToUpperInvariant(),
|
||||
["timestampColumn"] = SqlPollServerFixture.TimestampColumn.ToUpperInvariant(),
|
||||
});
|
||||
|
||||
await using var driver = await StartAsync(upper);
|
||||
|
||||
var snapshot = (await driver.ReadAsync(["Sql/Line1/Speed"], TestContext.Current.CancellationToken))
|
||||
.ShouldHaveSingleItem();
|
||||
SqlStatusCodes.IsGood(snapshot.StatusCode).ShouldBeTrue();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A cross-database name cannot be validated from this connection's catalog, so it is refused rather
|
||||
/// than quoted into a query — the documented v1 limitation, asserted so it stays deliberate.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task CatalogGate_realSqlServer_rejectsAThreePartName()
|
||||
{
|
||||
SkipUnlessLive();
|
||||
|
||||
var threePart = Tag("Sql/Line1/Remote", new JsonObject
|
||||
{
|
||||
["driver"] = "Sql",
|
||||
["model"] = "KeyValue",
|
||||
["table"] = $"otherdb.{SqlPollServerFixture.Qualified(SqlPollServerFixture.KeyValueTable)}",
|
||||
["keyColumn"] = SqlPollServerFixture.KeyColumn,
|
||||
["keyValue"] = SqlPollServerFixture.PresentKey,
|
||||
["valueColumn"] = SqlPollServerFixture.ValueColumn,
|
||||
});
|
||||
|
||||
await using var driver = await StartAsync(threePart);
|
||||
|
||||
(await driver.ReadAsync(["Sql/Line1/Remote"], TestContext.Current.CancellationToken))
|
||||
.ShouldHaveSingleItem().StatusCode.ShouldBe(SqlStatusCodes.BadNodeIdUnknown);
|
||||
}
|
||||
|
||||
/// <summary>Skips the calling test when the live-server gate is not configured or not reachable.</summary>
|
||||
private void SkipUnlessLive()
|
||||
{
|
||||
|
||||
@@ -0,0 +1,354 @@
|
||||
using System.Globalization;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// The design §8.1 catalog gate end-to-end through <see cref="SqlDriver"/>, against the real SQLite
|
||||
/// catalog the poll fixture creates — real <c>ListSchemas</c>/<c>ListTables</c>/<c>ListColumns</c>
|
||||
/// round-trips, not a hand-built <see cref="SqlCatalog"/>.
|
||||
/// <para><b>The two facts that matter most here</b> are the ones a pure unit test cannot show: that a
|
||||
/// rejected tag still <em>has a node</em> and reads <c>BadNodeIdUnknown</c> (rather than silently
|
||||
/// vanishing from the address space), and that a catalog the driver cannot read faults Initialize instead
|
||||
/// of rejecting every tag.</para>
|
||||
/// </summary>
|
||||
public sealed class SqlCatalogGateDriverTests
|
||||
{
|
||||
private const string DriverInstanceId = "sql-gate";
|
||||
private const string ConfigJson = """{"provider":"SqlServer"}""";
|
||||
|
||||
[Fact]
|
||||
public async Task A_tag_naming_a_real_table_and_columns_polls_normally()
|
||||
{
|
||||
using var fixture = new SqlitePollFixture();
|
||||
await using var driver = NewDriver(fixture, KvEntry("Speed", SqlitePollFixture.PresentKey));
|
||||
|
||||
await driver.InitializeAsync(ConfigJson, CancellationToken.None);
|
||||
|
||||
driver.GetHealth().State.ShouldBe(DriverState.Healthy);
|
||||
var snapshot = (await driver.ReadAsync(["Speed"], CancellationToken.None)).ShouldHaveSingleItem();
|
||||
SqlStatusCodes.IsGood(snapshot.StatusCode).ShouldBeTrue();
|
||||
snapshot.Value.ShouldBe(SqlitePollFixture.PresentValue);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// §8.1's specified outcome, in full: the tag is refused by the allow-list, so it never reaches a
|
||||
/// query — but its node still exists and reads <c>BadNodeIdUnknown</c>. Dropping the node instead would
|
||||
/// turn a diagnosable Bad quality into a missing address-space entry.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task A_tag_naming_an_unknown_column_keeps_its_node_and_reads_BadNodeIdUnknown()
|
||||
{
|
||||
using var fixture = new SqlitePollFixture();
|
||||
await using var driver = NewDriver(
|
||||
fixture,
|
||||
KvEntry("Speed", SqlitePollFixture.PresentKey),
|
||||
KvEntry("Bogus", SqlitePollFixture.PresentKey, valueColumn: "no_such_column"));
|
||||
|
||||
await driver.InitializeAsync(ConfigJson, CancellationToken.None);
|
||||
|
||||
// The driver is healthy: an authoring typo is not a database fault.
|
||||
driver.GetHealth().State.ShouldBe(DriverState.Healthy);
|
||||
|
||||
// The node is still materialized...
|
||||
var capture = new CapturingBuilder();
|
||||
await ((ITagDiscovery)driver).DiscoverAsync(capture, CancellationToken.None);
|
||||
capture.Variables.Select(v => v.Info.FullName).ShouldBe(["Speed", "Bogus"], ignoreOrder: true);
|
||||
|
||||
// ...and it is the rejected tag — and only it — that reads BadNodeIdUnknown.
|
||||
var snapshots = await driver.ReadAsync(["Speed", "Bogus"], CancellationToken.None);
|
||||
SqlStatusCodes.IsGood(snapshots[0].StatusCode).ShouldBeTrue();
|
||||
snapshots[1].StatusCode.ShouldBe(SqlStatusCodes.BadNodeIdUnknown);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_tag_naming_an_unknown_table_reads_BadNodeIdUnknown()
|
||||
{
|
||||
using var fixture = new SqlitePollFixture();
|
||||
await using var driver = NewDriver(
|
||||
fixture, KvEntry("Bogus", SqlitePollFixture.PresentKey, table: "NoSuchTable"));
|
||||
|
||||
await driver.InitializeAsync(ConfigJson, CancellationToken.None);
|
||||
|
||||
(await driver.ReadAsync(["Bogus"], CancellationToken.None))
|
||||
.ShouldHaveSingleItem().StatusCode.ShouldBe(SqlStatusCodes.BadNodeIdUnknown);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The injection shape #496 exists to close. Before the gate, this name was bracket-quoted into a real
|
||||
/// query that failed only once the connection was open; now it never reaches a query at all.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task A_hostile_table_name_never_reaches_a_query()
|
||||
{
|
||||
using var fixture = new SqlitePollFixture();
|
||||
var logger = new CapturingLogger();
|
||||
await using var driver = NewDriver(
|
||||
fixture, logger,
|
||||
KvEntry("Evil", SqlitePollFixture.PresentKey, table: "TagValues\"; DROP TABLE TagValues--"));
|
||||
|
||||
await driver.InitializeAsync(ConfigJson, CancellationToken.None);
|
||||
|
||||
(await driver.ReadAsync(["Evil"], CancellationToken.None))
|
||||
.ShouldHaveSingleItem().StatusCode.ShouldBe(SqlStatusCodes.BadNodeIdUnknown);
|
||||
|
||||
// The fixture's table is untouched — proven by a second tag still reading through it.
|
||||
await using var honest = NewDriver(fixture, KvEntry("Speed", SqlitePollFixture.PresentKey));
|
||||
await honest.InitializeAsync(ConfigJson, CancellationToken.None);
|
||||
var snapshot = (await honest.ReadAsync(["Speed"], CancellationToken.None)).ShouldHaveSingleItem();
|
||||
SqlStatusCodes.IsGood(snapshot.StatusCode).ShouldBeTrue();
|
||||
|
||||
logger.Entries.ShouldContain(e =>
|
||||
e.Level == LogLevel.Warning && e.Message.Contains("rejected by the catalog gate", StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A node that goes Bad with nothing in the log is unsupportable, so every drop names the tag, the
|
||||
/// field and the reason.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Every_rejection_is_logged_with_the_tag_the_field_and_the_reason()
|
||||
{
|
||||
using var fixture = new SqlitePollFixture();
|
||||
var logger = new CapturingLogger();
|
||||
await using var driver = NewDriver(
|
||||
fixture, logger, KvEntry("Bogus", SqlitePollFixture.PresentKey, valueColumn: "num_valeu"));
|
||||
|
||||
await driver.InitializeAsync(ConfigJson, CancellationToken.None);
|
||||
|
||||
var warning = logger.Entries
|
||||
.Where(e => e.Level == LogLevel.Warning)
|
||||
.Select(e => e.Message)
|
||||
.ShouldHaveSingleItem();
|
||||
warning.ShouldContain("Bogus");
|
||||
warning.ShouldContain(nameof(SqlTagDefinition.ValueColumn));
|
||||
warning.ShouldContain("num_valeu");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Case-insensitive authoring has always worked on SQL Server's default collation, so the gate must
|
||||
/// accept it — and it substitutes the catalog's spelling, which is what makes the emitted SQL carry
|
||||
/// catalog strings rather than operator input.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task A_case_variant_identifier_is_accepted_and_polls()
|
||||
{
|
||||
using var fixture = new SqlitePollFixture();
|
||||
await using var driver = NewDriver(
|
||||
fixture,
|
||||
KvEntry(
|
||||
"Speed", SqlitePollFixture.PresentKey,
|
||||
table: SqlitePollFixture.KeyValueTable.ToUpperInvariant(),
|
||||
valueColumn: SqlitePollFixture.ValueColumn.ToUpperInvariant()));
|
||||
|
||||
await driver.InitializeAsync(ConfigJson, CancellationToken.None);
|
||||
|
||||
var snapshot = (await driver.ReadAsync(["Speed"], CancellationToken.None)).ShouldHaveSingleItem();
|
||||
SqlStatusCodes.IsGood(snapshot.StatusCode).ShouldBeTrue();
|
||||
snapshot.Value.ShouldBe(SqlitePollFixture.PresentValue);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A driver with nothing authored has nothing to validate; issuing catalog queries to prove that would
|
||||
/// be a round-trip that can only fail.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task A_driver_with_no_authored_tags_initializes_without_touching_the_catalog()
|
||||
{
|
||||
using var fixture = new SqlitePollFixture();
|
||||
await using var driver = NewDriver(fixture);
|
||||
|
||||
await driver.InitializeAsync(ConfigJson, CancellationToken.None);
|
||||
|
||||
driver.GetHealth().State.ShouldBe(DriverState.Healthy);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// <b>The fail-closed rule.</b> A catalog that cannot be read is the ABSENCE of evidence about the
|
||||
/// tags, not evidence against them. Rejecting every tag would serve a confidently-empty address space
|
||||
/// and send the operator hunting typos that do not exist; faulting Initialize instead lands
|
||||
/// <c>DriverInstanceActor</c> in Reconnecting with its retry timer running.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task A_catalog_that_cannot_be_read_faults_Initialize_rather_than_rejecting_every_tag()
|
||||
{
|
||||
using var fixture = new SqlitePollFixture();
|
||||
await using var driver = new SqlDriver(
|
||||
new SqlDriverOptions
|
||||
{
|
||||
RawTags = [KvEntry("Speed", SqlitePollFixture.PresentKey)],
|
||||
OperationTimeout = TimeSpan.FromSeconds(15),
|
||||
CommandTimeout = TimeSpan.FromSeconds(10),
|
||||
},
|
||||
DriverInstanceId,
|
||||
new CatalogSqlOverride("SELECT this is not valid sql"),
|
||||
fixture.ConnectionString,
|
||||
factory: fixture.Factory,
|
||||
logger: NullLogger<SqlDriver>.Instance);
|
||||
|
||||
var thrown = await Should.ThrowAsync<InvalidOperationException>(
|
||||
async () => await driver.InitializeAsync(ConfigJson, CancellationToken.None));
|
||||
|
||||
// The operator surface names the stage that actually failed — "reached it but could not read the
|
||||
// catalog" and "could not reach it" send an operator to different systems.
|
||||
thrown.Message.ShouldContain("catalog");
|
||||
driver.GetHealth().State.ShouldBe(DriverState.Faulted);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Zero visible schemas is a grant problem, not an empty database, so it must fault rather than reject
|
||||
/// every tag for an authoring fault the operator does not have.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task A_catalog_reporting_no_schemas_at_all_faults_Initialize()
|
||||
{
|
||||
using var fixture = new SqlitePollFixture();
|
||||
await using var driver = new SqlDriver(
|
||||
new SqlDriverOptions
|
||||
{
|
||||
RawTags = [KvEntry("Speed", SqlitePollFixture.PresentKey)],
|
||||
OperationTimeout = TimeSpan.FromSeconds(15),
|
||||
CommandTimeout = TimeSpan.FromSeconds(10),
|
||||
},
|
||||
DriverInstanceId,
|
||||
new CatalogSqlOverride("SELECT 'x' AS TABLE_SCHEMA WHERE 1 = 0"),
|
||||
fixture.ConnectionString,
|
||||
factory: fixture.Factory,
|
||||
logger: NullLogger<SqlDriver>.Instance);
|
||||
|
||||
await Should.ThrowAsync<InvalidOperationException>(
|
||||
async () => await driver.InitializeAsync(ConfigJson, CancellationToken.None));
|
||||
|
||||
driver.GetHealth().State.ShouldBe(DriverState.Faulted);
|
||||
}
|
||||
|
||||
// ---- helpers ----
|
||||
|
||||
/// <summary>
|
||||
/// Delegates every member to <see cref="SqliteDialect"/> except <see cref="ListSchemasSql"/>, so the
|
||||
/// catalog-load failure modes can be driven against an otherwise-real dialect.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// A decorator rather than a subclass: <see cref="SqliteDialect"/> is sealed, and even if it were not,
|
||||
/// <c>new</c>-hiding a property would leave interface dispatch calling the base — the substitution
|
||||
/// would silently not happen and both tests below would pass for the wrong reason.
|
||||
/// </remarks>
|
||||
private sealed class CatalogSqlOverride(string listSchemasSql) : ISqlDialect
|
||||
{
|
||||
private static readonly SqliteDialect Inner = new();
|
||||
|
||||
public SqlProvider Provider => Inner.Provider;
|
||||
|
||||
public System.Data.Common.DbProviderFactory Factory => Inner.Factory;
|
||||
|
||||
public string LivenessSql => Inner.LivenessSql;
|
||||
|
||||
public string SingleRowLimitPrefix => Inner.SingleRowLimitPrefix;
|
||||
|
||||
public string SingleRowLimitSuffix => Inner.SingleRowLimitSuffix;
|
||||
|
||||
public string ListSchemasSql { get; } = listSchemasSql;
|
||||
|
||||
public string DefaultSchemaSql => Inner.DefaultSchemaSql;
|
||||
|
||||
public string ListTablesSql => Inner.ListTablesSql;
|
||||
|
||||
public string ListColumnsSql => Inner.ListColumnsSql;
|
||||
|
||||
public string QuoteIdentifier(string ident) => Inner.QuoteIdentifier(ident);
|
||||
|
||||
public DriverDataType MapColumnType(string sqlDataType) => Inner.MapColumnType(sqlDataType);
|
||||
}
|
||||
|
||||
private static SqlDriver NewDriver(SqlitePollFixture fixture, params RawTagEntry[] rawTags)
|
||||
=> NewDriver(fixture, new CapturingLogger(), rawTags);
|
||||
|
||||
private static SqlDriver NewDriver(
|
||||
SqlitePollFixture fixture, CapturingLogger logger, params RawTagEntry[] rawTags)
|
||||
=> new(
|
||||
new SqlDriverOptions
|
||||
{
|
||||
RawTags = rawTags,
|
||||
OperationTimeout = TimeSpan.FromSeconds(15),
|
||||
CommandTimeout = TimeSpan.FromSeconds(10),
|
||||
},
|
||||
DriverInstanceId,
|
||||
new SqliteDialect(),
|
||||
fixture.ConnectionString,
|
||||
factory: fixture.Factory,
|
||||
logger: logger);
|
||||
|
||||
/// <summary>One authored raw tag, with the table and value column overridable so the gate can be exercised.</summary>
|
||||
private static RawTagEntry KvEntry(
|
||||
string rawPath,
|
||||
string keyValue,
|
||||
string? table = null,
|
||||
string? valueColumn = null)
|
||||
=> new(rawPath, string.Create(CultureInfo.InvariantCulture, $$"""
|
||||
{
|
||||
"driver": "Sql",
|
||||
"model": "KeyValue",
|
||||
"table": "{{(table ?? SqlitePollFixture.KeyValueTable).Replace("\"", "\\\"", StringComparison.Ordinal)}}",
|
||||
"keyColumn": "{{SqlitePollFixture.KeyColumn}}",
|
||||
"keyValue": "{{keyValue}}",
|
||||
"valueColumn": "{{valueColumn ?? SqlitePollFixture.ValueColumn}}",
|
||||
"timestampColumn": "{{SqlitePollFixture.TimestampColumn}}"
|
||||
}
|
||||
"""), WriteIdempotent: false);
|
||||
|
||||
/// <summary>Records everything the driver streams into the address space.</summary>
|
||||
private sealed class CapturingBuilder : IAddressSpaceBuilder
|
||||
{
|
||||
/// <summary>The variables registered, in order.</summary>
|
||||
public List<(string BrowseName, DriverAttributeInfo Info)> Variables { get; } = [];
|
||||
|
||||
public IAddressSpaceBuilder Folder(string browseName, string displayName) => this;
|
||||
|
||||
public IVariableHandle Variable(string browseName, string displayName, DriverAttributeInfo attributeInfo)
|
||||
{
|
||||
Variables.Add((browseName, attributeInfo));
|
||||
return new Handle(attributeInfo.FullName);
|
||||
}
|
||||
|
||||
public void AddProperty(string browseName, DriverDataType dataType, object? value) { }
|
||||
|
||||
private sealed class Handle(string fullReference) : IVariableHandle
|
||||
{
|
||||
public string FullReference => fullReference;
|
||||
|
||||
public IAlarmConditionSink MarkAsAlarmCondition(AlarmConditionInfo info) => new Sink();
|
||||
|
||||
private sealed class Sink : IAlarmConditionSink
|
||||
{
|
||||
public void OnTransition(AlarmEventArgs args) { }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Records every log record, level + rendered message.</summary>
|
||||
private sealed class CapturingLogger : ILogger<SqlDriver>
|
||||
{
|
||||
public List<(LogLevel Level, string Message)> Entries { get; } = [];
|
||||
|
||||
public IDisposable BeginScope<TState>(TState state) where TState : notnull => NullScope.Instance;
|
||||
|
||||
public bool IsEnabled(LogLevel logLevel) => true;
|
||||
|
||||
public void Log<TState>(
|
||||
LogLevel logLevel, EventId eventId, TState state, Exception? exception,
|
||||
Func<TState, Exception?, string> formatter)
|
||||
=> Entries.Add((logLevel, formatter(state, exception)));
|
||||
|
||||
private sealed class NullScope : IDisposable
|
||||
{
|
||||
public static NullScope Instance { get; } = new();
|
||||
|
||||
public void Dispose() { }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,316 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// The design §8.1 catalog gate (Gitea #496), tested as a pure function over a hand-built
|
||||
/// <see cref="SqlCatalog"/>. The end-to-end behaviour against a real catalog — and the proof that a
|
||||
/// rejected tag keeps its node and publishes <c>BadNodeIdUnknown</c> — lives in
|
||||
/// <see cref="SqlCatalogGateDriverTests"/>.
|
||||
/// </summary>
|
||||
public sealed class SqlCatalogGateTests
|
||||
{
|
||||
private static readonly SqliteDialect Dialect = new();
|
||||
|
||||
/// <summary>A catalog with one schema, two relations, and deliberately mixed-case column spellings.</summary>
|
||||
private static SqlCatalog Catalog(string defaultSchema = "dbo") => new(
|
||||
defaultSchema,
|
||||
["dbo", "mes"],
|
||||
new Dictionary<string, IReadOnlyList<string>>(StringComparer.Ordinal)
|
||||
{
|
||||
["dbo"] = ["TagValues", "LatestStatus"],
|
||||
["mes"] = ["Orders"],
|
||||
},
|
||||
new Dictionary<string, IReadOnlyList<string>>(StringComparer.Ordinal)
|
||||
{
|
||||
["dbo.TagValues"] = ["tag_name", "num_value", "sample_ts"],
|
||||
["dbo.LatestStatus"] = ["station_id", "oven_temp", "pressure", "sample_ts"],
|
||||
["mes.Orders"] = ["order_id", "qty"],
|
||||
});
|
||||
|
||||
private static SqlTagDefinition KeyValueTag(
|
||||
string table = "dbo.TagValues",
|
||||
string keyColumn = "tag_name",
|
||||
string valueColumn = "num_value",
|
||||
string? timestampColumn = "sample_ts") =>
|
||||
new("plant/sql/Speed", SqlTagModel.KeyValue, table,
|
||||
KeyColumn: keyColumn, KeyValue: "Line1.Speed",
|
||||
ValueColumn: valueColumn, TimestampColumn: timestampColumn);
|
||||
|
||||
private static SqlCatalogGateResult Apply(params SqlTagDefinition[] tags) =>
|
||||
SqlCatalogGate.Apply(tags, Catalog(), Dialect);
|
||||
|
||||
[Fact]
|
||||
public void A_fully_resolvable_tag_is_accepted()
|
||||
{
|
||||
var result = Apply(KeyValueTag());
|
||||
|
||||
result.Rejected.ShouldBeEmpty();
|
||||
result.Accepted.Count.ShouldBe(1);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The heart of §8.1: what reaches the planner must be a string the catalog gave us, not the string an
|
||||
/// operator typed. Authoring every identifier in the wrong case proves the substitution actually
|
||||
/// happens rather than the input merely being waved through.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Accepted_identifiers_are_rewritten_to_the_catalogs_own_spelling()
|
||||
{
|
||||
var authored = KeyValueTag(
|
||||
table: "DBO.TAGVALUES", keyColumn: "TAG_NAME", valueColumn: "Num_Value", timestampColumn: "SAMPLE_TS");
|
||||
|
||||
var accepted = Apply(authored).Accepted.ShouldHaveSingleItem();
|
||||
|
||||
accepted.Table.ShouldBe("dbo.TagValues");
|
||||
accepted.KeyColumn.ShouldBe("tag_name");
|
||||
accepted.ValueColumn.ShouldBe("num_value");
|
||||
accepted.TimestampColumn.ShouldBe("sample_ts");
|
||||
// Identity and bound VALUES are untouched — the gate rewrites identifiers only.
|
||||
accepted.Name.ShouldBe(authored.Name);
|
||||
accepted.KeyValue.ShouldBe(authored.KeyValue);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void An_unqualified_table_resolves_in_the_default_schema()
|
||||
{
|
||||
var accepted = Apply(KeyValueTag(table: "TagValues")).Accepted.ShouldHaveSingleItem();
|
||||
|
||||
accepted.Table.ShouldBe("dbo.TagValues");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Guessing <c>dbo</c> would be a silent lie on an estate that maps service accounts to their own
|
||||
/// default schema, so the gate must resolve an unqualified name in whatever schema the server reports.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void An_unqualified_table_follows_a_non_dbo_default_schema()
|
||||
{
|
||||
var catalog = Catalog(defaultSchema: "mes");
|
||||
|
||||
var result = SqlCatalogGate.Apply([KeyValueTag(table: "Orders", keyColumn: "order_id", valueColumn: "qty", timestampColumn: null)], catalog, Dialect);
|
||||
|
||||
result.Accepted.ShouldHaveSingleItem().Table.ShouldBe("mes.Orders");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void An_unknown_table_rejects_the_tag()
|
||||
{
|
||||
var rejection = Apply(KeyValueTag(table: "dbo.NoSuchTable")).Rejected.ShouldHaveSingleItem();
|
||||
|
||||
rejection.RawPath.ShouldBe("plant/sql/Speed");
|
||||
rejection.Field.ShouldBe(nameof(SqlTagDefinition.Table));
|
||||
rejection.Reason.ShouldContain("NoSuchTable");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void An_unknown_schema_rejects_the_tag()
|
||||
{
|
||||
var rejection = Apply(KeyValueTag(table: "nope.TagValues")).Rejected.ShouldHaveSingleItem();
|
||||
|
||||
rejection.Field.ShouldBe(nameof(SqlTagDefinition.Table));
|
||||
rejection.Reason.ShouldContain("nope");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void An_unknown_column_rejects_the_tag_and_names_the_field()
|
||||
{
|
||||
var rejection = Apply(KeyValueTag(valueColumn: "no_such_column")).Rejected.ShouldHaveSingleItem();
|
||||
|
||||
rejection.Field.ShouldBe(nameof(SqlTagDefinition.ValueColumn));
|
||||
rejection.Reason.ShouldContain("no_such_column");
|
||||
rejection.Reason.ShouldContain("dbo.TagValues");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A table in the right catalog but the wrong schema must not resolve — otherwise the gate would
|
||||
/// allow-list a column set from a relation the query will never read.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void A_table_from_another_schema_does_not_resolve_unqualified()
|
||||
{
|
||||
var rejection = Apply(KeyValueTag(table: "Orders", keyColumn: "order_id", valueColumn: "qty", timestampColumn: null))
|
||||
.Rejected.ShouldHaveSingleItem();
|
||||
|
||||
rejection.Field.ShouldBe(nameof(SqlTagDefinition.Table));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A cross-database or linked-server name addresses a catalog this connection cannot enumerate, so it
|
||||
/// cannot be allow-listed. Rejecting is the fail-closed answer, and the message says what to do instead.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void A_three_part_name_is_rejected_with_an_actionable_message()
|
||||
{
|
||||
var rejection = Apply(KeyValueTag(table: "otherdb.dbo.TagValues")).Rejected.ShouldHaveSingleItem();
|
||||
|
||||
rejection.Field.ShouldBe(nameof(SqlTagDefinition.Table));
|
||||
rejection.Reason.ShouldContain("3-part");
|
||||
rejection.Reason.ShouldContain("view");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The injection shape the whole gate exists for: a hostile identifier must be refused by the
|
||||
/// allow-list, not merely quoted into a query against a nonexistent object.
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[InlineData("TagValues]; DROP TABLE TagValues--")]
|
||||
[InlineData("'; DROP TABLE TagValues--")]
|
||||
[InlineData("TagValues WHERE 1=1 OR 1=1")]
|
||||
public void A_hostile_table_name_is_rejected_by_the_allow_list(string table)
|
||||
{
|
||||
Apply(KeyValueTag(table: table)).Rejected.ShouldHaveSingleItem()
|
||||
.Field.ShouldBe(nameof(SqlTagDefinition.Table));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("num_value]; DROP TABLE TagValues--")]
|
||||
[InlineData("(SELECT password FROM users)")]
|
||||
public void A_hostile_column_name_is_rejected_by_the_allow_list(string column)
|
||||
{
|
||||
Apply(KeyValueTag(valueColumn: column)).Rejected.ShouldHaveSingleItem()
|
||||
.Field.ShouldBe(nameof(SqlTagDefinition.ValueColumn));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A name carrying a control or Unicode format character cannot be safely rendered into a log line
|
||||
/// (the Trojan-Source class, CVE-2021-42574), so the gate rejects it <em>without echoing it</em> — the
|
||||
/// charset check runs before the catalog lookup precisely so no such string can reach a message.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Driven against <see cref="SqlServerDialect"/>, not the test-only <see cref="SqliteDialect"/>: the
|
||||
/// charset rules belong to the dialect (the gate delegates to
|
||||
/// <see cref="ISqlDialect.QuoteIdentifier"/> rather than duplicating them), and SQLite's rules
|
||||
/// deliberately stop at control characters. Asserting SQL Server's rules means using SQL Server's
|
||||
/// dialect — the first draft of this test asserted them against SQLite's and failed for that reason.
|
||||
/// </remarks>
|
||||
[Theory]
|
||||
[InlineData("num\u0000value")] // Cc — embedded NUL
|
||||
[InlineData("num\u0009value")] // Cc — tab; truncates or corrupts a logged statement
|
||||
[InlineData("num\u202Evalue")] // Cf — right-to-left override
|
||||
[InlineData("num\u200Bvalue")] // Cf — zero-width space
|
||||
public void An_unrenderable_identifier_is_rejected_without_being_echoed(string column)
|
||||
{
|
||||
var result = SqlCatalogGate.Apply(
|
||||
[KeyValueTag(valueColumn: column)], Catalog(), new SqlServerDialect());
|
||||
|
||||
var rejection = result.Rejected.ShouldHaveSingleItem();
|
||||
rejection.Field.ShouldBe(nameof(SqlTagDefinition.ValueColumn));
|
||||
rejection.Reason.ShouldContain("withheld");
|
||||
rejection.Reason.ShouldNotContain(column);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// An over-long name cannot name a real object and is likewise withheld, rather than pasting an
|
||||
/// unbounded slab of operator input into a log line.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void An_over_long_identifier_is_rejected_without_being_echoed()
|
||||
{
|
||||
var column = new string('x', SqlServerDialect.MaxIdentifierLength + 1);
|
||||
|
||||
var result = SqlCatalogGate.Apply(
|
||||
[KeyValueTag(valueColumn: column)], Catalog(), new SqlServerDialect());
|
||||
|
||||
result.Rejected.ShouldHaveSingleItem().Reason.ShouldContain("withheld");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The complement, and the reason the charset check is not simply "withhold everything": a name that
|
||||
/// IS safely renderable gets echoed, because an operator hunting a typo has to see what they wrote.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void A_renderable_but_unknown_identifier_IS_echoed_so_the_typo_is_findable()
|
||||
{
|
||||
var rejection = Apply(KeyValueTag(valueColumn: "num_valeu")).Rejected.ShouldHaveSingleItem();
|
||||
|
||||
rejection.Reason.ShouldContain("num_valeu");
|
||||
rejection.Reason.ShouldNotContain("withheld");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// On a case-sensitive collation a relation may legitimately carry both <c>Value</c> and <c>value</c>.
|
||||
/// Picking one would publish a different column's data under the operator's node, so the only safe
|
||||
/// answer is to refuse — but an EXACT match must still win, or a valid config would break.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void An_ambiguous_case_insensitive_column_match_is_rejected_but_an_exact_match_still_wins()
|
||||
{
|
||||
var catalog = new SqlCatalog(
|
||||
"dbo",
|
||||
["dbo"],
|
||||
new Dictionary<string, IReadOnlyList<string>>(StringComparer.Ordinal) { ["dbo"] = ["T"] },
|
||||
new Dictionary<string, IReadOnlyList<string>>(StringComparer.Ordinal)
|
||||
{
|
||||
["dbo.T"] = ["k", "Value", "value"],
|
||||
});
|
||||
|
||||
var ambiguous = new SqlTagDefinition(
|
||||
"p/Ambiguous", SqlTagModel.KeyValue, "dbo.T",
|
||||
KeyColumn: "k", KeyValue: "x", ValueColumn: "VALUE");
|
||||
SqlCatalogGate.Apply([ambiguous], catalog, Dialect).Rejected.ShouldHaveSingleItem()
|
||||
.Field.ShouldBe(nameof(SqlTagDefinition.ValueColumn));
|
||||
|
||||
var exact = ambiguous with { Name = "p/Exact", ValueColumn = "value" };
|
||||
SqlCatalogGate.Apply([exact], catalog, Dialect).Accepted.ShouldHaveSingleItem()
|
||||
.ValueColumn.ShouldBe("value");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// One operator typo must not stop the other tags on the same database — the same rule the tag-table
|
||||
/// build already follows for a malformed blob.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void A_rejected_tag_does_not_take_its_healthy_neighbours_with_it()
|
||||
{
|
||||
var good = KeyValueTag() with { Name = "plant/sql/Good" };
|
||||
var bad = KeyValueTag(valueColumn: "typo") with { Name = "plant/sql/Bad" };
|
||||
|
||||
var result = Apply(good, bad);
|
||||
|
||||
result.Accepted.ShouldHaveSingleItem().Name.ShouldBe("plant/sql/Good");
|
||||
result.Rejected.ShouldHaveSingleItem().RawPath.ShouldBe("plant/sql/Bad");
|
||||
}
|
||||
|
||||
/// <summary>Every identifier-bearing field is checked, not just the two the key-value model happens to use.</summary>
|
||||
[Fact]
|
||||
public void The_wide_row_models_identifier_fields_are_validated_too()
|
||||
{
|
||||
var selectorTypo = new SqlTagDefinition(
|
||||
"p/Oven", SqlTagModel.WideRow, "dbo.LatestStatus",
|
||||
ColumnName: "oven_temp", RowSelectorColumn: "no_such_selector", RowSelectorValue: "7");
|
||||
Apply(selectorTypo).Rejected.ShouldHaveSingleItem()
|
||||
.Field.ShouldBe(nameof(SqlTagDefinition.RowSelectorColumn));
|
||||
|
||||
var orderTypo = new SqlTagDefinition(
|
||||
"p/Newest", SqlTagModel.WideRow, "dbo.LatestStatus",
|
||||
ColumnName: "oven_temp", RowSelectorTopByTimestamp: "no_such_ts");
|
||||
Apply(orderTypo).Rejected.ShouldHaveSingleItem()
|
||||
.Field.ShouldBe(nameof(SqlTagDefinition.RowSelectorTopByTimestamp));
|
||||
|
||||
var columnTypo = new SqlTagDefinition(
|
||||
"p/Bad", SqlTagModel.WideRow, "dbo.LatestStatus",
|
||||
ColumnName: "no_such_column", RowSelectorColumn: "station_id", RowSelectorValue: "7");
|
||||
Apply(columnTypo).Rejected.ShouldHaveSingleItem()
|
||||
.Field.ShouldBe(nameof(SqlTagDefinition.ColumnName));
|
||||
|
||||
var ok = new SqlTagDefinition(
|
||||
"p/Ok", SqlTagModel.WideRow, "dbo.LatestStatus",
|
||||
ColumnName: "OVEN_TEMP", RowSelectorColumn: "STATION_ID", RowSelectorValue: "7");
|
||||
var accepted = Apply(ok).Accepted.ShouldHaveSingleItem();
|
||||
accepted.ColumnName.ShouldBe("oven_temp");
|
||||
accepted.RowSelectorColumn.ShouldBe("station_id");
|
||||
accepted.RowSelectorValue.ShouldBe("7"); // a bound value, never canonicalized
|
||||
}
|
||||
|
||||
/// <summary>An absent optional identifier is not a rejection — only a present, unresolvable one is.</summary>
|
||||
[Fact]
|
||||
public void An_absent_optional_timestamp_column_is_left_alone()
|
||||
{
|
||||
var accepted = Apply(KeyValueTag(timestampColumn: null)).Accepted.ShouldHaveSingleItem();
|
||||
|
||||
accepted.TimestampColumn.ShouldBeNull();
|
||||
}
|
||||
}
|
||||
@@ -424,6 +424,27 @@ public sealed class SqlDriverTests
|
||||
driver.GetHealth().State.ShouldBe(DriverState.Degraded);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HandlePollError_forANonDbExceptionCarryingCredentials_degradesWithoutLeakingThem()
|
||||
{
|
||||
// The residual C1 leg HandlePollError owns: a NON-DbException raised from provider interaction —
|
||||
// canonically the ArgumentException a malformed connection string's keyword parser throws, echoing
|
||||
// credential fragments (the exact unquoted-';'-in-password vector this whole fix exists for). It is
|
||||
// not the DbException class ReadAsync owns, so it reaches Degrade — and must arrive TYPE-only, never
|
||||
// ex.Message. Pre-fix this degraded with the raw message and leaked the token.
|
||||
using var fixture = new SqlitePollFixture();
|
||||
var driver = NewDriver(fixture, KvEntry("Speed", SqlitePollFixture.PresentKey));
|
||||
|
||||
driver.HandlePollError(new ArgumentException(
|
||||
"Keyword not supported: 'secrettail;connect timeout'. Password=" + SecretToken));
|
||||
|
||||
var health = driver.GetHealth();
|
||||
health.State.ShouldBe(DriverState.Degraded);
|
||||
health.LastError.ShouldNotBeNull();
|
||||
health.LastError.ShouldNotContain(SecretToken); // never the message text
|
||||
health.LastError.ShouldContain(nameof(ArgumentException)); // but still actionable (the type)
|
||||
}
|
||||
|
||||
// ---- I2: a late poll cannot un-fault a Faulted driver ----
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -12,16 +12,19 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests;
|
||||
/// <em>identifier</em> — which cannot be parameterized in SQL — is dialect-quoted so a hostile one becomes
|
||||
/// an inert reference to a nonexistent object rather than executable text. If these pass, a malicious tag
|
||||
/// cannot alter the database; the seed table is intact after every hostile poll.
|
||||
/// <para><b>Scope note — what this suite does NOT assume.</b> Design §8.1 also specifies a catalog gate:
|
||||
/// validate every authored table/column against <c>INFORMATION_SCHEMA</c> before quoting it, so an unknown
|
||||
/// identifier <em>rejects the tag</em>. <b>No such gate exists in the driver yet</b> (see the "Not yet
|
||||
/// implemented" note on <see cref="ISqlDialect"/>), and no task in this workstream builds one. So a hostile
|
||||
/// identifier here is <b>not</b> rejected as <c>BadNodeIdUnknown</c> — it is bracket-/double-quoted into a
|
||||
/// single nonexistent identifier, the query then fails after the connection opened, and the tag Bad-codes
|
||||
/// as a query failure (<see cref="SqlStatusCodes.BadCommunicationError"/>). This suite proves the payload
|
||||
/// is <em>inert</em> — it does not execute and the table survives — which is the guarantee the code
|
||||
/// actually makes. The catalog gate is a separate follow-up; a test that pretended it existed would be
|
||||
/// asserting fiction.</para>
|
||||
/// <para><b>Scope note — this suite deliberately tests the layer BELOW the catalog gate.</b> Design
|
||||
/// §8.1's catalog gate now exists (Gitea #496): <see cref="SqlCatalogGate"/> validates every authored
|
||||
/// table/column against the live catalog at Initialize, so in the assembled driver a hostile identifier
|
||||
/// is rejected up front and its tag reads <see cref="SqlStatusCodes.BadNodeIdUnknown"/> — proven by
|
||||
/// <c>SqlCatalogGateDriverTests</c>.</para>
|
||||
/// <para>The tests here construct a <see cref="SqlPollReader"/> <b>directly</b>, with hand-built
|
||||
/// definitions that never pass through the gate, and that is the point: defence in depth is only worth
|
||||
/// the name if each layer holds on its own. These assertions pin the <em>quoting backstop</em> — that
|
||||
/// even with the allow-list bypassed entirely, a hostile identifier becomes an inert reference to a
|
||||
/// nonexistent object, the query fails after the connection opened, the tag Bad-codes as a query failure
|
||||
/// (<see cref="SqlStatusCodes.BadCommunicationError"/>), and the seed table survives. Rewriting them to
|
||||
/// expect <c>BadNodeIdUnknown</c> would delete the only coverage the backstop has and leave the gate as a
|
||||
/// single point of failure.</para>
|
||||
/// </summary>
|
||||
public sealed class SqlInjectionRegressionTests
|
||||
{
|
||||
@@ -68,9 +71,11 @@ public sealed class SqlInjectionRegressionTests
|
||||
using var fixture = new SqlitePollFixture();
|
||||
var seedRows = await RowCountAsync(fixture, SqlitePollFixture.KeyValueTable);
|
||||
|
||||
// A table name carrying a statement terminator + DROP. There is NO catalog gate, so this is not
|
||||
// rejected up front — it is quoted into one nonexistent identifier. The query fails (no such table),
|
||||
// the connection having opened, so the tag Bad-codes as a query failure. The DROP never runs.
|
||||
// A table name carrying a statement terminator + DROP. The reader is driven directly, so the
|
||||
// catalog gate never sees it and the quoting backstop is on its own: the name is quoted into one
|
||||
// nonexistent identifier, the query fails (no such table) with the connection already open, so the
|
||||
// tag Bad-codes as a query failure. The DROP never runs. In the assembled driver the gate rejects
|
||||
// this first and the tag reads BadNodeIdUnknown instead — see SqlCatalogGateDriverTests.
|
||||
var reader = NewReader(fixture,
|
||||
new SqlTagDefinition(
|
||||
Name: "Evil",
|
||||
@@ -83,7 +88,7 @@ public sealed class SqlInjectionRegressionTests
|
||||
|
||||
var snapshots = await reader.ReadAsync(["Evil"], CancellationToken.None);
|
||||
|
||||
// Bad-coded — but as a QUERY failure, not BadNodeIdUnknown (there is no identifier-catalog gate).
|
||||
// Bad-coded as a QUERY failure, not BadNodeIdUnknown — this reader was built without the gate.
|
||||
SqlStatusCodes.IsBad(snapshots[0].StatusCode).ShouldBeTrue();
|
||||
snapshots[0].StatusCode.ShouldBe(SqlStatusCodes.BadCommunicationError);
|
||||
// The load-bearing assertion: the payload did NOT execute. The table and its rows are untouched.
|
||||
|
||||
@@ -64,6 +64,12 @@ public sealed class SqliteDialect : ISqlDialect
|
||||
/// </summary>
|
||||
public string ListSchemasSql => $"SELECT '{MainSchema}' AS TABLE_SCHEMA";
|
||||
|
||||
/// <summary>
|
||||
/// SQLite has no per-principal default schema, so an unqualified name always resolves in
|
||||
/// <see cref="MainSchema"/> — the same single schema <see cref="ListSchemasSql"/> reports.
|
||||
/// </summary>
|
||||
public string DefaultSchemaSql => $"SELECT '{MainSchema}'";
|
||||
|
||||
/// <summary>
|
||||
/// Tables + views from <c>sqlite_schema</c> (the modern name for <c>sqlite_master</c>), with the
|
||||
/// internal <c>sqlite_*</c> objects filtered out and the type folded onto the
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// #504 — <see cref="DisplayText.Abbreviate"/>, the null/short-safe replacement for the
|
||||
/// <c>@value[..N]</c> range-operator slices the Admin UI used to abbreviate hashes and thumbprints.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The bug being pinned: a range operator over an unvalidated DB string throws
|
||||
/// <see cref="System.ArgumentOutOfRangeException"/> out of <c>BuildRenderTree</c>, which is unhandled
|
||||
/// in Blazor and takes the WHOLE page to an HTTP 500 — not just the offending row. Nothing enforces a
|
||||
/// minimum length at the schema, entity, or render layer, so the short-input cases below are the ones
|
||||
/// that matter; the happy path is almost incidental.
|
||||
/// </remarks>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class DisplayTextTests
|
||||
{
|
||||
/// <summary>A value longer than the budget is truncated and marked with an ellipsis — the normal
|
||||
/// case for a 64-char SHA-256.</summary>
|
||||
[Fact]
|
||||
public void A_long_value_is_truncated_and_marked()
|
||||
{
|
||||
DisplayText.Abbreviate(new string('a', 64), 12).ShouldBe(new string('a', 12) + "…");
|
||||
}
|
||||
|
||||
/// <summary>The regression: a value SHORTER than the budget must render, not throw. This is exactly
|
||||
/// what a hand-inserted / API-authored <c>SourceHash</c> like <c>h1</c> hits.</summary>
|
||||
[Theory]
|
||||
[InlineData("h1", 12)]
|
||||
[InlineData("h-abs-hr200", 12)] // 11 chars — one short of the old slice, the original crash
|
||||
[InlineData("abc", 16)]
|
||||
[InlineData("x", 64)]
|
||||
public void A_value_shorter_than_the_budget_renders_verbatim(string value, int maxLength)
|
||||
{
|
||||
DisplayText.Abbreviate(value, maxLength).ShouldBe(value);
|
||||
}
|
||||
|
||||
/// <summary>No ellipsis when nothing was dropped — the display must never imply text that is not
|
||||
/// there. (The old markup appended "…" unconditionally, outside the slice.)</summary>
|
||||
[Fact]
|
||||
public void A_short_value_gets_no_ellipsis()
|
||||
{
|
||||
DisplayText.Abbreviate("h1", 12).ShouldNotContain("…");
|
||||
}
|
||||
|
||||
/// <summary>A value exactly at the budget is the boundary — it fits, so it is neither truncated nor
|
||||
/// marked.</summary>
|
||||
[Fact]
|
||||
public void A_value_exactly_at_the_budget_is_untouched()
|
||||
{
|
||||
DisplayText.Abbreviate("123456789012", 12).ShouldBe("123456789012");
|
||||
}
|
||||
|
||||
/// <summary>Null / blank render as the Admin UI's em-dash placeholder rather than throwing or
|
||||
/// producing a stray ellipsis.</summary>
|
||||
[Theory]
|
||||
[InlineData(null)]
|
||||
[InlineData("")]
|
||||
[InlineData(" ")]
|
||||
public void A_null_or_blank_value_renders_the_placeholder(string? value)
|
||||
{
|
||||
DisplayText.Abbreviate(value, 12).ShouldBe(DisplayText.Empty);
|
||||
}
|
||||
|
||||
/// <summary>A nonsensical budget is clamped rather than allowed to throw — a display bug must not
|
||||
/// become a page crash, which is the whole point of this helper.</summary>
|
||||
[Theory]
|
||||
[InlineData(0)]
|
||||
[InlineData(-1)]
|
||||
[InlineData(int.MinValue)]
|
||||
public void A_non_positive_budget_is_clamped_not_thrown(int maxLength)
|
||||
{
|
||||
DisplayText.Abbreviate("abcdef", maxLength).ShouldBe("a…");
|
||||
}
|
||||
|
||||
/// <summary>Whatever the inputs, it never throws — the property that keeps a bad row from killing a
|
||||
/// page. Includes the shapes that broke the old code.</summary>
|
||||
[Fact]
|
||||
public void It_never_throws_for_any_combination()
|
||||
{
|
||||
string?[] values = [null, "", " ", "h1", "h-abs-hr200", new string('f', 64), "…"];
|
||||
int[] budgets = [int.MinValue, -1, 0, 1, 12, 16, 64, int.MaxValue];
|
||||
|
||||
foreach (var value in values)
|
||||
{
|
||||
foreach (var budget in budgets)
|
||||
{
|
||||
Should.NotThrow(() => DisplayText.Abbreviate(value, budget));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+128
-77
@@ -13,14 +13,21 @@ namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.ScriptAnalysis;
|
||||
/// pass to <c>ctx.GetTag("…")</c> / <c>ctx.SetVirtualTag("…")</c>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Fidelity finding (see <see cref="ScriptTagCatalog"/> doc comment): the runtime Akka path resolves
|
||||
/// a <c>ctx.GetTag</c> literal against the driver <b>FullName</b> (the wire reference in
|
||||
/// <c>Tag.TagConfig.FullName</c>), NOT the UNS browse path — the UNS-path engine is dormant. v3: Tag is
|
||||
/// Raw-only (no <c>EquipmentId</c>/<c>FolderPath</c>/<c>DriverInstanceId</c>), so the catalog projects
|
||||
/// the surviving <c>Name</c>/<c>DataType</c>/<c>TagConfig</c> columns: the resolvable path derives from
|
||||
/// the <c>TagConfig.FullName</c> field when present, else the tag <c>Name</c>; the projected
|
||||
/// <c>DriverInstanceId</c> is always <see langword="null"/>. Virtual tags emit their leaf Name. These
|
||||
/// assertions check the resolvable key is present AND that the UNS browse paths are absent.
|
||||
/// <para>
|
||||
/// Fidelity finding (see the <see cref="ScriptTagCatalog"/> doc comment): the runtime resolves a
|
||||
/// <c>ctx.GetTag</c> literal through <c>DependencyMuxActor._byRef</c>, keyed Ordinal by the driver's
|
||||
/// wire reference — and in v3 that wire reference <b>is the RawPath</b>
|
||||
/// (<c>Folder/…/Driver/Device/[TagGroup/…]/Tag</c>). So the catalog projects RawPaths for raw tags and
|
||||
/// the leaf <c>Name</c> for virtual tags.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// These assertions were rewritten for #490. They previously pinned the pre-v3 projection —
|
||||
/// <c>Tag.TagConfig.FullName</c> — which since v3 is not the mux key at all, so they were encoding a
|
||||
/// contract the runtime had stopped honouring: completion offered paths that could never resolve, and a
|
||||
/// correct absolute RawPath resolved to nothing. The seed now builds a real raw topology
|
||||
/// (RawFolder → DriverInstance → Device → TagGroup → Tag) because a RawPath only exists if that chain
|
||||
/// does.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class ScriptTagCatalogTests
|
||||
@@ -43,8 +50,9 @@ public sealed class ScriptTagCatalogTests
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Seeds an Area → Line → Equipment path with: one equipment driver tag (FullName "Motor.Speed"),
|
||||
/// one virtual tag, and one FolderPath-scoped tag (EquipmentId null, FolderPath set).
|
||||
/// Seeds the UNS path (Area → Line → Equipment) AND the raw topology a RawPath is built from:
|
||||
/// RawFolder "Plant" → DriverInstance "Modbus" → Device "dev1", with one tag directly under the
|
||||
/// device and one under a nested TagGroup. A virtual tag is added for the leaf-Name projection.
|
||||
/// </summary>
|
||||
private static void Seed(DbContextOptions<OtOpcUaConfigDbContext> opts)
|
||||
{
|
||||
@@ -61,26 +69,38 @@ public sealed class ScriptTagCatalogTests
|
||||
MachineCode = "machine_001",
|
||||
});
|
||||
|
||||
// Raw tag — the GetTag key is the driver FullName from TagConfig.
|
||||
// Raw topology — the ancestry a RawPath is composed from.
|
||||
db.RawFolders.Add(new RawFolder { RawFolderId = "RF-1", ClusterId = "MAIN", ParentRawFolderId = null, Name = "Plant" });
|
||||
db.DriverInstances.Add(new DriverInstance
|
||||
{
|
||||
DriverInstanceId = "DRV-1", ClusterId = "MAIN", RawFolderId = "RF-1",
|
||||
Name = "Modbus", DriverType = "Modbus", DriverConfig = "{}",
|
||||
});
|
||||
db.Devices.Add(new Device { DeviceId = "DEV-1", DriverInstanceId = "DRV-1", Name = "dev1", DeviceConfig = "{}" });
|
||||
db.TagGroups.Add(new TagGroup { TagGroupId = "TG-1", DeviceId = "DEV-1", ParentTagGroupId = null, Name = "Motors" });
|
||||
|
||||
// Directly under the device -> Plant/Modbus/dev1/Speed
|
||||
db.Tags.Add(new Tag
|
||||
{
|
||||
TagId = "TAG-EQ",
|
||||
DeviceId = "DEV-1",
|
||||
TagGroupId = null,
|
||||
Name = "Speed",
|
||||
DataType = "Float",
|
||||
AccessLevel = TagAccessLevel.Read,
|
||||
TagConfig = "{\"FullName\":\"Motor.Speed\"}",
|
||||
});
|
||||
|
||||
// Raw tag whose FullName is an MXAccess dot-ref; resolves by that FullName.
|
||||
// Under a tag group -> Plant/Modbus/dev1/Motors/Torque
|
||||
db.Tags.Add(new Tag
|
||||
{
|
||||
TagId = "TAG-SP",
|
||||
TagId = "TAG-GRP",
|
||||
DeviceId = "DEV-1",
|
||||
Name = "DownloadPath",
|
||||
DataType = "String",
|
||||
TagGroupId = "TG-1",
|
||||
Name = "Torque",
|
||||
DataType = "Double",
|
||||
AccessLevel = TagAccessLevel.Read,
|
||||
TagConfig = "{\"FullName\":\"DelmiaReceiver_001.DownloadPath\"}",
|
||||
TagConfig = "{\"FullName\":\"Motor.Torque\"}",
|
||||
});
|
||||
|
||||
db.VirtualTags.Add(new VirtualTag
|
||||
@@ -95,30 +115,27 @@ public sealed class ScriptTagCatalogTests
|
||||
db.SaveChanges();
|
||||
}
|
||||
|
||||
/// <summary>A null filter returns only the resolvable keys: the driver FullName for the equipment
|
||||
/// tag, the MXAccess dot-ref for the FolderPath-scoped tag, and the virtual tag's leaf Name — and
|
||||
/// NONE of the UNS browse paths.</summary>
|
||||
/// <summary>The v3 resolvable keys: a RawPath per raw tag (device-level and group-nested), plus the
|
||||
/// virtual tag's leaf Name — and NOT the pre-v3 <c>TagConfig.FullName</c>, which is no longer the mux
|
||||
/// key and would suggest a path that cannot resolve at runtime (#490).</summary>
|
||||
[Fact]
|
||||
public async Task GetPaths_no_filter_returns_resolvable_keys_only()
|
||||
public async Task GetPaths_no_filter_returns_rawpaths_only()
|
||||
{
|
||||
var (catalog, opts) = Fresh();
|
||||
Seed(opts);
|
||||
|
||||
var paths = await catalog.GetPathsAsync(null, default);
|
||||
|
||||
// Equipment driver tag: the authoritative GetTag key (driver FullName).
|
||||
paths.ShouldContain("Motor.Speed");
|
||||
paths.ShouldContain("Plant/Modbus/dev1/Speed"); // directly under the device
|
||||
paths.ShouldContain("Plant/Modbus/dev1/Motors/Torque"); // nested under a tag group
|
||||
paths.ShouldContain("Computed"); // virtual tag: leaf Name
|
||||
|
||||
// FolderPath-scoped tag: MXAccess dot-ref.
|
||||
paths.ShouldContain("DelmiaReceiver_001.DownloadPath");
|
||||
// The pre-v3 wire address is NOT a resolvable key in v3.
|
||||
paths.ShouldNotContain("Motor.Speed");
|
||||
paths.ShouldNotContain("Motor.Torque");
|
||||
|
||||
// Virtual tag: leaf Name only.
|
||||
paths.ShouldContain("Computed");
|
||||
|
||||
// The UNS browse paths are intentionally NOT suggested (the UNS-path engine is dormant).
|
||||
// UNS browse paths are intentionally NOT suggested (that engine is dormant).
|
||||
paths.ShouldNotContain("Assembly/LineA/Machine1/Speed");
|
||||
paths.ShouldNotContain("DelmiaReceiver_001/DownloadPath");
|
||||
paths.ShouldNotContain("Assembly/LineA/Machine1/Computed");
|
||||
}
|
||||
|
||||
/// <summary>The result is distinct.</summary>
|
||||
@@ -133,37 +150,66 @@ public sealed class ScriptTagCatalogTests
|
||||
paths.Distinct(StringComparer.OrdinalIgnoreCase).Count().ShouldBe(paths.Count);
|
||||
}
|
||||
|
||||
/// <summary>A literal prefix narrows the result (case-insensitive StartsWith) to matching keys.</summary>
|
||||
/// <summary>A literal prefix narrows the result (case-insensitive StartsWith). This is the completion
|
||||
/// path an author actually hits: typing a partial absolute RawPath inside ctx.GetTag("…").</summary>
|
||||
[Fact]
|
||||
public async Task GetPaths_prefix_filter_narrows()
|
||||
public async Task GetPaths_prefix_filter_narrows_on_a_partial_rawpath()
|
||||
{
|
||||
var (catalog, opts) = Fresh();
|
||||
Seed(opts);
|
||||
|
||||
var paths = await catalog.GetPathsAsync("Motor", default);
|
||||
var paths = await catalog.GetPathsAsync("Plant/Modbus/dev1/", default);
|
||||
|
||||
paths.ShouldContain("Motor.Speed");
|
||||
paths.ShouldNotContain("DelmiaReceiver_001.DownloadPath");
|
||||
paths.ShouldContain("Plant/Modbus/dev1/Speed");
|
||||
paths.ShouldContain("Plant/Modbus/dev1/Motors/Torque");
|
||||
paths.ShouldNotContain("Computed");
|
||||
paths.ShouldAllBe(p => p.StartsWith("Motor", StringComparison.OrdinalIgnoreCase));
|
||||
paths.ShouldAllBe(p => p.StartsWith("Plant/Modbus/dev1/", StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
/// <summary>The prefix match is case-insensitive.</summary>
|
||||
/// <summary>The prefix match is case-insensitive (completion is forgiving; the exact lookup is not).</summary>
|
||||
[Fact]
|
||||
public async Task GetPaths_prefix_filter_is_case_insensitive()
|
||||
{
|
||||
var (catalog, opts) = Fresh();
|
||||
Seed(opts);
|
||||
|
||||
var paths = await catalog.GetPathsAsync("motor", default);
|
||||
|
||||
paths.ShouldContain("Motor.Speed");
|
||||
(await catalog.GetPathsAsync("plant/modbus", default)).ShouldContain("Plant/Modbus/dev1/Speed");
|
||||
}
|
||||
|
||||
/// <summary>A tag whose <c>TagConfig</c> is malformed JSON must not throw — the catalog falls back
|
||||
/// to the raw blob and still returns every other tag.</summary>
|
||||
/// <summary>A tag whose ancestry chain is broken (device row missing) has no RawPath. The runtime could
|
||||
/// not route it either, so it is omitted rather than guessed — and it must not throw or suppress the
|
||||
/// tags that DO resolve.</summary>
|
||||
[Fact]
|
||||
public async Task GetPaths_malformed_tagconfig_does_not_throw()
|
||||
public async Task GetPaths_tag_with_a_broken_ancestry_chain_is_omitted()
|
||||
{
|
||||
var (catalog, opts) = Fresh();
|
||||
Seed(opts);
|
||||
|
||||
using (var db = new OtOpcUaConfigDbContext(opts))
|
||||
{
|
||||
db.Tags.Add(new Tag
|
||||
{
|
||||
TagId = "TAG-ORPHAN",
|
||||
DeviceId = "DEV-DOES-NOT-EXIST",
|
||||
Name = "Orphan",
|
||||
DataType = "Float",
|
||||
AccessLevel = TagAccessLevel.Read,
|
||||
TagConfig = "{}",
|
||||
});
|
||||
db.SaveChanges();
|
||||
}
|
||||
|
||||
IReadOnlyList<string> paths = [];
|
||||
await Should.NotThrowAsync(async () => paths = await catalog.GetPathsAsync(null, default));
|
||||
|
||||
paths.ShouldNotContain("Orphan");
|
||||
paths.ShouldContain("Plant/Modbus/dev1/Speed");
|
||||
}
|
||||
|
||||
/// <summary>A malformed <c>TagConfig</c> is irrelevant to the RawPath (which comes from the topology,
|
||||
/// not the blob) — it must neither throw nor drop the tag.</summary>
|
||||
[Fact]
|
||||
public async Task GetPaths_malformed_tagconfig_does_not_affect_the_rawpath()
|
||||
{
|
||||
var (catalog, opts) = Fresh();
|
||||
Seed(opts);
|
||||
@@ -185,37 +231,33 @@ public sealed class ScriptTagCatalogTests
|
||||
IReadOnlyList<string> paths = [];
|
||||
await Should.NotThrowAsync(async () => paths = await catalog.GetPathsAsync(null, default));
|
||||
|
||||
// The malformed tag falls through to the raw blob (acceptable), and the other tags still appear.
|
||||
paths.ShouldContain("Motor.Speed");
|
||||
paths.ShouldContain("DelmiaReceiver_001.DownloadPath");
|
||||
paths.ShouldContain("Computed");
|
||||
paths.ShouldContain("Plant/Modbus/dev1/Broken");
|
||||
paths.ShouldContain("Plant/Modbus/dev1/Speed");
|
||||
}
|
||||
|
||||
/// <summary>A FolderPath-scoped tag with a null/empty <c>FolderPath</c> yields just its <c>Name</c>
|
||||
/// (no leading dot).</summary>
|
||||
/// <summary>A driver at the cluster root (no RawFolder) contributes no folder segment.</summary>
|
||||
[Fact]
|
||||
public async Task GetPaths_unbound_tag_without_folder_yields_name_only()
|
||||
public async Task GetPaths_driver_at_cluster_root_has_no_folder_segment()
|
||||
{
|
||||
var (catalog, opts) = Fresh();
|
||||
|
||||
using (var db = new OtOpcUaConfigDbContext(opts))
|
||||
{
|
||||
db.DriverInstances.Add(new DriverInstance
|
||||
{
|
||||
DriverInstanceId = "DRV-ROOT", ClusterId = "MAIN", RawFolderId = null,
|
||||
Name = "RootDrv", DriverType = "Modbus", DriverConfig = "{}",
|
||||
});
|
||||
db.Devices.Add(new Device { DeviceId = "DEV-ROOT", DriverInstanceId = "DRV-ROOT", Name = "d0", DeviceConfig = "{}" });
|
||||
db.Tags.Add(new Tag
|
||||
{
|
||||
TagId = "TAG-SP-ROOT",
|
||||
DeviceId = "DEV-1",
|
||||
Name = "RootScalar",
|
||||
DataType = "String",
|
||||
AccessLevel = TagAccessLevel.Read,
|
||||
TagConfig = "{\"FullName\":\"RootScalar\"}",
|
||||
TagId = "TAG-ROOT", DeviceId = "DEV-ROOT", Name = "Scalar",
|
||||
DataType = "String", AccessLevel = TagAccessLevel.Read, TagConfig = "{}",
|
||||
});
|
||||
db.SaveChanges();
|
||||
}
|
||||
|
||||
var paths = await catalog.GetPathsAsync(null, default);
|
||||
|
||||
paths.ShouldContain("RootScalar");
|
||||
paths.ShouldNotContain(".RootScalar");
|
||||
(await catalog.GetPathsAsync(null, default)).ShouldContain("RootDrv/d0/Scalar");
|
||||
}
|
||||
|
||||
/// <summary>An empty database yields an empty list rather than throwing.</summary>
|
||||
@@ -224,26 +266,24 @@ public sealed class ScriptTagCatalogTests
|
||||
{
|
||||
var (catalog, _) = Fresh();
|
||||
|
||||
var paths = await catalog.GetPathsAsync(null, default);
|
||||
|
||||
paths.ShouldBeEmpty();
|
||||
(await catalog.GetPathsAsync(null, default)).ShouldBeEmpty();
|
||||
}
|
||||
|
||||
/// <summary>A raw tag whose FullName is a dot-ref resolves by that FullName to a Tag-kind info with
|
||||
/// the configured DataType. v3: Tag no longer binds a driver, so the projected DriverInstanceId is null.</summary>
|
||||
/// <summary>Hover: an absolute RawPath resolves to Tag-kind info carrying the configured DataType and —
|
||||
/// new in #490 — the owning driver instance, which the topology now makes available.</summary>
|
||||
[Fact]
|
||||
public async Task GetTagInfo_unbound_tag_returns_tag_info()
|
||||
public async Task GetTagInfo_absolute_rawpath_returns_tag_info_with_driver()
|
||||
{
|
||||
var (catalog, opts) = Fresh();
|
||||
Seed(opts);
|
||||
|
||||
var info = await catalog.GetTagInfoAsync("DelmiaReceiver_001.DownloadPath", default);
|
||||
var info = await catalog.GetTagInfoAsync("Plant/Modbus/dev1/Motors/Torque", default);
|
||||
|
||||
info.ShouldNotBeNull();
|
||||
info!.Path.ShouldBe("DelmiaReceiver_001.DownloadPath");
|
||||
info!.Path.ShouldBe("Plant/Modbus/dev1/Motors/Torque");
|
||||
info.Kind.ShouldBe("Tag");
|
||||
info.DataType.ShouldBe("String");
|
||||
info.DriverInstanceId.ShouldBeNull();
|
||||
info.DataType.ShouldBe("Double");
|
||||
info.DriverInstanceId.ShouldBe("DRV-1");
|
||||
}
|
||||
|
||||
/// <summary>A virtual tag resolves by its leaf Name to a "Virtual tag"-kind info with no driver.</summary>
|
||||
@@ -256,12 +296,22 @@ public sealed class ScriptTagCatalogTests
|
||||
var info = await catalog.GetTagInfoAsync("Computed", default);
|
||||
|
||||
info.ShouldNotBeNull();
|
||||
info!.Path.ShouldBe("Computed");
|
||||
info.Kind.ShouldBe("Virtual tag");
|
||||
info!.Kind.ShouldBe("Virtual tag");
|
||||
info.DataType.ShouldBe("Double");
|
||||
info.DriverInstanceId.ShouldBeNull();
|
||||
}
|
||||
|
||||
/// <summary>The pre-v3 wire address no longer resolves — the regression #490 is really about. Hovering
|
||||
/// one must report "not a known path" rather than validating a string the runtime would drop.</summary>
|
||||
[Fact]
|
||||
public async Task GetTagInfo_pre_v3_fullname_no_longer_resolves()
|
||||
{
|
||||
var (catalog, opts) = Fresh();
|
||||
Seed(opts);
|
||||
|
||||
(await catalog.GetTagInfoAsync("Motor.Speed", default)).ShouldBeNull();
|
||||
}
|
||||
|
||||
/// <summary>An unknown path resolves to null.</summary>
|
||||
[Fact]
|
||||
public async Task GetTagInfo_unknown_path_returns_null()
|
||||
@@ -269,19 +319,20 @@ public sealed class ScriptTagCatalogTests
|
||||
var (catalog, opts) = Fresh();
|
||||
Seed(opts);
|
||||
|
||||
(await catalog.GetTagInfoAsync("NoSuchPath", default)).ShouldBeNull();
|
||||
(await catalog.GetTagInfoAsync("Plant/Modbus/dev1/NoSuchTag", default)).ShouldBeNull();
|
||||
}
|
||||
|
||||
/// <summary>The lookup is case-SENSITIVE (Ordinal): a path that differs only in case does NOT
|
||||
/// resolve, mirroring the runtime DependencyMuxActor's StringComparer.Ordinal keying.</summary>
|
||||
/// <summary>The lookup is case-SENSITIVE (Ordinal), mirroring the runtime DependencyMuxActor's
|
||||
/// StringComparer.Ordinal keying — a case-mismatched path would not resolve live, so hover must not
|
||||
/// claim it does.</summary>
|
||||
[Fact]
|
||||
public async Task GetTagInfo_case_mismatch_returns_null_ordinal()
|
||||
{
|
||||
var (catalog, opts) = Fresh();
|
||||
Seed(opts);
|
||||
|
||||
(await catalog.GetTagInfoAsync("motor.speed", default)).ShouldBeNull();
|
||||
(await catalog.GetTagInfoAsync("Motor.Speed", default)).ShouldNotBeNull();
|
||||
(await catalog.GetTagInfoAsync("plant/modbus/dev1/speed", default)).ShouldBeNull();
|
||||
(await catalog.GetTagInfoAsync("Plant/Modbus/dev1/Speed", default)).ShouldNotBeNull();
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
@@ -61,4 +61,15 @@ public sealed class ModbusDriverFormModelTests
|
||||
json.ShouldContain("\"family\":\"MELSEC\""); // enum-as-name (not a number), camelCase key
|
||||
json.ShouldNotContain("\"family\":0", Case.Sensitive); // never the numeric enum form
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Round_trip_preserves_Transport_mode()
|
||||
{
|
||||
var form = new ModbusDriverForm.FormModel { Transport = ModbusTransportMode.RtuOverTcp };
|
||||
var json = JsonSerializer.Serialize(form.ToOptions(), JsonOpts);
|
||||
var back = ModbusDriverForm.FormModel.FromOptions(
|
||||
JsonSerializer.Deserialize<ModbusDriverOptions>(json, JsonOpts)!);
|
||||
back.Transport.ShouldBe(ModbusTransportMode.RtuOverTcp);
|
||||
json.ShouldContain("\"transport\":\"RtuOverTcp\""); // name string, never a number
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Sql;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Uns;
|
||||
|
||||
/// <summary>
|
||||
/// Guards the AdminUI <c>SqlDriverForm</c>'s driver-config output against the runtime factory that consumes
|
||||
/// it. The form is razor (no bUnit here), so these tests pin two things the live verify then confirms:
|
||||
/// (1) the exact JSON bytes the form emits for a minimal + fuller config are accepted by the authoritative
|
||||
/// reader, <see cref="SqlDriverFactoryExtensions.CreateInstance(string,string)"/>; and (2) the
|
||||
/// serialization contract — <c>provider</c> as a NAME string (never an ordinal), camelCase keys, and no
|
||||
/// composer-owned <c>rawTags</c> / inert <c>allowWrites</c> leaking into the authored blob. A numeric enum
|
||||
/// or a stray connection string is the "authors fine, never reads / leaks a secret" defect class this guards.
|
||||
/// </summary>
|
||||
public sealed class SqlDriverFormContractTests
|
||||
{
|
||||
// The JsonSerializerOptions SqlDriverForm serializes with (camelCase + string enums + omit-nulls).
|
||||
private static readonly JsonSerializerOptions FormOptions = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||
WriteIndented = false,
|
||||
Converters = { new JsonStringEnumConverter() },
|
||||
};
|
||||
|
||||
// ---- LOAD-BEARING: the exact bytes SqlDriverForm emits must construct a driver through the factory ----
|
||||
|
||||
[Theory]
|
||||
// Minimal valid config: provider + required connectionStringRef only (all timeouts left to the factory).
|
||||
[InlineData("""{"provider":"SqlServer","connectionStringRef":"AdminUiFormTest"}""")]
|
||||
// Fuller config with every optional the form authors (timeouts satisfy operationTimeout > commandTimeout).
|
||||
[InlineData("""{"provider":"SqlServer","connectionStringRef":"AdminUiFormTest","defaultPollInterval":"00:00:05","operationTimeout":"00:00:20","commandTimeout":"00:00:10","maxConcurrentGroups":8,"nullIsBad":true}""")]
|
||||
public void FormEmittedJson_constructs_a_driver_through_the_factory(string formEmittedJson)
|
||||
{
|
||||
var envVar = SqlConnectionStringResolver.EnvironmentVariableFor("AdminUiFormTest");
|
||||
var previous = Environment.GetEnvironmentVariable(envVar);
|
||||
Environment.SetEnvironmentVariable(envVar, "Server=localhost;Database=Test;Trusted_Connection=True;");
|
||||
try
|
||||
{
|
||||
var driver = SqlDriverFactoryExtensions.CreateInstance("adminui-form-test", formEmittedJson);
|
||||
driver.ShouldNotBeNull();
|
||||
}
|
||||
finally
|
||||
{
|
||||
Environment.SetEnvironmentVariable(envVar, previous);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Factory_rejects_a_blob_missing_connectionStringRef()
|
||||
{
|
||||
// The form marks connectionStringRef required; the factory is the authoritative enforcement.
|
||||
var ex = Should.Throw<InvalidOperationException>(
|
||||
() => SqlDriverFactoryExtensions.CreateInstance("adminui-form-test", """{"provider":"SqlServer"}"""));
|
||||
ex.Message.ShouldContain("connectionStringRef");
|
||||
}
|
||||
|
||||
// ---- serialization contract: enum-by-name, camelCase, no rawTags / allowWrites / null optionals --------
|
||||
|
||||
[Fact]
|
||||
public void Minimal_config_serializes_provider_as_a_name_and_omits_owned_and_null_fields()
|
||||
{
|
||||
// Mirrors SqlDriverForm.FormModel.ToDto() for a minimal config (only the required ref set).
|
||||
var dto = new SqlDriverConfigDto { Provider = SqlProvider.SqlServer, ConnectionStringRef = "MesStaging" };
|
||||
|
||||
var json = JsonSerializer.Serialize(dto, FormOptions);
|
||||
|
||||
json.ShouldBe("""{"provider":"SqlServer","connectionStringRef":"MesStaging"}""");
|
||||
json.ShouldNotContain("\"provider\":0"); // never an ordinal — that faults the string-typed factory read
|
||||
json.ShouldNotContain("rawTags"); // composer-owned; never authored by the form
|
||||
json.ShouldNotContain("allowWrites"); // inert in v1 (read-only); never authored
|
||||
json.ShouldNotContain("defaultPollInterval"); // null optional omitted ⇒ factory default applies
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Provider_round_trips_as_the_SqlServer_name()
|
||||
{
|
||||
var json = JsonSerializer.Serialize(
|
||||
new SqlDriverConfigDto { Provider = SqlProvider.SqlServer, ConnectionStringRef = "R" }, FormOptions);
|
||||
|
||||
json.ShouldContain("\"SqlServer\"");
|
||||
JsonSerializer.Deserialize<SqlDriverConfigDto>(json, FormOptions)!.Provider.ShouldBe(SqlProvider.SqlServer);
|
||||
}
|
||||
}
|
||||
+31
-5
@@ -86,8 +86,19 @@ public sealed class DriverHostActorNativeAlarmAckRoutingTests : RuntimeActorTest
|
||||
|
||||
actor.Tell(new DriverHostActor.RouteNativeAlarmAck("Plant/gw/dev1/does-not-exist", "cmt", "alice"));
|
||||
|
||||
// Give the (fire-and-forget) handler time to run; the unmapped node must produce no ack.
|
||||
AwaitAssert(() => recorder.Acks.ShouldBeEmpty(), duration: TimeSpan.FromMilliseconds(800));
|
||||
// POSITIVE CONTROL, not a sleep (#501). `Acks` is empty at t=0, so any assertion made before the
|
||||
// host has processed the message above passes without testing anything — and AwaitAssert returns on
|
||||
// its FIRST success, so it spends none of its duration and is the wrong tool for an absence.
|
||||
//
|
||||
// Instead send a MAPPED ack afterwards. The host processes its mailbox in order and forwards to the
|
||||
// child via Tell, and the child handles RouteAlarmAck with ReceiveAsync (which suspends its own
|
||||
// mailbox), so by the time this one reaches the driver the unmapped one has provably already been
|
||||
// handled. Whatever `Acks` holds then is the complete answer.
|
||||
actor.Tell(new DriverHostActor.RouteNativeAlarmAck(AlarmRawPath, "control", "alice"));
|
||||
|
||||
AwaitAssert(() => recorder.Acks.ShouldContain(a => a.Comment == "control"), duration: Timeout);
|
||||
recorder.Acks.Count.ShouldBe(1,
|
||||
"only the control ack may reach the driver — the unmapped condition NodeId must have been dropped");
|
||||
}
|
||||
|
||||
/// <summary>On a SECONDARY node the ack is gated off (same primary gate as the inbound write path): the
|
||||
@@ -110,10 +121,25 @@ public sealed class DriverHostActorNativeAlarmAckRoutingTests : RuntimeActorTest
|
||||
},
|
||||
CorrelationId.NewId()));
|
||||
|
||||
actor.Tell(new DriverHostActor.RouteNativeAlarmAck(AlarmRawPath, "cmt", "alice"));
|
||||
actor.Tell(new DriverHostActor.RouteNativeAlarmAck(AlarmRawPath, "gated", "alice"));
|
||||
|
||||
// No ack reached the driver — the gate short-circuited before the inverse-map lookup.
|
||||
AwaitAssert(() => recorder.Acks.ShouldBeEmpty(), duration: TimeSpan.FromMilliseconds(800));
|
||||
// POSITIVE CONTROL (#501) — same reasoning as the unmapped-node test, with the role itself as the
|
||||
// control: return the node to PRIMARY and re-send the SAME mapped ack. Mailbox ordering means that
|
||||
// once this one reaches the driver, the Secondary-gated one has already been processed. The
|
||||
// discriminator is the comment, so a leaked ack is visible rather than merged into the count.
|
||||
actor.Tell(new RedundancyStateChanged(
|
||||
new[]
|
||||
{
|
||||
new NodeRedundancyState(TestNode, RedundancyRole.Primary,
|
||||
IsClusterLeader: true, IsDriverPrimary: true, AsOfUtc: DateTime.UtcNow),
|
||||
},
|
||||
CorrelationId.NewId()));
|
||||
actor.Tell(new DriverHostActor.RouteNativeAlarmAck(AlarmRawPath, "control", "alice"));
|
||||
|
||||
AwaitAssert(() => recorder.Acks.ShouldContain(a => a.Comment == "control"), duration: Timeout);
|
||||
recorder.Acks.ShouldNotContain(a => a.Comment == "gated",
|
||||
"the Primary gate must have dropped the ack sent while this node was Secondary");
|
||||
recorder.Acks.Count.ShouldBe(1);
|
||||
}
|
||||
|
||||
/// <summary>Spawns the host with the recording alarm-driver factory, dispatches the deployment, and waits
|
||||
|
||||
+44
-6
@@ -55,11 +55,9 @@ public sealed class DriverHostActorWriteRoutingTests : RuntimeActorTestBase
|
||||
|
||||
var actor = SpawnHostAndApply(db, deploymentId, recorder);
|
||||
|
||||
var asker = CreateTestProbe();
|
||||
// The node manager passes the FULL ns-qualified id; the host must normalise + resolve it.
|
||||
actor.Tell(new DriverHostActor.RouteNodeWrite($"ns=3;s={UnsNodeId}", 123.0, AddressSpaceRealm.Uns), asker.Ref);
|
||||
RouteWriteUntilAccepted(actor, $"ns=3;s={UnsNodeId}", 123.0, AddressSpaceRealm.Uns);
|
||||
|
||||
asker.ExpectMsg<DriverHostActor.NodeWriteResult>(Timeout).Success.ShouldBeTrue();
|
||||
AwaitAssert(() =>
|
||||
{
|
||||
recorder.Writes.Count.ShouldBe(1);
|
||||
@@ -79,10 +77,8 @@ public sealed class DriverHostActorWriteRoutingTests : RuntimeActorTestBase
|
||||
|
||||
var actor = SpawnHostAndApply(db, deploymentId, recorder);
|
||||
|
||||
var asker = CreateTestProbe();
|
||||
actor.Tell(new DriverHostActor.RouteNodeWrite($"ns=2;s={RawPath}", 456.0, AddressSpaceRealm.Raw), asker.Ref);
|
||||
RouteWriteUntilAccepted(actor, $"ns=2;s={RawPath}", 456.0, AddressSpaceRealm.Raw);
|
||||
|
||||
asker.ExpectMsg<DriverHostActor.NodeWriteResult>(Timeout).Success.ShouldBeTrue();
|
||||
AwaitAssert(() =>
|
||||
{
|
||||
recorder.Writes.Count.ShouldBe(1);
|
||||
@@ -91,6 +87,48 @@ public sealed class DriverHostActorWriteRoutingTests : RuntimeActorTestBase
|
||||
}, duration: Timeout);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Routes a write, retrying until the host accepts it, and fails with the host's own rejection
|
||||
/// <c>Reason</c> if it never does.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para><b>Why a retry rather than a single Tell.</b> <c>ApplyAck</c> — which
|
||||
/// <see cref="SpawnHostAndApply"/> waits for — marks the end of the <em>apply</em>, not the point at
|
||||
/// which a write can succeed. Several things still have to happen after it: the spawned child has to
|
||||
/// finish <c>InitializeAsync</c> and leave <c>Connecting</c> (that state deliberately fast-fails
|
||||
/// writes with "driver not connected"), and the host has to push the NodeId→driver reverse map that
|
||||
/// resolves the write. Telling the write immediately therefore races the setup, which is why this
|
||||
/// assertion failed roughly 1 run in 30 of the fully parallel assembly — and never once in 60
|
||||
/// consecutive runs of this class alone.</para>
|
||||
/// <para><b>Why retrying does not weaken the assertion.</b> Every rejection branch — the Primary
|
||||
/// gate, an unresolved reverse map, "driver not running", and the child's own pre-Connected
|
||||
/// fast-fail — replies <em>without</em> reaching the driver. A rejected attempt records nothing, so
|
||||
/// the caller's <c>Writes.Count.ShouldBe(1)</c> still means exactly what it did before: the accepted
|
||||
/// write reached the driver exactly once.</para>
|
||||
/// <para>The failure message carries the last <c>Reason</c> so a genuine regression is diagnosable
|
||||
/// rather than surfacing as a bare "expected True".</para>
|
||||
/// </remarks>
|
||||
/// <param name="actor">The driver-host actor.</param>
|
||||
/// <param name="nodeId">The ns-qualified NodeId string, as the node manager passes it.</param>
|
||||
/// <param name="value">The value to write.</param>
|
||||
/// <param name="realm">The address-space realm the NodeId belongs to.</param>
|
||||
private void RouteWriteUntilAccepted(
|
||||
IActorRef actor, string nodeId, object value, AddressSpaceRealm realm)
|
||||
{
|
||||
var lastReason = "(no reply)";
|
||||
AwaitAssert(
|
||||
() =>
|
||||
{
|
||||
var asker = CreateTestProbe();
|
||||
actor.Tell(new DriverHostActor.RouteNodeWrite(nodeId, value, realm), asker.Ref);
|
||||
var result = asker.ExpectMsg<DriverHostActor.NodeWriteResult>(TimeSpan.FromSeconds(1));
|
||||
lastReason = result.Reason ?? "(none)";
|
||||
result.Success.ShouldBeTrue($"host kept rejecting the write; last reason: {lastReason}");
|
||||
},
|
||||
duration: Timeout,
|
||||
interval: TimeSpan.FromMilliseconds(50));
|
||||
}
|
||||
|
||||
/// <summary>On a SECONDARY, RouteNodeWrite (via the UNS NodeId) replies "not primary" and the driver
|
||||
/// receives NO write — the primary gate fires before the reverse-map lookup.</summary>
|
||||
[Fact]
|
||||
|
||||
+122
@@ -0,0 +1,122 @@
|
||||
using Akka.Actor;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Runtime.Drivers;
|
||||
using ZB.MOM.WW.OtOpcUa.Runtime.Tests.Harness;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.Drivers;
|
||||
|
||||
/// <summary>
|
||||
/// Gitea #488 — the periodic desired-vs-actual subscription reconcile that rides
|
||||
/// <see cref="DriverInstanceActor"/>'s existing <c>health-poll</c> tick.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The hole it closes: the desired ref set is re-applied on a <b>Connected transition</b> and on a
|
||||
/// deploy's <c>SetDesiredSubscriptions</c>, and nowhere else. A subscription lost while the driver
|
||||
/// <i>stayed</i> Connected — the shape reproduced here, a subscribe that failed and was never
|
||||
/// retried — left the actor permanently subscribed to nothing while reporting Healthy.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// All three tests drive a short <c>healthPollInterval</c> rather than the production 30 s. The
|
||||
/// two absence assertions are bounded by a positive control (a counted number of ticks provably
|
||||
/// processed), never by a bare sleep.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class DriverInstanceActorSubscriptionReconcileTests : RuntimeActorTestBase
|
||||
{
|
||||
private static readonly TimeSpan FastPoll = TimeSpan.FromMilliseconds(100);
|
||||
|
||||
/// <summary>
|
||||
/// The reconcile itself: the first subscribe fails, leaving the actor Connected with a desired set
|
||||
/// and no handle. Nothing else would ever retry it — no reconnect, no deploy — so a second
|
||||
/// subscribe can only come from the periodic reconcile.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void A_lost_subscription_is_re_established_while_still_connected()
|
||||
{
|
||||
var driver = new SubscribableStubDriver { SubscribeFailuresBeforeSuccess = 1 };
|
||||
var actor = Sys.ActorOf(DriverInstanceActor.Props(driver, healthPollInterval: FastPoll));
|
||||
|
||||
actor.Tell(new DriverInstanceActor.SetDesiredSubscriptions(
|
||||
new[] { "tag-a" }, TimeSpan.FromMilliseconds(100)));
|
||||
actor.Tell(new DriverInstanceActor.InitializeRequested("{}"));
|
||||
|
||||
// The connect-path subscribe runs and throws, so the handle stays null.
|
||||
AwaitCondition(() => driver.SubscribeCount >= 1, PresenceBudget);
|
||||
|
||||
// Only the reconcile can produce this second attempt — and it must succeed this time.
|
||||
AwaitCondition(() => driver.SubscribeCount >= 2, PresenceBudget);
|
||||
driver.LastSubscribedRefs.ShouldBe(new[] { "tag-a" });
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A healthy subscription is left alone — the reconcile must not re-subscribe on every tick, which
|
||||
/// would churn the backend every 30 s in production for no reason.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void A_healthy_subscription_is_not_re_subscribed_on_every_tick()
|
||||
{
|
||||
var publisher = new CountingHealthPublisher();
|
||||
var driver = new SubscribableStubDriver();
|
||||
var actor = Sys.ActorOf(DriverInstanceActor.Props(
|
||||
driver, healthPublisher: publisher, healthPollInterval: FastPoll));
|
||||
|
||||
actor.Tell(new DriverInstanceActor.SetDesiredSubscriptions(
|
||||
new[] { "tag-a" }, TimeSpan.FromMilliseconds(100)));
|
||||
actor.Tell(new DriverInstanceActor.InitializeRequested("{}"));
|
||||
AwaitCondition(() => driver.SubscribeCount >= 1, PresenceBudget);
|
||||
|
||||
// POSITIVE CONTROL for the absence below. Every health-poll tick publishes a snapshot, so the
|
||||
// publisher count is a direct observation of ticks PROCESSED. Waiting for several of them is what
|
||||
// makes "SubscribeCount is still 1" mean "the reconcile ran and declined" rather than "the timer
|
||||
// had not fired yet" — the latter would pass instantly and prove nothing.
|
||||
var before = publisher.Count;
|
||||
AwaitCondition(() => publisher.Count >= before + 4, PresenceBudget);
|
||||
|
||||
driver.SubscribeCount.ShouldBe(1,
|
||||
"the handle is live, so the reconcile must leave it alone");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A driver that is not <see cref="ISubscribable"/> can still be handed a desired set. Reconciling
|
||||
/// it would tell <c>Subscribe</c> every tick and fail every tick, forever — so the reconcile is
|
||||
/// gated on the capability.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void A_non_subscribable_driver_is_never_reconciled()
|
||||
{
|
||||
var publisher = new CountingHealthPublisher();
|
||||
var driver = new StubDriver(); // IDriver only — no ISubscribable
|
||||
var actor = Sys.ActorOf(DriverInstanceActor.Props(
|
||||
driver, healthPublisher: publisher, healthPollInterval: FastPoll));
|
||||
|
||||
actor.Tell(new DriverInstanceActor.SetDesiredSubscriptions(
|
||||
new[] { "tag-a" }, TimeSpan.FromMilliseconds(100)));
|
||||
actor.Tell(new DriverInstanceActor.InitializeRequested("{}"));
|
||||
|
||||
// Same positive control: assert the reconcile stays silent across several PROCESSED ticks.
|
||||
// EventFilter bounds the absence to this block, and the reconcile announces itself at Info —
|
||||
// the first test above is the proof that this message does appear when the reconcile fires.
|
||||
EventFilter.Info(contains: "periodic reconcile").Expect(0, () =>
|
||||
{
|
||||
var before = publisher.Count;
|
||||
AwaitCondition(() => publisher.Count >= before + 4, PresenceBudget);
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>Counts health-snapshot publishes, which is one per <c>health-poll</c> tick once the actor
|
||||
/// is running — the observable that turns "time passed" into "ticks were processed".</summary>
|
||||
private sealed class CountingHealthPublisher : IDriverHealthPublisher
|
||||
{
|
||||
private int _count;
|
||||
|
||||
/// <summary>Number of snapshots published so far.</summary>
|
||||
public int Count => Volatile.Read(ref _count);
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Publish(string clusterId, string driverInstanceId, DriverHealth health, int errorCount5Min)
|
||||
=> Interlocked.Increment(ref _count);
|
||||
}
|
||||
}
|
||||
@@ -88,7 +88,7 @@ public sealed class DriverInstanceActorTests : RuntimeActorTestBase
|
||||
[Fact]
|
||||
public async Task Write_propagates_status_code_on_Bad_result()
|
||||
{
|
||||
const uint badStatus = 0x80340000; // BadOutOfService — top severity bits = 10b
|
||||
const uint badStatus = 0x80340000; // BadNodeIdUnknown — top severity bits = 10b
|
||||
var driver = new WritableStubDriver { NextStatusCode = badStatus };
|
||||
var actor = Sys.ActorOf(DriverInstanceActor.Props(driver));
|
||||
|
||||
|
||||
@@ -114,6 +114,18 @@ internal sealed class SubscribableStubDriver : StubDriver, ISubscribable
|
||||
/// synchronously-completed stub task hides (the continuation otherwise runs inline).</summary>
|
||||
public bool UnsubscribeYields { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// How many of the first <see cref="SubscribeAsync"/> calls throw before one succeeds. Default 0
|
||||
/// (always succeed) leaves existing behaviour untouched.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This is how a test reaches the state the #488 reconcile exists for: the actor is Connected and
|
||||
/// holds a desired ref set, but the subscribe that should have established the handle failed, so
|
||||
/// <c>_subscriptionHandle</c> is null and nothing else will ever re-assert it — there is no
|
||||
/// connect transition coming, and no deploy.
|
||||
/// </remarks>
|
||||
public int SubscribeFailuresBeforeSuccess { get; set; }
|
||||
|
||||
/// <summary>Subscribes to the specified full references.</summary>
|
||||
/// <param name="fullReferences">The full references to subscribe to.</param>
|
||||
/// <param name="publishingInterval">The publishing interval.</param>
|
||||
@@ -121,8 +133,13 @@ internal sealed class SubscribableStubDriver : StubDriver, ISubscribable
|
||||
public Task<ISubscriptionHandle> SubscribeAsync(
|
||||
IReadOnlyList<string> fullReferences, TimeSpan publishingInterval, CancellationToken cancellationToken)
|
||||
{
|
||||
Interlocked.Increment(ref SubscribeCount);
|
||||
var attempt = Interlocked.Increment(ref SubscribeCount);
|
||||
LastSubscribedRefs = fullReferences;
|
||||
if (attempt <= SubscribeFailuresBeforeSuccess)
|
||||
{
|
||||
throw new InvalidOperationException($"stub subscribe failure #{attempt}");
|
||||
}
|
||||
|
||||
return Task.FromResult<ISubscriptionHandle>(_handle);
|
||||
}
|
||||
|
||||
|
||||
@@ -11,6 +11,25 @@ namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.Harness;
|
||||
/// </summary>
|
||||
public abstract class RuntimeActorTestBase : TestKit
|
||||
{
|
||||
/// <summary>
|
||||
/// Shared upper bound for a <b>presence</b> wait (<c>AwaitAssert</c> / <c>AwaitCondition</c>).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para><b>A budget, not a delay.</b> These helpers poll and return the instant the condition holds,
|
||||
/// so the value is how long to wait before giving up. Raising it costs nothing on the happy path and
|
||||
/// <em>cannot</em> make a genuinely failing assertion pass — it only changes how quickly a real
|
||||
/// breakage is reported. That is the opposite of an <b>absence</b> window (<c>ExpectNoMsg</c>,
|
||||
/// settle-then-assert), where the elapsed time IS the assertion and every millisecond is spent on
|
||||
/// every run. Absence windows keep their own short, individually-calibrated literals and must not be
|
||||
/// routed through here.</para>
|
||||
/// <para><b>Why it exists (Gitea #500).</b> This assembly runs 44 Akka test classes roughly 14-way
|
||||
/// parallel, each with its own <see cref="TestKit"/> ActorSystem. Budgets of 300–500 ms sized on an
|
||||
/// idle machine are comfortable in isolation and marginal under that contention: three consecutive
|
||||
/// 30-run verification rounds each surfaced a <em>different</em> test failing on one, in three
|
||||
/// different files. Fixing them one at a time was chasing a distribution rather than a defect.</para>
|
||||
/// </remarks>
|
||||
protected static readonly TimeSpan PresenceBudget = TimeSpan.FromSeconds(15);
|
||||
|
||||
/// <summary>Gets the Akka test HOCON configuration string for single-node cluster setup.</summary>
|
||||
protected static string AkkaTestHocon => @"
|
||||
akka {
|
||||
|
||||
@@ -57,7 +57,7 @@ public sealed class PeerProbeSupervisorTests : RuntimeActorTestBase
|
||||
State(Adm, RedundancyRole.Detached)));
|
||||
|
||||
AwaitAssert(() => sup.UnderlyingActor.ChildCount.ShouldBe(1),
|
||||
duration: TimeSpan.FromMilliseconds(500));
|
||||
duration: PresenceBudget);
|
||||
}
|
||||
|
||||
/// <summary>Verifies the child for a departed peer is stopped when the next snapshot omits it.</summary>
|
||||
@@ -71,14 +71,16 @@ public sealed class PeerProbeSupervisorTests : RuntimeActorTestBase
|
||||
State(Local, RedundancyRole.Primary),
|
||||
State(Peer, RedundancyRole.Secondary)));
|
||||
AwaitAssert(() => sup.UnderlyingActor.ChildCount.ShouldBe(1),
|
||||
duration: TimeSpan.FromMilliseconds(500));
|
||||
duration: PresenceBudget);
|
||||
|
||||
sup.Tell(Snapshot(State(Local, RedundancyRole.Primary)));
|
||||
AwaitAssert(() => sup.UnderlyingActor.ChildCount.ShouldBe(0),
|
||||
duration: TimeSpan.FromMilliseconds(500));
|
||||
duration: PresenceBudget);
|
||||
}
|
||||
|
||||
/// <summary>Verifies a single-node snapshot (just the local node) spawns no children.</summary>
|
||||
/// <summary>Verifies a single-node snapshot (just the local node) spawns no children — the local node
|
||||
/// is never its own probe peer. Established against a following two-node snapshot as a positive
|
||||
/// control, so the count of 1 (rather than 2) is what carries the "no local child" claim.</summary>
|
||||
[Fact]
|
||||
public void Single_node_snapshot_spawns_no_children()
|
||||
{
|
||||
@@ -87,8 +89,21 @@ public sealed class PeerProbeSupervisorTests : RuntimeActorTestBase
|
||||
|
||||
sup.Tell(Snapshot(State(Local, RedundancyRole.Primary)));
|
||||
|
||||
AwaitAssert(() => sup.UnderlyingActor.ChildCount.ShouldBe(0),
|
||||
duration: TimeSpan.FromMilliseconds(500));
|
||||
// POSITIVE CONTROL (#501). This is an ABSENCE assertion, and ChildCount is 0 before the snapshot
|
||||
// above has been processed at all — so asserting it directly passes at t=0 and proves nothing
|
||||
// (AwaitAssert returns on its first success, spending none of its duration). Unlike the other
|
||||
// ChildCount.ShouldBe(0) waits in this class, there is no preceding ShouldBe(1) to make it a
|
||||
// transition.
|
||||
//
|
||||
// Follow with a snapshot that DOES spawn exactly one child. The supervisor processes its mailbox
|
||||
// in order, so once the count reaches 1 the single-node snapshot has provably been handled — and
|
||||
// had it spawned a child for the local node, the count would be 2.
|
||||
sup.Tell(Snapshot(
|
||||
State(Local, RedundancyRole.Primary),
|
||||
State(Peer, RedundancyRole.Secondary)));
|
||||
|
||||
AwaitAssert(() => sup.UnderlyingActor.ChildCount.ShouldBe(1),
|
||||
duration: PresenceBudget);
|
||||
}
|
||||
|
||||
/// <summary>Verifies a previously-removed peer is respawned when it re-appears, without an
|
||||
@@ -103,17 +118,17 @@ public sealed class PeerProbeSupervisorTests : RuntimeActorTestBase
|
||||
State(Local, RedundancyRole.Primary),
|
||||
State(Peer, RedundancyRole.Secondary)));
|
||||
AwaitAssert(() => sup.UnderlyingActor.ChildCount.ShouldBe(1),
|
||||
duration: TimeSpan.FromMilliseconds(500));
|
||||
duration: PresenceBudget);
|
||||
|
||||
sup.Tell(Snapshot(State(Local, RedundancyRole.Primary)));
|
||||
AwaitAssert(() => sup.UnderlyingActor.ChildCount.ShouldBe(0),
|
||||
duration: TimeSpan.FromMilliseconds(500));
|
||||
duration: PresenceBudget);
|
||||
|
||||
sup.Tell(Snapshot(
|
||||
State(Local, RedundancyRole.Primary),
|
||||
State(Peer, RedundancyRole.Secondary)));
|
||||
AwaitAssert(() => sup.UnderlyingActor.ChildCount.ShouldBe(1),
|
||||
duration: TimeSpan.FromMilliseconds(500));
|
||||
duration: PresenceBudget);
|
||||
}
|
||||
|
||||
/// <summary>Locks in the stale-Terminated guard: when an OLD (already-replaced) child's
|
||||
@@ -132,27 +147,27 @@ public sealed class PeerProbeSupervisorTests : RuntimeActorTestBase
|
||||
State(Local, RedundancyRole.Primary),
|
||||
State(Peer, RedundancyRole.Secondary)));
|
||||
AwaitAssert(() => sup.UnderlyingActor.ChildCount.ShouldBe(1),
|
||||
duration: TimeSpan.FromMilliseconds(500));
|
||||
AwaitAssert(() => spawned.Count.ShouldBe(1), duration: TimeSpan.FromMilliseconds(500));
|
||||
duration: PresenceBudget);
|
||||
AwaitAssert(() => spawned.Count.ShouldBe(1), duration: PresenceBudget);
|
||||
var oldRef = spawned[0];
|
||||
|
||||
// Drop the peer -> child #0 stopped, ChildCount back to 0.
|
||||
sup.Tell(Snapshot(State(Local, RedundancyRole.Primary)));
|
||||
AwaitAssert(() => sup.UnderlyingActor.ChildCount.ShouldBe(0),
|
||||
duration: TimeSpan.FromMilliseconds(500));
|
||||
duration: PresenceBudget);
|
||||
|
||||
// Re-add the SAME peer -> a NEW child #1 (the FRESH ref) is spawned.
|
||||
sup.Tell(Snapshot(
|
||||
State(Local, RedundancyRole.Primary),
|
||||
State(Peer, RedundancyRole.Secondary)));
|
||||
AwaitAssert(() => sup.UnderlyingActor.ChildCount.ShouldBe(1),
|
||||
duration: TimeSpan.FromMilliseconds(500));
|
||||
AwaitAssert(() => spawned.Count.ShouldBe(2), duration: TimeSpan.FromMilliseconds(500));
|
||||
duration: PresenceBudget);
|
||||
AwaitAssert(() => spawned.Count.ShouldBe(2), duration: PresenceBudget);
|
||||
|
||||
// Now deliver a STALE Terminated for the OLD ref. The current child for Peer is the fresh
|
||||
// child #1, so ref-equality finds no match and the supervisor must leave ChildCount at 1.
|
||||
sup.Tell(new Terminated(oldRef, existenceConfirmed: true, addressTerminated: false));
|
||||
AwaitAssert(() => sup.UnderlyingActor.ChildCount.ShouldBe(1),
|
||||
duration: TimeSpan.FromMilliseconds(500));
|
||||
duration: PresenceBudget);
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user