diff --git a/README.md b/README.md index d09d317..7b2de45 100644 --- a/README.md +++ b/README.md @@ -142,6 +142,7 @@ gr/ Galaxy repository docs, SQL queries, schema | [Status Dashboard](docs/StatusDashboard.md) | HTTP server, health checks, metrics reporting | | [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 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 | | [Redundancy](docs/Redundancy.md) | Non-transparent warm/hot redundancy, ServiceLevel, paired deployment | diff --git a/docs/Client.UI.md b/docs/Client.UI.md new file mode 100644 index 0000000..2cb9c91 --- /dev/null +++ b/docs/Client.UI.md @@ -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 (1–3600, 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 (0–332) | Light blue | +| Medium (333–665) | Light yellow | +| High (666–899) | Light red | +| Critical (900–1000) | 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 diff --git a/docs/images/alarms-tab.png b/docs/images/alarms-tab.png new file mode 100644 index 0000000..3584663 Binary files /dev/null and b/docs/images/alarms-tab.png differ diff --git a/docs/images/connection-panel.png b/docs/images/connection-panel.png new file mode 100644 index 0000000..0252d3d Binary files /dev/null and b/docs/images/connection-panel.png differ diff --git a/docs/images/datetimerangepicker.png b/docs/images/datetimerangepicker.png new file mode 100644 index 0000000..7f0f435 Binary files /dev/null and b/docs/images/datetimerangepicker.png differ diff --git a/docs/images/history-tab.png b/docs/images/history-tab.png new file mode 100644 index 0000000..54ef515 Binary files /dev/null and b/docs/images/history-tab.png differ diff --git a/docs/images/subscriptions-tab.png b/docs/images/subscriptions-tab.png new file mode 100644 index 0000000..8ba3d6d Binary files /dev/null and b/docs/images/subscriptions-tab.png differ diff --git a/src/ZB.MOM.WW.LmxOpcUa.Client.Shared/Adapters/DefaultSessionAdapter.cs b/src/ZB.MOM.WW.LmxOpcUa.Client.Shared/Adapters/DefaultSessionAdapter.cs index 41a0e38..f1a5086 100644 --- a/src/ZB.MOM.WW.LmxOpcUa.Client.Shared/Adapters/DefaultSessionAdapter.cs +++ b/src/ZB.MOM.WW.LmxOpcUa.Client.Shared/Adapters/DefaultSessionAdapter.cs @@ -220,4 +220,27 @@ internal sealed class DefaultSessionAdapter : ISessionAdapter _session.Dispose(); } + + public async Task?> 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(); + } } \ No newline at end of file diff --git a/src/ZB.MOM.WW.LmxOpcUa.Client.Shared/Adapters/ISessionAdapter.cs b/src/ZB.MOM.WW.LmxOpcUa.Client.Shared/Adapters/ISessionAdapter.cs index c2e6cc1..01ba0b6 100644 --- a/src/ZB.MOM.WW.LmxOpcUa.Client.Shared/Adapters/ISessionAdapter.cs +++ b/src/ZB.MOM.WW.LmxOpcUa.Client.Shared/Adapters/ISessionAdapter.cs @@ -59,5 +59,7 @@ internal interface ISessionAdapter : IDisposable /// Task CreateSubscriptionAsync(int publishingIntervalMs, CancellationToken ct = default); + Task?> CallMethodAsync(NodeId objectId, NodeId methodId, object[] inputArguments, CancellationToken ct = default); + Task CloseAsync(CancellationToken ct = default); } \ No newline at end of file diff --git a/src/ZB.MOM.WW.LmxOpcUa.Client.Shared/IOpcUaClientService.cs b/src/ZB.MOM.WW.LmxOpcUa.Client.Shared/IOpcUaClientService.cs index 6e7753f..60224a9 100644 --- a/src/ZB.MOM.WW.LmxOpcUa.Client.Shared/IOpcUaClientService.cs +++ b/src/ZB.MOM.WW.LmxOpcUa.Client.Shared/IOpcUaClientService.cs @@ -25,6 +25,7 @@ public interface IOpcUaClientService : IDisposable Task SubscribeAlarmsAsync(NodeId? sourceNodeId = null, int intervalMs = 1000, CancellationToken ct = default); Task UnsubscribeAlarmsAsync(CancellationToken ct = default); Task RequestConditionRefreshAsync(CancellationToken ct = default); + Task AcknowledgeAlarmAsync(string conditionNodeId, byte[] eventId, string comment, CancellationToken ct = default); Task> HistoryReadRawAsync(NodeId nodeId, DateTime startTime, DateTime endTime, int maxValues = 1000, CancellationToken ct = default); diff --git a/src/ZB.MOM.WW.LmxOpcUa.Client.Shared/Models/AlarmEventArgs.cs b/src/ZB.MOM.WW.LmxOpcUa.Client.Shared/Models/AlarmEventArgs.cs index 307f3e1..1e5222c 100644 --- a/src/ZB.MOM.WW.LmxOpcUa.Client.Shared/Models/AlarmEventArgs.cs +++ b/src/ZB.MOM.WW.LmxOpcUa.Client.Shared/Models/AlarmEventArgs.cs @@ -13,7 +13,9 @@ public sealed class AlarmEventArgs : EventArgs bool retain, bool activeState, bool ackedState, - DateTime time) + DateTime time, + byte[]? eventId = null, + string? conditionNodeId = null) { SourceName = sourceName; ConditionName = conditionName; @@ -23,6 +25,8 @@ public sealed class AlarmEventArgs : EventArgs ActiveState = activeState; AckedState = ackedState; Time = time; + EventId = eventId; + ConditionNodeId = conditionNodeId; } /// The name of the source object that raised the alarm. @@ -48,4 +52,10 @@ public sealed class AlarmEventArgs : EventArgs /// The time the event occurred. public DateTime Time { get; } + + /// The EventId used for alarm acknowledgment. + public byte[]? EventId { get; } + + /// The NodeId of the condition instance (SourceNode), used for acknowledgment. + public string? ConditionNodeId { get; } } \ No newline at end of file diff --git a/src/ZB.MOM.WW.LmxOpcUa.Client.Shared/OpcUaClientService.cs b/src/ZB.MOM.WW.LmxOpcUa.Client.Shared/OpcUaClientService.cs index 6e88860..4a10f78 100644 --- a/src/ZB.MOM.WW.LmxOpcUa.Client.Shared/OpcUaClientService.cs +++ b/src/ZB.MOM.WW.LmxOpcUa.Client.Shared/OpcUaClientService.cs @@ -277,6 +277,28 @@ public sealed class OpcUaClientService : IOpcUaClientService Logger.Debug("Condition refresh requested"); } + public async Task 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> HistoryReadRawAsync( 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) 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 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 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 retain = fields.Count > 7 ? fields[7].Value as bool? ?? false : false; - var ackedState = fields.Count > 8 ? fields[8].Value as bool? ?? false : false; - var activeState = fields.Count > 9 ? fields[9].Value as bool? ?? false : false; + var retain = fields.Count > 7 && ParseBool(fields[7].Value); + var conditionNodeId = fields.Count > 12 ? fields[12].Value?.ToString() : null; + + // 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( - 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() @@ -542,6 +633,8 @@ public sealed class OpcUaClientService : IOpcUaClientService filter.AddSelectClause(ObjectTypeIds.AlarmConditionType, "EnabledState/Id"); // 11: SuppressedOrShelved filter.AddSelectClause(ObjectTypeIds.AlarmConditionType, "SuppressedOrShelved"); + // 12: SourceNode (ConditionId for acknowledgment) + filter.AddSelectClause(ObjectTypeIds.BaseEventType, BrowseNames.SourceNode); return filter; } diff --git a/src/ZB.MOM.WW.LmxOpcUa.Client.UI/Assets/app-icon.svg b/src/ZB.MOM.WW.LmxOpcUa.Client.UI/Assets/app-icon.svg new file mode 100644 index 0000000..890a857 --- /dev/null +++ b/src/ZB.MOM.WW.LmxOpcUa.Client.UI/Assets/app-icon.svg @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + + UA + diff --git a/src/ZB.MOM.WW.LmxOpcUa.Client.UI/Controls/DateTimePicker.axaml b/src/ZB.MOM.WW.LmxOpcUa.Client.UI/Controls/DateTimePicker.axaml new file mode 100644 index 0000000..324bd3d --- /dev/null +++ b/src/ZB.MOM.WW.LmxOpcUa.Client.UI/Controls/DateTimePicker.axaml @@ -0,0 +1,11 @@ + + + + + + diff --git a/src/ZB.MOM.WW.LmxOpcUa.Client.UI/Controls/DateTimePicker.axaml.cs b/src/ZB.MOM.WW.LmxOpcUa.Client.UI/Controls/DateTimePicker.axaml.cs new file mode 100644 index 0000000..dfedbeb --- /dev/null +++ b/src/ZB.MOM.WW.LmxOpcUa.Client.UI/Controls/DateTimePicker.axaml.cs @@ -0,0 +1,169 @@ +using System; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Interactivity; + +namespace ZB.MOM.WW.LmxOpcUa.Client.UI.Controls; + +/// +/// A combined date + time picker that exposes a single DateTimeOffset value. +/// Bridges between CalendarDatePicker (DateTime?) and TimePicker (TimeSpan?) +/// and the public SelectedDateTime (DateTimeOffset?) property. +/// +public partial class DateTimePicker : UserControl +{ + public static readonly StyledProperty SelectedDateTimeProperty = + AvaloniaProperty.Register( + nameof(SelectedDateTime), defaultValue: DateTimeOffset.Now); + + public static readonly StyledProperty MinDateTimeProperty = + AvaloniaProperty.Register( + nameof(MinDateTime)); + + public static readonly StyledProperty MaxDateTimeProperty = + AvaloniaProperty.Register( + nameof(MaxDateTime)); + + private bool _isUpdating; + + public DateTimePicker() + { + InitializeComponent(); + } + + /// The combined date and time value. + public DateTimeOffset? SelectedDateTime + { + get => GetValue(SelectedDateTimeProperty); + set => SetValue(SelectedDateTimeProperty, value); + } + + /// Optional minimum allowed date/time. + public DateTimeOffset? MinDateTime + { + get => GetValue(MinDateTimeProperty); + set => SetValue(MinDateTimeProperty, value); + } + + /// Optional maximum allowed date/time. + public DateTimeOffset? MaxDateTime + { + get => GetValue(MaxDateTimeProperty); + set => SetValue(MaxDateTimeProperty, value); + } + + protected override void OnLoaded(RoutedEventArgs e) + { + base.OnLoaded(e); + + var datePart = this.FindControl("DatePart"); + var timePart = this.FindControl("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("DatePart"); + var timePart = this.FindControl("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("DatePart"); + var timePart = this.FindControl("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("DatePart"); + if (datePart == null) return; + + datePart.DisplayDateStart = MinDateTime?.DateTime; + datePart.DisplayDateEnd = MaxDateTime?.DateTime; + } +} diff --git a/src/ZB.MOM.WW.LmxOpcUa.Client.UI/Controls/DateTimeRangePicker.axaml b/src/ZB.MOM.WW.LmxOpcUa.Client.UI/Controls/DateTimeRangePicker.axaml new file mode 100644 index 0000000..b0ca464 --- /dev/null +++ b/src/ZB.MOM.WW.LmxOpcUa.Client.UI/Controls/DateTimeRangePicker.axaml @@ -0,0 +1,27 @@ + + + + + + + + + + + + + +