Compare commits

...

6 Commits

Author SHA1 Message Date
Joseph Doherty
d8ef35d5bd Phase 3 PR 65 -- S7 ITagDiscovery + ISubscribable polling overlay + IHostConnectivityProbe. Three more capability interfaces on S7Driver, matching the Modbus driver's capability coverage. ITagDiscovery: DiscoverAsync streams every configured tag into IAddressSpaceBuilder under a single 'S7' folder; builder.Variable gets a DriverAttributeInfo carrying DriverDataType (MapDataType: Bool->Boolean, Byte/Int/UInt sizes->Int32 (until Core.Abstractions adds widths), Float32/Float64 direct, String + DateTime direct), SecurityClass (Operate if tag.Writable else ViewOnly -- matches the Modbus pattern so DriverNodeManager's ACL layer can gate writes per role without S7-specific logic), IsHistorized=false (S7 has no native historian surface), IsAlarm=false (S7 alarms land through TIA Portal's alarm-in-DB pattern which is per-site and out of scope for PR 65). ISubscribable polling overlay: same pattern Modbus established in PR 22. SubscribeAsync spawns a Task.Run loop that polls every tag, diffs against LastValues, raises OnDataChange on changes plus a force-raise on initial-data push per OPC UA Part 4 convention. Interval floored at 100ms -- S7 CPUs scan 2-10ms but process the comms mailbox at most once per scan, so sub-scan polling just queues wire-side with worse latency per S7netplus documented pattern. Poll errors tolerated: first-read fault doesn't kill the loop (caller can't receive initial values but subsequent polls try again); transient poll errors also swallowed so the loop survives a power-cycle + reconnect through the health surface. UnsubscribeAsync cancels the CTS + removes the subscription -- unknown handle is a no-op, not a throw, because the caller's race with server-side cleanup shouldn't crash either side. Shutdown tears down every subscription before disposing the Plc. IHostConnectivityProbe: HostName surfaced as host:port to match Modbus driver convention (Admin /hosts dashboard renders both families uniformly). GetHostStatuses returns one row (single-endpoint driver). ProbeLoopAsync serializes on the shared Gate + calls Plc.ReadStatusAsync (cheap Get-CPU-Status PDU that doubles as an 'is PLC up' check) every Probe.Interval with a Probe.Timeout cap, transitions HostState Unknown/Stopped -> Running on success and -> Stopped on any failure, raises OnHostStatusChanged only on actual transitions (no noise for steady-state probes). Probe loop starts at end of InitializeAsync when Probe.Enabled=true (default); Shutdown cancels the probe CTS. Initial state stays Unknown until first successful probe -- avoids broadcasting a premature Running before any PDU round-trip has happened. Unit tests (S7DiscoveryAndSubscribeTests, 4 facts): DiscoverAsync_projects_every_tag_into_the_address_space (3 tags + mixed writable/read-only -> Operate vs ViewOnly asserted), GetHostStatuses_returns_one_row_with_host_port_identity_pre_init, SubscribeAsync_returns_unique_handles_and_UnsubscribeAsync_accepts_them (diagnosticId uniqueness + idempotent double-unsubscribe), Subscribe_publishing_interval_is_floored_at_100ms (accepts 50ms request without throwing -- floor is applied internally). Uses a RecordingAddressSpaceBuilder stub that implements IVariableHandle.FullReference + MarkAsAlarmCondition (throws NotImplementedException since the S7 driver never calls it -- alarms out of scope). 57/57 S7 unit tests pass. dotnet build clean. All 5 capability interfaces (IDriver/ITagDiscovery/IReadable/IWritable/ISubscribable/IHostConnectivityProbe) now implemented -- the S7 driver surface is on par with the Modbus driver, minus the extended data types (Int64/UInt64/Float64/String/DateTime deferred per PR 64). 2026-04-19 00:16:10 -04:00
5e318a1ab6 Merge pull request 'Phase 3 PR 64 -- S7 IReadable + IWritable via S7.Net' (#63) from phase-3-pr64-s7-read-write into v2 2026-04-19 00:12:59 -04:00
Joseph Doherty
394d126b2e Phase 3 PR 64 -- S7 IReadable + IWritable via S7.Net string-based Plc.ReadAsync/WriteAsync. Adds IReadable + IWritable capability interfaces to S7Driver, routing reads/writes through S7netplus's string-address API (Plc.ReadAsync(string, ct) / Plc.WriteAsync(string, object, ct)). All operations serialize on the class's SemaphoreSlim Gate because S7netplus mandates one Plc connection per PLC with client-side serialization -- parallel reads against a single S7 CPU queue wire-side anyway and just eat connection-resource budget. Supported data types in this PR: Bool, Byte, Int16, UInt16, Int32, UInt32, Float32. S7.Net's string-based read returns UNSIGNED boxed values (DBX=bool, DBB=byte, DBW=ushort, DBD=uint); the driver reinterprets them into the requested S7DataType via the (DataType, Size, raw) switch: unchecked short-cast for Int16, unchecked int-cast for Int32, BitConverter.UInt32BitsToSingle for Float32. Writes inverse the conversion -- Int16 -> unchecked ushort cast, Int32 -> unchecked uint cast, Float32 -> BitConverter.SingleToUInt32Bits -- before handing to S7.Net's WriteAsync. This avoids a second PLC round-trip that a typed ReadAsync(DataType, db, offset, VarType, ...) overload would need. Int64, UInt64, Float64, String, DateTime throw NotSupportedException (-> BadNotSupported StatusCode); S7 STRING has non-trivial header semantics + LReal/DateTime need typed S7.Net API paths, both land in a follow-up PR when scope demands. InitializeAsync now parses every tag's Address string via S7AddressParser at init time. Bad addresses throw FormatException and flip health to Faulted -- callers can't register a broken driver. The parsed form goes into _parsedByName so Read/Write can consult Size/BitOffset without re-parsing per operation. StatusCode mapping in catch chain: unknown tag name -> BadNodeIdUnknown (0x80340000), unsupported data type -> BadNotSupported (0x803D0000), read-only tag write attempt -> BadNotWritable (0x803B0000), S7.Net PlcException (carries PUT/GET-disabled signal on S7-1200/1500) -> BadDeviceFailure (0x80550000) so operators see a TIA-Portal config problem rather than a transient-fault false flag per driver-specs.md \u00A75, any other runtime exception on read -> BadCommunicationError (0x80050000) to distinguish socket/timeout from tag-level faults. Write generic-exception path stays BadInternalError because write failures can legitimately be driver-side value-range problems. Unit tests (S7DriverReadWriteTests, 3 facts): Initialize_rejects_invalid_tag_address_and_fails_fast -- Tags with a malformed address must throw at InitializeAsync rather than producing a half-healthy driver; ReadAsync_without_initialize_throws_InvalidOperationException + WriteAsync_without_initialize_throws_InvalidOperationException -- pre-init calls hit RequirePlc and throw the uniform 'not initialized' message. Wire-level round-trip coverage (integration test against a live S7-1500 or a mock S7 server) is deferred -- S7.Net doesn't ship an in-process fake and a conformant mock is non-trivial. 53/53 Modbus.Driver.S7.Tests pass (50 parser + 3 read/write). dotnet build clean. 2026-04-19 00:10:41 -04:00
0eab1271be Merge pull request 'Phase 3 PR 63 -- S7AddressParser (DB/M/I/Q/T/C grammar)' (#62) from phase-3-pr63-s7-address-parser into v2 2026-04-19 00:08:27 -04:00
Joseph Doherty
d5034c40f7 Phase 3 PR 63 -- S7AddressParser for DB/M/I/Q/T/C address strings. Adds S7AddressParser + S7ParsedAddress + S7Area + S7Size to the Driver.S7 project. Grammar follows driver-specs.md \u00A75 + Siemens TIA Portal / STEP 7 Classic convention: (1) Data blocks: DB{n}.DB{X|B|W|D}{offset}[.bit] where X=bit (requires .bit suffix 0-7), B=byte, W=word (16-bit), D=dword (32-bit). (2) Merkers: MB{n}, MW{n}, MD{n}, or M{n}.{bit} for bit access. (3) Inputs + Outputs: same {B|W|D} prefix or {n}.{bit} pattern as M. (4) Timers: T{n}. (5) Counters: C{n}. Output is an immutable S7ParsedAddress record struct with Area (DataBlock / Memory / Input / Output / Timer / Counter), DbNumber (only meaningful for DataBlock), Size (Bit / Byte / Word / DWord), ByteOffset (also timer/counter number when Area is Timer/Counter), BitOffset (0-7 for Size=Bit; 0 otherwise). Case-insensitive via ToUpperInvariant, whitespace trimmed on entry. Parse throws FormatException with the offending input echoed in the message; TryParse returns bool for config-validation callers that can't afford exceptions (e.g. Admin UI tag-editor live validation). Strict rejection policy -- 16 garbage cases covered in the theory test: empty/whitespace input, unknown area letter (Z0), DB without number/tail, DB bit size without .bit suffix, bit offset 8+, word/dword with .bit suffix, DB number 0 (must be >=1), non-numeric DB number, unknown size letter (Q), M without offset, M bit access without .bit, bit 8, negative offset, non-digit offset, non-numeric timer. Strict rejection surfaces config errors at driver-init time rather than as BadInternalError on every Read against the bad tag. No driver code wires through yet -- PR 64 is where IReadable/IWritable consume S7ParsedAddress and translate into S7netplus Plc.ReadAsync calls (the S7.Net address grammar is a strict subset of what we accept, and the parser's S7ParsedAddress is the bridge). Unit tests (S7AddressParserTests, 50 facts): parse-valid theories for DB/M/I/Q/T/C covering all size variants + edge bit offsets 0 and 7; case-insensitive + whitespace-trim theory; reject-invalid theory with 16 garbage cases; TryParse round-trip for valid and invalid inputs. 50/50 pass, dotnet build clean. 2026-04-19 00:06:24 -04:00
5e67c49f7c Merge pull request 'Phase 3 PR 62 -- Siemens S7 native driver project scaffold' (#61) from phase-3-pr62-s7-driver-scaffold into v2 2026-04-19 00:05:17 -04:00
5 changed files with 901 additions and 1 deletions

View 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 &gt;= 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);
}
}

View File

@@ -26,8 +26,36 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.S7;
/// </para>
/// </remarks>
public sealed class S7Driver(S7DriverOptions options, string driverInstanceId)
: IDriver, IDisposable, IAsyncDisposable
: IDriver, ITagDiscovery, IReadable, IWritable, ISubscribable, IHostConnectivityProbe, IDisposable, IAsyncDisposable
{
// ---- ISubscribable + IHostConnectivityProbe state ----
private readonly System.Collections.Concurrent.ConcurrentDictionary<long, SubscriptionState> _subscriptions = new();
private long _nextSubscriptionId;
private readonly object _probeLock = new();
private HostState _hostState = HostState.Unknown;
private DateTime _hostStateChangedUtc = DateTime.UtcNow;
private CancellationTokenSource? _probeCts;
public event EventHandler<DataChangeEventArgs>? OnDataChange;
public event EventHandler<HostStatusChangedEventArgs>? OnHostStatusChanged;
/// <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);
@@ -68,7 +96,29 @@ public sealed class S7Driver(S7DriverOptions options, string driverInstanceId)
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);
// Kick off the probe loop once the connection is up. Initial HostState stays
// Unknown until the first probe tick succeeds — avoids broadcasting a premature
// Running transition before any PDU round-trip has happened.
if (_options.Probe.Enabled)
{
_probeCts = new CancellationTokenSource();
_ = Task.Run(() => ProbeLoopAsync(_probeCts.Token), _probeCts.Token);
}
}
catch (Exception ex)
{
@@ -89,6 +139,17 @@ public sealed class S7Driver(S7DriverOptions options, string driverInstanceId)
public Task ShutdownAsync(CancellationToken cancellationToken)
{
try { _probeCts?.Cancel(); } catch { }
_probeCts?.Dispose();
_probeCts = null;
foreach (var state in _subscriptions.Values)
{
try { state.Cts.Cancel(); } catch { }
state.Cts.Dispose();
}
_subscriptions.Clear();
try { Plc?.Close(); } catch { /* best-effort — tearing down anyway */ }
Plc = null;
_health = new DriverHealth(DriverState.Unknown, _health.LastSuccessfulRead, null);
@@ -106,6 +167,339 @@ public sealed class S7Driver(S7DriverOptions options, string driverInstanceId)
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");
// ---- ITagDiscovery ----
public Task DiscoverAsync(IAddressSpaceBuilder builder, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(builder);
var folder = builder.Folder("S7", "S7");
foreach (var t in _options.Tags)
{
folder.Variable(t.Name, t.Name, new DriverAttributeInfo(
FullName: t.Name,
DriverDataType: MapDataType(t.DataType),
IsArray: false,
ArrayDim: null,
SecurityClass: t.Writable ? SecurityClassification.Operate : SecurityClassification.ViewOnly,
IsHistorized: false,
IsAlarm: false));
}
return Task.CompletedTask;
}
private static DriverDataType MapDataType(S7DataType t) => t switch
{
S7DataType.Bool => DriverDataType.Boolean,
S7DataType.Byte => DriverDataType.Int32, // no 8-bit in DriverDataType yet
S7DataType.Int16 or S7DataType.UInt16 or S7DataType.Int32 or S7DataType.UInt32 => DriverDataType.Int32,
S7DataType.Int64 or S7DataType.UInt64 => DriverDataType.Int32, // widens; lossy for >2^31-1
S7DataType.Float32 => DriverDataType.Float32,
S7DataType.Float64 => DriverDataType.Float64,
S7DataType.String => DriverDataType.String,
S7DataType.DateTime => DriverDataType.DateTime,
_ => DriverDataType.Int32,
};
// ---- ISubscribable (polling overlay) ----
public Task<ISubscriptionHandle> SubscribeAsync(
IReadOnlyList<string> fullReferences, TimeSpan publishingInterval, CancellationToken cancellationToken)
{
var id = Interlocked.Increment(ref _nextSubscriptionId);
var cts = new CancellationTokenSource();
// Floor at 100 ms — S7 CPUs scan 2-10 ms but the comms mailbox is processed at most
// once per scan; sub-100 ms polling just queues wire-side with worse latency.
var interval = publishingInterval < TimeSpan.FromMilliseconds(100)
? TimeSpan.FromMilliseconds(100)
: publishingInterval;
var handle = new S7SubscriptionHandle(id);
var state = new SubscriptionState(handle, [.. fullReferences], interval, cts);
_subscriptions[id] = state;
_ = Task.Run(() => PollLoopAsync(state, cts.Token), cts.Token);
return Task.FromResult<ISubscriptionHandle>(handle);
}
public Task UnsubscribeAsync(ISubscriptionHandle handle, CancellationToken cancellationToken)
{
if (handle is S7SubscriptionHandle h && _subscriptions.TryRemove(h.Id, out var state))
{
state.Cts.Cancel();
state.Cts.Dispose();
}
return Task.CompletedTask;
}
private async Task PollLoopAsync(SubscriptionState state, CancellationToken ct)
{
// Initial-data push per OPC UA Part 4 convention.
try { await PollOnceAsync(state, forceRaise: true, ct).ConfigureAwait(false); }
catch (OperationCanceledException) { return; }
catch { /* first-read error — polling continues */ }
while (!ct.IsCancellationRequested)
{
try { await Task.Delay(state.Interval, ct).ConfigureAwait(false); }
catch (OperationCanceledException) { return; }
try { await PollOnceAsync(state, forceRaise: false, ct).ConfigureAwait(false); }
catch (OperationCanceledException) { return; }
catch { /* transient polling error — loop continues, health surface reflects it */ }
}
}
private async Task PollOnceAsync(SubscriptionState state, bool forceRaise, CancellationToken ct)
{
var snapshots = await ReadAsync(state.TagReferences, ct).ConfigureAwait(false);
for (var i = 0; i < state.TagReferences.Count; i++)
{
var tagRef = state.TagReferences[i];
var current = snapshots[i];
var lastSeen = state.LastValues.TryGetValue(tagRef, out var prev) ? prev : default;
if (forceRaise || !Equals(lastSeen?.Value, current.Value) || lastSeen?.StatusCode != current.StatusCode)
{
state.LastValues[tagRef] = current;
OnDataChange?.Invoke(this, new DataChangeEventArgs(state.Handle, tagRef, current));
}
}
}
private sealed record SubscriptionState(
S7SubscriptionHandle Handle,
IReadOnlyList<string> TagReferences,
TimeSpan Interval,
CancellationTokenSource Cts)
{
public System.Collections.Concurrent.ConcurrentDictionary<string, DataValueSnapshot> LastValues { get; }
= new(StringComparer.OrdinalIgnoreCase);
}
private sealed record S7SubscriptionHandle(long Id) : ISubscriptionHandle
{
public string DiagnosticId => $"s7-sub-{Id}";
}
// ---- IHostConnectivityProbe ----
/// <summary>
/// Host identifier surfaced in <see cref="GetHostStatuses"/>. <c>host:port</c> format
/// matches the Modbus driver's convention so the Admin UI dashboard renders both
/// family's rows uniformly.
/// </summary>
public string HostName => $"{_options.Host}:{_options.Port}";
public IReadOnlyList<HostConnectivityStatus> GetHostStatuses()
{
lock (_probeLock)
return [new HostConnectivityStatus(HostName, _hostState, _hostStateChangedUtc)];
}
private async Task ProbeLoopAsync(CancellationToken ct)
{
while (!ct.IsCancellationRequested)
{
var success = false;
try
{
// Probe via S7.Net's low-cost GetCpuStatus — returns the CPU state (Run/Stop)
// and is intentionally light on the comms mailbox. Single-word Plc.ReadAsync
// would also work but GetCpuStatus doubles as a "PLC actually up" check.
using var probeCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
probeCts.CancelAfter(_options.Probe.Timeout);
var plc = Plc;
if (plc is null) throw new InvalidOperationException("Plc dropped during probe");
await _gate.WaitAsync(probeCts.Token).ConfigureAwait(false);
try
{
_ = await plc.ReadStatusAsync(probeCts.Token).ConfigureAwait(false);
success = true;
}
finally { _gate.Release(); }
}
catch (OperationCanceledException) when (ct.IsCancellationRequested) { return; }
catch { /* transport/timeout/exception — treated as Stopped below */ }
TransitionTo(success ? HostState.Running : HostState.Stopped);
try { await Task.Delay(_options.Probe.Interval, ct).ConfigureAwait(false); }
catch (OperationCanceledException) { return; }
}
}
private void TransitionTo(HostState newState)
{
HostState old;
lock (_probeLock)
{
old = _hostState;
if (old == newState) return;
_hostState = newState;
_hostStateChangedUtc = DateTime.UtcNow;
}
OnHostStatusChanged?.Invoke(this, new HostStatusChangedEventArgs(HostName, old, newState));
}
public void Dispose() => DisposeAsync().AsTask().GetAwaiter().GetResult();
public async ValueTask DisposeAsync()

View 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);
}
}

