diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Contracts/ModbusEquipmentTagParser.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Contracts/ModbusEquipmentTagParser.cs
new file mode 100644
index 00000000..8fd480da
--- /dev/null
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Contracts/ModbusEquipmentTagParser.cs
@@ -0,0 +1,54 @@
+using System.Text.Json;
+
+namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus;
+
+/// Parses an equipment tag's TagConfig JSON (the shape authored by the AdminUI
+/// ModbusTagConfigModel) 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 ModbusEquipmentTagParser
+{
+ /// 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 a Modbus address object.
+ public static bool TryParse(string reference, out ModbusTagDefinition 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("region", out _)
+ || !root.TryGetProperty("address", out var addr))
+ return false;
+ if (addr.ValueKind != JsonValueKind.Number
+ || !addr.TryGetInt32(out var address)
+ || address < 0 || address > ushort.MaxValue)
+ return false;
+ var region = ReadEnum(root, "region", ModbusRegion.HoldingRegisters);
+ var dataType = ReadEnum(root, "dataType", ModbusDataType.Int16);
+ var byteOrder = ReadEnum(root, "byteOrder", ModbusByteOrder.BigEndian);
+ var bitIndex = (byte)ReadInt(root, "bitIndex");
+ var stringLength = (ushort)ReadInt(root, "stringLength");
+ def = new ModbusTagDefinition(
+ Name: reference, Region: region, Address: (ushort)address, DataType: dataType,
+ Writable: true, ByteOrder: byteOrder, BitIndex: bitIndex, StringLength: 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.Modbus/ModbusDriver.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus/ModbusDriver.cs
index cd1ab6b2..c045297d 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus/ModbusDriver.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus/ModbusDriver.cs
@@ -34,6 +34,11 @@ public sealed class ModbusDriver
private readonly Dictionary _tagsByName = 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 ModbusEquipmentTagParser, cached).
+ private readonly EquipmentTagRefResolver _resolver;
+
// Last-published value per tag, keyed by FullReference. Used by ShouldPublish to apply
// the deadband filter. Stored as object so all numeric types share one map; the comparison
// does a typed cast inside.
@@ -111,6 +116,9 @@ public sealed class ModbusDriver
_options = options;
_driverInstanceId = driverInstanceId;
_logger = logger ?? NullLogger.Instance;
+ _resolver = new EquipmentTagRefResolver(
+ r => _tagsByName.TryGetValue(r, out var t) ? t : null,
+ r => ModbusEquipmentTagParser.TryParse(r, out var d) ? d : null);
_transportFactory = transportFactory
?? (o => new ModbusTcpTransport(
o.Host, o.Port, o.Timeout, o.AutoReconnect,
@@ -148,7 +156,7 @@ public sealed class ModbusDriver
private bool ShouldPublish(string tagRef, DataValueSnapshot snapshot)
{
- if (!_tagsByName.TryGetValue(tagRef, out var tag) || tag.Deadband is null) return true;
+ if (!_resolver.TryResolve(tagRef, out var tag) || tag.Deadband is null) return true;
if (snapshot.Value is null) return true;
// Deadband only applies to numeric scalar types — array / Bool / String publishes
// unconditionally. Easier to special-case skip than to enumerate the supported types.
@@ -297,7 +305,7 @@ public sealed class ModbusDriver
for (var i = 0; i < fullReferences.Count; i++)
{
if (coalesced.Contains(i)) continue;
- if (!_tagsByName.TryGetValue(fullReferences[i], out var tag))
+ if (!_resolver.TryResolve(fullReferences[i], out var tag))
{
results[i] = new DataValueSnapshot(null, StatusBadNodeIdUnknown, null, now);
continue;
@@ -711,7 +719,7 @@ public sealed class ModbusDriver
var eligible = new List<(int Index, string Ref, ModbusTagDefinition Tag)>();
for (var i = 0; i < fullReferences.Count; i++)
{
- if (!_tagsByName.TryGetValue(fullReferences[i], out var tag)) continue;
+ if (!_resolver.TryResolve(fullReferences[i], out var tag)) continue;
if (tag.CoalesceProhibited) continue;
if (tag.ArrayCount.HasValue) continue;
if (tag.Region is not (ModbusRegion.HoldingRegisters or ModbusRegion.InputRegisters)) continue;
@@ -931,7 +939,7 @@ public sealed class ModbusDriver
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;
@@ -1617,6 +1625,7 @@ public sealed class ModbusDriver
_reprobeCts = null;
_tagsByName.Clear();
+ _resolver.Clear(); // drop transient equipment-tag parses so a config change re-parses
_lastPublishedByRef.Clear();
lock (_lastWrittenLock) _lastWrittenByRef.Clear();
lock (_autoProhibitedLock) _autoProhibited.Clear();
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests/ModbusEquipmentTagTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests/ModbusEquipmentTagTests.cs
new file mode 100644
index 00000000..7500bdbe
--- /dev/null
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests/ModbusEquipmentTagTests.cs
@@ -0,0 +1,61 @@
+using Shouldly;
+using Xunit;
+using ZB.MOM.WW.OtOpcUa.Driver.Modbus;
+
+namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests;
+
+[Trait("Category", "Unit")]
+public class ModbusEquipmentTagTests
+{
+ [Fact]
+ public void Parses_equipment_tagconfig_into_a_transient_definition()
+ {
+ var json = """{"region":"HoldingRegisters","address":40001,"dataType":"UInt16","byteOrder":"BigEndian","bitIndex":0,"stringLength":0}""";
+ ModbusEquipmentTagParser.TryParse(json, out var def).ShouldBeTrue();
+ def!.Name.ShouldBe(json);
+ def.Region.ShouldBe(ModbusRegion.HoldingRegisters);
+ def.Address.ShouldBe((ushort)40001);
+ def.DataType.ShouldBe(ModbusDataType.UInt16);
+ }
+
+ [Fact]
+ public void Rejects_a_non_address_blob()
+ => ModbusEquipmentTagParser.TryParse("""{"FullName":"x"}""", out _).ShouldBeFalse();
+
+ [Fact]
+ public void Rejects_garbage()
+ => ModbusEquipmentTagParser.TryParse("not json", out _).ShouldBeFalse();
+
+ [Fact]
+ public void Rejects_address_as_a_json_string()
+ => ModbusEquipmentTagParser.TryParse(
+ """{"region":"HoldingRegisters","address":"40001","dataType":"UInt16"}""", out _).ShouldBeFalse();
+
+ [Fact]
+ public void Rejects_address_out_of_ushort_range()
+ => ModbusEquipmentTagParser.TryParse(
+ """{"region":"HoldingRegisters","address":70000,"dataType":"UInt16"}""", out _).ShouldBeFalse();
+
+ ///
+ /// End-to-end driver-level proof: a Modbus driver with NO authored tags can still read an
+ /// equipment-tag ref (the raw TagConfig JSON) — the resolver parses it into a transient
+ /// definition and the read goes to the wire instead of returning BadNodeIdUnknown.
+ ///
+ [Fact]
+ public async Task Driver_resolves_an_equipment_ref_and_reads_instead_of_BadNodeIdUnknown()
+ {
+ // Address 10 fits the fake transport's 256-register bank (40001 would overflow it).
+ var json = """{"region":"HoldingRegisters","address":10,"dataType":"UInt16","byteOrder":"BigEndian","bitIndex":0,"stringLength":0}""";
+ var fake = new ModbusDriverTests.FakeTransport();
+ var opts = new ModbusDriverOptions { Host = "fake", Tags = [] };
+ var drv = new ModbusDriver(opts, "modbus-eq", _ => fake);
+ await drv.InitializeAsync("{}", CancellationToken.None);
+ fake.HoldingRegisters[10] = 4242;
+
+ var r = await drv.ReadAsync([json], CancellationToken.None);
+
+ r[0].StatusCode.ShouldBe(0u);
+ r[0].StatusCode.ShouldNotBe(0x80340000u); // not BadNodeIdUnknown
+ r[0].Value.ShouldBe((ushort)4242);
+ }
+}