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:
@@ -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:
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
+11
-1
@@ -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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user