From 188cbf7d24697dfe601f7d60075076476c7ef4a0 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Tue, 31 Mar 2026 20:46:45 -0400 Subject: [PATCH] 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) --- README.md | 1 + docs/Client.UI.md | 264 ++++++++++++++++++ docs/images/alarms-tab.png | Bin 0 -> 5692 bytes docs/images/connection-panel.png | Bin 0 -> 3496 bytes docs/images/datetimerangepicker.png | Bin 0 -> 3179 bytes docs/images/history-tab.png | Bin 0 -> 10523 bytes docs/images/subscriptions-tab.png | Bin 0 -> 5478 bytes .../Adapters/DefaultSessionAdapter.cs | 23 ++ .../Adapters/ISessionAdapter.cs | 2 + .../IOpcUaClientService.cs | 1 + .../Models/AlarmEventArgs.cs | 12 +- .../OpcUaClientService.cs | 103 ++++++- .../Assets/app-icon.svg | 18 ++ .../Controls/DateTimePicker.axaml | 11 + .../Controls/DateTimePicker.axaml.cs | 169 +++++++++++ .../Controls/DateTimeRangePicker.axaml | 27 ++ .../Controls/DateTimeRangePicker.axaml.cs | 187 +++++++++++++ .../Helpers/StatusCodeFormatter.cs | 36 +++ .../Helpers/ValueFormatter.cs | 33 +++ src/ZB.MOM.WW.LmxOpcUa.Client.UI/Program.cs | 2 +- .../Services/ISettingsService.cs | 10 + .../Services/JsonSettingsService.cs | 50 ++++ .../Services/UserSettings.cs | 20 ++ .../ViewModels/AlarmEventViewModel.cs | 13 +- .../ViewModels/AlarmsViewModel.cs | 111 +++++++- .../ViewModels/HistoryViewModel.cs | 19 +- .../ViewModels/MainWindowViewModel.cs | 188 ++++++++++--- .../ViewModels/ReadWriteViewModel.cs | 4 +- .../ViewModels/SubscriptionsViewModel.cs | 136 +++++++-- .../Views/AckAlarmWindow.axaml | 39 +++ .../Views/AckAlarmWindow.axaml.cs | 67 +++++ .../Views/AlarmsView.axaml | 25 +- .../Views/AlarmsView.axaml.cs | 70 ++++- .../Views/HistoryView.axaml | 45 ++- .../Views/MainWindow.axaml | 139 +++++---- .../Views/MainWindow.axaml.cs | 110 +++++++- .../Views/SubscriptionsView.axaml | 13 +- .../Views/SubscriptionsView.axaml.cs | 42 ++- .../Views/WriteValueWindow.axaml | 37 +++ .../Views/WriteValueWindow.axaml.cs | 77 +++++ .../ZB.MOM.WW.LmxOpcUa.Client.UI.csproj | 7 + .../Historian/HistorianDataSource.cs | 10 +- .../OpcUa/LmxNodeManager.cs | 19 +- .../Fakes/FakeOpcUaClientService.cs | 6 + .../Fakes/FakeSessionAdapter.cs | 6 + .../Fakes/FakeOpcUaClientService.cs | 17 ++ .../Fakes/FakeSettingsService.cs | 23 ++ .../MainWindowViewModelTests.cs | 130 ++++++++- .../DateTimeRangePickerScreenshot.cs | 170 +++++++++++ .../Screenshots/DocumentationScreenshots.cs | 199 +++++++++++++ .../Screenshots/ScreenshotTestApp.cs | 20 ++ .../SubscriptionsViewModelTests.cs | 128 +++++++++ .../ZB.MOM.WW.LmxOpcUa.Client.UI.Tests.csproj | 2 + 53 files changed, 2652 insertions(+), 189 deletions(-) create mode 100644 docs/Client.UI.md create mode 100644 docs/images/alarms-tab.png create mode 100644 docs/images/connection-panel.png create mode 100644 docs/images/datetimerangepicker.png create mode 100644 docs/images/history-tab.png create mode 100644 docs/images/subscriptions-tab.png create mode 100644 src/ZB.MOM.WW.LmxOpcUa.Client.UI/Assets/app-icon.svg create mode 100644 src/ZB.MOM.WW.LmxOpcUa.Client.UI/Controls/DateTimePicker.axaml create mode 100644 src/ZB.MOM.WW.LmxOpcUa.Client.UI/Controls/DateTimePicker.axaml.cs create mode 100644 src/ZB.MOM.WW.LmxOpcUa.Client.UI/Controls/DateTimeRangePicker.axaml create mode 100644 src/ZB.MOM.WW.LmxOpcUa.Client.UI/Controls/DateTimeRangePicker.axaml.cs create mode 100644 src/ZB.MOM.WW.LmxOpcUa.Client.UI/Helpers/StatusCodeFormatter.cs create mode 100644 src/ZB.MOM.WW.LmxOpcUa.Client.UI/Helpers/ValueFormatter.cs create mode 100644 src/ZB.MOM.WW.LmxOpcUa.Client.UI/Services/ISettingsService.cs create mode 100644 src/ZB.MOM.WW.LmxOpcUa.Client.UI/Services/JsonSettingsService.cs create mode 100644 src/ZB.MOM.WW.LmxOpcUa.Client.UI/Services/UserSettings.cs create mode 100644 src/ZB.MOM.WW.LmxOpcUa.Client.UI/Views/AckAlarmWindow.axaml create mode 100644 src/ZB.MOM.WW.LmxOpcUa.Client.UI/Views/AckAlarmWindow.axaml.cs create mode 100644 src/ZB.MOM.WW.LmxOpcUa.Client.UI/Views/WriteValueWindow.axaml create mode 100644 src/ZB.MOM.WW.LmxOpcUa.Client.UI/Views/WriteValueWindow.axaml.cs create mode 100644 tests/ZB.MOM.WW.LmxOpcUa.Client.UI.Tests/Fakes/FakeSettingsService.cs create mode 100644 tests/ZB.MOM.WW.LmxOpcUa.Client.UI.Tests/Screenshots/DateTimeRangePickerScreenshot.cs create mode 100644 tests/ZB.MOM.WW.LmxOpcUa.Client.UI.Tests/Screenshots/DocumentationScreenshots.cs create mode 100644 tests/ZB.MOM.WW.LmxOpcUa.Client.UI.Tests/Screenshots/ScreenshotTestApp.cs 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 0000000000000000000000000000000000000000..3584663d8bc6da074823a8642510913d5d0e6184 GIT binary patch literal 5692 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 literal 0 HcmV?d00001 diff --git a/docs/images/connection-panel.png b/docs/images/connection-panel.png new file mode 100644 index 0000000000000000000000000000000000000000..0252d3d133eb79e5a1c832b2e21d906caed96647 GIT binary patch literal 3496 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 literal 0 HcmV?d00001 diff --git a/docs/images/datetimerangepicker.png b/docs/images/datetimerangepicker.png new file mode 100644 index 0000000000000000000000000000000000000000..7f0f43511eb0696c7895626beae803bddbd3ce26 GIT binary patch literal 3179 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 O4gkFYgH#*1#{LU{;V_cq;a5D0WvL0(!D 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 literal 0 HcmV?d00001 diff --git a/docs/images/subscriptions-tab.png b/docs/images/subscriptions-tab.png new file mode 100644 index 0000000000000000000000000000000000000000..8ba3d6da4e606df1d352219c614e758740fe1665 GIT binary patch literal 5478 zcmeHLX*gSJyH4A)OKGudx0K@3NlQ^R1T9+nQA24_QzT|;CWeS9ZLw`dRh5V-RSh*n z8beTx);!aS&={*oDu^LMLn2@7^Xr`JI@dYZ`TadV)_T^v-nFjvKI?w&_j&G=`<5mr zgrtN30Kf^;yLYSsfWr*Fzbkl{e+(^qsKY-U2{Sac6%-VloU{540GyIFy>r_(s$g~c z-lIv{oWOcFVxr@irsQEGySraazq$&D%9Xy5x#QaUHfjT7of=6Wt*ZiKQ(M-j8}Wz<8VjqRV{-`CW$)2fWID~5AJD) zvA-MFNT-Wk{0R7rv|PJvqXHI@PgG+1-(%W~1lZn7;jB#XEqwbJi%uRAtDUX|JpQE@ ziW^u&jXix38Lca=rFuRBZ0mcdTwqz_g_oE&|-F(;`eSiLYfc{s^ z#@MlRa&IUzJ3aD*3Zhp)sJz*7qXiRE*f@H9AJ7rw<(+ zPp*U44O08a(P7ss)FkSW4IYO8d#{HHvnbYblvV@0KRE1gNfWTq+_ds?zFFdnLPmj^ zj=D|IpU?U+-dYtBsgskhtgFY29MD0&U8NAIV;DX4y3m*q(ZQ{;0^1K77Y6{J<;Q~U zN_EuLu3h^w6%4(I47769D0T4e11Xr~WvMQg)$M%Mf?=F3Fivyt9AT~t))6uDfzQkF zXfG1yUf5)Ccjg_HS+&lQo0ZG9_d)*RA^zPK{!)Sf?X+kU)S$VxJylxTW_n8AwsFDw0y~>kJGqkxlonc6asXZ2td$Nr_bDoH$(Qgc zJPrW(Jg4@aK-jM8ey_~>vCxB&UcL1jKrB{393ZB70CNWoVG95Ngv$Qkfc|$rtR(4s zrKYB0te0971X$5TE6u+ioXgaPZ+faA&C5t}6>y1_tU!I9d?JFkH!CWqe>3+%y^IcY zp(91ys%qP!i(pS+OU|t?vwc7D-?R3Kx8LxIyNbu-kI0IjO-o8j$_EC=?=;V@Mri8Q ztvvSgBy`41#XQR8!3e8|~5F4Em{KtnCmChQ(b1Gf9ez*~z8wBDAPtEXZ z^iba`GmOzM1MQXKE`v4e^3i2$I`};f63<=~Ynz&K#?cUPIQxuuQnIL=imIxps3=T1 zj^Ht=#FabW@rDLL35d%6k}IIieEaszfX5z&U}k>XTU|uDFqj;~HpNFkCl@QL7d=|} z6O|E&+k&Twr4~Kt8UnOj*k|DoEX7R~Z9rTR;_+(8L6rYTGGXogIQ+Ps5gItR-DcjD zLNX%yRZTV5Ylp{Qg@;f6)=Yq|+4ID67bb_&!#7r9KS%tRQ-ulyx&sq!@XTgKQ7&} zp>0Hm*1*GS91!lS#2P*{wkLx>aknRlE$=)9HrX6G-mmV}QBJ$ue#p`YhDe8HWz*Zt zm$uvkz0ec0_=Ruml)sJX;WoETGy>{JJzt4@awY3nXY=sNOAURp$aNB%PKMZbllubH zG_!|~%WnNDGUGC!)oj;N>rRwj;}=+5C>@rXU0B$JJV{xKWzm@@z4`3(rZF*+3bHrK`JLnNN*S{^mu7q0)ipyswt4cM0MSl-@oV%1;_*!G!+{&yH%z?GyQjBysRnZ8*OB0 z#f#OGr|FAr>oZkVK#GqCDW0$HxSqQ`?CkuJu6q6OGxa2?;?5T zi_r2)Oy8qD`6|1$KRKR$@M(|Yd8XT})rgRjGqPTp1 z^Jk0EJD+=`2V9@rRm*RNdX(A@mzg0|smO^Zn#fPu){0g>Hq-tz7_TFCcd1N5#aTOY zNKrS}qM@I^nfbR9w-~Cy_zY#~fF$?E$cU)pOIwQXx zP?)_-9BYm5n8?M2(?`p}k)!4wmX{K*y|NfTZNPP^!Jl6|NZ^qkb_%|v7$11?g7oZL za;>WYc1lUPG)&wC$l^5;LiT3N{8NYB%X9K`wPoxW(~8qh7S9XOxst+4J3-kMd{7p% zWNwQarC$o3ZUJh9M*r-%3oRxE#4ay9I>_SQsm+ux8({rH`PaVwR~@mhv`TSZt%Vv$ z$-!h)GmBchJMAu6D0>{1pCtXpg{X_{wNfyAL?EuIE`JYEoB~4zFNy55xzz1%WFQJ| zz!=Leyl9^u?Rd^F7vAnTKbD&LgYm!SlAA@(lvx%kBK!G2=hs+63#(N?PUA4CJy}%M zxpDpH*Ai&c$Gy*^r1^;-?z(ZhiSO0=Z|;Ye=U8TF?odPuhcFV-ppcl;E6EdWs}qKT z@^3+hJUw_OSxZ~k$m6CoDTK&HlAGFF>CAx3D3Mg9jP@^IyU{A-k){+o*)wM87lX&` zEQ5Ql^OI(whdr2q@BpsIPUPRnJ;_f1Ls~FTzD^|_dp-d*6@?F-4scPI?p9!;P>W)Q z4%iuN8f`OSbvH?Gw@{C?FSK_8tYFM^BZQswX$KgdyP>IXKM^E1Z!@SA@2-JHfgmDo z+KKlAA?4!Op`2I!M2Lod}!XYv~1a9H0M8q@Uh9KJ>Gh<0jCStvN4w|=Y1Uo-j3j7*}7aq zM+d&WLYZs`P8J(N8}PDmv5Tq*c0b;uJCYJ5mtg>|NOD@WGm-MOaEm2an~TI@`_JeJ zZyPZQnCzGavz?dej+w)Der}=dkF$(2+Pl(1Jujn-a~G^4E@%auz> z;wCY$`O}I;E-?}6k?tCD-rhWXYnA-NqPUT}h_Oq%5?=l>ftp8Kl!9L%|MiEo@Z{|> zNMJ`nx=45bu5(Z62cjpx7A$*51j86psNCG#qN4pJE5t;7+vm@po7Y-m_*ts%GsHO< z;~M99bU){^`I|wjXnY8D0LA03A!a#L-~pqythcqqj~Ru%agQU+g$5R@+3=U@2x13Z z)xb?>*zb!=!Ha!b^`&$R%^TVVGK)Q-EB+ux|4i4P-|w9ll}}tBx$jT+#uK2r6eoXS z;)f@+@glB^dM;{Uwh)C~u)!Vy=ZIc!ZwCqUqaeRvDA?YPvWSP$fKv57GFtvTx`B3q z(0o(8&g-xo*N7&mwx1#b;)ahb%KylN$pU|vpidQdJzp+7P$}_mZJ`UQD;T$}TKOpy>3f~kQ+w(B zXqchVFk28rKwGGeF_I!*9y5VWow(yCiU{;`W<3kTaxbQgMlJfqsQ3AK5%5+e8!@r? zdW1*4b)K->%|x-IHYMp-9v3E}qMJe@f@1yr#lD54++o1V-9*RYy9 z&3e*;-u*5W>H!Kn$Aao;1er<64ka#jwazFRvYMyg7%P4E6t|*G{aN-8O)2kZVe>X% zcZkPGY3?z0FXP1OZ2t!*B4|lBgL$?mp64WqQlxq_Q|_w{)kW@6%W%gFP`%iDK1P>$ zjFEIDW^J6`+P8ZDae^b0HSU7ovc_Q~`?4obp15%7_Az89gc3Q)Nw*<%nDJHoe6O*! zj$URnYYWXa0QA&WW2WHp11X1SgdA;+gy^3JU^To;53# zFz21QVKDjto;-c4U_1P1BmVZAI0ch)pj@G(G3e0ls$l|(6W*e2^Js>lo;_@vlTV|S z9b7?|PKQ3(fsCv`J3p(CA^KQC5wSwJSgmV7`87qTd zFUjf|9D4 zG=()ls&&OB0|`sClpy}XrP>)Bw|3WVE!*Yz_J>G?5_8}>N9reQ2_5`#qbp{O2>V<@ zQK+<1Lyqj!MK?9xjD9Uw4-?Z1`k=7)cxC;c6oI(?a=8guYwPh9)J{!Zooyv~I|dMU zA*(ktlVN^;I5r>#GwhM*5PPM?5_(M?g@eS=MQ6~;c3r?B&kHpIajmmF+2zH`u=%Eh z{R<1x@Y38o-T%-z`uJO%X^Uk|oOu{R2EA}Pa`Q}Frf+~$Jbkf(_@d{ukifJ8U>Kun z`ifFYRW@`vJvpgy9FR;wx<5Gvh=TmB3`85FwteQ?nb={2h_$CjzMCG6Ecbb(1K(U| zYcs@IN9kX^dbQvtyvDv9)*9c8;Kie9yq-*D7vu2vyRpfm>T;71%J^FE(}b_;eGAYg zek-(#D4>07Yik?K2Zl8{E8kQcR>DA cblgKYzjZgUU)*}h7fu1D|FpbQZRq;+U)nuW?*IS* literal 0 HcmV?d00001 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 @@ + + + + + + + + + + + + + +