Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 41 additions & 0 deletions App/Keybindings/DefaultKeybindings.cs
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ public interface IKeybindingActions
void UnsubscribeSelected();
void ToggleScopeSelection();
void OpenScope();
void WriteSelected();

// Application
void OpenConfig();
Expand Down Expand Up @@ -137,6 +138,26 @@ private static void ConfigureAddressSpaceBindings(KeybindingManager manager, IKe
showInStatusBar: true,
statusBarPriority: 30,
category: "Address Space");

manager.Register(
KeybindingContext.AddressSpace,
(Key)'w',
"Write",
"Write value to selected node",
actions.WriteSelected,
showInStatusBar: true,
statusBarPriority: 40,
category: "Address Space");

manager.Register(
KeybindingContext.AddressSpace,
(Key)'W',
"Write",
"Write value to selected node",
actions.WriteSelected,
showInStatusBar: false,
statusBarPriority: 41,
category: "Address Space");
}

private static void ConfigureMonitoredVariablesBindings(KeybindingManager manager, IKeybindingActions actions)
Expand Down Expand Up @@ -200,6 +221,26 @@ private static void ConfigureMonitoredVariablesBindings(KeybindingManager manage
showInStatusBar: false,
statusBarPriority: 41,
category: "Monitored Variables");

manager.Register(
KeybindingContext.MonitoredVariables,
(Key)'w',
"Write",
"Write value to selected variable",
actions.WriteSelected,
showInStatusBar: true,
statusBarPriority: 50,
category: "Monitored Variables");

manager.Register(
KeybindingContext.MonitoredVariables,
(Key)'W',
"Write",
"Write value to selected variable",
actions.WriteSelected,
showInStatusBar: false,
statusBarPriority: 51,
category: "Monitored Variables");
}

private static void ConfigureScopeBindings(KeybindingManager manager)
Expand Down
127 changes: 127 additions & 0 deletions App/MainWindow.cs
Original file line number Diff line number Diff line change
Expand Up @@ -543,6 +543,132 @@ private void UnsubscribeSelected()
}
}

private void WriteSelected()
{
if (!_connectionManager.IsConnected)
{
_logger.Warning("Not connected");
return;
}

if (_keybindingManager.CurrentContext == KeybindingContext.MonitoredVariables)
{
var variable = _monitoredVariablesView.SelectedVariable;
if (variable == null) return;
WriteToMonitoredVariable(variable);
}
else if (_keybindingManager.CurrentContext == KeybindingContext.AddressSpace)
{
var node = _addressSpaceView.SelectedNode;
if (node == null) return;
if (node.NodeClass != Opc.Ua.NodeClass.Variable)
{
_logger.Warning($"Cannot write to {node.NodeClass} nodes, only Variables");
return;
}
WriteToAddressSpaceNodeAsync(node).FireAndForget(_logger);
}
}

private void WriteToMonitoredVariable(MonitoredNode variable)
{
if (!variable.IsWritable)
{
_logger.Warning($"Node '{variable.DisplayName}' is not writable");
MessageBox.ErrorQuery("Write", $"Node '{variable.DisplayName}' is not writable.", "OK");
return;
}

if (!OpcValueConverter.IsWriteSupported(variable.DataType))
{
_logger.Warning($"Write not supported for data type {variable.DataType}");
MessageBox.ErrorQuery("Write", $"Write not supported for data type: {variable.DataType}", "OK");
return;
}

OpenWriteDialogAndWrite(
variable.NodeId,
variable.DisplayName,
variable.DataType,
variable.DataTypeName,
variable.Value);
}

