Add UI features, alarm ack, historian UTC fix, and Client.UI documentation

Major changes across the client stack:
- Settings persistence (connection, subscriptions, alarm source)
- Deferred OPC UA SDK init for instant startup
- Array/status code formatting, write value popup, alarm acknowledgment
- Severity-colored alarm rows, condition dedup on server side
- DateTimeRangePicker control with preset buttons and UTC text input
- Historian queries use wwTimezone=UTC and OPCQuality column
- Recursive subscribe from tree, multi-select remove
- Connection panel with expander, folder chooser for cert path
- Dynamic tab headers showing subscription/alarm counts
- Client.UI.md documentation with headless-rendered screenshots

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Joseph Doherty
2026-03-31 20:46:45 -04:00
parent 8fae2cb790
commit 188cbf7d24
53 changed files with 2652 additions and 189 deletions

View File

@@ -142,6 +142,7 @@ gr/ Galaxy repository docs, SQL queries, schema
| [Status Dashboard](docs/StatusDashboard.md) | HTTP server, health checks, metrics reporting | | [Status Dashboard](docs/StatusDashboard.md) | HTTP server, health checks, metrics reporting |
| [Service Hosting](docs/ServiceHosting.md) | TopShelf, startup/shutdown sequence, error handling | | [Service Hosting](docs/ServiceHosting.md) | TopShelf, startup/shutdown sequence, error handling |
| [Client CLI](docs/Client.CLI.md) | Connect, browse, read, write, subscribe, historyread, alarms, redundancy commands | | [Client CLI](docs/Client.CLI.md) | Connect, browse, read, write, subscribe, historyread, alarms, redundancy commands |
| [Client UI](docs/Client.UI.md) | Avalonia desktop client: browse, subscribe, alarms, history, write values |
| [Security](docs/security.md) | Transport security profiles, certificate trust, production hardening | | [Security](docs/security.md) | Transport security profiles, certificate trust, production hardening |
| [Redundancy](docs/Redundancy.md) | Non-transparent warm/hot redundancy, ServiceLevel, paired deployment | | [Redundancy](docs/Redundancy.md) | Non-transparent warm/hot redundancy, ServiceLevel, paired deployment |

264
docs/Client.UI.md Normal file
View File

@@ -0,0 +1,264 @@
# Client UI
## Overview
`ZB.MOM.WW.LmxOpcUa.Client.UI` is a cross-platform Avalonia desktop application for connecting to and interacting with the LmxOpcUa OPC UA server. It targets .NET 10 and uses the shared `IOpcUaClientService` from `Client.Shared` for all OPC UA operations.
The UI provides a single-window interface for browsing the address space, reading and writing values, monitoring live subscriptions, managing alarms, and querying historical data.
## Build and Run
```bash
cd src/ZB.MOM.WW.LmxOpcUa.Client.UI
dotnet build
dotnet run
```
## Technology Stack
| Component | Technology |
|-----------|-----------|
| Framework | .NET 10 |
| UI Toolkit | Avalonia 11.2 |
| MVVM | CommunityToolkit.Mvvm |
| OPC UA | OPCFoundation.NetStandard.Opc.Ua.Client |
| Logging | Serilog |
| Theme | Avalonia Fluent |
## Window Layout
The application uses a single-window layout with five main areas:
```
┌─────────────────────────────────────────────────────────────┐
│ [Endpoint URL ] [Connect] [Disconnect] │
│ ▸ Connection Settings │
│ Redundancy: Warm Service Level: 200 URI: urn:... │
├──────────────┬──────────────────────────────────────────────┤
│ │ ┌─Read/Write─┬─Subscriptions─┬─Alarms─┬─History─┐│
│ Address │ │ ││
│ Space │ │ ││
│ Tree │ │ (active tab content) ││
│ Browser │ │ ││
│ │ │ ││
│ (lazy-load) │ └──────────────────────────────────────────────┘│
├──────────────┴──────────────────────────────────────────────┤
│ Connected to opc.tcp://... | LmxOpcUa | Session: ... | 3 subs│
└─────────────────────────────────────────────────────────────┘
```
## Connection Panel
![Connection Panel](images/connection-panel.png)
The top bar provides the endpoint URL, Connect, and Disconnect buttons. The **Connection Settings** expander reveals additional options when expanded:
| Setting | Description |
|---------|-------------|
| Endpoint URL | OPC UA server endpoint (e.g., `opc.tcp://localhost:4840/LmxOpcUa`) |
| Username / Password | Credentials for `UserName` token authentication |
| Security Mode | Transport security: None, Sign, SignAndEncrypt |
| Failover URLs | Comma-separated backup endpoints for redundancy failover |
| Session Timeout | Session timeout in seconds (13600, default 60) |
| Certificate Store Path | Path to the client certificate store (folder chooser) |
| Auto-accept certificates | Whether to accept untrusted server certificates |
### Settings Persistence
Connection settings are saved to `{LocalAppData}/LmxOpcUaClient/settings.json` after each successful connection and on window close. The settings are reloaded on next launch, including:
- All connection parameters
- Active subscription node IDs (restored after reconnection)
- Alarm subscription source node (restored with condition refresh)
### Redundancy
When connected, the redundancy row displays the server's redundancy mode, service level, and application URI. The shared service handles automatic failover to backup endpoints if configured.
## Browse Tree
The left panel shows the OPC UA address space as a lazy-loaded tree. Nodes are loaded on demand when expanded.
### Context Menu
Right-click on tree nodes to access:
| Action | Description |
|--------|-------------|
| **Subscribe** | Subscribe to data changes on the selected node(s). For Object nodes, recursively subscribes all Variable descendants (up to 10 levels deep). Switches to the Subscriptions tab. |
| **View History** | Set the selected Variable node as the target in the History tab and switch to it. Only enabled for Variable nodes. |
| **Monitor Alarms** | Stop any active alarm subscription and subscribe to alarm events on the selected node. Switches to the Alarms tab with automatic condition refresh. |
Multi-select is supported (Ctrl+Click, Shift+Click) for the Subscribe action.
## Read/Write Tab
Select a node in the browse tree to auto-read its current value. The tab displays:
- Node ID
- Current value (arrays displayed as `[0,1,2,3]`)
- Status code (e.g., `0x00000000 (Good)`)
- Source and server timestamps
To write a value, enter the new value and click Send. The service reads the current value first to determine the target type, then converts and writes.
## Subscriptions Tab
![Subscriptions Tab](images/subscriptions-tab.png)
Monitor live data changes from subscribed nodes. The tab shows a data grid with:
| Column | Description |
|--------|-------------|
| Node ID | The monitored node identifier |
| Value | Current value (arrays formatted as `[v1,v2,...]`) |
| Status | OPC UA status code with description (e.g., `0x00000000 (Good)`) |
| Timestamp | Source timestamp in ISO 8601 format |
### Adding Subscriptions
- Type a node ID and click **Add**, or
- Right-click nodes in the browse tree and select **Subscribe**
### Removing Subscriptions
Select one or more rows (Ctrl+Click for multi-select) and click **Remove**.
### Writing Values
Double-click a subscription row to open a write dialog. The dialog:
1. Pre-fills the current value
2. Validates the input can parse to the target type before writing
3. Shows the write result status
4. Closes automatically on success, shows error in red on failure
### Tab Header
The tab header shows the active subscription count: `Subscriptions (26)`.
### Persistence
Active subscription node IDs are saved when the application closes or disconnects, and restored on the next connection.
## Alarms Tab
![Alarms Tab](images/alarms-tab.png)
Monitor alarm/condition events from the server.
### Subscribing
Enter an optional source node ID and click **Subscribe**. A condition refresh is automatically requested to display current retained alarms. Alternatively, right-click a node in the browse tree and select **Monitor Alarms**.
### Alarm Display
The data grid shows retained alarm conditions with color-coded rows:
| Severity Range | Color |
|---------------|-------|
| Inactive | Light grey |
| Low (0332) | Light blue |
| Medium (333665) | Light yellow |
| High (666899) | Light red |
| Critical (9001000) | Red |
Alarms are updated in place when the server re-sends condition state changes. Non-retained alarms are automatically removed.
### Acknowledging Alarms
Right-click an active, unacknowledged alarm and select **Acknowledge...**. Enter an acknowledgment comment in the popup dialog. The alarm is acknowledged via the OPC UA `Acknowledge` method on the condition node.
### Tab Header
The tab header shows active unacknowledged alarm count: `Alarms (2)`.
### Persistence
The alarm subscription source node is saved and restored on reconnection with automatic condition refresh.
## History Tab
![History Tab](images/history-tab.png)
Read historical data from the Wonderware Historian.
### Time Range
![Date/Time Range Picker](images/datetimerangepicker.png)
The date/time range picker provides:
- **Text input** — Type start and end times in `yyyy-MM-dd HH:mm:ss` format (UTC)
- **Preset buttons** — Quick selection: 5m (last 5 minutes), 1h (last hour), 1d (last day), 1w (last week)
All times are in UTC. Invalid input turns red on blur.
### Query Options
| Option | Description |
|--------|-------------|
| Aggregate | Raw (default), Average, Minimum, Maximum, Count, Start, End |
| Interval (ms) | Processing interval for aggregate queries (shown only for aggregates) |
| Max Values | Maximum number of raw values to return (default 1000) |
### Results
The results grid displays:
| Column | Description |
|--------|-------------|
| Value | The historical value (arrays formatted) |
| Status | OPC UA status code with description |
| Source Timestamp | When the value was recorded |
| Server Timestamp | When the server processed the value |
## Status Bar
The bottom status bar shows:
- Connection state and endpoint URL
- Server name and session identifier
- Active subscription count
## Architecture
### Deferred Initialization
The OPC UA SDK is not loaded until the user clicks Connect. This keeps application startup instant. The `IOpcUaClientService` and all child ViewModels are created on first connection.
### UI Thread Dispatch
All service event handlers (data changes, alarm events, connection state changes) are dispatched through an `IUiDispatcher` abstraction before updating `ObservableCollection`s. In production this wraps `Dispatcher.UIThread.Post()`; in tests it runs synchronously.
### ViewModels
| ViewModel | Responsibility |
|-----------|---------------|
| `MainWindowViewModel` | Connection lifecycle, tab coordination, settings persistence |
| `BrowseTreeViewModel` | Root node loading, tree clearing |
| `TreeNodeViewModel` | Lazy-load children on expand via `BrowseAsync` |
| `ReadWriteViewModel` | Auto-read on selection, write with type coercion |
| `SubscriptionsViewModel` | Add/remove subscriptions, DataChanged event handling |
| `AlarmsViewModel` | Alarm subscribe/unsubscribe, event filtering, acknowledge |
| `HistoryViewModel` | Raw and aggregate history reads |
### Custom Controls
| Control | Description |
|---------|-------------|
| `DateTimeRangePicker` | UTC start/end text inputs with preset duration buttons |
## Testing
The UI has 102 unit tests covering ViewModel logic and headless rendering:
```bash
dotnet test tests/ZB.MOM.WW.LmxOpcUa.Client.UI.Tests
```
Tests use:
- `FakeOpcUaClientService` — configurable fake implementing `IOpcUaClientService`
- `SynchronousUiDispatcher` — runs dispatch actions inline for deterministic testing
- `FakeSettingsService` — tracks save/load calls for settings persistence tests
- Avalonia headless rendering — screenshot capture for visual verification

BIN
docs/images/alarms-tab.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

BIN
docs/images/history-tab.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.3 KiB

View File

@@ -220,4 +220,27 @@ internal sealed class DefaultSessionAdapter : ISessionAdapter
_session.Dispose(); _session.Dispose();
} }
public async Task<IList<object>?> CallMethodAsync(NodeId objectId, NodeId methodId, object[] inputArguments,
CancellationToken ct = default)
{
var result = await _session.CallAsync(
null,
new CallMethodRequestCollection
{
new()
{
ObjectId = objectId,
MethodId = methodId,
InputArguments = new VariantCollection(inputArguments.Select(a => new Variant(a)))
}
},
ct);
var callResult = result.Results[0];
if (StatusCode.IsBad(callResult.StatusCode))
throw new ServiceResultException(callResult.StatusCode);
return callResult.OutputArguments?.Select(v => v.Value).ToList();
}
} }

View File

@@ -59,5 +59,7 @@ internal interface ISessionAdapter : IDisposable
/// </summary> /// </summary>
Task<ISubscriptionAdapter> CreateSubscriptionAsync(int publishingIntervalMs, CancellationToken ct = default); Task<ISubscriptionAdapter> CreateSubscriptionAsync(int publishingIntervalMs, CancellationToken ct = default);
Task<IList<object>?> CallMethodAsync(NodeId objectId, NodeId methodId, object[] inputArguments, CancellationToken ct = default);
Task CloseAsync(CancellationToken ct = default); Task CloseAsync(CancellationToken ct = default);
} }

View File

@@ -25,6 +25,7 @@ public interface IOpcUaClientService : IDisposable
Task SubscribeAlarmsAsync(NodeId? sourceNodeId = null, int intervalMs = 1000, CancellationToken ct = default); Task SubscribeAlarmsAsync(NodeId? sourceNodeId = null, int intervalMs = 1000, CancellationToken ct = default);
Task UnsubscribeAlarmsAsync(CancellationToken ct = default); Task UnsubscribeAlarmsAsync(CancellationToken ct = default);
Task RequestConditionRefreshAsync(CancellationToken ct = default); Task RequestConditionRefreshAsync(CancellationToken ct = default);
Task<StatusCode> AcknowledgeAlarmAsync(string conditionNodeId, byte[] eventId, string comment, CancellationToken ct = default);
Task<IReadOnlyList<DataValue>> HistoryReadRawAsync(NodeId nodeId, DateTime startTime, DateTime endTime, Task<IReadOnlyList<DataValue>> HistoryReadRawAsync(NodeId nodeId, DateTime startTime, DateTime endTime,
int maxValues = 1000, CancellationToken ct = default); int maxValues = 1000, CancellationToken ct = default);

View File

@@ -13,7 +13,9 @@ public sealed class AlarmEventArgs : EventArgs
bool retain, bool retain,
bool activeState, bool activeState,
bool ackedState, bool ackedState,
DateTime time) DateTime time,
byte[]? eventId = null,
string? conditionNodeId = null)
{ {
SourceName = sourceName; SourceName = sourceName;
ConditionName = conditionName; ConditionName = conditionName;
@@ -23,6 +25,8 @@ public sealed class AlarmEventArgs : EventArgs
ActiveState = activeState; ActiveState = activeState;
AckedState = ackedState; AckedState = ackedState;
Time = time; Time = time;
EventId = eventId;
ConditionNodeId = conditionNodeId;
} }
/// <summary>The name of the source object that raised the alarm.</summary> /// <summary>The name of the source object that raised the alarm.</summary>
@@ -48,4 +52,10 @@ public sealed class AlarmEventArgs : EventArgs
/// <summary>The time the event occurred.</summary> /// <summary>The time the event occurred.</summary>
public DateTime Time { get; } public DateTime Time { get; }
/// <summary>The EventId used for alarm acknowledgment.</summary>
public byte[]? EventId { get; }
/// <summary>The NodeId of the condition instance (SourceNode), used for acknowledgment.</summary>
public string? ConditionNodeId { get; }
} }

View File

@@ -277,6 +277,28 @@ public sealed class OpcUaClientService : IOpcUaClientService
Logger.Debug("Condition refresh requested"); Logger.Debug("Condition refresh requested");
} }
public async Task<StatusCode> AcknowledgeAlarmAsync(string conditionNodeId, byte[] eventId, string comment,
CancellationToken ct = default)
{
ThrowIfDisposed();
ThrowIfNotConnected();
// The Acknowledge method lives on the .Condition child node, not the source node itself
var conditionObjId = conditionNodeId.EndsWith(".Condition")
? NodeId.Parse(conditionNodeId)
: NodeId.Parse(conditionNodeId + ".Condition");
var acknowledgeMethodId = MethodIds.AcknowledgeableConditionType_Acknowledge;
await _session!.CallMethodAsync(
conditionObjId,
acknowledgeMethodId,
[eventId, new LocalizedText(comment)],
ct);
Logger.Debug("Acknowledged alarm on {ConditionId}", conditionNodeId);
return StatusCodes.Good;
}
public async Task<IReadOnlyList<DataValue>> HistoryReadRawAsync( public async Task<IReadOnlyList<DataValue>> HistoryReadRawAsync(
NodeId nodeId, DateTime startTime, DateTime endTime, int maxValues = 1000, CancellationToken ct = default) NodeId nodeId, DateTime startTime, DateTime endTime, int maxValues = 1000, CancellationToken ct = default)
{ {
@@ -502,17 +524,86 @@ public sealed class OpcUaClientService : IOpcUaClientService
if (fields == null || fields.Count < 6) if (fields == null || fields.Count < 6)
return; return;
var eventId = fields.Count > 0 ? fields[0].Value as byte[] : null;
var sourceName = fields.Count > 2 ? fields[2].Value as string ?? string.Empty : string.Empty; var sourceName = fields.Count > 2 ? fields[2].Value as string ?? string.Empty : string.Empty;
var time = fields.Count > 3 ? fields[3].Value as DateTime? ?? DateTime.MinValue : DateTime.MinValue; var time = DateTime.MinValue;
if (fields.Count > 3 && fields[3].Value != null)
{
if (fields[3].Value is DateTime dt)
time = dt;
else if (DateTime.TryParse(fields[3].Value.ToString(), out var parsed))
time = parsed;
}
var message = fields.Count > 4 ? (fields[4].Value as LocalizedText)?.Text ?? string.Empty : string.Empty; var message = fields.Count > 4 ? (fields[4].Value as LocalizedText)?.Text ?? string.Empty : string.Empty;
var severity = fields.Count > 5 ? Convert.ToUInt16(fields[5].Value) : (ushort)0; var severity = fields.Count > 5 ? Convert.ToUInt16(fields[5].Value) : (ushort)0;
var conditionName = fields.Count > 6 ? fields[6].Value as string ?? string.Empty : string.Empty; var conditionName = fields.Count > 6 ? fields[6].Value as string ?? string.Empty : string.Empty;
var retain = fields.Count > 7 ? fields[7].Value as bool? ?? false : false; var retain = fields.Count > 7 && ParseBool(fields[7].Value);
var ackedState = fields.Count > 8 ? fields[8].Value as bool? ?? false : false; var conditionNodeId = fields.Count > 12 ? fields[12].Value?.ToString() : null;
var activeState = fields.Count > 9 ? fields[9].Value as bool? ?? false : false;
// Try standard OPC UA ActiveState/AckedState fields first
bool? ackedField = fields.Count > 8 && fields[8].Value != null ? ParseBool(fields[8].Value) : null;
bool? activeField = fields.Count > 9 && fields[9].Value != null ? ParseBool(fields[9].Value) : null;
var ackedState = ackedField ?? false;
var activeState = activeField ?? false;
// Fallback: read InAlarm/Acked from condition node Galaxy attributes
// when the server doesn't populate standard event fields.
// Must run on a background thread to avoid deadlocking the notification thread.
if (ackedField == null && activeField == null && conditionNodeId != null && _session != null)
{
var session = _session;
var capturedConditionNodeId = conditionNodeId;
var capturedMessage = message;
_ = Task.Run(async () =>
{
try
{
var inAlarmValue = await session.ReadValueAsync(NodeId.Parse($"{capturedConditionNodeId}.InAlarm"));
if (inAlarmValue.Value is bool inAlarm) activeState = inAlarm;
var ackedValue = await session.ReadValueAsync(NodeId.Parse($"{capturedConditionNodeId}.Acked"));
if (ackedValue.Value is bool acked) ackedState = acked;
if (time == DateTime.MinValue && activeState)
{
var timeValue = await session.ReadValueAsync(NodeId.Parse($"{capturedConditionNodeId}.TimeAlarmOn"));
if (timeValue.Value is DateTime alarmTime && alarmTime != DateTime.MinValue)
time = alarmTime;
}
// Read alarm description to use as message
try
{
var descValue = await session.ReadValueAsync(NodeId.Parse($"{capturedConditionNodeId}.DescAttrName"));
if (descValue.Value is string desc && !string.IsNullOrEmpty(desc))
capturedMessage = desc;
}
catch { /* DescAttrName may not exist */ }
}
catch
{
// Supplemental read failed; use defaults
}
AlarmEvent?.Invoke(this, new AlarmEventArgs(
sourceName, conditionName, severity, capturedMessage, retain, activeState, ackedState, time,
eventId, capturedConditionNodeId));
});
return;
}
AlarmEvent?.Invoke(this, new AlarmEventArgs( AlarmEvent?.Invoke(this, new AlarmEventArgs(
sourceName, conditionName, severity, message, retain, activeState, ackedState, time)); sourceName, conditionName, severity, message, retain, activeState, ackedState, time,
eventId, conditionNodeId));
}
private static bool ParseBool(object? value)
{
if (value == null) return false;
if (value is bool b) return b;
try { return Convert.ToBoolean(value); }
catch { return false; }
} }
private static EventFilter CreateAlarmEventFilter() private static EventFilter CreateAlarmEventFilter()
@@ -542,6 +633,8 @@ public sealed class OpcUaClientService : IOpcUaClientService
filter.AddSelectClause(ObjectTypeIds.AlarmConditionType, "EnabledState/Id"); filter.AddSelectClause(ObjectTypeIds.AlarmConditionType, "EnabledState/Id");
// 11: SuppressedOrShelved // 11: SuppressedOrShelved
filter.AddSelectClause(ObjectTypeIds.AlarmConditionType, "SuppressedOrShelved"); filter.AddSelectClause(ObjectTypeIds.AlarmConditionType, "SuppressedOrShelved");
// 12: SourceNode (ConditionId for acknowledgment)
filter.AddSelectClause(ObjectTypeIds.BaseEventType, BrowseNames.SourceNode);
return filter; return filter;
} }

