Compare commits
1 Commits
phase-3-pr
...
phase-3-pr
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
394d126b2e |
@@ -26,8 +26,24 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.S7;
|
|||||||
/// </para>
|
/// </para>
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
public sealed class S7Driver(S7DriverOptions options, string driverInstanceId)
|
public sealed class S7Driver(S7DriverOptions options, string driverInstanceId)
|
||||||
: IDriver, IDisposable, IAsyncDisposable
|
: 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 S7DriverOptions _options = options;
|
||||||
private readonly SemaphoreSlim _gate = new(1, 1);
|
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);
|
await plc.OpenAsync(cts.Token).ConfigureAwait(false);
|
||||||
|
|
||||||
Plc = plc;
|
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);
|
_health = new DriverHealth(DriverState.Healthy, DateTime.UtcNow, null);
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
@@ -106,6 +135,165 @@ public sealed class S7Driver(S7DriverOptions options, string driverInstanceId)
|
|||||||
|
|
||||||
public Task FlushOptionalCachesAsync(CancellationToken cancellationToken) => Task.CompletedTask;
|
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 void Dispose() => DisposeAsync().AsTask().GetAwaiter().GetResult();
|
||||||
|
|
||||||
public async ValueTask DisposeAsync()
|
public async ValueTask DisposeAsync()
|
||||||
|
|||||||
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user