feat(s7): resolve equipment-tag refs (read + write) via EquipmentTagRefResolver

This commit is contained in:
Joseph Doherty
2026-06-13 11:25:31 -04:00
parent 34a42486dc
commit b031a6ceef
3 changed files with 137 additions and 13 deletions
@@ -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();
}