Compare commits
14 Commits
phase-3-pr
...
phase-3-pr
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
394d126b2e | ||
|
|
d5034c40f7 | ||
| 5e67c49f7c | |||
|
|
0575280a3b | ||
| 8150177296 | |||
|
|
56d8af8bdb | ||
| be8261a4ac | |||
| 65de2b4a09 | |||
| fccb566a30 | |||
| 9ccc7338b8 | |||
| e33783e042 | |||
|
|
a44fc7a610 | ||
|
|
d4c1873998 | ||
|
|
f52b7d8979 |
@@ -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"/>
|
||||
|
||||
@@ -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.
|
||||
|
||||
161
src/ZB.MOM.WW.OtOpcUa.Driver.Modbus/MelsecAddress.cs
Normal file
161
src/ZB.MOM.WW.OtOpcUa.Driver.Modbus/MelsecAddress.cs
Normal file
@@ -0,0 +1,161 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus;
|
||||
|
||||
/// <summary>
|
||||
/// Mitsubishi MELSEC PLC family selector for address-translation helpers. The Q/L/iQ-R
|
||||
/// families write bit-device addresses (X, Y) in <b>hexadecimal</b> in GX Works and the
|
||||
/// CPU manuals; the FX and iQ-F families write them in <b>octal</b> (same convention as
|
||||
/// AutomationDirect DirectLOGIC). Mixing the two up is the #1 MELSEC driver bug source —
|
||||
/// an operator typing <c>X20</c> into a Q-series tag config means decimal 32, but the
|
||||
/// same string on an FX3U means decimal 16, so the helper must know the family to route
|
||||
/// correctly.
|
||||
/// </summary>
|
||||
public enum MelsecFamily
|
||||
{
|
||||
/// <summary>
|
||||
/// MELSEC-Q / MELSEC-L / MELSEC iQ-R. X and Y device numbers are interpreted as
|
||||
/// <b>hexadecimal</b>; <c>X20</c> means decimal 32.
|
||||
/// </summary>
|
||||
Q_L_iQR,
|
||||
|
||||
/// <summary>
|
||||
/// MELSEC-F (FX3U / FX3GE / FX3G) and MELSEC iQ-F (FX5U). X and Y device numbers
|
||||
/// are interpreted as <b>octal</b> (same as DirectLOGIC); <c>X20</c> means decimal 16.
|
||||
/// iQ-F has a GX Works3 project toggle that can flip to decimal — if a site uses
|
||||
/// that, configure the tag's Address directly as a decimal PDU address and do not
|
||||
/// route through this helper.
|
||||
/// </summary>
|
||||
F_iQF,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Mitsubishi MELSEC address-translation helpers for the QJ71MT91 / LJ71MT91 / RJ71EN71 /
|
||||
/// iQ-R built-in / iQ-F / FX3U-ENET-P502 Modbus modules. MELSEC does NOT hard-wire
|
||||
/// Modbus-to-device mappings like DL260 does — every site configures its own "Modbus
|
||||
/// Device Assignment Parameter" block of up to 16 entries. The helpers here cover only
|
||||
/// the <b>address-notation</b> portion of the translation (hex X20 vs octal X20 + adding
|
||||
/// the bank base); the caller is still responsible for knowing the assignment-block
|
||||
/// offset for their site.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// See <c>docs/v2/mitsubishi.md</c> §device-assignment + §X-Y-hex-trap for the full
|
||||
/// matrix and primary-source citations.
|
||||
/// </remarks>
|
||||
public static class MelsecAddress
|
||||
{
|
||||
/// <summary>
|
||||
/// Translate a MELSEC X-input address (e.g. <c>"X0"</c>, <c>"X10"</c>) to a 0-based
|
||||
/// Modbus discrete-input address, given the PLC family's address notation (hex or
|
||||
/// octal) and the Modbus Device Assignment block's X-range base.
|
||||
/// </summary>
|
||||
/// <param name="xAddress">MELSEC X address. <c>X</c> prefix optional, case-insensitive.</param>
|
||||
/// <param name="family">The PLC family — determines whether the trailing digits are hex or octal.</param>
|
||||
/// <param name="xBankBase">
|
||||
/// 0-based Modbus DI address the assignment-block has configured X0 to land at.
|
||||
/// Typical default on QJ71MT91 sample projects: 0. Pass the site-specific value.
|
||||
/// </param>
|
||||
public static ushort XInputToDiscrete(string xAddress, MelsecFamily family, ushort xBankBase = 0) =>
|
||||
AddFamilyOffset(xBankBase, StripPrefix(xAddress, 'X'), family);
|
||||
|
||||
/// <summary>
|
||||
/// Translate a MELSEC Y-output address to a 0-based Modbus coil address. Same rules
|
||||
/// as <see cref="XInputToDiscrete"/> for hex/octal parsing.
|
||||
/// </summary>
|
||||
public static ushort YOutputToCoil(string yAddress, MelsecFamily family, ushort yBankBase = 0) =>
|
||||
AddFamilyOffset(yBankBase, StripPrefix(yAddress, 'Y'), family);
|
||||
|
||||
/// <summary>
|
||||
/// Translate a MELSEC M-relay address (internal relay) to a 0-based Modbus coil
|
||||
/// address. M-addresses are <b>decimal</b> on every MELSEC family — unlike X/Y which
|
||||
/// are hex on Q/L/iQ-R. Includes the bank base that the assignment-block configured.
|
||||
/// </summary>
|
||||
public static ushort MRelayToCoil(string mAddress, ushort mBankBase = 0)
|
||||
{
|
||||
var digits = StripPrefix(mAddress, 'M');
|
||||
if (!ushort.TryParse(digits, out var offset))
|
||||
throw new ArgumentException(
|
||||
$"M-relay address '{mAddress}' is not a valid decimal integer", nameof(mAddress));
|
||||
var result = mBankBase + offset;
|
||||
if (result > ushort.MaxValue)
|
||||
throw new OverflowException($"M-relay {mAddress} + base {mBankBase} exceeds 0xFFFF");
|
||||
return (ushort)result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Translate a MELSEC D-register address (data register) to a 0-based Modbus holding
|
||||
/// register address. D-addresses are <b>decimal</b>. Default assignment convention is
|
||||
/// D0 → HR 0 (pass <paramref name="dBankBase"/> = 0); sites with shifted layouts pass
|
||||
/// their configured base.
|
||||
/// </summary>
|
||||
public static ushort DRegisterToHolding(string dAddress, ushort dBankBase = 0)
|
||||
{
|
||||
var digits = StripPrefix(dAddress, 'D');
|
||||
if (!ushort.TryParse(digits, out var offset))
|
||||
throw new ArgumentException(
|
||||
$"D-register address '{dAddress}' is not a valid decimal integer", nameof(dAddress));
|
||||
var result = dBankBase + offset;
|
||||
if (result > ushort.MaxValue)
|
||||
throw new OverflowException($"D-register {dAddress} + base {dBankBase} exceeds 0xFFFF");
|
||||
return (ushort)result;
|
||||
}
|
||||
|
||||
private static string StripPrefix(string address, char expectedPrefix)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(address))
|
||||
throw new ArgumentException("Address must not be empty", nameof(address));
|
||||
var s = address.Trim();
|
||||
if (s.Length > 0 && char.ToUpperInvariant(s[0]) == char.ToUpperInvariant(expectedPrefix))
|
||||
s = s.Substring(1);
|
||||
if (s.Length == 0)
|
||||
throw new ArgumentException($"Address '{address}' has no digits after prefix", nameof(address));
|
||||
return s;
|
||||
}
|
||||
|
||||
private static ushort AddFamilyOffset(ushort baseAddr, string digits, MelsecFamily family)
|
||||
{
|
||||
uint offset = family switch
|
||||
{
|
||||
MelsecFamily.Q_L_iQR => ParseHex(digits),
|
||||
MelsecFamily.F_iQF => ParseOctal(digits),
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(family), family, "Unknown MELSEC family"),
|
||||
};
|
||||
var result = baseAddr + offset;
|
||||
if (result > ushort.MaxValue)
|
||||
throw new OverflowException($"Address {baseAddr}+{offset} exceeds 0xFFFF");
|
||||
return (ushort)result;
|
||||
}
|
||||
|
||||
private static uint ParseHex(string digits)
|
||||
{
|
||||
uint result = 0;
|
||||
foreach (var ch in digits)
|
||||
{
|
||||
uint nibble;
|
||||
if (ch >= '0' && ch <= '9') nibble = (uint)(ch - '0');
|
||||
else if (ch >= 'A' && ch <= 'F') nibble = (uint)(ch - 'A' + 10);
|
||||
else if (ch >= 'a' && ch <= 'f') nibble = (uint)(ch - 'a' + 10);
|
||||
else throw new ArgumentException(
|
||||
$"Address contains non-hex digit '{ch}' — Q/L/iQ-R X/Y addresses are hexadecimal",
|
||||
nameof(digits));
|
||||
result = result * 16 + nibble;
|
||||
if (result > ushort.MaxValue)
|
||||
throw new OverflowException($"Hex address exceeds 0xFFFF");
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private static uint ParseOctal(string digits)
|
||||
{
|
||||
uint result = 0;
|
||||
foreach (var ch in digits)
|
||||
{
|
||||
if (ch < '0' || ch > '7')
|
||||
throw new ArgumentException(
|
||||
$"Address contains non-octal digit '{ch}' — FX/iQ-F X/Y addresses are octal (0-7)",
|
||||
nameof(digits));
|
||||
result = result * 8 + (uint)(ch - '0');
|
||||
if (result > ushort.MaxValue)
|
||||
throw new OverflowException($"Octal address exceeds 0xFFFF");
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
216
src/ZB.MOM.WW.OtOpcUa.Driver.S7/S7AddressParser.cs
Normal file
216
src/ZB.MOM.WW.OtOpcUa.Driver.S7/S7AddressParser.cs
Normal file
@@ -0,0 +1,216 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.S7;
|
||||
|
||||
/// <summary>
|
||||
/// Siemens S7 memory area. The driver's tag-address parser maps every S7 tag string into
|
||||
/// exactly one of these + an offset. Values match the on-wire S7 area codes only
|
||||
/// incidentally — S7.Net uses its own <c>DataType</c> enum (<c>DataBlock</c>, <c>Memory</c>,
|
||||
/// <c>Input</c>, <c>Output</c>, <c>Timer</c>, <c>Counter</c>) so the adapter layer translates.
|
||||
/// </summary>
|
||||
public enum S7Area
|
||||
{
|
||||
DataBlock,
|
||||
Memory, // M (Merker / marker byte)
|
||||
Input, // I (process-image input)
|
||||
Output, // Q (process-image output)
|
||||
Timer,
|
||||
Counter,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Access width for a DB / M / I / Q address. Timers and counters are always 16-bit
|
||||
/// opaque (not user-addressable via size suffixes).
|
||||
/// </summary>
|
||||
public enum S7Size
|
||||
{
|
||||
Bit, // X
|
||||
Byte, // B
|
||||
Word, // W — 16-bit
|
||||
DWord, // D — 32-bit
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parsed form of an S7 tag-address string. Produced by <see cref="S7AddressParser.Parse"/>.
|
||||
/// </summary>
|
||||
/// <param name="Area">Memory area (DB, M, I, Q, T, C).</param>
|
||||
/// <param name="DbNumber">Data block number; only meaningful when <paramref name="Area"/> is <see cref="S7Area.DataBlock"/>.</param>
|
||||
/// <param name="Size">Access width. Always <see cref="S7Size.Word"/> for Timer and Counter.</param>
|
||||
/// <param name="ByteOffset">Byte offset into the area (for DB/M/I/Q) or the timer/counter number.</param>
|
||||
/// <param name="BitOffset">Bit position 0-7 when <paramref name="Size"/> is <see cref="S7Size.Bit"/>; 0 otherwise.</param>
|
||||
public readonly record struct S7ParsedAddress(
|
||||
S7Area Area,
|
||||
int DbNumber,
|
||||
S7Size Size,
|
||||
int ByteOffset,
|
||||
int BitOffset);
|
||||
|
||||
/// <summary>
|
||||
/// Parses Siemens S7 address strings into <see cref="S7ParsedAddress"/>. Accepts the
|
||||
/// Siemens TIA-Portal / STEP 7 Classic syntax documented in <c>docs/v2/driver-specs.md</c> §5:
|
||||
/// <list type="bullet">
|
||||
/// <item><c>DB{n}.DB{X|B|W|D}{offset}[.bit]</c> — e.g. <c>DB1.DBX0.0</c>, <c>DB1.DBW0</c>, <c>DB1.DBD4</c></item>
|
||||
/// <item><c>M{B|W|D}{offset}</c> or <c>M{offset}.{bit}</c> — e.g. <c>MB0</c>, <c>MW0</c>, <c>MD4</c>, <c>M0.0</c></item>
|
||||
/// <item><c>I{B|W|D}{offset}</c> or <c>I{offset}.{bit}</c> — e.g. <c>IB0</c>, <c>IW0</c>, <c>ID0</c>, <c>I0.0</c></item>
|
||||
/// <item><c>Q{B|W|D}{offset}</c> or <c>Q{offset}.{bit}</c> — e.g. <c>QB0</c>, <c>QW0</c>, <c>QD0</c>, <c>Q0.0</c></item>
|
||||
/// <item><c>T{n}</c> — e.g. <c>T0</c>, <c>T15</c></item>
|
||||
/// <item><c>C{n}</c> — e.g. <c>C0</c>, <c>C10</c></item>
|
||||
/// </list>
|
||||
/// Grammar is case-insensitive. Leading/trailing whitespace tolerated. Bit specifiers
|
||||
/// must be 0-7; byte offsets must be non-negative; DB numbers must be >= 1.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Parse is deliberately strict — the parser rejects syntactic garbage up-front so a bad
|
||||
/// tag config fails at driver init time instead of surfacing as a misleading
|
||||
/// <c>BadInternalError</c> on every Read against that tag.
|
||||
/// </remarks>
|
||||
public static class S7AddressParser
|
||||
{
|
||||
/// <summary>
|
||||
/// Parse an S7 address. Throws <see cref="FormatException"/> on any syntax error with
|
||||
/// the offending input echoed in the message so operators can correlate to the tag
|
||||
/// config that produced the fault.
|
||||
/// </summary>
|
||||
public static S7ParsedAddress Parse(string address)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(address))
|
||||
throw new FormatException("S7 address must not be empty");
|
||||
var s = address.Trim().ToUpperInvariant();
|
||||
|
||||
// --- DB{n}.DB{X|B|W|D}{offset}[.bit] ---
|
||||
if (s.StartsWith("DB") && TryParseDataBlock(s, out var dbResult))
|
||||
return dbResult;
|
||||
|
||||
if (s.Length < 2)
|
||||
throw new FormatException($"S7 address '{address}' is too short to parse");
|
||||
|
||||
var areaChar = s[0];
|
||||
var rest = s.Substring(1);
|
||||
|
||||
switch (areaChar)
|
||||
{
|
||||
case 'M': return ParseMIQ(S7Area.Memory, rest, address);
|
||||
case 'I': return ParseMIQ(S7Area.Input, rest, address);
|
||||
case 'Q': return ParseMIQ(S7Area.Output, rest, address);
|
||||
case 'T': return ParseTimerOrCounter(S7Area.Timer, rest, address);
|
||||
case 'C': return ParseTimerOrCounter(S7Area.Counter, rest, address);
|
||||
default:
|
||||
throw new FormatException($"S7 address '{address}' starts with unknown area '{areaChar}' (expected DB/M/I/Q/T/C)");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Try-parse variant for callers that can't afford an exception on bad input (e.g.
|
||||
/// config validation pages in the Admin UI). Returns <c>false</c> for any input that
|
||||
/// would throw from <see cref="Parse"/>.
|
||||
/// </summary>
|
||||
public static bool TryParse(string address, out S7ParsedAddress result)
|
||||
{
|
||||
try
|
||||
{
|
||||
result = Parse(address);
|
||||
return true;
|
||||
}
|
||||
catch (FormatException)
|
||||
{
|
||||
result = default;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static bool TryParseDataBlock(string s, out S7ParsedAddress result)
|
||||
{
|
||||
result = default;
|
||||
// Split on first '.': left side must be DB{n}, right side DB{X|B|W|D}{offset}[.bit]
|
||||
var dot = s.IndexOf('.');
|
||||
if (dot < 0) return false;
|
||||
var head = s.Substring(0, dot); // DB{n}
|
||||
var tail = s.Substring(dot + 1); // DB{X|B|W|D}{offset}[.bit]
|
||||
|
||||
if (head.Length < 3) return false;
|
||||
if (!int.TryParse(head.AsSpan(2), out var dbNumber) || dbNumber < 1)
|
||||
throw new FormatException($"S7 DB number in '{s}' must be a positive integer");
|
||||
|
||||
if (!tail.StartsWith("DB") || tail.Length < 4)
|
||||
throw new FormatException($"S7 DB address tail '{tail}' must start with DB{{X|B|W|D}}");
|
||||
|
||||
var sizeChar = tail[2];
|
||||
var offsetStart = 3;
|
||||
var size = sizeChar switch
|
||||
{
|
||||
'X' => S7Size.Bit,
|
||||
'B' => S7Size.Byte,
|
||||
'W' => S7Size.Word,
|
||||
'D' => S7Size.DWord,
|
||||
_ => throw new FormatException($"S7 DB size '{sizeChar}' in '{s}' must be X/B/W/D"),
|
||||
};
|
||||
|
||||
var (byteOffset, bitOffset) = ParseOffsetAndOptionalBit(tail, offsetStart, size, s);
|
||||
result = new S7ParsedAddress(S7Area.DataBlock, dbNumber, size, byteOffset, bitOffset);
|
||||
return true;
|
||||
}
|
||||
|
||||
private static S7ParsedAddress ParseMIQ(S7Area area, string rest, string original)
|
||||
{
|
||||
if (rest.Length == 0)
|
||||
throw new FormatException($"S7 address '{original}' has no offset");
|
||||
|
||||
var first = rest[0];
|
||||
S7Size size;
|
||||
int offsetStart;
|
||||
switch (first)
|
||||
{
|
||||
case 'B': size = S7Size.Byte; offsetStart = 1; break;
|
||||
case 'W': size = S7Size.Word; offsetStart = 1; break;
|
||||
case 'D': size = S7Size.DWord; offsetStart = 1; break;
|
||||
default:
|
||||
// No size prefix => bit-level address requires explicit .bit. Size stays Bit;
|
||||
// ParseOffsetAndOptionalBit will demand the dot.
|
||||
size = S7Size.Bit;
|
||||
offsetStart = 0;
|
||||
break;
|
||||
}
|
||||
|
||||
var (byteOffset, bitOffset) = ParseOffsetAndOptionalBit(rest, offsetStart, size, original);
|
||||
return new S7ParsedAddress(area, DbNumber: 0, size, byteOffset, bitOffset);
|
||||
}
|
||||
|
||||
private static S7ParsedAddress ParseTimerOrCounter(S7Area area, string rest, string original)
|
||||
{
|
||||
if (rest.Length == 0)
|
||||
throw new FormatException($"S7 address '{original}' has no {area} number");
|
||||
if (!int.TryParse(rest, out var number) || number < 0)
|
||||
throw new FormatException($"S7 {area} number in '{original}' must be a non-negative integer");
|
||||
return new S7ParsedAddress(area, DbNumber: 0, S7Size.Word, number, BitOffset: 0);
|
||||
}
|
||||
|
||||
private static (int byteOffset, int bitOffset) ParseOffsetAndOptionalBit(
|
||||
string s, int start, S7Size size, string original)
|
||||
{
|
||||
var offsetEnd = start;
|
||||
while (offsetEnd < s.Length && s[offsetEnd] >= '0' && s[offsetEnd] <= '9')
|
||||
offsetEnd++;
|
||||
if (offsetEnd == start)
|
||||
throw new FormatException($"S7 address '{original}' has no byte-offset digits");
|
||||
|
||||
if (!int.TryParse(s.AsSpan(start, offsetEnd - start), out var byteOffset) || byteOffset < 0)
|
||||
throw new FormatException($"S7 byte offset in '{original}' must be non-negative");
|
||||
|
||||
// No bit-suffix: done unless size is Bit with no prefix, which requires one.
|
||||
if (offsetEnd == s.Length)
|
||||
{
|
||||
if (size == S7Size.Bit)
|
||||
throw new FormatException($"S7 address '{original}' needs a .{{bit}} suffix for bit access");
|
||||
return (byteOffset, 0);
|
||||
}
|
||||
|
||||
if (s[offsetEnd] != '.')
|
||||
throw new FormatException($"S7 address '{original}' has unexpected character after offset");
|
||||
|
||||
if (size != S7Size.Bit)
|
||||
throw new FormatException($"S7 address '{original}' has a bit suffix but the size is {size} — bit access needs X (DB) or no size prefix (M/I/Q)");
|
||||
|
||||
if (!int.TryParse(s.AsSpan(offsetEnd + 1), out var bitOffset) || bitOffset is < 0 or > 7)
|
||||
throw new FormatException($"S7 bit offset in '{original}' must be 0-7");
|
||||
|
||||
return (byteOffset, bitOffset);
|
||||
}
|
||||
}
|
||||
307
src/ZB.MOM.WW.OtOpcUa.Driver.S7/S7Driver.cs
Normal file
307
src/ZB.MOM.WW.OtOpcUa.Driver.S7/S7Driver.cs
Normal file
@@ -0,0 +1,307 @@
|
||||
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, IReadable, IWritable, IDisposable, IAsyncDisposable
|
||||
{
|
||||
/// <summary>OPC UA StatusCode used when the tag name isn't in the driver's tag map.</summary>
|
||||
private const uint StatusBadNodeIdUnknown = 0x80340000u;
|
||||
/// <summary>OPC UA StatusCode used when the tag's data type isn't implemented yet.</summary>
|
||||
private const uint StatusBadNotSupported = 0x803D0000u;
|
||||
/// <summary>OPC UA StatusCode used when the tag is declared read-only.</summary>
|
||||
private const uint StatusBadNotWritable = 0x803B0000u;
|
||||
/// <summary>OPC UA StatusCode used when write fails validation (e.g. out-of-range value).</summary>
|
||||
private const uint StatusBadInternalError = 0x80020000u;
|
||||
/// <summary>OPC UA StatusCode used for socket / timeout / protocol-layer faults.</summary>
|
||||
private const uint StatusBadCommunicationError = 0x80050000u;
|
||||
/// <summary>OPC UA StatusCode used when S7 returns <c>ErrorCode.WrongCPU</c> / PUT/GET disabled.</summary>
|
||||
private const uint StatusBadDeviceFailure = 0x80550000u;
|
||||
|
||||
private readonly Dictionary<string, S7TagDefinition> _tagsByName = new(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly Dictionary<string, S7ParsedAddress> _parsedByName = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
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;
|
||||
|
||||
// Parse every tag's address once at init so config typos fail fast here instead
|
||||
// of surfacing as BadInternalError on every Read against the bad tag. The parser
|
||||
// also rejects bit-offset > 7, DB 0, unknown area letters, etc.
|
||||
_tagsByName.Clear();
|
||||
_parsedByName.Clear();
|
||||
foreach (var t in _options.Tags)
|
||||
{
|
||||
var parsed = S7AddressParser.Parse(t.Address); // throws FormatException
|
||||
_tagsByName[t.Name] = t;
|
||||
_parsedByName[t.Name] = parsed;
|
||||
}
|
||||
|
||||
_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;
|
||||
|
||||
// ---- IReadable ----
|
||||
|
||||
public async Task<IReadOnlyList<DataValueSnapshot>> ReadAsync(
|
||||
IReadOnlyList<string> fullReferences, CancellationToken cancellationToken)
|
||||
{
|
||||
var plc = RequirePlc();
|
||||
var now = DateTime.UtcNow;
|
||||
var results = new DataValueSnapshot[fullReferences.Count];
|
||||
|
||||
await _gate.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
for (var i = 0; i < fullReferences.Count; i++)
|
||||
{
|
||||
var name = fullReferences[i];
|
||||
if (!_tagsByName.TryGetValue(name, out var tag))
|
||||
{
|
||||
results[i] = new DataValueSnapshot(null, StatusBadNodeIdUnknown, null, now);
|
||||
continue;
|
||||
}
|
||||
try
|
||||
{
|
||||
var value = await ReadOneAsync(plc, tag, cancellationToken).ConfigureAwait(false);
|
||||
results[i] = new DataValueSnapshot(value, 0u, now, now);
|
||||
_health = new DriverHealth(DriverState.Healthy, now, null);
|
||||
}
|
||||
catch (NotSupportedException)
|
||||
{
|
||||
results[i] = new DataValueSnapshot(null, StatusBadNotSupported, null, now);
|
||||
}
|
||||
catch (global::S7.Net.PlcException pex)
|
||||
{
|
||||
// S7.Net's PlcException carries an ErrorCode; PUT/GET-disabled on
|
||||
// S7-1200/1500 surfaces here. Map to BadDeviceFailure so operators see a
|
||||
// device-config problem (toggle PUT/GET in TIA Portal) rather than a
|
||||
// transient fault — per driver-specs.md §5.
|
||||
results[i] = new DataValueSnapshot(null, StatusBadDeviceFailure, null, now);
|
||||
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, pex.Message);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
results[i] = new DataValueSnapshot(null, StatusBadCommunicationError, null, now);
|
||||
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, ex.Message);
|
||||
}
|
||||
}
|
||||
}
|
||||
finally { _gate.Release(); }
|
||||
return results;
|
||||
}
|
||||
|
||||
private async Task<object> ReadOneAsync(global::S7.Net.Plc plc, S7TagDefinition tag, CancellationToken ct)
|
||||
{
|
||||
var addr = _parsedByName[tag.Name];
|
||||
// S7.Net's string-based ReadAsync returns object where the boxed .NET type depends on
|
||||
// the size suffix: DBX=bool, DBB=byte, DBW=ushort, DBD=uint. Our S7DataType enum
|
||||
// specifies the SEMANTIC type (Int16 vs UInt16 vs Float32 etc.); the reinterpret below
|
||||
// converts the raw unsigned boxed value into the requested type without issuing an
|
||||
// extra PLC round-trip.
|
||||
var raw = await plc.ReadAsync(tag.Address, ct).ConfigureAwait(false)
|
||||
?? throw new System.IO.InvalidDataException($"S7.Net returned null for '{tag.Address}'");
|
||||
|
||||
return (tag.DataType, addr.Size, raw) switch
|
||||
{
|
||||
(S7DataType.Bool, S7Size.Bit, bool b) => b,
|
||||
(S7DataType.Byte, S7Size.Byte, byte by) => by,
|
||||
(S7DataType.UInt16, S7Size.Word, ushort u16) => u16,
|
||||
(S7DataType.Int16, S7Size.Word, ushort u16) => unchecked((short)u16),
|
||||
(S7DataType.UInt32, S7Size.DWord, uint u32) => u32,
|
||||
(S7DataType.Int32, S7Size.DWord, uint u32) => unchecked((int)u32),
|
||||
(S7DataType.Float32, S7Size.DWord, uint u32) => BitConverter.UInt32BitsToSingle(u32),
|
||||
|
||||
(S7DataType.Int64, _, _) => throw new NotSupportedException("S7 Int64 reads land in a follow-up PR"),
|
||||
(S7DataType.UInt64, _, _) => throw new NotSupportedException("S7 UInt64 reads land in a follow-up PR"),
|
||||
(S7DataType.Float64, _, _) => throw new NotSupportedException("S7 Float64 (LReal) reads land in a follow-up PR"),
|
||||
(S7DataType.String, _, _) => throw new NotSupportedException("S7 STRING reads land in a follow-up PR"),
|
||||
(S7DataType.DateTime, _, _) => throw new NotSupportedException("S7 DateTime reads land in a follow-up PR"),
|
||||
|
||||
_ => throw new System.IO.InvalidDataException(
|
||||
$"S7 Read type-mismatch: tag '{tag.Name}' declared {tag.DataType} but address '{tag.Address}' " +
|
||||
$"parsed as Size={addr.Size}; S7.Net returned {raw.GetType().Name}"),
|
||||
};
|
||||
}
|
||||
|
||||
// ---- IWritable ----
|
||||
|
||||
public async Task<IReadOnlyList<WriteResult>> WriteAsync(
|
||||
IReadOnlyList<WriteRequest> writes, CancellationToken cancellationToken)
|
||||
{
|
||||
var plc = RequirePlc();
|
||||
var results = new WriteResult[writes.Count];
|
||||
|
||||
await _gate.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
for (var i = 0; i < writes.Count; i++)
|
||||
{
|
||||
var w = writes[i];
|
||||
if (!_tagsByName.TryGetValue(w.FullReference, out var tag))
|
||||
{
|
||||
results[i] = new WriteResult(StatusBadNodeIdUnknown);
|
||||
continue;
|
||||
}
|
||||
if (!tag.Writable)
|
||||
{
|
||||
results[i] = new WriteResult(StatusBadNotWritable);
|
||||
continue;
|
||||
}
|
||||
try
|
||||
{
|
||||
await WriteOneAsync(plc, tag, w.Value, cancellationToken).ConfigureAwait(false);
|
||||
results[i] = new WriteResult(0u);
|
||||
}
|
||||
catch (NotSupportedException)
|
||||
{
|
||||
results[i] = new WriteResult(StatusBadNotSupported);
|
||||
}
|
||||
catch (global::S7.Net.PlcException)
|
||||
{
|
||||
results[i] = new WriteResult(StatusBadDeviceFailure);
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
results[i] = new WriteResult(StatusBadInternalError);
|
||||
}
|
||||
}
|
||||
}
|
||||
finally { _gate.Release(); }
|
||||
return results;
|
||||
}
|
||||
|
||||
private async Task WriteOneAsync(global::S7.Net.Plc plc, S7TagDefinition tag, object? value, CancellationToken ct)
|
||||
{
|
||||
// S7.Net's Plc.WriteAsync(string address, object value) expects the boxed value to
|
||||
// match the address's size-suffix type: DBX=bool, DBB=byte, DBW=ushort, DBD=uint.
|
||||
// Our S7DataType lets the caller pass short/int/float; convert to the unsigned
|
||||
// wire representation before handing off.
|
||||
var boxed = tag.DataType switch
|
||||
{
|
||||
S7DataType.Bool => (object)Convert.ToBoolean(value),
|
||||
S7DataType.Byte => (object)Convert.ToByte(value),
|
||||
S7DataType.UInt16 => (object)Convert.ToUInt16(value),
|
||||
S7DataType.Int16 => (object)unchecked((ushort)Convert.ToInt16(value)),
|
||||
S7DataType.UInt32 => (object)Convert.ToUInt32(value),
|
||||
S7DataType.Int32 => (object)unchecked((uint)Convert.ToInt32(value)),
|
||||
S7DataType.Float32 => (object)BitConverter.SingleToUInt32Bits(Convert.ToSingle(value)),
|
||||
|
||||
S7DataType.Int64 => throw new NotSupportedException("S7 Int64 writes land in a follow-up PR"),
|
||||
S7DataType.UInt64 => throw new NotSupportedException("S7 UInt64 writes land in a follow-up PR"),
|
||||
S7DataType.Float64 => throw new NotSupportedException("S7 Float64 (LReal) writes land in a follow-up PR"),
|
||||
S7DataType.String => throw new NotSupportedException("S7 STRING writes land in a follow-up PR"),
|
||||
S7DataType.DateTime => throw new NotSupportedException("S7 DateTime writes land in a follow-up PR"),
|
||||
_ => throw new InvalidOperationException($"Unknown S7DataType {tag.DataType}"),
|
||||
};
|
||||
await plc.WriteAsync(tag.Address, boxed, ct).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private global::S7.Net.Plc RequirePlc() =>
|
||||
Plc ?? throw new InvalidOperationException("S7Driver not initialized");
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
112
src/ZB.MOM.WW.OtOpcUa.Driver.S7/S7DriverOptions.cs
Normal file
112
src/ZB.MOM.WW.OtOpcUa.Driver.S7/S7DriverOptions.cs
Normal 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,
|
||||
}
|
||||
@@ -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>
|
||||
@@ -0,0 +1,43 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests.Mitsubishi;
|
||||
|
||||
/// <summary>
|
||||
/// Tag map for the Mitsubishi MELSEC device class with a representative Modbus Device
|
||||
/// Assignment block mapping D0..D1023 → HR[0..1023]. Mirrors the behaviors in
|
||||
/// <c>mitsubishi.json</c> pymodbus profile and <c>docs/v2/mitsubishi.md</c>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// MELSEC Modbus sites all have *different* device-assignment parameter blocks; this profile
|
||||
/// models the conventional default. Per-model differences (FX5U needs firmware ≥ 1.060 for
|
||||
/// Modbus server; QJ71MT91 lacks FC22/FC23; FX/iQ-F use octal X/Y while Q/L/iQ-R use hex)
|
||||
/// are handled in <see cref="MelsecAddress"/> (PR 59) and the per-model test files.
|
||||
/// </remarks>
|
||||
public static class MitsubishiProfile
|
||||
{
|
||||
/// <summary>
|
||||
/// Scratch HR the smoke test writes + reads. Address 200 mirrors the
|
||||
/// dl205/s7_1500/standard scratch range so one smoke test pattern works across every
|
||||
/// device profile the simulator supports.
|
||||
/// </summary>
|
||||
public const ushort SmokeHoldingRegister = 200;
|
||||
|
||||
/// <summary>Value the smoke test writes then reads back.</summary>
|
||||
public const short SmokeHoldingValue = 7890;
|
||||
|
||||
public static ModbusDriverOptions BuildOptions(string host, int port) => new()
|
||||
{
|
||||
Host = host,
|
||||
Port = port,
|
||||
UnitId = 1,
|
||||
Timeout = TimeSpan.FromSeconds(2),
|
||||
Tags =
|
||||
[
|
||||
new ModbusTagDefinition(
|
||||
Name: "Smoke_HReg200",
|
||||
Region: ModbusRegion.HoldingRegisters,
|
||||
Address: SmokeHoldingRegister,
|
||||
DataType: ModbusDataType.Int16,
|
||||
Writable: true),
|
||||
],
|
||||
Probe = new ModbusProbeOptions { Enabled = false },
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests.Mitsubishi;
|
||||
|
||||
/// <summary>
|
||||
/// End-to-end smoke against the MELSEC <c>mitsubishi.json</c> pymodbus profile (or a real
|
||||
/// MELSEC QJ71MT91 / iQ-R / FX5U when <c>MODBUS_SIM_ENDPOINT</c> points at one). Drives
|
||||
/// the full <see cref="ModbusDriver"/> + real <see cref="ModbusTcpTransport"/> stack.
|
||||
/// Success proves the driver initializes against the MELSEC sim, writes a known value,
|
||||
/// and reads it back — the baseline every Mitsubishi-specific test (PR 59+) builds on.
|
||||
/// </summary>
|
||||
[Collection(ModbusSimulatorCollection.Name)]
|
||||
[Trait("Category", "Integration")]
|
||||
[Trait("Device", "Mitsubishi")]
|
||||
public sealed class MitsubishiSmokeTests(ModbusSimulatorFixture sim)
|
||||
{
|
||||
[Fact]
|
||||
public async Task Mitsubishi_roundtrip_write_then_read_of_holding_register()
|
||||
{
|
||||
if (sim.SkipReason is not null) Assert.Skip(sim.SkipReason);
|
||||
if (!string.Equals(Environment.GetEnvironmentVariable("MODBUS_SIM_PROFILE"), "mitsubishi",
|
||||
StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
Assert.Skip("MODBUS_SIM_PROFILE != mitsubishi — skipping.");
|
||||
}
|
||||
|
||||
var options = MitsubishiProfile.BuildOptions(sim.Host, sim.Port);
|
||||
await using var driver = new ModbusDriver(options, driverInstanceId: "melsec-smoke");
|
||||
await driver.InitializeAsync(driverConfigJson: "{}", TestContext.Current.CancellationToken);
|
||||
|
||||
var writeResults = await driver.WriteAsync(
|
||||
[new(FullReference: "Smoke_HReg200", Value: (short)MitsubishiProfile.SmokeHoldingValue)],
|
||||
TestContext.Current.CancellationToken);
|
||||
writeResults.Count.ShouldBe(1);
|
||||
writeResults[0].StatusCode.ShouldBe(0u, "write must succeed against the MELSEC pymodbus profile");
|
||||
|
||||
var readResults = await driver.ReadAsync(
|
||||
["Smoke_HReg200"],
|
||||
TestContext.Current.CancellationToken);
|
||||
readResults.Count.ShouldBe(1);
|
||||
readResults[0].StatusCode.ShouldBe(0u);
|
||||
readResults[0].Value.ShouldBe((short)MitsubishiProfile.SmokeHoldingValue);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
{
|
||||
"_comment": "mitsubishi.json -- Mitsubishi MELSEC Modbus TCP quirk simulator covering QJ71MT91, iQ-R, iQ-F/FX5U, and FX3U-ENET-P502 behaviors documented in docs/v2/mitsubishi.md. MELSEC CPUs store multi-word values in CDAB order (opposite of S7 ABCD, same family as DL260). The Modbus-module 'Modbus Device Assignment Parameter' block is per-site, so this profile models one *representative* assignment mapping D-register D0..D1023 -> HR 0..1023, M-relay M0..M511 -> coil 0..511, X-input X0..X15 -> DI 0..15 (X-addresses are HEX on Q/L/iQ-R, so X10 = decimal 16; on FX/iQ-F they're OCTAL like DL260). pymodbus bit-address semantics are the same as dl205.json and s7_1500.json (FC01/02/05/15 address N maps to cell index N/16).",
|
||||
|
||||
"server_list": {
|
||||
"srv": {
|
||||
"comm": "tcp",
|
||||
"host": "0.0.0.0",
|
||||
"port": 5020,
|
||||
"framer": "socket",
|
||||
"device_id": 1
|
||||
}
|
||||
},
|
||||
|
||||
"device_list": {
|
||||
"dev": {
|
||||
"setup": {
|
||||
"co size": 4096,
|
||||
"di size": 4096,
|
||||
"hr size": 4096,
|
||||
"ir size": 1024,
|
||||
"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, 0],
|
||||
[10, 10],
|
||||
[100, 101],
|
||||
[200, 209],
|
||||
[300, 301],
|
||||
[500, 500]
|
||||
],
|
||||
|
||||
"uint16": [
|
||||
{"_quirk": "D0 fingerprint marker. MELSEC D0 is the first data register; Modbus Device Assignment typically maps D0..D1023 -> HR 0..1023. 0x1234 is the fingerprint operators set in GX Works to prove the mapping parameter block is in effect.",
|
||||
"addr": 0, "value": 4660},
|
||||
|
||||
{"_quirk": "Scratch HR range 200..209 -- mirrors the dl205/s7_1500/standard scratch range so smoke tests (MitsubishiProfile.SmokeHoldingRegister=200) round-trip identically against any profile.",
|
||||
"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},
|
||||
|
||||
{"_quirk": "Float32 1.5f in CDAB word order (MELSEC Q/L/iQ-R/iQ-F default, same as DL260). HR[100]=0x0000=0 low word, HR[101]=0x3FC0=16320 high word. Decode with ByteOrder.WordSwap returns 1.5f; BigEndian decode returns a denormal.",
|
||||
"addr": 100, "value": 0},
|
||||
{"addr": 101, "value": 16320},
|
||||
|
||||
{"_quirk": "Int32 0x12345678 in CDAB word order. HR[300]=0x5678=22136 low word, HR[301]=0x1234=4660 high word. Contrasts with the S7 profile's ABCD encoding at the same address.",
|
||||
"addr": 300, "value": 22136},
|
||||
{"addr": 301, "value": 4660},
|
||||
|
||||
{"_quirk": "D10 = decimal 1234 stored as BINARY (NOT BCD like DL205). 0x04D2 = 1234 decimal. Caller reading with Bcd16 data type would decode this as binary 1234's BCD nibbles which are non-BCD and throw InvalidDataException -- proves MELSEC is binary-by-default, opposite of DL205's BCD-by-default quirk.",
|
||||
"addr": 10, "value": 1234},
|
||||
|
||||
{"_quirk": "Modbus Device Assignment boundary marker. HR[500] represents the last register in an assigned D-range D500. Beyond this (HR[501..4095]) would be Illegal Data Address on a real QJ71MT91 with this specific parameter block; pymodbus returns default 0 because its shared cell array has space -- real-PLC parity is documented in docs/v2/mitsubishi.md §device-assignment, not enforced here.",
|
||||
"addr": 500, "value": 500}
|
||||
],
|
||||
|
||||
"bits": [
|
||||
{"_quirk": "M-relay marker cell at cell 32 = Modbus coil 512 = MELSEC M512 (coils 0..15 collide with the D0 uint16 marker cell, so we place the M marker above that). Cell 32 bit 0 = 1 and bit 2 = 1 (value = 0b101 = 5) = M512=ON, M513=OFF, M514=ON. Matches the Y0/Y2 marker pattern in dl205 and s7_1500 profiles.",
|
||||
"addr": 32, "value": 5},
|
||||
|
||||
{"_quirk": "X-input marker cell at cell 33 = Modbus DI 528 (= MELSEC X210 hex on Q/L/iQ-R). Cell 33 bit 0 = 1 and bit 3 = 1 (value = 0x9 = 9). Chosen above cell 1 so it doesn't collide with any uint16 D-register. Proves the hex-parsing X-input helper on Q/L/iQ-R family; FX/iQ-F families use octal X-addresses tested separately.",
|
||||
"addr": 33, "value": 9}
|
||||
],
|
||||
|
||||
"uint32": [],
|
||||
"float32": [],
|
||||
"string": [],
|
||||
"repeat": []
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -27,6 +27,7 @@
|
||||
<None Update="Pymodbus\**\*" CopyToOutputDirectory="PreserveNewest"/>
|
||||
<None Update="DL205\**\*" CopyToOutputDirectory="PreserveNewest"/>
|
||||
<None Update="S7\**\*" CopyToOutputDirectory="PreserveNewest"/>
|
||||
<None Update="Mitsubishi\**\*" CopyToOutputDirectory="PreserveNewest"/>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class MelsecAddressTests
|
||||
{
|
||||
// --- X / Y hex vs octal family trap ---
|
||||
|
||||
[Theory]
|
||||
[InlineData("X0", (ushort)0)]
|
||||
[InlineData("X9", (ushort)9)]
|
||||
[InlineData("XA", (ushort)10)] // hex
|
||||
[InlineData("XF", (ushort)15)]
|
||||
[InlineData("X10", (ushort)16)] // hex 0x10 = decimal 16
|
||||
[InlineData("X20", (ushort)32)] // hex 0x20 = decimal 32 — the classic MELSEC-Q trap
|
||||
[InlineData("X1FF", (ushort)511)]
|
||||
[InlineData("x10", (ushort)16)] // lowercase prefix
|
||||
public void XInputToDiscrete_QLiQR_parses_hex(string x, ushort expected)
|
||||
=> MelsecAddress.XInputToDiscrete(x, MelsecFamily.Q_L_iQR).ShouldBe(expected);
|
||||
|
||||
[Theory]
|
||||
[InlineData("X0", (ushort)0)]
|
||||
[InlineData("X7", (ushort)7)]
|
||||
[InlineData("X10", (ushort)8)] // octal 10 = decimal 8
|
||||
[InlineData("X20", (ushort)16)] // octal 20 = decimal 16 — SAME string, DIFFERENT value on FX
|
||||
[InlineData("X777", (ushort)511)]
|
||||
public void XInputToDiscrete_FiQF_parses_octal(string x, ushort expected)
|
||||
=> MelsecAddress.XInputToDiscrete(x, MelsecFamily.F_iQF).ShouldBe(expected);
|
||||
|
||||
[Theory]
|
||||
[InlineData("Y0", (ushort)0)]
|
||||
[InlineData("Y1F", (ushort)31)]
|
||||
public void YOutputToCoil_QLiQR_parses_hex(string y, ushort expected)
|
||||
=> MelsecAddress.YOutputToCoil(y, MelsecFamily.Q_L_iQR).ShouldBe(expected);
|
||||
|
||||
[Theory]
|
||||
[InlineData("Y0", (ushort)0)]
|
||||
[InlineData("Y17", (ushort)15)]
|
||||
public void YOutputToCoil_FiQF_parses_octal(string y, ushort expected)
|
||||
=> MelsecAddress.YOutputToCoil(y, MelsecFamily.F_iQF).ShouldBe(expected);
|
||||
|
||||
[Fact]
|
||||
public void Same_address_string_decodes_differently_between_families()
|
||||
{
|
||||
// This is the headline quirk: "X20" in GX Works means one thing on Q-series and
|
||||
// another on FX-series. The driver's family selector is the only defence.
|
||||
MelsecAddress.XInputToDiscrete("X20", MelsecFamily.Q_L_iQR).ShouldBe((ushort)32);
|
||||
MelsecAddress.XInputToDiscrete("X20", MelsecFamily.F_iQF).ShouldBe((ushort)16);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("X8")] // 8 is non-octal
|
||||
[InlineData("X12G")] // G is non-hex
|
||||
public void XInputToDiscrete_FiQF_rejects_non_octal(string bad)
|
||||
=> Should.Throw<ArgumentException>(() => MelsecAddress.XInputToDiscrete(bad, MelsecFamily.F_iQF));
|
||||
|
||||
[Theory]
|
||||
[InlineData("X12G")]
|
||||
public void XInputToDiscrete_QLiQR_rejects_non_hex(string bad)
|
||||
=> Should.Throw<ArgumentException>(() => MelsecAddress.XInputToDiscrete(bad, MelsecFamily.Q_L_iQR));
|
||||
|
||||
[Fact]
|
||||
public void XInputToDiscrete_honors_bank_base_from_assignment_block()
|
||||
{
|
||||
// Real-world QJ71MT91 assignment blocks commonly place X at DI 8192+ when other
|
||||
// ranges take the low Modbus addresses. Helper must add the base cleanly.
|
||||
MelsecAddress.XInputToDiscrete("X10", MelsecFamily.Q_L_iQR, xBankBase: 8192).ShouldBe((ushort)(8192 + 16));
|
||||
}
|
||||
|
||||
// --- M-relay (decimal, both families) ---
|
||||
|
||||
[Theory]
|
||||
[InlineData("M0", (ushort)0)]
|
||||
[InlineData("M10", (ushort)10)] // M addresses are DECIMAL, not hex or octal
|
||||
[InlineData("M511", (ushort)511)]
|
||||
[InlineData("m99", (ushort)99)] // lowercase
|
||||
public void MRelayToCoil_parses_decimal(string m, ushort expected)
|
||||
=> MelsecAddress.MRelayToCoil(m).ShouldBe(expected);
|
||||
|
||||
[Fact]
|
||||
public void MRelayToCoil_honors_bank_base()
|
||||
=> MelsecAddress.MRelayToCoil("M0", mBankBase: 512).ShouldBe((ushort)512);
|
||||
|
||||
[Fact]
|
||||
public void MRelayToCoil_rejects_non_numeric()
|
||||
=> Should.Throw<ArgumentException>(() => MelsecAddress.MRelayToCoil("M1F"));
|
||||
|
||||
// --- D-register (decimal, both families) ---
|
||||
|
||||
[Theory]
|
||||
[InlineData("D0", (ushort)0)]
|
||||
[InlineData("D100", (ushort)100)]
|
||||
[InlineData("d1023", (ushort)1023)]
|
||||
public void DRegisterToHolding_parses_decimal(string d, ushort expected)
|
||||
=> MelsecAddress.DRegisterToHolding(d).ShouldBe(expected);
|
||||
|
||||
[Fact]
|
||||
public void DRegisterToHolding_honors_bank_base()
|
||||
=> MelsecAddress.DRegisterToHolding("D10", dBankBase: 4096).ShouldBe((ushort)4106);
|
||||
|
||||
[Fact]
|
||||
public void DRegisterToHolding_rejects_empty()
|
||||
=> Should.Throw<ArgumentException>(() => MelsecAddress.DRegisterToHolding("D"));
|
||||
|
||||
// --- overflow ---
|
||||
|
||||
[Fact]
|
||||
public void XInputToDiscrete_overflow_throws()
|
||||
{
|
||||
// 0xFFFF + base 1 = 0x10000 — past ushort.
|
||||
Should.Throw<OverflowException>(() =>
|
||||
MelsecAddress.XInputToDiscrete("XFFFF", MelsecFamily.Q_L_iQR, xBankBase: 1));
|
||||
}
|
||||
}
|
||||
119
tests/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/S7AddressParserTests.cs
Normal file
119
tests/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/S7AddressParserTests.cs
Normal file
@@ -0,0 +1,119 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.S7.Tests;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class S7AddressParserTests
|
||||
{
|
||||
// --- Data blocks ---
|
||||
|
||||
[Theory]
|
||||
[InlineData("DB1.DBX0.0", 1, S7Size.Bit, 0, 0)]
|
||||
[InlineData("DB1.DBX0.7", 1, S7Size.Bit, 0, 7)]
|
||||
[InlineData("DB1.DBB0", 1, S7Size.Byte, 0, 0)]
|
||||
[InlineData("DB1.DBW0", 1, S7Size.Word, 0, 0)]
|
||||
[InlineData("DB1.DBD4", 1, S7Size.DWord, 4, 0)]
|
||||
[InlineData("DB10.DBW100", 10, S7Size.Word, 100, 0)]
|
||||
[InlineData("DB1.DBX15.3", 1, S7Size.Bit, 15, 3)]
|
||||
public void Parse_data_block_addresses(string input, int db, S7Size size, int byteOff, int bitOff)
|
||||
{
|
||||
var r = S7AddressParser.Parse(input);
|
||||
r.Area.ShouldBe(S7Area.DataBlock);
|
||||
r.DbNumber.ShouldBe(db);
|
||||
r.Size.ShouldBe(size);
|
||||
r.ByteOffset.ShouldBe(byteOff);
|
||||
r.BitOffset.ShouldBe(bitOff);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("db1.dbw0", 1, S7Size.Word, 0)]
|
||||
[InlineData(" DB1.DBW0 ", 1, S7Size.Word, 0)] // trim whitespace
|
||||
public void Parse_is_case_insensitive_and_trims(string input, int db, S7Size size, int off)
|
||||
{
|
||||
var r = S7AddressParser.Parse(input);
|
||||
r.Area.ShouldBe(S7Area.DataBlock);
|
||||
r.DbNumber.ShouldBe(db);
|
||||
r.Size.ShouldBe(size);
|
||||
r.ByteOffset.ShouldBe(off);
|
||||
}
|
||||
|
||||
// --- M / I / Q ---
|
||||
|
||||
[Theory]
|
||||
[InlineData("MB0", S7Area.Memory, S7Size.Byte, 0, 0)]
|
||||
[InlineData("MW10", S7Area.Memory, S7Size.Word, 10, 0)]
|
||||
[InlineData("MD4", S7Area.Memory, S7Size.DWord, 4, 0)]
|
||||
[InlineData("M0.0", S7Area.Memory, S7Size.Bit, 0, 0)]
|
||||
[InlineData("M255.7", S7Area.Memory, S7Size.Bit, 255, 7)]
|
||||
[InlineData("IB0", S7Area.Input, S7Size.Byte, 0, 0)]
|
||||
[InlineData("IW0", S7Area.Input, S7Size.Word, 0, 0)]
|
||||
[InlineData("I0.0", S7Area.Input, S7Size.Bit, 0, 0)]
|
||||
[InlineData("QB0", S7Area.Output, S7Size.Byte, 0, 0)]
|
||||
[InlineData("QW0", S7Area.Output, S7Size.Word, 0, 0)]
|
||||
[InlineData("Q0.0", S7Area.Output, S7Size.Bit, 0, 0)]
|
||||
[InlineData("QD4", S7Area.Output, S7Size.DWord, 4, 0)]
|
||||
public void Parse_MIQ_addresses(string input, S7Area area, S7Size size, int byteOff, int bitOff)
|
||||
{
|
||||
var r = S7AddressParser.Parse(input);
|
||||
r.Area.ShouldBe(area);
|
||||
r.DbNumber.ShouldBe(0);
|
||||
r.Size.ShouldBe(size);
|
||||
r.ByteOffset.ShouldBe(byteOff);
|
||||
r.BitOffset.ShouldBe(bitOff);
|
||||
}
|
||||
|
||||
// --- Timers / counters ---
|
||||
|
||||
[Theory]
|
||||
[InlineData("T0", S7Area.Timer, 0)]
|
||||
[InlineData("T15", S7Area.Timer, 15)]
|
||||
[InlineData("C0", S7Area.Counter, 0)]
|
||||
[InlineData("C10", S7Area.Counter, 10)]
|
||||
public void Parse_timer_and_counter(string input, S7Area area, int number)
|
||||
{
|
||||
var r = S7AddressParser.Parse(input);
|
||||
r.Area.ShouldBe(area);
|
||||
r.ByteOffset.ShouldBe(number);
|
||||
r.Size.ShouldBe(S7Size.Word, "timers + counters are 16-bit opaque");
|
||||
}
|
||||
|
||||
// --- Reject garbage ---
|
||||
|
||||
[Theory]
|
||||
[InlineData("")]
|
||||
[InlineData(" ")]
|
||||
[InlineData("Z0")] // unknown area
|
||||
[InlineData("DB")] // no number or tail
|
||||
[InlineData("DB1")] // no tail
|
||||
[InlineData("DB1.")] // empty tail
|
||||
[InlineData("DB1.DBX0")] // bit size without .bit
|
||||
[InlineData("DB1.DBX0.8")] // bit 8 out of range
|
||||
[InlineData("DB1.DBW0.0")] // word with bit suffix
|
||||
[InlineData("DB0.DBW0")] // db 0 invalid
|
||||
[InlineData("DBA.DBW0")] // non-numeric db
|
||||
[InlineData("DB1.DBQ0")] // invalid size letter
|
||||
[InlineData("M")] // no offset
|
||||
[InlineData("M0")] // bit access needs .bit
|
||||
[InlineData("M0.8")] // bit 8
|
||||
[InlineData("MB-1")] // negative offset
|
||||
[InlineData("MW")] // no offset digits
|
||||
[InlineData("TA")] // non-numeric timer
|
||||
public void Parse_rejects_invalid(string bad)
|
||||
=> Should.Throw<FormatException>(() => S7AddressParser.Parse(bad));
|
||||
|
||||
[Fact]
|
||||
public void TryParse_returns_false_for_garbage_without_throwing()
|
||||
{
|
||||
S7AddressParser.TryParse("not-an-address", out var r).ShouldBeFalse();
|
||||
r.ShouldBe(default);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryParse_returns_true_for_valid_address()
|
||||
{
|
||||
S7AddressParser.TryParse("DB1.DBW0", out var r).ShouldBeTrue();
|
||||
r.DbNumber.ShouldBe(1);
|
||||
r.Size.ShouldBe(S7Size.Word);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.S7.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for <see cref="S7Driver"/>'s <c>IReadable</c>/<c>IWritable</c> surface
|
||||
/// that don't require a live PLC — covers error paths (not-initialized, unknown tag,
|
||||
/// read-only write rejection, unsupported data types). Wire-level round-trip tests
|
||||
/// against a live S7 or a mock-server land in a follow-up PR since S7.Net doesn't ship
|
||||
/// an in-process fake and an adequate mock is non-trivial.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class S7DriverReadWriteTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task Initialize_rejects_invalid_tag_address_and_fails_fast()
|
||||
{
|
||||
// Bad address at init time must throw; the alternative (deferring the parse to the
|
||||
// first read) would surface the config bug as BadInternalError on every subsequent
|
||||
// Read which is impossible for an operator to diagnose from the OPC UA client.
|
||||
var opts = new S7DriverOptions
|
||||
{
|
||||
Host = "192.0.2.1", // reserved — will never complete TCP handshake
|
||||
Timeout = TimeSpan.FromMilliseconds(250),
|
||||
Tags = [new S7TagDefinition("BadTag", "NOT-AN-S7-ADDRESS", S7DataType.Int16)],
|
||||
};
|
||||
using var drv = new S7Driver(opts, "s7-bad-tag");
|
||||
|
||||
// Either the TCP connect fails first (Exception) or the parser fails (FormatException)
|
||||
// — both are acceptable since both are init-time fail-fast. What matters is that we
|
||||
// don't return a "healthy" driver with a latent bad tag.
|
||||
await Should.ThrowAsync<Exception>(async () =>
|
||||
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReadAsync_without_initialize_throws_InvalidOperationException()
|
||||
{
|
||||
using var drv = new S7Driver(new S7DriverOptions { Host = "192.0.2.1" }, "s7-uninit");
|
||||
await Should.ThrowAsync<InvalidOperationException>(async () =>
|
||||
await drv.ReadAsync(["Any"], TestContext.Current.CancellationToken));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task WriteAsync_without_initialize_throws_InvalidOperationException()
|
||||
{
|
||||
using var drv = new S7Driver(new S7DriverOptions { Host = "192.0.2.1" }, "s7-uninit");
|
||||
await Should.ThrowAsync<InvalidOperationException>(async () =>
|
||||
await drv.WriteAsync(
|
||||
[new(FullReference: "Any", Value: (short)0)],
|
||||
TestContext.Current.CancellationToken));
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user