diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Contracts/FocasEquipmentTagParser.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Contracts/FocasEquipmentTagParser.cs
new file mode 100644
index 00000000..94bd1000
--- /dev/null
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Contracts/FocasEquipmentTagParser.cs
@@ -0,0 +1,49 @@
+using System.Text.Json;
+
+namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
+
+/// Parses an equipment tag's TagConfig JSON (the shape authored by the AdminUI
+/// FocasTagConfigModel) 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 FocasEquipmentTagParser
+{
+ /// 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 FOCAS address object.
+ public static bool TryParse(string reference, out FocasTagDefinition 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;
+ // The address string is FOCAS's sole mandatory address field (FocasTagConfigModel.Validate).
+ if (root.ValueKind != JsonValueKind.Object
+ || !root.TryGetProperty("address", out var addr)
+ || addr.ValueKind != JsonValueKind.String)
+ return false;
+ var address = addr.GetString();
+ if (string.IsNullOrWhiteSpace(address)) return false;
+ var dataType = ReadEnum(root, "dataType", FocasDataType.Int32);
+ var deviceHostAddress = ReadString(root, "deviceHostAddress") ?? "";
+ def = new FocasTagDefinition(
+ Name: reference, DeviceHostAddress: deviceHostAddress, Address: address,
+ DataType: dataType, Writable: true);
+ 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 string? ReadString(JsonElement o, string name)
+ => o.TryGetProperty(name, out var e) && e.ValueKind == JsonValueKind.String ? e.GetString() : null;
+}
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasDriver.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasDriver.cs
index fefc713a..f20f187d 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasDriver.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasDriver.cs
@@ -27,6 +27,10 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
private readonly ILogger _logger;
private readonly Dictionary _devices = new(StringComparer.OrdinalIgnoreCase);
private readonly Dictionary _tagsByName = new(StringComparer.OrdinalIgnoreCase);
+ // Resolves a read/write 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 FocasEquipmentTagParser, cached).
+ private readonly EquipmentTagRefResolver _resolver;
// Per-tag-name cache of the FocasAddress parsed once at InitializeAsync. ReadAsync /
// WriteAsync look up the pre-parsed value instead of re-parsing tag.Address on every hot
// call — resolves Driver.FOCAS-008.
@@ -59,6 +63,9 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
_driverInstanceId = driverInstanceId;
_clientFactory = clientFactory ?? new Wire.WireFocasClientFactory();
_logger = logger ?? NullLogger.Instance;
+ _resolver = new EquipmentTagRefResolver(
+ r => _tagsByName.TryGetValue(r, out var t) ? t : null,
+ r => FocasEquipmentTagParser.TryParse(r, out var d) ? d : null);
_poll = new PollGroupEngine(
reader: ReadAsync,
onChange: (handle, tagRef, snapshot) =>
@@ -198,6 +205,7 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
}
_devices.Clear();
_tagsByName.Clear();
+ _resolver.Clear(); // drop transient equipment-tag parses so a config change re-parses
_parsedAddressesByTagName.Clear();
Volatile.Write(ref _health, new DriverHealth(DriverState.Unknown, Volatile.Read(ref _health).LastSuccessfulRead, null));
}
@@ -248,7 +256,7 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
continue;
}
- if (!_tagsByName.TryGetValue(reference, out var def))
+ if (!_resolver.TryResolve(reference, out var def))
{
results[i] = new DataValueSnapshot(null, FocasStatusMapper.BadNodeIdUnknown, null, now);
continue;
@@ -307,7 +315,7 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
for (var i = 0; i < writes.Count; i++)
{
var w = writes[i];
- if (!_tagsByName.TryGetValue(w.FullReference, out var def))
+ if (!_resolver.TryResolve(w.FullReference, out var def))
{
results[i] = new WriteResult(FocasStatusMapper.BadNodeIdUnknown);
continue;
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasEquipmentTagTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasEquipmentTagTests.cs
new file mode 100644
index 00000000..f539b5e7
--- /dev/null
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasEquipmentTagTests.cs
@@ -0,0 +1,67 @@
+using Shouldly;
+using Xunit;
+using ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
+
+namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests;
+
+[Trait("Category", "Unit")]
+public class FocasEquipmentTagTests
+{
+ [Fact]
+ public void Parses_equipment_tagconfig_into_a_transient_definition()
+ {
+ var json = """{"deviceHostAddress":"focas://10.0.0.5:8193","address":"R100","dataType":"Byte"}""";
+ FocasEquipmentTagParser.TryParse(json, out var def).ShouldBeTrue();
+ def!.Name.ShouldBe(json);
+ def.DeviceHostAddress.ShouldBe("focas://10.0.0.5:8193");
+ def.Address.ShouldBe("R100");
+ def.DataType.ShouldBe(FocasDataType.Byte);
+ }
+
+ [Fact]
+ public void Rejects_a_non_address_blob()
+ => FocasEquipmentTagParser.TryParse("""{"FullName":"x"}""", out _).ShouldBeFalse();
+
+ [Fact]
+ public void Rejects_garbage()
+ => FocasEquipmentTagParser.TryParse("not json", out _).ShouldBeFalse();
+
+ [Fact]
+ public void Rejects_address_as_a_json_number()
+ => FocasEquipmentTagParser.TryParse(
+ """{"address":100,"dataType":"Int32"}""", out _).ShouldBeFalse();
+
+ [Fact]
+ public void Rejects_a_blank_address()
+ => FocasEquipmentTagParser.TryParse(
+ """{"address":" ","dataType":"Int32"}""", out _).ShouldBeFalse();
+
+ // FOCAS's only mandatory address field is the canonical Address STRING (no narrow numeric
+ // TagConfig field like Modbus's ushort register address), so the Modbus "numeric out-of-range"
+ // rejection case has no analog here — covered instead by the json-number + blank-address cases.
+
+ ///
+ /// End-to-end driver-level proof: a FOCAS 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()
+ {
+ var json = """{"deviceHostAddress":"focas://10.0.0.5:8193","address":"R100","dataType":"Byte"}""";
+ var factory = new FakeFocasClientFactory { Customise = () => new FakeFocasClient { Values = { ["R100"] = (sbyte)42 } } };
+ var drv = new FocasDriver(new FocasDriverOptions
+ {
+ Devices = [new FocasDeviceOptions("focas://10.0.0.5:8193")],
+ Tags = [],
+ Probe = new FocasProbeOptions { Enabled = false },
+ }, "focas-eq", factory);
+ await drv.InitializeAsync("{}", CancellationToken.None);
+
+ var r = await drv.ReadAsync([json], CancellationToken.None);
+
+ r[0].StatusCode.ShouldBe(FocasStatusMapper.Good);
+ r[0].StatusCode.ShouldNotBe(FocasStatusMapper.BadNodeIdUnknown);
+ r[0].Value.ShouldBe((sbyte)42);
+ }
+}