private async Task WriteToAddressSpaceNodeAsync(BrowsedNode node)
{
byte accessLevel = 0;
Opc.Ua.BuiltInType builtInType = Opc.Ua.BuiltInType.Variant;
string dataTypeName = "Unknown";
string? currentValue = null;

try
{
var attrs = await _connectionManager.Client.ReadAttributesAsync(
node.NodeId,
Opc.Ua.Attributes.AccessLevel,
Opc.Ua.Attributes.DataType);

if (attrs.Count >= 2)
{
if (Opc.Ua.StatusCode.IsGood(attrs[0].StatusCode) && attrs[0].Value is byte al)
accessLevel = al;

if (Opc.Ua.StatusCode.IsGood(attrs[1].StatusCode) && attrs[1].Value is Opc.Ua.NodeId dataTypeNodeId)
{
(builtInType, dataTypeName) = SubscriptionManager.ResolveDataType(dataTypeNodeId);
}
}
Comment on lines +606 to +620

var dv = await _connectionManager.Client.ReadValueAsync(node.NodeId);
currentValue = dv?.Value?.ToString();
}
catch (Exception ex)
{
_logger.Error($"Failed to read attributes for write: {ex.Message}");
return;
}

if ((accessLevel & Opc.Ua.AccessLevels.CurrentWrite) == 0)
{
_logger.Warning($"Node '{node.DisplayName}' is not writable");
UiThread.Run(() => MessageBox.ErrorQuery("Write", $"Node '{node.DisplayName}' is not writable.", "OK"));
return;
}

if (!OpcValueConverter.IsWriteSupported(builtInType))
{
_logger.Warning($"Write not supported for data type {builtInType}");
UiThread.Run(() => MessageBox.ErrorQuery("Write", $"Write not supported for data type: {builtInType}", "OK"));
return;
}

UiThread.Run(() => OpenWriteDialogAndWrite(node.NodeId, node.DisplayName, builtInType, dataTypeName, currentValue));
}

private void OpenWriteDialogAndWrite(Opc.Ua.NodeId nodeId, string displayName, Opc.Ua.BuiltInType dataType, string dataTypeName, string? currentValue)
{
var dialog = new WriteValueDialog(nodeId, displayName, dataType, dataTypeName, currentValue);
Application.Run(dialog);

if (!dialog.Confirmed || dialog.ParsedValue == null) return;

var parsedValue = dialog.ParsedValue;
PerformWriteAsync(nodeId, displayName, parsedValue).FireAndForget(_logger);
}

private async Task PerformWriteAsync(Opc.Ua.NodeId nodeId, string displayName, object value)
{
var status = await _connectionManager.WriteValueAsync(nodeId, value);
if (Opc.Ua.StatusCode.IsGood(status))
{
_logger.Info($"Wrote {value} to {displayName}");
}
else
{
_logger.Error($"Write failed for {displayName}: 0x{status.Code:X8}");
}
}

private void OnNodeSelected(BrowsedNode node)
{
_nodeDetailsView.ShowNodeAsync(node).FireAndForget(_logger);
Expand Down Expand Up @@ -1271,6 +1397,7 @@ public void LoadConfigFromCommandLine(string configPath)
void DefaultKeybindings.IKeybindingActions.UnsubscribeSelected() => UnsubscribeSelected();
void DefaultKeybindings.IKeybindingActions.ToggleScopeSelection() { /* Handled by MonitoredVariablesView */ }
void DefaultKeybindings.IKeybindingActions.OpenScope() => LaunchScope();
void DefaultKeybindings.IKeybindingActions.WriteSelected() => WriteSelected();
void DefaultKeybindings.IKeybindingActions.OpenConfig() => OpenConfig();
void DefaultKeybindings.IKeybindingActions.SaveConfig() => SaveConfig();
void DefaultKeybindings.IKeybindingActions.SaveConfigAs() => SaveConfigAs();
Expand Down
1 change: 1 addition & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -467,6 +467,7 @@ Automates release builds and publishing.
|-----|--------|
| Enter | Subscribe to selected node |
| F5 | Refresh address space tree |
| W | Write value to selected node |

**Monitored Variables:**
| Key | Action |
Expand Down
14 changes: 14 additions & 0 deletions OpcUa/ConnectionManager.cs
Original file line number Diff line number Diff line change
Expand Up @@ -259,6 +259,20 @@ public Task<bool> UnsubscribeAsync(uint clientHandle)
return _subscriptionManager?.RemoveNodeAsync(clientHandle) ?? Task.FromResult(false);
}

/// <summary>
/// Writes a value to an OPC UA node's Value attribute.
/// </summary>
public Task<Opc.Ua.StatusCode> WriteValueAsync(Opc.Ua.NodeId nodeId, object value)
{
if (!_client.IsConnected)
{
_logger.Warning("Cannot write: not connected");
return Task.FromResult((Opc.Ua.StatusCode)Opc.Ua.StatusCodes.BadNotConnected);
}

return _client.WriteValueAsync(nodeId, value);
}

private async Task InitializeSubscriptionAsync(int publishingInterval = 250)
{
_subscriptionManager = new SubscriptionManager(_client, _logger);
Expand Down
2 changes: 1 addition & 1 deletion OpcUa/SubscriptionManager.cs
Original file line number Diff line number Diff line change
Expand Up @@ -263,7 +263,7 @@ private async Task ReadNodeAttributesAsync(MonitoredNode item)
}
}

