diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Contracts/S7EquipmentTagParser.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Contracts/S7EquipmentTagParser.cs new file mode 100644 index 00000000..f16c5780 --- /dev/null +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Contracts/S7EquipmentTagParser.cs @@ -0,0 +1,59 @@ +using System.Text.Json; + +namespace ZB.MOM.WW.OtOpcUa.Driver.S7; + +/// Parses an equipment tag's TagConfig JSON (the shape authored by the AdminUI +/// S7TagConfigModeladdress / dataType / stringLength) into a transient +/// whose equals the reference string +/// itself, so a value the driver publishes back keys the runtime's forward router correctly. +public static class S7EquipmentTagParser +{ + /// S7 string max length (DBSTRING header reserves 2 bytes; 254 chars max). + private const int MaxStringLength = 254; + + /// Attempts to parse an equipment-tag reference into a transient definition. + /// The equipment tag's TagConfig JSON (also used as the def identity). + /// The transient definition when parsing succeeds. + /// when is an S7 address object. + 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(JsonElement o, string name, TEnum fallback) where TEnum : struct, Enum + => o.TryGetProperty(name, out var e) && e.ValueKind == JsonValueKind.String + && Enum.TryParse(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; +} diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7/S7Driver.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7/S7Driver.cs index 312144dc..de1d15d6 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7/S7Driver.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7/S7Driver.cs @@ -28,10 +28,26 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.S7; /// 8-64 connection-resource budget. /// /// -public sealed class S7Driver(S7DriverOptions options, string driverInstanceId, ILogger? logger = null) +public sealed class S7Driver : IDriver, ITagDiscovery, IReadable, IWritable, ISubscribable, IHostConnectivityProbe, IDisposable, IAsyncDisposable { - private readonly ILogger _logger = logger ?? NullLogger.Instance; + private readonly string _driverInstanceId; + private readonly ILogger _logger; + + /// Initializes a new instance of the class. + /// Driver configuration (the constructor-supplied fallback used when + /// Initialize/Reinitialize receive an empty config body). + /// Unique driver instance identifier. + /// Optional logger; a null logger is used when not supplied. + public S7Driver(S7DriverOptions options, string driverInstanceId, ILogger? logger = null) + { + _options = options; + _driverInstanceId = driverInstanceId; + _logger = logger ?? NullLogger.Instance; + _resolver = new EquipmentTagRefResolver( + r => _tagsByName.TryGetValue(r, out var t) ? t : null, + r => S7EquipmentTagParser.TryParse(r, out var d) ? d : null); + } // ---- ISubscribable + IHostConnectivityProbe state ---- private readonly ConcurrentDictionary _subscriptions = new(); @@ -76,6 +92,11 @@ public sealed class S7Driver(S7DriverOptions options, string driverInstanceId, I private readonly Dictionary _tagsByName = new(StringComparer.OrdinalIgnoreCase); private readonly Dictionary _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 _resolver; + /// /// Active driver configuration. Seeded from the constructor argument, then replaced by /// whatever / 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 "{}" some unit tests pass). /// - private S7DriverOptions _options = options; + private S7DriverOptions _options; private readonly SemaphoreSlim _gate = new(1, 1); /// @@ -104,7 +125,7 @@ public sealed class S7Driver(S7DriverOptions options, string driverInstanceId, I private bool _disposed; /// Gets the unique driver instance identifier. - public string DriverInstanceId => driverInstanceId; + public string DriverInstanceId => _driverInstanceId; /// Gets the driver type name. 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 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)); } diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/S7EquipmentTagTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/S7EquipmentTagTests.cs new file mode 100644 index 00000000..06074506 --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/S7EquipmentTagTests.cs @@ -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(); +}