View File

@@ -0,0 +1,18 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64">
<!-- Server/network icon for OPC UA -->
<rect x="8" y="6" width="48" height="36" rx="4" fill="#2563eb" stroke="#1d4ed8" stroke-width="2"/>
<rect x="14" y="12" width="36" height="24" rx="2" fill="#dbeafe"/>
<!-- Network nodes -->
<circle cx="24" cy="24" r="4" fill="#2563eb"/>
<circle cx="40" cy="20" r="3" fill="#3b82f6"/>
<circle cx="36" cy="30" r="3" fill="#3b82f6"/>
<!-- Connection lines -->
<line x1="24" y1="24" x2="40" y2="20" stroke="#60a5fa" stroke-width="1.5"/>
<line x1="24" y1="24" x2="36" y2="30" stroke="#60a5fa" stroke-width="1.5"/>
<line x1="40" y1="20" x2="36" y2="30" stroke="#60a5fa" stroke-width="1.5"/>
<!-- Base stand -->
<rect x="24" y="42" width="16" height="4" rx="1" fill="#6b7280"/>
<rect x="20" y="46" width="24" height="4" rx="2" fill="#9ca3af"/>
<!-- UA text -->
<text x="32" y="60" text-anchor="middle" font-family="Arial,sans-serif" font-size="8" font-weight="bold" fill="#1d4ed8">UA</text>
</svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

View File

@@ -0,0 +1,11 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
x:Class="ZB.MOM.WW.LmxOpcUa.Client.UI.Controls.DateTimePicker">
<StackPanel Orientation="Horizontal" Spacing="6">
<CalendarDatePicker Name="DatePart" Width="130" />
<TimePicker Name="TimePart"
ClockIdentifier="24HourClock"
MinuteIncrement="1"
Width="100" />
</StackPanel>
</UserControl>

View File

@@ -0,0 +1,169 @@
using System;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Interactivity;
namespace ZB.MOM.WW.LmxOpcUa.Client.UI.Controls;
/// <summary>
/// A combined date + time picker that exposes a single DateTimeOffset value.
/// Bridges between CalendarDatePicker (DateTime?) and TimePicker (TimeSpan?)
/// and the public SelectedDateTime (DateTimeOffset?) property.
/// </summary>
public partial class DateTimePicker : UserControl
{
public static readonly StyledProperty<DateTimeOffset?> SelectedDateTimeProperty =
AvaloniaProperty.Register<DateTimePicker, DateTimeOffset?>(
nameof(SelectedDateTime), defaultValue: DateTimeOffset.Now);
public static readonly StyledProperty<DateTimeOffset?> MinDateTimeProperty =
AvaloniaProperty.Register<DateTimePicker, DateTimeOffset?>(
nameof(MinDateTime));
public static readonly StyledProperty<DateTimeOffset?> MaxDateTimeProperty =
AvaloniaProperty.Register<DateTimePicker, DateTimeOffset?>(
nameof(MaxDateTime));
private bool _isUpdating;
public DateTimePicker()
{
InitializeComponent();
}
/// <summary>The combined date and time value.</summary>
public DateTimeOffset? SelectedDateTime
{
get => GetValue(SelectedDateTimeProperty);
set => SetValue(SelectedDateTimeProperty, value);
}
/// <summary>Optional minimum allowed date/time.</summary>
public DateTimeOffset? MinDateTime
{
get => GetValue(MinDateTimeProperty);
set => SetValue(MinDateTimeProperty, value);
}
/// <summary>Optional maximum allowed date/time.</summary>
public DateTimeOffset? MaxDateTime
{
get => GetValue(MaxDateTimeProperty);
set => SetValue(MaxDateTimeProperty, value);
}
protected override void OnLoaded(RoutedEventArgs e)
{
base.OnLoaded(e);
var datePart = this.FindControl<CalendarDatePicker>("DatePart");
var timePart = this.FindControl<TimePicker>("TimePart");
if (datePart != null)
datePart.SelectedDateChanged += OnDatePartChanged;
if (timePart != null)
timePart.SelectedTimeChanged += OnTimePartChanged;
// Push initial value to the sub-controls
SyncFromDateTime();
}
protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change)
{
base.OnPropertyChanged(change);
if (_isUpdating) return;
if (change.Property == SelectedDateTimeProperty)
SyncFromDateTime();
else if (change.Property == MinDateTimeProperty || change.Property == MaxDateTimeProperty)
{
ClampDateTime();
UpdateCalendarBounds();
}
}
private void OnDatePartChanged(object? sender, SelectionChangedEventArgs e)
{
if (_isUpdating) return;
SyncToDateTime();
}
private void OnTimePartChanged(object? sender, TimePickerSelectedValueChangedEventArgs e)
{
if (_isUpdating) return;
SyncToDateTime();
}
private void SyncFromDateTime()
{
var dt = SelectedDateTime;
if (dt == null) return;
_isUpdating = true;
try
{
var datePart = this.FindControl<CalendarDatePicker>("DatePart");
var timePart = this.FindControl<TimePicker>("TimePart");
if (datePart != null)
datePart.SelectedDate = dt.Value.DateTime.Date;
if (timePart != null)
timePart.SelectedTime = dt.Value.TimeOfDay;
}
finally
{
_isUpdating = false;
}
}
private void SyncToDateTime()
{
var datePart = this.FindControl<CalendarDatePicker>("DatePart");
var timePart = this.FindControl<TimePicker>("TimePart");
var date = datePart?.SelectedDate ?? DateTime.Now.Date;
var time = timePart?.SelectedTime ?? TimeSpan.Zero;
_isUpdating = true;
try
{
var combined = new DateTimeOffset(
date.Year, date.Month, date.Day,
time.Hours, time.Minutes, time.Seconds,
DateTimeOffset.Now.Offset);
SelectedDateTime = Clamp(combined);
}
finally
{
_isUpdating = false;
}
}
private void ClampDateTime()
{
if (SelectedDateTime == null) return;
var clamped = Clamp(SelectedDateTime.Value);
if (clamped != SelectedDateTime)
SelectedDateTime = clamped;
}
private DateTimeOffset Clamp(DateTimeOffset value)
{
if (MinDateTime.HasValue && value < MinDateTime.Value)
return MinDateTime.Value;
if (MaxDateTime.HasValue && value > MaxDateTime.Value)
return MaxDateTime.Value;
return value;
}
private void UpdateCalendarBounds()
{
var datePart = this.FindControl<CalendarDatePicker>("DatePart");
if (datePart == null) return;
datePart.DisplayDateStart = MinDateTime?.DateTime;
datePart.DisplayDateEnd = MaxDateTime?.DateTime;
}
}

View File

@@ -0,0 +1,27 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
x:Class="ZB.MOM.WW.LmxOpcUa.Client.UI.Controls.DateTimeRangePicker">
<StackPanel Orientation="Horizontal" Spacing="8">
<StackPanel Spacing="2">
<TextBlock Text="Start (UTC)" FontSize="11" Foreground="Gray" />
<TextBox Name="StartInput" Width="170" FontFamily="Consolas,monospace" FontSize="13"
Watermark="yyyy-MM-dd HH:mm:ss (UTC)"
Text="{Binding StartText, RelativeSource={RelativeSource AncestorType=UserControl}}" />
</StackPanel>
<TextBlock Text="to" VerticalAlignment="Bottom" Margin="0,0,0,6" />
<StackPanel Spacing="2">
<TextBlock Text="End (UTC)" FontSize="11" Foreground="Gray" />
<TextBox Name="EndInput" Width="170" FontFamily="Consolas,monospace" FontSize="13"
Watermark="yyyy-MM-dd HH:mm:ss (UTC)"
Text="{Binding EndText, RelativeSource={RelativeSource AncestorType=UserControl}}" />
</StackPanel>
<Border Background="#F0F0F0" CornerRadius="4" Padding="4,2" VerticalAlignment="Bottom" Margin="0,0,0,2">
<StackPanel Orientation="Horizontal" Spacing="4">
<Button Name="Last5MinBtn" Content="5m" Padding="8,3" FontSize="11" />
<Button Name="LastHourBtn" Content="1h" Padding="8,3" FontSize="11" />
<Button Name="LastDayBtn" Content="1d" Padding="8,3" FontSize="11" />
<Button Name="LastWeekBtn" Content="1w" Padding="8,3" FontSize="11" />
</StackPanel>
</Border>
</StackPanel>
</UserControl>

View File

@@ -0,0 +1,187 @@
using System;
using System.Globalization;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Interactivity;
using Avalonia.Media;
namespace ZB.MOM.WW.LmxOpcUa.Client.UI.Controls;
/// <summary>
/// A date/time range picker using formatted text boxes and preset duration buttons.
/// Text properties are two-way bound to TextBoxes via XAML; DateTime properties
/// are synced in code-behind.
/// </summary>
public partial class DateTimeRangePicker : UserControl
{
private const string Format = "yyyy-MM-dd HH:mm:ss";
public static readonly StyledProperty<DateTimeOffset?> StartDateTimeProperty =
AvaloniaProperty.Register<DateTimeRangePicker, DateTimeOffset?>(
nameof(StartDateTime), defaultBindingMode: Avalonia.Data.BindingMode.TwoWay);
public static readonly StyledProperty<DateTimeOffset?> EndDateTimeProperty =
AvaloniaProperty.Register<DateTimeRangePicker, DateTimeOffset?>(
nameof(EndDateTime), defaultBindingMode: Avalonia.Data.BindingMode.TwoWay);
public static readonly StyledProperty<string> StartTextProperty =
AvaloniaProperty.Register<DateTimeRangePicker, string>(nameof(StartText), defaultValue: "");
public static readonly StyledProperty<string> EndTextProperty =
AvaloniaProperty.Register<DateTimeRangePicker, string>(nameof(EndText), defaultValue: "");
public static readonly StyledProperty<DateTimeOffset?> MinDateTimeProperty =
AvaloniaProperty.Register<DateTimeRangePicker, DateTimeOffset?>(nameof(MinDateTime));
public static readonly StyledProperty<DateTimeOffset?> MaxDateTimeProperty =
AvaloniaProperty.Register<DateTimeRangePicker, DateTimeOffset?>(nameof(MaxDateTime));
private bool _isUpdating;
public DateTimeRangePicker()
{
InitializeComponent();
}
public DateTimeOffset? StartDateTime
{
get => GetValue(StartDateTimeProperty);
set => SetValue(StartDateTimeProperty, value);
}
public DateTimeOffset? EndDateTime
{
get => GetValue(EndDateTimeProperty);
set => SetValue(EndDateTimeProperty, value);
}
public string StartText
{
get => GetValue(StartTextProperty);
set => SetValue(StartTextProperty, value);
}
public string EndText
{
get => GetValue(EndTextProperty);
set => SetValue(EndTextProperty, value);
}
public DateTimeOffset? MinDateTime
{
get => GetValue(MinDateTimeProperty);
set => SetValue(MinDateTimeProperty, value);
}
public DateTimeOffset? MaxDateTime
{
get => GetValue(MaxDateTimeProperty);
set => SetValue(MaxDateTimeProperty, value);
}
protected override void OnLoaded(RoutedEventArgs e)
{
base.OnLoaded(e);
var startInput = this.FindControl<TextBox>("StartInput");
var endInput = this.FindControl<TextBox>("EndInput");
if (startInput != null) startInput.LostFocus += OnStartLostFocus;
if (endInput != null) endInput.LostFocus += OnEndLostFocus;
var last5Min = this.FindControl<Button>("Last5MinBtn");
var lastHour = this.FindControl<Button>("LastHourBtn");
var lastDay = this.FindControl<Button>("LastDayBtn");
var lastWeek = this.FindControl<Button>("LastWeekBtn");
if (last5Min != null) last5Min.Click += (_, _) => ApplyPreset(TimeSpan.FromMinutes(5));
if (lastHour != null) lastHour.Click += (_, _) => ApplyPreset(TimeSpan.FromHours(1));
if (lastDay != null) lastDay.Click += (_, _) => ApplyPreset(TimeSpan.FromDays(1));
if (lastWeek != null) lastWeek.Click += (_, _) => ApplyPreset(TimeSpan.FromDays(7));
}
protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change)
{
base.OnPropertyChanged(change);
if (_isUpdating) return;
if (change.Property == StartDateTimeProperty)
{
_isUpdating = true;
StartText = StartDateTime?.UtcDateTime.ToString(Format) ?? "";
_isUpdating = false;
}
else if (change.Property == EndDateTimeProperty)
{
_isUpdating = true;
EndText = EndDateTime?.UtcDateTime.ToString(Format) ?? "";
_isUpdating = false;
}
}
private void OnStartLostFocus(object? sender, RoutedEventArgs e)
{
var startInput = this.FindControl<TextBox>("StartInput");
if (startInput == null) return;
if (TryParseDateTime(startInput.Text, out var dt))
{
_isUpdating = true;
StartDateTime = dt;
_isUpdating = false;
startInput.Foreground = null;
}
else if (!string.IsNullOrWhiteSpace(startInput.Text))
{
startInput.Foreground = Brushes.Red;
}
}
private void OnEndLostFocus(object? sender, RoutedEventArgs e)
{
var endInput = this.FindControl<TextBox>("EndInput");
if (endInput == null) return;
if (TryParseDateTime(endInput.Text, out var dt))
{
_isUpdating = true;
EndDateTime = dt;
_isUpdating = false;
endInput.Foreground = null;
}
else if (!string.IsNullOrWhiteSpace(endInput.Text))
{
endInput.Foreground = Brushes.Red;
}
}
private void ApplyPreset(TimeSpan span)
{
_isUpdating = true;
try
{
var now = DateTimeOffset.UtcNow;
StartDateTime = now - span;
EndDateTime = now;
StartText = StartDateTime.Value.UtcDateTime.ToString(Format);
EndText = EndDateTime.Value.UtcDateTime.ToString(Format);
}
finally
{
_isUpdating = false;
}
}
private static bool TryParseDateTime(string? text, out DateTimeOffset result)
{
result = default;
if (string.IsNullOrWhiteSpace(text)) return false;
if (DateTimeOffset.TryParseExact(text, Format, CultureInfo.InvariantCulture,
DateTimeStyles.AssumeUniversal, out result))
return true;
return DateTimeOffset.TryParse(text, CultureInfo.InvariantCulture,
DateTimeStyles.AssumeUniversal, out result);
}
}

View File

@@ -0,0 +1,36 @@
using Opc.Ua;
namespace ZB.MOM.WW.LmxOpcUa.Client.UI.Helpers;
/// <summary>
/// Formats OPC UA status codes as "0xHEX (description)".
/// </summary>
internal static class StatusCodeFormatter
{
public static string Format(StatusCode statusCode)
{
var code = statusCode.Code;
var hex = $"0x{code:X8}";
// Try the SDK's browse name lookup first
var name = StatusCodes.GetBrowseName(code);
if (!string.IsNullOrEmpty(name))
return $"{hex} ({name})";
// Try masking to just the main code (top 16 bits) for sub-status codes
var mainCode = code & 0xFFFF0000;
if (mainCode != code)
{
name = StatusCodes.GetBrowseName(mainCode);
if (!string.IsNullOrEmpty(name))
return $"{hex} ({name})";
}
// Fallback to severity category
if (StatusCode.IsGood(statusCode))
return $"{hex} (Good)";
if (StatusCode.IsBad(statusCode))
return $"{hex} (Bad)";
return $"{hex} (Uncertain)";
}
}