private (BuiltInType, string) ResolveDataType(NodeId dataTypeNodeId)
internal static (BuiltInType, string) ResolveDataType(NodeId dataTypeNodeId)
{
// Compare against standard OPC UA DataType NodeIds explicitly for clarity and maintainability
if (dataTypeNodeId.NamespaceIndex == 0 && dataTypeNodeId.IdType == IdType.Numeric)
Expand Down
100 changes: 100 additions & 0 deletions Tests/Opcilloscope.Tests/Integration/WriteIntegrationTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
using Opc.Ua;
using Opcilloscope.OpcUa;
using Opcilloscope.Tests.Infrastructure;
using Opcilloscope.Utilities;

namespace Opcilloscope.Tests.Integration;

/// <summary>
/// Integration tests covering the node-write path end-to-end.
/// Wrapper-level write coverage for String/Int32/Boolean lives in OpcUaIntegrationTests;
/// these tests fill the remaining gaps (Double, read-only failure, ConnectionManager pass-through).
/// </summary>
[Collection("TestServer")]
public class WriteIntegrationTests : IAsyncLifetime
{
private readonly TestServerFixture _fixture;
private readonly Logger _logger = new();
private ConnectionManager? _connectionManager;

public WriteIntegrationTests(TestServerFixture fixture)
{
_fixture = fixture;
}

public Task InitializeAsync()
{
_connectionManager = new ConnectionManager(_logger);
return Task.CompletedTask;
}

public Task DisposeAsync()
{
_connectionManager?.Dispose();
return Task.CompletedTask;
}

private int GetNamespaceIndex()
{
var session = _connectionManager!.Client.Session
?? throw new InvalidOperationException("Client not connected");
var index = session.NamespaceUris.GetIndex(Opcilloscope.TestServer.TestNodeManager.NamespaceUri);
if (index < 0)
throw new InvalidOperationException("Test server namespace not found");
return index;
}

[Fact]
public async Task WrapperWrite_Double_RoundTrips()
{
using var client = await _fixture.CreateConnectedClientAsync();
var nsIndex = client.Session!.NamespaceUris.GetIndex(Opcilloscope.TestServer.TestNodeManager.NamespaceUri);
var nodeId = new NodeId("SineFrequency", (ushort)nsIndex);
var testValue = 0.42;

var status = await client.WriteValueAsync(nodeId, testValue);
Assert.True(StatusCode.IsGood(status));

var readBack = await client.ReadValueAsync(nodeId);
Assert.NotNull(readBack);
Assert.Equal(testValue, (double)readBack!.Value, 6);
Comment on lines +55 to +60
}

[Fact]
public async Task WrapperWrite_ReadOnlyNode_ReturnsBadStatus()
{
using var client = await _fixture.CreateConnectedClientAsync();
var nsIndex = client.Session!.NamespaceUris.GetIndex(Opcilloscope.TestServer.TestNodeManager.NamespaceUri);
// ServerName is a static read-only string in the StaticData folder
var nodeId = new NodeId("ServerName", (ushort)nsIndex);

var status = await client.WriteValueAsync(nodeId, "ShouldNotBeWritten");

Assert.False(StatusCode.IsGood(status), $"Expected non-Good status writing to read-only node, got 0x{status.Code:X8}");
}

[Fact]
public async Task ConnectionManager_WriteValueAsync_RoundTrips()
{
await _connectionManager!.ConnectAsync(_fixture.EndpointUrl);
var nodeId = new NodeId("WritableNumber", (ushort)GetNamespaceIndex());
var testValue = 7777;

var status = await _connectionManager.WriteValueAsync(nodeId, testValue);
Assert.True(StatusCode.IsGood(status));

var readBack = await _connectionManager.Client.ReadValueAsync(nodeId);
Assert.NotNull(readBack);
Assert.Equal(testValue, readBack!.Value);
}

[Fact]
public async Task ConnectionManager_WriteValueAsync_NotConnected_ReturnsBadNotConnected()
{
var nodeId = new NodeId("WritableNumber", (ushort)2);

var status = await _connectionManager!.WriteValueAsync(nodeId, 1);

Assert.Equal(StatusCodes.BadNotConnected, status.Code);
}
}
Loading