feat(s7): resolve equipment-tag refs (read + write) via EquipmentTagRefResolver
This commit is contained in:
@@ -0,0 +1,59 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Driver.S7;
|
||||||
|
|
||||||
|
/// <summary>Parses an equipment tag's <c>TagConfig</c> JSON (the shape authored by the AdminUI
|
||||||
|
/// <c>S7TagConfigModel</c> — <c>address</c> / <c>dataType</c> / <c>stringLength</c>) into a transient
|
||||||
|
/// <see cref="S7TagDefinition"/> whose <see cref="S7TagDefinition.Name"/> equals the reference string
|
||||||
|
/// itself, so a value the driver publishes back keys the runtime's forward router correctly.</summary>
|
||||||
|
public static class S7EquipmentTagParser
|
||||||
|
{
|
||||||
|
/// <summary>S7 string max length (DBSTRING header reserves 2 bytes; 254 chars max).</summary>
|
||||||
|
private const int MaxStringLength = 254;
|
||||||
|
|
||||||
|
/// <summary>Attempts to parse an equipment-tag reference into a transient definition.</summary>
|
||||||
|
/// <param name="reference">The equipment tag's TagConfig JSON (also used as the def identity).</param>
|
||||||
|
/// <param name="def">The transient definition when parsing succeeds.</param>
|
||||||
|
/// <returns><see langword="true"/> when <paramref name="reference"/> is an S7 address object.</returns>
|
||||||
|
public static bool TryParse(string reference, out S7TagDefinition def)
|
||||||
|
{
|
||||||
|
def = null!;
|
||||||
|
// Authored tag names never start with '{' (AdminUI name validation), so a leading brace marks an equipment-tag TagConfig blob.
|
||||||
|
if (string.IsNullOrWhiteSpace(reference) || reference[0] != '{') return false;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var doc = JsonDocument.Parse(reference);
|
||||||
|
var root = doc.RootElement;
|
||||||
|
if (root.ValueKind != JsonValueKind.Object
|
||||||
|
|| !root.TryGetProperty("address", out var addrEl)
|
||||||
|
|| addrEl.ValueKind != JsonValueKind.String
|
||||||
|
|| !root.TryGetProperty("dataType", out _))
|
||||||
|
return false;
|
||||||
|
var address = addrEl.GetString();
|
||||||
|
if (string.IsNullOrWhiteSpace(address)) return false;
|
||||||
|
var dataType = ReadEnum(root, "dataType", S7DataType.Int16);
|
||||||
|
var stringLength = ReadInt(root, "stringLength");
|
||||||
|
// Range-guard rather than truncate: an S7 string can't exceed 254 chars, and a
|
||||||
|
// negative length is meaningless — reject so a malformed blob can't slip through.
|
||||||
|
if (stringLength < 0 || stringLength > MaxStringLength) return false;
|
||||||
|
def = new S7TagDefinition(
|
||||||
|
Name: reference,
|
||||||
|
Address: address,
|
||||||
|
DataType: dataType,
|
||||||
|
Writable: true, // node-level authz governs writes
|
||||||
|
StringLength: stringLength == 0 ? MaxStringLength : stringLength);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
catch (JsonException) { return false; }
|
||||||
|
catch (FormatException) { return false; }
|
||||||
|
catch (InvalidOperationException) { return false; }
|
||||||
|
}
|
||||||
|
|
||||||
|
private static TEnum ReadEnum<TEnum>(JsonElement o, string name, TEnum fallback) where TEnum : struct, Enum
|
||||||
|
=> o.TryGetProperty(name, out var e) && e.ValueKind == JsonValueKind.String
|
||||||
|
&& Enum.TryParse<TEnum>(e.GetString(), ignoreCase: true, out var v) ? v : fallback;
|
||||||
|
|
||||||
|
private static int ReadInt(JsonElement o, string name)
|
||||||
|
=> o.TryGetProperty(name, out var e) && e.ValueKind == JsonValueKind.Number
|
||||||
|
&& e.TryGetInt32(out var v) ? v : 0;
|
||||||
|
}
|
||||||
@@ -28,10 +28,26 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.S7;
|
|||||||
/// 8-64 connection-resource budget.
|
/// 8-64 connection-resource budget.
|
||||||
/// </para>
|
/// </para>
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
public sealed class S7Driver(S7DriverOptions options, string driverInstanceId, ILogger<S7Driver>? logger = null)
|
public sealed class S7Driver
|
||||||
: IDriver, ITagDiscovery, IReadable, IWritable, ISubscribable, IHostConnectivityProbe, IDisposable, IAsyncDisposable
|
: IDriver, ITagDiscovery, IReadable, IWritable, ISubscribable, IHostConnectivityProbe, IDisposable, IAsyncDisposable
|
||||||
{
|
{
|
||||||
private readonly ILogger<S7Driver> _logger = logger ?? NullLogger<S7Driver>.Instance;
|
private readonly string _driverInstanceId;
|
||||||
|
private readonly ILogger<S7Driver> _logger;
|
||||||
|
|
||||||
|
/// <summary>Initializes a new instance of the <see cref="S7Driver"/> class.</summary>
|
||||||
|
/// <param name="options">Driver configuration (the constructor-supplied fallback used when
|
||||||
|
/// Initialize/Reinitialize receive an empty config body).</param>
|
||||||
|
/// <param name="driverInstanceId">Unique driver instance identifier.</param>
|
||||||
|
/// <param name="logger">Optional logger; a null logger is used when not supplied.</param>
|
||||||
|
public S7Driver(S7DriverOptions options, string driverInstanceId, ILogger<S7Driver>? logger = null)
|
||||||
|
{
|
||||||
|
_options = options;
|
||||||
|
_driverInstanceId = driverInstanceId;
|
||||||
|
_logger = logger ?? NullLogger<S7Driver>.Instance;
|
||||||
|
_resolver = new EquipmentTagRefResolver<S7TagDefinition>(
|
||||||
|
r => _tagsByName.TryGetValue(r, out var t) ? t : null,
|
||||||
|
r => S7EquipmentTagParser.TryParse(r, out var d) ? d : null);
|
||||||
|
}
|
||||||
// ---- ISubscribable + IHostConnectivityProbe state ----
|
// ---- ISubscribable + IHostConnectivityProbe state ----
|
||||||
|
|
||||||
private readonly ConcurrentDictionary<long, SubscriptionState> _subscriptions = new();
|
private readonly ConcurrentDictionary<long, SubscriptionState> _subscriptions = new();
|
||||||
@@ -76,6 +92,11 @@ public sealed class S7Driver(S7DriverOptions options, string driverInstanceId, I
|
|||||||
private readonly Dictionary<string, S7TagDefinition> _tagsByName = new(StringComparer.OrdinalIgnoreCase);
|
private readonly Dictionary<string, S7TagDefinition> _tagsByName = new(StringComparer.OrdinalIgnoreCase);
|
||||||
private readonly Dictionary<string, S7ParsedAddress> _parsedByName = new(StringComparer.OrdinalIgnoreCase);
|
private readonly Dictionary<string, S7ParsedAddress> _parsedByName = new(StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
// Resolves a read/write/subscribe fullReference to a tag definition, bridging the two
|
||||||
|
// authoring models: an authored tag-table entry (by name) OR an equipment tag whose
|
||||||
|
// reference is its raw TagConfig JSON (parsed once via S7EquipmentTagParser, cached).
|
||||||
|
private readonly EquipmentTagRefResolver<S7TagDefinition> _resolver;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Active driver configuration. Seeded from the constructor argument, then replaced by
|
/// Active driver configuration. Seeded from the constructor argument, then replaced by
|
||||||
/// whatever <see cref="InitializeAsync"/> / <see cref="ReinitializeAsync"/> parse out of
|
/// whatever <see cref="InitializeAsync"/> / <see cref="ReinitializeAsync"/> parse out of
|
||||||
@@ -83,7 +104,7 @@ public sealed class S7Driver(S7DriverOptions options, string driverInstanceId, I
|
|||||||
/// constructor value is the fallback used when the caller passes an empty / placeholder
|
/// constructor value is the fallback used when the caller passes an empty / placeholder
|
||||||
/// JSON document (e.g. the <c>"{}"</c> some unit tests pass).
|
/// JSON document (e.g. the <c>"{}"</c> some unit tests pass).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private S7DriverOptions _options = options;
|
private S7DriverOptions _options;
|
||||||
private readonly SemaphoreSlim _gate = new(1, 1);
|
private readonly SemaphoreSlim _gate = new(1, 1);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -104,7 +125,7 @@ public sealed class S7Driver(S7DriverOptions options, string driverInstanceId, I
|
|||||||
private bool _disposed;
|
private bool _disposed;
|
||||||
|
|
||||||
/// <summary>Gets the unique driver instance identifier.</summary>
|
/// <summary>Gets the unique driver instance identifier.</summary>
|
||||||
public string DriverInstanceId => driverInstanceId;
|
public string DriverInstanceId => _driverInstanceId;
|
||||||
/// <summary>Gets the driver type name.</summary>
|
/// <summary>Gets the driver type name.</summary>
|
||||||
public string DriverType => "S7";
|
public string DriverType => "S7";
|
||||||
|
|
||||||
@@ -121,7 +142,7 @@ public sealed class S7Driver(S7DriverOptions options, string driverInstanceId, I
|
|||||||
// IDriver contract is honoured (Driver.S7-011). An empty / placeholder document
|
// IDriver contract is honoured (Driver.S7-011). An empty / placeholder document
|
||||||
// (e.g. the "{}" some unit tests pass) keeps the constructor-supplied options.
|
// (e.g. the "{}" some unit tests pass) keeps the constructor-supplied options.
|
||||||
if (HasConfigBody(driverConfigJson))
|
if (HasConfigBody(driverConfigJson))
|
||||||
_options = S7DriverFactoryExtensions.ParseOptions(driverInstanceId, driverConfigJson);
|
_options = S7DriverFactoryExtensions.ParseOptions(_driverInstanceId, driverConfigJson);
|
||||||
|
|
||||||
// Timer (T{n}) / Counter (C{n}) addresses parse cleanly but the read path has no
|
// Timer (T{n}) / Counter (C{n}) addresses parse cleanly but the read path has no
|
||||||
// S7DataType for them and no decode case — reject them here so a config typo
|
// S7DataType for them and no decode case — reject them here so a config typo
|
||||||
@@ -156,6 +177,7 @@ public sealed class S7Driver(S7DriverOptions options, string driverInstanceId, I
|
|||||||
// also rejects bit-offset > 7, DB 0, unknown area letters, etc.
|
// also rejects bit-offset > 7, DB 0, unknown area letters, etc.
|
||||||
_tagsByName.Clear();
|
_tagsByName.Clear();
|
||||||
_parsedByName.Clear();
|
_parsedByName.Clear();
|
||||||
|
_resolver.Clear(); // drop transient equipment-tag parses so a config change re-parses
|
||||||
foreach (var t in _options.Tags)
|
foreach (var t in _options.Tags)
|
||||||
{
|
{
|
||||||
var parsed = S7AddressParser.Parse(t.Address); // throws FormatException
|
var parsed = S7AddressParser.Parse(t.Address); // throws FormatException
|
||||||
@@ -165,7 +187,7 @@ public sealed class S7Driver(S7DriverOptions options, string driverInstanceId, I
|
|||||||
|
|
||||||
_health = new DriverHealth(DriverState.Healthy, DateTime.UtcNow, null);
|
_health = new DriverHealth(DriverState.Healthy, DateTime.UtcNow, null);
|
||||||
_logger.LogInformation("S7Driver connected. Driver={DriverInstanceId} Host={Host} CPU={CpuType} Tags={TagCount}",
|
_logger.LogInformation("S7Driver connected. Driver={DriverInstanceId} Host={Host} CPU={CpuType} Tags={TagCount}",
|
||||||
driverInstanceId, _options.Host, _options.CpuType, _options.Tags.Count);
|
_driverInstanceId, _options.Host, _options.CpuType, _options.Tags.Count);
|
||||||
|
|
||||||
// Kick off the probe loop once the connection is up. Initial HostState stays
|
// Kick off the probe loop once the connection is up. Initial HostState stays
|
||||||
// Unknown until the first probe tick succeeds — avoids broadcasting a premature
|
// Unknown until the first probe tick succeeds — avoids broadcasting a premature
|
||||||
@@ -187,7 +209,7 @@ public sealed class S7Driver(S7DriverOptions options, string driverInstanceId, I
|
|||||||
try { Plc?.Close(); } catch { }
|
try { Plc?.Close(); } catch { }
|
||||||
Plc = null;
|
Plc = null;
|
||||||
_health = new DriverHealth(DriverState.Faulted, null, ex.Message);
|
_health = new DriverHealth(DriverState.Faulted, null, ex.Message);
|
||||||
_logger.LogError(ex, "S7Driver connect failed. Driver={DriverInstanceId} Host={Host}", driverInstanceId, _options.Host);
|
_logger.LogError(ex, "S7Driver connect failed. Driver={DriverInstanceId} Host={Host}", _driverInstanceId, _options.Host);
|
||||||
throw;
|
throw;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -363,7 +385,7 @@ public sealed class S7Driver(S7DriverOptions options, string driverInstanceId, I
|
|||||||
for (var i = 0; i < fullReferences.Count; i++)
|
for (var i = 0; i < fullReferences.Count; i++)
|
||||||
{
|
{
|
||||||
var name = fullReferences[i];
|
var name = fullReferences[i];
|
||||||
if (!_tagsByName.TryGetValue(name, out var tag))
|
if (!_resolver.TryResolve(name, out var tag))
|
||||||
{
|
{
|
||||||
results[i] = new DataValueSnapshot(null, StatusBadNodeIdUnknown, null, now);
|
results[i] = new DataValueSnapshot(null, StatusBadNodeIdUnknown, null, now);
|
||||||
continue;
|
continue;
|
||||||
@@ -410,7 +432,14 @@ public sealed class S7Driver(S7DriverOptions options, string driverInstanceId, I
|
|||||||
|
|
||||||
private async Task<object> ReadOneAsync(Plc plc, S7TagDefinition tag, CancellationToken ct)
|
private async Task<object> ReadOneAsync(Plc plc, S7TagDefinition tag, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var addr = _parsedByName[tag.Name];
|
// Authored tags pre-parse their address at init (_parsedByName); an equipment-tag ref
|
||||||
|
// (resolved transiently by _resolver) has no _parsedByName entry, so parse its address
|
||||||
|
// on demand. S7AddressParser.Parse throws FormatException on a bad address, which the
|
||||||
|
// caller's catch maps to BadCommunicationError — the same surface a bad authored tag
|
||||||
|
// would have hit at init (transient defs aren't init-validated).
|
||||||
|
var addr = _parsedByName.TryGetValue(tag.Name, out var parsed)
|
||||||
|
? parsed
|
||||||
|
: S7AddressParser.Parse(tag.Address);
|
||||||
// S7.Net's string-based ReadAsync returns object where the boxed .NET type depends on
|
// 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
|
// 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
|
// specifies the SEMANTIC type (Int16 vs UInt16 vs Float32 etc.); the reinterpret below
|
||||||
@@ -476,7 +505,7 @@ public sealed class S7Driver(S7DriverOptions options, string driverInstanceId, I
|
|||||||
for (var i = 0; i < writes.Count; i++)
|
for (var i = 0; i < writes.Count; i++)
|
||||||
{
|
{
|
||||||
var w = writes[i];
|
var w = writes[i];
|
||||||
if (!_tagsByName.TryGetValue(w.FullReference, out var tag))
|
if (!_resolver.TryResolve(w.FullReference, out var tag))
|
||||||
{
|
{
|
||||||
results[i] = new WriteResult(StatusBadNodeIdUnknown);
|
results[i] = new WriteResult(StatusBadNodeIdUnknown);
|
||||||
continue;
|
continue;
|
||||||
@@ -743,10 +772,10 @@ public sealed class S7Driver(S7DriverOptions options, string driverInstanceId, I
|
|||||||
{
|
{
|
||||||
if (initial)
|
if (initial)
|
||||||
_logger.LogWarning(ex, "S7 poll initial-read failed. Driver={DriverInstanceId} ConsecutiveFailures={Count}",
|
_logger.LogWarning(ex, "S7 poll initial-read failed. Driver={DriverInstanceId} ConsecutiveFailures={Count}",
|
||||||
driverInstanceId, consecutiveFailures);
|
_driverInstanceId, consecutiveFailures);
|
||||||
else
|
else
|
||||||
_logger.LogWarning(ex, "S7 poll tick failed. Driver={DriverInstanceId} ConsecutiveFailures={Count}",
|
_logger.LogWarning(ex, "S7 poll tick failed. Driver={DriverInstanceId} ConsecutiveFailures={Count}",
|
||||||
driverInstanceId, consecutiveFailures);
|
_driverInstanceId, consecutiveFailures);
|
||||||
|
|
||||||
if (consecutiveFailures >= PollFailureHealthThreshold)
|
if (consecutiveFailures >= PollFailureHealthThreshold)
|
||||||
{
|
{
|
||||||
@@ -878,7 +907,7 @@ public sealed class S7Driver(S7DriverOptions options, string driverInstanceId, I
|
|||||||
_hostStateChangedUtc = DateTime.UtcNow;
|
_hostStateChangedUtc = DateTime.UtcNow;
|
||||||
}
|
}
|
||||||
_logger.LogInformation("S7 probe transition. Driver={DriverInstanceId} Host={Host} {OldState} → {NewState}",
|
_logger.LogInformation("S7 probe transition. Driver={DriverInstanceId} Host={Host} {OldState} → {NewState}",
|
||||||
driverInstanceId, HostName, old, newState);
|
_driverInstanceId, HostName, old, newState);
|
||||||
OnHostStatusChanged?.Invoke(this, new HostStatusChangedEventArgs(HostName, old, newState));
|
OnHostStatusChanged?.Invoke(this, new HostStatusChangedEventArgs(HostName, old, newState));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
using Shouldly;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Driver.S7.Tests;
|
||||||
|
|
||||||
|
[Trait("Category", "Unit")]
|
||||||
|
public class S7EquipmentTagTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void Parses_equipment_tagconfig_into_a_transient_definition()
|
||||||
|
{
|
||||||
|
var json = """{"address":"DB1.DBW0","dataType":"UInt16","stringLength":0}""";
|
||||||
|
S7EquipmentTagParser.TryParse(json, out var def).ShouldBeTrue();
|
||||||
|
def!.Name.ShouldBe(json);
|
||||||
|
def.Address.ShouldBe("DB1.DBW0");
|
||||||
|
def.DataType.ShouldBe(S7DataType.UInt16);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Rejects_a_non_address_blob()
|
||||||
|
=> S7EquipmentTagParser.TryParse("""{"FullName":"x"}""", out _).ShouldBeFalse();
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Rejects_garbage()
|
||||||
|
=> S7EquipmentTagParser.TryParse("not json", out _).ShouldBeFalse();
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Rejects_address_as_a_json_number()
|
||||||
|
=> S7EquipmentTagParser.TryParse(
|
||||||
|
"""{"address":40001,"dataType":"UInt16"}""", out _).ShouldBeFalse();
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Rejects_string_length_out_of_range()
|
||||||
|
=> S7EquipmentTagParser.TryParse(
|
||||||
|
"""{"address":"DB1.DBW0","dataType":"String","stringLength":300}""", out _).ShouldBeFalse();
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user