diff --git a/src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Cli/FocasCommandBase.cs b/src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Cli/FocasCommandBase.cs
index bc994a50..62965d52 100644
--- a/src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Cli/FocasCommandBase.cs
+++ b/src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Cli/FocasCommandBase.cs
@@ -1,4 +1,5 @@
using CliFx.Attributes;
+using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.Cli.Common;
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Cli;
@@ -54,7 +55,11 @@ public abstract class FocasCommandBase : DriverCommandBase
HostAddress: HostAddress,
DeviceName: $"cli-{CncHost}:{CncPort}",
Series: Series)],
- Tags = tags,
+ // v3: turn each typed def into the RawTagEntry delivery unit — TagConfig blob via
+ // FocasTagDefinitionFactory.ToTagConfig, keyed by the def's synthesised name (RawPath), routed to
+ // the device by the def's DeviceHostAddress (which the driver matches against Devices).
+ RawTags = [.. tags.Select(t => new RawTagEntry(
+ t.Name, FocasTagDefinitionFactory.ToTagConfig(t), t.WriteIdempotent, t.DeviceHostAddress))],
Timeout = Timeout,
Probe = new FocasProbeOptions { Enabled = false },
};
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Contracts/FocasDriverOptions.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Contracts/FocasDriverOptions.cs
index ef60b648..da05b679 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Contracts/FocasDriverOptions.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Contracts/FocasDriverOptions.cs
@@ -1,4 +1,5 @@
using System.ComponentModel.DataAnnotations;
+using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
@@ -10,8 +11,16 @@ public sealed class FocasDriverOptions
{
/// Gets the list of configured CNC devices.
public IReadOnlyList Devices { get; init; } = [];
- /// Gets the list of FOCAS tag definitions.
- 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,
+ /// routing each tag to its device by . FOCAS has no online
+ /// tag-discovery for user tags — the driver serves exactly these (plus the fixed-node tree).
+ ///
+ public IReadOnlyList RawTags { get; init; } = [];
/// Gets the probe options.
public FocasProbeOptions Probe { get; init; } = new();
/// Gets the timeout duration for operations.
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
deleted file mode 100644
index 1fe27347..00000000
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Contracts/FocasEquipmentTagParser.cs
+++ /dev/null
@@ -1,87 +0,0 @@
-using System.Text.Json;
-using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
-
-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;
- // Strict enum read (R2-11 Phase C): a typo'd dataType rejects the tag (→ BadNodeIdUnknown)
- // instead of silently defaulting to a wrong-width Good.
- if (!TagConfigJson.TryReadEnumStrict(root, "dataType", FocasDataType.Int32, out var dataType)) return false;
- var deviceHostAddress = ReadString(root, "deviceHostAddress") ?? "";
- def = new FocasTagDefinition(
- Name: reference, DeviceHostAddress: deviceHostAddress, Address: address,
- // FOCAS equipment tags are FORCED read-only (05/UNDER-1): the only wire client
- // (WireFocasClient.cs:71-73) returns BadNotWritable for EVERY address, so advertising a
- // writable node is a lie — a write that used to reach the backend and always fail with
- // BadNotWritable now fails at the driver seam with the same family of Bad status (no
- // successful operation changes). Any authored `writable:true` is ignored + warned by
- // Inspect. Honour the key here once PMC writes ship in the wire client.
- DataType: dataType, Writable: false);
- return true;
- }
- catch (JsonException) { return false; }
- catch (FormatException) { return false; }
- catch (InvalidOperationException) { return false; }
- }
-
- ///
- /// Deploy-time inspection (05/CONV-2, 05/UNDER-1): warns on a present-but-invalid dataType
- /// (silently defaulted by the lenient runtime), on an authored writable:true (FOCAS writes
- /// are unsupported — the tag is forced read-only), and on a structurally unparseable TagConfig.
- /// Empty when clean or not an equipment-tag object. Never throws.
- ///
- /// The equipment tag's TagConfig JSON.
- /// The warnings; empty when clean.
- public static IReadOnlyList Inspect(string reference)
- {
- var warnings = new List();
- if (string.IsNullOrWhiteSpace(reference) || reference[0] != '{') return warnings;
- try
- {
- using var doc = JsonDocument.Parse(reference);
- var root = doc.RootElement;
- if (root.ValueKind != JsonValueKind.Object)
- {
- warnings.Add("FOCAS TagConfig root is not a JSON object — the tag will not resolve (BadNodeIdUnknown).");
- return warnings;
- }
- var w = TagConfigJson.DescribeInvalidEnum(root, "dataType");
- if (w is not null) warnings.Add(w);
- if (root.TryGetProperty("writable", out var wr) && wr.ValueKind == JsonValueKind.True)
- warnings.Add("FOCAS writes unsupported — 'writable:true' is ignored; the tag is forced read-only until PMC writes exist.");
- }
- catch (JsonException)
- {
- warnings.Add("FOCAS TagConfig is not valid JSON — the tag will not resolve (BadNodeIdUnknown).");
- }
- return warnings;
- }
-
- 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.Contracts/FocasTagDefinitionFactory.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Contracts/FocasTagDefinitionFactory.cs
new file mode 100644
index 00000000..7bef3447
--- /dev/null
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Contracts/FocasTagDefinitionFactory.cs
@@ -0,0 +1,129 @@
+using System.Text.Json;
+using System.Text.Json.Nodes;
+using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
+
+namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
+
+///
+/// v3 pure mapper: turns an authored raw tag's TagConfig JSON (the shape produced by the
+/// AdminUI FocasTagConfigModel) 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 FocasTagDefinitionFactory
+{
+ ///
+ /// 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. The FOCAS TagConfig carries no device host — the
+ /// driver resolves the device from the tag's , so the produced
+ /// definition's is left empty here and filled in by
+ /// the driver at table-build time. Writability lives on the Tag entity / node ACL (the authored FOCAS
+ /// TagConfig has no writable key), so an absent key ⇒ read-only; an explicit writable:true
+ /// is honoured for the driver's internal write gate (the wire itself is still read-only).
+ /// 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 FOCAS address object.
+ public static bool FromTagConfig(string tagConfig, string rawPath, out FocasTagDefinition def)
+ {
+ def = null!;
+ if (string.IsNullOrWhiteSpace(tagConfig)) return false;
+ try
+ {
+ using var doc = JsonDocument.Parse(tagConfig);
+ 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;
+ // Strict enum read (R2-11 Phase C): a typo'd dataType rejects the tag (→ BadNodeIdUnknown)
+ // instead of silently defaulting to a wrong-width Good.
+ if (!TagConfigJson.TryReadEnumStrict(root, "dataType", FocasDataType.Int32, out var dataType)) return false;
+ // FOCAS authored tags carry no `writable` key (writability lives on the Tag entity / node ACL):
+ // absent ⇒ read-only. An explicit writable:true is honoured for the driver's internal write gate
+ // (default-false, so a missing key stays read-only — the note is preserved).
+ var writable = root.TryGetProperty("writable", out var wr) && wr.ValueKind == JsonValueKind.True;
+ def = new FocasTagDefinition(
+ Name: rawPath,
+ // The device is resolved by the driver from RawTagEntry.DeviceName; empty here.
+ DeviceHostAddress: "",
+ Address: address,
+ DataType: dataType,
+ Writable: writable,
+ WriteIdempotent: false);
+ return true;
+ }
+ catch (JsonException) { return false; }
+ catch (FormatException) { return false; }
+ 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 a
+ /// from operator flags and need the TagConfig string to hand
+ /// the driver as a . The enum is written as its name string. Neither the
+ /// device host (which travels on ) nor WriteIdempotent
+ /// (which travels on the ) is emitted.
+ ///
+ /// The definition to serialise.
+ /// The camelCase TagConfig JSON string.
+ public static string ToTagConfig(FocasTagDefinition def)
+ {
+ ArgumentNullException.ThrowIfNull(def);
+ var o = new JsonObject
+ {
+ ["address"] = def.Address,
+ ["dataType"] = def.DataType.ToString(),
+ ["writable"] = def.Writable,
+ };
+ return o.ToJsonString();
+ }
+
+ ///
+ /// Deploy-time inspection (05/CONV-2, 05/UNDER-1): warns on a present-but-invalid dataType
+ /// (silently defaulted by the lenient runtime), on an authored writable:true (the FOCAS wire
+ /// is read-only — WriteAsync always returns BadNotWritable), and on a structurally
+ /// unparseable TagConfig. Empty when clean or not an equipment-tag object. Never throws.
+ ///
+ /// The equipment tag's TagConfig JSON.
+ /// The warnings; empty when clean.
+ public static IReadOnlyList Inspect(string reference)
+ {
+ var warnings = new List();
+ if (string.IsNullOrWhiteSpace(reference) || reference[0] != '{') return warnings;
+ try
+ {
+ using var doc = JsonDocument.Parse(reference);
+ var root = doc.RootElement;
+ if (root.ValueKind != JsonValueKind.Object)
+ {
+ warnings.Add("FOCAS TagConfig root is not a JSON object — the tag will not resolve (BadNodeIdUnknown).");
+ return warnings;
+ }
+ var w = TagConfigJson.DescribeInvalidEnum(root, "dataType");
+ if (w is not null) warnings.Add(w);
+ if (root.TryGetProperty("writable", out var wr) && wr.ValueKind == JsonValueKind.True)
+ warnings.Add("FOCAS wire is read-only — an authored 'writable:true' still fails at the wire with BadNotWritable.");
+ }
+ catch (JsonException)
+ {
+ warnings.Add("FOCAS TagConfig is not valid JSON — the tag will not resolve (BadNodeIdUnknown).");
+ }
+ return warnings;
+ }
+}
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 9df8851c..1c3b654c 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasDriver.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasDriver.cs
@@ -27,10 +27,11 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
private readonly PollGroupEngine _poll;
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).
+ // v3: RawPath → definition, case-sensitive ordinal. Built in InitializeAsync from _options.RawTags
+ // via the pure FocasTagDefinitionFactory mapper (the reference is always a RawPath).
+ private readonly Dictionary _tagsByRawPath = new(StringComparer.Ordinal);
+ // Resolves a read/write fullReference (always 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 blob-parse.
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
@@ -67,8 +68,7 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
_clientFactory = clientFactory ?? new Wire.WireFocasClientFactory();
_logger = logger ?? NullLogger.Instance;
_resolver = new EquipmentTagRefResolver(
- r => _tagsByName.TryGetValue(r, out var t) ? t : null,
- ResolveEquipmentTagRef);
+ r => _tagsByRawPath.TryGetValue(r, out var t) ? t : null);
_poll = new PollGroupEngine(
reader: ReadAsync,
onChange: (handle, tagRef, snapshot) =>
@@ -118,31 +118,61 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
$"FOCAS device has invalid HostAddress '{device.HostAddress}' — expected 'focas://{{ip}}[:{{port}}]'.");
_devices[device.HostAddress] = new DeviceState(addr, device);
}
- // Pre-flight: validate every tag's address against the declared CNC
- // series so misconfigured addresses fail at init (clear config error)
- // instead of producing BadOutOfRange on every read at runtime.
- // Series=Unknown short-circuits the matrix; pre-matrix configs stay permissive.
- foreach (var tag in _options.Tags)
+ // Build the RawPath → definition table from the authored raw tags. Each entry's TagConfig
+ // blob is mapped by the pure factory; the entry's WriteIdempotent flag + DeviceName are
+ // threaded onto the def (they live on the RawTagEntry, not inside the blob). Per-tag misses
+ // are tolerant (skipped + logged → BadNodeIdUnknown at read, "a miss is a miss") — a single
+ // bad tag must not fail the whole driver init. The ONE hard failure is an unknown device
+ // segment: a tag routed to a device not in the Devices list is a driver-level config error
+ // that fails fast (Driver.FOCAS-003).
+ foreach (var entry in _options.RawTags)
{
- var parsed = FocasAddress.TryParse(tag.Address)
- ?? throw new InvalidOperationException(
- $"FOCAS tag '{tag.Name}' has invalid Address '{tag.Address}'. " +
- $"Expected forms: R100, R100.3, PARAM:1815/0, MACRO:500.");
- if (!_devices.TryGetValue(tag.DeviceHostAddress, out var device))
+ if (!FocasTagDefinitionFactory.FromTagConfig(entry.TagConfig, entry.RawPath, out var def))
+ {
+ _logger.LogWarning(
+ "FOCAS tag config did not map to a definition; skipping. Driver={DriverInstanceId} RawPath={RawPath}",
+ _driverInstanceId, entry.RawPath);
+ continue;
+ }
+ // TODO(v3 WaveC): the live device host must come from the tag's Device row's DeviceConfig
+ // (keyed by the RawPath's device segment). Until Wave C threads DeviceConfig through, recover
+ // the host by matching the entry's DeviceName against the configured Devices list.
+ var deviceHost = ResolveDeviceHost(entry.DeviceName);
+ if (deviceHost is null)
throw new InvalidOperationException(
- $"FOCAS tag '{tag.Name}' references device '{tag.DeviceHostAddress}' " +
+ $"FOCAS tag '{entry.RawPath}' references device '{entry.DeviceName}' " +
$"which is not in the Devices list. Check for a typo (e.g. wrong port or hostname).");
+ if (!_devices.TryGetValue(deviceHost, out var device))
+ throw new InvalidOperationException(
+ $"FOCAS tag '{entry.RawPath}' references device '{entry.DeviceName}' " +
+ $"which is not in the Devices list. Check for a typo (e.g. wrong port or hostname).");
+ // Per-tag address / capability-matrix validation is tolerant: an unparseable address or a
+ // capability-matrix violation skips the tag (→ BadNodeIdUnknown at read) rather than failing
+ // the whole init (R2-11 05/UNDER-6 — the same pre-flight, surfacing a resolver miss).
+ var parsed = FocasAddress.TryParse(def.Address);
+ if (parsed is null)
+ {
+ _logger.LogWarning(
+ "FOCAS tag has invalid Address '{Address}'; skipping. Driver={DriverInstanceId} RawPath={RawPath}",
+ def.Address, _driverInstanceId, entry.RawPath);
+ continue;
+ }
if (FocasCapabilityMatrix.Validate(device.Options.Series, parsed) is { } reason)
{
- throw new InvalidOperationException(
- $"FOCAS tag '{tag.Name}' ({tag.Address}) rejected by capability matrix: {reason}");
+ _logger.LogWarning(
+ "FOCAS tag '{RawPath}' ({Address}) rejected by capability matrix: {Reason}; skipping. Driver={DriverInstanceId}",
+ entry.RawPath, def.Address, reason, _driverInstanceId);
+ continue;
}
- _tagsByName[tag.Name] = tag;
+ _tagsByRawPath[entry.RawPath] = def with
+ {
+ DeviceHostAddress = deviceHost,
+ WriteIdempotent = entry.WriteIdempotent,
+ };
// Cache the parsed FocasAddress so ReadAsync / WriteAsync don't re-parse on every
- // hot-path call. The address string has already been validated
- // by FocasAddress.TryParse above; reusing the parsed record avoids per-tick allocs
- // on subscription pollers.
- _parsedAddressesByTagName[tag.Name] = parsed;
+ // hot-path call. The address string has already been validated by FocasAddress.TryParse
+ // above; reusing the parsed record avoids per-tick allocs on subscription pollers.
+ _parsedAddressesByTagName[entry.RawPath] = parsed;
}
if (_options.Probe.Enabled)
@@ -223,8 +253,8 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
state.DisposeClient();
}
_devices.Clear();
- _tagsByName.Clear();
- _resolver.Clear(); // drop transient equipment-tag parses so a config change re-parses
+ _tagsByRawPath.Clear();
+ _resolver.Clear(); // no-op in v3 (the resolver reads the live table by closure); kept for symmetry
_parsedAddressesByTagName.Clear();
Volatile.Write(ref _health, new DriverHealth(DriverState.Unknown, Volatile.Read(ref _health).LastSuccessfulRead, null));
}
@@ -254,18 +284,22 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
internal bool IsParsedAddressCached(string reference) =>
_parsedAddressesByTagName.ContainsKey(reference);
- // Resolves an equipment-tag TagConfig reference into a FocasTagDefinition, applying the SAME
- // capability pre-flight authored tags get at InitializeAsync (:105-119) — R2-11 (05/UNDER-6). A
- // reference whose address is unparseable, whose bound device is unknown, or whose address violates
- // the device series' capability matrix returns null ⇒ resolver miss ⇒ BadNodeIdUnknown at read time,
- // instead of a wrong-status read reaching the wire. The negative is cached by the resolver.
- private FocasTagDefinition? ResolveEquipmentTagRef(string reference)
+ // TODO(v3 WaveC): the live device host must come from the tag's Device row's DeviceConfig (keyed by
+ // the RawPath's device segment). Until Wave C threads DeviceConfig through, recover the host by matching
+ // the RawTagEntry's DeviceName against the configured Devices list (by DeviceName, falling back to
+ // HostAddress; and, for a single-device driver, an empty DeviceName routes to the sole device). Returns
+ // null when no device matches — the caller fails the tag fast (Driver.FOCAS-003).
+ private string? ResolveDeviceHost(string deviceName)
{
- if (!FocasEquipmentTagParser.TryParse(reference, out var def)) return null;
- var parsed = FocasAddress.TryParse(def.Address);
- if (parsed is null) return null;
- if (!_devices.TryGetValue(def.DeviceHostAddress, out var device)) return null;
- return FocasCapabilityMatrix.Validate(device.Options.Series, parsed) is null ? def : null;
+ foreach (var d in _options.Devices)
+ {
+ if (string.Equals(d.DeviceName ?? d.HostAddress, deviceName, StringComparison.OrdinalIgnoreCase)
+ || string.Equals(d.HostAddress, deviceName, StringComparison.OrdinalIgnoreCase))
+ return d.HostAddress;
+ }
+ if (string.IsNullOrEmpty(deviceName) && _options.Devices.Count == 1)
+ return _options.Devices[0].HostAddress;
+ return null;
}
// Resolves a tag definition to its parsed FocasAddress, caching the result so that
@@ -516,7 +550,7 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
}
}
- var tagsForDevice = _options.Tags.Where(t =>
+ var tagsForDevice = _tagsByRawPath.Values.Where(t =>
string.Equals(t.DeviceHostAddress, device.HostAddress, StringComparison.OrdinalIgnoreCase));
foreach (var tag in tagsForDevice)
{
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasDriverFactoryExtensions.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasDriverFactoryExtensions.cs
index e878c7a1..733e6cc8 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasDriverFactoryExtensions.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasDriverFactoryExtensions.cs
@@ -1,5 +1,6 @@
using System.Text.Json;
using System.Text.Json.Serialization;
+using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Core.Hosting;
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Wire;
@@ -68,18 +69,11 @@ public static class FocasDriverFactoryExtensions
Series: ParseSeries(d.Series ?? dto.Series),
PositionDecimalPlaces: d.PositionDecimalPlaces ?? 0))]
: [],
- Tags = dto.Tags is { Count: > 0 }
- ? [.. dto.Tags.Select(t => new FocasTagDefinition(
- Name: t.Name ?? throw new InvalidOperationException(
- $"FOCAS config for '{driverInstanceId}' has a tag missing Name"),
- DeviceHostAddress: t.DeviceHostAddress ?? throw new InvalidOperationException(
- $"FOCAS tag '{t.Name}' in '{driverInstanceId}' missing DeviceHostAddress"),
- Address: t.Address ?? throw new InvalidOperationException(
- $"FOCAS tag '{t.Name}' in '{driverInstanceId}' missing Address"),
- DataType: ParseDataType(t.DataType, t.Name!, driverInstanceId),
- Writable: t.Writable ?? true,
- WriteIdempotent: t.WriteIdempotent ?? false))]
- : [],
+ // v3: the deploy artifact (Wave C) populates rawTags with the cluster's authored raw FOCAS
+ // tags (RawPath + TagConfig blob + WriteIdempotent + DeviceName). The driver maps each
+ // RawTagEntry's TagConfig blob through FocasTagDefinitionFactory at Initialize and routes it to
+ // its device by DeviceName; the legacy pre-declared DTO tag path is retired.
+ RawTags = dto.RawTags is { Count: > 0 } ? [.. dto.RawTags] : [],
Probe = new FocasProbeOptions
{
Enabled = dto.Probe?.Enabled ?? true,
@@ -190,18 +184,6 @@ public static class FocasDriverFactoryExtensions
$"FOCAS Series '{raw}' is not one of {string.Join(", ", Enum.GetNames())}");
}
- private static FocasDataType ParseDataType(string? raw, string tagName, string driverInstanceId)
- {
- if (string.IsNullOrWhiteSpace(raw))
- throw new InvalidOperationException(
- $"FOCAS tag '{tagName}' in '{driverInstanceId}' missing DataType");
- return Enum.TryParse(raw, ignoreCase: true, out var dt)
- ? dt
- : throw new InvalidOperationException(
- $"FOCAS tag '{tagName}' has unknown DataType '{raw}'. " +
- $"Expected one of {string.Join(", ", Enum.GetNames())}");
- }
-
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNameCaseInsensitive = true,
@@ -254,8 +236,11 @@ public static class FocasDriverFactoryExtensions
/// Gets or sets the list of CNC devices to manage.
public List? Devices { get; init; }
- /// Gets or sets the list of FOCAS tags to poll.
- public List? Tags { get; init; }
+ ///
+ /// Gets or sets the authored raw tags (RawPath + TagConfig blob + WriteIdempotent + DeviceName)
+ /// the driver serves. Bound verbatim into .
+ ///
+ public List? RawTags { get; init; }
/// Gets or sets the probe configuration options.
public FocasProbeDto? Probe { get; init; }
@@ -290,27 +275,6 @@ public static class FocasDriverFactoryExtensions
public int? PositionDecimalPlaces { get; init; }
}
- internal sealed class FocasTagDto
- {
- /// Gets or sets the tag name.
- public string? Name { get; init; }
-
- /// Gets or sets the hostname or IP address of the CNC device for this tag.
- public string? DeviceHostAddress { get; init; }
-
- /// Gets or sets the FOCAS address or path for this tag.
- public string? Address { get; init; }
-
- /// Gets or sets the data type for this tag.
- public string? DataType { get; init; }
-
- /// Gets or sets a value indicating whether this tag is writable.
- public bool? Writable { get; init; }
-
- /// Gets or sets a value indicating whether writes to this tag are idempotent.
- public bool? WriteIdempotent { get; init; }
- }
-
internal sealed class FocasProbeDto
{
/// Gets or sets a value indicating whether probing is enabled.
diff --git a/tests/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Cli.Tests/FocasCommandBaseBuildOptionsTests.cs b/tests/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Cli.Tests/FocasCommandBaseBuildOptionsTests.cs
index f2e34733..52061a5d 100644
--- a/tests/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Cli.Tests/FocasCommandBaseBuildOptionsTests.cs
+++ b/tests/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Cli.Tests/FocasCommandBaseBuildOptionsTests.cs
@@ -74,9 +74,12 @@ public sealed class FocasCommandBaseBuildOptionsTests
options.Devices[0].Series.ShouldBe(FocasCncSeries.Zero_i_F);
}
- /// Verifies that BuildOptions forwards tag list verbatim.
+ ///
+ /// Verifies that BuildOptions turns each typed def into a v3 RawTagEntry — RawPath = def.Name,
+ /// DeviceName = def.DeviceHostAddress, and a TagConfig blob that round-trips through the mapper.
+ ///
[Fact]
- public void BuildOptions_forwards_tag_list_verbatim()
+ public void BuildOptions_maps_tags_to_rawtag_entries()
{
var sut = new ProbeOnly { CncHost = "h" };
var tag = new FocasTagDefinition(
@@ -88,7 +91,14 @@ public sealed class FocasCommandBaseBuildOptionsTests
var options = sut.Invoke([tag]);
- options.Tags.Count.ShouldBe(1);
- options.Tags[0].ShouldBeSameAs(tag);
+ options.RawTags.Count.ShouldBe(1);
+ var entry = options.RawTags[0];
+ entry.RawPath.ShouldBe("t");
+ entry.DeviceName.ShouldBe("focas://h:8193");
+ entry.WriteIdempotent.ShouldBeFalse();
+ FocasTagDefinitionFactory.FromTagConfig(entry.TagConfig, entry.RawPath, out var round).ShouldBeTrue();
+ round.Address.ShouldBe("R100");
+ round.DataType.ShouldBe(FocasDataType.Int16);
+ round.Writable.ShouldBeFalse();
}
}
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasAlarmProjectionTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasAlarmProjectionTests.cs
index 31663e29..0bb541b5 100644
--- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasAlarmProjectionTests.cs
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasAlarmProjectionTests.cs
@@ -15,7 +15,7 @@ public sealed class FocasAlarmProjectionTests
var drv = new FocasDriver(new FocasDriverOptions
{
Devices = [new FocasDeviceOptions(Host)],
- Tags = [],
+ RawTags = [],
Probe = new FocasProbeOptions { Enabled = false },
AlarmProjection = new FocasAlarmProjectionOptions
{
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasCapabilityTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasCapabilityTests.cs
index 2b6597d0..54c6243d 100644
--- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasCapabilityTests.cs
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasCapabilityTests.cs
@@ -19,11 +19,11 @@ public sealed class FocasCapabilityTests
var drv = new FocasDriver(new FocasDriverOptions
{
Devices = [new FocasDeviceOptions("focas://10.0.0.5:8193", DeviceName: "Lathe-1")],
- Tags =
+ RawTags = FocasRawTags.Entries(
[
new FocasTagDefinition("Run", "focas://10.0.0.5:8193", "R100", FocasDataType.Byte),
new FocasTagDefinition("Alarm", "focas://10.0.0.5:8193", "R200", FocasDataType.Byte, Writable: false),
- ],
+ ]),
Probe = new FocasProbeOptions { Enabled = false },
}, "drv-1", new FakeFocasClientFactory());
await drv.InitializeAsync("{}", CancellationToken.None);
@@ -51,7 +51,7 @@ public sealed class FocasCapabilityTests
var drv = new FocasDriver(new FocasDriverOptions
{
Devices = [new FocasDeviceOptions("focas://10.0.0.5:8193")],
- Tags = [new FocasTagDefinition("X", "focas://10.0.0.5:8193", "R100", FocasDataType.Byte)],
+ RawTags = [FocasRawTags.Entry(new FocasTagDefinition("X", "focas://10.0.0.5:8193", "R100", FocasDataType.Byte))],
Probe = new FocasProbeOptions { Enabled = false },
}, "drv-1", factory);
await drv.InitializeAsync("{}", CancellationToken.None);
@@ -77,7 +77,7 @@ public sealed class FocasCapabilityTests
var drv = new FocasDriver(new FocasDriverOptions
{
Devices = [new FocasDeviceOptions("focas://10.0.0.5:8193")],
- Tags = [new FocasTagDefinition("X", "focas://10.0.0.5:8193", "R100", FocasDataType.Byte)],
+ RawTags = [FocasRawTags.Entry(new FocasTagDefinition("X", "focas://10.0.0.5:8193", "R100", FocasDataType.Byte))],
Probe = new FocasProbeOptions { Enabled = false },
}, "drv-1", factory);
await drv.InitializeAsync("{}", CancellationToken.None);
@@ -181,11 +181,11 @@ public sealed class FocasCapabilityTests
new FocasDeviceOptions("focas://10.0.0.5:8193"),
new FocasDeviceOptions("focas://10.0.0.6:8193"),
],
- Tags =
+ RawTags = FocasRawTags.Entries(
[
new FocasTagDefinition("A", "focas://10.0.0.5:8193", "R100", FocasDataType.Byte),
new FocasTagDefinition("B", "focas://10.0.0.6:8193", "R100", FocasDataType.Byte),
- ],
+ ]),
Probe = new FocasProbeOptions { Enabled = false },
}, "drv-1", new FakeFocasClientFactory());
await drv.InitializeAsync("{}", CancellationToken.None);
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasConnectBackoffTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasConnectBackoffTests.cs
index 318ecd1d..42bacb46 100644
--- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasConnectBackoffTests.cs
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasConnectBackoffTests.cs
@@ -17,7 +17,7 @@ public sealed class FocasConnectBackoffTests
private static FocasDriverOptions Options(bool probeEnabled) => new()
{
Devices = [new FocasDeviceOptions("focas://10.0.0.5:8193")],
- Tags = [new FocasTagDefinition("R", "focas://10.0.0.5:8193", "R100", FocasDataType.Byte)],
+ RawTags = [FocasRawTags.Entry(new FocasTagDefinition("R", "focas://10.0.0.5:8193", "R100", FocasDataType.Byte))],
Probe = new FocasProbeOptions
{
Enabled = probeEnabled,
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasDriverMediumFindingsTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasDriverMediumFindingsTests.cs
index a3d99867..9a7c517e 100644
--- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasDriverMediumFindingsTests.cs
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasDriverMediumFindingsTests.cs
@@ -27,11 +27,11 @@ public sealed class FocasDriverMediumFindingsTests
var drv = new FocasDriver(new FocasDriverOptions
{
Devices = [new FocasDeviceOptions("focas://10.0.0.5:8193")],
- Tags =
+ RawTags = FocasRawTags.Entries(
[
- // DeviceHostAddress has a port typo — not in Devices
+ // DeviceHostAddress (→ RawTagEntry.DeviceName) has a port typo — not in Devices
new FocasTagDefinition("X", "focas://10.0.0.5:9999", "R100", FocasDataType.Byte),
- ],
+ ]),
Probe = new FocasProbeOptions { Enabled = false },
}, "drv-1", new FakeFocasClientFactory());
@@ -49,11 +49,11 @@ public sealed class FocasDriverMediumFindingsTests
var drv = new FocasDriver(new FocasDriverOptions
{
Devices = [new FocasDeviceOptions("focas://10.0.0.5:8193")],
- Tags =
+ RawTags = FocasRawTags.Entries(
[
// Correct address so address validation passes
new FocasTagDefinition("TypoTag", "focas://10.0.0.99:8193", "R100", FocasDataType.Byte),
- ],
+ ]),
Probe = new FocasProbeOptions { Enabled = false },
}, "drv-1", new FakeFocasClientFactory());
@@ -74,11 +74,11 @@ public sealed class FocasDriverMediumFindingsTests
new FocasDeviceOptions("focas://10.0.0.5:8193"),
new FocasDeviceOptions("focas://10.0.0.6:8193"),
],
- Tags =
+ RawTags = FocasRawTags.Entries(
[
new FocasTagDefinition("A", "focas://10.0.0.5:8193", "R100", FocasDataType.Byte),
new FocasTagDefinition("B", "focas://10.0.0.6:8193", "R100", FocasDataType.Byte),
- ],
+ ]),
Probe = new FocasProbeOptions { Enabled = false },
}, "drv-1", new FakeFocasClientFactory());
@@ -97,12 +97,12 @@ public sealed class FocasDriverMediumFindingsTests
var drv = new FocasDriver(new FocasDriverOptions
{
Devices = [new FocasDeviceOptions("focas://10.0.0.5:8193")],
- Tags =
+ RawTags = FocasRawTags.Entries(
[
// Writable: true is the default — must still be ViewOnly
new FocasTagDefinition("Speed", "focas://10.0.0.5:8193", "R100", FocasDataType.Int16, Writable: true),
new FocasTagDefinition("Alarm", "focas://10.0.0.5:8193", "R200", FocasDataType.Byte, Writable: false),
- ],
+ ]),
Probe = new FocasProbeOptions { Enabled = false },
}, "drv-1", new FakeFocasClientFactory());
await drv.InitializeAsync("{}", CancellationToken.None);
@@ -132,11 +132,11 @@ public sealed class FocasDriverMediumFindingsTests
var drv = new FocasDriver(new FocasDriverOptions
{
Devices = [new FocasDeviceOptions("focas://10.0.0.5:8193")],
- Tags =
+ RawTags = FocasRawTags.Entries(
[
new FocasTagDefinition("Idempotent", "focas://10.0.0.5:8193", "R100", FocasDataType.Int16, WriteIdempotent: true),
new FocasTagDefinition("NonIdempotent", "focas://10.0.0.5:8193", "R200", FocasDataType.Int16, WriteIdempotent: false),
- ],
+ ]),
Probe = new FocasProbeOptions { Enabled = false },
}, "drv-1", new FakeFocasClientFactory());
await drv.InitializeAsync("{}", CancellationToken.None);
@@ -162,7 +162,7 @@ public sealed class FocasDriverMediumFindingsTests
var drv = new FocasDriver(new FocasDriverOptions
{
Devices = [new FocasDeviceOptions("focas://10.0.0.5:8193")],
- Tags = [new FocasTagDefinition("X", "focas://10.0.0.5:8193", "R100", FocasDataType.Byte)],
+ RawTags = [FocasRawTags.Entry(new FocasTagDefinition("X", "focas://10.0.0.5:8193", "R100", FocasDataType.Byte))],
Probe = new FocasProbeOptions { Enabled = false },
}, "drv-1", factory);
await drv.InitializeAsync("{}", CancellationToken.None);
@@ -191,7 +191,7 @@ public sealed class FocasDriverMediumFindingsTests
var drv = new FocasDriver(new FocasDriverOptions
{
Devices = [new FocasDeviceOptions("focas://10.0.0.5:8193")],
- Tags = [new FocasTagDefinition("X", "focas://10.0.0.5:8193", "R100", FocasDataType.Byte)],
+ RawTags = [FocasRawTags.Entry(new FocasTagDefinition("X", "focas://10.0.0.5:8193", "R100", FocasDataType.Byte))],
Probe = new FocasProbeOptions { Enabled = false },
}, "drv-1", factory);
await drv.InitializeAsync("{}", CancellationToken.None);
@@ -227,7 +227,7 @@ public sealed class FocasDriverMediumFindingsTests
var drv = new FocasDriver(new FocasDriverOptions
{
Devices = [new FocasDeviceOptions("focas://10.0.0.5:8193")],
- Tags = [new FocasTagDefinition("X", "focas://10.0.0.5:8193", "R100", FocasDataType.Byte)],
+ RawTags = [FocasRawTags.Entry(new FocasTagDefinition("X", "focas://10.0.0.5:8193", "R100", FocasDataType.Byte))],
Probe = new FocasProbeOptions { Enabled = false },
}, "drv-1", factory);
await drv.InitializeAsync("{}", CancellationToken.None);
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasEquipmentTagCapabilityGateTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasEquipmentTagCapabilityGateTests.cs
index bda91d72..0694be12 100644
--- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasEquipmentTagCapabilityGateTests.cs
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasEquipmentTagCapabilityGateTests.cs
@@ -5,57 +5,54 @@ using ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests;
///
-/// R2-11 (05/UNDER-6): an equipment-tag reference whose address violates the device series'
-/// capability matrix must fail to RESOLVE (surfacing BadNodeIdUnknown) — the same pre-flight
-/// authored tags get at InitializeAsync — instead of reaching the wire and failing later with a
-/// wire error. A capability-valid equipment tag still resolves + reads.
+/// R2-11 (05/UNDER-6): an authored raw tag whose address violates the device series' capability matrix
+/// must be dropped from the driver's table at InitializeAsync and surface BadNodeIdUnknown
+/// at read (a resolver miss) — instead of reaching the wire and failing later with a wire error. A
+/// capability-valid tag still resolves + reads. An authored tag routed to an unknown device is a
+/// driver-level config error that fails init fast (Driver.FOCAS-003).
///
[Trait("Category", "Unit")]
public sealed class FocasEquipmentTagCapabilityGateTests
{
private const string Host = "focas://10.0.0.5:8193";
- private static FocasDriver NewDriver() => new(new FocasDriverOptions
+ // Series 16i: macro range 0..999 — MACRO:9500 is out of range.
+ private static FocasDriver NewDriver(params FocasTagDefinition[] tags) => new(new FocasDriverOptions
{
- // Series 16i: macro range 0..999 — MACRO:9500 is out of range.
Devices = [new FocasDeviceOptions(Host, Series: FocasCncSeries.Sixteen_i)],
+ RawTags = FocasRawTags.Entries(tags),
Probe = new FocasProbeOptions { Enabled = false },
}, "drv-1", new FakeFocasClientFactory());
- private static string EquipTag(string address) =>
- $"{{\"address\":\"{address}\",\"dataType\":\"Int32\",\"deviceHostAddress\":\"{Host}\"}}";
-
[Fact]
- public async Task Capability_violating_equipment_tag_does_not_resolve()
+ public async Task Capability_violating_authored_tag_does_not_resolve()
{
- var drv = NewDriver();
+ var drv = NewDriver(new FocasTagDefinition("eq/Bad", Host, "MACRO:9500", FocasDataType.Int32));
await drv.InitializeAsync("{}", CancellationToken.None);
- var results = await drv.ReadAsync([EquipTag("MACRO:9500")], CancellationToken.None);
+ var results = await drv.ReadAsync(["eq/Bad"], CancellationToken.None);
results.Single().StatusCode.ShouldBe(FocasStatusMapper.BadNodeIdUnknown);
}
[Fact]
- public async Task Capability_valid_equipment_tag_resolves_and_reads()
+ public async Task Capability_valid_authored_tag_resolves_and_reads()
{
- var drv = NewDriver();
+ var drv = NewDriver(new FocasTagDefinition("eq/Good", Host, "MACRO:100", FocasDataType.Int32));
await drv.InitializeAsync("{}", CancellationToken.None);
- var results = await drv.ReadAsync([EquipTag("MACRO:100")], CancellationToken.None);
+ var results = await drv.ReadAsync(["eq/Good"], CancellationToken.None);
results.Single().StatusCode.ShouldNotBe(FocasStatusMapper.BadNodeIdUnknown);
}
[Fact]
- public async Task Equipment_tag_for_unknown_device_does_not_resolve()
+ public async Task Authored_tag_for_unknown_device_fails_init()
{
- var drv = NewDriver();
- await drv.InitializeAsync("{}", CancellationToken.None);
+ var drv = NewDriver(new FocasTagDefinition("eq/BadDevice", "focas://10.9.9.9:8193", "MACRO:100", FocasDataType.Int32));
- var badDevice = "{\"address\":\"MACRO:100\",\"dataType\":\"Int32\",\"deviceHostAddress\":\"focas://10.9.9.9:8193\"}";
- var results = await drv.ReadAsync([badDevice], CancellationToken.None);
-
- results.Single().StatusCode.ShouldBe(FocasStatusMapper.BadNodeIdUnknown);
+ var ex = await Should.ThrowAsync(
+ () => drv.InitializeAsync("{}", CancellationToken.None));
+ ex.Message.ShouldContain("not in the Devices list");
}
}
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasEquipmentTagParserStrictnessTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasEquipmentTagParserStrictnessTests.cs
deleted file mode 100644
index 846b0b52..00000000
--- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasEquipmentTagParserStrictnessTests.cs
+++ /dev/null
@@ -1,65 +0,0 @@
-using Shouldly;
-using Xunit;
-using ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
-
-namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests;
-
-///
-/// R2-11 Phase C (+ 05/UNDER-1) strictness surface for the FOCAS equipment-tag parser: the runtime is now
-/// STRICT — a typo'd dataType rejects the tag (TryParse ⇒ ⇒
-/// BadNodeIdUnknown); FOCAS tags remain forced read-only (Writable == false regardless of any
-/// authored writable:true), and Inspect reports the typo + the write-request.
-///
-public sealed class FocasEquipmentTagParserStrictnessTests
-{
- [Fact]
- public void Typo_dataType_rejects_the_tag()
- {
- FocasEquipmentTagParser.TryParse("{\"address\":\"D0100\",\"dataType\":\"Int322\"}", out _)
- .ShouldBeFalse();
- }
-
- [Fact]
- public void Valid_dataType_still_parses()
- {
- FocasEquipmentTagParser.TryParse("{\"address\":\"D0100\",\"dataType\":\"Int32\"}", out var def)
- .ShouldBeTrue();
- def.DataType.ShouldBe(FocasDataType.Int32);
- }
-
- [Fact]
- public void Writable_is_always_false_even_when_requested_true()
- {
- FocasEquipmentTagParser.TryParse("{\"address\":\"D0100\",\"dataType\":\"Int32\",\"writable\":true}", out var def)
- .ShouldBeTrue();
- def.Writable.ShouldBeFalse();
- }
-
- [Fact]
- public void Writable_is_false_when_absent()
- {
- FocasEquipmentTagParser.TryParse("{\"address\":\"D0100\",\"dataType\":\"Int32\"}", out var def)
- .ShouldBeTrue();
- def.Writable.ShouldBeFalse();
- }
-
- [Fact]
- public void Inspect_warns_on_writable_true_request()
- {
- var warnings = FocasEquipmentTagParser.Inspect("{\"address\":\"D0100\",\"dataType\":\"Int32\",\"writable\":true}");
- warnings.ShouldContain(w => w.Contains("read-only") && w.Contains("writable"));
- }
-
- [Fact]
- public void Inspect_reports_typo_dataType()
- {
- var warnings = FocasEquipmentTagParser.Inspect("{\"address\":\"D0100\",\"dataType\":\"Int322\"}");
- warnings.ShouldContain(w => w.Contains("Int322") && w.Contains("dataType"));
- }
-
- [Fact]
- public void Inspect_clean_read_only_config_has_no_warnings()
- {
- FocasEquipmentTagParser.Inspect("{\"address\":\"D0100\",\"dataType\":\"Int32\"}").ShouldBeEmpty();
- }
-}
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
index 70fa441a..86c3825a 100644
--- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasEquipmentTagTests.cs
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasEquipmentTagTests.cs
@@ -4,61 +4,68 @@ using ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests;
+///
+/// v3 authored-tag surface for FOCAS. Under v3 a tag's wire reference is its RawPath and the
+/// driver builds its RawPath → definition table from the authored RawTagEntry list via
+/// at InitializeAsync — there is no longer
+/// a blob-parse fallback where the read reference IS the raw TagConfig JSON (a miss is a miss ⇒
+/// BadNodeIdUnknown). These tests cover the mapper's parse/reject cases and the driver's authored-tag
+/// read path.
+///
[Trait("Category", "Unit")]
public class FocasEquipmentTagTests
{
+ private const string RawPath = "area/line/lathe/Run";
+
[Fact]
- public void Parses_equipment_tagconfig_into_a_transient_definition()
+ public void Maps_tagconfig_into_a_definition_keyed_by_rawpath()
{
- 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");
+ var json = """{"address":"R100","dataType":"Byte"}""";
+ FocasTagDefinitionFactory.FromTagConfig(json, RawPath, out var def).ShouldBeTrue();
+ def!.Name.ShouldBe(RawPath);
def.Address.ShouldBe("R100");
def.DataType.ShouldBe(FocasDataType.Byte);
}
[Fact]
public void Rejects_a_non_address_blob()
- => FocasEquipmentTagParser.TryParse("""{"FullName":"x"}""", out _).ShouldBeFalse();
+ => FocasTagDefinitionFactory.FromTagConfig("""{"FullName":"x"}""", RawPath, out _).ShouldBeFalse();
[Fact]
public void Rejects_garbage()
- => FocasEquipmentTagParser.TryParse("not json", out _).ShouldBeFalse();
+ => FocasTagDefinitionFactory.FromTagConfig("not json", RawPath, out _).ShouldBeFalse();
[Fact]
public void Rejects_address_as_a_json_number()
- => FocasEquipmentTagParser.TryParse(
- """{"address":100,"dataType":"Int32"}""", out _).ShouldBeFalse();
+ => FocasTagDefinitionFactory.FromTagConfig(
+ """{"address":100,"dataType":"Int32"}""", RawPath, out _).ShouldBeFalse();
[Fact]
public void Rejects_a_blank_address()
- => FocasEquipmentTagParser.TryParse(
- """{"address":" ","dataType":"Int32"}""", out _).ShouldBeFalse();
+ => FocasTagDefinitionFactory.FromTagConfig(
+ """{"address":" ","dataType":"Int32"}""", RawPath, 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.
+ /// End-to-end driver-level proof: a FOCAS driver serving an authored raw tag resolves it by its
+ /// RawPath and reads the wire instead of returning BadNodeIdUnknown.
///
[Fact]
- public async Task Driver_resolves_an_equipment_ref_and_reads_instead_of_BadNodeIdUnknown()
+ public async Task Driver_resolves_an_authored_rawtag_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 = [],
+ RawTags = [FocasRawTags.Entry(new FocasTagDefinition(RawPath, "focas://10.0.0.5:8193", "R100", FocasDataType.Byte))],
Probe = new FocasProbeOptions { Enabled = false },
}, "focas-eq", factory);
await drv.InitializeAsync("{}", CancellationToken.None);
- var r = await drv.ReadAsync([json], CancellationToken.None);
+ var r = await drv.ReadAsync([RawPath], CancellationToken.None);
r[0].StatusCode.ShouldBe(FocasStatusMapper.Good);
r[0].StatusCode.ShouldNotBe(FocasStatusMapper.BadNodeIdUnknown);
@@ -66,33 +73,47 @@ public class FocasEquipmentTagTests
}
///
- /// Regression guard for Driver.FOCAS-008 equipment-tag path: the parsed
- /// for a resolver-produced (equipment) tag must be
- /// cached after the first read so subsequent reads and writes skip re-parsing
- /// the raw TagConfig JSON on every call.
+ /// A read reference that is NOT an authored RawPath (e.g. a raw TagConfig JSON — the retired
+ /// equipment-tag blob-ref) is a resolver miss and surfaces BadNodeIdUnknown; the blob-parse
+ /// fallback is gone in v3.
///
[Fact]
- public async Task Equipment_tag_parsed_address_is_cached_after_first_read()
+ public async Task Blob_reference_is_a_resolver_miss_in_v3()
{
- var equipRef = """{"deviceHostAddress":"focas://10.0.0.5:8193","address":"R100","dataType":"Byte"}""";
- var factory = new FakeFocasClientFactory
- {
- Customise = () => new FakeFocasClient { Values = { ["R100"] = (sbyte)7 } },
- };
var drv = new FocasDriver(new FocasDriverOptions
{
Devices = [new FocasDeviceOptions("focas://10.0.0.5:8193")],
- Tags = [],
+ RawTags = [],
+ Probe = new FocasProbeOptions { Enabled = false },
+ }, "focas-eq", new FakeFocasClientFactory());
+ await drv.InitializeAsync("{}", CancellationToken.None);
+
+ var blob = """{"address":"R100","dataType":"Byte"}""";
+ var r = await drv.ReadAsync([blob], CancellationToken.None);
+
+ r[0].StatusCode.ShouldBe(FocasStatusMapper.BadNodeIdUnknown);
+ }
+
+ ///
+ /// Regression guard for Driver.FOCAS-008: the parsed for an authored
+ /// tag is cached at InitializeAsync (v3 pre-caches the whole table) so reads/writes skip
+ /// re-parsing the address string on every call.
+ ///
+ [Fact]
+ public async Task Authored_tag_parsed_address_is_cached_at_init()
+ {
+ var factory = new FakeFocasClientFactory { Customise = () => new FakeFocasClient { Values = { ["R100"] = (sbyte)7 } } };
+ var drv = new FocasDriver(new FocasDriverOptions
+ {
+ Devices = [new FocasDeviceOptions("focas://10.0.0.5:8193")],
+ RawTags = [FocasRawTags.Entry(new FocasTagDefinition(RawPath, "focas://10.0.0.5:8193", "R100", FocasDataType.Byte))],
Probe = new FocasProbeOptions { Enabled = false },
}, "focas-eq-cache", factory);
await drv.InitializeAsync("{}", CancellationToken.None);
- // Before any read the parsed address must NOT be in the cache.
- drv.IsParsedAddressCached(equipRef).ShouldBeFalse();
-
- // After a successful read the parsed address MUST be cached.
- var r = await drv.ReadAsync([equipRef], CancellationToken.None);
+ // v3 builds the table eagerly at init, so the parsed address is cached before any read.
+ drv.IsParsedAddressCached(RawPath).ShouldBeTrue();
+ var r = await drv.ReadAsync([RawPath], CancellationToken.None);
r[0].StatusCode.ShouldBe(FocasStatusMapper.Good);
- drv.IsParsedAddressCached(equipRef).ShouldBeTrue();
}
}
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasHandleRecycleTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasHandleRecycleTests.cs
index ffb64f10..97042231 100644
--- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasHandleRecycleTests.cs
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasHandleRecycleTests.cs
@@ -14,7 +14,7 @@ public sealed class FocasHandleRecycleTests
var drv = new FocasDriver(new FocasDriverOptions
{
Devices = [new FocasDeviceOptions("focas://10.0.0.5:8193")],
- Tags = [new FocasTagDefinition("R", "focas://10.0.0.5:8193", "R100", FocasDataType.Byte)],
+ RawTags = [FocasRawTags.Entry(new FocasTagDefinition("R", "focas://10.0.0.5:8193", "R100", FocasDataType.Byte))],
Probe = new FocasProbeOptions { Enabled = false },
HandleRecycle = new FocasHandleRecycleOptions
{
@@ -47,7 +47,7 @@ public sealed class FocasHandleRecycleTests
var drv = new FocasDriver(new FocasDriverOptions
{
Devices = [new FocasDeviceOptions("focas://10.0.0.5:8193")],
- Tags = [new FocasTagDefinition("R", "focas://10.0.0.5:8193", "R100", FocasDataType.Byte)],
+ RawTags = [FocasRawTags.Entry(new FocasTagDefinition("R", "focas://10.0.0.5:8193", "R100", FocasDataType.Byte))],
Probe = new FocasProbeOptions { Enabled = false },
}, "drv-1", factory);
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasIoSerializationTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasIoSerializationTests.cs
index 4bc271f6..a09c85bb 100644
--- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasIoSerializationTests.cs
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasIoSerializationTests.cs
@@ -110,7 +110,7 @@ public sealed class FocasIoSerializationTests
var drv = new FocasDriver(new FocasDriverOptions
{
Devices = [new FocasDeviceOptions("focas://10.0.0.5:8193")],
- Tags = [new FocasTagDefinition("CustomVar", "focas://10.0.0.5:8193", "MACRO:500", FocasDataType.Float64)],
+ RawTags = [FocasRawTags.Entry(new FocasTagDefinition("CustomVar", "focas://10.0.0.5:8193", "MACRO:500", FocasDataType.Float64))],
Probe = new FocasProbeOptions { Enabled = false },
Timeout = TimeSpan.FromMilliseconds(150),
}, "drv-1", factory);
@@ -133,7 +133,7 @@ public sealed class FocasIoSerializationTests
var drv = new FocasDriver(new FocasDriverOptions
{
Devices = [new FocasDeviceOptions("focas://10.0.0.5:8193")],
- Tags = [new FocasTagDefinition("CustomVar", "focas://10.0.0.5:8193", "MACRO:500", FocasDataType.Float64)],
+ RawTags = [FocasRawTags.Entry(new FocasTagDefinition("CustomVar", "focas://10.0.0.5:8193", "MACRO:500", FocasDataType.Float64))],
Probe = new FocasProbeOptions { Enabled = false },
Timeout = TimeSpan.FromMilliseconds(120),
}, "drv-1", factory);
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasLowFindingsTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasLowFindingsTests.cs
index 670c8ec1..5e09df86 100644
--- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasLowFindingsTests.cs
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasLowFindingsTests.cs
@@ -43,7 +43,7 @@ public sealed class FocasLowFindingsTests
var drv = new FocasDriver(new FocasDriverOptions
{
Devices = [new FocasDeviceOptions("focas://10.0.0.5:8193")],
- Tags = [new FocasTagDefinition("X", "focas://10.0.0.5:8193", "R100", FocasDataType.Byte)],
+ RawTags = [FocasRawTags.Entry(new FocasTagDefinition("X", "focas://10.0.0.5:8193", "R100", FocasDataType.Byte))],
Probe = new FocasProbeOptions { Enabled = false },
}, "drv-1", factory);
await drv.InitializeAsync("{}", CancellationToken.None);
@@ -73,11 +73,11 @@ public sealed class FocasLowFindingsTests
var drv = new FocasDriver(new FocasDriverOptions
{
Devices = [new FocasDeviceOptions("focas://10.0.0.5:8193")],
- Tags =
+ RawTags = FocasRawTags.Entries(
[
new FocasTagDefinition(
"X", "focas://10.0.0.5:8193", "R100", FocasDataType.Byte, Writable: true),
- ],
+ ]),
Probe = new FocasProbeOptions { Enabled = false },
}, "drv-1", factory);
await drv.InitializeAsync("{}", CancellationToken.None);
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasPmcBitRmwTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasPmcBitRmwTests.cs
index edd897f2..f3b357e0 100644
--- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasPmcBitRmwTests.cs
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasPmcBitRmwTests.cs
@@ -59,7 +59,7 @@ public sealed class FocasPmcBitRmwTests
var drv = new FocasDriver(new FocasDriverOptions
{
Devices = [new FocasDeviceOptions("focas://10.0.0.5:8193")],
- Tags = tags,
+ RawTags = FocasRawTags.Entries(tags),
Probe = new FocasProbeOptions { Enabled = false },
}, "drv-1", factory);
return (drv, fake);
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasPollErrorHealthTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasPollErrorHealthTests.cs
index dd0dc166..f738c275 100644
--- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasPollErrorHealthTests.cs
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasPollErrorHealthTests.cs
@@ -45,7 +45,8 @@ public sealed class FocasPollErrorHealthTests
var drv = new FocasDriver(new FocasDriverOptions
{
Devices = [new FocasDeviceOptions("focas://10.0.0.5:8193")],
- Tags = [new FocasTagDefinition("X", "focas://10.0.0.5:9999", "R100", FocasDataType.Byte)],
+ // DeviceName "…:9999" is not in Devices → InitializeAsync fails fast → Faulted.
+ RawTags = [FocasRawTags.Entry(new FocasTagDefinition("X", "focas://10.0.0.5:9999", "R100", FocasDataType.Byte))],
Probe = new FocasProbeOptions { Enabled = false },
}, "focas-1", new FakeFocasClientFactory());
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasRawTags.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasRawTags.cs
new file mode 100644
index 00000000..a6e66bf8
--- /dev/null
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasRawTags.cs
@@ -0,0 +1,30 @@
+using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
+
+namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests;
+
+///
+/// Test helper bridging the v2 authoring shape (a typed ) to the v3
+/// delivery shape (). Under v3 the driver no longer takes pre-declared
+/// definitions — the deploy artifact hands it authored raw tags (RawPath + TagConfig blob + DeviceName),
+/// and the driver rebuilds the typed definitions via and routes
+/// each to its device by . These tests keep expressing their intent
+/// as typed definitions; this helper serialises each back to its TagConfig blob (via
+/// ) and packages it as a
+/// whose RawPath is the def's Name and whose DeviceName is the def's DeviceHostAddress — so
+/// the round-trip through the real mapper + device match reproduces the same definition the test authored,
+/// keyed by the same name the read/write/subscribe call sites use.
+///
+internal static class FocasRawTags
+{
+ /// Serialises one typed definition into its (RawPath = def.Name, DeviceName = def.DeviceHostAddress).
+ /// The typed definition to convert.
+ /// The equivalent .
+ public static RawTagEntry Entry(FocasTagDefinition def)
+ => new(def.Name, FocasTagDefinitionFactory.ToTagConfig(def), def.WriteIdempotent, def.DeviceHostAddress);
+
+ /// Serialises a sequence of typed definitions into records.
+ /// The typed definitions to convert.
+ /// The equivalent raw-tag entries.
+ public static IReadOnlyList Entries(IEnumerable defs)
+ => [.. defs.Select(Entry)];
+}
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasReadWriteTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasReadWriteTests.cs
index 09df7adc..d95397a1 100644
--- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasReadWriteTests.cs
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasReadWriteTests.cs
@@ -14,7 +14,7 @@ public sealed class FocasReadWriteTests
var drv = new FocasDriver(new FocasDriverOptions
{
Devices = [new FocasDeviceOptions("focas://10.0.0.5:8193")],
- Tags = tags,
+ RawTags = FocasRawTags.Entries(tags),
Probe = new FocasProbeOptions { Enabled = false },
}, "drv-1", factory);
return (drv, factory);
@@ -221,11 +221,11 @@ public sealed class FocasReadWriteTests
var drv = new FocasDriver(new FocasDriverOptions
{
Devices = [new FocasDeviceOptions("focas://10.0.0.5:8193")],
- Tags =
+ RawTags = FocasRawTags.Entries(
[
new FocasTagDefinition("A", "focas://10.0.0.5:8193", "R100", FocasDataType.Byte),
new FocasTagDefinition("B", "focas://10.0.0.5:8193", "R101", FocasDataType.Byte, Writable: false),
- ],
+ ]),
Probe = new FocasProbeOptions { Enabled = false },
}, "drv-1", factory);
await drv.InitializeAsync("{}", CancellationToken.None);
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasResolveHostTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasResolveHostTests.cs
index 1fe06c2c..6d66e5fc 100644
--- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasResolveHostTests.cs
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasResolveHostTests.cs
@@ -5,10 +5,9 @@ using ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests;
///
-/// CONV-4 — must resolve an equipment-tag reference (raw
-/// TagConfig JSON) to its OWN device host so per-host breaker keys are correct for equipment
-/// tags. An address-less equipment ref (FOCAS coalesces a missing deviceHostAddress to
-/// "") must fall back rather than key an empty host.
+/// CONV-4 — must resolve an authored raw tag (by its RawPath)
+/// to its OWN device host so per-host breaker keys are correct for multi-device drivers. A reference
+/// that resolves to no authored tag falls back to the first device rather than keying an empty host.
///
[Trait("Category", "Unit")]
public sealed class FocasResolveHostTests
@@ -16,15 +15,16 @@ public sealed class FocasResolveHostTests
private const string DeviceA = "focas://10.0.0.5:8193";
private const string DeviceB = "focas://10.0.0.6:8193";
- // FOCAS's equipment-ref resolver validates against the initialized Devices map, so — unlike
- // the other drivers — the driver must be initialized (as it always is when ResolveHost runs at
- // runtime) before an equipment ref resolves. Probe off / no connect: init just parses devices.
- private static async Task NewDriverAsync()
+ // The driver builds its RawPath → definition table (with each tag's resolved device host) at
+ // InitializeAsync, so the driver must be initialized before ResolveHost can resolve a tag — as it
+ // always is when ResolveHost runs at runtime. Probe off / no connect: init just parses devices + tags.
+ private static async Task NewDriverAsync(params FocasTagDefinition[] tags)
{
var drv = new FocasDriver(
new FocasDriverOptions
{
Devices = [new FocasDeviceOptions(DeviceA), new FocasDeviceOptions(DeviceB)],
+ RawTags = FocasRawTags.Entries(tags),
Probe = new FocasProbeOptions { Enabled = false },
},
"focas-1", new FakeFocasClientFactory());
@@ -32,18 +32,17 @@ public sealed class FocasResolveHostTests
return drv;
}
- /// An equipment-tag ref naming device B resolves to device B's host, not the first device.
+ /// An authored tag routed to device B resolves to device B's host, not the first device.
[Fact]
- public async Task ResolveHost_EquipmentTagRef_ReturnsItsOwnDeviceHost()
+ public async Task ResolveHost_AuthoredTag_ReturnsItsOwnDeviceHost()
{
- var drv = await NewDriverAsync();
- var json = $$"""{"deviceHostAddress":"{{DeviceB}}","address":"R100","dataType":"Byte"}""";
- drv.ResolveHost(json).ShouldBe(DeviceB);
+ var drv = await NewDriverAsync(new FocasTagDefinition("eq/onB", DeviceB, "R100", FocasDataType.Byte));
+ drv.ResolveHost("eq/onB").ShouldBe(DeviceB);
}
- /// An equipment ref with no deviceHostAddress falls back to the first device, not an empty host.
+ /// A reference that resolves to no authored tag falls back to the first device, not an empty host.
[Fact]
- public async Task ResolveHost_EquipmentTagRef_WithoutDeviceHost_FallsBack()
+ public async Task ResolveHost_UnresolvedRef_FallsBack()
{
var drv = await NewDriverAsync();
var json = """{"address":"R100","dataType":"Byte"}""";
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasTagDefinitionFactoryStrictnessTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasTagDefinitionFactoryStrictnessTests.cs
new file mode 100644
index 00000000..eb65616d
--- /dev/null
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasTagDefinitionFactoryStrictnessTests.cs
@@ -0,0 +1,70 @@
+using Shouldly;
+using Xunit;
+using ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
+
+namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests;
+
+///
+/// R2-11 Phase C (+ 05/UNDER-1) strictness surface for the v3 :
+/// the runtime is STRICT — a typo'd dataType rejects the tag (FromTagConfig ⇒
+/// ⇒ BadNodeIdUnknown); the produced definition's Name is the
+/// RawPath (not the blob). Writability lives on the Tag entity / node ACL, so the authored FOCAS
+/// TagConfig has no writable key: an absent key ⇒ read-only (Writable == false), while an
+/// explicit writable:true is honoured for the driver's internal write gate (the wire is still
+/// read-only, which Inspect warns about).
+///
+public sealed class FocasTagDefinitionFactoryStrictnessTests
+{
+ private const string RawPath = "area/line/equip/tag";
+
+ [Fact]
+ public void Typo_dataType_rejects_the_tag()
+ => FocasTagDefinitionFactory.FromTagConfig(
+ "{\"address\":\"D0100\",\"dataType\":\"Int322\"}", RawPath, out _).ShouldBeFalse();
+
+ [Fact]
+ public void Valid_dataType_still_parses_and_names_by_rawPath()
+ {
+ FocasTagDefinitionFactory.FromTagConfig(
+ "{\"address\":\"D0100\",\"dataType\":\"Int32\"}", RawPath, out var def).ShouldBeTrue();
+ def.DataType.ShouldBe(FocasDataType.Int32);
+ def.Name.ShouldBe(RawPath);
+ def.Address.ShouldBe("D0100");
+ // The device host is not carried in the blob — the driver fills it from the RawTagEntry.DeviceName.
+ def.DeviceHostAddress.ShouldBe("");
+ }
+
+ [Fact]
+ public void Writable_is_honoured_when_requested_true()
+ {
+ FocasTagDefinitionFactory.FromTagConfig(
+ "{\"address\":\"D0100\",\"dataType\":\"Int32\",\"writable\":true}", RawPath, out var def).ShouldBeTrue();
+ def.Writable.ShouldBeTrue();
+ }
+
+ [Fact]
+ public void Writable_is_false_when_absent()
+ {
+ FocasTagDefinitionFactory.FromTagConfig(
+ "{\"address\":\"D0100\",\"dataType\":\"Int32\"}", RawPath, out var def).ShouldBeTrue();
+ def.Writable.ShouldBeFalse();
+ }
+
+ [Fact]
+ public void Inspect_warns_on_writable_true_request()
+ {
+ var warnings = FocasTagDefinitionFactory.Inspect("{\"address\":\"D0100\",\"dataType\":\"Int32\",\"writable\":true}");
+ warnings.ShouldContain(w => w.Contains("read-only") && w.Contains("writable"));
+ }
+
+ [Fact]
+ public void Inspect_reports_typo_dataType()
+ {
+ var warnings = FocasTagDefinitionFactory.Inspect("{\"address\":\"D0100\",\"dataType\":\"Int322\"}");
+ warnings.ShouldContain(w => w.Contains("Int322") && w.Contains("dataType"));
+ }
+
+ [Fact]
+ public void Inspect_clean_read_only_config_has_no_warnings()
+ => FocasTagDefinitionFactory.Inspect("{\"address\":\"D0100\",\"dataType\":\"Int32\"}").ShouldBeEmpty();
+}