From 3d081ad29c8c17d8ee6ab9fb1d4abf5ee792afc3 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Wed, 7 Oct 2026 22:56:47 -0400 Subject: [PATCH] fix(adminui,cli): /raw device-name placeholder + no-prerender tree, CLI read prints Bad status (#577) Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe --- docs/Client.CLI.md | 2 + docs/drivers/AbCip.md | 3 +- .../Commands/ReadCommand.cs | 18 +++++++- .../Components/Pages/Raw/GlobalRaw.razor | 6 ++- .../Shared/Drivers/DeviceModal.razor | 4 +- .../ReadCommandTests.cs | 43 ++++++++++++++++++- 6 files changed, 70 insertions(+), 6 deletions(-) diff --git a/docs/Client.CLI.md b/docs/Client.CLI.md index 1761c6b0..870f55ac 100644 --- a/docs/Client.CLI.md +++ b/docs/Client.CLI.md @@ -116,6 +116,8 @@ Source Time: 2026-03-30T19:58:38.0961252Z Server Time: 2026-03-30T19:58:38.0971257Z ``` +A Bad node status (unknown node, access denied, ...) is reported, not thrown: the command prints `Node:`, an empty `Value:` and `Status: 0x80340000 (BadNodeIdUnknown)`. Session-level failures (closed connection / channel) still fail the command. + ### write Writes a value to a node. The shared service reads the current value first to determine the target data type, then converts the supplied string value using `ValueConverter.ConvertValue()`. diff --git a/docs/drivers/AbCip.md b/docs/drivers/AbCip.md index 6d7b7193..ff97cbb4 100644 --- a/docs/drivers/AbCip.md +++ b/docs/drivers/AbCip.md @@ -75,7 +75,8 @@ the driver's `DriverConfig` JSON. Key fields: - **`Devices`** — one `AbCipDeviceOptions` per PLC (`HostAddress`, `PlcFamily`, optional `DeviceName`, per-device `AllowPacking` / `ConnectionSize` overrides). - **`Tags`** — pre-declared `AbCipTagDefinition` list; `Members` for UDT fan-out, `SafetyTag` for GuardLogix safety-partition tags. -- **`Probe`** — connectivity-probe `Enabled` / `Interval` / `Timeout` / `ProbeTagPath`. +- **`Probe`** — connectivity-probe `Enabled` / `Interval` / `Timeout` / `ProbeTagPath`. A driver created from the AdminUI starts with the probe **off** (an enabled probe with no tag path only logs a warning); an absent `probe` key in a stored config still means enabled to the driver. +- AdminUI driver forms (AbCip/AbLegacy) overlay only the keys they edit onto the stored JSON, so unmodelled keys survive a save (#576). - **`Transport`** — absent (default ⇒ `Native`), `Native`, or `Libplctag` (fallback); see [Transport](#transport-transport-option). - **Discovery** — `EnableControllerBrowse` (`@tags` walk) and `EnableDeclarationOnlyUdtGrouping` (whole-UDT read fast path). - **Alarms** — `EnableAlarmProjection` + `AlarmPollInterval` for the ALMD projection. diff --git a/src/Client/ZB.MOM.WW.OtOpcUa.Client.CLI/Commands/ReadCommand.cs b/src/Client/ZB.MOM.WW.OtOpcUa.Client.CLI/Commands/ReadCommand.cs index a8a0ff93..4f42413b 100644 --- a/src/Client/ZB.MOM.WW.OtOpcUa.Client.CLI/Commands/ReadCommand.cs +++ b/src/Client/ZB.MOM.WW.OtOpcUa.Client.CLI/Commands/ReadCommand.cs @@ -45,7 +45,23 @@ public class ReadCommand : CommandBase var ct = console.RegisterCancellationHandler(); (service, _) = await CreateServiceAndConnectAsync(ct); - var value = await service.ReadValueAsync(nodeId, ct); + DataValue value; + try + { + value = await service.ReadValueAsync(nodeId, ct); + } + catch (ServiceResultException ex) when (ex.StatusCode != StatusCodes.BadSessionIdInvalid + && ex.StatusCode != StatusCodes.BadConnectionClosed + && ex.StatusCode != StatusCodes.BadSecureChannelClosed) + { + // The SDK's Session.ReadValue surfaces a Bad node status (BadNodeIdUnknown, + // BadUserAccessDenied, ...) as an exception. A read command's job is to REPORT that status, + // not crash with a stack trace (#577) — session-level failures still propagate. + await console.Output.WriteLineAsync($"Node: {NodeId}"); + await console.Output.WriteLineAsync($"Value: "); + await console.Output.WriteLineAsync($"Status: {(StatusCode)ex.StatusCode} ({StatusCodes.GetBrowseName(ex.StatusCode)})"); + return; + } await console.Output.WriteLineAsync($"Node: {NodeId}"); await console.Output.WriteLineAsync($"Value: {value.Value}"); diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Raw/GlobalRaw.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Raw/GlobalRaw.razor index 845b9e06..4398507e 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Raw/GlobalRaw.razor +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Raw/GlobalRaw.razor @@ -1,6 +1,10 @@ @page "/raw" @attribute [Authorize(Policy = AdminUiPolicies.ConfigEditor)] -@rendermode RenderMode.InteractiveServer +@* prerender: false (#577 "first tree click ignored"): with prerender on, the server-rendered tree is shown + before the circuit attaches, so a click on its (handler-less) twists is silently dropped, and the + component then re-runs OnInitializedAsync and swaps in a second, freshly loaded node set. The tree + is the only content here and needs the circuit anyway, so render it once, interactively. *@ +@rendermode @(new InteractiveServerRenderMode(prerender: false)) @using ZB.MOM.WW.OtOpcUa.AdminUI.Uns @using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Raw @inject IRawTreeService Svc diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/DeviceModal.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/DeviceModal.razor index 5363f91e..9ca4b51e 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/DeviceModal.razor +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/DeviceModal.razor @@ -28,7 +28,7 @@
- +
A RawPath segment — letters, digits, dash, underscore.
@@ -145,7 +145,7 @@ { _resolvedDriverInstanceId = DriverInstanceId; _driverType = DriverType ?? ""; - _name = "Device1"; + _name = ""; // placeholder only: a pre-filled value made typing append to it (#577); the name is validated on save _enabled = true; _deviceConfigJson = "{}"; _rowVersion = []; diff --git a/tests/Client/ZB.MOM.WW.OtOpcUa.Client.CLI.Tests/ReadCommandTests.cs b/tests/Client/ZB.MOM.WW.OtOpcUa.Client.CLI.Tests/ReadCommandTests.cs index 1b894c0a..1dddb1bc 100644 --- a/tests/Client/ZB.MOM.WW.OtOpcUa.Client.CLI.Tests/ReadCommandTests.cs +++ b/tests/Client/ZB.MOM.WW.OtOpcUa.Client.CLI.Tests/ReadCommandTests.cs @@ -99,4 +99,45 @@ public class ReadCommandTests fakeService.DisconnectCalled.ShouldBeTrue(); fakeService.DisposeCalled.ShouldBeTrue(); } -} \ No newline at end of file + + /// A Bad node status is printed, not thrown (#577). + [Fact] + public async Task Execute_PrintsStatusCodeWhenTheReadFailsWithABadStatus() + { + var fakeService = new FakeOpcUaClientService + { + ReadException = new ServiceResultException(StatusCodes.BadNodeIdUnknown) + }; + var command = new ReadCommand(new FakeOpcUaClientServiceFactory(fakeService)) + { + Url = "opc.tcp://localhost:4840", + NodeId = "ns=2;s=Missing" + }; + + using var console = TestConsoleHelper.CreateConsole(); + await command.ExecuteAsync(console); // must not throw + + var output = TestConsoleHelper.GetOutput(console); + output.ShouldContain("Node: ns=2;s=Missing"); + output.ShouldContain("BadNodeIdUnknown"); + fakeService.DisconnectCalled.ShouldBeTrue(); // positive control: the finally still ran + } + + /// A session-level failure is still an error, not a printed status. + [Fact] + public async Task Execute_StillThrowsOnASessionLevelFailure() + { + var fakeService = new FakeOpcUaClientService + { + ReadException = new ServiceResultException(StatusCodes.BadConnectionClosed) + }; + var command = new ReadCommand(new FakeOpcUaClientServiceFactory(fakeService)) + { + Url = "opc.tcp://localhost:4840", + NodeId = "ns=2;s=Any" + }; + + using var console = TestConsoleHelper.CreateConsole(); + await Should.ThrowAsync(async () => await command.ExecuteAsync(console)); + } +}