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.
|
||||
/// </para>
|
||||
/// </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
|
||||
{
|
||||
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 ----
|
||||
|
||||
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, 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>
|
||||
/// Active driver configuration. Seeded from the constructor argument, then replaced by
|
||||
/// 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
|
||||
/// JSON document (e.g. the <c>"{}"</c> some unit tests pass).
|
||||
/// </summary>
|
||||
private S7DriverOptions _options = options;
|
||||
private S7DriverOptions _options;
|
||||
private readonly SemaphoreSlim _gate = new(1, 1);
|
||||
|
||||
/// <summary>
|
||||
@@ -104,7 +125,7 @@ public sealed class S7Driver(S7DriverOptions options, string driverInstanceId, I
|
||||
private bool _disposed;
|
||||
|
||||
/// <summary>Gets the unique driver instance identifier.</summary>
|
||||
public string DriverInstanceId => driverInstanceId;
|
||||
public string DriverInstanceId => _driverInstanceId;
|
||||
/// <summary>Gets the driver type name.</summary>
|
||||
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
|
||||
// (e.g. the "{}" some unit tests pass) keeps the constructor-supplied options.
|
||||
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
|
||||
// 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.
|
||||
_tagsByName.Clear();
|
||||
_parsedByName.Clear();
|
||||
_resolver.Clear(); // drop transient equipment-tag parses so a config change re-parses
|
||||
foreach (var t in _options.Tags)
|
||||
{
|
||||
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);
|
||||
_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
|
||||
// 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 { }
|
||||
Plc = null;
|
||||
_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;
|
||||
}
|
||||
}
|
||||
@@ -363,7 +385,7 @@ public sealed class S7Driver(S7DriverOptions options, string driverInstanceId, I
|
||||
for (var i = 0; i < fullReferences.Count; 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);
|
||||
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)
|
||||
{
|
||||
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
|
||||
// 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
|
||||
@@ -476,7 +505,7 @@ public sealed class S7Driver(S7DriverOptions options, string driverInstanceId, I
|
||||
for (var i = 0; i < writes.Count; 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);
|
||||
continue;
|
||||
@@ -743,10 +772,10 @@ public sealed class S7Driver(S7DriverOptions options, string driverInstanceId, I
|
||||
{
|
||||
if (initial)
|
||||
_logger.LogWarning(ex, "S7 poll initial-read failed. Driver={DriverInstanceId} ConsecutiveFailures={Count}",
|
||||
driverInstanceId, consecutiveFailures);
|
||||
_driverInstanceId, consecutiveFailures);
|
||||
else
|
||||
_logger.LogWarning(ex, "S7 poll tick failed. Driver={DriverInstanceId} ConsecutiveFailures={Count}",
|
||||
driverInstanceId, consecutiveFailures);
|
||||
_driverInstanceId, consecutiveFailures);
|
||||
|
||||
if (consecutiveFailures >= PollFailureHealthThreshold)
|
||||
{
|
||||
@@ -878,7 +907,7 @@ public sealed class S7Driver(S7DriverOptions options, string driverInstanceId, I
|
||||
_hostStateChangedUtc = DateTime.UtcNow;
|
||||
}
|
||||
_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));
|
||||
}
|
||||
|
||||
|
||||
@@ -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