View File

@@ -0,0 +1,33 @@
using System.Collections;
namespace ZB.MOM.WW.LmxOpcUa.Client.UI.Helpers;
/// <summary>
/// Formats OPC UA values for display, with array support.
/// </summary>
internal static class ValueFormatter
{
public static string Format(object? value)
{
if (value is null) return "(null)";
if (value is Array array) return FormatArray(array);
if (value is IEnumerable enumerable and not string) return FormatEnumerable(enumerable);
return value.ToString() ?? "(null)";
}
private static string FormatArray(Array array)
{
var elements = new string[array.Length];
for (var i = 0; i < array.Length; i++)
elements[i] = array.GetValue(i)?.ToString() ?? "null";
return $"[{string.Join(",", elements)}]";
}
private static string FormatEnumerable(IEnumerable enumerable)
{
var items = new List<string>();
foreach (var item in enumerable)
items.Add(item?.ToString() ?? "null");
return $"[{string.Join(",", items)}]";
}
}

View File

@@ -2,7 +2,7 @@ using Avalonia;
namespace ZB.MOM.WW.LmxOpcUa.Client.UI; namespace ZB.MOM.WW.LmxOpcUa.Client.UI;
internal class Program public class Program
{ {
[STAThread] [STAThread]
public static void Main(string[] args) public static void Main(string[] args)

View File

@@ -0,0 +1,10 @@
namespace ZB.MOM.WW.LmxOpcUa.Client.UI.Services;
/// <summary>
/// Loads and saves user settings.
/// </summary>
public interface ISettingsService
{
UserSettings Load();
void Save(UserSettings settings);
}

View File

@@ -0,0 +1,50 @@
using System.Text.Json;
namespace ZB.MOM.WW.LmxOpcUa.Client.UI.Services;
/// <summary>
/// Persists user settings to a JSON file under LocalApplicationData.
/// </summary>
public sealed class JsonSettingsService : ISettingsService
{
private static readonly string SettingsDir = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"LmxOpcUaClient");
private static readonly string SettingsPath = Path.Combine(SettingsDir, "settings.json");
private static readonly JsonSerializerOptions JsonOptions = new()
{
WriteIndented = true
};
public UserSettings Load()
{
try
{
if (!File.Exists(SettingsPath))
return new UserSettings();
var json = File.ReadAllText(SettingsPath);
return JsonSerializer.Deserialize<UserSettings>(json, JsonOptions) ?? new UserSettings();
}
catch
{
return new UserSettings();
}
}
public void Save(UserSettings settings)
{
try
{
Directory.CreateDirectory(SettingsDir);
var json = JsonSerializer.Serialize(settings, JsonOptions);
File.WriteAllText(SettingsPath, json);
}
catch
{
// Best-effort save; don't crash the app
}
}
}

View File

@@ -0,0 +1,20 @@
using ZB.MOM.WW.LmxOpcUa.Client.Shared.Models;
namespace ZB.MOM.WW.LmxOpcUa.Client.UI.Services;
/// <summary>
/// Persisted user connection settings.
/// </summary>
public sealed class UserSettings
{
public string EndpointUrl { get; set; } = "opc.tcp://localhost:4840";
public string? Username { get; set; }
public string? Password { get; set; }
public SecurityMode SecurityMode { get; set; } = SecurityMode.None;
public string? FailoverUrls { get; set; }
public int SessionTimeoutSeconds { get; set; } = 60;
public bool AutoAcceptCertificates { get; set; } = true;
public string? CertificateStorePath { get; set; }
public List<string> SubscribedNodes { get; set; } = [];
public string? AlarmSourceNodeId { get; set; }
}

View File

@@ -15,7 +15,9 @@ public class AlarmEventViewModel : ObservableObject
bool retain, bool retain,
bool activeState, bool activeState,
bool ackedState, bool ackedState,
DateTime time) DateTime time,
byte[]? eventId = null,
string? conditionNodeId = null)
{ {
SourceName = sourceName; SourceName = sourceName;
ConditionName = conditionName; ConditionName = conditionName;
@@ -25,6 +27,8 @@ public class AlarmEventViewModel : ObservableObject
ActiveState = activeState; ActiveState = activeState;
AckedState = ackedState; AckedState = ackedState;
Time = time; Time = time;
EventId = eventId;
ConditionNodeId = conditionNodeId;
} }
public string SourceName { get; } public string SourceName { get; }
@@ -35,4 +39,9 @@ public class AlarmEventViewModel : ObservableObject
public bool ActiveState { get; } public bool ActiveState { get; }
public bool AckedState { get; } public bool AckedState { get; }
public DateTime Time { get; } public DateTime Time { get; }
} public byte[]? EventId { get; }
public string? ConditionNodeId { get; }
/// <summary>Whether this alarm can be acknowledged (active, not yet acked, has EventId).</summary>
public bool CanAcknowledge => ActiveState && !AckedState && EventId != null && ConditionNodeId != null;
}

View File

@@ -32,6 +32,8 @@ public partial class AlarmsViewModel : ObservableObject
[ObservableProperty] private string? _monitoredNodeIdText; [ObservableProperty] private string? _monitoredNodeIdText;
[ObservableProperty] private int _activeAlarmCount;
public AlarmsViewModel(IOpcUaClientService service, IUiDispatcher dispatcher) public AlarmsViewModel(IOpcUaClientService service, IUiDispatcher dispatcher)
{ {
_service = service; _service = service;
@@ -44,17 +46,36 @@ public partial class AlarmsViewModel : ObservableObject
private void OnAlarmEvent(object? sender, AlarmEventArgs e) private void OnAlarmEvent(object? sender, AlarmEventArgs e)
{ {
// Only display alarm/condition events (those with a ConditionName), not generic events
if (string.IsNullOrEmpty(e.ConditionName)) return;
_dispatcher.Post(() => _dispatcher.Post(() =>
{ {
AlarmEvents.Add(new AlarmEventViewModel( // Find existing row by source + condition and update it, or add new
e.SourceName, var existing = AlarmEvents.FirstOrDefault(a =>
e.ConditionName, a.SourceName == e.SourceName && a.ConditionName == e.ConditionName);
e.Severity,
e.Message, if (existing != null)
e.Retain, {
e.ActiveState, var index = AlarmEvents.IndexOf(existing);
e.AckedState, AlarmEvents[index] = new AlarmEventViewModel(
e.Time)); e.SourceName, e.ConditionName, e.Severity, e.Message,
e.Retain, e.ActiveState, e.AckedState, e.Time,
e.EventId, e.ConditionNodeId);
// Remove alarms that are no longer retained
if (!e.Retain)
AlarmEvents.RemoveAt(index);
}
else if (e.Retain)
{
AlarmEvents.Add(new AlarmEventViewModel(
e.SourceName, e.ConditionName, e.Severity, e.Message,
e.Retain, e.ActiveState, e.AckedState, e.Time,
e.EventId, e.ConditionNodeId));
}
ActiveAlarmCount = AlarmEvents.Count(a => a.ActiveState && !a.AckedState);
}); });
} }
@@ -74,6 +95,15 @@ public partial class AlarmsViewModel : ObservableObject
await _service.SubscribeAlarmsAsync(sourceNodeId, Interval); await _service.SubscribeAlarmsAsync(sourceNodeId, Interval);
IsSubscribed = true; IsSubscribed = true;
try
{
await _service.RequestConditionRefreshAsync();
}
catch
{
// Refresh not supported
}
} }
catch catch
{ {
@@ -113,6 +143,68 @@ public partial class AlarmsViewModel : ObservableObject
} }
} }
/// <summary>
/// Acknowledges an alarm and returns (success, message).
/// </summary>
public async Task<(bool Success, string Message)> AcknowledgeAlarmAsync(AlarmEventViewModel alarm, string comment)
{
if (!IsConnected || alarm.EventId == null || alarm.ConditionNodeId == null)
return (false, "Alarm cannot be acknowledged (missing EventId or ConditionId).");
try
{
var result = await _service.AcknowledgeAlarmAsync(alarm.ConditionNodeId, alarm.EventId, comment);
if (Opc.Ua.StatusCode.IsGood(result))
return (true, "Alarm acknowledged successfully.");
return (false, $"Acknowledge failed: {Helpers.StatusCodeFormatter.Format(result)}");
}
catch (Exception ex)
{
return (false, $"Error: {ex.Message}");
}
}
/// <summary>
/// Returns the monitored node ID for persistence, or null if not subscribed.
/// </summary>
public string? GetAlarmSourceNodeId()
{
return IsSubscribed ? MonitoredNodeIdText : null;
}
/// <summary>
/// Restores an alarm subscription and requests a condition refresh.
/// </summary>
public async Task RestoreAlarmSubscriptionAsync(string? sourceNodeId)
{
if (!IsConnected || string.IsNullOrWhiteSpace(sourceNodeId)) return;
MonitoredNodeIdText = sourceNodeId;
try
{
var nodeId = string.IsNullOrWhiteSpace(sourceNodeId)
? null
: NodeId.Parse(sourceNodeId);
await _service.SubscribeAlarmsAsync(nodeId, Interval);
IsSubscribed = true;
try
{
await _service.RequestConditionRefreshAsync();
}
catch
{
// Refresh not supported
}
}
catch
{
// Subscribe failed
}
}
/// <summary> /// <summary>
/// Clears alarm events and resets state. /// Clears alarm events and resets state.
/// </summary> /// </summary>
@@ -120,6 +212,7 @@ public partial class AlarmsViewModel : ObservableObject
{ {
AlarmEvents.Clear(); AlarmEvents.Clear();
IsSubscribed = false; IsSubscribed = false;
ActiveAlarmCount = 0;
} }
/// <summary> /// <summary>

View File

