diff --git a/src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.S7.Cli/S7CommandBase.cs b/src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.S7.Cli/S7CommandBase.cs
index f25180c3..06f1c677 100644
--- a/src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.S7.Cli/S7CommandBase.cs
+++ b/src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.S7.Cli/S7CommandBase.cs
@@ -1,5 +1,6 @@
using CliFx.Attributes;
using CliFx.Exceptions;
+using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.Cli.Common;
namespace ZB.MOM.WW.OtOpcUa.Driver.S7.Cli;
@@ -48,8 +49,11 @@ public abstract class S7CommandBase : DriverCommandBase
///
/// Build an with the endpoint fields this base
- /// collected + whatever the subclass declares. Probe
- /// disabled — CLI runs are one-shot.
+ /// collected + whatever the subclass declares. Each typed
+ /// definition is serialised to its TagConfig blob via
+ /// and packaged as a
+ /// (RawPath = the def's Name) — the v3 delivery shape the
+ /// driver rebuilds definitions from. Probe disabled — CLI runs are one-shot.
///
/// The tag definitions to include in the options.
/// The constructed .
@@ -61,7 +65,7 @@ public abstract class S7CommandBase : DriverCommandBase
Rack = Rack,
Slot = Slot,
Timeout = Timeout,
- Tags = tags,
+ RawTags = [.. tags.Select(t => new RawTagEntry(t.Name, S7TagDefinitionFactory.ToTagConfig(t), t.WriteIdempotent))],
Probe = new S7ProbeOptions { Enabled = false },
};
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Contracts/S7DriverOptions.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Contracts/S7DriverOptions.cs
index 23c8974b..15a09eb7 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Contracts/S7DriverOptions.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Contracts/S7DriverOptions.cs
@@ -1,4 +1,5 @@
using System.ComponentModel.DataAnnotations;
+using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.S7;
@@ -53,8 +54,15 @@ public sealed class S7DriverOptions
/// Connect + per-operation timeout.
public TimeSpan Timeout { get; init; } = TimeSpan.FromSeconds(5);
- /// Pre-declared tag map. S7 has a symbol-table protocol but S7.Net does not expose it, so the driver operates off a static tag list configured per-site. Address grammar documented in S7AddressParser (PR 63).
- public IReadOnlyList Tags { get; init; } = [];
+ ///
+ /// Authored raw tags this driver serves. The deploy artifact hands each authored raw
+ /// Tag as a (RawPath identity + driver TagConfig blob +
+ /// WriteIdempotent flag); the driver maps each through
+ /// into its RawPath → definition table.
+ /// S7 has a symbol-table protocol but S7.Net does not expose it, so the driver serves exactly
+ /// these authored tags. Address grammar documented in S7AddressParser (PR 63).
+ ///
+ public IReadOnlyList RawTags { get; init; } = [];
///
/// Background connectivity-probe settings. When enabled, the driver runs a tick loop
@@ -104,7 +112,7 @@ public sealed class S7ProbeOptions
/// Element count when the tag is a 1-D array; null for a scalar. Any value >= 1
/// (including 1) surfaces as a 1-D OPC UA array node.
/// For an equipment tag this is threaded from the TagConfig JSON's arrayLength
-/// (honoured only when isArray is true) by . When
+/// (honoured only when isArray is true) by . When
/// set, the driver issues a single contiguous block read of
/// ArrayCount × element-bytes from the tag's start address and decodes each element
/// into an element-typed CLR array (short[] / int[] / float[] / etc.).
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Contracts/S7EquipmentTagParser.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Contracts/S7TagDefinitionFactory.cs
similarity index 53%
rename from src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Contracts/S7EquipmentTagParser.cs
rename to src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Contracts/S7TagDefinitionFactory.cs
index 8f0cdf1c..48044406 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Contracts/S7EquipmentTagParser.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Contracts/S7TagDefinitionFactory.cs
@@ -1,29 +1,45 @@
using System.Text.Json;
+using System.Text.Json.Nodes;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
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
+///
+/// v3 pure mapper: turns an authored raw tag's TagConfig JSON (the shape produced by the
+/// AdminUI S7TagConfigModel — address / dataType / stringLength) into a
+/// . Under v3 a tag's identity is its RawPath (a cluster-scoped
+/// slash path), not the address blob — so the produced definition's
+/// is the RawPath the driver was handed, which is exactly the wire reference the driver's
+/// RawPath → def resolver keys on. The driver builds that table by mapping each
+/// the deploy artifact delivers through ;
+/// is the inverse, used by authoring paths (the Client CLI) that hold a typed
+/// definition and need to synthesise the TagConfig blob for a .
+///
+public static class S7TagDefinitionFactory
{
/// 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)
+ ///
+ /// Maps an authored TagConfig object to a typed definition keyed by .
+ /// The input is always an authored TagConfig JSON object (there is no name-vs-blob heuristic in v3).
+ /// The dataType enum is read STRICTLY — a present-but-invalid (typo'd) value rejects the whole
+ /// tag (returns ⇒ the driver surfaces BadNodeIdUnknown) rather than
+ /// silently defaulting to a wrong-width Good. is NOT read
+ /// from the blob — it is a platform flag the caller threads in from the tag's
+ /// .
+ ///
+ /// The authored equipment-tag TagConfig JSON.
+ /// The tag's RawPath — becomes the definition's identity (Name).
+ /// The mapped definition when this returns .
+ /// when is a valid S7 address object.
+ public static bool FromTagConfig(string tagConfig, string rawPath, 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;
+ if (string.IsNullOrWhiteSpace(tagConfig)) return false;
try
{
- using var doc = JsonDocument.Parse(reference);
+ using var doc = JsonDocument.Parse(tagConfig);
var root = doc.RootElement;
if (root.ValueKind != JsonValueKind.Object
|| !root.TryGetProperty("address", out var addrEl)
@@ -42,18 +58,19 @@ public static class S7EquipmentTagParser
if (dataType == S7DataType.String && (stringLength < 0 || stringLength > MaxStringLength)) return false;
// Array intent: the canonical sink-side parse (DeploymentArtifact.ExtractTagArray)
// honours arrayLength ONLY when isArray is true AND the prop is a JSON number — mirror
- // that here so the driver's transient def agrees byte-for-byte with the materialised
- // OPC UA node's ValueRank/ArrayDimensions. Absent / isArray=false ⇒ null (scalar).
+ // that here so the driver's def agrees byte-for-byte with the materialised OPC UA node's
+ // ValueRank/ArrayDimensions. Absent / isArray=false ⇒ null (scalar).
var arrayCount = ReadArrayCount(root);
// "writable" defaults to true when absent (today's value); node-level authz still governs
// writes. Honouring the key makes read-only equipment tags authorable (UNDER-6).
var writable = TagConfigJson.ReadWritable(root);
def = new S7TagDefinition(
- Name: reference,
+ Name: rawPath,
Address: address,
DataType: dataType,
Writable: writable,
StringLength: stringLength == 0 ? MaxStringLength : stringLength,
+ WriteIdempotent: false,
ArrayCount: arrayCount);
return true;
}
@@ -62,6 +79,37 @@ public static class S7EquipmentTagParser
catch (InvalidOperationException) { return false; }
}
+ ///
+ /// Inverse of : serialises a typed definition back to the camelCase
+ /// TagConfig JSON the mapper reads. Used by authoring paths (the Client CLI) that construct an
+ /// from operator flags and need the TagConfig string to hand the
+ /// driver as a . The dataType enum is written as its name string;
+ /// stringLength is emitted only when it diverges from the 254 default so the blob stays minimal.
+ /// Name is NOT emitted — it becomes the . WriteIdempotent
+ /// is NOT emitted — it travels on the , not inside the blob.
+ ///
+ /// The definition to serialise.
+ /// The camelCase TagConfig JSON string.
+ public static string ToTagConfig(S7TagDefinition def)
+ {
+ ArgumentNullException.ThrowIfNull(def);
+ var o = new JsonObject
+ {
+ ["address"] = def.Address,
+ ["dataType"] = def.DataType.ToString(),
+ ["writable"] = def.Writable,
+ };
+ // FromTagConfig maps an absent / zero stringLength to MaxStringLength (254), so a def carrying
+ // the 254 default round-trips whether or not we emit it — emit only when it diverges.
+ if (def.StringLength != MaxStringLength) o["stringLength"] = def.StringLength;
+ if (def.ArrayCount is { } count)
+ {
+ o["isArray"] = true;
+ o["arrayLength"] = count;
+ }
+ return o.ToJsonString();
+ }
+
///
/// Deploy-time inspection (05/CONV-2): warns on a present-but-invalid dataType (silently
/// defaulted by the lenient runtime) and on a structurally unparseable TagConfig (a silent
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 476fc283..c7949bb4 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7/S7Driver.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7/S7Driver.cs
@@ -51,8 +51,7 @@ public sealed class S7Driver
_plcFactory = plcFactory ?? new S7PlcFactory();
_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);
+ r => _tagsByRawPath.TryGetValue(r, out var t) ? t : null);
// CONV-1: the polling overlay runs on the shared PollGroupEngine (structural array diff +
// drain-before-dispose + capped-exponential failure backoff + caller-token-filtered OCE),
// retiring the bespoke S7 poll fork. minInterval 100 ms matches the S7 scan-mailbox floor;
@@ -104,12 +103,15 @@ public sealed class S7Driver
/// OPC UA StatusCode used for a genuine device fault (CPU error, hardware fault).
private const uint StatusBadDeviceFailure = 0x808B0000u;
- private readonly Dictionary _tagsByName = new(StringComparer.OrdinalIgnoreCase);
- private readonly Dictionary _parsedByName = new(StringComparer.OrdinalIgnoreCase);
+ // v3: the RawPath → definition table. Keyed by RawPath (the v3 tag identity, == def.Name),
+ // case-sensitive ordinal. Built in InitializeAsync from _options.RawTags via the pure factory.
+ private readonly Dictionary _tagsByRawPath = new(StringComparer.Ordinal);
+ // Pre-parsed S7 address per tag, keyed by RawPath (== def.Name). Populated alongside
+ // _tagsByRawPath at init so a config typo fails fast here rather than as a per-read fault.
+ private readonly Dictionary _parsedByRawPath = new(StringComparer.Ordinal);
- // 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).
+ // Resolves a read/write/subscribe fullReference (a RawPath in v3) to a tag definition by a single
+ // authored-table hit; a miss is a miss (the driver surfaces BadNodeIdUnknown, never a parse attempt).
private readonly EquipmentTagRefResolver _resolver;
///
@@ -175,6 +177,24 @@ public sealed class S7Driver
if (HasConfigBody(driverConfigJson))
_options = S7DriverFactoryExtensions.ParseOptions(_driverInstanceId, driverConfigJson);
+ // v3: build the RawPath → definition table from the authored raw tags BEFORE the config
+ // guards run (they now iterate the mapped table). Each entry's TagConfig blob is mapped by
+ // the pure factory; the entry's WriteIdempotent flag is threaded onto the def (it lives on
+ // the RawTagEntry, not inside the blob). A mapper miss is skipped (logged), never thrown —
+ // one bad tag must not fail the whole driver init.
+ _tagsByRawPath.Clear();
+ _parsedByRawPath.Clear();
+ _resolver.Clear(); // no-op in v3 (the resolver reads the live table by closure); kept for symmetry
+ foreach (var entry in _options.RawTags)
+ {
+ if (S7TagDefinitionFactory.FromTagConfig(entry.TagConfig, entry.RawPath, out var def))
+ _tagsByRawPath[entry.RawPath] = def with { WriteIdempotent = entry.WriteIdempotent };
+ else
+ _logger.LogWarning(
+ "S7 tag config did not map to a definition; skipping. Driver={DriverInstanceId} RawPath={RawPath}",
+ _driverInstanceId, entry.RawPath);
+ }
+
// Phase 4d combined config guard. Timer/Counter addresses and the wide/structured
// types (Int64/UInt64/Float64/String/DateTime) are now SUPPORTED, but only in
// specific shapes — this rejects the genuinely-unsupported configurations
@@ -220,21 +240,17 @@ public sealed class S7Driver
// Parse every tag's address once at init so config typos fail fast here instead
// of surfacing as BadInternalError on every Read against the bad tag. The parser
- // 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)
+ // also rejects bit-offset > 7, DB 0, unknown area letters, etc. Keyed by RawPath
+ // (== def.Name) so Read/Write can pick up the pre-parsed address.
+ foreach (var t in _tagsByRawPath.Values)
{
- var parsed = S7AddressParser.Parse(t.Address); // throws FormatException
- _tagsByName[t.Name] = t;
- _parsedByName[t.Name] = parsed;
+ _parsedByRawPath[t.Name] = S7AddressParser.Parse(t.Address); // throws FormatException
}
_initialized = true;
_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, _tagsByRawPath.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
@@ -360,7 +376,7 @@ public sealed class S7Driver
///
private void RejectUnsupportedTagConfigs()
{
- foreach (var t in _options.Tags)
+ foreach (var t in _tagsByRawPath.Values)
{
var isWide = ByteAnchoredDataTypes.Contains(t.DataType);
@@ -442,7 +458,7 @@ public sealed class S7Driver
///
private void RejectUnsupportedTagDataTypes()
{
- foreach (var t in _options.Tags)
+ foreach (var t in _tagsByRawPath.Values)
{
if (UnimplementedDataTypes.Contains(t.DataType))
{
@@ -573,12 +589,10 @@ public sealed class S7Driver
private async Task