Compare commits

...

9 Commits

Author SHA1 Message Date
Joseph Doherty
0575280a3b Phase 3 PR 62 -- Siemens S7 native driver project scaffold (S7comm via S7netplus). First non-Modbus in-process driver. Creates src/ZB.MOM.WW.OtOpcUa.Driver.S7 (.NET 10, x64 -- S7netplus is managed, no bitness constraint like MXAccess) + tests/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests + slnx entries. Depends on S7netplus 0.20.0 which is the latest version on NuGet resolvable in this cache (0.21.0 per driver-specs.md is not yet published; 0.20.0 covers the same Plc+CpuType+ReadAsync surface). S7DriverOptions captures the connection settings documented in driver-specs.md \u00A75: Host, Port (default 102 ISO-on-TCP), CpuType (default S71500 per most-common deployment), Rack=0, Slot=0 (S7-1200/1500 onboard PN convention; S7-300/400 operators must override to slot 2 or 3), Timeout=5s, Tags list + Probe settings with default MW0 probe address. S7TagDefinition uses S7.Net-style address strings (DB1.DBW0, M0.0, I0.0, QD4) with an S7DataType enum (Bool, Byte, Int16, UInt16, Int32, UInt32, Int64, UInt64, Float32, Float64, String, DateTime -- the full type matrix from the spec); StringLength defaults to 254 (S7 STRING max). S7Driver implements the IDriver-only subset per the PR plan: InitializeAsync opens a managed Plc with the configured CpuType + Host + Rack + Slot, pins WriteTimeout / ReadTimeout on the underlying TcpClient, awaits Plc.OpenAsync with a linked CTS bounded by Options.Timeout so the ISO handshake itself respects the configured bound; health transitions Unknown -> Initializing -> Healthy on success or Unknown -> Initializing -> Faulted on handshake failure, with a best-effort Plc.Close() on the faulted path so retries don't leak the TcpClient. ShutdownAsync closes the Plc and flips health back to Unknown. DisposeAsync routes through ShutdownAsync + disposes the SemaphoreSlim. Internal Gate + Plc accessors are exposed to the test project (InternalsVisibleTo) so PRs 63-65 can stack read/write/subscribe on the same serialization semaphore per the S7netplus documented 'one Plc per PLC, SemaphoreSlim-serialized' pattern. ITagDiscovery, IReadable, IWritable, ISubscribable, IHostConnectivityProbe are all deliberately omitted from this PR -- they depend on the S7AddressParser (PR 63) and land sequenced in PRs 64-65. Unit tests (S7DriverScaffoldTests, 5 facts): default options target S7-1500 / port 102 / slot 0, default probe interval 5s, tag defaults to writable with StringLength 254, driver reports DriverType=S7 + Unknown health pre-init, Initialize against RFC-5737 reserved IP 192.0.2.1 with 250ms timeout transitions to Faulted and throws (tests the connect-failure path doesn't leave the driver in an ambiguous state). 5/5 pass. dotnet build ZB.MOM.WW.OtOpcUa.slnx: 0 errors. No regression in Modbus / Galaxy suites. PR 63 ships S7AddressParser next, PR 64 wires IReadable/IWritable over S7netplus, PR 65 adds discovery + polling-overlay subscribe + probe. 2026-04-19 00:03:09 -04:00
8150177296 Merge pull request 'Phase 2 PR 61 -- Close V1_ARCHIVE_STATUS.md: Streams D + E done' (#60) from phase-2-pr61-scrub-v1-archive-residue into v2 2026-04-18 23:22:58 -04:00
Joseph Doherty
56d8af8bdb Phase 2 PR 61 -- Close V1_ARCHIVE_STATUS.md; Phase 2 Streams D + E done. Purely a documentation-closure PR. The v1 archive deletion itself happened across earlier PRs: PR 2 on phase-2-stream-d archive-marked the four v1 projects (IsTestProject=false so dotnet test slnx bypassed them); Phase 3 PR 18 deleted the archived project source trees. What remained on disk was stale bin/obj residue from pre-deletion builds -- git never tracked those, so removing them from the working tree is cosmetic only (no source-file diff in this PR). What this PR actually changes: V1_ARCHIVE_STATUS.md is rewritten from 'Deletion plan (Phase 2 PR 3)' pre-work prose to a CLOSED retrospective that (a) lists all five v1 directories as deleted with check-marks (src/OtOpcUa.Host, src/Historian.Aveva, tests/Historian.Aveva.Tests, tests/Tests.v1Archive, tests/IntegrationTests), (b) names the parity-bar tests that now fill the role the 494 v1 tests originally held (Driver.Galaxy.E2E cross-FX subprocess parity + stability-findings regression, per-component *.Tests projects, Driver.Modbus.IntegrationTests, LiveStack/ smoke tests), and (c) gives the closure timeline connecting PR 2 -> Phase 3 PR 18 -> this PR 61. Also added the Modbus TCP driver family as parity coverage that didn't exist in v1 (DL205 + S7-1500 + Mitsubishi MELSEC via pymodbus sim). Stream D (retire legacy Host) has been effectively done since Phase 3 PR 18; Stream E (parity validation) is done since PR 2 landed the Driver.Galaxy.E2E project with HostSubprocessParityTests + HierarchyParityTests + StabilityFindingsRegressionTests. This PR exists to definitively close the two pending Phase 2 tasks on the task list and give future-me (or anyone picking up Phase 2 retrospectives) a single 'what actually happened' doc instead of a 'what we plan to do' prose that didn't match reality. dotnet build ZB.MOM.WW.OtOpcUa.slnx: 0 errors, 200 warnings (all xunit1051 cancellation-token analyzer advisories, unchanged from v2 tip). No test regressions -- no source code changed. 2026-04-18 23:20:54 -04:00
be8261a4ac Merge pull request 'Phase 3 PR 60 -- Mitsubishi MELSEC quirk integration tests' (#59) from phase-3-pr60-mitsubishi-quirk-tests into v2 2026-04-18 23:10:36 -04:00
65de2b4a09 Merge pull request 'Phase 3 PR 59 -- MelsecAddress helper with family selector (hex vs octal X/Y)' (#58) from phase-3-pr59-melsec-address-helper into v2 2026-04-18 23:10:29 -04:00
fccb566a30 Merge pull request 'Phase 3 PR 58 -- Mitsubishi MELSEC pymodbus profile + smoke' (#57) from phase-3-pr58-mitsubishi-sim-profile into v2 2026-04-18 23:10:21 -04:00
9ccc7338b8 Merge pull request 'Phase 3 PR 57 -- S7 byte-order + fingerprint integration tests' (#56) from phase-3-pr57-s7-quirk-tests into v2 2026-04-18 23:10:14 -04:00
e33783e042 Merge pull request 'Phase 3 PR 56 -- Siemens S7-1500 pymodbus profile + smoke' (#55) from phase-3-pr56-s7-sim-profile into v2 2026-04-18 23:10:07 -04:00
Joseph Doherty
a44fc7a610 Phase 3 PR 60 -- Mitsubishi MELSEC quirk integration tests against mitsubishi pymodbus profile. Seven facts in MitsubishiQuirkTests covering the quirks documented in docs/v2/mitsubishi.md that are testable end-to-end via pymodbus: (1) Mitsubishi_D0_fingerprint_reads_0x1234 -- MELSEC operators reserve D0 as a fingerprint word so Modbus clients can verify they're hitting the right Device Assignment block; test reads HR[0]=0x1234 via DRegisterToHolding('D0') helper. (2) Mitsubishi_Float32_CDAB_decodes_1_5f_from_D100 -- reads HR[100..101] with WordSwap AND BigEndian; asserts WordSwap==1.5f AND BigEndian!=1.5f, proving (a) MELSEC uses CDAB default same as DL260, (b) opposite of S7 ABCD, (c) driver flag is not a no-op. (3) Mitsubishi_D10_is_binary_not_BCD -- reads HR[10]=0x04D2 as Int16 and asserts value 1234 (binary decode), contrasting with DL205's BCD-by-default convention. (4) Mitsubishi_D10_as_BCD_throws_because_nibble_is_non_decimal -- reads same HR[10] as Bcd16 and asserts StatusCode != 0 because nibble 0xD fails BCD validation; proves the BCD decoder fails loud when the tag config is wrong rather than silently returning garbage. (5) Mitsubishi_QLiQR_X210_hex_maps_to_DI_528_reads_ON -- reads FC02 at the MelsecAddress.XInputToDiscrete('X210', Q_L_iQR)-resolved address (=528 decimal) and asserts ON; proves the hex-parsing path end-to-end. (6) Mitsubishi_family_trap_X20_differs_on_Q_vs_FX -- unit-level proof in the integration file so the headline family trap is visible to anyone filtering by Device=Mitsubishi. (7) Mitsubishi_M512_maps_to_coil_512_reads_ON -- reads FC01 at MRelayToCoil('M512')=512 (decimal) and asserts ON; proves the decimal M-relay path. Test fixture pattern: single MitsubishiQuirkTests class with a shared ShouldRun + NewDriverAsync helper rather than per-quirk classes (contrast with DL205's per-quirk splits). MELSEC per-model differentiation is handled by MelsecFamily enum on the helper rather than per-PR -- so one quirk file + one family enum covers Q/L/iQ-R/FX/iQ-F, and a new PLC family just adds an enum case instead of a new test class. 8/8 Mitsubishi integration tests pass (1 smoke + 7 quirk). 176/176 Modbus.Tests unit suite still green. S7 + DL205 integration tests can be run against their respective profiles by swapping MODBUS_SIM_PROFILE and restarting the pymodbus sim -- each family gates on its profile env var so no cross-family test pollution. 2026-04-18 23:07:00 -04:00
8 changed files with 571 additions and 44 deletions

View File

@@ -9,6 +9,7 @@
<Project Path="src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.csproj"/>
<Project Path="src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy.csproj"/>
<Project Path="src/ZB.MOM.WW.OtOpcUa.Driver.Modbus/ZB.MOM.WW.OtOpcUa.Driver.Modbus.csproj"/>
<Project Path="src/ZB.MOM.WW.OtOpcUa.Driver.S7/ZB.MOM.WW.OtOpcUa.Driver.S7.csproj"/>
<Project Path="src/ZB.MOM.WW.OtOpcUa.Client.Shared/ZB.MOM.WW.OtOpcUa.Client.Shared.csproj"/>
<Project Path="src/ZB.MOM.WW.OtOpcUa.Client.CLI/ZB.MOM.WW.OtOpcUa.Client.CLI.csproj"/>
<Project Path="src/ZB.MOM.WW.OtOpcUa.Client.UI/ZB.MOM.WW.OtOpcUa.Client.UI.csproj"/>
@@ -26,6 +27,7 @@
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.E2E/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.E2E.csproj"/>
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests.csproj"/>
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests.csproj"/>
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests.csproj"/>
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Client.Shared.Tests/ZB.MOM.WW.OtOpcUa.Client.Shared.Tests.csproj"/>
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Client.CLI.Tests/ZB.MOM.WW.OtOpcUa.Client.CLI.Tests.csproj"/>
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Client.UI.Tests/ZB.MOM.WW.OtOpcUa.Client.UI.Tests.csproj"/>

View File

@@ -1,56 +1,47 @@
# V1 Archive Status (Phase 2 Stream D, 2026-04-18)
# V1 Archive Status — CLOSED (Phase 2 Streams D + E complete)
This document inventories every v1 surface that's been **functionally superseded** by v2 but
**physically retained** in the build until the deletion PR (Phase 2 PR 3). Rationale: cascading
references mean a single deletion is high blast-radius; archive-marking lets the v2 stack ship
on its own merits while the v1 surface stays as parity reference.
> **Status as of 2026-04-18: the v1 archive has been fully removed from the tree.**
> This document is retained as historical record of the Phase 2 Stream D / E closure.
## Archived projects
## Final state
| Path | Status | Replaced by | Build behavior |
|---|---|---|---|
| `src/ZB.MOM.WW.OtOpcUa.Host/` | Archive (executable in build) | `OtOpcUa.Server` + `Driver.Galaxy.Host` + `Driver.Galaxy.Proxy` | Builds; not deployed by v2 install scripts |
| `src/ZB.MOM.WW.OtOpcUa.Historian.Aveva/` | Archive (plugin in build) | TODO: port into `Driver.Galaxy.Host/Backend/Historian/` (Task B.1.h follow-up) | Builds; loaded only by archived Host |
| `tests/ZB.MOM.WW.OtOpcUa.Tests.v1Archive/` | Archive | `Driver.Galaxy.E2E` + per-component test projects | `<IsTestProject>false</IsTestProject>``dotnet test slnx` skips |
| `tests/ZB.MOM.WW.OtOpcUa.IntegrationTests/` | Archive | `Driver.Galaxy.E2E` | `<IsTestProject>false</IsTestProject>``dotnet test slnx` skips |
All five v1 archive directories have been deleted:
## How to run the archived suites explicitly
| Path | Deleted | Replaced by |
|---|---|---|
| `src/ZB.MOM.WW.OtOpcUa.Host/` | ✅ | `OtOpcUa.Server` + `Driver.Galaxy.Host` + `Driver.Galaxy.Proxy` |
| `src/ZB.MOM.WW.OtOpcUa.Historian.Aveva/` | ✅ | `Driver.Galaxy.Host/Backend/Historian/` (ported in Phase 3 PRs 51-55) |
| `tests/ZB.MOM.WW.OtOpcUa.Historian.Aveva.Tests/` | ✅ | `Driver.Galaxy.Host.Tests/Historian/` |
| `tests/ZB.MOM.WW.OtOpcUa.Tests.v1Archive/` | ✅ | Per-component `*.Tests` projects + `Driver.Galaxy.E2E` |
| `tests/ZB.MOM.WW.OtOpcUa.IntegrationTests/` | ✅ | `Driver.Galaxy.E2E` + `Driver.Modbus.IntegrationTests` |
```powershell
# v1 unit tests (494):
dotnet test tests/ZB.MOM.WW.OtOpcUa.Tests.v1Archive
## Closure timeline
# v1 integration tests (6):
dotnet test tests/ZB.MOM.WW.OtOpcUa.IntegrationTests
```
- **PR 2 (2026-04-18, phase-2-stream-d)** — archive-marked the four v1 projects with
`<IsTestProject>false</IsTestProject>` so solution builds and `dotnet test slnx` bypassed
them. Capture: `docs/v2/implementation/exit-gate-phase-2-final.md`.
- **Phase 3 PR 18 (2026-04-18)** — deleted the archived project source trees. Leftover
`bin/` and `obj/` residue remained on disk from pre-deletion builds.
- **Phase 2 PR 61 (2026-04-18, this closure PR)** — scrubbed the empty residue directories
and confirmed `dotnet build ZB.MOM.WW.OtOpcUa.slnx` clean with 0 errors.
Both still pass on this dev box — they're the parity reference for Phase 2 PR 3's deletion
decision.
## Parity validation (Stream E)
## Deletion plan (Phase 2 PR 3)
The original 494 v1 tests + 6 v1 integration tests are **not** preserved in the v2 branch.
Their parity-bar role is now filled by:
Pre-conditions:
- [ ] `Driver.Galaxy.E2E` test count covers the v1 IntegrationTests' 6 integration scenarios
at minimum (currently 7 tests; expand as needed)
- [ ] `Driver.Galaxy.Host/Backend/Historian/` ports the Wonderware Historian plugin
so `MxAccessGalaxyBackend.HistoryReadAsync` returns real data (Task B.1.h)
- [ ] Operator review on a separate PR — destructive change
Steps:
1. `git rm -r src/ZB.MOM.WW.OtOpcUa.Host/`
2. `git rm -r src/ZB.MOM.WW.OtOpcUa.Historian.Aveva/`
(or move it under Driver.Galaxy.Host first if the lift is part of the same PR)
3. `git rm -r tests/ZB.MOM.WW.OtOpcUa.Tests.v1Archive/`
4. `git rm -r tests/ZB.MOM.WW.OtOpcUa.IntegrationTests/`
5. Edit `ZB.MOM.WW.OtOpcUa.slnx` — remove the four project lines
6. `dotnet build ZB.MOM.WW.OtOpcUa.slnx` → confirm clean
7. `dotnet test ZB.MOM.WW.OtOpcUa.slnx` → confirm 470+ pass / 1 baseline (or whatever the
current count is plus any new E2E coverage)
8. Commit: "Phase 2 Stream D — delete v1 archive (Host + Historian.Aveva + v1Tests + IntegrationTests)"
9. PR 3 against `v2`, link this doc + exit-gate-phase-2-final.md
10. One reviewer signoff
- `Driver.Galaxy.E2E` — cross-FX subprocess parity (spawns the net48 x86 Galaxy.Host.exe
+ connects via real named pipe, exercises every `IDriver` capability through the
supervisor). Stability-findings regression tests (4 × 2026-04-13 findings) live here.
- Per-component `*.Tests` projects — cover the code that moved out of the monolith into
discrete v2 projects. Running `dotnet test ZB.MOM.WW.OtOpcUa.slnx` executes all of them
as one solution-level gate.
- `Driver.Modbus.IntegrationTests` — adds Modbus TCP driver coverage that didn't exist in
v1 (DL205, S7-1500, Mitsubishi MELSEC via pymodbus sim profiles — PRs 30, 56-60).
- Live-stack smoke tests (`Driver.Galaxy.E2E/LiveStack/`) — optional, gated on presence
of the `OtOpcUaGalaxyHost` service + Galaxy repository on the dev box (PRs 33, 36, 37).
## Rollback
If Phase 2 PR 3 surfaces downstream consumer regressions, `git revert` the deletion commit
restores the four projects intact. The v2 stack continues to ship from the v2 branch.
`git revert` of the deletion commits restores the projects intact. The v2 stack continues
to ship from the `v2` branch regardless.

View File

@@ -0,0 +1,119 @@
using S7.Net;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.S7;
/// <summary>
/// Siemens S7 native driver — speaks S7comm over ISO-on-TCP (port 102) via the S7netplus
/// library. First implementation of <see cref="IDriver"/> for an in-process .NET Standard
/// PLC protocol that is NOT Modbus, validating that the v2 driver-capability interfaces
/// generalize beyond Modbus + Galaxy.
/// </summary>
/// <remarks>
/// <para>
/// PR 62 ships the scaffold: <see cref="IDriver"/> only (Initialize / Reinitialize /
/// Shutdown / GetHealth). <see cref="ITagDiscovery"/>, <see cref="IReadable"/>,
/// <see cref="IWritable"/>, <see cref="ISubscribable"/>, <see cref="IHostConnectivityProbe"/>
/// land in PRs 63-65 once the address parser (PR 63) is in place.
/// </para>
/// <para>
/// <b>Single-connection policy</b>: S7netplus documented pattern is one
/// <c>Plc</c> instance per PLC, serialized with a <see cref="SemaphoreSlim"/>.
/// Parallelising reads against a single S7 CPU doesn't help — the CPU scans the
/// communication mailbox at most once per cycle (2-10 ms) and queues concurrent
/// requests wire-side anyway. Multiple client-side connections just waste the CPU's
/// 8-64 connection-resource budget.
/// </para>
/// </remarks>
public sealed class S7Driver(S7DriverOptions options, string driverInstanceId)
: IDriver, IDisposable, IAsyncDisposable
{
private readonly S7DriverOptions _options = options;
private readonly SemaphoreSlim _gate = new(1, 1);
/// <summary>
/// Per-connection gate. Internal so PRs 63-65 (read/write/subscribe) can serialize on
/// the same semaphore without exposing it publicly. Single-connection-per-PLC is a
/// hard requirement of S7netplus — see class remarks.
/// </summary>
internal SemaphoreSlim Gate => _gate;
/// <summary>
/// Active S7.Net PLC connection. Null until <see cref="InitializeAsync"/> returns; null
/// after <see cref="ShutdownAsync"/>. Read-only outside this class; PR 64's Read/Write
/// will take the <see cref="_gate"/> before touching it.
/// </summary>
internal Plc? Plc { get; private set; }
private DriverHealth _health = new(DriverState.Unknown, null, null);
private bool _disposed;
public string DriverInstanceId => driverInstanceId;
public string DriverType => "S7";
public async Task InitializeAsync(string driverConfigJson, CancellationToken cancellationToken)
{
_health = new DriverHealth(DriverState.Initializing, null, null);
try
{
var plc = new Plc(_options.CpuType, _options.Host, _options.Rack, _options.Slot);
// S7netplus writes timeouts into the underlying TcpClient via Plc.WriteTimeout /
// Plc.ReadTimeout (milliseconds). Set before OpenAsync so the handshake itself
// honours the bound.
plc.WriteTimeout = (int)_options.Timeout.TotalMilliseconds;
plc.ReadTimeout = (int)_options.Timeout.TotalMilliseconds;
using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
cts.CancelAfter(_options.Timeout);
await plc.OpenAsync(cts.Token).ConfigureAwait(false);
Plc = plc;
_health = new DriverHealth(DriverState.Healthy, DateTime.UtcNow, null);
}
catch (Exception ex)
{
// Clean up a partially-constructed Plc so a retry from the caller doesn't leak
// the TcpClient. S7netplus's Close() is best-effort and idempotent.
try { Plc?.Close(); } catch { }
Plc = null;
_health = new DriverHealth(DriverState.Faulted, null, ex.Message);
throw;
}
}
public async Task ReinitializeAsync(string driverConfigJson, CancellationToken cancellationToken)
{
await ShutdownAsync(cancellationToken).ConfigureAwait(false);
await InitializeAsync(driverConfigJson, cancellationToken).ConfigureAwait(false);
}
public Task ShutdownAsync(CancellationToken cancellationToken)
{
try { Plc?.Close(); } catch { /* best-effort — tearing down anyway */ }
Plc = null;
_health = new DriverHealth(DriverState.Unknown, _health.LastSuccessfulRead, null);
return Task.CompletedTask;
}
public DriverHealth GetHealth() => _health;
/// <summary>
/// Approximate memory footprint. The Plc instance + one 240-960 byte PDU buffer is
/// under 4 KB; return 0 because the <see cref="IDriver"/> contract asks for a
/// driver-attributable growth number and S7.Net doesn't expose one.
/// </summary>
public long GetMemoryFootprint() => 0;
public Task FlushOptionalCachesAsync(CancellationToken cancellationToken) => Task.CompletedTask;
public void Dispose() => DisposeAsync().AsTask().GetAwaiter().GetResult();
public async ValueTask DisposeAsync()
{
if (_disposed) return;
_disposed = true;
try { await ShutdownAsync(CancellationToken.None).ConfigureAwait(false); }
catch { /* disposal is best-effort */ }
_gate.Dispose();
}
}

View File

@@ -0,0 +1,112 @@
using S7NetCpuType = global::S7.Net.CpuType;
namespace ZB.MOM.WW.OtOpcUa.Driver.S7;
/// <summary>
/// Siemens S7 native (S7comm / ISO-on-TCP port 102) driver configuration. Bound from the
/// driver's <c>DriverConfig</c> JSON at <c>DriverHost.RegisterAsync</c>. Unlike the Modbus
/// driver the S7 driver uses the PLC's *native* protocol — port 102 ISO-on-TCP rather
/// than Modbus's 502, and S7-specific area codes (DB, M, I, Q) rather than holding-
/// register / coil tables.
/// </summary>
/// <remarks>
/// <para>
/// The driver requires <b>PUT/GET communication enabled</b> in the TIA Portal
/// hardware config for S7-1200/1500. The factory default disables PUT/GET access,
/// so a driver configured against a freshly-flashed CPU will see a hard error
/// (S7.Net surfaces it as <c>Plc.ReadAsync</c> returning <c>ErrorCode.Accessing</c>).
/// The driver maps that specifically to <c>BadNotSupported</c> and flags it as a
/// configuration alert rather than a transient fault — blind Polly retry is wasted
/// effort when the PLC will keep refusing every request.
/// </para>
/// <para>
/// See <c>docs/v2/driver-specs.md</c> §5 for the full specification.
/// </para>
/// </remarks>
public sealed class S7DriverOptions
{
/// <summary>PLC IP address or hostname.</summary>
public string Host { get; init; } = "127.0.0.1";
/// <summary>TCP port. ISO-on-TCP is 102 on every S7 model; override only for unusual NAT setups.</summary>
public int Port { get; init; } = 102;
/// <summary>
/// CPU family. Determines the ISO-TSAP slot byte that S7.Net uses during connection
/// setup — pick the family that matches the target PLC exactly.
/// </summary>
public S7NetCpuType CpuType { get; init; } = S7NetCpuType.S71500;
/// <summary>
/// Hardware rack number. Almost always 0; relevant only for distributed S7-400 racks
/// with multiple CPUs.
/// </summary>
public short Rack { get; init; } = 0;
/// <summary>
/// CPU slot. Conventions per family: S7-300 = slot 2, S7-400 = slot 2 or 3,
/// S7-1200 / S7-1500 = slot 0 (onboard PN). S7.Net uses this to build the remote
/// TSAP. Wrong slot → connection refused during handshake.
/// </summary>
public short Slot { get; init; } = 0;
/// <summary>Connect + per-operation timeout.</summary>
public TimeSpan Timeout { get; init; } = TimeSpan.FromSeconds(5);
/// <summary>Pre-declared tag map. S7 has a symbol-table protocol but S7.Net does not expose it, so the driver operates off a static tag list configured per-site. Address grammar documented in S7AddressParser (PR 63).</summary>
public IReadOnlyList<S7TagDefinition> Tags { get; init; } = [];
/// <summary>
/// Background connectivity-probe settings. When enabled, the driver runs a tick loop
/// that issues a cheap read against <see cref="S7ProbeOptions.ProbeAddress"/> every
/// <see cref="S7ProbeOptions.Interval"/> and raises <c>OnHostStatusChanged</c> on
/// Running ↔ Stopped transitions.
/// </summary>
public S7ProbeOptions Probe { get; init; } = new();
}
public sealed class S7ProbeOptions
{
public bool Enabled { get; init; } = true;
public TimeSpan Interval { get; init; } = TimeSpan.FromSeconds(5);
public TimeSpan Timeout { get; init; } = TimeSpan.FromSeconds(2);
/// <summary>
/// Address to probe for liveness. DB1.DBW0 is the convention if the PLC project
/// reserves a small fingerprint DB for health checks (per <c>docs/v2/s7.md</c>);
/// if not, pick any valid Merker word like <c>MW0</c>.
/// </summary>
public string ProbeAddress { get; init; } = "MW0";
}
/// <summary>
/// One S7 variable as exposed by the driver. Addresses use S7.Net syntax — see
/// <c>S7AddressParser</c> (PR 63) for the grammar.
/// </summary>
/// <param name="Name">Tag name; OPC UA browse name + driver full reference.</param>
/// <param name="Address">S7 address string, e.g. <c>DB1.DBW0</c>, <c>M0.0</c>, <c>I0.0</c>, <c>QD4</c>. Grammar documented in <c>S7AddressParser</c> (PR 63).</param>
/// <param name="DataType">Logical data type — drives the underlying S7.Net read/write width.</param>
/// <param name="Writable">When true the driver accepts writes for this tag.</param>
/// <param name="StringLength">For <c>DataType = String</c>: S7-string max length. Default 254 (S7 max).</param>
public sealed record S7TagDefinition(
string Name,
string Address,
S7DataType DataType,
bool Writable = true,
int StringLength = 254);
public enum S7DataType
{
Bool,
Byte,
Int16,
UInt16,
Int32,
UInt32,
Int64,
UInt64,
Float32,
Float64,
String,
DateTime,
}

View File

@@ -0,0 +1,27 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<LangVersion>latest</LangVersion>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
<NoWarn>$(NoWarn);CS1591</NoWarn>
<RootNamespace>ZB.MOM.WW.OtOpcUa.Driver.S7</RootNamespace>
<AssemblyName>ZB.MOM.WW.OtOpcUa.Driver.S7</AssemblyName>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Core.Abstractions\ZB.MOM.WW.OtOpcUa.Core.Abstractions.csproj"/>
</ItemGroup>
<ItemGroup>
<PackageReference Include="S7netplus" Version="0.20.0"/>
</ItemGroup>
<ItemGroup>
<InternalsVisibleTo Include="ZB.MOM.WW.OtOpcUa.Driver.S7.Tests"/>
</ItemGroup>
</Project>

View File

@@ -0,0 +1,179 @@
using Shouldly;
using Xunit;
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests.Mitsubishi;
/// <summary>
/// Verifies the MELSEC-family Modbus quirks against the <c>mitsubishi.json</c> pymodbus
/// profile: CDAB word order default, binary-not-BCD D-register encoding, hex X-input
/// parsing (Q/L/iQ-R), D0 fingerprint, M-relay coil mapping with bank base.
/// </summary>
/// <remarks>
/// Groups all quirks in one test class instead of per-behavior classes (unlike the DL205
/// set) because MELSEC's per-model differentiation is handled by the
/// <see cref="MelsecFamily"/> enum on the helper + <c>MODBUS_SIM_PROFILE</c> env var on
/// the fixture, rather than per-PR test classes.
/// </remarks>
[Collection(ModbusSimulatorCollection.Name)]
[Trait("Category", "Integration")]
[Trait("Device", "Mitsubishi")]
public sealed class MitsubishiQuirkTests(ModbusSimulatorFixture sim)
{
[Fact]
public async Task Mitsubishi_D0_fingerprint_reads_0x1234()
{
if (!ShouldRun()) return;
await using var driver = await NewDriverAsync(
new ModbusTagDefinition("D0_Fingerprint",
ModbusRegion.HoldingRegisters,
Address: MelsecAddress.DRegisterToHolding("D0"),
DataType: ModbusDataType.UInt16, Writable: false));
var r = await driver.ReadAsync(["D0_Fingerprint"], TestContext.Current.CancellationToken);
r[0].StatusCode.ShouldBe(0u);
r[0].Value.ShouldBe((ushort)0x1234);
}
[Fact]
public async Task Mitsubishi_Float32_CDAB_decodes_1_5f_from_D100()
{
if (!ShouldRun()) return;
// MELSEC Q/L/iQ-R/iQ-F all store 32-bit values with CDAB word order (low word at
// lower D-register address). HR[100..101] = [0, 0x3FC0] decodes as 1.5f under
// WordSwap but as a denormal under BigEndian.
var addr = MelsecAddress.DRegisterToHolding("D100");
await using var driver = await NewDriverAsync(
new ModbusTagDefinition("D100_Float_CDAB",
ModbusRegion.HoldingRegisters, Address: addr,
DataType: ModbusDataType.Float32, Writable: false,
ByteOrder: ModbusByteOrder.WordSwap),
new ModbusTagDefinition("D100_Float_ABCD_control",
ModbusRegion.HoldingRegisters, Address: addr,
DataType: ModbusDataType.Float32, Writable: false,
ByteOrder: ModbusByteOrder.BigEndian));
var r = await driver.ReadAsync(
["D100_Float_CDAB", "D100_Float_ABCD_control"],
TestContext.Current.CancellationToken);
r[0].Value.ShouldBe(1.5f, "MELSEC stores Float32 CDAB; WordSwap decode returns 1.5f");
r[1].Value.ShouldNotBe(1.5f, "same wire with BigEndian must decode to a different value");
}
[Fact]
public async Task Mitsubishi_D10_is_binary_not_BCD()
{
if (!ShouldRun()) return;
// Counter-to-DL205: MELSEC D-registers are binary by default. D10 = 1234 decimal =
// 0x04D2. Reading as Int16 returns 1234; reading as Bcd16 would throw (nibble 0xD is
// non-BCD) — the integration test proves the Int16 decode wins.
await using var driver = await NewDriverAsync(
new ModbusTagDefinition("D10_Binary",
ModbusRegion.HoldingRegisters,
Address: MelsecAddress.DRegisterToHolding("D10"),
DataType: ModbusDataType.Int16, Writable: false));
var r = await driver.ReadAsync(["D10_Binary"], TestContext.Current.CancellationToken);
r[0].StatusCode.ShouldBe(0u);
r[0].Value.ShouldBe((short)1234, "MELSEC stores numeric D-register values in binary; 0x04D2 = 1234");
}
[Fact]
public async Task Mitsubishi_D10_as_BCD_throws_because_nibble_is_non_decimal()
{
if (!ShouldRun()) return;
// If a site configured D10 with Bcd16 data type but the ladder writes binary, the
// BCD decoder MUST reject the garbage rather than silently returning wrong decimal.
// 0x04D2 contains nibble 0xD which fails BCD validation.
await using var driver = await NewDriverAsync(
new ModbusTagDefinition("D10_WrongBcd",
ModbusRegion.HoldingRegisters,
Address: MelsecAddress.DRegisterToHolding("D10"),
DataType: ModbusDataType.Bcd16, Writable: false));
var r = await driver.ReadAsync(["D10_WrongBcd"], TestContext.Current.CancellationToken);
// ReadAsync catches the InvalidDataException from DecodeBcd and surfaces it as
// BadCommunicationError (PR 52 mapping). Non-zero status = caller sees a real
// problem and can check their tag config instead of getting silently-wrong numbers.
r[0].StatusCode.ShouldNotBe(0u, "BCD decode of binary 0x04D2 must fail loudly because nibble D is non-BCD");
}
[Fact]
public async Task Mitsubishi_QLiQR_X210_hex_maps_to_DI_528_reads_ON()
{
if (!ShouldRun()) return;
// MELSEC-Q / L / iQ-R: X addresses are hex. X210 = 0x210 = 528 decimal.
// mitsubishi.json seeds cell 33 (DI 528..543) with value 9 = bit 0 + bit 3 set.
// X210 → DI 528 → cell 33 bit 0 = 1 (ON).
var addr = MelsecAddress.XInputToDiscrete("X210", MelsecFamily.Q_L_iQR);
addr.ShouldBe((ushort)528);
await using var driver = await NewDriverAsync(
new ModbusTagDefinition("X210_hex",
ModbusRegion.DiscreteInputs, Address: addr,
DataType: ModbusDataType.Bool, Writable: false));
var r = await driver.ReadAsync(["X210_hex"], TestContext.Current.CancellationToken);
r[0].StatusCode.ShouldBe(0u);
r[0].Value.ShouldBe(true);
}
[Fact]
public void Mitsubishi_family_trap_X20_differs_on_Q_vs_FX()
{
// Not a live-sim test — a unit-level proof that the MELSEC family selector gates the
// address correctly. Included in the integration suite so anyone running the MELSEC
// tests sees the trap called out explicitly.
MelsecAddress.XInputToDiscrete("X20", MelsecFamily.Q_L_iQR).ShouldBe((ushort)32);
MelsecAddress.XInputToDiscrete("X20", MelsecFamily.F_iQF).ShouldBe((ushort)16);
}
[Fact]
public async Task Mitsubishi_M512_maps_to_coil_512_reads_ON()
{
if (!ShouldRun()) return;
// mitsubishi.json seeds cell 32 (coil 512..527) with value 5 = bit 0 + bit 2 set.
// M512 → coil 512 → cell 32 bit 0 = 1 (ON).
var addr = MelsecAddress.MRelayToCoil("M512");
addr.ShouldBe((ushort)512);
await using var driver = await NewDriverAsync(
new ModbusTagDefinition("M512",
ModbusRegion.Coils, Address: addr,
DataType: ModbusDataType.Bool, Writable: false));
var r = await driver.ReadAsync(["M512"], TestContext.Current.CancellationToken);
r[0].StatusCode.ShouldBe(0u);
r[0].Value.ShouldBe(true);
}
// --- helpers ---
private bool ShouldRun()
{
if (sim.SkipReason is not null) { Assert.Skip(sim.SkipReason); return false; }
if (!string.Equals(Environment.GetEnvironmentVariable("MODBUS_SIM_PROFILE"), "mitsubishi",
StringComparison.OrdinalIgnoreCase))
{
Assert.Skip("MODBUS_SIM_PROFILE != mitsubishi — skipping.");
return false;
}
return true;
}
private async Task<ModbusDriver> NewDriverAsync(params ModbusTagDefinition[] tags)
{
var drv = new ModbusDriver(
new ModbusDriverOptions
{
Host = sim.Host,
Port = sim.Port,
UnitId = 1,
Timeout = TimeSpan.FromSeconds(2),
Tags = tags,
Probe = new ModbusProbeOptions { Enabled = false },
},
driverInstanceId: "melsec-quirk");
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
return drv;
}
}

View File

@@ -0,0 +1,66 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.S7.Tests;
/// <summary>
/// Scaffold-level tests that don't need a live S7 PLC — exercise driver lifecycle shape,
/// default option values, and failure-mode transitions. PR 64 adds IReadable/IWritable
/// tests against a mock-server, PR 65 adds discovery + subscribe.
/// </summary>
[Trait("Category", "Unit")]
public sealed class S7DriverScaffoldTests
{
[Fact]
public void Default_options_target_S7_1500_slot_0_on_port_102()
{
var opts = new S7DriverOptions();
opts.Port.ShouldBe(102, "ISO-on-TCP is always 102 for S7; documented in driver-specs.md §5");
opts.CpuType.ShouldBe(global::S7.Net.CpuType.S71500);
opts.Rack.ShouldBe((short)0);
opts.Slot.ShouldBe((short)0, "S7-1200/1500 onboard PN ports are slot 0 by convention");
}
[Fact]
public void Default_probe_interval_is_reasonable_for_S7_scan_cycle()
{
// S7 PLCs scan 2-10 ms but comms mailbox typically processed once per scan.
// 5 s default probe is lightweight — ~0.001% of comms budget.
new S7ProbeOptions().Interval.ShouldBe(TimeSpan.FromSeconds(5));
}
[Fact]
public void Tag_definition_defaults_to_writable_with_S7_max_string_length()
{
var tag = new S7TagDefinition("T", "DB1.DBW0", S7DataType.Int16);
tag.Writable.ShouldBeTrue();
tag.StringLength.ShouldBe(254, "S7 STRING type max length is 254 chars");
}
[Fact]
public void Driver_instance_reports_type_and_id_before_connect()
{
var opts = new S7DriverOptions { Host = "127.0.0.1" };
using var drv = new S7Driver(opts, "s7-test");
drv.DriverType.ShouldBe("S7");
drv.DriverInstanceId.ShouldBe("s7-test");
drv.GetHealth().State.ShouldBe(DriverState.Unknown, "health starts Unknown until InitializeAsync runs");
}
[Fact]
public async Task Initialize_against_unreachable_host_transitions_to_Faulted_and_throws()
{
// Pick an RFC 5737 reserved-for-documentation IP so the connect attempt fails fast
// (no DNS mismatch, no accidental traffic to a real PLC).
var opts = new S7DriverOptions { Host = "192.0.2.1", Timeout = TimeSpan.FromMilliseconds(250) };
using var drv = new S7Driver(opts, "s7-unreach");
await Should.ThrowAsync<Exception>(async () =>
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken));
var health = drv.GetHealth();
health.State.ShouldBe(DriverState.Faulted, "unreachable host must flip the driver to Faulted so operators see it");
health.LastError.ShouldNotBeNull();
}
}

View File

@@ -0,0 +1,31 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
<RootNamespace>ZB.MOM.WW.OtOpcUa.Driver.S7.Tests</RootNamespace>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="xunit.v3" Version="1.1.0"/>
<PackageReference Include="Shouldly" Version="4.3.0"/>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0"/>
<PackageReference Include="xunit.runner.visualstudio" Version="3.0.2">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\ZB.MOM.WW.OtOpcUa.Driver.S7\ZB.MOM.WW.OtOpcUa.Driver.S7.csproj"/>
</ItemGroup>
<ItemGroup>
<NuGetAuditSuppress Include="https://github.com/advisories/GHSA-37gx-xxp4-5rgx"/>
<NuGetAuditSuppress Include="https://github.com/advisories/GHSA-w3x6-4m5h-cxqf"/>
</ItemGroup>
</Project>