@@ -16,7 +16,7 @@ public partial class HistoryViewModel : ObservableObject
private readonly IUiDispatcher _dispatcher; private readonly IUiDispatcher _dispatcher;
private readonly IOpcUaClientService _service; private readonly IOpcUaClientService _service;
[ObservableProperty] private DateTimeOffset _endTime = DateTimeOffset.UtcNow; [ObservableProperty] private DateTimeOffset? _endTime = DateTimeOffset.UtcNow;
[ObservableProperty] private double _intervalMs = 3600000; [ObservableProperty] private double _intervalMs = 3600000;
@@ -32,7 +32,7 @@ public partial class HistoryViewModel : ObservableObject
[ObservableProperty] [NotifyCanExecuteChangedFor(nameof(ReadHistoryCommand))] [ObservableProperty] [NotifyCanExecuteChangedFor(nameof(ReadHistoryCommand))]
private string? _selectedNodeId; private string? _selectedNodeId;
[ObservableProperty] private DateTimeOffset _startTime = DateTimeOffset.UtcNow.AddHours(-1); [ObservableProperty] private DateTimeOffset? _startTime = DateTimeOffset.UtcNow.AddHours(-1);
public HistoryViewModel(IOpcUaClientService service, IUiDispatcher dispatcher) public HistoryViewModel(IOpcUaClientService service, IUiDispatcher dispatcher)
{ {
@@ -80,26 +80,29 @@ public partial class HistoryViewModel : ObservableObject
var nodeId = NodeId.Parse(SelectedNodeId); var nodeId = NodeId.Parse(SelectedNodeId);
IReadOnlyList<DataValue> values; IReadOnlyList<DataValue> values;
var start = (StartTime ?? DateTimeOffset.UtcNow.AddHours(-1)).UtcDateTime;
var end = (EndTime ?? DateTimeOffset.UtcNow).UtcDateTime;
if (SelectedAggregateType != null) if (SelectedAggregateType != null)
values = await _service.HistoryReadAggregateAsync( values = await _service.HistoryReadAggregateAsync(
nodeId, nodeId,
StartTime.UtcDateTime, start,
EndTime.UtcDateTime, end,
SelectedAggregateType.Value, SelectedAggregateType.Value,
IntervalMs); IntervalMs);
else else
values = await _service.HistoryReadRawAsync( values = await _service.HistoryReadRawAsync(
nodeId, nodeId,
StartTime.UtcDateTime, start,
EndTime.UtcDateTime, end,
MaxValues); MaxValues);
_dispatcher.Post(() => _dispatcher.Post(() =>
{ {
foreach (var dv in values) foreach (var dv in values)
Results.Add(new HistoryValueViewModel( Results.Add(new HistoryValueViewModel(
dv.Value?.ToString() ?? "(null)", Helpers.ValueFormatter.Format(dv.Value),
dv.StatusCode.ToString(), Helpers.StatusCodeFormatter.Format(dv.StatusCode),
dv.SourceTimestamp.ToString("O"), dv.SourceTimestamp.ToString("O"),
dv.ServerTimestamp.ToString("O"))); dv.ServerTimestamp.ToString("O")));
}); });

View File

@@ -13,7 +13,11 @@ namespace ZB.MOM.WW.LmxOpcUa.Client.UI.ViewModels;
public partial class MainWindowViewModel : ObservableObject public partial class MainWindowViewModel : ObservableObject
{ {
private readonly IUiDispatcher _dispatcher; private readonly IUiDispatcher _dispatcher;
private readonly IOpcUaClientService _service; private readonly IOpcUaClientServiceFactory _factory;
private readonly ISettingsService _settingsService;
private IOpcUaClientService? _service;
private List<string> _savedSubscribedNodes = [];
private string? _savedAlarmSourceNodeId;
[ObservableProperty] private bool _autoAcceptCertificates = true; [ObservableProperty] private bool _autoAcceptCertificates = true;
@@ -50,20 +54,18 @@ public partial class MainWindowViewModel : ObservableObject
[ObservableProperty] private int _subscriptionCount; [ObservableProperty] private int _subscriptionCount;
[ObservableProperty] private int _activeAlarmCount;
[ObservableProperty] private string? _username; [ObservableProperty] private string? _username;
public MainWindowViewModel(IOpcUaClientServiceFactory factory, IUiDispatcher dispatcher) public MainWindowViewModel(IOpcUaClientServiceFactory factory, IUiDispatcher dispatcher,
ISettingsService? settingsService = null)
{ {
_service = factory.Create(); _factory = factory;
_dispatcher = dispatcher; _dispatcher = dispatcher;
_settingsService = settingsService ?? new JsonSettingsService();
BrowseTree = new BrowseTreeViewModel(_service, dispatcher); LoadSettings();
ReadWrite = new ReadWriteViewModel(_service, dispatcher);
Subscriptions = new SubscriptionsViewModel(_service, dispatcher);
Alarms = new AlarmsViewModel(_service, dispatcher);
History = new HistoryViewModel(_service, dispatcher);
_service.ConnectionStateChanged += OnConnectionStateChanged;
} }
/// <summary>All available security modes.</summary> /// <summary>All available security modes.</summary>
@@ -74,11 +76,44 @@ public partial class MainWindowViewModel : ObservableObject
/// <summary>The currently selected tree nodes (supports multi-select).</summary> /// <summary>The currently selected tree nodes (supports multi-select).</summary>
public ObservableCollection<TreeNodeViewModel> SelectedTreeNodes { get; } = []; public ObservableCollection<TreeNodeViewModel> SelectedTreeNodes { get; } = [];
public BrowseTreeViewModel BrowseTree { get; } public BrowseTreeViewModel? BrowseTree { get; private set; }
public ReadWriteViewModel ReadWrite { get; } public ReadWriteViewModel? ReadWrite { get; private set; }
public SubscriptionsViewModel Subscriptions { get; } public SubscriptionsViewModel? Subscriptions { get; private set; }
public AlarmsViewModel Alarms { get; } public AlarmsViewModel? Alarms { get; private set; }
public HistoryViewModel History { get; } public HistoryViewModel? History { get; private set; }
public string SubscriptionsTabHeader => SubscriptionCount > 0
? $"Subscriptions ({SubscriptionCount})"
: "Subscriptions";
public string AlarmsTabHeader => ActiveAlarmCount > 0
? $"Alarms ({ActiveAlarmCount})"
: "Alarms";
private void InitializeService()
{
if (_service != null) return;
_service = _factory.Create();
_service.ConnectionStateChanged += OnConnectionStateChanged;
BrowseTree = new BrowseTreeViewModel(_service, _dispatcher);
ReadWrite = new ReadWriteViewModel(_service, _dispatcher);
Subscriptions = new SubscriptionsViewModel(_service, _dispatcher);
Alarms = new AlarmsViewModel(_service, _dispatcher);
Alarms.PropertyChanged += (_, args) =>
{
if (args.PropertyName == nameof(AlarmsViewModel.ActiveAlarmCount))
_dispatcher.Post(() => ActiveAlarmCount = Alarms.ActiveAlarmCount);
};
History = new HistoryViewModel(_service, _dispatcher);
OnPropertyChanged(nameof(BrowseTree));
OnPropertyChanged(nameof(ReadWrite));
OnPropertyChanged(nameof(Subscriptions));
OnPropertyChanged(nameof(Alarms));
OnPropertyChanged(nameof(History));
}
private void OnConnectionStateChanged(object? sender, ConnectionStateChangedEventArgs e) private void OnConnectionStateChanged(object? sender, ConnectionStateChangedEventArgs e)
{ {
@@ -90,10 +125,10 @@ public partial class MainWindowViewModel : ObservableObject
OnPropertyChanged(nameof(IsConnected)); OnPropertyChanged(nameof(IsConnected));
var connected = value == ConnectionState.Connected; var connected = value == ConnectionState.Connected;
ReadWrite.IsConnected = connected; if (ReadWrite != null) ReadWrite.IsConnected = connected;
Subscriptions.IsConnected = connected; if (Subscriptions != null) Subscriptions.IsConnected = connected;
Alarms.IsConnected = connected; if (Alarms != null) Alarms.IsConnected = connected;
History.IsConnected = connected; if (History != null) History.IsConnected = connected;
switch (value) switch (value)
{ {
@@ -110,20 +145,31 @@ public partial class MainWindowViewModel : ObservableObject
StatusMessage = "Disconnected"; StatusMessage = "Disconnected";
SessionLabel = string.Empty; SessionLabel = string.Empty;
RedundancyInfo = null; RedundancyInfo = null;
BrowseTree.Clear(); BrowseTree?.Clear();
ReadWrite.Clear(); ReadWrite?.Clear();
Subscriptions.Clear(); Subscriptions?.Clear();
Alarms.Clear(); Alarms?.Clear();
History.Clear(); History?.Clear();
SubscriptionCount = 0; SubscriptionCount = 0;
ActiveAlarmCount = 0;
break; break;
} }
} }
partial void OnSelectedTreeNodeChanged(TreeNodeViewModel? value) partial void OnSelectedTreeNodeChanged(TreeNodeViewModel? value)
{ {
ReadWrite.SelectedNodeId = value?.NodeId; if (ReadWrite != null) ReadWrite.SelectedNodeId = value?.NodeId;
History.SelectedNodeId = value?.NodeId; if (History != null) History.SelectedNodeId = value?.NodeId;
}
partial void OnSubscriptionCountChanged(int value)
{
OnPropertyChanged(nameof(SubscriptionsTabHeader));
}
partial void OnActiveAlarmCountChanged(int value)
{
OnPropertyChanged(nameof(AlarmsTabHeader));
} }
private bool CanConnect() private bool CanConnect()
@@ -139,6 +185,8 @@ public partial class MainWindowViewModel : ObservableObject
ConnectionState = ConnectionState.Connecting; ConnectionState = ConnectionState.Connecting;
StatusMessage = "Connecting..."; StatusMessage = "Connecting...";
InitializeService();
var settings = new ConnectionSettings var settings = new ConnectionSettings
{ {
EndpointUrl = EndpointUrl, EndpointUrl = EndpointUrl,
@@ -152,7 +200,7 @@ public partial class MainWindowViewModel : ObservableObject
}; };
settings.Validate(); settings.Validate();
var info = await _service.ConnectAsync(settings); var info = await _service!.ConnectAsync(settings);
_dispatcher.Post(() => _dispatcher.Post(() =>
{ {
@@ -163,7 +211,7 @@ public partial class MainWindowViewModel : ObservableObject
// Load redundancy info // Load redundancy info
try try
{ {
var redundancy = await _service.GetRedundancyInfoAsync(); var redundancy = await _service!.GetRedundancyInfoAsync();
_dispatcher.Post(() => RedundancyInfo = redundancy); _dispatcher.Post(() => RedundancyInfo = redundancy);
} }
catch catch
@@ -173,6 +221,19 @@ public partial class MainWindowViewModel : ObservableObject
// Load root nodes // Load root nodes
await BrowseTree.LoadRootsAsync(); await BrowseTree.LoadRootsAsync();
// Restore saved subscriptions
if (_savedSubscribedNodes.Count > 0 && Subscriptions != null)
{
await Subscriptions.RestoreSubscriptionsAsync(_savedSubscribedNodes);
SubscriptionCount = Subscriptions.SubscriptionCount;
}
// Restore saved alarm subscription
if (!string.IsNullOrEmpty(_savedAlarmSourceNodeId) && Alarms != null)
await Alarms.RestoreAlarmSubscriptionAsync(_savedAlarmSourceNodeId);
SaveSettings();
} }
catch (Exception ex) catch (Exception ex)
{ {
@@ -195,9 +256,10 @@ public partial class MainWindowViewModel : ObservableObject
{ {
try try
{ {
Subscriptions.Teardown(); SaveSettings();
Alarms.Teardown(); Subscriptions?.Teardown();
await _service.DisconnectAsync(); Alarms?.Teardown();
await _service!.DisconnectAsync();
} }
catch catch
{ {
@@ -217,8 +279,11 @@ public partial class MainWindowViewModel : ObservableObject
{ {
if (SelectedTreeNodes.Count == 0 || !IsConnected) return; if (SelectedTreeNodes.Count == 0 || !IsConnected) return;
if (Subscriptions == null) return;
var nodes = SelectedTreeNodes.ToList(); var nodes = SelectedTreeNodes.ToList();
foreach (var node in nodes) await Subscriptions.AddSubscriptionForNodeAsync(node.NodeId); foreach (var node in nodes)
await Subscriptions.AddSubscriptionRecursiveAsync(node.NodeId, node.NodeClass);
SubscriptionCount = Subscriptions.SubscriptionCount; SubscriptionCount = Subscriptions.SubscriptionCount;
SelectedTabIndex = 1; // Subscriptions tab SelectedTabIndex = 1; // Subscriptions tab
@@ -237,6 +302,32 @@ public partial class MainWindowViewModel : ObservableObject
SelectedTabIndex = 3; // History tab SelectedTabIndex = 3; // History tab
} }
/// <summary>
/// Stops any active alarm subscription, subscribes to alarms on the selected node,
/// and switches to the Alarms tab.
/// </summary>
[RelayCommand]
private async Task MonitorAlarmsForSelectedNodeAsync()
{
if (SelectedTreeNodes.Count == 0 || !IsConnected || Alarms == null) return;
var node = SelectedTreeNodes[0];
// Stop existing alarm subscription if active
if (Alarms.IsSubscribed)
{
try { await _service!.UnsubscribeAlarmsAsync(); }
catch { /* best effort */ }
Alarms.Clear();
}
// Subscribe to the selected node
Alarms.MonitoredNodeIdText = node.NodeId;
await Alarms.SubscribeCommand.ExecuteAsync(null);
SelectedTabIndex = 2; // Alarms tab
}
/// <summary> /// <summary>
/// Updates whether "View History" should be enabled based on the selected node's type. /// Updates whether "View History" should be enabled based on the selected node's type.
/// Only Variable nodes can have history. /// Only Variable nodes can have history.
@@ -248,6 +339,39 @@ public partial class MainWindowViewModel : ObservableObject
&& SelectedTreeNodes[0].NodeClass == "Variable"; && SelectedTreeNodes[0].NodeClass == "Variable";
} }
private void LoadSettings()
{
var s = _settingsService.Load();
EndpointUrl = s.EndpointUrl;
Username = s.Username;
Password = s.Password;
SelectedSecurityMode = s.SecurityMode;
FailoverUrls = s.FailoverUrls;
SessionTimeoutSeconds = s.SessionTimeoutSeconds;
AutoAcceptCertificates = s.AutoAcceptCertificates;
if (!string.IsNullOrEmpty(s.CertificateStorePath))
CertificateStorePath = s.CertificateStorePath;
_savedSubscribedNodes = s.SubscribedNodes;
_savedAlarmSourceNodeId = s.AlarmSourceNodeId;
}
public void SaveSettings()
{
_settingsService.Save(new UserSettings
{
EndpointUrl = EndpointUrl,
Username = Username,
Password = Password,
SecurityMode = SelectedSecurityMode,
FailoverUrls = FailoverUrls,
SessionTimeoutSeconds = SessionTimeoutSeconds,
AutoAcceptCertificates = AutoAcceptCertificates,
CertificateStorePath = CertificateStorePath,
SubscribedNodes = Subscriptions?.GetSubscribedNodeIds() ?? _savedSubscribedNodes,
AlarmSourceNodeId = Alarms?.GetAlarmSourceNodeId() ?? _savedAlarmSourceNodeId
});
}
private static string[]? ParseFailoverUrls(string? csv) private static string[]? ParseFailoverUrls(string? csv)
{ {
if (string.IsNullOrWhiteSpace(csv)) if (string.IsNullOrWhiteSpace(csv))

View File

@@ -72,8 +72,8 @@ public partial class ReadWriteViewModel : ObservableObject
_dispatcher.Post(() => _dispatcher.Post(() =>
{ {
CurrentValue = dataValue.Value?.ToString() ?? "(null)"; CurrentValue = Helpers.ValueFormatter.Format(dataValue.Value);
CurrentStatus = dataValue.StatusCode.ToString(); CurrentStatus = Helpers.StatusCodeFormatter.Format(dataValue.StatusCode);
SourceTimestamp = dataValue.SourceTimestamp.ToString("O"); SourceTimestamp = dataValue.SourceTimestamp.ToString("O");
ServerTimestamp = dataValue.ServerTimestamp.ToString("O"); ServerTimestamp = dataValue.ServerTimestamp.ToString("O");
}); });

View File

@@ -41,6 +41,9 @@ public partial class SubscriptionsViewModel : ObservableObject
/// <summary>Currently active subscriptions.</summary> /// <summary>Currently active subscriptions.</summary>
public ObservableCollection<SubscriptionItemViewModel> ActiveSubscriptions { get; } = []; public ObservableCollection<SubscriptionItemViewModel> ActiveSubscriptions { get; } = [];
/// <summary>Currently selected subscriptions (for multi-select remove).</summary>
public List<SubscriptionItemViewModel> SelectedSubscriptions { get; } = [];
private void OnDataChanged(object? sender, DataChangedEventArgs e) private void OnDataChanged(object? sender, DataChangedEventArgs e)
{ {
_dispatcher.Post(() => _dispatcher.Post(() =>
@@ -48,8 +51,8 @@ public partial class SubscriptionsViewModel : ObservableObject
foreach (var item in ActiveSubscriptions) foreach (var item in ActiveSubscriptions)
if (item.NodeId == e.NodeId) if (item.NodeId == e.NodeId)
{ {
item.Value = e.Value.Value?.ToString() ?? "(null)"; item.Value = Helpers.ValueFormatter.Format(e.Value.Value);
item.Status = e.Value.StatusCode.ToString(); item.Status = Helpers.StatusCodeFormatter.Format(e.Value.StatusCode);
item.Timestamp = e.Value.SourceTimestamp.ToString("O"); item.Timestamp = e.Value.SourceTimestamp.ToString("O");
} }
}); });
@@ -87,31 +90,34 @@ public partial class SubscriptionsViewModel : ObservableObject
private bool CanRemoveSubscription() private bool CanRemoveSubscription()
{ {
return IsConnected && SelectedSubscription != null; return IsConnected && (SelectedSubscriptions.Count > 0 || SelectedSubscription != null);
} }
[RelayCommand(CanExecute = nameof(CanRemoveSubscription))] [RelayCommand(CanExecute = nameof(CanRemoveSubscription))]
private async Task RemoveSubscriptionAsync() private async Task RemoveSubscriptionAsync()
{ {
if (SelectedSubscription == null) return; var itemsToRemove = SelectedSubscriptions.Count > 0
? SelectedSubscriptions.ToList()
: SelectedSubscription != null ? [SelectedSubscription] : [];
var item = SelectedSubscription; if (itemsToRemove.Count == 0) return;
try foreach (var item in itemsToRemove)
{ {
var nodeId = NodeId.Parse(item.NodeId); try
await _service.UnsubscribeAsync(nodeId);
_dispatcher.Post(() =>
{ {
ActiveSubscriptions.Remove(item); var nodeId = NodeId.Parse(item.NodeId);
SubscriptionCount = ActiveSubscriptions.Count; await _service.UnsubscribeAsync(nodeId);
});
} _dispatcher.Post(() => ActiveSubscriptions.Remove(item));
catch }
{ catch
// Unsubscribe failed {
// Unsubscribe failed for this item; continue with others
}
} }
_dispatcher.Post(() => SubscriptionCount = ActiveSubscriptions.Count);
} }
/// <summary> /// <summary>
@@ -141,6 +147,102 @@ public partial class SubscriptionsViewModel : ObservableObject
} }
} }
/// <summary>
/// Subscribes to a node and all its Variable descendants recursively.
/// Object nodes are browsed for children; Variable nodes are subscribed directly.
/// </summary>
public Task AddSubscriptionRecursiveAsync(string nodeIdStr, string nodeClass, int intervalMs = 1000)
{
return AddSubscriptionRecursiveAsync(nodeIdStr, nodeClass, intervalMs, maxDepth: 10, currentDepth: 0);
}
private async Task AddSubscriptionRecursiveAsync(string nodeIdStr, string nodeClass, int intervalMs, int maxDepth, int currentDepth)
{
if (!IsConnected || string.IsNullOrWhiteSpace(nodeIdStr)) return;
if (currentDepth >= maxDepth) return;
if (nodeClass == "Variable")
{
await AddSubscriptionForNodeAsync(nodeIdStr, intervalMs);
return;
}
// Browse children and recurse
try
{
var nodeId = NodeId.Parse(nodeIdStr);
var children = await _service.BrowseAsync(nodeId);
foreach (var child in children)
await AddSubscriptionRecursiveAsync(child.NodeId, child.NodeClass, intervalMs, maxDepth, currentDepth + 1);
}
catch
{
// Browse failed for this node; skip it
}
}
/// <summary>
/// Returns the node IDs of all active subscriptions for persistence.
/// </summary>
public List<string> GetSubscribedNodeIds()
{
return ActiveSubscriptions.Select(s => s.NodeId).ToList();
}
/// <summary>
/// Restores subscriptions from a saved list of node IDs.
/// </summary>
public async Task RestoreSubscriptionsAsync(IEnumerable<string> nodeIds)
{
foreach (var nodeId in nodeIds)
await AddSubscriptionForNodeAsync(nodeId);
}
/// <summary>
/// Reads the current value of a node to determine its type, validates that the raw
/// input can be parsed to that type, writes the value, and returns (success, message).
/// </summary>
public async Task<(bool Success, string Message)> ValidateAndWriteAsync(string nodeIdStr, string rawValue)
{
try
{
var nodeId = NodeId.Parse(nodeIdStr);
// Read current value to determine target type
var currentDataValue = await _service.ReadValueAsync(nodeId);
var currentValue = currentDataValue.Value;
// Try parsing to the target type before writing
try
{
Shared.Helpers.ValueConverter.ConvertValue(rawValue, currentValue);
}
catch (FormatException ex)
{
var typeName = currentValue?.GetType().Name ?? "unknown";
return (false, $"Cannot parse \"{rawValue}\" as {typeName}: {ex.Message}");
}
catch (OverflowException ex)
{
var typeName = currentValue?.GetType().Name ?? "unknown";
return (false, $"Value \"{rawValue}\" is out of range for {typeName}: {ex.Message}");
}
var result = await _service.WriteValueAsync(nodeId, rawValue);
var statusText = Helpers.StatusCodeFormatter.Format(result);
if (Opc.Ua.StatusCode.IsGood(result))
return (true, statusText);
return (false, $"Write failed: {statusText}");
}
catch (Exception ex)
{
return (false, $"Error: {ex.Message}");
}
}
/// <summary> /// <summary>
/// Clears all subscriptions and resets state. /// Clears all subscriptions and resets state.
/// </summary> /// </summary>

View File

@@ -0,0 +1,39 @@
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
x:Class="ZB.MOM.WW.LmxOpcUa.Client.UI.Views.AckAlarmWindow"
Title="Acknowledge Alarm"
Width="420"
SizeToContent="Height"
MinHeight="240"
WindowStartupLocation="CenterOwner"
CanResize="False">
<StackPanel Margin="16" Spacing="12">
<TextBlock Text="Acknowledge Alarm" FontWeight="Bold" FontSize="16" />
<StackPanel Spacing="4">
<TextBlock Text="Source:" FontSize="12" Foreground="Gray" />
<TextBlock Name="SourceText" FontWeight="SemiBold" />
</StackPanel>
<StackPanel Spacing="4">
<TextBlock Text="Condition:" FontSize="12" Foreground="Gray" />
<TextBlock Name="ConditionText" />
</StackPanel>
<StackPanel Spacing="4">
<TextBlock Text="Comment:" FontSize="12" Foreground="Gray" />
<TextBox Name="CommentInput"
Watermark="Enter acknowledgment comment"
AcceptsReturn="True"
Height="60"
TextWrapping="Wrap" />
</StackPanel>
<TextBlock Name="ResultText" Foreground="Gray" />
<StackPanel Orientation="Horizontal" Spacing="8" HorizontalAlignment="Right">
<Button Name="AckButton" Content="Acknowledge" />
<Button Name="CancelButton" Content="Cancel" />
</StackPanel>
</StackPanel>
</Window>

View File

@@ -0,0 +1,67 @@
using Avalonia.Controls;
using Avalonia.Interactivity;
using Avalonia.Media;
using ZB.MOM.WW.LmxOpcUa.Client.UI.ViewModels;
namespace ZB.MOM.WW.LmxOpcUa.Client.UI.Views;
public partial class AckAlarmWindow : Window
{
private readonly AlarmsViewModel _alarmsVm;
private readonly AlarmEventViewModel _alarm;
public AckAlarmWindow()
{
InitializeComponent();
_alarmsVm = null!;
_alarm = null!;
}
public AckAlarmWindow(AlarmsViewModel alarmsVm, AlarmEventViewModel alarm)
{
InitializeComponent();
_alarmsVm = alarmsVm;
_alarm = alarm;
var sourceText = this.FindControl<TextBlock>("SourceText");
if (sourceText != null) sourceText.Text = alarm.SourceName;
var conditionText = this.FindControl<TextBlock>("ConditionText");
if (conditionText != null) conditionText.Text = $"{alarm.ConditionName} (Severity: {alarm.Severity})";
var ackButton = this.FindControl<Button>("AckButton");
if (ackButton != null) ackButton.Click += OnAckClicked;
var cancelButton = this.FindControl<Button>("CancelButton");
if (cancelButton != null) cancelButton.Click += OnCancelClicked;
}
private async void OnAckClicked(object? sender, RoutedEventArgs e)
{
var commentInput = this.FindControl<TextBox>("CommentInput");
var resultText = this.FindControl<TextBlock>("ResultText");
if (commentInput == null || resultText == null) return;
var comment = commentInput.Text ?? string.Empty;
resultText.Foreground = Brushes.Gray;
resultText.Text = "Acknowledging...";
var (success, message) = await _alarmsVm.AcknowledgeAlarmAsync(_alarm, comment);
if (success)
{
Close();
}
else
{
resultText.Foreground = Brushes.Red;
resultText.Text = message;
}
}
private void OnCancelClicked(object? sender, RoutedEventArgs e)
{
Close();
}
}

View File

@@ -17,20 +17,23 @@
</StackPanel> </StackPanel>
<!-- Alarm Events --> <!-- Alarm Events -->
<DataGrid ItemsSource="{Binding AlarmEvents}" <DataGrid Name="AlarmsGrid"
ItemsSource="{Binding AlarmEvents}"
AutoGenerateColumns="False" AutoGenerateColumns="False"
IsReadOnly="True" IsReadOnly="True"
Margin="0,8,0,0"> HorizontalScrollBarVisibility="Auto"
Margin="0,8,0,0"
LoadingRow="OnDataGridLoadingRow">
<DataGrid.Columns> <DataGrid.Columns>
<DataGridTextColumn Header="Time" Binding="{Binding Time}" Width="180" /> <DataGridTextColumn Header="Time" Binding="{Binding Time, StringFormat='{}{0:yyyy-MM-dd HH:mm:ss}'}" Width="Auto" />
<DataGridTextColumn Header="Source" Binding="{Binding SourceName}" Width="120" /> <DataGridTextColumn Header="Source" Binding="{Binding SourceName}" Width="Auto" />
<DataGridTextColumn Header="Condition" Binding="{Binding ConditionName}" Width="120" /> <DataGridTextColumn Header="Condition" Binding="{Binding ConditionName}" Width="Auto" />
<DataGridTextColumn Header="Severity" Binding="{Binding Severity}" Width="80" /> <DataGridTextColumn Header="Severity" Binding="{Binding Severity}" Width="Auto" />
<DataGridTextColumn Header="Message" Binding="{Binding Message}" Width="200" /> <DataGridTextColumn Header="Message" Binding="{Binding Message}" Width="Auto" />
<DataGridCheckBoxColumn Header="Active" Binding="{Binding ActiveState}" Width="60" /> <DataGridCheckBoxColumn Header="Active" Binding="{Binding ActiveState}" Width="Auto" />
<DataGridCheckBoxColumn Header="Acked" Binding="{Binding AckedState}" Width="60" /> <DataGridCheckBoxColumn Header="Acked" Binding="{Binding AckedState}" Width="Auto" />
<DataGridCheckBoxColumn Header="Retain" Binding="{Binding Retain}" Width="60" /> <DataGridCheckBoxColumn Header="Retain" Binding="{Binding Retain}" Width="Auto" />
</DataGrid.Columns> </DataGrid.Columns>
</DataGrid> </DataGrid>
</DockPanel> </DockPanel>
</UserControl> </UserControl>

View File

@@ -1,11 +1,79 @@
using System.ComponentModel;
using Avalonia.Controls; using Avalonia.Controls;
using Avalonia.Interactivity;
using Avalonia.Media;
using Avalonia.VisualTree;
using ZB.MOM.WW.LmxOpcUa.Client.UI.ViewModels;
namespace ZB.MOM.WW.LmxOpcUa.Client.UI.Views; namespace ZB.MOM.WW.LmxOpcUa.Client.UI.Views;
public partial class AlarmsView : UserControl public partial class AlarmsView : UserControl
{ {
// Severity color bands (OPC UA severity 0-1000)
private static readonly IBrush InactiveBrush = new SolidColorBrush(Color.Parse("#F0F0F0")); // light grey
private static readonly IBrush LowBrush = new SolidColorBrush(Color.Parse("#DBEAFE")); // light blue (1-332)
private static readonly IBrush MediumBrush = new SolidColorBrush(Color.Parse("#FEF3C7")); // light yellow (333-665)
private static readonly IBrush HighBrush = new SolidColorBrush(Color.Parse("#FEE2E2")); // light red (666-899)
private static readonly IBrush CriticalBrush = new SolidColorBrush(Color.Parse("#FECACA")); // red (900-1000)
public AlarmsView() public AlarmsView()
{ {
InitializeComponent(); InitializeComponent();
} }
}
protected override void OnLoaded(RoutedEventArgs e)
{
base.OnLoaded(e);
var grid = this.FindControl<DataGrid>("AlarmsGrid");
if (grid == null) return;
var contextMenu = new ContextMenu();
var ackItem = new MenuItem { Header = "Acknowledge..." };
ackItem.Click += OnAcknowledgeClicked;
contextMenu.Items.Add(ackItem);
contextMenu.Opening += OnContextMenuOpening;
grid.ContextMenu = contextMenu;
}
private void OnDataGridLoadingRow(object? sender, DataGridRowEventArgs e)
{
if (e.Row.DataContext is AlarmEventViewModel alarm)
e.Row.Background = GetSeverityBrush(alarm);
}
private static IBrush GetSeverityBrush(AlarmEventViewModel alarm)
{
if (!alarm.ActiveState)
return InactiveBrush;
return alarm.Severity switch
{
>= 900 => CriticalBrush,
>= 666 => HighBrush,
>= 333 => MediumBrush,
_ => LowBrush
};
}
private void OnContextMenuOpening(object? sender, CancelEventArgs e)
{
var grid = this.FindControl<DataGrid>("AlarmsGrid");
if (grid?.SelectedItem is not AlarmEventViewModel alarm || !alarm.CanAcknowledge)
e.Cancel = true;
}
private void OnAcknowledgeClicked(object? sender, RoutedEventArgs e)
{
if (DataContext is not AlarmsViewModel vm) return;
var grid = this.FindControl<DataGrid>("AlarmsGrid");
if (grid?.SelectedItem is not AlarmEventViewModel alarm || !alarm.CanAcknowledge) return;
var parentWindow = this.FindAncestorOfType<Window>();
if (parentWindow == null) return;
var ackWindow = new AckAlarmWindow(vm, alarm);
ackWindow.ShowDialog(parentWindow);
}
}

View File

@@ -1,33 +1,24 @@
<UserControl xmlns="https://github.com/avaloniaui" <UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:ZB.MOM.WW.LmxOpcUa.Client.UI.ViewModels" xmlns:vm="using:ZB.MOM.WW.LmxOpcUa.Client.UI.ViewModels"
xmlns:controls="using:ZB.MOM.WW.LmxOpcUa.Client.UI.Controls"
x:Class="ZB.MOM.WW.LmxOpcUa.Client.UI.Views.HistoryView" x:Class="ZB.MOM.WW.LmxOpcUa.Client.UI.Views.HistoryView"
x:DataType="vm:HistoryViewModel"> x:DataType="vm:HistoryViewModel">
<DockPanel Margin="8"> <DockPanel Margin="8">
<!-- Controls --> <!-- Controls -->
<StackPanel DockPanel.Dock="Top" Spacing="8"> <StackPanel DockPanel.Dock="Top" Spacing="10">
<TextBlock Text="History Read" FontWeight="Bold" /> <TextBlock Text="History Read" FontWeight="Bold" />
<TextBlock Text="{Binding SelectedNodeId, FallbackValue='(no node selected)'}" <TextBlock Text="{Binding SelectedNodeId, FallbackValue='(no node selected)'}"
Foreground="Gray" /> Foreground="Gray" />
<StackPanel Orientation="Horizontal" Spacing="8"> <!-- Row 1: Time range -->
<StackPanel Spacing="2"> <controls:DateTimeRangePicker StartDateTime="{Binding StartTime, Mode=TwoWay}"
<TextBlock Text="Start Time" FontSize="11" /> EndDateTime="{Binding EndTime, Mode=TwoWay}" />
<DatePicker SelectedDate="{Binding StartTime}" />
</StackPanel>
<StackPanel Spacing="2">
<TextBlock Text="End Time" FontSize="11" />
<DatePicker SelectedDate="{Binding EndTime}" />
</StackPanel>
<StackPanel Spacing="2">
<TextBlock Text="Max Values" FontSize="11" />
<NumericUpDown Value="{Binding MaxValues}" Minimum="1" Maximum="100000" Width="120" />
</StackPanel>
</StackPanel>
<StackPanel Orientation="Horizontal" Spacing="8"> <!-- Row 2: Aggregate, Interval, Max Values, Read button -->
<StackPanel Orientation="Horizontal" Spacing="12">
<StackPanel Spacing="2"> <StackPanel Spacing="2">
<TextBlock Text="Aggregate" FontSize="11" /> <TextBlock Text="Aggregate" FontSize="11" Foreground="Gray" />
<ComboBox ItemsSource="{Binding AggregateTypes}" <ComboBox ItemsSource="{Binding AggregateTypes}"
SelectedItem="{Binding SelectedAggregateType}" SelectedItem="{Binding SelectedAggregateType}"
Width="150"> Width="150">
@@ -39,12 +30,17 @@
</ComboBox> </ComboBox>
</StackPanel> </StackPanel>
<StackPanel Spacing="2" IsVisible="{Binding IsAggregateRead}"> <StackPanel Spacing="2" IsVisible="{Binding IsAggregateRead}">
<TextBlock Text="Interval (ms)" FontSize="11" /> <TextBlock Text="Interval (ms)" FontSize="11" Foreground="Gray" />
<NumericUpDown Value="{Binding IntervalMs}" Minimum="1000" Maximum="86400000" Width="150" /> <NumericUpDown Value="{Binding IntervalMs}" Minimum="1000" Maximum="86400000" Width="150" />
</StackPanel> </StackPanel>
<StackPanel Spacing="2">
<TextBlock Text="Max Values" FontSize="11" Foreground="Gray" />
<NumericUpDown Value="{Binding MaxValues}" Minimum="1" Maximum="100000" Width="120" />
</StackPanel>
<Button Content="Read History" <Button Content="Read History"
Command="{Binding ReadHistoryCommand}" Command="{Binding ReadHistoryCommand}"
VerticalAlignment="Bottom" /> VerticalAlignment="Bottom"
Padding="16,6" />
</StackPanel> </StackPanel>
<ProgressBar IsIndeterminate="True" <ProgressBar IsIndeterminate="True"
@@ -56,13 +52,14 @@
<DataGrid ItemsSource="{Binding Results}" <DataGrid ItemsSource="{Binding Results}"
AutoGenerateColumns="False" AutoGenerateColumns="False"
IsReadOnly="True" IsReadOnly="True"
HorizontalScrollBarVisibility="Auto"
Margin="0,8,0,0"> Margin="0,8,0,0">
<DataGrid.Columns> <DataGrid.Columns>
<DataGridTextColumn Header="Value" Binding="{Binding Value}" Width="200" /> <DataGridTextColumn Header="Value" Binding="{Binding Value}" Width="Auto" />
<DataGridTextColumn Header="Status" Binding="{Binding Status}" Width="120" /> <DataGridTextColumn Header="Status" Binding="{Binding Status}" Width="Auto" />
<DataGridTextColumn Header="Source Timestamp" Binding="{Binding SourceTimestamp}" Width="220" /> <DataGridTextColumn Header="Source Timestamp" Binding="{Binding SourceTimestamp}" Width="Auto" />
<DataGridTextColumn Header="Server Timestamp" Binding="{Binding ServerTimestamp}" Width="220" /> <DataGridTextColumn Header="Server Timestamp" Binding="{Binding ServerTimestamp}" Width="Auto" />
</DataGrid.Columns> </DataGrid.Columns>
</DataGrid> </DataGrid>
</DockPanel> </DockPanel>
</UserControl> </UserControl>

View File

@@ -4,59 +4,106 @@
xmlns:views="using:ZB.MOM.WW.LmxOpcUa.Client.UI.Views" xmlns:views="using:ZB.MOM.WW.LmxOpcUa.Client.UI.Views"
x:Class="ZB.MOM.WW.LmxOpcUa.Client.UI.Views.MainWindow" x:Class="ZB.MOM.WW.LmxOpcUa.Client.UI.Views.MainWindow"
x:DataType="vm:MainWindowViewModel" x:DataType="vm:MainWindowViewModel"
Title="LmxOpcUa Client" Title="OPC UA Client"
Width="1200" Width="1200"
Height="800"> Height="800">
<DockPanel> <DockPanel>
<!-- Top Connection Bar --> <!-- Top Connection Bar -->
<Border DockPanel.Dock="Top" Background="#F0F0F0" Padding="8"> <Border DockPanel.Dock="Top" Background="#F0F0F0" Padding="12">
<StackPanel Spacing="8"> <StackPanel Spacing="4">
<!-- Always visible: URL + Connect/Disconnect -->
<StackPanel Orientation="Horizontal" Spacing="8"> <StackPanel Orientation="Horizontal" Spacing="8">
<TextBox Text="{Binding EndpointUrl}" <StackPanel Spacing="2" VerticalAlignment="Bottom">
Width="300" <TextBlock Text="Endpoint URL" FontSize="11" Foreground="Gray" />
Watermark="opc.tcp://host:port" /> <TextBox Text="{Binding EndpointUrl}"
<TextBox Text="{Binding Username}" Width="400"
Width="120" Watermark="opc.tcp://host:port" />
Watermark="Username" /> </StackPanel>
<TextBox Text="{Binding Password}"
Width="120"
Watermark="Password"
PasswordChar="*" />
<ComboBox ItemsSource="{Binding SecurityModes}"
SelectedItem="{Binding SelectedSecurityMode}"
Width="140" />
<Button Content="Connect" <Button Content="Connect"
Command="{Binding ConnectCommand}" /> Command="{Binding ConnectCommand}"
VerticalAlignment="Bottom" Padding="16,6" />
<Button Content="Disconnect" <Button Content="Disconnect"
Command="{Binding DisconnectCommand}" /> Command="{Binding DisconnectCommand}"
</StackPanel> VerticalAlignment="Bottom" Padding="16,6" />
<StackPanel Orientation="Horizontal" Spacing="8">
<TextBox Text="{Binding FailoverUrls}"
Width="300"
Watermark="Failover URLs (comma-separated)" />
<TextBlock Text="Timeout (s):" VerticalAlignment="Center" />
<NumericUpDown Value="{Binding SessionTimeoutSeconds}"
Minimum="1" Maximum="3600"
Width="90"
FormatString="0" />
<CheckBox IsChecked="{Binding AutoAcceptCertificates}"
Content="Auto-accept certificates"
VerticalAlignment="Center" />
<TextBox Text="{Binding CertificateStorePath}"
Width="200"
Watermark="Certificate store path" />
</StackPanel> </StackPanel>
<!-- Expandable settings -->
<Expander Header="Connection Settings" Padding="0,4">
<StackPanel Spacing="10" Margin="4,8,4,4">
<!-- Row 1: Authentication -->
<StackPanel Orientation="Horizontal" Spacing="12">
<StackPanel Spacing="2">
<TextBlock Text="Username" FontSize="11" Foreground="Gray" />
<TextBox Text="{Binding Username}"
Width="160"
Watermark="(anonymous)" />
</StackPanel>
<StackPanel Spacing="2">
<TextBlock Text="Password" FontSize="11" Foreground="Gray" />
<TextBox Text="{Binding Password}"
Width="160"
Watermark="(none)"
PasswordChar="*" />
</StackPanel>
<StackPanel Spacing="2">
<TextBlock Text="Security Mode" FontSize="11" Foreground="Gray" />
<ComboBox ItemsSource="{Binding SecurityModes}"
SelectedItem="{Binding SelectedSecurityMode}"
Width="160" />
</StackPanel>
</StackPanel>
<!-- Row 2: Failover + Session -->
<StackPanel Orientation="Horizontal" Spacing="12">
<StackPanel Spacing="2">
<TextBlock Text="Failover URLs (comma-separated)" FontSize="11" Foreground="Gray" />
<TextBox Text="{Binding FailoverUrls}"
Width="400"
Watermark="opc.tcp://backup1:4840, opc.tcp://backup2:4840" />
</StackPanel>
<StackPanel Spacing="2">
<TextBlock Text="Session Timeout (s)" FontSize="11" Foreground="Gray" />
<NumericUpDown Value="{Binding SessionTimeoutSeconds}"
Minimum="1" Maximum="3600"
Width="120"
FormatString="0" />
</StackPanel>
</StackPanel>
<!-- Row 3: Certificates -->
<StackPanel Orientation="Horizontal" Spacing="12">
<StackPanel Spacing="2">
<TextBlock Text="Certificate Store Path" FontSize="11" Foreground="Gray" />
<StackPanel Orientation="Horizontal" Spacing="4">
<TextBox Text="{Binding CertificateStorePath}"
Width="370"
IsReadOnly="True"
Watermark="(default: AppData/LmxOpcUaClient/pki)" />
<Button Name="BrowseCertPathButton"
Content="..."
Width="30"
ToolTip.Tip="Browse for folder" />
</StackPanel>
</StackPanel>
<CheckBox IsChecked="{Binding AutoAcceptCertificates}"
Content="Auto-accept untrusted server certificates"
VerticalAlignment="Bottom"
Margin="0,0,0,4" />
</StackPanel>
</StackPanel>
</Expander>
<!-- Redundancy Info --> <!-- Redundancy Info -->
<StackPanel Orientation="Horizontal" <StackPanel Orientation="Horizontal"
Spacing="12" Spacing="16"
IsVisible="{Binding RedundancyInfo, Converter={x:Static ObjectConverters.IsNotNull}}"> IsVisible="{Binding RedundancyInfo, Converter={x:Static ObjectConverters.IsNotNull}}"
Margin="0,2,0,0">
<TextBlock Text="{Binding RedundancyInfo.Mode, StringFormat='Redundancy: {0}'}" <TextBlock Text="{Binding RedundancyInfo.Mode, StringFormat='Redundancy: {0}'}"
FontSize="11" /> FontSize="11" Foreground="Gray" />
<TextBlock Text="{Binding RedundancyInfo.ServiceLevel, StringFormat='Service Level: {0}'}" <TextBlock Text="{Binding RedundancyInfo.ServiceLevel, StringFormat='Service Level: {0}'}"
FontSize="11" /> FontSize="11" Foreground="Gray" />
<TextBlock Text="{Binding RedundancyInfo.ApplicationUri, StringFormat='URI: {0}'}" <TextBlock Text="{Binding RedundancyInfo.ApplicationUri, StringFormat='URI: {0}'}"
FontSize="11" /> FontSize="11" Foreground="Gray" />
</StackPanel> </StackPanel>
</StackPanel> </StackPanel>
</Border> </Border>
@@ -80,12 +127,10 @@
Name="BrowseTreePanel"> Name="BrowseTreePanel">
<views:BrowseTreeView.ContextMenu> <views:BrowseTreeView.ContextMenu>
<ContextMenu Name="TreeContextMenu"> <ContextMenu Name="TreeContextMenu">
<MenuItem Header="Subscribe" <MenuItem Header="Subscribe" Name="SubscribeMenuItem" />
Command="{Binding $parent[Window].((vm:MainWindowViewModel)DataContext).SubscribeSelectedNodesCommand}" <MenuItem Header="View History" Name="ViewHistoryMenuItem" />
IsEnabled="{Binding $parent[Window].((vm:MainWindowViewModel)DataContext).IsConnected}" /> <Separator />
<MenuItem Header="View History" <MenuItem Header="Monitor Alarms" Name="MonitorAlarmsMenuItem" />
Command="{Binding $parent[Window].((vm:MainWindowViewModel)DataContext).ViewHistoryForSelectedNodeCommand}"
IsEnabled="{Binding $parent[Window].((vm:MainWindowViewModel)DataContext).IsHistoryEnabledForSelection}" />
</ContextMenu> </ContextMenu>
</views:BrowseTreeView.ContextMenu> </views:BrowseTreeView.ContextMenu>
</views:BrowseTreeView> </views:BrowseTreeView>
@@ -102,10 +147,10 @@
<TabItem Header="Read/Write"> <TabItem Header="Read/Write">
<views:ReadWriteView DataContext="{Binding ReadWrite}" /> <views:ReadWriteView DataContext="{Binding ReadWrite}" />
</TabItem> </TabItem>
<TabItem Header="Subscriptions"> <TabItem Header="{Binding SubscriptionsTabHeader}">
<views:SubscriptionsView DataContext="{Binding Subscriptions}" /> <views:SubscriptionsView DataContext="{Binding Subscriptions}" />
</TabItem> </TabItem>
<TabItem Header="Alarms"> <TabItem Header="{Binding AlarmsTabHeader}">
<views:AlarmsView DataContext="{Binding Alarms}" /> <views:AlarmsView DataContext="{Binding Alarms}" />
</TabItem> </TabItem>
<TabItem Header="History"> <TabItem Header="History">

View File

@@ -1,6 +1,9 @@
using System.ComponentModel; using System.ComponentModel;
using System.Reflection;
using Avalonia.Controls; using Avalonia.Controls;
using Avalonia.Interactivity; using Avalonia.Interactivity;
using SkiaSharp;
using Svg.Skia;
using ZB.MOM.WW.LmxOpcUa.Client.UI.ViewModels; using ZB.MOM.WW.LmxOpcUa.Client.UI.ViewModels;
namespace ZB.MOM.WW.LmxOpcUa.Client.UI.Views; namespace ZB.MOM.WW.LmxOpcUa.Client.UI.Views;
@@ -10,6 +13,41 @@ public partial class MainWindow : Window
public MainWindow() public MainWindow()
{ {
InitializeComponent(); InitializeComponent();
LoadIcon();
}
private void LoadIcon()
{
try
{
var assembly = Assembly.GetExecutingAssembly();
using var stream = assembly.GetManifestResourceStream("ZB.MOM.WW.LmxOpcUa.Client.UI.Assets.app-icon.svg");
if (stream == null) return;
using var svg = new SKSvg();
svg.Load(stream);
if (svg.Picture == null) return;
var size = 64;
using var bitmap = new SKBitmap(size, size);
using var canvas = new SKCanvas(bitmap);
canvas.Clear(SKColors.Transparent);
var bounds = svg.Picture.CullRect;
var scale = Math.Min(size / bounds.Width, size / bounds.Height);
canvas.Translate((size - bounds.Width * scale) / 2, (size - bounds.Height * scale) / 2);
canvas.Scale(scale);
canvas.DrawPicture(svg.Picture);
using var image = SKImage.FromBitmap(bitmap);
using var data = image.Encode(SKEncodedImageFormat.Png, 100);
using var pngStream = new MemoryStream(data.ToArray());
Icon = new WindowIcon(pngStream);
}
catch
{
// Icon loading is best-effort
}
} }
protected override void OnLoaded(RoutedEventArgs e) protected override void OnLoaded(RoutedEventArgs e)
@@ -20,19 +58,28 @@ public partial class MainWindow : Window
var treeView = browseTreeView?.FindControl<TreeView>("BrowseTree"); var treeView = browseTreeView?.FindControl<TreeView>("BrowseTree");
if (treeView != null) treeView.SelectionChanged += OnTreeSelectionChanged; if (treeView != null) treeView.SelectionChanged += OnTreeSelectionChanged;
// Wire up context menu opening to sync selection and check history
var contextMenu = this.FindControl<ContextMenu>("TreeContextMenu"); var contextMenu = this.FindControl<ContextMenu>("TreeContextMenu");
if (contextMenu != null) contextMenu.Opening += OnTreeContextMenuOpening; if (contextMenu != null) contextMenu.Opening += OnTreeContextMenuOpening;
var subscribeItem = this.FindControl<MenuItem>("SubscribeMenuItem");
if (subscribeItem != null) subscribeItem.Click += OnSubscribeClicked;
var viewHistoryItem = this.FindControl<MenuItem>("ViewHistoryMenuItem");
if (viewHistoryItem != null) viewHistoryItem.Click += OnViewHistoryClicked;
var monitorAlarmsItem = this.FindControl<MenuItem>("MonitorAlarmsMenuItem");
if (monitorAlarmsItem != null) monitorAlarmsItem.Click += OnMonitorAlarmsClicked;
var browseCertPath = this.FindControl<Button>("BrowseCertPathButton");
if (browseCertPath != null) browseCertPath.Click += OnBrowseCertPathClicked;
} }
private void OnTreeSelectionChanged(object? sender, SelectionChangedEventArgs e) private void OnTreeSelectionChanged(object? sender, SelectionChangedEventArgs e)
{ {
if (DataContext is not MainWindowViewModel vm || sender is not TreeView treeView) return; if (DataContext is not MainWindowViewModel vm || sender is not TreeView treeView) return;
// Update single selection for read/write and history panels
vm.SelectedTreeNode = treeView.SelectedItem as TreeNodeViewModel; vm.SelectedTreeNode = treeView.SelectedItem as TreeNodeViewModel;
// Sync multi-selection collection
vm.SelectedTreeNodes.Clear(); vm.SelectedTreeNodes.Clear();
foreach (var item in treeView.SelectedItems) foreach (var item in treeView.SelectedItems)
if (item is TreeNodeViewModel node) if (item is TreeNodeViewModel node)
@@ -41,6 +88,59 @@ public partial class MainWindow : Window
private void OnTreeContextMenuOpening(object? sender, CancelEventArgs e) private void OnTreeContextMenuOpening(object? sender, CancelEventArgs e)
{ {
if (DataContext is MainWindowViewModel vm) vm.UpdateHistoryEnabledForSelection(); if (DataContext is not MainWindowViewModel vm) return;
vm.UpdateHistoryEnabledForSelection();
var subscribeItem = this.FindControl<MenuItem>("SubscribeMenuItem");
var viewHistoryItem = this.FindControl<MenuItem>("ViewHistoryMenuItem");
var monitorAlarmsItem = this.FindControl<MenuItem>("MonitorAlarmsMenuItem");
if (subscribeItem != null)
subscribeItem.IsEnabled = vm.IsConnected && vm.SelectedTreeNodes.Count > 0;
if (viewHistoryItem != null)
viewHistoryItem.IsEnabled = vm.IsHistoryEnabledForSelection;
if (monitorAlarmsItem != null)
monitorAlarmsItem.IsEnabled = vm.IsConnected && vm.SelectedTreeNodes.Count > 0;
} }
}
private async void OnSubscribeClicked(object? sender, RoutedEventArgs e)
{
if (DataContext is MainWindowViewModel vm)
await vm.SubscribeSelectedNodesCommand.ExecuteAsync(null);
}
private void OnViewHistoryClicked(object? sender, RoutedEventArgs e)
{
if (DataContext is MainWindowViewModel vm)
vm.ViewHistoryForSelectedNodeCommand.Execute(null);
}
private async void OnMonitorAlarmsClicked(object? sender, RoutedEventArgs e)
{
if (DataContext is MainWindowViewModel vm)
await vm.MonitorAlarmsForSelectedNodeCommand.ExecuteAsync(null);
}
private async void OnBrowseCertPathClicked(object? sender, RoutedEventArgs e)
{
if (DataContext is not MainWindowViewModel vm) return;
var dialog = new OpenFolderDialog
{
Title = "Select Certificate Store Folder",
Directory = vm.CertificateStorePath
};
var result = await dialog.ShowAsync(this);
if (!string.IsNullOrEmpty(result))
vm.CertificateStorePath = result;
}
protected override void OnClosing(WindowClosingEventArgs e)
{
if (DataContext is MainWindowViewModel vm)
vm.SaveSettings();
base.OnClosing(e);
}
}

View File

@@ -16,16 +16,19 @@
</StackPanel> </StackPanel>
<!-- Active Subscriptions List --> <!-- Active Subscriptions List -->
<DataGrid ItemsSource="{Binding ActiveSubscriptions}" <DataGrid Name="SubscriptionsGrid"
ItemsSource="{Binding ActiveSubscriptions}"
SelectedItem="{Binding SelectedSubscription}" SelectedItem="{Binding SelectedSubscription}"
SelectionMode="Extended"
AutoGenerateColumns="False" AutoGenerateColumns="False"
IsReadOnly="True" IsReadOnly="True"
HorizontalScrollBarVisibility="Auto"
Margin="0,8,0,0"> Margin="0,8,0,0">
<DataGrid.Columns> <DataGrid.Columns>
<DataGridTextColumn Header="Node ID" Binding="{Binding NodeId}" Width="250" /> <DataGridTextColumn Header="Node ID" Binding="{Binding NodeId}" Width="Auto" />
<DataGridTextColumn Header="Value" Binding="{Binding Value}" Width="150" /> <DataGridTextColumn Header="Value" Binding="{Binding Value}" Width="Auto" />
<DataGridTextColumn Header="Status" Binding="{Binding Status}" Width="120" /> <DataGridTextColumn Header="Status" Binding="{Binding Status}" Width="Auto" />
<DataGridTextColumn Header="Timestamp" Binding="{Binding Timestamp}" Width="200" /> <DataGridTextColumn Header="Timestamp" Binding="{Binding Timestamp}" Width="Auto" />
</DataGrid.Columns> </DataGrid.Columns>
</DataGrid> </DataGrid>
</DockPanel> </DockPanel>

View File

@@ -1,4 +1,8 @@
using Avalonia.Controls; using Avalonia.Controls;
using Avalonia.Input;
using Avalonia.Interactivity;
using Avalonia.VisualTree;
using ZB.MOM.WW.LmxOpcUa.Client.UI.ViewModels;
namespace ZB.MOM.WW.LmxOpcUa.Client.UI.Views; namespace ZB.MOM.WW.LmxOpcUa.Client.UI.Views;
@@ -8,4 +12,40 @@ public partial class SubscriptionsView : UserControl
{ {
InitializeComponent(); InitializeComponent();
} }
}
protected override void OnLoaded(RoutedEventArgs e)
{
base.OnLoaded(e);
var grid = this.FindControl<DataGrid>("SubscriptionsGrid");
if (grid != null)
{
grid.DoubleTapped += OnGridDoubleTapped;
grid.SelectionChanged += OnGridSelectionChanged;
}
}
private void OnGridSelectionChanged(object? sender, SelectionChangedEventArgs e)
{
if (DataContext is not SubscriptionsViewModel vm || sender is not DataGrid grid) return;
vm.SelectedSubscriptions.Clear();
foreach (var item in grid.SelectedItems)
if (item is SubscriptionItemViewModel sub)
vm.SelectedSubscriptions.Add(sub);
vm.RemoveSubscriptionCommand.NotifyCanExecuteChanged();
}
private void OnGridDoubleTapped(object? sender, TappedEventArgs e)
{
if (DataContext is not SubscriptionsViewModel vm) return;
if (vm.SelectedSubscription is not { } item) return;
var parentWindow = this.FindAncestorOfType<Window>();
if (parentWindow == null) return;
var writeWindow = new WriteValueWindow(vm, item.NodeId, item.Value);
writeWindow.ShowDialog(parentWindow);
}
}

View File

@@ -0,0 +1,37 @@
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
x:Class="ZB.MOM.WW.LmxOpcUa.Client.UI.Views.WriteValueWindow"
Title="Write Value"
Width="420"
SizeToContent="Height"
MinHeight="280"
WindowStartupLocation="CenterOwner"
CanResize="False">
<StackPanel Margin="16" Spacing="12">
<TextBlock Text="Write Value to Node" FontWeight="Bold" FontSize="16" />
<StackPanel Spacing="4">
<TextBlock Text="Node ID:" FontSize="12" Foreground="Gray" />
<TextBlock Name="NodeIdText" FontWeight="SemiBold" />
</StackPanel>
<StackPanel Spacing="4">
<TextBlock Text="Current Value:" FontSize="12" Foreground="Gray" />
<TextBlock Name="CurrentValueText" />
</StackPanel>
<StackPanel Spacing="4">
<TextBlock Text="New Value:" FontSize="12" Foreground="Gray" />
<TextBox Name="WriteValueInput" Watermark="Enter value to write" />
</StackPanel>
<StackPanel Spacing="4">
<TextBlock Name="ResultText" Foreground="Gray" />
</StackPanel>
<StackPanel Orientation="Horizontal" Spacing="8" HorizontalAlignment="Right">
<Button Name="WriteButton" Content="Write" />
<Button Name="CloseButton" Content="Close" />
</StackPanel>
</StackPanel>
</Window>

View File

@@ -0,0 +1,77 @@
using Avalonia.Controls;
using Avalonia.Interactivity;
using Avalonia.Media;
using ZB.MOM.WW.LmxOpcUa.Client.UI.ViewModels;
namespace ZB.MOM.WW.LmxOpcUa.Client.UI.Views;
public partial class WriteValueWindow : Window
{
private readonly SubscriptionsViewModel _subscriptionsVm;
private readonly string _nodeId;
public WriteValueWindow()
{
InitializeComponent();
_subscriptionsVm = null!;
_nodeId = string.Empty;
}
public WriteValueWindow(SubscriptionsViewModel subscriptionsVm, string nodeId, string? currentValue)
{
InitializeComponent();
_subscriptionsVm = subscriptionsVm;
_nodeId = nodeId;
var nodeIdText = this.FindControl<TextBlock>("NodeIdText");
if (nodeIdText != null) nodeIdText.Text = nodeId;
var currentValueText = this.FindControl<TextBlock>("CurrentValueText");
if (currentValueText != null) currentValueText.Text = currentValue ?? "(null)";
// Pre-fill the write input with the current value
var writeInput = this.FindControl<TextBox>("WriteValueInput");
if (writeInput != null) writeInput.Text = currentValue ?? "";
var writeButton = this.FindControl<Button>("WriteButton");
if (writeButton != null) writeButton.Click += OnWriteClicked;
var closeButton = this.FindControl<Button>("CloseButton");
if (closeButton != null) closeButton.Click += OnCloseClicked;
}
private async void OnWriteClicked(object? sender, RoutedEventArgs e)
{
var input = this.FindControl<TextBox>("WriteValueInput");
var resultText = this.FindControl<TextBlock>("ResultText");
if (input == null || resultText == null) return;
var rawValue = input.Text;
if (string.IsNullOrEmpty(rawValue))
{
resultText.Foreground = Brushes.Red;
resultText.Text = "Please enter a value.";
return;
}
resultText.Foreground = Brushes.Gray;
resultText.Text = "Writing...";
var (success, message) = await _subscriptionsVm.ValidateAndWriteAsync(_nodeId, rawValue);
if (success)
{
Close();
}
else
{
resultText.Foreground = Brushes.Red;
resultText.Text = message;
}
}
private void OnCloseClicked(object? sender, RoutedEventArgs e)
{
Close();
}
}

View File

@@ -11,9 +11,11 @@
<ItemGroup> <ItemGroup>
<PackageReference Include="Avalonia" Version="11.2.7"/> <PackageReference Include="Avalonia" Version="11.2.7"/>
<PackageReference Include="Avalonia.Desktop" Version="11.2.7"/> <PackageReference Include="Avalonia.Desktop" Version="11.2.7"/>
<PackageReference Include="Avalonia.Svg.Skia" Version="11.2.0.2"/>
<PackageReference Include="Avalonia.Themes.Fluent" Version="11.2.7"/> <PackageReference Include="Avalonia.Themes.Fluent" Version="11.2.7"/>
<PackageReference Include="Avalonia.Fonts.Inter" Version="11.2.7"/> <PackageReference Include="Avalonia.Fonts.Inter" Version="11.2.7"/>
<PackageReference Include="Avalonia.Controls.DataGrid" Version="11.2.7"/> <PackageReference Include="Avalonia.Controls.DataGrid" Version="11.2.7"/>
<PackageReference Include="Avalonia.Diagnostics" Version="11.2.7"/>
<PackageReference Include="CommunityToolkit.Mvvm" Version="8.4.0"/> <PackageReference Include="CommunityToolkit.Mvvm" Version="8.4.0"/>
<PackageReference Include="Serilog" Version="4.2.0"/> <PackageReference Include="Serilog" Version="4.2.0"/>
<PackageReference Include="Serilog.Sinks.Console" Version="6.0.0"/> <PackageReference Include="Serilog.Sinks.Console" Version="6.0.0"/>
@@ -27,4 +29,9 @@
<InternalsVisibleTo Include="ZB.MOM.WW.LmxOpcUa.Client.UI.Tests"/> <InternalsVisibleTo Include="ZB.MOM.WW.LmxOpcUa.Client.UI.Tests"/>
</ItemGroup> </ItemGroup>
<ItemGroup>
<AvaloniaResource Include="Assets\**" Exclude="Assets\app-icon.svg" />
<EmbeddedResource Include="Assets\app-icon.svg" />
</ItemGroup>
</Project> </Project>

View File

@@ -42,8 +42,8 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.Historian
{ {
var results = new List<DataValue>(); var results = new List<DataValue>();
var sql = maxValues > 0 var sql = maxValues > 0
? "SELECT TOP (@MaxValues) DateTime, Value, vValue, Quality FROM Runtime.dbo.History WHERE TagName = @TagName AND DateTime >= @StartTime AND DateTime <= @EndTime ORDER BY DateTime" ? "SELECT TOP (@MaxValues) DateTime, Value, vValue, OPCQuality FROM Runtime.dbo.History WHERE TagName = @TagName AND wwTimezone='UTC' AND DateTime >= @StartTime AND DateTime <= @EndTime ORDER BY DateTime"
: "SELECT DateTime, Value, vValue, Quality FROM Runtime.dbo.History WHERE TagName = @TagName AND DateTime >= @StartTime AND DateTime <= @EndTime ORDER BY DateTime"; : "SELECT DateTime, Value, vValue, OPCQuality FROM Runtime.dbo.History WHERE TagName = @TagName AND wwTimezone='UTC' AND DateTime >= @StartTime AND DateTime <= @EndTime ORDER BY DateTime";
using var conn = new SqlConnection(_config.ConnectionString); using var conn = new SqlConnection(_config.ConnectionString);
await conn.OpenAsync(ct); await conn.OpenAsync(ct);
@@ -58,7 +58,7 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.Historian
using var reader = await cmd.ExecuteReaderAsync(ct); using var reader = await cmd.ExecuteReaderAsync(ct);
while (await reader.ReadAsync(ct)) while (await reader.ReadAsync(ct))
{ {
var timestamp = reader.GetDateTime(0); var timestamp = DateTime.SpecifyKind(reader.GetDateTime(0), DateTimeKind.Utc);
object? value; object? value;
if (!reader.IsDBNull(1)) if (!reader.IsDBNull(1))
value = reader.GetDouble(1); value = reader.GetDouble(1);
@@ -99,7 +99,7 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.Historian
{ {
var results = new List<DataValue>(); var results = new List<DataValue>();
var sql = $"SELECT StartDateTime, [{aggregateColumn}] FROM Runtime.dbo.AnalogSummaryHistory " + var sql = $"SELECT StartDateTime, [{aggregateColumn}] FROM Runtime.dbo.AnalogSummaryHistory " +
"WHERE TagName = @TagName AND StartDateTime >= @StartTime AND StartDateTime <= @EndTime " + "WHERE TagName = @TagName AND wwTimezone='UTC' AND StartDateTime >= @StartTime AND StartDateTime <= @EndTime " +
"AND wwResolution = @Resolution ORDER BY StartDateTime"; "AND wwResolution = @Resolution ORDER BY StartDateTime";
using var conn = new SqlConnection(_config.ConnectionString); using var conn = new SqlConnection(_config.ConnectionString);
@@ -114,7 +114,7 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.Historian
using var reader = await cmd.ExecuteReaderAsync(ct); using var reader = await cmd.ExecuteReaderAsync(ct);
while (await reader.ReadAsync(ct)) while (await reader.ReadAsync(ct))
{ {
var timestamp = reader.GetDateTime(0); var timestamp = DateTime.SpecifyKind(reader.GetDateTime(0), DateTimeKind.Utc);
var value = reader.IsDBNull(1) ? (object?)null : reader.GetDouble(1); var value = reader.IsDBNull(1) ? (object?)null : reader.GetDouble(1);
results.Add(new DataValue results.Add(new DataValue

View File

@@ -1100,6 +1100,11 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa
/// Gets or sets the Galaxy tag reference for the acknowledge message that triggers acknowledgment. /// Gets or sets the Galaxy tag reference for the acknowledge message that triggers acknowledgment.
/// </summary> /// </summary>
public string AckMsgTagReference { get; set; } = ""; public string AckMsgTagReference { get; set; } = "";
/// <summary>
/// Gets or sets the most recent acknowledged state so duplicate transitions are not reissued.
/// </summary>
public bool? LastAcked { get; set; }
} }
#region Read/Write Handlers #region Read/Write Handlers
@@ -1726,13 +1731,15 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa
alarmInfo = null; alarmInfo = null;
} }
// Check for Acked transitions // Check for Acked transitions — skip if state hasn't changed
if (_alarmAckedTags.TryGetValue(address, out ackedAlarmInfo)) if (_alarmAckedTags.TryGetValue(address, out ackedAlarmInfo))
{ {
newAcked = vtq.Value is true || vtq.Value is 1 || newAcked = vtq.Value is true || vtq.Value is 1 ||
(vtq.Value is int ackedIntVal && ackedIntVal != 0); (vtq.Value is int ackedIntVal && ackedIntVal != 0);
pendingAckedEvents.Add((ackedAlarmInfo, newAcked)); if (ackedAlarmInfo.LastAcked.HasValue && newAcked == ackedAlarmInfo.LastAcked.Value)
ackedAlarmInfo = null; // handled ackedAlarmInfo = null; // No transition → skip
else
pendingAckedEvents.Add((ackedAlarmInfo, newAcked));
} }
} }
@@ -1818,6 +1825,12 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa
// Apply Acked state changes // Apply Acked state changes
foreach (var (info, acked) in pendingAckedEvents) foreach (var (info, acked) in pendingAckedEvents)
{ {
// Double-check dedup under lock
if (info.LastAcked.HasValue && acked == info.LastAcked.Value)
continue;
info.LastAcked = acked;
var condition = info.ConditionNode; var condition = info.ConditionNode;
if (condition == null) continue; if (condition == null) continue;

View File

@@ -142,6 +142,12 @@ public sealed class FakeOpcUaClientService : IOpcUaClientService
return Task.CompletedTask; return Task.CompletedTask;
} }
public Task<StatusCode> AcknowledgeAlarmAsync(string conditionNodeId, byte[] eventId, string comment,
CancellationToken ct = default)
{
return Task.FromResult(new StatusCode(StatusCodes.Good));
}
public Task<IReadOnlyList<DataValue>> HistoryReadRawAsync( public Task<IReadOnlyList<DataValue>> HistoryReadRawAsync(
NodeId nodeId, DateTime startTime, DateTime endTime, int maxValues = 1000, CancellationToken ct = default) NodeId nodeId, DateTime startTime, DateTime endTime, int maxValues = 1000, CancellationToken ct = default)
{ {

View File

@@ -128,6 +128,12 @@ internal sealed class FakeSessionAdapter : ISessionAdapter
return Task.FromResult<ISubscriptionAdapter>(sub); return Task.FromResult<ISubscriptionAdapter>(sub);
} }
public Task<IList<object>?> CallMethodAsync(NodeId objectId, NodeId methodId, object[] inputArguments,
CancellationToken ct = default)
{
return Task.FromResult<IList<object>?>(null);
}
public Task CloseAsync(CancellationToken ct) public Task CloseAsync(CancellationToken ct)
{ {
Closed = true; Closed = true;

View File

@@ -14,6 +14,7 @@ public sealed class FakeOpcUaClientService : IOpcUaClientService
public ConnectionInfo? ConnectResult { get; set; } public ConnectionInfo? ConnectResult { get; set; }
public Exception? ConnectException { get; set; } public Exception? ConnectException { get; set; }
public IReadOnlyList<BrowseResult> BrowseResults { get; set; } = []; public IReadOnlyList<BrowseResult> BrowseResults { get; set; } = [];
public Dictionary<string, IReadOnlyList<BrowseResult>> BrowseResultsByParent { get; set; } = new();
public Exception? BrowseException { get; set; } public Exception? BrowseException { get; set; }
public DataValue ReadResult { get; set; } = public DataValue ReadResult { get; set; } =
@@ -100,6 +101,10 @@ public sealed class FakeOpcUaClientService : IOpcUaClientService
BrowseCallCount++; BrowseCallCount++;
LastBrowseParentNodeId = parentNodeId; LastBrowseParentNodeId = parentNodeId;
if (BrowseException != null) throw BrowseException; if (BrowseException != null) throw BrowseException;
if (parentNodeId != null && BrowseResultsByParent.TryGetValue(parentNodeId.ToString(), out var perParent))
return Task.FromResult(perParent);
return Task.FromResult(BrowseResults); return Task.FromResult(BrowseResults);
} }
@@ -136,6 +141,18 @@ public sealed class FakeOpcUaClientService : IOpcUaClientService
return Task.CompletedTask; return Task.CompletedTask;
} }
public StatusCode AcknowledgeResult { get; set; } = StatusCodes.Good;
public Exception? AcknowledgeException { get; set; }
public int AcknowledgeCallCount { get; private set; }
public Task<StatusCode> AcknowledgeAlarmAsync(string conditionNodeId, byte[] eventId, string comment,
CancellationToken ct = default)
{
AcknowledgeCallCount++;
if (AcknowledgeException != null) throw AcknowledgeException;
return Task.FromResult(AcknowledgeResult);
}
public Task<IReadOnlyList<DataValue>> HistoryReadRawAsync(NodeId nodeId, DateTime startTime, DateTime endTime, public Task<IReadOnlyList<DataValue>> HistoryReadRawAsync(NodeId nodeId, DateTime startTime, DateTime endTime,
int maxValues = 1000, CancellationToken ct = default) int maxValues = 1000, CancellationToken ct = default)
{ {

View File

@@ -0,0 +1,23 @@
using ZB.MOM.WW.LmxOpcUa.Client.UI.Services;
namespace ZB.MOM.WW.LmxOpcUa.Client.UI.Tests.Fakes;
public sealed class FakeSettingsService : ISettingsService
{
public UserSettings Settings { get; set; } = new();
public int LoadCallCount { get; private set; }
public int SaveCallCount { get; private set; }
public UserSettings? LastSaved { get; private set; }
public UserSettings Load()
{
LoadCallCount++;
return Settings;
}
public void Save(UserSettings settings)
{
SaveCallCount++;
LastSaved = settings;
}
}

View File

@@ -12,6 +12,7 @@ namespace ZB.MOM.WW.LmxOpcUa.Client.UI.Tests;
public class MainWindowViewModelTests public class MainWindowViewModelTests
{ {
private readonly FakeOpcUaClientService _service; private readonly FakeOpcUaClientService _service;
private readonly FakeSettingsService _settingsService;
private readonly MainWindowViewModel _vm; private readonly MainWindowViewModel _vm;
public MainWindowViewModelTests() public MainWindowViewModelTests()
@@ -32,9 +33,10 @@ public class MainWindowViewModelTests
RedundancyResult = new RedundancyInfo("None", 200, ["urn:test"], "urn:test") RedundancyResult = new RedundancyInfo("None", 200, ["urn:test"], "urn:test")
}; };
_settingsService = new FakeSettingsService();
var factory = new FakeOpcUaClientServiceFactory(_service); var factory = new FakeOpcUaClientServiceFactory(_service);
var dispatcher = new SynchronousUiDispatcher(); var dispatcher = new SynchronousUiDispatcher();
_vm = new MainWindowViewModel(factory, dispatcher); _vm = new MainWindowViewModel(factory, dispatcher, _settingsService);
} }
[Fact] [Fact]
@@ -120,10 +122,12 @@ public class MainWindowViewModelTests
} }
[Fact] [Fact]
public void ConnectionStateChangedEvent_UpdatesState() public async Task ConnectionStateChangedEvent_UpdatesState()
{ {
await _vm.ConnectCommand.ExecuteAsync(null);
_service.RaiseConnectionStateChanged( _service.RaiseConnectionStateChanged(
new ConnectionStateChangedEventArgs(ConnectionState.Disconnected, ConnectionState.Reconnecting, new ConnectionStateChangedEventArgs(ConnectionState.Connected, ConnectionState.Reconnecting,
"opc.tcp://localhost:4840")); "opc.tcp://localhost:4840"));
_vm.ConnectionState.ShouldBe(ConnectionState.Reconnecting); _vm.ConnectionState.ShouldBe(ConnectionState.Reconnecting);
@@ -177,17 +181,18 @@ public class MainWindowViewModelTests
} }
[Fact] [Fact]
public void PropertyChanged_FiredForConnectionState() public async Task PropertyChanged_FiredForConnectionState()
{ {
await _vm.ConnectCommand.ExecuteAsync(null);
var changed = new List<string>(); var changed = new List<string>();
_vm.PropertyChanged += (_, e) => changed.Add(e.PropertyName!); _vm.PropertyChanged += (_, e) => changed.Add(e.PropertyName!);
_service.RaiseConnectionStateChanged( _service.RaiseConnectionStateChanged(
new ConnectionStateChangedEventArgs(ConnectionState.Disconnected, ConnectionState.Connected, new ConnectionStateChangedEventArgs(ConnectionState.Connected, ConnectionState.Reconnecting,
"opc.tcp://localhost:4840")); "opc.tcp://localhost:4840"));
changed.ShouldContain(nameof(MainWindowViewModel.ConnectionState)); changed.ShouldContain(nameof(MainWindowViewModel.ConnectionState));
changed.ShouldContain(nameof(MainWindowViewModel.IsConnected));
} }
[Fact] [Fact]
@@ -258,13 +263,15 @@ public class MainWindowViewModelTests
{ {
await _vm.ConnectCommand.ExecuteAsync(null); await _vm.ConnectCommand.ExecuteAsync(null);
var node1 = _vm.BrowseTree.RootNodes[0]; // Use a Variable node so recursive subscribe subscribes it directly
_vm.SelectedTreeNodes.Add(node1); var varNode = new TreeNodeViewModel(
"ns=2;s=TestVar", "TestVar", "Variable", false, _service, new SynchronousUiDispatcher());
_vm.SelectedTreeNodes.Add(varNode);
await _vm.SubscribeSelectedNodesCommand.ExecuteAsync(null); await _vm.SubscribeSelectedNodesCommand.ExecuteAsync(null);
_vm.Subscriptions.ActiveSubscriptions.Count.ShouldBe(1); _vm.Subscriptions!.ActiveSubscriptions.Count.ShouldBe(1);
_vm.Subscriptions.ActiveSubscriptions[0].NodeId.ShouldBe(node1.NodeId); _vm.Subscriptions.ActiveSubscriptions[0].NodeId.ShouldBe("ns=2;s=TestVar");
_vm.SelectedTabIndex.ShouldBe(1); _vm.SelectedTabIndex.ShouldBe(1);
} }
@@ -327,4 +334,107 @@ public class MainWindowViewModelTests
_vm.IsHistoryEnabledForSelection.ShouldBeFalse(); _vm.IsHistoryEnabledForSelection.ShouldBeFalse();
} }
[Fact]
public void Constructor_LoadsSettingsFromService()
{
_settingsService.LoadCallCount.ShouldBe(1);
}
[Fact]
public void Constructor_AppliesSavedSettings()
{
var saved = new UserSettings
{
EndpointUrl = "opc.tcp://saved:5555",
Username = "savedUser",
Password = "savedPass",
SecurityMode = SecurityMode.Sign,
FailoverUrls = "opc.tcp://backup:5555",
SessionTimeoutSeconds = 120,
AutoAcceptCertificates = false,
CertificateStorePath = "/custom/path"
};
var settingsService = new FakeSettingsService { Settings = saved };
var service = new FakeOpcUaClientService
{
ConnectResult = _service.ConnectResult,
BrowseResults = _service.BrowseResults,
RedundancyResult = _service.RedundancyResult
};
var factory = new FakeOpcUaClientServiceFactory(service);
var vm = new MainWindowViewModel(factory, new SynchronousUiDispatcher(), settingsService);
vm.EndpointUrl.ShouldBe("opc.tcp://saved:5555");
vm.Username.ShouldBe("savedUser");
vm.Password.ShouldBe("savedPass");
vm.SelectedSecurityMode.ShouldBe(SecurityMode.Sign);
vm.FailoverUrls.ShouldBe("opc.tcp://backup:5555");
vm.SessionTimeoutSeconds.ShouldBe(120);
vm.AutoAcceptCertificates.ShouldBeFalse();
vm.CertificateStorePath.ShouldBe("/custom/path");
}
[Fact]
public async Task ConnectCommand_SavesSettingsOnSuccess()
{
_vm.EndpointUrl = "opc.tcp://myserver:4840";
_vm.Username = "admin";
await _vm.ConnectCommand.ExecuteAsync(null);
_settingsService.SaveCallCount.ShouldBe(1);
_settingsService.LastSaved.ShouldNotBeNull();
_settingsService.LastSaved!.EndpointUrl.ShouldBe("opc.tcp://myserver:4840");
_settingsService.LastSaved.Username.ShouldBe("admin");
}
[Fact]
public async Task ConnectCommand_DoesNotSaveOnFailure()
{
_service.ConnectException = new Exception("Connection refused");
await _vm.ConnectCommand.ExecuteAsync(null);
_settingsService.SaveCallCount.ShouldBe(0);
}
[Fact]
public async Task ConnectCommand_SavesSubscribedNodes()
{
await _vm.ConnectCommand.ExecuteAsync(null);
// Add a subscription
_vm.Subscriptions.IsConnected = true;
await _vm.Subscriptions.AddSubscriptionForNodeAsync("ns=2;s=TestSub");
// Disconnect saves settings including subscriptions
await _vm.DisconnectCommand.ExecuteAsync(null);
_settingsService.LastSaved.ShouldNotBeNull();
_settingsService.LastSaved!.SubscribedNodes.ShouldContain("ns=2;s=TestSub");
}
[Fact]
public async Task ConnectCommand_RestoresSavedSubscriptions()
{
_settingsService.Settings.SubscribedNodes = ["ns=2;s=Restored1", "ns=2;s=Restored2"];
var service = new FakeOpcUaClientService
{
ConnectResult = _service.ConnectResult,
BrowseResults = _service.BrowseResults,
RedundancyResult = _service.RedundancyResult
};
var factory = new FakeOpcUaClientServiceFactory(service);
var vm = new MainWindowViewModel(factory, new SynchronousUiDispatcher(), _settingsService);
await vm.ConnectCommand.ExecuteAsync(null);
vm.Subscriptions.ActiveSubscriptions.Count.ShouldBe(2);
vm.Subscriptions.ActiveSubscriptions[0].NodeId.ShouldBe("ns=2;s=Restored1");
vm.Subscriptions.ActiveSubscriptions[1].NodeId.ShouldBe("ns=2;s=Restored2");
vm.SubscriptionCount.ShouldBe(2);
}
} }

