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:
Joseph Doherty
2026-07-15 20:19:55 -04:00
parent c379e246d0
commit 8b2c3fa04c
23 changed files with 467 additions and 350 deletions
@@ -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>