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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user