Merge B1-focas: v3 RawPath read-path

This commit is contained in:
Joseph Doherty
2026-07-15 20:20:49 -04:00
23 changed files with 467 additions and 350 deletions
@@ -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>