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
+/// S7TagConfigModel — address / 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