View File

@@ -0,0 +1,170 @@
using Avalonia;
using Avalonia.Controls;
using Avalonia.Headless;
using Avalonia.Media;
using Avalonia.Threading;
using Shouldly;
using Xunit;
using ZB.MOM.WW.LmxOpcUa.Client.UI.Controls;
namespace ZB.MOM.WW.LmxOpcUa.Client.UI.Tests.Screenshots;
public class DateTimeRangePickerScreenshot
{
private static readonly object Lock = new();
private static bool _initialized;
private static void EnsureInitialized()
{
lock (Lock)
{
if (_initialized) return;
_initialized = true;
AppBuilder.Configure<ScreenshotTestApp>()
.UseSkia()
.UseHeadless(new AvaloniaHeadlessPlatformOptions
{
UseHeadlessDrawing = false
})
.SetupWithoutStarting();
}
}
[Fact]
public void TextBoxes_ShowValues_WhenSetBeforeLoad()
{
EnsureInitialized();
Dispatcher.UIThread.Invoke(() =>
{
var picker = new DateTimeRangePicker
{
StartDateTime = new DateTimeOffset(2026, 3, 31, 8, 0, 0, TimeSpan.Zero),
EndDateTime = new DateTimeOffset(2026, 3, 31, 14, 0, 0, TimeSpan.Zero)
};
var window = new Window
{
Content = picker,
Width = 700, Height = 70
};
window.Show();
Dispatcher.UIThread.RunJobs();
var startInput = picker.FindControl<TextBox>("StartInput");
var endInput = picker.FindControl<TextBox>("EndInput");
startInput.ShouldNotBeNull();
endInput.ShouldNotBeNull();
startInput!.Text.ShouldBe("2026-03-31 08:00:00");
endInput!.Text.ShouldBe("2026-03-31 14:00:00");
window.Close();
});
}
[Fact]
public void TextBoxes_ShowValues_WhenSetAfterLoad()
{
EnsureInitialized();
Dispatcher.UIThread.Invoke(() =>
{
var picker = new DateTimeRangePicker();
var window = new Window
{
Content = picker,
Width = 700, Height = 70
};
window.Show();
Dispatcher.UIThread.RunJobs();
// Set values after the control is loaded
picker.StartDateTime = new DateTimeOffset(2026, 1, 15, 10, 30, 0, TimeSpan.Zero);
picker.EndDateTime = new DateTimeOffset(2026, 1, 15, 18, 45, 0, TimeSpan.Zero);
Dispatcher.UIThread.RunJobs();
var startInput = picker.FindControl<TextBox>("StartInput");
var endInput = picker.FindControl<TextBox>("EndInput");
startInput!.Text.ShouldBe("2026-01-15 10:30:00");
endInput!.Text.ShouldBe("2026-01-15 18:45:00");
window.Close();
});
}
[Fact]
public void PresetButtons_SetCorrectRange()
{
EnsureInitialized();
Dispatcher.UIThread.Invoke(() =>
{
var picker = new DateTimeRangePicker();
var window = new Window
{
Content = picker,
Width = 700, Height = 70
};
window.Show();
Dispatcher.UIThread.RunJobs();
// Click the 1h preset
var lastHourBtn = picker.FindControl<Button>("LastHourBtn");
lastHourBtn.ShouldNotBeNull();
lastHourBtn!.RaiseEvent(new Avalonia.Interactivity.RoutedEventArgs(Button.ClickEvent));
Dispatcher.UIThread.RunJobs();
picker.StartDateTime.ShouldNotBeNull();
picker.EndDateTime.ShouldNotBeNull();
var span = picker.EndDateTime!.Value - picker.StartDateTime!.Value;
span.TotalMinutes.ShouldBe(60, 1); // ~1 hour
// Verify text boxes are populated
var startInput = picker.FindControl<TextBox>("StartInput");
var endInput = picker.FindControl<TextBox>("EndInput");
startInput!.Text.ShouldNotBeNullOrEmpty();
endInput!.Text.ShouldNotBeNullOrEmpty();
window.Close();
});
}
[Fact]
public void Capture_DateTimeRangePicker_Screenshot()
{
EnsureInitialized();
Dispatcher.UIThread.Invoke(() =>
{
var picker = new DateTimeRangePicker
{
StartDateTime = new DateTimeOffset(2026, 3, 31, 8, 0, 0, TimeSpan.Zero),
EndDateTime = new DateTimeOffset(2026, 3, 31, 14, 0, 0, TimeSpan.Zero)
};
var window = new Window
{
Content = new Border { Padding = new Thickness(16), Background = Brushes.White, Child = picker },
Width = 700, Height = 70, Background = Brushes.White
};
window.Show();
Dispatcher.UIThread.RunJobs();
var bitmap = window.CaptureRenderedFrame();
Assert.NotNull(bitmap);
var outputDir = Path.Combine(
Path.GetDirectoryName(typeof(DateTimeRangePickerScreenshot).Assembly.Location)!, "screenshots");
Directory.CreateDirectory(outputDir);
bitmap!.Save(Path.Combine(outputDir, "datetimerangepicker.png"));
window.Close();
});
}
}

