Compare commits
25 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 123ddc3f40 | |||
| 0f38f48679 | |||
| 0e93587b1c | |||
| 4cc0215bb4 | |||
| 4b9bcddbcc | |||
| 2193d9f2e4 | |||
| 8b7ea3213d | |||
| 240d7aa8aa | |||
| 95295210b0 | |||
| e3155fbbf5 | |||
| ee12568cab | |||
| e27eb77187 | |||
| fcc7ed698e | |||
| 4a8c9badce | |||
| 650e27075f | |||
| 132e7b63f6 | |||
| 13bd9820b0 | |||
| 1d90bf3611 | |||
| 20be5416b9 | |||
| 47e9cd56ef | |||
| 2f38b5b285 | |||
| 20100c36ad | |||
| eed6617784 | |||
| 8d0f60ec51 | |||
| e787aa572b |
@@ -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)
|
(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');
|
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)
|
-- Galaxy MxAccess gateway — INTENTIONALLY NOT SEEDED (retired 2026-06-15)
|
||||||
--
|
--
|
||||||
|
|||||||
@@ -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
|
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.
|
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
|
## Reconnect and reliability
|
||||||
|
|
||||||
Central's `TelemetryDialSupervisor` (an admin-role actor) runs **one reconnecting dialer per
|
Central's `TelemetryDialSupervisor` (an admin-role actor) runs **one reconnecting dialer per
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ it reaches 📝.
|
|||||||
| Wave | Deliverable | Design | Impl. plan | Status | Effort | Fixture / hardware |
|
| 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) |
|
| **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 |
|
| **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** | 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 |
|
| **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
|
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.
|
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)
|
- **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
|
- **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.
|
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
|
**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.
|
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
|
- **Fixture:** added the `rtu_over_tcp` profile (`framer=rtu`, host `:5021`, co-runs with `standard`)
|
||||||
step is confirming the pymodbus simulator exposes the RTU framer on a TCP server.
|
to the Modbus integration fixture. **Unknown confirmed:** pymodbus 3.13's simulator DOES honour
|
||||||
- **Effort:** **S — the lowest on the roadmap.** Recommended first.
|
`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
|
### SQL poll — 📝 Plan ready
|
||||||
- **Design:** [`2026-07-15-sql-poll-driver-design.md`](2026-07-15-sql-poll-driver-design.md)
|
- **Design:** [`2026-07-15-sql-poll-driver-design.md`](2026-07-15-sql-poll-driver-design.md)
|
||||||
|
|||||||
@@ -60,6 +60,11 @@ public static class DraftValidator
|
|||||||
/// credential in the ConfigDb — persisted, replicated to every node's artifact cache, and readable by
|
/// 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.
|
/// 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>
|
/// 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
|
/// <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
|
/// <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
|
/// beneath it, because the two are merged before the DTO sees them — a credential pasted into the
|
||||||
@@ -74,7 +79,7 @@ public static class DraftValidator
|
|||||||
/// </remarks>
|
/// </remarks>
|
||||||
private static void ValidateSqlConnectionStringNotPersisted(DraftSnapshot draft, List<ValidationError> errors)
|
private static void ValidateSqlConnectionStringNotPersisted(DraftSnapshot draft, List<ValidationError> errors)
|
||||||
{
|
{
|
||||||
const string ForbiddenKey = "connectionString";
|
const string ForbiddenKey = SqlCredentialGuard.ForbiddenKey;
|
||||||
|
|
||||||
var sqlInstanceIds = draft.DriverInstances
|
var sqlInstanceIds = draft.DriverInstances
|
||||||
.Where(d => string.Equals(d.DriverType, Core.Abstractions.DriverTypeNames.Sql, StringComparison.Ordinal))
|
.Where(d => string.Equals(d.DriverType, Core.Abstractions.DriverTypeNames.Sql, StringComparison.Ordinal))
|
||||||
@@ -85,7 +90,7 @@ public static class DraftValidator
|
|||||||
foreach (var d in draft.DriverInstances)
|
foreach (var d in draft.DriverInstances)
|
||||||
{
|
{
|
||||||
if (!sqlInstanceIds.Contains(d.DriverInstanceId)) continue;
|
if (!sqlInstanceIds.Contains(d.DriverInstanceId)) continue;
|
||||||
if (!HasTopLevelKey(d.DriverConfig, ForbiddenKey)) continue;
|
if (!SqlCredentialGuard.CarriesLiteralConnectionString(d.DriverConfig)) continue;
|
||||||
errors.Add(new("SqlConnectionStringPersisted",
|
errors.Add(new("SqlConnectionStringPersisted",
|
||||||
$"Sql driver instance '{d.DriverInstanceId}' has a '{ForbiddenKey}' key in its DriverConfig. " +
|
$"Sql driver instance '{d.DriverInstanceId}' has a '{ForbiddenKey}' key in its DriverConfig. " +
|
||||||
"A Sql driver must name its credentials indirectly via 'connectionStringRef', which resolves " +
|
"A Sql driver must name its credentials indirectly via 'connectionStringRef', which resolves " +
|
||||||
@@ -98,7 +103,7 @@ public static class DraftValidator
|
|||||||
foreach (var dev in draft.Devices)
|
foreach (var dev in draft.Devices)
|
||||||
{
|
{
|
||||||
if (!sqlInstanceIds.Contains(dev.DriverInstanceId)) continue;
|
if (!sqlInstanceIds.Contains(dev.DriverInstanceId)) continue;
|
||||||
if (!HasTopLevelKey(dev.DeviceConfig, ForbiddenKey)) continue;
|
if (!SqlCredentialGuard.CarriesLiteralConnectionString(dev.DeviceConfig)) continue;
|
||||||
errors.Add(new("SqlConnectionStringPersisted",
|
errors.Add(new("SqlConnectionStringPersisted",
|
||||||
$"Device '{dev.DeviceId}' on Sql driver instance '{dev.DriverInstanceId}' has a " +
|
$"Device '{dev.DeviceId}' on Sql driver instance '{dev.DriverInstanceId}' has a " +
|
||||||
$"'{ForbiddenKey}' key in its DeviceConfig. DeviceConfig is merged onto DriverConfig before " +
|
$"'{ForbiddenKey}' key in its DeviceConfig. DeviceConfig is merged onto DriverConfig before " +
|
||||||
@@ -107,34 +112,6 @@ public static class DraftValidator
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// True when <paramref name="json"/> is a JSON object carrying <paramref name="key"/> at its top level,
|
|
||||||
/// matched case-insensitively. Never throws — a blank, malformed or non-object blob simply has no keys,
|
|
||||||
/// and shaping the config JSON is another rule's job.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="json">The config blob to inspect.</param>
|
|
||||||
/// <param name="key">The property name to look for.</param>
|
|
||||||
/// <returns><see langword="true"/> when the key is present at the top level.</returns>
|
|
||||||
private static bool HasTopLevelKey(string? json, string key)
|
|
||||||
{
|
|
||||||
if (string.IsNullOrWhiteSpace(json)) return false;
|
|
||||||
try
|
|
||||||
{
|
|
||||||
using var doc = System.Text.Json.JsonDocument.Parse(json);
|
|
||||||
if (doc.RootElement.ValueKind != System.Text.Json.JsonValueKind.Object) return false;
|
|
||||||
foreach (var property in doc.RootElement.EnumerateObject())
|
|
||||||
{
|
|
||||||
if (string.Equals(property.Name, key, StringComparison.OrdinalIgnoreCase)) return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
catch (System.Text.Json.JsonException)
|
|
||||||
{
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>WP7 Calculation-driver deploy gates. For every tag bound to a <c>Calculation</c> driver:
|
/// <summary>WP7 Calculation-driver deploy gates. For every tag bound to a <c>Calculation</c> driver:
|
||||||
/// <list type="number">
|
/// <list type="number">
|
||||||
/// <item><b>scriptId existence</b> — the tag's <c>TagConfig.scriptId</c> must be present and resolve to
|
/// <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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -130,6 +130,14 @@ public sealed class ModbusDriverOptions
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public MelsecFamily MelsecSubFamily { get; init; } = MelsecFamily.Q_L_iQR;
|
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>
|
/// <summary>
|
||||||
/// When <c>true</c>, the driver suppresses redundant writes: if the most recent
|
/// 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
|
/// 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>
|
/// <summary>Initializes a new Modbus TCP driver with the specified options and transport factory.</summary>
|
||||||
/// <param name="options">Driver configuration options.</param>
|
/// <param name="options">Driver configuration options.</param>
|
||||||
/// <param name="driverInstanceId">Unique identifier for this driver instance.</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>
|
/// <param name="logger">Logger instance; defaults to null logger if not provided.</param>
|
||||||
public ModbusDriver(ModbusDriverOptions options, string driverInstanceId,
|
public ModbusDriver(ModbusDriverOptions options, string driverInstanceId,
|
||||||
Func<ModbusDriverOptions, IModbusTransport>? transportFactory = null,
|
Func<ModbusDriverOptions, IModbusTransport>? transportFactory = null,
|
||||||
@@ -107,11 +108,7 @@ public sealed class ModbusDriver
|
|||||||
_resolver = new EquipmentTagRefResolver<ModbusTagDefinition>(
|
_resolver = new EquipmentTagRefResolver<ModbusTagDefinition>(
|
||||||
r => _tagsByRawPath.TryGetValue(r, out var t) ? t : null);
|
r => _tagsByRawPath.TryGetValue(r, out var t) ? t : null);
|
||||||
_transportFactory = transportFactory
|
_transportFactory = transportFactory
|
||||||
?? (o => new ModbusTcpTransport(
|
?? (o => ModbusTransportFactory.Create(o));
|
||||||
o.Host, o.Port, o.Timeout, o.AutoReconnect,
|
|
||||||
keepAlive: o.KeepAlive,
|
|
||||||
idleDisconnect: o.IdleDisconnectTimeout,
|
|
||||||
reconnect: o.Reconnect));
|
|
||||||
_poll = new PollGroupEngine(
|
_poll = new PollGroupEngine(
|
||||||
reader: ReadAsync,
|
reader: ReadAsync,
|
||||||
onChange: (handle, tagRef, snapshot) =>
|
onChange: (handle, tagRef, snapshot) =>
|
||||||
|
|||||||
@@ -74,6 +74,8 @@ public static class ModbusDriverFactoryExtensions
|
|||||||
: ParseEnum<ModbusFamily>(dto.Family, "<driver-level>", driverInstanceId, "Family"),
|
: ParseEnum<ModbusFamily>(dto.Family, "<driver-level>", driverInstanceId, "Family"),
|
||||||
MelsecSubFamily = dto.MelsecSubFamily is null ? MelsecFamily.Q_L_iQR
|
MelsecSubFamily = dto.MelsecSubFamily is null ? MelsecFamily.Q_L_iQR
|
||||||
: ParseEnum<MelsecFamily>(dto.MelsecSubFamily, "<driver-level>", driverInstanceId, "MelsecSubFamily"),
|
: 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,
|
AutoProhibitReprobeInterval = dto.AutoProhibitReprobeMs is { } reprobeMs ? TimeSpan.FromMilliseconds(reprobeMs) : null,
|
||||||
AutoReconnect = dto.AutoReconnect ?? true,
|
AutoReconnect = dto.AutoReconnect ?? true,
|
||||||
// v3: the deploy artifact (Wave C) populates rawTags with the cluster's authored raw Modbus
|
// 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; }
|
public string? Family { get; init; }
|
||||||
/// <summary>Gets or sets the Melsec subfamily.</summary>
|
/// <summary>Gets or sets the Melsec subfamily.</summary>
|
||||||
public string? MelsecSubFamily { get; init; }
|
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>
|
/// <summary>Gets or sets the automatic prohibition reprobei interval in milliseconds.</summary>
|
||||||
public int? AutoProhibitReprobeMs { get; init; }
|
public int? AutoProhibitReprobeMs { get; init; }
|
||||||
/// <summary>Gets or sets a value indicating whether automatic reconnection is enabled.</summary>
|
/// <summary>Gets or sets a value indicating whether automatic reconnection is enabled.</summary>
|
||||||
|
|||||||
@@ -29,15 +29,19 @@ public sealed class ModbusDriverProbe : IDriverProbe
|
|||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public string DriverType => "Modbus";
|
public string DriverType => "Modbus";
|
||||||
|
|
||||||
/// <summary>The parsed probe target — host/port/unitId + the effective connection timeout, all read
|
/// <summary>The parsed probe target — host/port/unitId + the effective connection timeout + the wire
|
||||||
/// from the SAME factory DTO shape (<c>ModbusDriverConfigDto</c>) the driver factory parses, so
|
/// transport mode, all read from the SAME factory DTO shape (<c>ModbusDriverConfigDto</c>) the driver
|
||||||
/// <c>timeoutMs</c> binds identically to the factory (R2-11, 05/CONV-2; the OpcUaClient parity rule).</summary>
|
/// factory parses, so <c>timeoutMs</c> binds identically to the factory (R2-11, 05/CONV-2; the
|
||||||
internal readonly record struct ProbeTarget(string Host, int Port, byte UnitId, TimeSpan Timeout);
|
/// 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.
|
/// <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
|
/// 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
|
/// 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="configJson">The driver config JSON (factory DTO shape).</param>
|
||||||
/// <param name="fallbackTimeout">The timeout used when the config omits <c>timeoutMs</c>.</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>
|
/// <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)
|
if (string.IsNullOrWhiteSpace(dto.Host) || port <= 0)
|
||||||
return (null, "Config has no host/port to probe.");
|
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;
|
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 />
|
/// <inheritdoc />
|
||||||
@@ -72,9 +83,17 @@ public sealed class ModbusDriverProbe : IDriverProbe
|
|||||||
using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||||||
cts.CancelAfter(timeout);
|
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.
|
// 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))
|
await using (transport.ConfigureAwait(false))
|
||||||
{
|
{
|
||||||
try
|
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;
|
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Concrete Modbus TCP transport. Wraps a single <see cref="TcpClient"/> and serializes
|
/// Concrete Modbus TCP transport. Wraps a single socket (owned by <see cref="ModbusSocketLifecycle"/>)
|
||||||
/// requests so at most one transaction is in-flight at a time — Modbus servers typically
|
/// and serializes requests so at most one transaction is in-flight at a time — Modbus servers
|
||||||
/// support concurrent transactions, but the single-flight model keeps the wire trace
|
/// typically support concurrent transactions, but the single-flight model keeps the wire trace
|
||||||
/// easy to diagnose and avoids interleaved-response correlation bugs.
|
/// easy to diagnose and avoids interleaved-response correlation bugs.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// <para>
|
/// <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
|
/// Survives mid-transaction socket drops: when a send/read fails with a socket-level
|
||||||
/// error (<see cref="IOException"/>, <see cref="SocketException"/>, <see cref="EndOfStreamException"/>)
|
/// error (<see cref="IOException"/>, <see cref="SocketException"/>, <see cref="EndOfStreamException"/>)
|
||||||
/// the transport disposes the dead socket, reconnects, and retries the PDU exactly
|
/// the transport disposes the dead socket, reconnects, and retries the PDU exactly
|
||||||
@@ -26,19 +32,11 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus;
|
|||||||
/// </remarks>
|
/// </remarks>
|
||||||
public sealed class ModbusTcpTransport : IModbusTransport
|
public sealed class ModbusTcpTransport : IModbusTransport
|
||||||
{
|
{
|
||||||
private readonly string _host;
|
private readonly ModbusSocketLifecycle _life;
|
||||||
private readonly int _port;
|
|
||||||
private readonly TimeSpan _timeout;
|
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 readonly SemaphoreSlim _gate = new(1, 1);
|
||||||
private TcpClient? _client;
|
|
||||||
private NetworkStream? _stream;
|
|
||||||
private ushort _nextTx;
|
private ushort _nextTx;
|
||||||
private bool _disposed;
|
private bool _disposed;
|
||||||
private DateTime _lastSuccessUtc = DateTime.UtcNow;
|
|
||||||
|
|
||||||
/// <summary>Initializes a new instance of the <see cref="ModbusTcpTransport"/> class.</summary>
|
/// <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>
|
/// <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,
|
TimeSpan? idleDisconnect = null,
|
||||||
ModbusReconnectOptions? reconnect = null)
|
ModbusReconnectOptions? reconnect = null)
|
||||||
{
|
{
|
||||||
_host = host;
|
|
||||||
_port = port;
|
|
||||||
_timeout = timeout;
|
_timeout = timeout;
|
||||||
_autoReconnect = autoReconnect;
|
_life = new ModbusSocketLifecycle(host, port, timeout, autoReconnect, keepAlive, idleDisconnect, reconnect);
|
||||||
_keepAlive = keepAlive ?? new ModbusKeepAliveOptions();
|
|
||||||
_idleDisconnect = idleDisconnect;
|
|
||||||
_reconnect = reconnect ?? new ModbusReconnectOptions();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public async Task ConnectAsync(CancellationToken ct)
|
public Task ConnectAsync(CancellationToken ct) => _life.ConnectAsync(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;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public async Task<byte[]> SendAsync(byte unitId, byte[] pdu, CancellationToken ct)
|
public async Task<byte[]> SendAsync(byte unitId, byte[] pdu, CancellationToken ct)
|
||||||
{
|
{
|
||||||
if (_disposed) throw new ObjectDisposedException(nameof(ModbusTcpTransport));
|
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);
|
await _gate.WaitAsync(ct).ConfigureAwait(false);
|
||||||
try
|
try
|
||||||
@@ -140,27 +72,27 @@ public sealed class ModbusTcpTransport : IModbusTransport
|
|||||||
// threshold, tear it down + reconnect before this PDU lands. Defends against silent
|
// 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
|
// NAT / firewall reaping where the socket looks alive locally but the upstream side
|
||||||
// dropped it minutes ago.
|
// dropped it minutes ago.
|
||||||
if (_idleDisconnect.HasValue && DateTime.UtcNow - _lastSuccessUtc > _idleDisconnect.Value)
|
if (_life.ShouldReconnectForIdle())
|
||||||
{
|
{
|
||||||
await TearDownAsync().ConfigureAwait(false);
|
await _life.TearDownAsync().ConfigureAwait(false);
|
||||||
await ConnectWithBackoffAsync(ct).ConfigureAwait(false);
|
await _life.ConnectWithBackoffAsync(ct).ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var result = await SendOnceAsync(unitId, pdu, ct).ConfigureAwait(false);
|
var result = await SendOnceAsync(unitId, pdu, ct).ConfigureAwait(false);
|
||||||
_lastSuccessUtc = DateTime.UtcNow;
|
_life.MarkSuccess();
|
||||||
return result;
|
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
|
// Mid-transaction drop: tear down the dead socket, reconnect (with backoff if
|
||||||
// configured), resend. Single retry — if it fails again, let it propagate so
|
// configured), resend. Single retry — if it fails again, let it propagate so
|
||||||
// health/status reflect reality.
|
// health/status reflect reality.
|
||||||
await TearDownAsync().ConfigureAwait(false);
|
await _life.TearDownAsync().ConfigureAwait(false);
|
||||||
await ConnectWithBackoffAsync(ct).ConfigureAwait(false);
|
await _life.ConnectWithBackoffAsync(ct).ConfigureAwait(false);
|
||||||
var result = await SendOnceAsync(unitId, pdu, ct).ConfigureAwait(false);
|
var result = await SendOnceAsync(unitId, pdu, ct).ConfigureAwait(false);
|
||||||
_lastSuccessUtc = DateTime.UtcNow;
|
_life.MarkSuccess();
|
||||||
return result;
|
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>
|
/// <summary>
|
||||||
/// Executes exactly one Modbus transaction on the current socket.
|
/// Executes exactly one Modbus transaction on the current socket.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -230,7 +125,8 @@ public sealed class ModbusTcpTransport : IModbusTransport
|
|||||||
/// </remarks>
|
/// </remarks>
|
||||||
private async Task<byte[]> SendOnceAsync(byte unitId, byte[] pdu, CancellationToken ct)
|
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;
|
var txId = ++_nextTx;
|
||||||
|
|
||||||
// MBAP: [TxId(2)][Proto=0(2)][Length(2)][UnitId(1)] + PDU
|
// MBAP: [TxId(2)][Proto=0(2)][Length(2)][UnitId(1)] + PDU
|
||||||
@@ -248,11 +144,11 @@ public sealed class ModbusTcpTransport : IModbusTransport
|
|||||||
cts.CancelAfter(_timeout);
|
cts.CancelAfter(_timeout);
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
await _stream.WriteAsync(adu.AsMemory(), cts.Token).ConfigureAwait(false);
|
await stream.WriteAsync(adu.AsMemory(), cts.Token).ConfigureAwait(false);
|
||||||
await _stream.FlushAsync(cts.Token).ConfigureAwait(false);
|
await stream.FlushAsync(cts.Token).ConfigureAwait(false);
|
||||||
|
|
||||||
var header = new byte[7];
|
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]);
|
var respTxId = (ushort)((header[0] << 8) | header[1]);
|
||||||
if (respTxId != txId)
|
if (respTxId != txId)
|
||||||
throw new ModbusTransportDesyncException(
|
throw new ModbusTransportDesyncException(
|
||||||
@@ -264,7 +160,7 @@ public sealed class ModbusTcpTransport : IModbusTransport
|
|||||||
ModbusTransportDesyncException.DesyncReason.TruncatedFrame,
|
ModbusTransportDesyncException.DesyncReason.TruncatedFrame,
|
||||||
$"Modbus response length too small: {respLen}");
|
$"Modbus response length too small: {respLen}");
|
||||||
var respPdu = new byte[respLen - 1];
|
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
|
// 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.
|
// error — the socket is still coherent, so it MUST propagate without teardown.
|
||||||
@@ -280,7 +176,7 @@ public sealed class ModbusTcpTransport : IModbusTransport
|
|||||||
catch (ModbusTransportDesyncException)
|
catch (ModbusTransportDesyncException)
|
||||||
{
|
{
|
||||||
// Framing violation: the socket is desynchronized — never reuse it.
|
// Framing violation: the socket is desynchronized — never reuse it.
|
||||||
await TearDownAsync().ConfigureAwait(false);
|
await _life.TearDownAsync().ConfigureAwait(false);
|
||||||
throw;
|
throw;
|
||||||
}
|
}
|
||||||
catch (OperationCanceledException) when (!ct.IsCancellationRequested)
|
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
|
// 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
|
// and corrupt the next read — tear the socket down and normalize as a desync so the
|
||||||
// reconnect retry / status mapping engages.
|
// reconnect retry / status mapping engages.
|
||||||
await TearDownAsync().ConfigureAwait(false);
|
await _life.TearDownAsync().ConfigureAwait(false);
|
||||||
throw new ModbusTransportDesyncException(
|
throw new ModbusTransportDesyncException(
|
||||||
ModbusTransportDesyncException.DesyncReason.Timeout,
|
ModbusTransportDesyncException.DesyncReason.Timeout,
|
||||||
$"Modbus per-op response timeout after {_timeout.TotalMilliseconds:0}ms");
|
$"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)
|
private static async Task ReadExactlyAsync(Stream s, byte[] buf, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var read = 0;
|
var read = 0;
|
||||||
@@ -332,12 +208,7 @@ public sealed class ModbusTcpTransport : IModbusTransport
|
|||||||
{
|
{
|
||||||
if (_disposed) return;
|
if (_disposed) return;
|
||||||
_disposed = true;
|
_disposed = true;
|
||||||
try
|
await _life.DisposeAsync().ConfigureAwait(false);
|
||||||
{
|
|
||||||
if (_stream is not null) await _stream.DisposeAsync().ConfigureAwait(false);
|
|
||||||
}
|
|
||||||
catch { /* best-effort */ }
|
|
||||||
_client?.Dispose();
|
|
||||||
_gate.Dispose();
|
_gate.Dispose();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus;
|
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus;
|
||||||
|
|
||||||
/// <summary>
|
/// <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
|
/// 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
|
/// 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
|
/// 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.
|
/// 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>
|
/// <summary>The response MBAP transaction id did not match the request's.</summary>
|
||||||
TxIdMismatch,
|
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,
|
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>
|
/// <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."),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,6 +9,8 @@
|
|||||||
@using System.ComponentModel.DataAnnotations
|
@using System.ComponentModel.DataAnnotations
|
||||||
@using ZB.MOM.WW.OtOpcUa.Configuration
|
@using ZB.MOM.WW.OtOpcUa.Configuration
|
||||||
@using ZB.MOM.WW.OtOpcUa.Configuration.Entities
|
@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 IDbContextFactory<OtOpcUaConfigDbContext> DbFactory
|
||||||
@inject NavigationManager Nav
|
@inject NavigationManager Nav
|
||||||
@inject AuthenticationStateProvider AuthState
|
@inject AuthenticationStateProvider AuthState
|
||||||
@@ -34,6 +36,10 @@ else
|
|||||||
{
|
{
|
||||||
<EditForm Model="_form" OnValidSubmit="SubmitAsync" FormName="nodeEdit">
|
<EditForm Model="_form" OnValidSubmit="SubmitAsync" FormName="nodeEdit">
|
||||||
<DataAnnotationsValidator />
|
<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">
|
<section class="panel rise" style="animation-delay:.02s">
|
||||||
<div class="panel-head">Identity</div>
|
<div class="panel-head">Identity</div>
|
||||||
<div style="padding:1rem">
|
<div style="padding:1rem">
|
||||||
@@ -178,6 +184,33 @@ else
|
|||||||
}
|
}
|
||||||
|
|
||||||
await using var db = await DbFactory.CreateDbContextAsync();
|
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 (IsNew)
|
||||||
{
|
{
|
||||||
if (await db.ClusterNodes.AnyAsync(n => n.NodeId == _form.NodeId))
|
if (await db.ClusterNodes.AnyAsync(n => n.NodeId == _form.NodeId))
|
||||||
@@ -194,7 +227,7 @@ else
|
|||||||
OpcUaPort = _form.OpcUaPort,
|
OpcUaPort = _form.OpcUaPort,
|
||||||
DashboardPort = _form.DashboardPort,
|
DashboardPort = _form.DashboardPort,
|
||||||
ApplicationUri = _form.ApplicationUri,
|
ApplicationUri = _form.ApplicationUri,
|
||||||
ServiceLevelBase = _form.ServiceLevelBase,
|
ServiceLevelBase = (byte)_form.ServiceLevelBase,
|
||||||
Enabled = _form.Enabled,
|
Enabled = _form.Enabled,
|
||||||
DriverConfigOverridesJson = string.IsNullOrWhiteSpace(_form.DriverConfigOverridesJson) ? null : _form.DriverConfigOverridesJson,
|
DriverConfigOverridesJson = string.IsNullOrWhiteSpace(_form.DriverConfigOverridesJson) ? null : _form.DriverConfigOverridesJson,
|
||||||
CreatedAt = DateTime.UtcNow,
|
CreatedAt = DateTime.UtcNow,
|
||||||
@@ -210,7 +243,7 @@ else
|
|||||||
entity.OpcUaPort = _form.OpcUaPort;
|
entity.OpcUaPort = _form.OpcUaPort;
|
||||||
entity.DashboardPort = _form.DashboardPort;
|
entity.DashboardPort = _form.DashboardPort;
|
||||||
entity.ApplicationUri = _form.ApplicationUri;
|
entity.ApplicationUri = _form.ApplicationUri;
|
||||||
entity.ServiceLevelBase = _form.ServiceLevelBase;
|
entity.ServiceLevelBase = (byte)_form.ServiceLevelBase;
|
||||||
entity.Enabled = _form.Enabled;
|
entity.Enabled = _form.Enabled;
|
||||||
entity.DriverConfigOverridesJson = string.IsNullOrWhiteSpace(_form.DriverConfigOverridesJson) ? null : _form.DriverConfigOverridesJson;
|
entity.DriverConfigOverridesJson = string.IsNullOrWhiteSpace(_form.DriverConfigOverridesJson) ? null : _form.DriverConfigOverridesJson;
|
||||||
}
|
}
|
||||||
@@ -254,14 +287,26 @@ else
|
|||||||
|
|
||||||
private sealed class FormModel
|
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; } = "";
|
public string NodeId { get; set; } = "";
|
||||||
[Required] public string Host { get; set; } = "";
|
[Required] public string Host { get; set; } = "";
|
||||||
[Range(1, 65535)] public int OpcUaPort { get; set; } = 4840;
|
[Range(1, 65535)] public int OpcUaPort { get; set; } = 4840;
|
||||||
[Range(1, 65535)] public int DashboardPort { get; set; } = 8081;
|
[Range(1, 65535)] public int DashboardPort { get; set; } = 8081;
|
||||||
[Required, RegularExpression("^urn:[A-Za-z0-9_:./-]+$")]
|
[Required, RegularExpression("^urn:[A-Za-z0-9_:./-]+$")]
|
||||||
public string ApplicationUri { get; set; } = "";
|
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 bool Enabled { get; set; } = true;
|
||||||
public string? DriverConfigOverridesJson { get; set; }
|
public string? DriverConfigOverridesJson { get; set; }
|
||||||
}
|
}
|
||||||
|
|||||||
+15
@@ -27,6 +27,16 @@
|
|||||||
}
|
}
|
||||||
</InputSelect>
|
</InputSelect>
|
||||||
</div>
|
</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">
|
<div class="col-md-3">
|
||||||
<label class="form-label">MELSEC sub-family</label>
|
<label class="form-label">MELSEC sub-family</label>
|
||||||
<InputSelect @bind-Value="_form.MelsecSubFamily" @bind-Value:after="EmitAsync" class="form-select form-select-sm">
|
<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 ModbusFamily Family { get; set; } = ModbusFamily.Generic;
|
||||||
public MelsecFamily MelsecSubFamily { get; set; } = MelsecFamily.Q_L_iQR;
|
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
|
// Transport flags
|
||||||
public bool AutoReconnect { get; set; } = true;
|
public bool AutoReconnect { get; set; } = true;
|
||||||
public int IdleDisconnectTimeoutSeconds { get; set; } = 0;
|
public int IdleDisconnectTimeoutSeconds { get; set; } = 0;
|
||||||
@@ -337,6 +350,7 @@
|
|||||||
TimeoutSeconds = (int)o.Timeout.TotalSeconds,
|
TimeoutSeconds = (int)o.Timeout.TotalSeconds,
|
||||||
Family = o.Family,
|
Family = o.Family,
|
||||||
MelsecSubFamily = o.MelsecSubFamily,
|
MelsecSubFamily = o.MelsecSubFamily,
|
||||||
|
Transport = o.Transport,
|
||||||
AutoReconnect = o.AutoReconnect,
|
AutoReconnect = o.AutoReconnect,
|
||||||
IdleDisconnectTimeoutSeconds = o.IdleDisconnectTimeout.HasValue ? (int)o.IdleDisconnectTimeout.Value.TotalSeconds : 0,
|
IdleDisconnectTimeoutSeconds = o.IdleDisconnectTimeout.HasValue ? (int)o.IdleDisconnectTimeout.Value.TotalSeconds : 0,
|
||||||
MaxRegistersPerRead = o.MaxRegistersPerRead,
|
MaxRegistersPerRead = o.MaxRegistersPerRead,
|
||||||
@@ -387,6 +401,7 @@
|
|||||||
MaxReadGap = (ushort)Math.Clamp(MaxReadGap, 0, 65535),
|
MaxReadGap = (ushort)Math.Clamp(MaxReadGap, 0, 65535),
|
||||||
Family = Family,
|
Family = Family,
|
||||||
MelsecSubFamily = MelsecSubFamily,
|
MelsecSubFamily = MelsecSubFamily,
|
||||||
|
Transport = Transport,
|
||||||
WriteOnChangeOnly = WriteOnChangeOnly,
|
WriteOnChangeOnly = WriteOnChangeOnly,
|
||||||
AutoReconnect = AutoReconnect,
|
AutoReconnect = AutoReconnect,
|
||||||
KeepAlive = new ModbusKeepAliveOptions
|
KeepAlive = new ModbusKeepAliveOptions
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Commons.Types;
|
||||||
using ZB.MOM.WW.OtOpcUa.Configuration;
|
using ZB.MOM.WW.OtOpcUa.Configuration;
|
||||||
|
|
||||||
namespace ZB.MOM.WW.OtOpcUa.AdminUI.ScriptAnalysis;
|
namespace ZB.MOM.WW.OtOpcUa.AdminUI.ScriptAnalysis;
|
||||||
@@ -40,41 +41,42 @@ public sealed record ScriptTagInfo(string Path, string Kind, string DataType, st
|
|||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Default <see cref="IScriptTagCatalog"/>. Returns ONLY the resolvable keys a script may pass to
|
/// 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,
|
/// <c>ctx.GetTag</c> / <c>ctx.SetVirtualTag</c> — in v3 that is a raw tag's <b>RawPath</b>, plus the
|
||||||
/// the MXAccess dot-ref for FolderPath-scoped tags (<c>EquipmentId == null</c>), and the leaf
|
/// leaf <c>Name</c> (best-effort) for virtual tags.
|
||||||
/// <c>Name</c> (best-effort) for virtual tags.
|
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// <para>
|
/// <para>
|
||||||
/// <b>Fidelity over breadth.</b> Verified: the live runtime resolves a <c>ctx.GetTag("X")</c>
|
/// <b>Fidelity over breadth.</b> This projects exactly what the live runtime resolves, and nothing
|
||||||
/// literal against the driver <c>FullName</c> — the resolution chain is
|
/// else. Verified chain: <c>AddressSpaceComposer</c> harvests the <c>ctx.GetTag("…")</c> literals
|
||||||
/// <c>AddressSpaceComposer</c> (via <c>EquipmentScriptPaths.ExtractDependencyRefs</c>) harvesting the <c>ctx.GetTag("…")</c> literals
|
/// (via <c>EquipmentScriptPaths.ExtractDependencyRefs</c>) into <c>DependencyRefs</c>; those become
|
||||||
/// into <c>EquipmentVirtualTagPlan.DependencyRefs</c>
|
/// <c>VirtualTagActor._dependencyRefs</c>, registered with <c>DependencyMuxActor</c>, whose
|
||||||
/// (<c>src/Server/…OpcUaServer/AddressSpaceComposer.cs</c>); those become
|
/// <c>_byRef</c> map is keyed (Ordinal) by <c>DriverInstanceActor.AttributeValuePublished.FullReference</c>
|
||||||
/// <c>VirtualTagActor._dependencyRefs</c>, registered with the
|
/// — and in v3 a driver's wire reference <b>is the RawPath</b>
|
||||||
/// <c>DependencyMuxActor</c>, whose <c>_byRef</c> map is keyed by
|
/// (<c>DriverHostActor._nodeIdByDriverRef</c> is keyed <c>(DriverInstanceId, RawPath)</c>).
|
||||||
/// <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.
|
|
||||||
/// </para>
|
/// </para>
|
||||||
/// <para>
|
/// <para>
|
||||||
/// The per-category resolvable key:
|
/// <b>This replaced a stale v2 projection (Gitea #490).</b> The catalog used to emit
|
||||||
/// <list type="bullet">
|
/// <c>Tag.TagConfig.FullName</c> — the pre-v3 wire address — which since v3 is <i>not</i> the mux
|
||||||
/// <item>Equipment driver tag (<c>EquipmentId != null</c>) → the driver <c>FullName</c>
|
/// key. The consequence was worse than the missing feature the issue reported: completion offered
|
||||||
/// extracted from <c>Tag.TagConfig</c> (the verified <c>DependencyMux</c> key).
|
/// paths that could never resolve at runtime, while a <b>correct</b> absolute RawPath got no
|
||||||
/// GalaxyMxGateway is a standard Equipment-kind driver, so Galaxy points resolve
|
/// completion and hovered as "not a known configured tag path". Both directions are now right.
|
||||||
/// 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>
|
|
||||||
/// </para>
|
/// </para>
|
||||||
/// <para>
|
/// <para>
|
||||||
/// Follow-up: surface the UNS browse path as a completion <i>detail</i> (a non-inserted hint
|
/// RawPaths are built through the shared <see cref="RawPathResolver"/> — the same byte-parity
|
||||||
/// shown alongside the resolvable key) for discoverability, rather than as an inserted value.
|
/// 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>
|
||||||
/// <para>
|
/// <para>
|
||||||
/// Each call creates and disposes its own context via the pooled factory — the same pattern
|
/// 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>
|
/// <summary>
|
||||||
/// Loads Tags + VirtualTags (untracked) and projects one <see cref="ScriptTagInfo"/> per row —
|
/// Loads the raw topology + Tags + VirtualTags (untracked) and projects one
|
||||||
/// the SINGLE source of the per-category resolvable-key projection shared by
|
/// <see cref="ScriptTagInfo"/> per resolvable key — the SINGLE source of the projection shared by
|
||||||
/// <see cref="GetPathsAsync"/> (distinct paths, prefix-filtered) and
|
/// <see cref="GetPathsAsync"/> (distinct paths, prefix-filtered) and <see cref="GetTagInfoAsync"/>
|
||||||
/// <see cref="GetTagInfoAsync"/> (exact Ordinal lookup).
|
/// (exact Ordinal lookup).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private async Task<IReadOnlyList<ScriptTagInfo>> BuildEntriesAsync(CancellationToken ct)
|
private async Task<IReadOnlyList<ScriptTagInfo>> BuildEntriesAsync(CancellationToken ct)
|
||||||
{
|
{
|
||||||
await using var db = await dbFactory.CreateDbContextAsync(ct);
|
await using var db = await dbFactory.CreateDbContextAsync(ct);
|
||||||
|
|
||||||
// v3: Tag is Raw-only — EquipmentId / FolderPath / DriverInstanceId were retired (the
|
// The raw topology, fleet-wide, in the four ancestry maps RawPathResolver takes. Building the
|
||||||
// equipment↔Tag + driver bindings are gone). The resolvable key is the driver FullName carried
|
// resolver here (rather than reimplementing the join) is what keeps a suggested path
|
||||||
// in TagConfig; project only the surviving columns. (The Raw/UNS tag catalog is rebuilt in
|
// byte-identical to the one the deploy gate and the runtime compute.
|
||||||
// Batch 2/3.)
|
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
|
var tagRows = await db.Tags
|
||||||
.AsNoTracking()
|
.AsNoTracking()
|
||||||
.Select(t => new { t.Name, t.DataType, t.TagConfig })
|
.Select(t => new { t.DeviceId, t.TagGroupId, t.Name, t.DataType })
|
||||||
.ToListAsync(ct);
|
.ToListAsync(ct);
|
||||||
|
|
||||||
var vtagRows = await db.VirtualTags
|
var vtagRows = await db.VirtualTags
|
||||||
@@ -176,46 +199,22 @@ public sealed class ScriptTagCatalog(IDbContextFactory<OtOpcUaConfigDbContext> d
|
|||||||
|
|
||||||
foreach (var t in tagRows)
|
foreach (var t in tagRows)
|
||||||
{
|
{
|
||||||
// The runtime GetTag key is the driver FullName from TagConfig; fall back to Name if absent.
|
// v3: the runtime GetTag key IS the RawPath. A null result means a broken/unknown ancestry
|
||||||
var full = ExtractFullNameFromTagConfig(t.TagConfig);
|
// chain — the runtime could not route that tag either, so it is omitted rather than guessed.
|
||||||
var path = string.IsNullOrWhiteSpace(full) ? t.Name : full;
|
var path = resolver.TryBuildTagPath(t.DeviceId, t.TagGroupId, t.Name);
|
||||||
entries.Add(new ScriptTagInfo(path, "Tag", t.DataType, null));
|
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)
|
foreach (var v in vtagRows)
|
||||||
{
|
{
|
||||||
// Virtual tag — best-effort: the live resolution of virtual-tag cascade/write targets is
|
// 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));
|
entries.Add(new ScriptTagInfo(v.Name, "Virtual tag", v.DataType, null));
|
||||||
}
|
}
|
||||||
|
|
||||||
return entries;
|
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 "";
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -170,6 +170,7 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
|
|||||||
/// <summary>Interval between bounded post-connect re-discovery passes. Production default 2s; tests
|
/// <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>
|
/// inject a tiny value so the loop runs without real-time waits.</summary>
|
||||||
private readonly TimeSpan _rediscoverInterval;
|
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
|
/// <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>
|
/// (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="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
|
/// <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>
|
/// 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>
|
/// <returns>A <see cref="Akka.Actor.Props"/> instance configured to create the wrapped <see cref="DriverInstanceActor"/>.</returns>
|
||||||
public static Props Props(
|
public static Props Props(
|
||||||
IDriver driver,
|
IDriver driver,
|
||||||
@@ -249,7 +253,8 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
|
|||||||
TimeSpan? rediscoverInterval = null,
|
TimeSpan? rediscoverInterval = null,
|
||||||
int rediscoverMaxAttempts = DefaultRediscoverMaxAttempts,
|
int rediscoverMaxAttempts = DefaultRediscoverMaxAttempts,
|
||||||
TimeSpan? rediscoverDiscoverTimeout = null,
|
TimeSpan? rediscoverDiscoverTimeout = null,
|
||||||
IDriverCapabilityInvoker? invoker = null) =>
|
IDriverCapabilityInvoker? invoker = null,
|
||||||
|
TimeSpan? healthPollInterval = null) =>
|
||||||
Akka.Actor.Props.Create(() => new DriverInstanceActor(
|
Akka.Actor.Props.Create(() => new DriverInstanceActor(
|
||||||
driver,
|
driver,
|
||||||
reconnectInterval ?? DefaultReconnectInterval,
|
reconnectInterval ?? DefaultReconnectInterval,
|
||||||
@@ -259,7 +264,8 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
|
|||||||
rediscoverInterval,
|
rediscoverInterval,
|
||||||
rediscoverMaxAttempts,
|
rediscoverMaxAttempts,
|
||||||
rediscoverDiscoverTimeout,
|
rediscoverDiscoverTimeout,
|
||||||
invoker));
|
invoker,
|
||||||
|
healthPollInterval));
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Returns true when the driver should boot in DEV-STUB mode based on host platform and
|
/// 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="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;
|
/// <param name="invoker">Phase 6.1 resilience invoker wrapping this driver's capability calls;
|
||||||
/// defaults to <see cref="NullDriverCapabilityInvoker"/> (pass-through) when null.</param>
|
/// 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(
|
public DriverInstanceActor(
|
||||||
IDriver driver,
|
IDriver driver,
|
||||||
TimeSpan reconnectInterval,
|
TimeSpan reconnectInterval,
|
||||||
@@ -302,7 +310,8 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
|
|||||||
TimeSpan? rediscoverInterval = null,
|
TimeSpan? rediscoverInterval = null,
|
||||||
int rediscoverMaxAttempts = DefaultRediscoverMaxAttempts,
|
int rediscoverMaxAttempts = DefaultRediscoverMaxAttempts,
|
||||||
TimeSpan? rediscoverDiscoverTimeout = null,
|
TimeSpan? rediscoverDiscoverTimeout = null,
|
||||||
IDriverCapabilityInvoker? invoker = null)
|
IDriverCapabilityInvoker? invoker = null,
|
||||||
|
TimeSpan? healthPollInterval = null)
|
||||||
{
|
{
|
||||||
_driver = driver;
|
_driver = driver;
|
||||||
_invoker = invoker ?? NullDriverCapabilityInvoker.Instance;
|
_invoker = invoker ?? NullDriverCapabilityInvoker.Instance;
|
||||||
@@ -313,6 +322,7 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
|
|||||||
_rediscoverInterval = rediscoverInterval ?? DefaultRediscoverInterval;
|
_rediscoverInterval = rediscoverInterval ?? DefaultRediscoverInterval;
|
||||||
_rediscoverMaxAttempts = rediscoverMaxAttempts;
|
_rediscoverMaxAttempts = rediscoverMaxAttempts;
|
||||||
_rediscoverDiscoverTimeout = rediscoverDiscoverTimeout ?? DefaultRediscoverDiscoverTimeout;
|
_rediscoverDiscoverTimeout = rediscoverDiscoverTimeout ?? DefaultRediscoverDiscoverTimeout;
|
||||||
|
_healthPollInterval = healthPollInterval ?? HealthPollInterval;
|
||||||
OtOpcUaTelemetry.DriverInstanceLifecycle.Add(1,
|
OtOpcUaTelemetry.DriverInstanceLifecycle.Add(1,
|
||||||
new KeyValuePair<string, object?>("event", startStubbed ? "spawn_stub" : "spawn"),
|
new KeyValuePair<string, object?>("event", startStubbed ? "spawn_stub" : "spawn"),
|
||||||
new KeyValuePair<string, object?>("driver_type", driver.DriverType));
|
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
|
// 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.
|
// long-lived Healthy drivers keep their snapshot fresh for newly-joined SignalR clients.
|
||||||
PublishHealthSnapshot();
|
PublishHealthSnapshot();
|
||||||
Timers.StartPeriodicTimer("health-poll", HealthPollTick.Instance, HealthPollInterval);
|
Timers.StartPeriodicTimer("health-poll", HealthPollTick.Instance, _healthPollInterval);
|
||||||
}
|
}
|
||||||
|
|
||||||
private void Stubbed()
|
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.
|
// to Self (HandleSubscribeAsync already logged the underlying cause). Swallow it so it doesn't dead-letter.
|
||||||
Receive<SubscriptionFailed>(msg =>
|
Receive<SubscriptionFailed>(msg =>
|
||||||
_log.Debug("DriverInstance {Id}: resubscribe reported failure: {Reason}", _driverInstanceId, msg.Reason));
|
_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()
|
private void Reconnecting()
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
+144
-42
@@ -10,59 +10,109 @@ using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Runtime;
|
|||||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests.Runtime;
|
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests.Runtime;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// PR 6.2 — pins the EventPump's bounded-channel + drop-newest behavior. We
|
/// PR 6.2 — pins the EventPump's bounded-channel + drop-newest behavior. We hold the dispatch loop
|
||||||
/// hold the dispatch loop with a slow handler so the channel fills, then verify
|
/// with a blocking handler so the channel fills, then verify the producer keeps reading from the gw
|
||||||
/// the producer keeps reading from the gw stream and increments the
|
/// stream and increments the <c>galaxy.events.dropped</c> counter rather than blocking.
|
||||||
/// <c>galaxy.events.dropped</c> counter rather than blocking.
|
|
||||||
/// </summary>
|
/// </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
|
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]
|
[Fact]
|
||||||
public async Task Drops_newest_when_channel_fills_and_records_metric()
|
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
|
try
|
||||||
{
|
{
|
||||||
var subscriber = new ManualSubscriber();
|
var subscriber = new ManualSubscriber();
|
||||||
var registry = new SubscriptionRegistry();
|
var registry = new SubscriptionRegistry();
|
||||||
registry.Register(1, [new TagBinding("Tag.A", ItemHandle: 7)]);
|
registry.Register(1, [new TagBinding("Tag.A", ItemHandle: 7)]);
|
||||||
|
|
||||||
// Tiny channel + slow handler ⇒ producer hits FullMode.Wait → TryWrite false
|
pump = new EventPump(
|
||||||
// for every overflow event.
|
subscriber, registry, channelCapacity: channelCapacity, clientName: clientName);
|
||||||
var dispatchGate = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
pump.OnDataChange += (_, _) =>
|
||||||
await using var pump = new EventPump(
|
|
||||||
subscriber, registry, channelCapacity: 2, clientName: "PumpTest");
|
|
||||||
pump.OnDataChange += async (_, _) =>
|
|
||||||
{
|
{
|
||||||
// Block the dispatch loop until we've shoved enough events through to
|
handlerEntered.TrySetResult();
|
||||||
// overflow the bounded channel. Consume the gate exactly once.
|
dispatchGate.Wait();
|
||||||
await dispatchGate.Task.ConfigureAwait(false);
|
|
||||||
};
|
};
|
||||||
pump.Start();
|
pump.Start();
|
||||||
|
|
||||||
const int totalEvents = 10;
|
// Stage 1 — one event, then wait for the dispatcher to take it and block. After this the
|
||||||
for (var i = 0; i < totalEvents; i++)
|
// 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);
|
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
|
// PRESENCE assertion on the discriminator (#500's rule): poll until the drop count reaches its
|
||||||
// remainder should have hit the dropped counter. Don't pin exact counts —
|
// terminal value rather than sleeping a fixed 150 ms and hoping the producer got scheduled.
|
||||||
// the scheduler can interleave; pin the invariants instead.
|
// The budget is an upper bound before giving up — spending it costs nothing on the happy path,
|
||||||
counters.Received.ShouldBeGreaterThanOrEqualTo(totalEvents);
|
// and no amount of it can make a genuinely broken pump pass. Dropped starts at 0, so this
|
||||||
counters.Dropped.ShouldBeGreaterThan(0,
|
// cannot be satisfied by the initial state.
|
||||||
"with capacity=2 and a held dispatcher we must drop at least one of 10 events");
|
await WaitUntilAsync(() => counters.Dropped == expectedDropped,
|
||||||
(counters.Received).ShouldBe(counters.Dispatched + counters.Dropped + counters.InFlight,
|
$"expected {expectedDropped} dropped events");
|
||||||
"received = dispatched + dropped + (events still queued)");
|
|
||||||
|
|
||||||
// Release the dispatcher so DisposeAsync can drain cleanly.
|
counters.Received.ShouldBe(totalEvents,
|
||||||
dispatchGate.TrySetResult();
|
"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
|
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();
|
counters.Dispose();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -110,23 +160,21 @@ public sealed class EventPumpBoundedChannelTests
|
|||||||
// Poll until at least one galaxy.events.received measurement tagged
|
// Poll until at least one galaxy.events.received measurement tagged
|
||||||
// galaxy.client=Driver-X lands in the listener, rather than using a
|
// galaxy.client=Driver-X lands in the listener, rather than using a
|
||||||
// fixed delay that races under parallel test load on a busy box.
|
// fixed delay that races under parallel test load on a busy box.
|
||||||
var deadline = DateTime.UtcNow.AddSeconds(5);
|
// Budget, not 5 s: the same contention that produced #503 stretches how long the
|
||||||
bool found = false;
|
// producer loop takes to be scheduled, and a presence poll costs nothing to over-budget.
|
||||||
while (DateTime.UtcNow < deadline)
|
await WaitUntilAsync(
|
||||||
|
() =>
|
||||||
{
|
{
|
||||||
listener.RecordObservableInstruments();
|
listener.RecordObservableInstruments();
|
||||||
bool hasMatch;
|
|
||||||
lock (captured)
|
lock (captured)
|
||||||
{
|
{
|
||||||
hasMatch = captured.Any(c =>
|
return captured.Any(c =>
|
||||||
c.Instrument == "galaxy.events.received" &&
|
c.Instrument == "galaxy.events.received" &&
|
||||||
c.Tags.Any(t => t.Key == "galaxy.client" &&
|
c.Tags.Any(t => t.Key == "galaxy.client" &&
|
||||||
string.Equals((string?)t.Value, "Driver-X", StringComparison.Ordinal)));
|
string.Equals((string?)t.Value, "Driver-X", StringComparison.Ordinal)));
|
||||||
}
|
}
|
||||||
if (hasMatch) { found = true; break; }
|
},
|
||||||
await Task.Delay(25);
|
"a galaxy.events.received measurement tagged galaxy.client=Driver-X");
|
||||||
}
|
|
||||||
_ = found; // assertion happens below after dispose
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// The static Meter is shared across all EventPump instances in the test
|
// 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");
|
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 capture = new CounterCapture();
|
||||||
var listener = new MeterListener();
|
var listener = new MeterListener();
|
||||||
@@ -154,8 +241,20 @@ public sealed class EventPumpBoundedChannelTests
|
|||||||
{
|
{
|
||||||
if (instr.Meter.Name == EventPump.MeterName) l.EnableMeasurementEvents(instr);
|
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)
|
switch (instr.Name)
|
||||||
{
|
{
|
||||||
case "galaxy.events.received": Interlocked.Add(ref capture._received, value); break;
|
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);
|
public long Dispatched => Interlocked.Read(ref _dispatched);
|
||||||
/// <summary>Gets the count of dropped events.</summary>
|
/// <summary>Gets the count of dropped events.</summary>
|
||||||
public long Dropped => Interlocked.Read(ref _dropped);
|
public long Dropped => Interlocked.Read(ref _dropped);
|
||||||
/// <summary>Gets the count of in-flight events.</summary>
|
// There is deliberately no InFlight member. It could only be DERIVED as
|
||||||
public long InFlight => Math.Max(0, Received - Dispatched - Dropped);
|
// 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>
|
/// <summary>Disposes the meter listener.</summary>
|
||||||
public void Dispose() => Listener?.Dispose();
|
public void Dispose() => Listener?.Dispose();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,7 +21,10 @@ COPY profiles/ /fixtures/
|
|||||||
# compose profile. See Docker/README.md §exception injection.
|
# compose profile. See Docker/README.md §exception injection.
|
||||||
COPY exception_injector.py /fixtures/
|
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.
|
# Default to the standard profile; docker-compose.yml overrides per service.
|
||||||
# --http_port intentionally omitted; pymodbus 3.13's web UI binds on a
|
# --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
|
The Modbus driver's integration tests talk to a
|
||||||
[`pymodbus`](https://pymodbus.readthedocs.io/) simulator running as a
|
[`pymodbus`](https://pymodbus.readthedocs.io/) simulator running as a
|
||||||
pinned Docker container. One image, per-profile service in compose, same
|
pinned Docker container. One image, per-profile service in compose. Most
|
||||||
port binding (`5020`) regardless of which profile is live. Docker is the
|
profiles bind `:5020`, so only one of *those* runs at a time; the
|
||||||
only supported launch path — a fresh clone needs Docker Desktop and
|
`rtu_over_tcp` profile binds `:5021` and is designed to co-run alongside
|
||||||
nothing else.
|
`standard`. Docker is the only supported launch path — a fresh clone needs
|
||||||
|
Docker Desktop and nothing else.
|
||||||
|
|
||||||
| File | Purpose |
|
| File | Purpose |
|
||||||
|---|---|
|
|---|---|
|
||||||
| [`Dockerfile`](Dockerfile) | `python:3.12-slim-bookworm` + `pymodbus[simulator]==3.13.0` + every profile JSON + `exception_injector.py` |
|
| [`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 |
|
| [`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 |
|
| [`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
|
docker compose -f tests\...\Docker\docker-compose.yml --profile dl205 down
|
||||||
```
|
```
|
||||||
|
|
||||||
Only one profile binds `:5020` at a time; switch by stopping the current
|
Only one of the `:5020` profiles binds at a time; switch by stopping the
|
||||||
service + starting another. The integration tests discriminate by a
|
current service + starting another. (`rtu_over_tcp` is the exception — it
|
||||||
separate `MODBUS_SIM_PROFILE` env var so they skip correctly when the
|
binds `:5021` and co-runs with `standard`.) The integration tests
|
||||||
wrong profile is live.
|
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
|
> **⚠️ Profile-cycling gotcha (bit us in the 2026-07 sweep — see
|
||||||
> `archreview/plans/INTEGRATION-SWEEP-STATUS.md`).** Each profile is a
|
> `archreview/plans/INTEGRATION-SWEEP-STATUS.md`).** Each profile is a
|
||||||
|
|||||||
+24
@@ -78,6 +78,30 @@ services:
|
|||||||
"--json_file", "/fixtures/s7_1500.json"
|
"--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
|
# Exception-injection profile. Runs the standalone pure-stdlib Modbus/TCP
|
||||||
# server shipped as exception_injector.py instead of the pymodbus
|
# server shipped as exception_injector.py instead of the pymodbus
|
||||||
# simulator — pymodbus naturally emits only exception codes 02 + 03, and
|
# 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> /
|
/// (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>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
|
/// <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.
|
/// of 1 second.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[Trait("Category", "Unit")]
|
[Trait("Category", "Unit")]
|
||||||
@@ -70,7 +70,7 @@ public sealed class ModbusEdgeCaseValidationTests
|
|||||||
[InlineData(60_000, 60)]
|
[InlineData(60_000, 60)]
|
||||||
public void ClampToWholeSeconds_rounds_up_to_at_least_one_second(int ms, int expected)
|
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>
|
/// <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
|
// 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
|
// the feature. The OS would reject the negative int — clamping to 1 keeps the socket
|
||||||
// valid until the operator fixes the config.
|
// 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>();
|
||||||
|
}
|
||||||
+128
-77
@@ -13,14 +13,21 @@ namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.ScriptAnalysis;
|
|||||||
/// pass to <c>ctx.GetTag("…")</c> / <c>ctx.SetVirtualTag("…")</c>.
|
/// pass to <c>ctx.GetTag("…")</c> / <c>ctx.SetVirtualTag("…")</c>.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// Fidelity finding (see <see cref="ScriptTagCatalog"/> doc comment): the runtime Akka path resolves
|
/// <para>
|
||||||
/// a <c>ctx.GetTag</c> literal against the driver <b>FullName</b> (the wire reference in
|
/// Fidelity finding (see the <see cref="ScriptTagCatalog"/> doc comment): the runtime resolves a
|
||||||
/// <c>Tag.TagConfig.FullName</c>), NOT the UNS browse path — the UNS-path engine is dormant. v3: Tag is
|
/// <c>ctx.GetTag</c> literal through <c>DependencyMuxActor._byRef</c>, keyed Ordinal by the driver's
|
||||||
/// Raw-only (no <c>EquipmentId</c>/<c>FolderPath</c>/<c>DriverInstanceId</c>), so the catalog projects
|
/// wire reference — and in v3 that wire reference <b>is the RawPath</b>
|
||||||
/// the surviving <c>Name</c>/<c>DataType</c>/<c>TagConfig</c> columns: the resolvable path derives from
|
/// (<c>Folder/…/Driver/Device/[TagGroup/…]/Tag</c>). So the catalog projects RawPaths for raw tags and
|
||||||
/// the <c>TagConfig.FullName</c> field when present, else the tag <c>Name</c>; the projected
|
/// the leaf <c>Name</c> for virtual tags.
|
||||||
/// <c>DriverInstanceId</c> is always <see langword="null"/>. Virtual tags emit their leaf Name. These
|
/// </para>
|
||||||
/// assertions check the resolvable key is present AND that the UNS browse paths are absent.
|
/// <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>
|
/// </remarks>
|
||||||
[Trait("Category", "Unit")]
|
[Trait("Category", "Unit")]
|
||||||
public sealed class ScriptTagCatalogTests
|
public sealed class ScriptTagCatalogTests
|
||||||
@@ -43,8 +50,9 @@ public sealed class ScriptTagCatalogTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Seeds an Area → Line → Equipment path with: one equipment driver tag (FullName "Motor.Speed"),
|
/// Seeds the UNS path (Area → Line → Equipment) AND the raw topology a RawPath is built from:
|
||||||
/// one virtual tag, and one FolderPath-scoped tag (EquipmentId null, FolderPath set).
|
/// 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>
|
/// </summary>
|
||||||
private static void Seed(DbContextOptions<OtOpcUaConfigDbContext> opts)
|
private static void Seed(DbContextOptions<OtOpcUaConfigDbContext> opts)
|
||||||
{
|
{
|
||||||
@@ -61,26 +69,38 @@ public sealed class ScriptTagCatalogTests
|
|||||||
MachineCode = "machine_001",
|
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
|
db.Tags.Add(new Tag
|
||||||
{
|
{
|
||||||
TagId = "TAG-EQ",
|
TagId = "TAG-EQ",
|
||||||
DeviceId = "DEV-1",
|
DeviceId = "DEV-1",
|
||||||
|
TagGroupId = null,
|
||||||
Name = "Speed",
|
Name = "Speed",
|
||||||
DataType = "Float",
|
DataType = "Float",
|
||||||
AccessLevel = TagAccessLevel.Read,
|
AccessLevel = TagAccessLevel.Read,
|
||||||
TagConfig = "{\"FullName\":\"Motor.Speed\"}",
|
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
|
db.Tags.Add(new Tag
|
||||||
{
|
{
|
||||||
TagId = "TAG-SP",
|
TagId = "TAG-GRP",
|
||||||
DeviceId = "DEV-1",
|
DeviceId = "DEV-1",
|
||||||
Name = "DownloadPath",
|
TagGroupId = "TG-1",
|
||||||
DataType = "String",
|
Name = "Torque",
|
||||||
|
DataType = "Double",
|
||||||
AccessLevel = TagAccessLevel.Read,
|
AccessLevel = TagAccessLevel.Read,
|
||||||
TagConfig = "{\"FullName\":\"DelmiaReceiver_001.DownloadPath\"}",
|
TagConfig = "{\"FullName\":\"Motor.Torque\"}",
|
||||||
});
|
});
|
||||||
|
|
||||||
db.VirtualTags.Add(new VirtualTag
|
db.VirtualTags.Add(new VirtualTag
|
||||||
@@ -95,30 +115,27 @@ public sealed class ScriptTagCatalogTests
|
|||||||
db.SaveChanges();
|
db.SaveChanges();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>A null filter returns only the resolvable keys: the driver FullName for the equipment
|
/// <summary>The v3 resolvable keys: a RawPath per raw tag (device-level and group-nested), plus the
|
||||||
/// tag, the MXAccess dot-ref for the FolderPath-scoped tag, and the virtual tag's leaf Name — and
|
/// virtual tag's leaf Name — and NOT the pre-v3 <c>TagConfig.FullName</c>, which is no longer the mux
|
||||||
/// NONE of the UNS browse paths.</summary>
|
/// key and would suggest a path that cannot resolve at runtime (#490).</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task GetPaths_no_filter_returns_resolvable_keys_only()
|
public async Task GetPaths_no_filter_returns_rawpaths_only()
|
||||||
{
|
{
|
||||||
var (catalog, opts) = Fresh();
|
var (catalog, opts) = Fresh();
|
||||||
Seed(opts);
|
Seed(opts);
|
||||||
|
|
||||||
var paths = await catalog.GetPathsAsync(null, default);
|
var paths = await catalog.GetPathsAsync(null, default);
|
||||||
|
|
||||||
// Equipment driver tag: the authoritative GetTag key (driver FullName).
|
paths.ShouldContain("Plant/Modbus/dev1/Speed"); // directly under the device
|
||||||
paths.ShouldContain("Motor.Speed");
|
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.
|
// The pre-v3 wire address is NOT a resolvable key in v3.
|
||||||
paths.ShouldContain("DelmiaReceiver_001.DownloadPath");
|
paths.ShouldNotContain("Motor.Speed");
|
||||||
|
paths.ShouldNotContain("Motor.Torque");
|
||||||
|
|
||||||
// Virtual tag: leaf Name only.
|
// UNS browse paths are intentionally NOT suggested (that engine is dormant).
|
||||||
paths.ShouldContain("Computed");
|
|
||||||
|
|
||||||
// The UNS browse paths are intentionally NOT suggested (the UNS-path engine is dormant).
|
|
||||||
paths.ShouldNotContain("Assembly/LineA/Machine1/Speed");
|
paths.ShouldNotContain("Assembly/LineA/Machine1/Speed");
|
||||||
paths.ShouldNotContain("DelmiaReceiver_001/DownloadPath");
|
|
||||||
paths.ShouldNotContain("Assembly/LineA/Machine1/Computed");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>The result is distinct.</summary>
|
/// <summary>The result is distinct.</summary>
|
||||||
@@ -133,37 +150,66 @@ public sealed class ScriptTagCatalogTests
|
|||||||
paths.Distinct(StringComparer.OrdinalIgnoreCase).Count().ShouldBe(paths.Count);
|
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]
|
[Fact]
|
||||||
public async Task GetPaths_prefix_filter_narrows()
|
public async Task GetPaths_prefix_filter_narrows_on_a_partial_rawpath()
|
||||||
{
|
{
|
||||||
var (catalog, opts) = Fresh();
|
var (catalog, opts) = Fresh();
|
||||||
Seed(opts);
|
Seed(opts);
|
||||||
|
|
||||||
var paths = await catalog.GetPathsAsync("Motor", default);
|
var paths = await catalog.GetPathsAsync("Plant/Modbus/dev1/", default);
|
||||||
|
|
||||||
paths.ShouldContain("Motor.Speed");
|
paths.ShouldContain("Plant/Modbus/dev1/Speed");
|
||||||
paths.ShouldNotContain("DelmiaReceiver_001.DownloadPath");
|
paths.ShouldContain("Plant/Modbus/dev1/Motors/Torque");
|
||||||
paths.ShouldNotContain("Computed");
|
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]
|
[Fact]
|
||||||
public async Task GetPaths_prefix_filter_is_case_insensitive()
|
public async Task GetPaths_prefix_filter_is_case_insensitive()
|
||||||
{
|
{
|
||||||
var (catalog, opts) = Fresh();
|
var (catalog, opts) = Fresh();
|
||||||
Seed(opts);
|
Seed(opts);
|
||||||
|
|
||||||
var paths = await catalog.GetPathsAsync("motor", default);
|
(await catalog.GetPathsAsync("plant/modbus", default)).ShouldContain("Plant/Modbus/dev1/Speed");
|
||||||
|
|
||||||
paths.ShouldContain("Motor.Speed");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>A tag whose <c>TagConfig</c> is malformed JSON must not throw — the catalog falls back
|
/// <summary>A tag whose ancestry chain is broken (device row missing) has no RawPath. The runtime could
|
||||||
/// to the raw blob and still returns every other tag.</summary>
|
/// 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]
|
[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();
|
var (catalog, opts) = Fresh();
|
||||||
Seed(opts);
|
Seed(opts);
|
||||||
@@ -185,37 +231,33 @@ public sealed class ScriptTagCatalogTests
|
|||||||
IReadOnlyList<string> paths = [];
|
IReadOnlyList<string> paths = [];
|
||||||
await Should.NotThrowAsync(async () => paths = await catalog.GetPathsAsync(null, default));
|
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("Plant/Modbus/dev1/Broken");
|
||||||
paths.ShouldContain("Motor.Speed");
|
paths.ShouldContain("Plant/Modbus/dev1/Speed");
|
||||||
paths.ShouldContain("DelmiaReceiver_001.DownloadPath");
|
|
||||||
paths.ShouldContain("Computed");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>A FolderPath-scoped tag with a null/empty <c>FolderPath</c> yields just its <c>Name</c>
|
/// <summary>A driver at the cluster root (no RawFolder) contributes no folder segment.</summary>
|
||||||
/// (no leading dot).</summary>
|
|
||||||
[Fact]
|
[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();
|
var (catalog, opts) = Fresh();
|
||||||
|
|
||||||
using (var db = new OtOpcUaConfigDbContext(opts))
|
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
|
db.Tags.Add(new Tag
|
||||||
{
|
{
|
||||||
TagId = "TAG-SP-ROOT",
|
TagId = "TAG-ROOT", DeviceId = "DEV-ROOT", Name = "Scalar",
|
||||||
DeviceId = "DEV-1",
|
DataType = "String", AccessLevel = TagAccessLevel.Read, TagConfig = "{}",
|
||||||
Name = "RootScalar",
|
|
||||||
DataType = "String",
|
|
||||||
AccessLevel = TagAccessLevel.Read,
|
|
||||||
TagConfig = "{\"FullName\":\"RootScalar\"}",
|
|
||||||
});
|
});
|
||||||
db.SaveChanges();
|
db.SaveChanges();
|
||||||
}
|
}
|
||||||
|
|
||||||
var paths = await catalog.GetPathsAsync(null, default);
|
(await catalog.GetPathsAsync(null, default)).ShouldContain("RootDrv/d0/Scalar");
|
||||||
|
|
||||||
paths.ShouldContain("RootScalar");
|
|
||||||
paths.ShouldNotContain(".RootScalar");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>An empty database yields an empty list rather than throwing.</summary>
|
/// <summary>An empty database yields an empty list rather than throwing.</summary>
|
||||||
@@ -224,26 +266,24 @@ public sealed class ScriptTagCatalogTests
|
|||||||
{
|
{
|
||||||
var (catalog, _) = Fresh();
|
var (catalog, _) = Fresh();
|
||||||
|
|
||||||
var paths = await catalog.GetPathsAsync(null, default);
|
(await catalog.GetPathsAsync(null, default)).ShouldBeEmpty();
|
||||||
|
|
||||||
paths.ShouldBeEmpty();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>A raw tag whose FullName is a dot-ref resolves by that FullName to a Tag-kind info with
|
/// <summary>Hover: an absolute RawPath resolves to Tag-kind info carrying the configured DataType and —
|
||||||
/// the configured DataType. v3: Tag no longer binds a driver, so the projected DriverInstanceId is null.</summary>
|
/// new in #490 — the owning driver instance, which the topology now makes available.</summary>
|
||||||
[Fact]
|
[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();
|
var (catalog, opts) = Fresh();
|
||||||
Seed(opts);
|
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.ShouldNotBeNull();
|
||||||
info!.Path.ShouldBe("DelmiaReceiver_001.DownloadPath");
|
info!.Path.ShouldBe("Plant/Modbus/dev1/Motors/Torque");
|
||||||
info.Kind.ShouldBe("Tag");
|
info.Kind.ShouldBe("Tag");
|
||||||
info.DataType.ShouldBe("String");
|
info.DataType.ShouldBe("Double");
|
||||||
info.DriverInstanceId.ShouldBeNull();
|
info.DriverInstanceId.ShouldBe("DRV-1");
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>A virtual tag resolves by its leaf Name to a "Virtual tag"-kind info with no driver.</summary>
|
/// <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);
|
var info = await catalog.GetTagInfoAsync("Computed", default);
|
||||||
|
|
||||||
info.ShouldNotBeNull();
|
info.ShouldNotBeNull();
|
||||||
info!.Path.ShouldBe("Computed");
|
info!.Kind.ShouldBe("Virtual tag");
|
||||||
info.Kind.ShouldBe("Virtual tag");
|
|
||||||
info.DataType.ShouldBe("Double");
|
info.DataType.ShouldBe("Double");
|
||||||
info.DriverInstanceId.ShouldBeNull();
|
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>
|
/// <summary>An unknown path resolves to null.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task GetTagInfo_unknown_path_returns_null()
|
public async Task GetTagInfo_unknown_path_returns_null()
|
||||||
@@ -269,19 +319,20 @@ public sealed class ScriptTagCatalogTests
|
|||||||
var (catalog, opts) = Fresh();
|
var (catalog, opts) = Fresh();
|
||||||
Seed(opts);
|
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
|
/// <summary>The lookup is case-SENSITIVE (Ordinal), mirroring the runtime DependencyMuxActor's
|
||||||
/// resolve, mirroring the runtime DependencyMuxActor's StringComparer.Ordinal keying.</summary>
|
/// StringComparer.Ordinal keying — a case-mismatched path would not resolve live, so hover must not
|
||||||
|
/// claim it does.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task GetTagInfo_case_mismatch_returns_null_ordinal()
|
public async Task GetTagInfo_case_mismatch_returns_null_ordinal()
|
||||||
{
|
{
|
||||||
var (catalog, opts) = Fresh();
|
var (catalog, opts) = Fresh();
|
||||||
Seed(opts);
|
Seed(opts);
|
||||||
|
|
||||||
(await catalog.GetTagInfoAsync("motor.speed", default)).ShouldBeNull();
|
(await catalog.GetTagInfoAsync("plant/modbus/dev1/speed", default)).ShouldBeNull();
|
||||||
(await catalog.GetTagInfoAsync("Motor.Speed", default)).ShouldNotBeNull();
|
(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.ShouldContain("\"family\":\"MELSEC\""); // enum-as-name (not a number), camelCase key
|
||||||
json.ShouldNotContain("\"family\":0", Case.Sensitive); // never the numeric enum form
|
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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+31
-5
@@ -86,8 +86,19 @@ public sealed class DriverHostActorNativeAlarmAckRoutingTests : RuntimeActorTest
|
|||||||
|
|
||||||
actor.Tell(new DriverHostActor.RouteNativeAlarmAck("Plant/gw/dev1/does-not-exist", "cmt", "alice"));
|
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.
|
// POSITIVE CONTROL, not a sleep (#501). `Acks` is empty at t=0, so any assertion made before the
|
||||||
AwaitAssert(() => recorder.Acks.ShouldBeEmpty(), duration: TimeSpan.FromMilliseconds(800));
|
// 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
|
/// <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()));
|
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.
|
// POSITIVE CONTROL (#501) — same reasoning as the unmapped-node test, with the role itself as the
|
||||||
AwaitAssert(() => recorder.Acks.ShouldBeEmpty(), duration: TimeSpan.FromMilliseconds(800));
|
// 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
|
/// <summary>Spawns the host with the recording alarm-driver factory, dispatches the deployment, and waits
|
||||||
|
|||||||
+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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -114,6 +114,18 @@ internal sealed class SubscribableStubDriver : StubDriver, ISubscribable
|
|||||||
/// synchronously-completed stub task hides (the continuation otherwise runs inline).</summary>
|
/// synchronously-completed stub task hides (the continuation otherwise runs inline).</summary>
|
||||||
public bool UnsubscribeYields { get; set; }
|
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>
|
/// <summary>Subscribes to the specified full references.</summary>
|
||||||
/// <param name="fullReferences">The full references to subscribe to.</param>
|
/// <param name="fullReferences">The full references to subscribe to.</param>
|
||||||
/// <param name="publishingInterval">The publishing interval.</param>
|
/// <param name="publishingInterval">The publishing interval.</param>
|
||||||
@@ -121,8 +133,13 @@ internal sealed class SubscribableStubDriver : StubDriver, ISubscribable
|
|||||||
public Task<ISubscriptionHandle> SubscribeAsync(
|
public Task<ISubscriptionHandle> SubscribeAsync(
|
||||||
IReadOnlyList<string> fullReferences, TimeSpan publishingInterval, CancellationToken cancellationToken)
|
IReadOnlyList<string> fullReferences, TimeSpan publishingInterval, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
Interlocked.Increment(ref SubscribeCount);
|
var attempt = Interlocked.Increment(ref SubscribeCount);
|
||||||
LastSubscribedRefs = fullReferences;
|
LastSubscribedRefs = fullReferences;
|
||||||
|
if (attempt <= SubscribeFailuresBeforeSuccess)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException($"stub subscribe failure #{attempt}");
|
||||||
|
}
|
||||||
|
|
||||||
return Task.FromResult<ISubscriptionHandle>(_handle);
|
return Task.FromResult<ISubscriptionHandle>(_handle);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -78,7 +78,9 @@ public sealed class PeerProbeSupervisorTests : RuntimeActorTestBase
|
|||||||
duration: PresenceBudget);
|
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]
|
[Fact]
|
||||||
public void Single_node_snapshot_spawns_no_children()
|
public void Single_node_snapshot_spawns_no_children()
|
||||||
{
|
{
|
||||||
@@ -87,7 +89,20 @@ public sealed class PeerProbeSupervisorTests : RuntimeActorTestBase
|
|||||||
|
|
||||||
sup.Tell(Snapshot(State(Local, RedundancyRole.Primary)));
|
sup.Tell(Snapshot(State(Local, RedundancyRole.Primary)));
|
||||||
|
|
||||||
AwaitAssert(() => sup.UnderlyingActor.ChildCount.ShouldBe(0),
|
// 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);
|
duration: PresenceBudget);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user