fix(drivers): ILoggerFactory on FOCAS+TwinCAT factories; replace stale TODO(v3 WaveC); AbCip browsePageDelayMs form field; document Device1 + Modbus health (#608 #602 #596)

Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
This commit is contained in:
Joseph Doherty
2026-10-08 06:53:38 -04:00
parent 3baa332d6b
commit 9c39f2b6c4
17 changed files with 192 additions and 50 deletions
+6
View File
@@ -48,6 +48,12 @@ settings; the device's `DeviceConfig` holds host/port/endpoint. At deploy/probe
connect (inside the **Device** modal) probes that merged config. Driver forms no longer connect (inside the **Device** modal) probes that merged config. Driver forms no longer
serialize the endpoint keys, so `DeviceConfig` is the single source of truth. serialize the endpoint keys, so `DeviceConfig` is the single source of truth.
**Creating a driver auto-creates an empty `Device1` (by design, #596).** Every driver needs at least one
device to hang browse and tags off, and for a single-device driver the merger treats that device's
`DeviceConfig` as the endpoint, so an empty, renamable, deletable `Device1` is the lowest-friction start
(`RawTreeService.CreateDriverAsync`; pinned by `RawTreeServiceWaveBTests`). It is not a placeholder to be
removed later.
## Authoring tags ## Authoring tags
**Add tags ▸** on a Device or TagGroup offers: **Add tags ▸** on a Device or TagGroup offers:
+1 -2
View File
@@ -247,8 +247,7 @@ The driver has two interchangeable wire transports, selected by one `DriverConfi
bench controller exists. bench controller exists.
- Native browse (#572, resolved in code): listing paging no longer assumes ascending instance order, pages - Native browse (#572, resolved in code): listing paging no longer assumes ascending instance order, pages
are paced, and a browse's template reads share one lease. One residual: whether a real controller ever are paced, and a browse's template reads share one lease. One residual: whether a real controller ever
lists a page out of order is unobserved — the reader is now correct either way. `browsePageDelayMs` has no AdminUI form field; author it in lists a page out of order is unobserved — the reader is now correct either way. `browsePageDelayMs` is an AdminUI driver-form field ("Browse page delay (ms)", #596); keys the form does not model are preserved on save (#576).
the driver-config JSON (the form preserves keys it does not model, #576).
- Micro800, routed and `logixpccc` paths are covered by `ab_server` only. The Micro800 profile passes - Micro800, routed and `logixpccc` paths are covered by `ab_server` only. The Micro800 profile passes
natively. The AbLegacy default excludes `logixpccc` (and PLC-5). natively. The AbLegacy default excludes `logixpccc` (and PLC-5).
- Follow-up issues for this workstream: #564 to #577. - Follow-up issues for this workstream: #564 to #577.
+1
View File
@@ -149,3 +149,4 @@ and the UI authoring flow.
- **`WriteOnChangeOnly` + write-only tags** — the suppression cache is only invalidated by a read that returns a divergent value. A tag that is never subscribed/polled never refreshes its cache entry, so a re-asserted value can be suppressed indefinitely. Subscribe every tag that needs deterministic re-writes, or leave the option off. - **`WriteOnChangeOnly` + write-only tags** — the suppression cache is only invalidated by a read that returns a divergent value. A tag that is never subscribed/polled never refreshes its cache entry, so a re-asserted value can be suppressed indefinitely. Subscribe every tag that needs deterministic re-writes, or leave the option off.
- **Auto-prohibited ranges** are visible via `GetAutoProhibitedRanges` and logged on first occurrence / on clear — use them to find protected register holes in a device's map. - **Auto-prohibited ranges** are visible via `GetAutoProhibitedRanges` and logged on first occurrence / on clear — use them to find protected register holes in a device's map.
- **Int64 / UInt64 OPC UA node DataType** — tags declared with `DataType: Int64` or `DataType: UInt64` advertise the correct OPC UA scalar type (`Int64` / `UInt64`) on their node. Values outside the 32-bit range are preserved end-to-end; the wire codec (4-register read/write) was already correct before this fix. - **Int64 / UInt64 OPC UA node DataType** — tags declared with `DataType: Int64` or `DataType: UInt64` advertise the correct OPC UA scalar type (`Int64` / `UInt64`) on their node. Values outside the 32-bit range are preserved end-to-end; the wire codec (4-register read/write) was already correct before this fix.
- **Health is per-tag, last-writer-wins — a deliberate asymmetry (#602).** Unlike the S7 read/write passes, which accumulate one worst-state-wins outcome per batch, `ModbusDriver` stamps `ReadHealth`/`WriteHealth` (Volatile) per tag as the loop runs. This is intentional: the comms-loss watchdog keys on `LastSuccessfulRead`, which any success advances and `Degraded`/`Faulted` writes preserve, so a late healthy tag cannot mask a dead device, and a per-pass accumulator would add risk to the `Degraded` latch semantics (CLAUDE.md "Comms-loss detection") for no observable gain. Revisit only if a test shows a defect.
@@ -163,8 +163,8 @@ public sealed record AbCipDeviceOptions(
/// threaded in from <see cref="RawTagEntry.DeviceName"/> by the driver at table-build time (the mapper /// threaded in from <see cref="RawTagEntry.DeviceName"/> by the driver at table-build time (the mapper
/// leaves it empty; device placement is no longer in the TagConfig blob). Resolved against the driver's /// leaves it empty; device placement is no longer in the TagConfig blob). Resolved against the driver's
/// devices by matching <see cref="AbCipDeviceOptions.DeviceName"/> or <see cref="AbCipDeviceOptions.HostAddress"/>. /// devices by matching <see cref="AbCipDeviceOptions.DeviceName"/> or <see cref="AbCipDeviceOptions.HostAddress"/>.
/// TODO(v3 WaveC): once the connection host moves to the Device row's DeviceConfig this is a pure logical /// The host-address match is deliberate (the Client CLI passes the host as the device name); the name
/// device name; the name still reads <c>DeviceHostAddress</c> to avoid churn across the driver + CLI.</param> /// reads <c>DeviceHostAddress</c> to avoid churn across the driver + CLI.</param>
/// <param name="TagPath">Logix symbolic path (controller or program scope).</param> /// <param name="TagPath">Logix symbolic path (controller or program scope).</param>
/// <param name="DataType">Logix atomic type, or <see cref="AbCipDataType.Structure"/> for UDT-typed tags.</param> /// <param name="DataType">Logix atomic type, or <see cref="AbCipDataType.Structure"/> for UDT-typed tags.</param>
/// <param name="Writable">When <c>true</c> and the tag's ExternalAccess permits writes, IWritable routes writes here.</param> /// <param name="Writable">When <c>true</c> and the tag's ExternalAccess permits writes, IWritable routes writes here.</param>
@@ -101,8 +101,8 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
private readonly Dictionary<string, DeviceState> _devices = new(StringComparer.OrdinalIgnoreCase); private readonly Dictionary<string, DeviceState> _devices = new(StringComparer.OrdinalIgnoreCase);
// Tag-routing index: a tag's device routing key (DeviceName ?? HostAddress, plus the HostAddress alias) // Tag-routing index: a tag's device routing key (DeviceName ?? HostAddress, plus the HostAddress alias)
// → its DeviceState. A tag resolves to its device by DeviceName-or-host so multi-device routing keys on // → its DeviceState. A tag resolves to its device by DeviceName-or-host so multi-device routing keys on
// the RawPath's device segment. TODO(v3 WaveC): collapses to a pure DeviceName index once the connection // the RawPath's device segment. The HostAddress alias is deliberate (Client CLI + tests route by host);
// host moves into the Device row's DeviceConfig. // _options.Devices is already the merged per-device DeviceConfig.
private readonly Dictionary<string, DeviceState> _devicesByRoutingKey = new(StringComparer.OrdinalIgnoreCase); private readonly Dictionary<string, DeviceState> _devicesByRoutingKey = new(StringComparer.OrdinalIgnoreCase);
// v3 authored RawPath → definition table (parents + fanned-out UDT members), keyed case-sensitive // v3 authored RawPath → definition table (parents + fanned-out UDT members), keyed case-sensitive
// ordinal. Built in InitializeAsync from _options.RawTags via the pure mapper. // ordinal. Built in InitializeAsync from _options.RawTags via the pure mapper.
@@ -1716,8 +1716,8 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
private static bool IsArrayTag(AbCipTagDefinition tag) => tag.IsArray || tag.ElementCount > 1; private static bool IsArrayTag(AbCipTagDefinition tag) => tag.IsArray || tag.ElementCount > 1;
// A tag routes to a device when its device routing key (DeviceHostAddress, threaded from the // A tag routes to a device when its device routing key (DeviceHostAddress, threaded from the
// RawTagEntry.DeviceName) matches the device's DeviceName or its host address. TODO(v3 WaveC): // RawTagEntry.DeviceName) matches the device's DeviceName or its host address (the host alias is
// collapses to a DeviceName-only match once the connection host lives on the Device row's DeviceConfig. // deliberate: the Client CLI routes by host).
private static bool RoutesToDevice(AbCipTagDefinition tag, AbCipDeviceOptions device) => private static bool RoutesToDevice(AbCipTagDefinition tag, AbCipDeviceOptions device) =>
string.Equals(tag.DeviceHostAddress, device.HostAddress, StringComparison.OrdinalIgnoreCase) string.Equals(tag.DeviceHostAddress, device.HostAddress, StringComparison.OrdinalIgnoreCase)
|| (!string.IsNullOrEmpty(device.DeviceName) || (!string.IsNullOrEmpty(device.DeviceName)
@@ -637,12 +637,11 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
/// read/write surfaces <c>BadNodeIdUnknown</c>. /// read/write surfaces <c>BadNodeIdUnknown</c>.
/// </summary> /// </summary>
/// <remarks> /// <remarks>
/// TODO(v3 WaveC): the live device host address must ultimately come from the owning /// <see cref="AbLegacyDriverOptions.Devices"/> is already the per-device <c>DeviceConfig</c>
/// <c>Device</c> row's <c>DeviceConfig</c> (resolved by the deploy artifact / bootstrapper) rather /// (<c>DriverDeviceConfigMerger</c> rebuilds it at deploy time), so routing matches the delivered
/// than from a configured <see cref="AbLegacyDriverOptions.Devices"/> entry. Until Wave C threads /// <see cref="RawTagEntry.DeviceName"/> against <c>_options.Devices</c> and reuses that entry's
/// the Device row through, the best available option is to route by matching the delivered /// <c>HostAddress</c> as the connection key. The HostAddress fallback is deliberate: the Client CLI
/// <see cref="RawTagEntry.DeviceName"/> against <c>_options.Devices</c> and reusing that entry's /// passes the host as the DeviceName.
/// <c>HostAddress</c> as the connection key.
/// </remarks> /// </remarks>
/// <param name="deviceName">The owning device name delivered on the <see cref="RawTagEntry"/>.</param> /// <param name="deviceName">The owning device name delivered on the <see cref="RawTagEntry"/>.</param>
/// <returns>The matched device's <c>HostAddress</c>; empty when unroutable.</returns> /// <returns>The matched device's <c>HostAddress</c>; empty when unroutable.</returns>
@@ -224,9 +224,9 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
_driverInstanceId, entry.RawPath); _driverInstanceId, entry.RawPath);
continue; continue;
} }
// TODO(v3 WaveC): the live device host must come from the tag's Device row's DeviceConfig // options.Devices already IS the per-device DeviceConfig (DriverDeviceConfigMerger rebuilds it at
// (keyed by the RawPath's device segment). Until Wave C threads DeviceConfig through, recover // deploy time, one object per Device row with DeviceName = the RawPath device segment), so the
// the host by matching the entry's DeviceName against the configured Devices list. // host is recovered by DeviceName against that list.
var deviceHost = ResolveDeviceHost(entry.DeviceName); var deviceHost = ResolveDeviceHost(entry.DeviceName);
if (deviceHost is null) if (deviceHost is null)
throw new InvalidOperationException( throw new InvalidOperationException(
@@ -382,11 +382,10 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
internal bool IsParsedAddressCached(string reference) => internal bool IsParsedAddressCached(string reference) =>
_parsedAddressesByTagName.ContainsKey(reference); _parsedAddressesByTagName.ContainsKey(reference);
// TODO(v3 WaveC): the live device host must come from the tag's Device row's DeviceConfig (keyed by // Recovers the host by matching the RawTagEntry's DeviceName against the configured Devices list (which
// the RawPath's device segment). Until Wave C threads DeviceConfig through, recover the host by matching // DriverDeviceConfigMerger fills from each Device row's DeviceConfig). By DeviceName, falling back to
// the RawTagEntry's DeviceName against the configured Devices list (by DeviceName, falling back to // HostAddress (the Client CLI passes the host as the DeviceName); for a single-device driver an empty
// HostAddress; and, for a single-device driver, an empty DeviceName routes to the sole device). Returns // DeviceName routes to the sole device. Returns null when no device matches — the caller fails the tag fast (Driver.FOCAS-003).
// null when no device matches — the caller fails the tag fast (Driver.FOCAS-003).
private string? ResolveDeviceHost(string deviceName) private string? ResolveDeviceHost(string deviceName)
{ {
foreach (var d in _options.Devices) foreach (var d in _options.Devices)
@@ -1,5 +1,6 @@
using System.Text.Json; using System.Text.Json;
using System.Text.Json.Serialization; using System.Text.Json.Serialization;
using Microsoft.Extensions.Logging;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions; using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Core.Hosting; using ZB.MOM.WW.OtOpcUa.Core.Hosting;
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Wire; using ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Wire;
@@ -34,10 +35,12 @@ public static class FocasDriverFactoryExtensions
/// Throws if 'FOCAS' is already registered — single-instance per process. /// Throws if 'FOCAS' is already registered — single-instance per process.
/// </summary> /// </summary>
/// <param name="registry">The driver factory registry to register with.</param> /// <param name="registry">The driver factory registry to register with.</param>
public static void Register(DriverFactoryRegistry registry) /// <param name="loggerFactory">Optional logger factory; without it the driver and its wire client run on
/// the null logger and none of their diagnostics reach the node log (CONV-3).</param>
public static void Register(DriverFactoryRegistry registry, ILoggerFactory? loggerFactory = null)
{ {
ArgumentNullException.ThrowIfNull(registry); ArgumentNullException.ThrowIfNull(registry);
registry.Register(DriverTypeName, CreateInstance); registry.Register(DriverTypeName, (id, json) => CreateInstance(id, json, loggerFactory));
} }
/// <summary> /// <summary>
@@ -46,18 +49,21 @@ public static class FocasDriverFactoryExtensions
/// <param name="driverInstanceId">The unique driver instance identifier.</param> /// <param name="driverInstanceId">The unique driver instance identifier.</param>
/// <param name="driverConfigJson">The driver configuration JSON string.</param> /// <param name="driverConfigJson">The driver configuration JSON string.</param>
/// <returns>A configured <see cref="FocasDriver"/> instance.</returns> /// <returns>A configured <see cref="FocasDriver"/> instance.</returns>
internal static FocasDriver CreateInstance(string driverInstanceId, string driverConfigJson) /// <param name="loggerFactory">Optional logger factory for the driver and its wire client.</param>
internal static FocasDriver CreateInstance(string driverInstanceId, string driverConfigJson,
ILoggerFactory? loggerFactory = null)
{ {
var binding = ParseBinding(driverInstanceId, driverConfigJson); var binding = ParseBinding(driverInstanceId, driverConfigJson, loggerFactory);
return new FocasDriver( return new FocasDriver(
binding.Options, binding.Options,
driverInstanceId, driverInstanceId,
binding.ClientFactory, binding.ClientFactory,
loggerFactory?.CreateLogger<FocasDriver>(),
// Hand the driver the SAME parse it was built with, so InitializeAsync can re-derive EVERY // Hand the driver the SAME parse it was built with, so InitializeAsync can re-derive EVERY
// config-derived dependency on a reinitialize — options AND the backend client factory // config-derived dependency on a reinitialize — options AND the backend client factory
// together (#516). Re-parsing options alone would run a new device/tag set against the old // together (#516). Re-parsing options alone would run a new device/tag set against the old
// backend, which is worse than not re-parsing at all. // backend, which is worse than not re-parsing at all.
rebind: json => ParseBinding(driverInstanceId, json)); rebind: json => ParseBinding(driverInstanceId, json, loggerFactory));
} }
/// <summary> /// <summary>
@@ -68,8 +74,10 @@ public static class FocasDriverFactoryExtensions
/// </summary> /// </summary>
/// <param name="driverInstanceId">The unique driver instance identifier.</param> /// <param name="driverInstanceId">The unique driver instance identifier.</param>
/// <param name="driverConfigJson">The driver configuration JSON string.</param> /// <param name="driverConfigJson">The driver configuration JSON string.</param>
/// <param name="loggerFactory">Optional logger factory handed to the wire backend.</param>
/// <returns>The parsed options + backend factory.</returns> /// <returns>The parsed options + backend factory.</returns>
internal static FocasDriverBinding ParseBinding(string driverInstanceId, string driverConfigJson) internal static FocasDriverBinding ParseBinding(string driverInstanceId, string driverConfigJson,
ILoggerFactory? loggerFactory = null)
{ {
ArgumentException.ThrowIfNullOrWhiteSpace(driverInstanceId); ArgumentException.ThrowIfNullOrWhiteSpace(driverInstanceId);
ArgumentException.ThrowIfNullOrWhiteSpace(driverConfigJson); ArgumentException.ThrowIfNullOrWhiteSpace(driverConfigJson);
@@ -109,7 +117,7 @@ public static class FocasDriverFactoryExtensions
HandleRecycle = BuildHandleRecycle(dto.HandleRecycle), HandleRecycle = BuildHandleRecycle(dto.HandleRecycle),
}; };
return new FocasDriverBinding(options, BuildClientFactory(dto, driverInstanceId)); return new FocasDriverBinding(options, BuildClientFactory(dto, driverInstanceId, loggerFactory));
} }
/// <summary> /// <summary>
@@ -131,14 +139,15 @@ public static class FocasDriverFactoryExtensions
/// </summary> /// </summary>
/// <param name="dto">The driver configuration DTO.</param> /// <param name="dto">The driver configuration DTO.</param>
/// <param name="driverInstanceId">The driver instance identifier for error reporting.</param> /// <param name="driverInstanceId">The driver instance identifier for error reporting.</param>
/// <param name="loggerFactory">Optional logger factory for the wire client.</param>
/// <returns>A configured <see cref="IFocasClientFactory"/> instance.</returns> /// <returns>A configured <see cref="IFocasClientFactory"/> instance.</returns>
internal static IFocasClientFactory BuildClientFactory( internal static IFocasClientFactory BuildClientFactory(
FocasDriverConfigDto dto, string driverInstanceId) FocasDriverConfigDto dto, string driverInstanceId, ILoggerFactory? loggerFactory = null)
{ {
var backend = (dto.Backend ?? "wire").Trim().ToLowerInvariant(); var backend = (dto.Backend ?? "wire").Trim().ToLowerInvariant();
return backend switch return backend switch
{ {
"wire" => new WireFocasClientFactory(), "wire" => new WireFocasClientFactory(loggerFactory?.CreateLogger<FocasWireClient>()),
"unimplemented" or "none" or "stub" => new UnimplementedFocasClientFactory(), "unimplemented" or "none" or "stub" => new UnimplementedFocasClientFactory(),
_ => throw new InvalidOperationException( _ => throw new InvalidOperationException(
$"FOCAS driver config for '{driverInstanceId}' has unknown Backend '{dto.Backend}'. " + $"FOCAS driver config for '{driverInstanceId}' has unknown Backend '{dto.Backend}'. " +
@@ -819,9 +819,9 @@ public sealed class TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery
/// <summary> /// <summary>
/// Resolve a <see cref="RawTagEntry.DeviceName"/> to the device's live host address /// Resolve a <see cref="RawTagEntry.DeviceName"/> to the device's live host address
/// (<see cref="TwinCATTagDefinition.DeviceHostAddress"/>) used to route reads/writes/subscriptions /// (<see cref="TwinCATTagDefinition.DeviceHostAddress"/>) used to route reads/writes/subscriptions
/// to the right connection. <b>TODO(v3 WaveC):</b> the live device host must come from the /// to the right connection. The deploy-time
/// <c>Device</c> row's <c>DeviceConfig</c> (the deploy artifact will carry it); today we resolve /// <c>DriverDeviceConfigMerger</c> already fills <see cref="TwinCATDriverOptions.Devices"/> from each
/// the DeviceName against this driver's own <see cref="TwinCATDriverOptions.Devices"/> list — /// <c>Device</c> row's <c>DeviceConfig</c>, so we resolve the DeviceName against that list —
/// first by <see cref="TwinCATDeviceOptions.DeviceName"/>, then by /// first by <see cref="TwinCATDeviceOptions.DeviceName"/>, then by
/// <see cref="TwinCATDeviceOptions.HostAddress"/> (callers that route by host, e.g. the Client /// <see cref="TwinCATDeviceOptions.HostAddress"/> (callers that route by host, e.g. the Client
/// CLI, pass the host string as the DeviceName). An empty DeviceName routes to the sole device /// CLI, pass the host string as the DeviceName). An empty DeviceName routes to the sole device
@@ -1,4 +1,5 @@
using System.Text.Json; using System.Text.Json;
using Microsoft.Extensions.Logging;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions; using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Core.Hosting; using ZB.MOM.WW.OtOpcUa.Core.Hosting;
@@ -16,21 +17,25 @@ public static class TwinCATDriverFactoryExtensions
/// <summary>Registers the TwinCAT driver factory with the provided registry.</summary> /// <summary>Registers the TwinCAT driver factory with the provided registry.</summary>
/// <param name="registry">The driver factory registry to register with.</param> /// <param name="registry">The driver factory registry to register with.</param>
public static void Register(DriverFactoryRegistry registry) /// <param name="loggerFactory">Optional logger factory; without it the driver runs on the null logger
/// and none of its diagnostics reach the node log (CONV-3).</param>
public static void Register(DriverFactoryRegistry registry, ILoggerFactory? loggerFactory = null)
{ {
ArgumentNullException.ThrowIfNull(registry); ArgumentNullException.ThrowIfNull(registry);
registry.Register(DriverTypeName, CreateInstance); registry.Register(DriverTypeName, (id, json) => CreateInstance(id, json, loggerFactory));
} }
/// <summary>Creates a TwinCAT driver instance from the provided configuration.</summary> /// <summary>Creates a TwinCAT driver instance from the provided configuration.</summary>
/// <param name="driverInstanceId">The driver instance identifier.</param> /// <param name="driverInstanceId">The driver instance identifier.</param>
/// <param name="driverConfigJson">The driver configuration as JSON.</param> /// <param name="driverConfigJson">The driver configuration as JSON.</param>
/// <returns>The constructed <see cref="TwinCATDriver"/> instance.</returns> /// <returns>The constructed <see cref="TwinCATDriver"/> instance.</returns>
internal static TwinCATDriver CreateInstance(string driverInstanceId, string driverConfigJson) /// <param name="loggerFactory">Optional logger factory for the driver instance.</param>
internal static TwinCATDriver CreateInstance(string driverInstanceId, string driverConfigJson,
ILoggerFactory? loggerFactory = null)
{ {
ArgumentException.ThrowIfNullOrWhiteSpace(driverInstanceId); ArgumentException.ThrowIfNullOrWhiteSpace(driverInstanceId);
var options = ParseOptions(driverConfigJson, driverInstanceId); var options = ParseOptions(driverConfigJson, driverInstanceId);
return new TwinCATDriver(options, driverInstanceId); return new TwinCATDriver(options, driverInstanceId, logger: loggerFactory?.CreateLogger<TwinCATDriver>());
} }
/// <summary> /// <summary>
@@ -46,6 +46,11 @@
</InputSelect> </InputSelect>
<div class="form-text">Which protocol stack talks to the controller. Default omits the setting so the node-level <code>Drivers:AbCip:DefaultTransport</code> picks (Native unless an operator pinned it); takes effect on the next deploy.</div> <div class="form-text">Which protocol stack talks to the controller. Default omits the setting so the node-level <code>Drivers:AbCip:DefaultTransport</code> picks (Native unless an operator pinned it); takes effect on the next deploy.</div>
</div> </div>
<div class="col-md-3">
<label class="form-label" for="abcipBrowsePageDelay">Browse page delay (ms)</label>
<InputNumber @bind-Value="_form.BrowsePageDelayMs" @bind-Value:after="EmitAsync" class="form-control form-control-sm" id="abcipBrowsePageDelay" />
<div class="form-text">Pause between controller symbol-browse pages (<code>browsePageDelayMs</code>). Default 5 ms; 0 disables the pause.</div>
</div>
</div> </div>
</div> </div>
</section> </section>
@@ -147,7 +152,7 @@
/// <summary>The factory binds integer-millisecond keys, not the TimeSpan strings the typed options serialise (#576).</summary> /// <summary>The factory binds integer-millisecond keys, not the TimeSpan strings the typed options serialise (#576).</summary>
internal static readonly (string TimeSpanPath, string MsPath)[] TimeSpanToMs = internal static readonly (string TimeSpanPath, string MsPath)[] TimeSpanToMs =
[ [
("timeout", "timeoutMs"), ("alarmPollInterval", "alarmPollIntervalMs"), ("timeout", "timeoutMs"), ("alarmPollInterval", "alarmPollIntervalMs"), ("browsePageDelay", "browsePageDelayMs"),
("probe.interval", "probe.intervalMs"), ("probe.timeout", "probe.timeoutMs"), ("probe.interval", "probe.intervalMs"), ("probe.timeout", "probe.timeoutMs"),
]; ];
@@ -215,6 +220,8 @@
public int MaxConcurrentReads { get; set; } = 8; public int MaxConcurrentReads { get; set; } = 8;
public bool EnableControllerBrowse { get; set; } = false; public bool EnableControllerBrowse { get; set; } = false;
public bool EnableDeclarationOnlyUdtGrouping { get; set; } = false; public bool EnableDeclarationOnlyUdtGrouping { get; set; } = false;
// Milliseconds between controller symbol-browse pages (factory key browsePageDelayMs; default 5).
public int BrowsePageDelayMs { get; set; } = 5;
// Alarm projection // Alarm projection
public bool EnableAlarmProjection { get; set; } = false; public bool EnableAlarmProjection { get; set; } = false;
@@ -241,6 +248,7 @@
f._loadedAlarmPoll = Ms(json, "alarmPollIntervalMs") ?? f._loadedAlarmPoll; f._loadedAlarmPoll = Ms(json, "alarmPollIntervalMs") ?? f._loadedAlarmPoll;
f._loadedProbeInterval = Ms(json, "probe.intervalMs") ?? f._loadedProbeInterval; f._loadedProbeInterval = Ms(json, "probe.intervalMs") ?? f._loadedProbeInterval;
f._loadedProbeTimeout = Ms(json, "probe.timeoutMs") ?? f._loadedProbeTimeout; f._loadedProbeTimeout = Ms(json, "probe.timeoutMs") ?? f._loadedProbeTimeout;
f.BrowsePageDelayMs = DriverConfigMs.Read(json, "browsePageDelayMs") ?? f.BrowsePageDelayMs;
f.TimeoutSeconds = DriverConfigMs.ToSeconds(f._loadedTimeout ?? o.Timeout); f.TimeoutSeconds = DriverConfigMs.ToSeconds(f._loadedTimeout ?? o.Timeout);
f.AlarmPollIntervalSeconds = DriverConfigMs.ToSeconds(f._loadedAlarmPoll ?? o.AlarmPollInterval); f.AlarmPollIntervalSeconds = DriverConfigMs.ToSeconds(f._loadedAlarmPoll ?? o.AlarmPollInterval);
f.ProbeIntervalSeconds = DriverConfigMs.ToSeconds(f._loadedProbeInterval ?? o.Probe.Interval); f.ProbeIntervalSeconds = DriverConfigMs.ToSeconds(f._loadedProbeInterval ?? o.Probe.Interval);
@@ -259,6 +267,7 @@
MaxConcurrentReads = o.MaxConcurrentReads, MaxConcurrentReads = o.MaxConcurrentReads,
EnableControllerBrowse = o.EnableControllerBrowse, EnableControllerBrowse = o.EnableControllerBrowse,
EnableDeclarationOnlyUdtGrouping = o.EnableDeclarationOnlyUdtGrouping, EnableDeclarationOnlyUdtGrouping = o.EnableDeclarationOnlyUdtGrouping,
BrowsePageDelayMs = (int)o.BrowsePageDelay.TotalMilliseconds,
EnableAlarmProjection = o.EnableAlarmProjection, EnableAlarmProjection = o.EnableAlarmProjection,
AlarmPollIntervalSeconds = (int)o.AlarmPollInterval.TotalSeconds, AlarmPollIntervalSeconds = (int)o.AlarmPollInterval.TotalSeconds,
ProbeEnabled = o.Probe.Enabled, ProbeEnabled = o.Probe.Enabled,
@@ -286,6 +295,7 @@
EnableAlarmProjection = EnableAlarmProjection, EnableAlarmProjection = EnableAlarmProjection,
AlarmPollInterval = DriverConfigMs.Save(AlarmPollIntervalSeconds, _loadedAlarmPoll), AlarmPollInterval = DriverConfigMs.Save(AlarmPollIntervalSeconds, _loadedAlarmPoll),
EnableDeclarationOnlyUdtGrouping = EnableDeclarationOnlyUdtGrouping, EnableDeclarationOnlyUdtGrouping = EnableDeclarationOnlyUdtGrouping,
BrowsePageDelay = TimeSpan.FromMilliseconds(Math.Max(0, BrowsePageDelayMs)),
ProbeTimeoutSeconds = AdminProbeTimeoutSeconds, ProbeTimeoutSeconds = AdminProbeTimeoutSeconds,
}; };
} }
@@ -12,9 +12,9 @@
directly (New folder/driver/group, Rename, Delete, Toggle) via the small dialogs directly (New folder/driver/group, Rename, Delete, Toggle) via the small dialogs
built alongside — RawNameDialog / RawConfirmDialog / RawDriverTypeDialog — and built alongside — RawNameDialog / RawConfirmDialog / RawDriverTypeDialog — and
refreshes the affected subtree afterwards (reload the container for create-under refreshes the affected subtree afterwards (reload the container for create-under
ops, the parent for item-level ops). Only Browse device stays a placeholder (Wave C ops, the parent for item-level ops). Browse device opens the discovery modal
owns it). The former RawStubModal is repurposed here as the shared message surface (OnBrowseDevice). The former RawStubModal is repurposed here as the shared message
for delete-blockers, rename warnings, and the Browse "coming soon" note. This surface for delete-blockers and rename warnings. This
component is a single instance that recurses via the RenderNode RenderFragment, so it component is a single instance that recurses via the RenderNode RenderFragment, so it
owns one set of modals + one injected service for the whole tree. *@ owns one set of modals + one injected service for the whole tree. *@
@using ZB.MOM.WW.OtOpcUa.AdminUI.Uns @using ZB.MOM.WW.OtOpcUa.AdminUI.Uns
@@ -294,8 +294,8 @@
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Per-kind context menus. Every OnClick opens a placeholder in this wave; the // Per-kind context menus. Every OnClick dispatches to a named handler below,
// named handlers below are the seam Wave B/C replaces with the real modals. // each of which opens the real modal or calls the service.
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
private IReadOnlyList<ContextMenuItem> MenuFor(RawNode node) private IReadOnlyList<ContextMenuItem> MenuFor(RawNode node)
@@ -422,8 +422,8 @@
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Named action handlers — each opens the real modal/dialog or calls the service // Named action handlers — each opens the real modal/dialog or calls the service
// directly, then refreshes the affected subtree. Only OnBrowseDevice is a // directly, then refreshes the affected subtree. OnBrowseDevice opens the
// placeholder (Wave C owns device browse). // discovery modal.
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// --- Folder / Cluster --- // --- Folder / Cluster ---
@@ -511,8 +511,9 @@ public sealed class RawTreeService(IDbContextFactory<OtOpcUaConfigDbContext> dbF
DriverConfig = string.IsNullOrWhiteSpace(driverConfigJson) ? "{}" : driverConfigJson, DriverConfig = string.IsNullOrWhiteSpace(driverConfigJson) ? "{}" : driverConfigJson,
}); });
// Auto-create the default device so a freshly-created driver has a child to show. Wave B (WP3) // Auto-create the default device so a freshly-created driver has a child to show. By design
// refines device/driver config authoring; here the default device carries an empty config. // (#596): every driver needs at least one device for browse + tags, and DriverDeviceConfigMerger treats
// the sole device's DeviceConfig as the endpoint. It is renamable/deletable and carries an empty config.
db.Devices.Add(new Device db.Devices.Add(new Device
{ {
DeviceId = NewId("DEV"), DeviceId = NewId("DEV"),
@@ -214,7 +214,7 @@ public static class DriverFactoryBootstrap
Driver.AbCip.AbCipDriverFactoryExtensions.Register(registry, loggerFactory, abCipDefaultTransport); Driver.AbCip.AbCipDriverFactoryExtensions.Register(registry, loggerFactory, abCipDefaultTransport);
Driver.AbLegacy.AbLegacyDriverFactoryExtensions.Register(registry, loggerFactory); Driver.AbLegacy.AbLegacyDriverFactoryExtensions.Register(registry, loggerFactory);
Driver.Calculation.CalculationDriverFactoryExtensions.Register(registry, loggerFactory, scriptRoot); Driver.Calculation.CalculationDriverFactoryExtensions.Register(registry, loggerFactory, scriptRoot);
Driver.FOCAS.FocasDriverFactoryExtensions.Register(registry); Driver.FOCAS.FocasDriverFactoryExtensions.Register(registry, loggerFactory);
Driver.Galaxy.GalaxyDriverFactoryExtensions.Register(registry, secretResolver, loggerFactory); Driver.Galaxy.GalaxyDriverFactoryExtensions.Register(registry, secretResolver, loggerFactory);
Driver.Modbus.ModbusDriverFactoryExtensions.Register(registry, loggerFactory); Driver.Modbus.ModbusDriverFactoryExtensions.Register(registry, loggerFactory);
Driver.Mqtt.MqttDriverFactoryExtensions.Register(registry, loggerFactory, primaryRoleSource); Driver.Mqtt.MqttDriverFactoryExtensions.Register(registry, loggerFactory, primaryRoleSource);
@@ -228,6 +228,6 @@ public static class DriverFactoryBootstrap
Driver.OpcUaClient.OpcUaClientDriverFactoryExtensions.Register(registry, loggerFactory, secretResolver); Driver.OpcUaClient.OpcUaClientDriverFactoryExtensions.Register(registry, loggerFactory, secretResolver);
Driver.S7.S7DriverFactoryExtensions.Register(registry); Driver.S7.S7DriverFactoryExtensions.Register(registry);
Driver.Sql.SqlDriverFactoryExtensions.Register(registry, loggerFactory); Driver.Sql.SqlDriverFactoryExtensions.Register(registry, loggerFactory);
Driver.TwinCAT.TwinCATDriverFactoryExtensions.Register(registry); Driver.TwinCAT.TwinCATDriverFactoryExtensions.Register(registry, loggerFactory);
} }
} }
@@ -0,0 +1,47 @@
using Microsoft.Extensions.Logging;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Hosting;
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Wire;
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests;
/// <summary>#602 CONV-3: the FOCAS factory hands the host logger factory to the driver and its wire client.</summary>
[Trait("Category", "Unit")]
public sealed class FocasFactoryLoggerTests
{
private sealed class RecordingLoggerFactory : ILoggerFactory
{
public List<string> Categories { get; } = [];
public ILogger CreateLogger(string categoryName)
{
lock (Categories) Categories.Add(categoryName);
return Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance;
}
public void AddProvider(ILoggerProvider provider) { }
public void Dispose() { }
}
[Fact]
public void Registered_factory_creates_driver_and_wire_client_loggers_from_the_host_factory()
{
var lf = new RecordingLoggerFactory();
var registry = new DriverFactoryRegistry();
FocasDriverFactoryExtensions.Register(registry, lf);
var drv = registry.TryGet(FocasDriverFactoryExtensions.DriverTypeName)!("drv-1", "{}");
drv.ShouldBeOfType<FocasDriver>();
lf.Categories.ShouldContain(typeof(FocasDriver).FullName!);
lf.Categories.ShouldContain(typeof(FocasWireClient).FullName!);
}
[Fact]
public void Registering_without_a_logger_factory_still_works()
{
var registry = new DriverFactoryRegistry();
FocasDriverFactoryExtensions.Register(registry);
registry.TryGet(FocasDriverFactoryExtensions.DriverTypeName)!("drv-1", "{}").ShouldBeOfType<FocasDriver>();
}
}
@@ -0,0 +1,44 @@
using Microsoft.Extensions.Logging;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Hosting;
namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests;
/// <summary>#602 CONV-3: the TwinCAT factory hands the host logger factory to the driver.</summary>
[Trait("Category", "Unit")]
public sealed class TwinCATFactoryLoggerTests
{
private sealed class RecordingLoggerFactory : ILoggerFactory
{
public List<string> Categories { get; } = [];
public ILogger CreateLogger(string categoryName)
{
lock (Categories) Categories.Add(categoryName);
return Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance;
}
public void AddProvider(ILoggerProvider provider) { }
public void Dispose() { }
}
[Fact]
public void Registered_factory_creates_the_driver_logger_from_the_host_factory()
{
var lf = new RecordingLoggerFactory();
var registry = new DriverFactoryRegistry();
TwinCATDriverFactoryExtensions.Register(registry, lf);
var drv = registry.TryGet(TwinCATDriverFactoryExtensions.DriverTypeName)!("drv-1", "{}");
drv.ShouldBeOfType<TwinCATDriver>();
lf.Categories.ShouldContain(typeof(TwinCATDriver).FullName!);
}
[Fact]
public void Registering_without_a_logger_factory_still_works()
{
var registry = new DriverFactoryRegistry();
TwinCATDriverFactoryExtensions.Register(registry);
registry.TryGet(TwinCATDriverFactoryExtensions.DriverTypeName)!("drv-1", "{}").ShouldBeOfType<TwinCATDriver>();
}
}
@@ -306,4 +306,26 @@ public sealed class AbCipDriverPageFormSerializationTests
opts.ShouldNotBeNull(); opts.ShouldNotBeNull();
opts.Devices[0].HostAddress.ShouldBe("ab://10.0.0.1/1,0"); opts.Devices[0].HostAddress.ShouldBe("ab://10.0.0.1/1,0");
} }
// ---- #596: browsePageDelayMs is form-authorable, reaches the factory, and unknown keys survive ----
[Fact]
public void BrowsePageDelayMs_is_loaded_edited_and_bound_by_the_factory_without_dropping_unknown_keys()
{
var original = """{"browsePageDelayMs":40,"futureOption":"keep-me","devices":[{"hostAddress":"ab://10.0.0.1/1,0"}]}""";
var loaded = AbCipDriverForm.TryDeserialize(original)!;
var form = AbCipDriverForm.FormModel.FromJson(original, loaded);
form.BrowsePageDelayMs.ShouldBe(40); // the form SEES the factory-keyed value
var untouched = AbCipDriverForm.Serialize(form.ToOptions(loaded.Devices), original);
ZB.MOM.WW.OtOpcUa.Driver.AbCip.AbCipDriverFactoryExtensions.ParseOptions("t", untouched)
.BrowsePageDelay.ShouldBe(TimeSpan.FromMilliseconds(40));
form.BrowsePageDelayMs = 125;
var saved = AbCipDriverForm.Serialize(form.ToOptions(loaded.Devices), original);
ZB.MOM.WW.OtOpcUa.Driver.AbCip.AbCipDriverFactoryExtensions.ParseOptions("t", saved)
.BrowsePageDelay.ShouldBe(TimeSpan.FromMilliseconds(125));
saved.ShouldContain("futureOption");
System.Text.Json.Nodes.JsonNode.Parse(saved)!.AsObject().ContainsKey("browsePageDelay").ShouldBeFalse();
}
} }