View File

@@ -0,0 +1,199 @@
using Avalonia;
using Avalonia.Controls;
using Avalonia.Headless;
using Avalonia.Media;
using Avalonia.Threading;
using Xunit;
using ZB.MOM.WW.LmxOpcUa.Client.UI.Controls;
using ZB.MOM.WW.LmxOpcUa.Client.UI.ViewModels;
using ZB.MOM.WW.LmxOpcUa.Client.UI.Views;
using ZB.MOM.WW.LmxOpcUa.Client.UI.Services;
using ZB.MOM.WW.LmxOpcUa.Client.UI.Tests.Fakes;
using ZB.MOM.WW.LmxOpcUa.Client.Shared.Models;
using BrowseResult = ZB.MOM.WW.LmxOpcUa.Client.Shared.Models.BrowseResult;
namespace ZB.MOM.WW.LmxOpcUa.Client.UI.Tests.Screenshots;
public class DocumentationScreenshots
{
private static readonly object Lock = new();
private static bool _initialized;
private static void EnsureInitialized()
{
lock (Lock)
{
if (_initialized) return;
_initialized = true;
AppBuilder.Configure<ScreenshotTestApp>()
.UseSkia()
.UseHeadless(new AvaloniaHeadlessPlatformOptions { UseHeadlessDrawing = false })
.SetupWithoutStarting();
}
}
private static string OutputDir
{
get
{
var dir = Path.Combine(
Path.GetDirectoryName(typeof(DocumentationScreenshots).Assembly.Location)!, "screenshots", "docs");
Directory.CreateDirectory(dir);
return dir;
}
}
[Fact]
public void Capture_ConnectionPanel()
{
EnsureInitialized();
Dispatcher.UIThread.Invoke(() =>
{
var vm = CreateConnectedViewModel();
var panel = new StackPanel
{
Spacing = 4,
Children =
{
new StackPanel
{
Orientation = Avalonia.Layout.Orientation.Horizontal, Spacing = 8,
Children =
{
new StackPanel { Spacing = 2, Children = { new TextBlock { Text = "Endpoint URL", FontSize = 11, Foreground = Brushes.Gray }, new TextBox { Text = "opc.tcp://localhost:4840/LmxOpcUa", Width = 400 } } },
new Button { Content = "Connect", Padding = new Thickness(16, 6) },
new Button { Content = "Disconnect", Padding = new Thickness(16, 6) }
}
}
}
};
var window = new Window
{
Content = new Border { Padding = new Thickness(12), Background = new SolidColorBrush(Color.Parse("#F0F0F0")), Child = panel },
Width = 700, Height = 60, Background = Brushes.White
};
window.Show();
var bitmap = window.CaptureRenderedFrame();
bitmap?.Save(Path.Combine(OutputDir, "connection-panel.png"));
window.Close();
});
}
[Fact]
public void Capture_SubscriptionsTab()
{
EnsureInitialized();
Dispatcher.UIThread.Invoke(() =>
{
var service = new FakeOpcUaClientService();
var dispatcher = new SynchronousUiDispatcher();
var vm = new SubscriptionsViewModel(service, dispatcher) { IsConnected = true };
vm.ActiveSubscriptions.Add(new SubscriptionItemViewModel("ns=3;s=DEV.ScanState", 1000) { Value = "True", Status = "0x00000000 (Good)", Timestamp = "2026-03-31T14:30:00Z" });
vm.ActiveSubscriptions.Add(new SubscriptionItemViewModel("ns=3;s=TestMachine_001.TestHistoryValue", 1000) { Value = "3", Status = "0x00000000 (Good)", Timestamp = "2026-03-31T14:30:01Z" });
vm.ActiveSubscriptions.Add(new SubscriptionItemViewModel("ns=3;s=DevPlatform.CPULoad", 1000) { Value = "42.5", Status = "0x00000000 (Good)", Timestamp = "2026-03-31T14:30:02Z" });
var view = new SubscriptionsView { DataContext = vm };
var window = new Window { Content = view, Width = 750, Height = 250, Background = Brushes.White };
window.Show();
Dispatcher.UIThread.RunJobs();
var bitmap = window.CaptureRenderedFrame();
bitmap?.Save(Path.Combine(OutputDir, "subscriptions-tab.png"));
window.Close();
});
}
[Fact]
public void Capture_AlarmsTab()
{
EnsureInitialized();
Dispatcher.UIThread.Invoke(() =>
{
var service = new FakeOpcUaClientService();
var dispatcher = new SynchronousUiDispatcher();
var vm = new AlarmsViewModel(service, dispatcher)
{
IsConnected = true, IsSubscribed = true, MonitoredNodeIdText = "ns=3;s=TestArea"
};
vm.AlarmEvents.Add(new AlarmEventViewModel("TestMachine_001.TestAlarm002", "TestAlarm002", 500, "Test alarm #2", true, true, true, new DateTime(2026, 3, 26, 17, 38, 22)));
vm.AlarmEvents.Add(new AlarmEventViewModel("TestMachine_001.TestAlarm003", "TestAlarm003", 500, "Test alarm #3", true, true, false, new DateTime(2026, 3, 26, 17, 38, 22)));
vm.AlarmEvents.Add(new AlarmEventViewModel("TestMachine_001.TestAlarm001", "TestAlarm001", 500, "Alarm cleared", true, false, false, DateTime.MinValue));
var view = new AlarmsView { DataContext = vm };
var window = new Window { Content = view, Width = 900, Height = 250, Background = Brushes.White };
window.Show();
Dispatcher.UIThread.RunJobs();
var bitmap = window.CaptureRenderedFrame();
bitmap?.Save(Path.Combine(OutputDir, "alarms-tab.png"));
window.Close();
});
}
[Fact]
public void Capture_HistoryTab()
{
EnsureInitialized();
Dispatcher.UIThread.Invoke(() =>
{
var service = new FakeOpcUaClientService();
var dispatcher = new SynchronousUiDispatcher();
var vm = new HistoryViewModel(service, dispatcher)
{
IsConnected = true,
SelectedNodeId = "ns=3;s=TestMachine_001.TestHistoryValue",
StartTime = new DateTimeOffset(2026, 3, 26, 0, 0, 0, TimeSpan.Zero),
EndTime = new DateTimeOffset(2026, 3, 26, 18, 0, 0, TimeSpan.Zero)
};
vm.Results.Add(new HistoryValueViewModel("0", "0x00000000 (Good)", "2026-03-26T04:02:59Z", "2026-03-26T04:02:59Z"));
vm.Results.Add(new HistoryValueViewModel("7", "0x00000000 (Good)", "2026-03-26T04:22:13Z", "2026-03-26T04:22:13Z"));
vm.Results.Add(new HistoryValueViewModel("4", "0x00000000 (Good)", "2026-03-26T04:22:16Z", "2026-03-26T04:22:16Z"));
vm.Results.Add(new HistoryValueViewModel("9", "0x00000000 (Good)", "2026-03-26T05:08:46Z", "2026-03-26T05:08:46Z"));
var view = new HistoryView { DataContext = vm };
var window = new Window { Content = view, Width = 800, Height = 350, Background = Brushes.White };
window.Show();
Dispatcher.UIThread.RunJobs();
var bitmap = window.CaptureRenderedFrame();
bitmap?.Save(Path.Combine(OutputDir, "history-tab.png"));
window.Close();
});
}
[Fact]
public void Capture_DateTimeRangePicker()
{
EnsureInitialized();
Dispatcher.UIThread.Invoke(() =>
{
var picker = new DateTimeRangePicker
{
StartDateTime = new DateTimeOffset(2026, 3, 26, 0, 0, 0, TimeSpan.Zero),
EndDateTime = new DateTimeOffset(2026, 3, 31, 18, 0, 0, TimeSpan.Zero)
};
var window = new Window
{
Content = new Border { Padding = new Thickness(12), Background = Brushes.White, Child = picker },
Width = 650, Height = 65, Background = Brushes.White
};
window.Show();
Dispatcher.UIThread.RunJobs();
var bitmap = window.CaptureRenderedFrame();
bitmap?.Save(Path.Combine(OutputDir, "datetimerangepicker.png"));
window.Close();
});
}
private static MainWindowViewModel CreateConnectedViewModel()
{
var service = new FakeOpcUaClientService
{
ConnectResult = new ConnectionInfo("opc.tcp://localhost:4840/LmxOpcUa", "LmxOpcUa", "None",
"http://opcfoundation.org/UA/SecurityPolicy#None", "session-1", "TestSession"),
BrowseResults = new[] { new BrowseResult("ns=3;s=ZB", "ZB", "Object", true) },
RedundancyResult = new RedundancyInfo("Warm", 200, new[] { "urn:localhost:LmxOpcUa:instance1" }, "urn:localhost:LmxOpcUa:instance1")
};
var factory = new FakeOpcUaClientServiceFactory(service);
var settings = new FakeSettingsService();
return new MainWindowViewModel(factory, new SynchronousUiDispatcher(), settings);
}
}

