v3(focas): adopt Modbus RawTagEntry exemplar (multi-device)
- Rename FocasEquipmentTagParser -> FocasTagDefinitionFactory; TryParse ->
FromTagConfig(tagConfig, rawPath, out def). Drop leading-{ heuristic + the
deviceHostAddress blob key; def.Name = rawPath. Add inverse ToTagConfig.
writable: absent -> read-only, explicit true honoured (note preserved).
- FocasDriverOptions.Tags -> RawTags (IReadOnlyList<RawTagEntry>).
- FocasDriver: _tagsByRawPath (Ordinal); byName-only resolver; build table from
RawTags via FromTagConfig; multi-device routing threads RawTagEntry.DeviceName
-> ResolveDeviceHost match against options.Devices -> def.DeviceHostAddress
(TODO v3 WaveC: live host from Device row DeviceConfig). Tolerant per-tag
skip+log (mapper/address/matrix miss -> BadNodeIdUnknown); unknown device
fails init fast.
- Factory: bind List<RawTagEntry> RawTags; retire FocasTagDto/ParseDataType.
- CLI FocasCommandBase.BuildOptions -> RawTags via ToTagConfig + DeviceName.
- Tests: FocasRawTags helper; migrate Tags= -> RawTags=; rename parser tests to
FromTagConfig; rewrite retired blob-ref tests (equipment-tag/capability-gate/
resolve-host) onto authored RawPath. 272 driver + 52 CLI green.
This commit is contained in:
@@ -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 },
|
||||
};
|
||||
|
||||
@@ -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
|
||||
{
|
||||
/// <summary>Gets the list of configured CNC devices.</summary>
|
||||
public IReadOnlyList<FocasDeviceOptions> Devices { get; init; } = [];
|
||||
/// <summary>Gets the list of FOCAS tag definitions.</summary>
|
||||
public IReadOnlyList<FocasTagDefinition> Tags { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Authored raw tags this driver serves. The deploy artifact hands each authored raw
|
||||
/// <c>Tag</c> as a <see cref="RawTagEntry"/> (RawPath identity + driver <c>TagConfig</c> blob +
|
||||
/// WriteIdempotent flag + <see cref="RawTagEntry.DeviceName"/>); the driver maps each through
|
||||
/// <see cref="FocasTagDefinitionFactory.FromTagConfig"/> into its RawPath → definition table,
|
||||
/// routing each tag to its device by <see cref="RawTagEntry.DeviceName"/>. FOCAS has no online
|
||||
/// tag-discovery for user tags — the driver serves exactly these (plus the fixed-node tree).
|
||||
/// </summary>
|
||||
public IReadOnlyList<RawTagEntry> RawTags { get; init; } = [];
|
||||
/// <summary>Gets the probe options.</summary>
|
||||
public FocasProbeOptions Probe { get; init; } = new();
|
||||
/// <summary>Gets the timeout duration for operations.</summary>
|
||||
|
||||
@@ -1,87 +0,0 @@
|
||||
using System.Text.Json;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
|
||||
|
||||
/// <summary>Parses an equipment tag's <c>TagConfig</c> JSON (the shape authored by the AdminUI
|
||||
/// <c>FocasTagConfigModel</c>) into a transient <see cref="FocasTagDefinition"/> whose
|
||||
/// <see cref="FocasTagDefinition.Name"/> equals the reference string itself, so a value the
|
||||
/// driver publishes back keys the runtime's forward router correctly.</summary>
|
||||
public static class FocasEquipmentTagParser
|
||||
{
|
||||
/// <summary>Attempts to parse an equipment-tag reference into a transient definition.</summary>
|
||||
/// <param name="reference">The equipment tag's TagConfig JSON (also used as the def identity).</param>
|
||||
/// <param name="def">The transient definition when parsing succeeds.</param>
|
||||
/// <returns><see langword="true"/> when <paramref name="reference"/> is a FOCAS address object.</returns>
|
||||
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; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deploy-time inspection (05/CONV-2, 05/UNDER-1): warns on a present-but-invalid <c>dataType</c>
|
||||
/// (silently defaulted by the lenient runtime), on an authored <c>writable:true</c> (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.
|
||||
/// </summary>
|
||||
/// <param name="reference">The equipment tag's TagConfig JSON.</param>
|
||||
/// <returns>The warnings; empty when clean.</returns>
|
||||
public static IReadOnlyList<string> Inspect(string reference)
|
||||
{
|
||||
var warnings = new List<string>();
|
||||
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<FocasDataType>(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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// v3 pure mapper: turns an authored raw tag's <c>TagConfig</c> JSON (the shape produced by the
|
||||
/// AdminUI <c>FocasTagConfigModel</c>) into a <see cref="FocasTagDefinition"/>. Under v3 a tag's
|
||||
/// identity is its <b>RawPath</b> (a cluster-scoped slash path), not the address blob — so the
|
||||
/// produced definition's <see cref="FocasTagDefinition.Name"/> is the RawPath the driver was handed,
|
||||
/// which is exactly the wire reference the driver's <c>RawPath → def</c> resolver keys on. The driver
|
||||
/// builds that table by mapping each <see cref="RawTagEntry"/> the deploy artifact delivers through
|
||||
/// <see cref="FromTagConfig"/>; <see cref="ToTagConfig"/> is the inverse, used by authoring paths (the
|
||||
/// Client CLI) that hold a typed definition and need to synthesise the <c>TagConfig</c> blob for a
|
||||
/// <see cref="RawTagEntry"/>.
|
||||
/// </summary>
|
||||
public static class FocasTagDefinitionFactory
|
||||
{
|
||||
/// <summary>
|
||||
/// Maps an authored <c>TagConfig</c> object to a typed definition keyed by <paramref name="rawPath"/>.
|
||||
/// The input is always an authored TagConfig JSON object (there is no name-vs-blob heuristic in v3).
|
||||
/// The <c>dataType</c> enum is read STRICTLY — a present-but-invalid (typo'd) value rejects the whole
|
||||
/// tag (returns <see langword="false"/> ⇒ the driver surfaces <c>BadNodeIdUnknown</c>) rather than
|
||||
/// silently defaulting to a wrong-width Good. The FOCAS TagConfig carries <b>no device host</b> — the
|
||||
/// driver resolves the device from the tag's <see cref="RawTagEntry.DeviceName"/>, so the produced
|
||||
/// definition's <see cref="FocasTagDefinition.DeviceHostAddress"/> 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 <c>writable</c> key), so an absent key ⇒ read-only; an explicit <c>writable:true</c>
|
||||
/// is honoured for the driver's internal write gate (the wire itself is still read-only).
|
||||
/// <see cref="FocasTagDefinition.WriteIdempotent"/> is NOT read from the blob — it is a platform flag
|
||||
/// the caller threads in from the tag's <see cref="RawTagEntry.WriteIdempotent"/>.
|
||||
/// </summary>
|
||||
/// <param name="tagConfig">The authored equipment-tag TagConfig JSON.</param>
|
||||
/// <param name="rawPath">The tag's RawPath — becomes the definition's identity (<c>Name</c>).</param>
|
||||
/// <param name="def">The mapped definition when this returns <see langword="true"/>.</param>
|
||||
/// <returns><see langword="true"/> when <paramref name="tagConfig"/> is a valid FOCAS address object.</returns>
|
||||
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; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Inverse of <see cref="FromTagConfig"/>: serialises a typed definition back to the camelCase
|
||||
/// <c>TagConfig</c> JSON the mapper reads. Used by authoring paths (the Client CLI) that construct a
|
||||
/// <see cref="FocasTagDefinition"/> from operator flags and need the <c>TagConfig</c> string to hand
|
||||
/// the driver as a <see cref="RawTagEntry"/>. The enum is written as its name string. Neither the
|
||||
/// device host (which travels on <see cref="RawTagEntry.DeviceName"/>) nor <c>WriteIdempotent</c>
|
||||
/// (which travels on the <see cref="RawTagEntry"/>) is emitted.
|
||||
/// </summary>
|
||||
/// <param name="def">The definition to serialise.</param>
|
||||
/// <returns>The camelCase TagConfig JSON string.</returns>
|
||||
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();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deploy-time inspection (05/CONV-2, 05/UNDER-1): warns on a present-but-invalid <c>dataType</c>
|
||||
/// (silently defaulted by the lenient runtime), on an authored <c>writable:true</c> (the FOCAS wire
|
||||
/// is read-only — <c>WriteAsync</c> always returns <c>BadNotWritable</c>), and on a structurally
|
||||
/// unparseable TagConfig. Empty when clean or not an equipment-tag object. Never throws.
|
||||
/// </summary>
|
||||
/// <param name="reference">The equipment tag's TagConfig JSON.</param>
|
||||
/// <returns>The warnings; empty when clean.</returns>
|
||||
public static IReadOnlyList<string> Inspect(string reference)
|
||||
{
|
||||
var warnings = new List<string>();
|
||||
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<FocasDataType>(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;
|
||||
}
|
||||
}
|
||||
@@ -27,10 +27,11 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
private readonly PollGroupEngine _poll;
|
||||
private readonly ILogger<FocasDriver> _logger;
|
||||
private readonly Dictionary<string, DeviceState> _devices = new(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly Dictionary<string, FocasTagDefinition> _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<string, FocasTagDefinition> _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<FocasTagDefinition> _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<FocasDriver>.Instance;
|
||||
_resolver = new EquipmentTagRefResolver<FocasTagDefinition>(
|
||||
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)
|
||||
{
|
||||
|
||||
@@ -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<FocasCncSeries>())}");
|
||||
}
|
||||
|
||||
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<FocasDataType>(raw, ignoreCase: true, out var dt)
|
||||
? dt
|
||||
: throw new InvalidOperationException(
|
||||
$"FOCAS tag '{tagName}' has unknown DataType '{raw}'. " +
|
||||
$"Expected one of {string.Join(", ", Enum.GetNames<FocasDataType>())}");
|
||||
}
|
||||
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
PropertyNameCaseInsensitive = true,
|
||||
@@ -254,8 +236,11 @@ public static class FocasDriverFactoryExtensions
|
||||
/// <summary>Gets or sets the list of CNC devices to manage.</summary>
|
||||
public List<FocasDeviceDto>? Devices { get; init; }
|
||||
|
||||
/// <summary>Gets or sets the list of FOCAS tags to poll.</summary>
|
||||
public List<FocasTagDto>? Tags { get; init; }
|
||||
/// <summary>
|
||||
/// Gets or sets the authored raw tags (RawPath + TagConfig blob + WriteIdempotent + DeviceName)
|
||||
/// the driver serves. Bound verbatim into <see cref="FocasDriverOptions.RawTags"/>.
|
||||
/// </summary>
|
||||
public List<RawTagEntry>? RawTags { get; init; }
|
||||
|
||||
/// <summary>Gets or sets the probe configuration options.</summary>
|
||||
public FocasProbeDto? Probe { get; init; }
|
||||
@@ -290,27 +275,6 @@ public static class FocasDriverFactoryExtensions
|
||||
public int? PositionDecimalPlaces { get; init; }
|
||||
}
|
||||
|
||||
internal sealed class FocasTagDto
|
||||
{
|
||||
/// <summary>Gets or sets the tag name.</summary>
|
||||
public string? Name { get; init; }
|
||||
|
||||
/// <summary>Gets or sets the hostname or IP address of the CNC device for this tag.</summary>
|
||||
public string? DeviceHostAddress { get; init; }
|
||||
|
||||
/// <summary>Gets or sets the FOCAS address or path for this tag.</summary>
|
||||
public string? Address { get; init; }
|
||||
|
||||
/// <summary>Gets or sets the data type for this tag.</summary>
|
||||
public string? DataType { get; init; }
|
||||
|
||||
/// <summary>Gets or sets a value indicating whether this tag is writable.</summary>
|
||||
public bool? Writable { get; init; }
|
||||
|
||||
/// <summary>Gets or sets a value indicating whether writes to this tag are idempotent.</summary>
|
||||
public bool? WriteIdempotent { get; init; }
|
||||
}
|
||||
|
||||
internal sealed class FocasProbeDto
|
||||
{
|
||||
/// <summary>Gets or sets a value indicating whether probing is enabled.</summary>
|
||||
|
||||
+14
-4
@@ -74,9 +74,12 @@ public sealed class FocasCommandBaseBuildOptionsTests
|
||||
options.Devices[0].Series.ShouldBe(FocasCncSeries.Zero_i_F);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that BuildOptions forwards tag list verbatim.</summary>
|
||||
/// <summary>
|
||||
/// Verifies that BuildOptions turns each typed def into a v3 <c>RawTagEntry</c> — RawPath = def.Name,
|
||||
/// DeviceName = def.DeviceHostAddress, and a TagConfig blob that round-trips through the mapper.
|
||||
/// </summary>
|
||||
[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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
+14
-14
@@ -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);
|
||||
|
||||
+19
-22
@@ -5,57 +5,54 @@ using ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// R2-11 (05/UNDER-6): an equipment-tag reference whose address violates the device series'
|
||||
/// capability matrix must fail to RESOLVE (surfacing <c>BadNodeIdUnknown</c>) — the same pre-flight
|
||||
/// authored tags get at <c>InitializeAsync</c> — 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 <c>InitializeAsync</c> and surface <c>BadNodeIdUnknown</c>
|
||||
/// 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).
|
||||
/// </summary>
|
||||
[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<InvalidOperationException>(
|
||||
() => drv.InitializeAsync("{}", CancellationToken.None));
|
||||
ex.Message.ShouldContain("not in the Devices list");
|
||||
}
|
||||
}
|
||||
|
||||
-65
@@ -1,65 +0,0 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// R2-11 Phase C (+ 05/UNDER-1) strictness surface for the FOCAS equipment-tag parser: the runtime is now
|
||||
/// STRICT — a typo'd <c>dataType</c> rejects the tag (<c>TryParse</c> ⇒ <see langword="false"/> ⇒
|
||||
/// <c>BadNodeIdUnknown</c>); FOCAS tags remain forced read-only (<c>Writable == false</c> regardless of any
|
||||
/// authored <c>writable:true</c>), and <c>Inspect</c> reports the typo + the write-request.
|
||||
/// </summary>
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -4,61 +4,68 @@ using ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// v3 authored-tag surface for FOCAS. Under v3 a tag's wire reference is its <b>RawPath</b> and the
|
||||
/// driver builds its <c>RawPath → definition</c> table from the authored <c>RawTagEntry</c> list via
|
||||
/// <see cref="FocasTagDefinitionFactory.FromTagConfig"/> at <c>InitializeAsync</c> — 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.
|
||||
/// </summary>
|
||||
[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.
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
[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
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Regression guard for Driver.FOCAS-008 equipment-tag path: the parsed
|
||||
/// <see cref="FocasAddress"/> 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.
|
||||
/// </summary>
|
||||
[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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Regression guard for Driver.FOCAS-008: the parsed <see cref="FocasAddress"/> for an authored
|
||||
/// tag is cached at <c>InitializeAsync</c> (v3 pre-caches the whole table) so reads/writes skip
|
||||
/// re-parsing the address string on every call.
|
||||
/// </summary>
|
||||
[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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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());
|
||||
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Test helper bridging the v2 authoring shape (a typed <see cref="FocasTagDefinition"/>) to the v3
|
||||
/// delivery shape (<see cref="RawTagEntry"/>). 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 <see cref="FocasTagDefinitionFactory"/> and routes
|
||||
/// each to its device by <see cref="RawTagEntry.DeviceName"/>. These tests keep expressing their intent
|
||||
/// as typed definitions; this helper serialises each back to its TagConfig blob (via
|
||||
/// <see cref="FocasTagDefinitionFactory.ToTagConfig"/>) and packages it as a <see cref="RawTagEntry"/>
|
||||
/// whose RawPath is the def's <c>Name</c> and whose DeviceName is the def's <c>DeviceHostAddress</c> — 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.
|
||||
/// </summary>
|
||||
internal static class FocasRawTags
|
||||
{
|
||||
/// <summary>Serialises one typed definition into its <see cref="RawTagEntry"/> (RawPath = def.Name, DeviceName = def.DeviceHostAddress).</summary>
|
||||
/// <param name="def">The typed definition to convert.</param>
|
||||
/// <returns>The equivalent <see cref="RawTagEntry"/>.</returns>
|
||||
public static RawTagEntry Entry(FocasTagDefinition def)
|
||||
=> new(def.Name, FocasTagDefinitionFactory.ToTagConfig(def), def.WriteIdempotent, def.DeviceHostAddress);
|
||||
|
||||
/// <summary>Serialises a sequence of typed definitions into <see cref="RawTagEntry"/> records.</summary>
|
||||
/// <param name="defs">The typed definitions to convert.</param>
|
||||
/// <returns>The equivalent raw-tag entries.</returns>
|
||||
public static IReadOnlyList<RawTagEntry> Entries(IEnumerable<FocasTagDefinition> defs)
|
||||
=> [.. defs.Select(Entry)];
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -5,10 +5,9 @@ using ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// CONV-4 — <see cref="FocasDriver.ResolveHost"/> 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 <c>deviceHostAddress</c> to
|
||||
/// <c>""</c>) must fall back rather than key an empty host.
|
||||
/// CONV-4 — <see cref="FocasDriver.ResolveHost"/> 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.
|
||||
/// </summary>
|
||||
[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<FocasDriver> 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<FocasDriver> 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;
|
||||
}
|
||||
|
||||
/// <summary>An equipment-tag ref naming device B resolves to device B's host, not the first device.</summary>
|
||||
/// <summary>An authored tag routed to device B resolves to device B's host, not the first device.</summary>
|
||||
[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);
|
||||
}
|
||||
|
||||
/// <summary>An equipment ref with no deviceHostAddress falls back to the first device, not an empty host.</summary>
|
||||
/// <summary>A reference that resolves to no authored tag falls back to the first device, not an empty host.</summary>
|
||||
[Fact]
|
||||
public async Task ResolveHost_EquipmentTagRef_WithoutDeviceHost_FallsBack()
|
||||
public async Task ResolveHost_UnresolvedRef_FallsBack()
|
||||
{
|
||||
var drv = await NewDriverAsync();
|
||||
var json = """{"address":"R100","dataType":"Byte"}""";
|
||||
|
||||
+70
@@ -0,0 +1,70 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// R2-11 Phase C (+ 05/UNDER-1) strictness surface for the v3 <see cref="FocasTagDefinitionFactory"/>:
|
||||
/// the runtime is STRICT — a typo'd <c>dataType</c> rejects the tag (<c>FromTagConfig</c> ⇒
|
||||
/// <see langword="false"/> ⇒ <c>BadNodeIdUnknown</c>); the produced definition's <c>Name</c> is the
|
||||
/// RawPath (not the blob). Writability lives on the Tag entity / node ACL, so the authored FOCAS
|
||||
/// TagConfig has no <c>writable</c> key: an absent key ⇒ read-only (<c>Writable == false</c>), while an
|
||||
/// explicit <c>writable:true</c> is honoured for the driver's internal write gate (the wire is still
|
||||
/// read-only, which <c>Inspect</c> warns about).
|
||||
/// </summary>
|
||||
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();
|
||||
}
|
||||
Reference in New Issue
Block a user