diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.S7/S7Driver.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.S7/S7Driver.cs index 393f7ca..b7e6be2 100644 --- a/src/ZB.MOM.WW.OtOpcUa.Driver.S7/S7Driver.cs +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.S7/S7Driver.cs @@ -26,8 +26,24 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.S7; /// /// public sealed class S7Driver(S7DriverOptions options, string driverInstanceId) - : IDriver, IDisposable, IAsyncDisposable + : IDriver, IReadable, IWritable, IDisposable, IAsyncDisposable { + /// OPC UA StatusCode used when the tag name isn't in the driver's tag map. + private const uint StatusBadNodeIdUnknown = 0x80340000u; + /// OPC UA StatusCode used when the tag's data type isn't implemented yet. + private const uint StatusBadNotSupported = 0x803D0000u; + /// OPC UA StatusCode used when the tag is declared read-only. + private const uint StatusBadNotWritable = 0x803B0000u; + /// OPC UA StatusCode used when write fails validation (e.g. out-of-range value). + private const uint StatusBadInternalError = 0x80020000u; + /// OPC UA StatusCode used for socket / timeout / protocol-layer faults. + private const uint StatusBadCommunicationError = 0x80050000u; + /// OPC UA StatusCode used when S7 returns ErrorCode.WrongCPU / PUT/GET disabled. + private const uint StatusBadDeviceFailure = 0x80550000u; + + private readonly Dictionary _tagsByName = new(StringComparer.OrdinalIgnoreCase); + private readonly Dictionary _parsedByName = new(StringComparer.OrdinalIgnoreCase); + private readonly S7DriverOptions _options = options; private readonly SemaphoreSlim _gate = new(1, 1); @@ -68,6 +84,19 @@ 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); } catch (Exception ex) @@ -106,6 +135,165 @@ public sealed class S7Driver(S7DriverOptions options, string driverInstanceId) public Task FlushOptionalCachesAsync(CancellationToken cancellationToken) => Task.CompletedTask; + // ---- IReadable ---- + + public async Task> ReadAsync( + IReadOnlyList 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 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> WriteAsync( + IReadOnlyList 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() diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/S7DriverReadWriteTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/S7DriverReadWriteTests.cs new file mode 100644 index 0000000..36e5d78 --- /dev/null +++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/S7DriverReadWriteTests.cs @@ -0,0 +1,54 @@ +using Shouldly; +using Xunit; + +namespace ZB.MOM.WW.OtOpcUa.Driver.S7.Tests; + +/// +/// Unit tests for 's IReadable/IWritable 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. +/// +[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(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(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(async () => + await drv.WriteAsync( + [new(FullReference: "Any", Value: (short)0)], + TestContext.Current.CancellationToken)); + } +}