Compare commits
11 Commits
phase-3-pr
...
phase-3-pr
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
394d126b2e | ||
|
|
d5034c40f7 | ||
| 5e67c49f7c | |||
|
|
0575280a3b | ||
| 8150177296 | |||
|
|
56d8af8bdb | ||
| be8261a4ac | |||
| 65de2b4a09 | |||
| fccb566a30 | |||
| 9ccc7338b8 | |||
| e33783e042 |
@@ -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.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.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.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.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.CLI/ZB.MOM.WW.OtOpcUa.Client.CLI.csproj"/>
|
||||||
<Project Path="src/ZB.MOM.WW.OtOpcUa.Client.UI/ZB.MOM.WW.OtOpcUa.Client.UI.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.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.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.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.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.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"/>
|
<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
|
> **Status as of 2026-04-18: the v1 archive has been fully removed from the tree.**
|
||||||
**physically retained** in the build until the deletion PR (Phase 2 PR 3). Rationale: cascading
|
> This document is retained as historical record of the Phase 2 Stream D / E closure.
|
||||||
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.
|
|
||||||
|
|
||||||
## Archived projects
|
## Final state
|
||||||
|
|
||||||
| Path | Status | Replaced by | Build behavior |
|
All five v1 archive directories have been deleted:
|
||||||
|---|---|---|---|
|
|
||||||
| `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 |
|
|
||||||
|
|
||||||
## 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
|
## Closure timeline
|
||||||
# v1 unit tests (494):
|
|
||||||
dotnet test tests/ZB.MOM.WW.OtOpcUa.Tests.v1Archive
|
|
||||||
|
|
||||||
# v1 integration tests (6):
|
- **PR 2 (2026-04-18, phase-2-stream-d)** — archive-marked the four v1 projects with
|
||||||
dotnet test tests/ZB.MOM.WW.OtOpcUa.IntegrationTests
|
`<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
|
## Parity validation (Stream E)
|
||||||
decision.
|
|
||||||
|
|
||||||
## 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` — cross-FX subprocess parity (spawns the net48 x86 Galaxy.Host.exe
|
||||||
- [ ] `Driver.Galaxy.E2E` test count covers the v1 IntegrationTests' 6 integration scenarios
|
+ connects via real named pipe, exercises every `IDriver` capability through the
|
||||||
at minimum (currently 7 tests; expand as needed)
|
supervisor). Stability-findings regression tests (4 × 2026-04-13 findings) live here.
|
||||||
- [ ] `Driver.Galaxy.Host/Backend/Historian/` ports the Wonderware Historian plugin
|
- Per-component `*.Tests` projects — cover the code that moved out of the monolith into
|
||||||
so `MxAccessGalaxyBackend.HistoryReadAsync` returns real data (Task B.1.h)
|
discrete v2 projects. Running `dotnet test ZB.MOM.WW.OtOpcUa.slnx` executes all of them
|
||||||
- [ ] Operator review on a separate PR — destructive change
|
as one solution-level gate.
|
||||||
|
- `Driver.Modbus.IntegrationTests` — adds Modbus TCP driver coverage that didn't exist in
|
||||||
Steps:
|
v1 (DL205, S7-1500, Mitsubishi MELSEC via pymodbus sim profiles — PRs 30, 56-60).
|
||||||
1. `git rm -r src/ZB.MOM.WW.OtOpcUa.Host/`
|
- Live-stack smoke tests (`Driver.Galaxy.E2E/LiveStack/`) — optional, gated on presence
|
||||||
2. `git rm -r src/ZB.MOM.WW.OtOpcUa.Historian.Aveva/`
|
of the `OtOpcUaGalaxyHost` service + Galaxy repository on the dev box (PRs 33, 36, 37).
|
||||||
(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
|
|
||||||
|
|
||||||
## Rollback
|
## Rollback
|
||||||
|
|
||||||
If Phase 2 PR 3 surfaces downstream consumer regressions, `git revert` the deletion commit
|
`git revert` of the deletion commits restores the projects intact. The v2 stack continues
|
||||||
restores the four projects intact. The v2 stack continues to ship from the v2 branch.
|
to ship from the `v2` branch regardless.
|
||||||
|
|||||||
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>
|
||||||
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