fix(adminui,cli): /raw device-name placeholder + no-prerender tree, CLI read prints Bad status (#577)
Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
This commit is contained in:
@@ -116,6 +116,8 @@ Source Time: 2026-03-30T19:58:38.0961252Z
|
|||||||
Server Time: 2026-03-30T19:58:38.0971257Z
|
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
|
### 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()`.
|
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()`.
|
||||||
|
|||||||
@@ -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).
|
- **`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.
|
- **`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).
|
- **`Transport`** — absent (default ⇒ `Native`), `Native`, or `Libplctag` (fallback); see [Transport](#transport-transport-option).
|
||||||
- **Discovery** — `EnableControllerBrowse` (`@tags` walk) and `EnableDeclarationOnlyUdtGrouping` (whole-UDT read fast path).
|
- **Discovery** — `EnableControllerBrowse` (`@tags` walk) and `EnableDeclarationOnlyUdtGrouping` (whole-UDT read fast path).
|
||||||
- **Alarms** — `EnableAlarmProjection` + `AlarmPollInterval` for the ALMD projection.
|
- **Alarms** — `EnableAlarmProjection` + `AlarmPollInterval` for the ALMD projection.
|
||||||
|
|||||||
@@ -45,7 +45,23 @@ public class ReadCommand : CommandBase
|
|||||||
var ct = console.RegisterCancellationHandler();
|
var ct = console.RegisterCancellationHandler();
|
||||||
(service, _) = await CreateServiceAndConnectAsync(ct);
|
(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($"Node: {NodeId}");
|
||||||
await console.Output.WriteLineAsync($"Value: {value.Value}");
|
await console.Output.WriteLineAsync($"Value: {value.Value}");
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
@page "/raw"
|
@page "/raw"
|
||||||
@attribute [Authorize(Policy = AdminUiPolicies.ConfigEditor)]
|
@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.Uns
|
||||||
@using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Raw
|
@using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Raw
|
||||||
@inject IRawTreeService Svc
|
@inject IRawTreeService Svc
|
||||||
|
|||||||
@@ -28,7 +28,7 @@
|
|||||||
<div class="row g-3 mb-2">
|
<div class="row g-3 mb-2">
|
||||||
<div class="col-md-6">
|
<div class="col-md-6">
|
||||||
<label class="form-label">Device name</label>
|
<label class="form-label">Device name</label>
|
||||||
<input class="form-control form-control-sm mono" @bind="_name" placeholder="Device1" />
|
<input class="form-control form-control-sm mono" @bind="_name" placeholder="e.g. plc1" />
|
||||||
<div class="form-text">A RawPath segment — letters, digits, dash, underscore.</div>
|
<div class="form-text">A RawPath segment — letters, digits, dash, underscore.</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-3">
|
<div class="col-md-3">
|
||||||
@@ -145,7 +145,7 @@
|
|||||||
{
|
{
|
||||||
_resolvedDriverInstanceId = DriverInstanceId;
|
_resolvedDriverInstanceId = DriverInstanceId;
|
||||||
_driverType = DriverType ?? "";
|
_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;
|
_enabled = true;
|
||||||
_deviceConfigJson = "{}";
|
_deviceConfigJson = "{}";
|
||||||
_rowVersion = [];
|
_rowVersion = [];
|
||||||
|
|||||||
@@ -99,4 +99,45 @@ public class ReadCommandTests
|
|||||||
fakeService.DisconnectCalled.ShouldBeTrue();
|
fakeService.DisconnectCalled.ShouldBeTrue();
|
||||||
fakeService.DisposeCalled.ShouldBeTrue();
|
fakeService.DisposeCalled.ShouldBeTrue();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>A Bad node status is printed, not thrown (#577).</summary>
|
||||||
|
[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
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>A session-level failure is still an error, not a printed status.</summary>
|
||||||
|
[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<ServiceResultException>(async () => await command.ExecuteAsync(console));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user