From 232c557985ffd985318ff3ee22716483459c5ac4 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Sat, 13 Jun 2026 11:12:01 -0400 Subject: [PATCH] feat(modbus): resolve equipment-tag refs (read + write) via EquipmentTagRefResolver --- .../ModbusEquipmentTagParser.cs | 54 ++++++++++++++++ .../ModbusDriver.cs | 17 ++++-- .../ModbusEquipmentTagTests.cs | 61 +++++++++++++++++++ 3 files changed, 128 insertions(+), 4 deletions(-) create mode 100644 src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Contracts/ModbusEquipmentTagParser.cs create mode 100644 tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests/ModbusEquipmentTagTests.cs 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); + } +}