View File

@@ -0,0 +1,20 @@
using Avalonia;
using Avalonia.Headless;
using Avalonia.Themes.Fluent;
namespace ZB.MOM.WW.LmxOpcUa.Client.UI.Tests.Screenshots;
public class ScreenshotTestApp : Application
{
public override void Initialize()
{
Styles.Add(new FluentTheme());
}
public static AppBuilder BuildAvaloniaApp() => AppBuilder.Configure<ScreenshotTestApp>()
.UseSkia()
.UseHeadless(new AvaloniaHeadlessPlatformOptions
{
UseHeadlessDrawing = false
});
}

View File

@@ -5,6 +5,7 @@ using ZB.MOM.WW.LmxOpcUa.Client.Shared.Models;
using ZB.MOM.WW.LmxOpcUa.Client.UI.Services; using ZB.MOM.WW.LmxOpcUa.Client.UI.Services;
using ZB.MOM.WW.LmxOpcUa.Client.UI.Tests.Fakes; using ZB.MOM.WW.LmxOpcUa.Client.UI.Tests.Fakes;
using ZB.MOM.WW.LmxOpcUa.Client.UI.ViewModels; using ZB.MOM.WW.LmxOpcUa.Client.UI.ViewModels;
using BrowseResult = ZB.MOM.WW.LmxOpcUa.Client.Shared.Models.BrowseResult;
namespace ZB.MOM.WW.LmxOpcUa.Client.UI.Tests; namespace ZB.MOM.WW.LmxOpcUa.Client.UI.Tests;
@@ -168,4 +169,131 @@ public class SubscriptionsViewModelTests
_vm.ActiveSubscriptions.ShouldBeEmpty(); _vm.ActiveSubscriptions.ShouldBeEmpty();
_service.SubscribeCallCount.ShouldBe(0); _service.SubscribeCallCount.ShouldBe(0);
} }
[Fact]
public async Task GetSubscribedNodeIds_ReturnsActiveNodeIds()
{
_vm.IsConnected = true;
await _vm.AddSubscriptionForNodeAsync("ns=2;s=Node1");
await _vm.AddSubscriptionForNodeAsync("ns=2;s=Node2");
var ids = _vm.GetSubscribedNodeIds();
ids.Count.ShouldBe(2);
ids.ShouldContain("ns=2;s=Node1");
ids.ShouldContain("ns=2;s=Node2");
}
[Fact]
public async Task RestoreSubscriptionsAsync_SubscribesAllNodes()
{
_vm.IsConnected = true;
await _vm.RestoreSubscriptionsAsync(["ns=2;s=A", "ns=2;s=B"]);
_vm.ActiveSubscriptions.Count.ShouldBe(2);
_service.SubscribeCallCount.ShouldBe(2);
}
[Fact]
public async Task ValidateAndWriteAsync_SuccessReturnsTrue()
{
_vm.IsConnected = true;
_service.ReadResult = new DataValue(new Variant(42), Opc.Ua.StatusCodes.Good, DateTime.UtcNow);
_service.WriteResult = Opc.Ua.StatusCodes.Good;
var (success, message) = await _vm.ValidateAndWriteAsync("ns=2;s=Node1", "99");
success.ShouldBeTrue();
message.ShouldContain("Good");
_service.WriteCallCount.ShouldBe(1);
}
[Fact]
public async Task ValidateAndWriteAsync_ParseFailureReturnsFalse()
{
_vm.IsConnected = true;
_service.ReadResult = new DataValue(new Variant(42), Opc.Ua.StatusCodes.Good, DateTime.UtcNow);
var (success, message) = await _vm.ValidateAndWriteAsync("ns=2;s=Node1", "not-a-number");
success.ShouldBeFalse();
message.ShouldContain("Cannot parse");
message.ShouldContain("Int32");
_service.WriteCallCount.ShouldBe(0);
}
[Fact]
public async Task ValidateAndWriteAsync_WriteFailureReturnsFalse()
{
_vm.IsConnected = true;
_service.ReadResult = new DataValue(new Variant("hello"), Opc.Ua.StatusCodes.Good, DateTime.UtcNow);
_service.WriteException = new Exception("Access denied");
var (success, message) = await _vm.ValidateAndWriteAsync("ns=2;s=Node1", "world");
success.ShouldBeFalse();
message.ShouldContain("Access denied");
}
[Fact]
public async Task ValidateAndWriteAsync_BadStatusReturnsFalse()
{
_vm.IsConnected = true;
_service.ReadResult = new DataValue(new Variant("hello"), Opc.Ua.StatusCodes.Good, DateTime.UtcNow);
_service.WriteResult = Opc.Ua.StatusCodes.BadNotWritable;
var (success, message) = await _vm.ValidateAndWriteAsync("ns=2;s=Node1", "world");
success.ShouldBeFalse();
message.ShouldContain("Write failed");
}
[Fact]
public async Task AddSubscriptionRecursiveAsync_SubscribesVariableDirectly()
{
_vm.IsConnected = true;
await _vm.AddSubscriptionRecursiveAsync("ns=2;s=Var1", "Variable");
_vm.ActiveSubscriptions.Count.ShouldBe(1);
_vm.ActiveSubscriptions[0].NodeId.ShouldBe("ns=2;s=Var1");
}
[Fact]
public async Task AddSubscriptionRecursiveAsync_BrowsesObjectAndSubscribesVariableChildren()
{
_vm.IsConnected = true;
_service.BrowseResultsByParent["ns=2;s=Folder"] =
[
new BrowseResult("ns=2;s=Child1", "Child1", "Variable", false),
new BrowseResult("ns=2;s=Child2", "Child2", "Variable", false)
];
await _vm.AddSubscriptionRecursiveAsync("ns=2;s=Folder", "Object");
_vm.ActiveSubscriptions.Count.ShouldBe(2);
_service.SubscribeCallCount.ShouldBe(2);
}
[Fact]
public async Task AddSubscriptionRecursiveAsync_RecursesNestedObjects()
{
_vm.IsConnected = true;
_service.BrowseResultsByParent["ns=2;s=Root"] =
[
new BrowseResult("ns=2;s=SubFolder", "SubFolder", "Object", true),
new BrowseResult("ns=2;s=RootVar", "RootVar", "Variable", false)
];
_service.BrowseResultsByParent["ns=2;s=SubFolder"] =
[
new BrowseResult("ns=2;s=DeepVar", "DeepVar", "Variable", false)
];
await _vm.AddSubscriptionRecursiveAsync("ns=2;s=Root", "Object");
_vm.ActiveSubscriptions.Count.ShouldBe(2);
_vm.ActiveSubscriptions.ShouldContain(s => s.NodeId == "ns=2;s=RootVar");
_vm.ActiveSubscriptions.ShouldContain(s => s.NodeId == "ns=2;s=DeepVar");
}
} }

View File

@@ -10,6 +10,8 @@
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Avalonia.Headless" Version="11.2.7"/>
<PackageReference Include="Avalonia.Themes.Fluent" Version="11.2.7"/>
<PackageReference Include="xunit.v3" Version="1.1.0"/> <PackageReference Include="xunit.v3" Version="1.1.0"/>
<PackageReference Include="Shouldly" Version="4.3.0"/> <PackageReference Include="Shouldly" Version="4.3.0"/>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0"/> <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0"/>