View File

@@ -0,0 +1,117 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.S7.Tests;
/// <summary>
/// Shape tests for <see cref="S7Driver"/>'s <see cref="ITagDiscovery"/>,
/// <see cref="ISubscribable"/>, and <see cref="IHostConnectivityProbe"/> surfaces that
/// don't need a live PLC. Wire-level polling round-trips and probe transitions land in a
/// follow-up PR once we have a mock S7 server.
/// </summary>
[Trait("Category", "Unit")]
public sealed class S7DiscoveryAndSubscribeTests
{
private sealed class RecordingAddressSpaceBuilder : IAddressSpaceBuilder
{
public readonly List<string> Folders = new();
public readonly List<(string Name, DriverAttributeInfo Attr)> Variables = new();
public IAddressSpaceBuilder Folder(string browseName, string displayName)
{
Folders.Add(browseName);
return this;
}
public IVariableHandle Variable(string browseName, string displayName, DriverAttributeInfo attributeInfo)
{
Variables.Add((browseName, attributeInfo));
return new StubHandle();
}
public void AddProperty(string browseName, DriverDataType dataType, object? value) { }
public void AttachAlarmCondition(IVariableHandle sourceVariable, string alarmName, DriverAttributeInfo alarmInfo) { }
private sealed class StubHandle : IVariableHandle
{
public string FullReference => "stub";
public IAlarmConditionSink MarkAsAlarmCondition(AlarmConditionInfo info)
=> throw new NotImplementedException("S7 driver never calls this — no alarm surfacing");
}
}
[Fact]
public async Task DiscoverAsync_projects_every_tag_into_the_address_space()
{
var opts = new S7DriverOptions
{
Host = "192.0.2.1",
Tags =
[
new("TempSetpoint", "DB1.DBW0", S7DataType.Int16, Writable: true),
new("FaultBit", "M0.0", S7DataType.Bool, Writable: false),
new("PIDOutput", "DB5.DBD12", S7DataType.Float32, Writable: true),
],
};
using var drv = new S7Driver(opts, "s7-disco");
var builder = new RecordingAddressSpaceBuilder();
await drv.DiscoverAsync(builder, TestContext.Current.CancellationToken);
builder.Folders.ShouldContain("S7");
builder.Variables.Count.ShouldBe(3);
builder.Variables[0].Name.ShouldBe("TempSetpoint");
builder.Variables[0].Attr.SecurityClass.ShouldBe(SecurityClassification.Operate, "writable tags get Operate security class");
builder.Variables[1].Attr.SecurityClass.ShouldBe(SecurityClassification.ViewOnly, "read-only tags get ViewOnly");
builder.Variables[2].Attr.DriverDataType.ShouldBe(DriverDataType.Float32);
}
[Fact]
public void GetHostStatuses_returns_one_row_with_host_port_identity_pre_init()
{
var opts = new S7DriverOptions { Host = "plc1.internal", Port = 102 };
using var drv = new S7Driver(opts, "s7-host");
var rows = drv.GetHostStatuses();
rows.Count.ShouldBe(1);
rows[0].HostName.ShouldBe("plc1.internal:102");
rows[0].State.ShouldBe(HostState.Unknown, "pre-init / pre-probe state is Unknown");
}
[Fact]
public async Task SubscribeAsync_returns_unique_handles_and_UnsubscribeAsync_accepts_them()
{
var opts = new S7DriverOptions { Host = "192.0.2.1" };
using var drv = new S7Driver(opts, "s7-sub");
// SubscribeAsync does not itself call ReadAsync (the poll task does), so this works
// even though the driver isn't initialized. The poll task catches the resulting
// InvalidOperationException and the loop quietly continues — same pattern as the
// Modbus driver's poll loop tolerating transient transport failures.
var h1 = await drv.SubscribeAsync(["T1"], TimeSpan.FromMilliseconds(200), TestContext.Current.CancellationToken);
var h2 = await drv.SubscribeAsync(["T2"], TimeSpan.FromMilliseconds(200), TestContext.Current.CancellationToken);
h1.DiagnosticId.ShouldStartWith("s7-sub-");
h2.DiagnosticId.ShouldStartWith("s7-sub-");
h1.DiagnosticId.ShouldNotBe(h2.DiagnosticId);
await drv.UnsubscribeAsync(h1, TestContext.Current.CancellationToken);
await drv.UnsubscribeAsync(h2, TestContext.Current.CancellationToken);
// UnsubscribeAsync with an unknown handle must be a no-op, not throw.
await drv.UnsubscribeAsync(h1, TestContext.Current.CancellationToken);
}
[Fact]
public async Task Subscribe_publishing_interval_is_floored_at_100ms()
{
var opts = new S7DriverOptions { Host = "192.0.2.1", Probe = new S7ProbeOptions { Enabled = false } };
using var drv = new S7Driver(opts, "s7-floor");
// 50 ms requested — the floor protects the S7 CPU from sub-scan polling that would
// just queue wire-side. Test that the subscription is accepted (the floor is applied
// internally; the floor value isn't exposed, so we're really just asserting that the
// driver doesn't reject small intervals).
var h = await drv.SubscribeAsync(["T"], TimeSpan.FromMilliseconds(50), TestContext.Current.CancellationToken);
h.ShouldNotBeNull();
await drv.UnsubscribeAsync(h, TestContext.Current.CancellationToken);
}
}

View File

@@ -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));
}
}