diff --git a/docs/Raw.md b/docs/Raw.md index 022451bc..4272ee99 100644 --- a/docs/Raw.md +++ b/docs/Raw.md @@ -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 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 **Add tags ▸** on a Device or TagGroup offers: diff --git a/docs/drivers/AbCip.md b/docs/drivers/AbCip.md index 2a8696b9..c71dcfe7 100644 --- a/docs/drivers/AbCip.md +++ b/docs/drivers/AbCip.md @@ -247,8 +247,7 @@ The driver has two interchangeable wire transports, selected by one `DriverConfi bench controller exists. - 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 - lists a page out of order is unobserved — the reader is now correct either way. `browsePageDelayMs` has no AdminUI form field; author it in - the driver-config JSON (the form preserves keys it does not model, #576). + 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). - Micro800, routed and `logixpccc` paths are covered by `ab_server` only. The Micro800 profile passes natively. The AbLegacy default excludes `logixpccc` (and PLC-5). - Follow-up issues for this workstream: #564 to #577. diff --git a/docs/drivers/Modbus.md b/docs/drivers/Modbus.md index 7023dc26..746e76b9 100644 --- a/docs/drivers/Modbus.md +++ b/docs/drivers/Modbus.md @@ -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. - **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. +- **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. diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Contracts/AbCipDriverOptions.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Contracts/AbCipDriverOptions.cs index e559efc3..8257b6ea 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Contracts/AbCipDriverOptions.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Contracts/AbCipDriverOptions.cs @@ -163,8 +163,8 @@ public sealed record AbCipDeviceOptions( /// threaded in from 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 /// devices by matching or . -/// TODO(v3 WaveC): once the connection host moves to the Device row's DeviceConfig this is a pure logical -/// device name; the name still reads DeviceHostAddress to avoid churn across the driver + CLI. +/// The host-address match is deliberate (the Client CLI passes the host as the device name); the name +/// reads DeviceHostAddress to avoid churn across the driver + CLI. /// Logix symbolic path (controller or program scope). /// Logix atomic type, or for UDT-typed tags. /// When true and the tag's ExternalAccess permits writes, IWritable routes writes here. diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriver.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriver.cs index 98a94735..9157f8ba 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriver.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriver.cs @@ -101,8 +101,8 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery, private readonly Dictionary _devices = new(StringComparer.OrdinalIgnoreCase); // 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 - // the RawPath's device segment. TODO(v3 WaveC): collapses to a pure DeviceName index once the connection - // host moves into the Device row's DeviceConfig. + // the RawPath's device segment. The HostAddress alias is deliberate (Client CLI + tests route by host); + // _options.Devices is already the merged per-device DeviceConfig. private readonly Dictionary _devicesByRoutingKey = new(StringComparer.OrdinalIgnoreCase); // v3 authored RawPath → definition table (parents + fanned-out UDT members), keyed case-sensitive // 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; // 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): - // collapses to a DeviceName-only match once the connection host lives on the Device row's DeviceConfig. + // RawTagEntry.DeviceName) matches the device's DeviceName or its host address (the host alias is + // deliberate: the Client CLI routes by host). private static bool RoutesToDevice(AbCipTagDefinition tag, AbCipDeviceOptions device) => string.Equals(tag.DeviceHostAddress, device.HostAddress, StringComparison.OrdinalIgnoreCase) || (!string.IsNullOrEmpty(device.DeviceName) diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriver.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriver.cs index 57c6ba1d..0a11cdb6 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriver.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriver.cs @@ -637,12 +637,11 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover /// read/write surfaces BadNodeIdUnknown. /// /// - /// TODO(v3 WaveC): the live device host address must ultimately come from the owning - /// Device row's DeviceConfig (resolved by the deploy artifact / bootstrapper) rather - /// than from a configured entry. Until Wave C threads - /// the Device row through, the best available option is to route by matching the delivered - /// against _options.Devices and reusing that entry's - /// HostAddress as the connection key. + /// is already the per-device DeviceConfig + /// (DriverDeviceConfigMerger rebuilds it at deploy time), so routing matches the delivered + /// against _options.Devices and reuses that entry's + /// HostAddress as the connection key. The HostAddress fallback is deliberate: the Client CLI + /// passes the host as the DeviceName. /// /// The owning device name delivered on the . /// The matched device's HostAddress; empty when unroutable. diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasDriver.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasDriver.cs index bbf4d0e9..9fb93432 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasDriver.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasDriver.cs @@ -224,9 +224,9 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery, _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. + // options.Devices already IS the per-device DeviceConfig (DriverDeviceConfigMerger rebuilds it at + // deploy time, one object per Device row with DeviceName = the RawPath device segment), so the + // host is recovered by DeviceName against that list. var deviceHost = ResolveDeviceHost(entry.DeviceName); if (deviceHost is null) throw new InvalidOperationException( @@ -382,11 +382,10 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery, internal bool IsParsedAddressCached(string reference) => _parsedAddressesByTagName.ContainsKey(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). + // Recovers the host by matching the RawTagEntry's DeviceName against the configured Devices list (which + // DriverDeviceConfigMerger fills from each Device row's DeviceConfig). By DeviceName, falling back to + // HostAddress (the Client CLI passes the host as the DeviceName); 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) { foreach (var d in _options.Devices) diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasDriverFactoryExtensions.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasDriverFactoryExtensions.cs index 60a3a31f..b6c70b01 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasDriverFactoryExtensions.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasDriverFactoryExtensions.cs @@ -1,5 +1,6 @@ using System.Text.Json; using System.Text.Json.Serialization; +using Microsoft.Extensions.Logging; using ZB.MOM.WW.OtOpcUa.Core.Abstractions; using ZB.MOM.WW.OtOpcUa.Core.Hosting; 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. /// /// The driver factory registry to register with. - public static void Register(DriverFactoryRegistry registry) + /// 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). + public static void Register(DriverFactoryRegistry registry, ILoggerFactory? loggerFactory = null) { ArgumentNullException.ThrowIfNull(registry); - registry.Register(DriverTypeName, CreateInstance); + registry.Register(DriverTypeName, (id, json) => CreateInstance(id, json, loggerFactory)); } /// @@ -46,18 +49,21 @@ public static class FocasDriverFactoryExtensions /// The unique driver instance identifier. /// The driver configuration JSON string. /// A configured instance. - internal static FocasDriver CreateInstance(string driverInstanceId, string driverConfigJson) + /// Optional logger factory for the driver and its wire client. + 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( binding.Options, driverInstanceId, binding.ClientFactory, + loggerFactory?.CreateLogger(), // 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 // 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. - rebind: json => ParseBinding(driverInstanceId, json)); + rebind: json => ParseBinding(driverInstanceId, json, loggerFactory)); } /// @@ -68,8 +74,10 @@ public static class FocasDriverFactoryExtensions /// /// The unique driver instance identifier. /// The driver configuration JSON string. + /// Optional logger factory handed to the wire backend. /// The parsed options + backend factory. - internal static FocasDriverBinding ParseBinding(string driverInstanceId, string driverConfigJson) + internal static FocasDriverBinding ParseBinding(string driverInstanceId, string driverConfigJson, + ILoggerFactory? loggerFactory = null) { ArgumentException.ThrowIfNullOrWhiteSpace(driverInstanceId); ArgumentException.ThrowIfNullOrWhiteSpace(driverConfigJson); @@ -109,7 +117,7 @@ public static class FocasDriverFactoryExtensions HandleRecycle = BuildHandleRecycle(dto.HandleRecycle), }; - return new FocasDriverBinding(options, BuildClientFactory(dto, driverInstanceId)); + return new FocasDriverBinding(options, BuildClientFactory(dto, driverInstanceId, loggerFactory)); } /// @@ -131,14 +139,15 @@ public static class FocasDriverFactoryExtensions /// /// The driver configuration DTO. /// The driver instance identifier for error reporting. + /// Optional logger factory for the wire client. /// A configured instance. internal static IFocasClientFactory BuildClientFactory( - FocasDriverConfigDto dto, string driverInstanceId) + FocasDriverConfigDto dto, string driverInstanceId, ILoggerFactory? loggerFactory = null) { var backend = (dto.Backend ?? "wire").Trim().ToLowerInvariant(); return backend switch { - "wire" => new WireFocasClientFactory(), + "wire" => new WireFocasClientFactory(loggerFactory?.CreateLogger()), "unimplemented" or "none" or "stub" => new UnimplementedFocasClientFactory(), _ => throw new InvalidOperationException( $"FOCAS driver config for '{driverInstanceId}' has unknown Backend '{dto.Backend}'. " + diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATDriver.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATDriver.cs index 486f06b2..86d7831e 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATDriver.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATDriver.cs @@ -819,9 +819,9 @@ public sealed class TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery /// /// Resolve a to the device's live host address /// () used to route reads/writes/subscriptions - /// to the right connection. TODO(v3 WaveC): the live device host must come from the - /// Device row's DeviceConfig (the deploy artifact will carry it); today we resolve - /// the DeviceName against this driver's own list — + /// to the right connection. The deploy-time + /// DriverDeviceConfigMerger already fills from each + /// Device row's DeviceConfig, so we resolve the DeviceName against that list — /// first by , then by /// (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 diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATDriverFactoryExtensions.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATDriverFactoryExtensions.cs index a66a1595..ceb18c20 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATDriverFactoryExtensions.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATDriverFactoryExtensions.cs @@ -1,4 +1,5 @@ using System.Text.Json; +using Microsoft.Extensions.Logging; using ZB.MOM.WW.OtOpcUa.Core.Abstractions; using ZB.MOM.WW.OtOpcUa.Core.Hosting; @@ -16,21 +17,25 @@ public static class TwinCATDriverFactoryExtensions /// Registers the TwinCAT driver factory with the provided registry. /// The driver factory registry to register with. - public static void Register(DriverFactoryRegistry registry) + /// Optional logger factory; without it the driver runs on the null logger + /// and none of its diagnostics reach the node log (CONV-3). + public static void Register(DriverFactoryRegistry registry, ILoggerFactory? loggerFactory = null) { ArgumentNullException.ThrowIfNull(registry); - registry.Register(DriverTypeName, CreateInstance); + registry.Register(DriverTypeName, (id, json) => CreateInstance(id, json, loggerFactory)); } /// Creates a TwinCAT driver instance from the provided configuration. /// The driver instance identifier. /// The driver configuration as JSON. /// The constructed instance. - internal static TwinCATDriver CreateInstance(string driverInstanceId, string driverConfigJson) + /// Optional logger factory for the driver instance. + internal static TwinCATDriver CreateInstance(string driverInstanceId, string driverConfigJson, + ILoggerFactory? loggerFactory = null) { ArgumentException.ThrowIfNullOrWhiteSpace(driverInstanceId); var options = ParseOptions(driverConfigJson, driverInstanceId); - return new TwinCATDriver(options, driverInstanceId); + return new TwinCATDriver(options, driverInstanceId, logger: loggerFactory?.CreateLogger()); } /// diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Forms/AbCipDriverForm.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Forms/AbCipDriverForm.razor index 093822d8..2b997a44 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Forms/AbCipDriverForm.razor +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Forms/AbCipDriverForm.razor @@ -46,6 +46,11 @@
Which protocol stack talks to the controller. Default omits the setting so the node-level Drivers:AbCip:DefaultTransport picks (Native unless an operator pinned it); takes effect on the next deploy.
+
+ + +
Pause between controller symbol-browse pages (browsePageDelayMs). Default 5 ms; 0 disables the pause.
+
@@ -147,7 +152,7 @@ /// The factory binds integer-millisecond keys, not the TimeSpan strings the typed options serialise (#576). 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"), ]; @@ -215,6 +220,8 @@ public int MaxConcurrentReads { get; set; } = 8; public bool EnableControllerBrowse { 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 public bool EnableAlarmProjection { get; set; } = false; @@ -241,6 +248,7 @@ f._loadedAlarmPoll = Ms(json, "alarmPollIntervalMs") ?? f._loadedAlarmPoll; f._loadedProbeInterval = Ms(json, "probe.intervalMs") ?? f._loadedProbeInterval; 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.AlarmPollIntervalSeconds = DriverConfigMs.ToSeconds(f._loadedAlarmPoll ?? o.AlarmPollInterval); f.ProbeIntervalSeconds = DriverConfigMs.ToSeconds(f._loadedProbeInterval ?? o.Probe.Interval); @@ -259,6 +267,7 @@ MaxConcurrentReads = o.MaxConcurrentReads, EnableControllerBrowse = o.EnableControllerBrowse, EnableDeclarationOnlyUdtGrouping = o.EnableDeclarationOnlyUdtGrouping, + BrowsePageDelayMs = (int)o.BrowsePageDelay.TotalMilliseconds, EnableAlarmProjection = o.EnableAlarmProjection, AlarmPollIntervalSeconds = (int)o.AlarmPollInterval.TotalSeconds, ProbeEnabled = o.Probe.Enabled, @@ -286,6 +295,7 @@ EnableAlarmProjection = EnableAlarmProjection, AlarmPollInterval = DriverConfigMs.Save(AlarmPollIntervalSeconds, _loadedAlarmPoll), EnableDeclarationOnlyUdtGrouping = EnableDeclarationOnlyUdtGrouping, + BrowsePageDelay = TimeSpan.FromMilliseconds(Math.Max(0, BrowsePageDelayMs)), ProbeTimeoutSeconds = AdminProbeTimeoutSeconds, }; } diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Raw/RawTree.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Raw/RawTree.razor index 767dd77c..7b78a3f3 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Raw/RawTree.razor +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Raw/RawTree.razor @@ -12,9 +12,9 @@ directly (New folder/driver/group, Rename, Delete, Toggle) via the small dialogs built alongside — RawNameDialog / RawConfirmDialog / RawDriverTypeDialog — and 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 - owns it). The former RawStubModal is repurposed here as the shared message surface - for delete-blockers, rename warnings, and the Browse "coming soon" note. This + ops, the parent for item-level ops). Browse device opens the discovery modal + (OnBrowseDevice). The former RawStubModal is repurposed here as the shared message + surface for delete-blockers and rename warnings. This 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. *@ @using ZB.MOM.WW.OtOpcUa.AdminUI.Uns @@ -294,8 +294,8 @@ } // --------------------------------------------------------------------------- - // Per-kind context menus. Every OnClick opens a placeholder in this wave; the - // named handlers below are the seam Wave B/C replaces with the real modals. + // Per-kind context menus. Every OnClick dispatches to a named handler below, + // each of which opens the real modal or calls the service. // --------------------------------------------------------------------------- private IReadOnlyList MenuFor(RawNode node) @@ -422,8 +422,8 @@ // --------------------------------------------------------------------------- // Named action handlers — each opens the real modal/dialog or calls the service - // directly, then refreshes the affected subtree. Only OnBrowseDevice is a - // placeholder (Wave C owns device browse). + // directly, then refreshes the affected subtree. OnBrowseDevice opens the + // discovery modal. // --------------------------------------------------------------------------- // --- Folder / Cluster --- diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/RawTreeService.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/RawTreeService.cs index 72e711f7..3e7d77fa 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/RawTreeService.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/RawTreeService.cs @@ -511,8 +511,9 @@ public sealed class RawTreeService(IDbContextFactory dbF DriverConfig = string.IsNullOrWhiteSpace(driverConfigJson) ? "{}" : driverConfigJson, }); - // Auto-create the default device so a freshly-created driver has a child to show. Wave B (WP3) - // refines device/driver config authoring; here the default device carries an empty config. + // Auto-create the default device so a freshly-created driver has a child to show. By design + // (#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 { DeviceId = NewId("DEV"), diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Host/Drivers/DriverFactoryBootstrap.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Host/Drivers/DriverFactoryBootstrap.cs index dc687cf8..49794a36 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Host/Drivers/DriverFactoryBootstrap.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Host/Drivers/DriverFactoryBootstrap.cs @@ -214,7 +214,7 @@ public static class DriverFactoryBootstrap Driver.AbCip.AbCipDriverFactoryExtensions.Register(registry, loggerFactory, abCipDefaultTransport); Driver.AbLegacy.AbLegacyDriverFactoryExtensions.Register(registry, loggerFactory); 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.Modbus.ModbusDriverFactoryExtensions.Register(registry, loggerFactory); Driver.Mqtt.MqttDriverFactoryExtensions.Register(registry, loggerFactory, primaryRoleSource); @@ -228,6 +228,6 @@ public static class DriverFactoryBootstrap Driver.OpcUaClient.OpcUaClientDriverFactoryExtensions.Register(registry, loggerFactory, secretResolver); Driver.S7.S7DriverFactoryExtensions.Register(registry); Driver.Sql.SqlDriverFactoryExtensions.Register(registry, loggerFactory); - Driver.TwinCAT.TwinCATDriverFactoryExtensions.Register(registry); + Driver.TwinCAT.TwinCATDriverFactoryExtensions.Register(registry, loggerFactory); } } diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasFactoryLoggerTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasFactoryLoggerTests.cs new file mode 100644 index 00000000..ff37b432 --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasFactoryLoggerTests.cs @@ -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; + +/// #602 CONV-3: the FOCAS factory hands the host logger factory to the driver and its wire client. +[Trait("Category", "Unit")] +public sealed class FocasFactoryLoggerTests +{ + private sealed class RecordingLoggerFactory : ILoggerFactory + { + public List 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(); + 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(); + } +} diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/TwinCATFactoryLoggerTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/TwinCATFactoryLoggerTests.cs new file mode 100644 index 00000000..47f5b04a --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/TwinCATFactoryLoggerTests.cs @@ -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; + +/// #602 CONV-3: the TwinCAT factory hands the host logger factory to the driver. +[Trait("Category", "Unit")] +public sealed class TwinCATFactoryLoggerTests +{ + private sealed class RecordingLoggerFactory : ILoggerFactory + { + public List 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(); + 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(); + } +} diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/AbCipDriverPageFormSerializationTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/AbCipDriverPageFormSerializationTests.cs index 9ac7ee98..a3558146 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/AbCipDriverPageFormSerializationTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/AbCipDriverPageFormSerializationTests.cs @@ -306,4 +306,26 @@ public sealed class AbCipDriverPageFormSerializationTests opts.ShouldNotBeNull(); 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(); + } }