From 3a312ffdd187fbf049ed5df896447ac5528a1a83 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 12 Nov 2026 11:34:50 -0500 Subject: [PATCH] fix(history): a folder's event history reads the Galaxy objects behind its native conditions (#886) A notifier folder read one event source, its NodeId identifier (a RawPath or an EquipmentId). AVEVA stores a Galaxy object's A&E history under the object's tag_name (Source_Object), so AVEVA's own alarm and event history reached no production folder. Its lower-case property keys were also missed, so every row read Severity 1 with no ConditionName. Sources. A folder's event history now reads its own source plus the Galaxy object of every native condition that fires on it live: those parented under it and those WireAlarmNotifiers wired to it. Live and history use the same membership. The object comes from the condition's Galaxy alarm reference, its raw tag's attributeRef, up to the first '.': - The composer reads it with the driver's own parser (GalaxyAttributeRef, shared with GalaxyDriver), only for a GalaxyMxGateway tag with an alarm (RawTagPlan.NativeAlarmReference, part of plan equality). - The applier binds it after materialising the condition (IOpcUaAddressSpaceSink.BindNativeAlarmHistory, forwarded by the deferred sink). A rebuild or the condition's removal forgets it. Each object is read with the #882 rule: its own events and . rows, never a prefix sibling. Fan-out. historiangw's ReadEvents filter is a single predicate, so there is one request per source; the wire contract is unchanged. EventSourceFanOut merges the reads as one source: - order is (time, source position, arrival), reversed for a reverse read; ties are never re-sorted by EventId, so the boundary skip stays stable; - duplicates by EventId are dropped and the merge is cut to the cap; - a truncated source bounds the page at the time it stopped. Paging, continuation points (bound to the whole source set), the walked-back reverse read and the tie-cluster over-fetch are unchanged. A folder with one source is read exactly as before. ACL. Every source is read inside the folder's own HistoryRead, after AuthorizeHistoryRead (#826). A denied folder reads nothing and fails closed. Mapping (EventMapper, property names now case-insensitive): - Severity: an AVEVA row's priority goes through GalaxyAlarmPriority, the table live Galaxy alarms use (MxAccessSeverityMapper now delegates to it), so history matches live. Otherwise severity (OtOpcUa's own rows, which AVEVA stores lower-cased), clamped. AVEVA's own 1-4 severity is ignored. The table ranks a higher priority as more severe, which inverts AVEVA's convention: filed as #887. - An AVEVA row whose source_conditionvariable names a native condition that fires on the folder takes that condition's SourceNode and live ConditionName. Otherwise ConditionName is alarmname or the condition variable's attribute. - alarm_id/alarmid map to AlarmId. Tests: - GalaxyEquipmentEventHistoryWireTests (4, real SDK session, production Gateway backend, AVEVA-shaped fake gateway): equipment EqA backed by objects Oven_007 + OvenDoor_07. Covers object and attribute events from both objects, the prefix sibling excluded, cross-object and in-object ties, forward paging at 1-4 a page, reverse whole and paged, SourceNode/ConditionName/Severity, the raw device folder, ACL deny with no backend call plus a granted positive control, and a rebuild forgetting the bindings. - EventSourceFanOutTests (8), EventMapper AVEVA cases (11), GalaxyAttributeRefTests (16), a composer test and an applier ordering test. - Mutation-checked, 17 mutants, all killed. Covered: parent-only membership, no scope, a case-sensitive condition match, no condition rewrite, no truncation bound, EventId tie order, no sort, no cap, no stop at the bound, unreversed source order, no dedupe, no '.' boundary, priority ignored, case-sensitive keys, no applier bind, no deferred forward, and a composer that ignores the driver type. The forget-on-rematerialise guard survives as belt-and-braces: the rebuild clear covers it. - Live, read-only, no WinRM, vd03 HistorianGateway ea9f21c: equipment folder EQ-886-ZipsenLeft (objects DevWinPlatform + Left_001). It read 0 events before and 92 now, equal to the unfiltered read's rows for both objects. 1/7-event pages and reverse whole/5-event pages agree, and 73 rows resolve to their live condition. diff_runs vs the #882 run: 221/226 identical. The 5 that differ are expected: DevWinPlatform Severity 1 -> 750/1000, and #881 capabilities. Claude-Session: https://claude.ai/code/session_01GT67ACmpMR7b9LGRPLijMi --- docs/Historian.md | 65 +++- .../2026-11-10-opcua2/aveva-history-801.md | 30 ++ .../aveva-gate-sdk2-886.json.gz | Bin 0 -> 34567 bytes .../validation/aveva-history-801/README.md | 3 + .../OpcUa/DeferredAddressSpaceSink.cs | 6 + .../OpcUa/IOpcUaAddressSpaceSink.cs | 18 + .../GalaxyAlarmPriority.cs | 37 ++ .../GalaxyAttributeRef.cs | 75 ++++ .../Historian/HistoryReadContracts.cs | 8 +- .../GalaxyDriver.cs | 26 +- .../Runtime/MxAccessSeverityMapper.cs | 26 +- .../Mapping/EventMapper.cs | 70 +++- .../AddressSpaceApplier.cs | 15 +- .../AddressSpaceComposer.cs | 28 +- .../EventSourceFanOut.cs | 193 +++++++++++ .../OtOpcUaNodeManager.cs | 99 +++++- .../ReverseEventsReader.cs | 33 +- .../SdkAddressSpaceSink.cs | 4 + .../GalaxyAttributeRefTests.cs | 40 +++ .../Mapping/EventMapperTests.cs | 91 ++++- .../AvevaGatewayHistoryLiveGateTests.cs | 148 +++++++- .../GalaxyEquipmentEventHistoryWireTests.cs | 322 ++++++++++++++++++ .../AddressSpaceApplierRawUnsTests.cs | 51 ++- .../AddressSpaceComposerDualNamespaceTests.cs | 36 ++ .../EventSourceFanOutTests.cs | 202 +++++++++++ 25 files changed, 1531 insertions(+), 95 deletions(-) create mode 100644 docs/plans/evidence/2026-11-10-opcua2/aveva-history-801/aveva-gate-sdk2-886.json.gz create mode 100644 src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/GalaxyAlarmPriority.cs create mode 100644 src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/GalaxyAttributeRef.cs create mode 100644 src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/EventSourceFanOut.cs create mode 100644 tests/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions.Tests/GalaxyAttributeRefTests.cs create mode 100644 tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/GalaxyEquipmentEventHistoryWireTests.cs create mode 100644 tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/EventSourceFanOutTests.cs diff --git a/docs/Historian.md b/docs/Historian.md index e83742dbc..78df197e9 100644 --- a/docs/Historian.md +++ b/docs/Historian.md @@ -639,14 +639,71 @@ attributes as `DevWinPlatform.CPULoadAvg`. Both are returned by the one request served: a notifier whose source is `X` serves the events whose `SourceName` is `X` or starts with `X.`. The match is on the `.` boundary, so `DevWinPlatform` never serves `DevWinPlatform2` or `DevWinPlatform2.CPULoadAvg`. This matches live delivery, where an attribute alarm's condition fires on the same folder as the object's own events. Each event -keeps its own `SourceName` (the attribute's); its `SourceNode` is the condition its stored alarm id names, so an AVEVA -row (no OtOpcUa alarm id) has a null `SourceNode`. The attribute events are part of the notifier's own read, so they +keeps its own `SourceName` (the attribute's); its `SourceNode` is the condition its stored alarm id names, or, since #886, +the native condition that fires on the folder and dials the row's `source_conditionvariable` (below). The attribute events are part of the notifier's own read, so they are governed by that notifier's HistoryRead verdict: with `Security:Acl:Enabled`, a session denied the folder gets `BadUserAccessDenied` and the backend is never called. No attribute-level notifier is exposed; a notifier whose source is itself an attribute name (`DevWinPlatform.CPULoadAvg`) still answers `GoodNoData` from the gateway, whose `Source_Object` is the object. The SqlServer and QuestDB backends match the source exactly, as before: they hold only -OtOpcUa's own alarm events. ⚠️ A production folder's source is its NodeId identifier (a RawPath or an EquipmentId), -not a Galaxy object name, so AVEVA's own events reach a folder only where those coincide (follow-up [#886](https://gitea.dohertylan.com/dohertj2/lmxopcua/issues/886)). +OtOpcUa's own alarm events. A production folder's own source is its NodeId identifier (a RawPath or an EquipmentId), +not a Galaxy object name; #886 (below) adds the Galaxy objects behind the folder as further sources. + +#### Galaxy native event history on real folders (Gitea #886) + +**Gap.** Before #886 a notifier folder read exactly one event source, its NodeId identifier. AVEVA stores a Galaxy +object's events under the object's `tag_name` (`Source_Object`), which is never a RawPath or an EquipmentId, so AVEVA's +own alarm and event history reached no production folder. AVEVA's lower-case property keys (`priority`, `alarm_id`, +`source_conditionvariable`, and the keys of OtOpcUa's own events, which AVEVA stores lower-cased: `alarmname`, +`alarmid`, `severity`, `message`) were also missed, so every row read Severity 1 with no ConditionName. + +**Rule: a folder's history sources are the folder itself plus the Galaxy objects of the native conditions that fire on it +live.** A native Galaxy condition fires live on its Raw parent folder and on every UNS equipment folder wired to it +(`WireAlarmNotifiers`). History uses the same membership: + +- The condition's Galaxy reference is its raw tag's `attributeRef` (`tag_name.AttributeName`, the address the driver + dials, e.g. `Oven_007.TempHiAlarm`). The composer reads it with the same parser the Galaxy driver uses + (`GalaxyAttributeRef`) and only for a `GalaxyMxGateway` tag that carries an alarm (`RawTagPlan.NativeAlarmReference`). + The applier hands it to the node manager after it materialises the condition (`BindNativeAlarmHistory`). +- The object is the reference up to the first `.` (`Oven_007`). An equipment folder whose tags come from two Galaxy + objects reads both. +- When a folder is read, the server collects, under the node-manager lock, the object of every native condition whose + parent is that folder or that is wired to it. Sources are the folder's own identifier (OtOpcUa's own events, as + before) plus those objects, de-duplicated and ordinal-sorted. A folder with no Galaxy conditions keeps its single + source and the exact pre-#886 code path. +- Each object is read with the #882 rule: the object's own events and `.` rows, never a prefix + sibling (`Oven_007` never serves `Oven_0070`). This serves every alarm of the object, including one whose condition + is not wired to this folder, as #882 already does for a folder named after the object. + +**Fan-out and merge (`EventSourceFanOut`).** The historiangw `ReadEvents` filter is a single property predicate, so there +is no multi-source request and the wire contract is unchanged: one gateway request per source, sequential within a +folder, each with the page's cap. The results are merged into one stream ordered by `(EventTimeUtc, source, arrival)`: +time in the read's direction, then the source's position in the folder's ordinal-sorted list (reversed for a reverse +read), then the order the source returned them. Ties are deliberately **not** re-sorted by EventId: the Gateway has no +intra-timestamp order, and re-sorting could move an unread tie in front of one already sent; a source's own order is what +keeps the boundary skip stable, as for a single source. The merge is de-duplicated by EventId and cut to the cap. When a +source was truncated at time H, its unread rows are at or after H and, at H, follow the ones it returned, so the page keeps +everything before the earliest such H and, at H, each source in merge order up to and including the first one cut there. +That prefix is never empty. With the same cap on every source the cut to the cap already stops there; the bound matters +for an uncapped or default-capped read. The merge presents itself as one source to the existing paging machinery, so +continuation points, boundary-tie skips, the walked-back reverse read (Gateway, which sorts its ties by EventId itself) and +the tie-cluster over-fetch (a forward read, reversed for a reverse read, which is why the reverse merge is the forward merge +backwards) are unchanged. A continuation point is bound to the folder's whole source set; if a redeploy changes the set +between pages, the next page answers `BadContinuationPointInvalid`. A folder with one source is read exactly as before. + +**ACL.** Nothing new is authorized per source. Every source is read inside the folder's own HistoryRead, after +`AuthorizeHistoryRead` (#826): with `Security:Acl:Enabled`, a session denied the folder gets `BadUserAccessDenied` and no +source is read (fail closed). + +**Mapping AVEVA rows (EventMapper).** Property names are matched case-insensitively. + +| Field | Source | Notes | +|---|---|---| +| `Severity` | `priority` (AVEVA native rows) through `GalaxyAlarmPriority`, the live Galaxy mapping (`< 250` → 250, `< 500` → 500, `< 750` → 750, else 1000), else `severity` clamped to 1–1000 (OtOpcUa's own rows), else 1 | AVEVA's own `severity` (1–4, or 0 for a system event) is not on the OPC UA scale and is never used for a row that has `priority`. ⚠️ AVEVA ranks priority 1 as the most urgent and 999 as the least; the live mapping treats a higher number as more severe. History follows live so the two agree (lmxopcua#887 tracks the inversion). | +| `ConditionName` | The live condition's name when the row's `source_conditionvariable` names a native condition that fires on this folder; else `alarmname` (OtOpcUa's own rows); else the attribute part of `source_conditionvariable` (`TempHiAlarm`) | Live parity: a resolved row shows the same ConditionName as the live condition. | +| `SourceNode` | The native condition whose `attributeRef` equals the row's `source_conditionvariable` (case-insensitive, as the Galaxy driver matches it), among the conditions that fire on this folder; else the condition `alarmid` names (OtOpcUa's own rows) | A condition that does not fire on this folder is never named, so history discloses no node outside the folder's live event set. Object-level system events have none. | +| `Message` | `message` (OtOpcUa's own rows), else the event type (`Alarm.Set`, `System.Stop`) | Unchanged apart from case. | +| `AlarmId` (internal) | `alarmid`, else `alarm_id` | Used only to resolve `SourceNode`. AVEVA's `alarm_id` identifies one alarm occurrence (shared by its Set and Clear), not a condition, so it is never a ConditionName. | + Event-field projection supports the standard `BaseEventType` select clauses — `EventId`, `SourceName`, `Time`, `ReceiveTime`, `Message`, diff --git a/docs/plans/evidence/2026-11-10-opcua2/aveva-history-801.md b/docs/plans/evidence/2026-11-10-opcua2/aveva-history-801.md index 9e80333a5..d5d90dd39 100644 --- a/docs/plans/evidence/2026-11-10-opcua2/aveva-history-801.md +++ b/docs/plans/evidence/2026-11-10-opcua2/aveva-history-801.md @@ -88,6 +88,36 @@ read over SMB and never printed. - Report: [`aveva-history-801/aveva-gate-sdk2-882.json.gz`](aveva-history-801/aveva-gate-sdk2-882.json.gz), sha256 of the JSON `449e689c…9b27` (label `sdk2-882-008f0b36`: the #882 working tree on top of `008f0b36`). +## #886 re-run (2026-11-12) + +The same harness with the #886 change: a folder's event history also reads the Galaxy objects behind the native conditions +that fire on it, and AVEVA's lower-case `priority` / `alarm_id` / `source_conditionvariable` keys are mapped. Same vd03 +HistorianGateway `ea9f21c`, same event window, read-only, network-only, **no WinRM**. The API key was read over SMB from +vd03's OtOpcUa environment backup, kept in a 0600 file, passed by environment and never printed; the local copy was deleted +afterwards. + +**The new case is a realistic equipment folder.** `EQ-886-ZipsenLeft` (UNS) is not named after any Galaxy object. Two native +conditions are wired to it, as the applier wires a Galaxy deployment: `Plant/Galaxy/DevWinPlatform/CPULoadHiAlarm` +(attributeRef `DevWinPlatform.CPULoadHiAlarm`) and `Plant/Galaxy/Left_001/FRR_Warning` (attributeRef `Left_001.FRR_Warning`), +each under its own raw device folder. + +| Check | Result | +|---|---| +| Folder's own source (what it read before #886) | `EQ-886-ZipsenLeft`: **0 events** in AVEVA | +| Whole read, wire | **92 events**: `DevWinPlatform` 2 + `DevWinPlatform.CPULoadAvg` 70 + 20 `Left_001` / `Left_001.*` rows (FRR_Warning alarms, user writes, deploy events). They are exactly the unfiltered read's rows named either object or `.*`, in time order across both objects. | +| Paging | 1-event pages (92 pages) and 7-event pages (14 pages) equal the whole read, row for row. | +| Reverse | Whole and 5-event pages (19 pages): newest first, ties by EventId descending, equal to the expected order. | +| SourceNode / ConditionName | **73 rows** resolve to their live condition: 70 `CPULoadAvg` alarm rows → `Plant/Galaxy/DevWinPlatform/CPULoadHiAlarm` / `CPULoadHiAlarm`, 3 `FRR_Warning` rows → `Plant/Galaxy/Left_001/FRR_Warning` / `FRR_Warning`. Every other row (system and user events) has a null SourceNode. | +| Severity | The wire equals the backend's mapping for every row. Alarm rows (`priority` 500 / 700) read **750**; priority-999 system and user events read **1000** (live Galaxy table; see lmxopcua#887 on its direction). Before: 1 for every row. | + +`diff_runs.py` against `aveva-gate-sdk2-882.json.gz`: 226 shared cases, **221 identical**. The 5 that differ are expected: +the 4 `DevWinPlatform` event cases (same 72 events, ids, times, sources and messages; only Severity changed from 1 to +750 × 70 / 1000 × 2) and `metadata.historyServerCapabilities` (#881, merged after that baseline). Seven cases are new +(`events886.*`). Harness failures: 0. + +Report: [`aveva-history-801/aveva-gate-sdk2-886.json.gz`](aveva-history-801/aveva-gate-sdk2-886.json.gz), sha256 of the +JSON `6ca314ee…c890` (label `sdk2-886-da948e5f-wt`: the #886 working tree on top of `da948e5f`). + ## Files - [`aveva-history-801/aveva-gate-sdk2-d46044f5.json.gz`](aveva-history-801/aveva-gate-sdk2-d46044f5.json.gz), sha256 of the JSON `00b5ecb6…fc27`. diff --git a/docs/plans/evidence/2026-11-10-opcua2/aveva-history-801/aveva-gate-sdk2-886.json.gz b/docs/plans/evidence/2026-11-10-opcua2/aveva-history-801/aveva-gate-sdk2-886.json.gz new file mode 100644 index 0000000000000000000000000000000000000000..38d224e243d798259aa6fc250f9a2c9515fda3a7 GIT binary patch literal 34567 zcmb5WXIN89+cqp9y(&@_r3fg!g^u(h3IY;JfC$nPnn0-1l%{k9q)HJG5_<2_L7KD> ziU^3*(5r&rw}SiLd*9FZyzg7t_Sd-x$=42w-@<66ebA?oRkN@#UpH|w-@#{s(HuNOI4zCy~b_OxViBo9w z)bHa*0q6TK11QuNr(UWD_`B5qaz@a+kTIef2_AWOqiIUcOVY1@>~QaCC1g8)`{dZk zb9YrRz`N2|uxGQ)<=L|xpSuU&0)}cMej8_G#>{l>F8_)HYeGLBFYSGN5j}{p?wIp8hr9-d5;R*>!sCpzD{9mcRE<3+_IL7~9WJ?wvi`?MLPl>JaYD~oc7;ygKmY+q7SgQemPbe`~NzLNt^v~n(0zE z<7lXE*eJKy=$IVooM$FZ?`P|_RI1s_jdbv`e^6iuzmLy5nw#%^jR}ibutU^L7stPxV~M!_k>r%d6yA7`#+h67md4!qE}Qpp z4P0vf>yM6|OoqiRSmQf?jC@!nN^baNW@fP`L@u8FY=6pm?sv4wT)?7lX7OXjjh0_b z=hEqBU(LORq3M)89P=#lPjZ#Xy*u9QI(&%|43)^*mjBWW$%ql1b3H3R_BYSGzL$ih zrtzKK-$W82L zt|$Bq2Dx+00e(9RGr#9LO3P1>MLr7&!R0=nwCWPWMwd|u%VI3(OpHlpmzfs6+JSm# z`QR`cydGUO9kSht-0p=rNLzppkyc}*D5PQD!ka*@QJ6{2MD_YUmdd8Do78 zncV0TF_T_53wn91<#%}yV~hn@w8wY!u_$%#q!@$#AyT@}1o^%x5BL~k&klXpA<14U z<=sExg_iP;=tT_~`F|LN$&9ADSx5B2nIQ&;3y6=JjHf#=cllk|baZh8&;zKY#26}( zUl#?*peOacOO&swMA&RF|Eg)3@2U@bsjwyeC2!DLOcaEZQ-r<@dB)E6<0)P*Ty-My zMXV-#$l8*pxaGZzqf8ajXK+ecBNr^rVvH!R0ZjFYf0ainKdLiio|~ z-im4L6-KNoa=imSal#sBIO}5P=VSX>OPn)GLQ#fxS+OBxhgpru#<`P*5Wj=Nk-kl7 z%H|1;WGs2v@JSFMW{rD{7tUY3u3tx{X1^J%|dh^|rLY_^XY zg$)!y9^R5Dz5*kht5PdOiw_c1Nfhh)0R6LbhHxB^wE#DvHf?VTomug}5v_9ImMCsB zvUi5OKS=%aP1h&nYZcLPcDlBV5OrBNLDEK}w|%@hH4eT&t&}tCmft)|YUZKsQL5nP(q0 zrACh??Xq}B%|0wOs8PuSTCc}@kPp%_Kjc&J8k_E%{-gyL(#O?ay?nbE>IC#4 zwKA;i4UmC*M7a{hS_Rp_H}fK(msfBPaIzn0G9(+5Exznr!{QMA@k#S!e)z;~} zwwWh+7nL_vDJfO7M*r4v z37+x&ZL{|WWV2~XYjkxIA^NCI4bXeg^HQVQ8hX|L8`?dgCUUzM9{i}ZAp+=B z0ZO3iXHNAnp zPNWCnz5!zmCS9QcbPG+^{j#JhU6sDZJW+S zm-cKf6*veUm!N&WGatrLw40#3qe*G5j}+z5Lsm7=t$1QuFLg;PZxtIDffccI2F08c z!OVWtV4>v1t?w_4P#fyJ8>Yzy=(H7}ND3eD^{J z7J_)dK0&scbPzp$ILuEvpgGC^Y62C{M4N=oGPzoAFmpAr1o%WJ>sHrMHE@pn`2j3# z8L!>xekar|G-*5A}5$?5~1ZE9;H= zy-v^znvuU-eBxi!-MyIUL@hbVGc|M~SGchlN(hYiDAru42O#WIi!aa4x{Nql+ZLntmXS&6*Ita8ct?x|Ct&L`%S*5Ic#FM=vY z#LKqa5?t@W!O=W zQSe!f?q<~Pw2!eQHQ|voho{oxR1Y#u0c}vH2%4s+(Yg3FiNXEih648af==*dlH}ai zS*&6kr2M{0J@1AkZcGE2Aw#_x!wLBrJmAj7LA1~DX0fuY$#y=cUXY>W>g_giDPM9p zi_%oOPR?6W@AMFMkf%Zi%dji4`xiVvH-!*Gx>&}@0X!{xwR&0*-c$p=Dwy?yo8yqG zA`z>qR&3BK$kq9Mrsd~JQkj-RD<=Ed1_VFO*`4n_PF6=I;L8_Hp!QMw&Xp;mBb-Fd z!Z;I%tygVa@m%TR3jU zgDPH^O)yO==YB$xNb)?R!^7t)WWPi5FK3BAqf^Wlb>$Yzv;Uw?C;V&Wy4fp|V35k| zxGYqygrlBvtL`f}360QwN<~GxNm|HJaI{>uvt$*aO_u_jAntefm!tJ55+pQ+(G>R> zvrh_`0GEOY0GHbQw@V8^1R+g0mvW`>fI1>9&iHGrW0MaRUD|r4Fm#+YaysP2#^=O% z@!tc;lseuGS8%&;koxs!KlGUjYPb2WZKqk;mvM)rH;IPk0e&UTJr=pHvfBk=69u;~ z;a^R-EEXIpmu>xMb&~cZNjatLc2+r4J#Fe}kzU?yjn$divwOi6D;RC+fMM8qd4m7C z)_BK0+|8))CKAtzv+(A>e6mTRdulm08Sy1XunbbC<;9I8Jo2TP9w&#aF*@El#7|Ew zfnF+)A1A-|PmLbIy@&WA&An$o@UbB?!Ql0f57;HY+K>u}g%8p4uQ1lMko!{dM>}=e zj65T6g4Y$rd9RRMqV!bgXBmD*d@y+FRi>TlQ&AE5FRicz`gxW+^pmw3_#Vmc#71e;LmBOK5oxd1&{wHZGC{t&fkhT9MEg+qUSQGp#YJ)FwjYyJ&h#C|=Te!^IX_r8eWs2* zi_j};dcD1W^3s3LyP4R*p0qDNJekF+oI;yZa*nKIJpH~)5=!drJiehD8^^L(ywqKh z0sGr-deyCxAado7(iL6MbJs@D$U`qRT|-?8Bvqi`nQ|}&&TNtq zjB;hM-@F62ANhQMl+iSiUK~xjJ({XZqsfk9;)v))Ug0OmLZZ_Nm3UA@EyR(1NRy4m zZ-?fH=_RX%GMH-rCnOS#YDmFz6(`X%Mw}T98ZlkAryO$}5w^3BdfgHTezJX2zu2g` zpbsg%ms-w{F~IxlE2l`YR+xcVu~Z{*VbgLEMZ7nN-EIN@fcW7Fp28D1bJREIv)OnsLb@|i&7Y=kYepop5Vh-+o}Y4XJD z0whOq{;lGY!wpDWwf6+FheuT&T4_K`3HUX>y220xI5*J~c-s9hRUnB#BM_R98rov1 z6^K4mSz-jK3jBfP4P-Qpr7fpsui8^G8H(~n8E`-ZRNAB7Y^JF@khtpZk1=Z}x^&Xg7pR1Uj z=!v<8ItS>09lU()g_vI6x*BqgOV4_!oiGkPe6<=0M!V~StC9Nh25D5N1_QH>dSuDC zlU^eItO|07N&j6p(*6|$gT(g0hF%j!fjJt=Vfqr~sR#n$I4~v#JsgETlg`4nuea2j za~rs6Dge))lQnKUU6*ieu!0+`1ZT**tAN130~_ept*URrx?Kr@t5;(+kXSX?m?9Vq zn_y;*0v9%rf~VZ|(bek>DPU`DawcY!R7Buw71$7SR?f53XE7vkW0AtD->QUD`#GVQw$o*Kq=Mln-VKQI`+)C*_GN2A9@q^31G%e z4bk5~RgpoTP}%{(?rlbb?s)KCpH`9q+>A{C8(0HV9>Kp7)CX0ThvormN8UOxG)lOD zT0z-td(894=9i36K47L<{(>f+ME_<$zR}jwSOR_E%H2zSOncRP5W;LpjRStosZWk`^{K_%CD9 zufI}+I$qQ5n=cwwdn(MVH!0ka7k{H`1INeU!wdM#E22C`%p3+^2TA#q%Af8b;e%_@CTd; zn7s1)z_<46Mce1tk6(WW6F+(qgPm0=Ymt^Y9plbiIkWZNS#-Il{n1>e$(X{{_Ll$8 zmxdi{2Zv8Gl^mGZ9-t3PlJ*$8SN;u!!XX~sAyi@Y~=b@m)lLQIquOA)naB{6Y ztbRJWr!nTqZ}aW8Iq|fmp3>iSQ&HRZM#4&2wmfFgBWS0lUuV36;?DV{XIPYa`Fz=? ze8O=o&3-$N0>PF@-I}pl3!Q0U{n{&E0O5$AkFH-{DIW-lOy1X6KKiV`40HNTv($>d zad0J}VEKlv8=K|34iO1{?V6XoVjr$8CZ{Fcn(DrBV9!ncZGyhxAeh~B$tq7zJny5hi*x3p=F`FvbHQM z$>b49>WC}F<=rGJ2pvWGtUj!nkHYxR*{{mr@pQrhja<;mL0TWG-v}>fkC|T4{Tm3r z`??oC0Rd#c8cS4(A?CAe*^?jZxBtMKy7z+=r#`$TX)?^P*kz>8tY9HRqjg!r{zXbA_E#a4I5YwC&SUje4BCA6Ns8OY=o0lY zdp|IV)6&MT?kH)_cFSNjAy<*uW;^~5=+^~I;4A|bh}PqY24L}qiVl?(G->`wl}R*6 zc}%Ql4>}8m4>ho+07@Rai*qqoA4CfKBK3~rlEYh${nT!Q4HXnt1Fk*6zK5KlDn>#P zGudDaZmPH)EyZ~Q(*WrM%E|NsvE=c)#K>!wI$gP3H6Wq_%|7nx0A^nG#q0+p8-1u~ z4RO3Tg2kIMY7z91lGJi|l$p*C4;3m;ez|j96$~ISCbD_e3c7GC5N8i8HS4g%oJ^NQ ztuX`Z(tLZK0xZbV&X~Pe%GM-F4Rv+a4lH#)0uU}g0ZvZhKvhf(YgNHUqQJB6=HQ9c zHx;-U+hh~Tr~V!t9$J7X-ts*~JO$a3zSKm)KS8G8L(7Wnr3O`2#aIO7W+X5Zxh!7+ z6B~O6s*Lqk$d{Myq)9yFbR^Ll8tf7(rqcz78a&DcS;9-WM^i;B3aUbriV)43BQcmF zv?5PwK1XQ@N2wI=whGiOiI^bDAdfl6poYxv_Km!WbT?oX%ucgI=Kz1mTPUf!5t=Bh zI=r_oRWTPGjw>hCUoS~`Rt5EpMHOx*Wr03SMK}w`VTuM{#`U5;)m@PW7Ag&hKE&<+ z2ysaJ>nOVWW{XT&?66f;^ zKO-ys9i9;Ckp+%|jXX$LPg*Q2c{~>N7!x%XnMlU`LG9q%xci%ISErj?uImlYn0=nX zvSY@$+!~yDCd0;(Ku^p*l)TaFTc)@}`b;{GC%Zag^jcN}mQH?_3{e50rfZQ@7)dR%V0!tgOd$7Wds!3i@Z8Ja7hzEi|VVQ2yFNRzsem= z5HNC!+juQfdsA)1Xz;$l>uy-LI42J(`#MU^ofeh9R)&P50eIsu&>FJqsv65G*Tym&dJy0M7hh7+3KJgj<(Ny{!muvf-x&p4~Ky#u5%I5!Wu@5I(`o24F!L^&8xtbG^S<<@-FU0rdISJi+}+pV0klZkNIR5*jerJ08r}YA*t() zz@0EqGI?6kc!QZTa41(zf_&r63eNx#4QuNU27vGbu;12R1@_yHY!|4f=RNodn2^ek zCy>x%Y;Xn+?HaV3YFJDV%>dxyuoxjB9A`M=;Q)-p$FzFhn|GY8XAN##`WMK_;Xtm0 z302USS}FLl5A{hhCJzU41-Np+z|45mqJ4@!+^_RG-w`Ycc5Y zDXA8)h-?XE@2yMkO?x;1=QY5N5i#@=fUvR8zZ#x!Z&=;z#i27y1vupZjz+ow4Y|V|u_*70u-}-pcHuv(Qqj*06bI&SgL?1Q1o$HDbyzv*lJ^vji-?aC1-{xfc zV~dXvdGyzFH*f0jsym5?fuZTvE@O+4vNTj>X7v2@PRGZ8vLP!v(B5ZzR>9 zSte#L--xe2b4y5TOH3uW7jhNzH<`-^^?3j&mGJb2+ZfO7nPh@{qn8a$|DtoLZZ9U~ z^BG}7mMQ)BX>Ru4C{rgw*4qpJACPWmh)3VeK=|ECk%jDM;~uHy_M9bMGZlD|aJx5) zek@_Zo=+uIbXn5W~#FUZb(3ToU7^*-kCxFFPsV51eCJl`I zy8vOpV$fAsiKCrnQ0P1{3R$3WFnNb6Wa5LtYUhxhRaime4wGS6X9Y*6fhio>Zr;MjYjpPHFAtE zHHecaY3cn<_%t>T`?8$5tJw?tTzKqrnZ2?!nG31Jf8EUsh30CS(`noR^VI39a-gkL zkU4SdA~rnisBBIc7{z0&KVk$1fLSXHY=$aXFa;aJR;>*HxYj@;(~QAs{zXS`P+~6i zPh*hxi&E1q^6nQgqpwz%fZyoi(cmaM<*ZS47PrG2$ZReBMkh?0RKp7N{_^ekX?J}s zJ#IbDDi7UMe0|U6%LOKo;xHuh&EE}rT3E8In>gk-lqGUoFB={>ZA8#eqIZSP2(MiS z3jy)#Vj+YjMUNqb9?@)7NRiOQ+i^${wZzf7$~ENqUp&ce(~OjWsk9m|_1r5xgY-`0^Go-(h_` zs0!RIk>0E{IA{y%MqSe~M)@QnIZCJ$Yt~=+1P4r zM&NuLKqX+s&;o=X05p411}xs7vrg0y4kEL$!2)O6_ZI=IA+ZJnsD*<(V1XMhS6xGc zqJkQr+1?Suya!xQHA`zYCy`h>2(6wE;%uOFSmssRKWKSyftLSJLdqT4q)|Aw3H%0R z>j02mJ*!Ss{_bCtkc$Hf&`LnU!d$cp{OXUIC2zsKiwyKkj`aaH(H9$UG{D29SDpj% z*_y3CfZ<9jc*@^Fwi(02ULb%fsz9S~YhQ2Aq7~Z~Dzg?SEsaXN|!j zOd?|QhtC&ibf3S1*A7fF1A2Biwg2`}fa$Nj_3~dorUg-luKUZPw^SdJhkrZCeDd3M zU)6iDD@K0fXW8lT!TYa8HH$r^{z5sf&#AN7~@F~F{$o3X3GTJX4 z4IJ@)PP*HyWXPt`Y(v2>Qi^=TW{?Y6e|iv$0VBX#6YjAN_C6Z~=qf-;wHh1-wT{05%oJp6 z{&~Fv1gNa!kt_9u?asu8c23e@{GRk~5nGX`+LkFV8uc8s(O3B zH>EhnTK0$+S-&=_8u@ULX^6C)v3viEyHiX-w-}wWEs3KUBF7Nvvf%L9w4rAJ4!g>dgsCaGjWA~6n#%yT=L$95|C1n zn6r3hcR*PKbiPSI+lb6o%|r9Q7|*b~xN698LW(J>mcAfIt=x^VK9pLPSx5kG3{c@s zO3uRUhtrRyU)+12qz~Zr9!=@rSWawRfOWLrG*ZTqLO_-WdYgbf&k+}Z>oWby8btEpRAJV0jxE6vy-}5z6>JlQ@S8%{q?rCA$iBR#OKy3X!-Lyt47IrPlrkL)>;9pvgVXnM z>qsK7`L<6bTlWc}=GU?UEm}Po(*Wl%6!LIb>fRre9LcGecF_SRG5fsI=l6MrG>I%N-yOU@k zQd;ufi?Vnni0AC+D(X}HN5*`d@Tq}XQ%Aw&8V#%*-&L=d##xN8@DTPs;rwHeWC#-W+L_(+e;196@GwnfX;IH zEE`@;oUNM+jsOJ92-x0#F?zTJvi;1OVo30jG<5(jf1elrjAoR+$2aj@Wu=OwX03BBW146fkv5KB3#6ssjWwS=2R0wO7eqlo#tl^dZ8e~WxQGE0UsXwL*MkCj` z!G-x9$PzHdr3A@aou;3sDiMbSOH?Ao&x|&?4cF4?$km^szf0`^y_J?uzx3 za^{Beh9|`CW=6Be4F5M^Ci>rjdGEGf<-{eqSKyS#cvp=tAbHT(n7bl%~q)Cb?G#6*CSgt_W=wCc&mzmS^uR1ILO@+9q#lLEyiMiC{9(4_3K1Fn;p2Iu`t9( zGP}kyAl5>|3ejhfO^qtaG@|6^Mp>b?PU$nQ>3O}^lx8`xXAufdT0;W5qHN`a6)1{3 zD78pRnHM0K#*~p_fB@Qq(zhR~0JM5Q7G*;50US>r3LXRI1=e487?5iLxnrYxZ4$Ww zaJ)RXB%$!8KWL1JG(eniI|H2BVP@?(@iEvSCC-2scdo%4Y3iVE_I>!_96iXM55v z{mFnpd?uZZ?S%IhJbBdwFj&i5(8`)CK=+LMvv8CdrwgYJD>kJb^r#i5ABZehYT^Za z4?kY(&#Y)UE&c8DoA#!xpInJ2Z5u8s_EF~ZcgLAaZ804`CC^^$54s{MnBU&=KfRD@ z+e<&c`}q81@!MO&n57J^%mfzPdpm?u7=EwQ}4~k=t=T9wu9;26>D*aB! zy6RXwP=hBchfCFYd?Ahot_`-%0J(9shuf_F7a4g+J52<1jl0G`v@}!iJrx! zr8?6aE!q!1a40(m%s6On{=#BU?KT;{nUPuBQhZ)>Q*4*hAYUWA7xnND7WL~`Tk2m? zCDFUSUG&(x0LcbRO+QSx?fR^Oj?!I`t%q?T?IVyj2bhfeujanN?q@C zD!KoTW2xk(*dVoJvq&sD?SM5)f0X`BUn*|7Y{LJNu;@Hg^|nbt==*qneFEJ@_E>)X z4&6`m$hLxauxw^+&1xM=E`$|1+GSkIb3y#mw9t6{b-c9P?U@44+kUiAc=yA3wMH5< z2GSeRnn|re1qpu=Z3R}OH?%bqTibHo|KwN{p6--@Qm4 zC+BX9U8-Q}LWIrF9H|z&7B*&Bi%K;;`3UlR|K;Y{*(WoEhm(z4CM&kb1Qg^JFOrZ) z(`CsivJCgCR5B{X`E0nxa;rJ9MO_xcqK!&7nc_O6Z4pKT4{IHj&4<>3JN?)w+1zw& z`KTjOrWi+UMG30Ejdjt&X|#+BZwgXb=RF@9SuxsHxw_&RQme&mD9+h1smdG%jGJdd zycW!1mGMA=#|+51NZ@jSON_jZ%pV6KoKf;-g4^4!MEN^@wWmMThw2hyQFFIJf-fHx zI2vQ~c4~^;YV8LfES%EB0-&0=vs`rOD!JiZDOG@W^-`j&;DmMEpz@Vfr{YC(gozq` zy6L+oH6at*@pE?l%k2o0OdE+Qb$XyiYg~ujIy;K7JW`ttEr4X}qbiL}im&H)1O` z;g>ZY$5g#TxXUcEkm$-2GqEu~dI!?2jQ(Rei7ujio%c=?lptl-2u~#(u#k(thMVh-PbH>v34`m z9f?a*8hvKU<_6(a%fiwK%d}8w|^Sk#-h;%^4HxE}qS&btS;8xNMs? zY&T%V`4)(;7kM<_elE#x-yXIQ&;49dz`mV$F`f&^v1ljm80C7-Du4iTM!9~?)Pb*_ zd&t(Op35zrT!gJm6`r12czgLYusjNzO&&e#M@2*odPvYS;3a->^yrvsuKpGN_;@dH z?_IIy;?Yw#@ePC0ZDPLhulvt@)4%%di+%e#%jfy1q~`9bIM&yjY47TakCemPN8K1y zbJyFBpIuXDm(Ck{ENZc;tRPHYt3o990Y*!zWJ@xtVY z?nM|05na3maMa~in>kAD)1r0v7fhyQnhM*$gx;+?TQ8$2%}v*km*G1SWd5|r<9W?H zLqq<#d3~(NfPA#HAOAp0hY*>u+6P)Q2X(_`ZB9?knjCUr`jM36RlY}_d(vB3>J#&w z8DDxb^fp)fz7*zf^?lR6R}mHC@JgmSrZGv~AZcVRl8JCIBXw1Yqu~+loR;LD_;VXw z*xT1pHtKbq=3cpj>_&s4C+$`x4)b5jqr$!Uzt zq0$I59oKSCb<`!!Q5IaMnYF%#P>}wfLxpCkD|k6PLWf`t>s4iWde9cpKw4s>>nWTb z{b2Ld4CO22_o9FM`%7(jBkSz1YKwzur<_HTnV{8SxQvP?Q#DI0Reg;z;zMeKa_J^e z?6^nxv$wi4*$o634;twP;O_5`nV`usc%z9+NcF78b2%qLb?fcY&YW8hEvu71ZWDT5 z3!Lm-zCxRB;k$X6BrL5d&0sq!s0VxZji#sSAZ|!%$Gu1C^X}h$qABw_ z%raXM#AY+~T82i$ERH1;{r#t~5|>zH-L2eMh02NhB^EaCuR@-&y@jeIt^pk)qq3SpH>eq|A>`3?(wF-n`+_8{+|wKDmFs6-nuhCe=z$ zY2fF}z4V1}v8twJVsH7UO}(^nRKEah0f@_*Gkn`Pc(nP5Ve!zf1qeaqojvtlgiCpj zoGS*rK5azUfLMS2NMYk>FDSghr$r~aU}aJla{fJ2P~wpEBFgmba}&G({%Ygx$W--z zL-2JfN6RjZlSXyrI^!1pMR+L$c~Je$h*zkIGFPj5zPS1wy!uiIM=3pPs~8#|@1O9u zh2ic}dXmd(TfzBa)%u0>H*xR3oFYQx<-KaY<{elGp$G+Y-d4hY@dQ>#Wp$U*x(bo= z3J$UiyAW4>!DI1Mn7g54K~H*_h{H|caq6QjyH^S|=?Xfxg9|!-z0+@Y@ zE!$ybzb_{vf*3|ZrmCCTi^$CFE`#t$Vsp9vD;%}27laqmauMMhCj+64EoR6vAY=(Q#zg1h%cmC_RNKPbhi&S{lW+i-mSP^$#|@^zY!Vr zfOs>LBA4SX`M;WnizU_hE0&Z8h$U4=;fKmyH5n&_u@#Aej?QWe$X(X55O}fCSpr#X ze{bAsIaG?t?KV`mdKmN>o=2Byn(eqZH$CBP^?#iN(cpe>aZ<#|$}&Rh&7&udqWrJ= zo@G+2z87?rZ*BcHFTRk$N5xnpZ1?!)ozl@e-yt^xS9|+j!V(7i2>#VI8KsTAK&2<& z{R4xR+8o=u)cW19>o{Viz{8W<1hrf*gzobY{kz%id(cnAv#+kd?tw|YD3Mo_|@q+K`fpq=}l%Bcr&5-i1pD;VoPHf~p|2V3HU=#4O zp}haehpkAD=cxyHBG$^3mc~HzVRFS4F2z9w<$pq-)r}UXFWZT7M78yvF}{2q`uU1K zfvPfw`st-4$~Cy2Nw>AKTx-5H0m++OhkuQ@T8`UyuHT_h+v;I7692+gq$lps^9Hpx z_qfXi5HzVWWb+pOg~+c%-x@M7q-{F!32^dEec1@D^o;?%*PF%iP5k_KAn8EA-@RM4 zJ!`WPk1VzvZKj_Cfuuq8#&g)B9MNYbZ#z7GT0L)`_VtrWf08~+?OQMfAo9u8feS<~ z@_4?c3aXB-k|@-yeZiOGqG*hL5%cx=&+$%h%i?YHxMIQlGp8G$jnwf+Uxn}|dHOwn zz(_u%$rvrx$FJwU*v^a%{w6Ju<$i7Po493L-Jc-R!TtL`HMOzfnYS2E8S?5L`%%pW zm@m=yo&M|*wiCQzK4X3-r;=+#l`+G4)_RTEN&XSfE#|hRBkS`^9{|_$2yidUYWIFp zpD3r4La?~TS@#xVW8EGO77ibjg|gTA4JfLkI(0wnFH60mMM%SQlpoi|G=#cmhYu}B zlHT^Tx#gj_M~RCEgfY!WOmCC{kr6# z*+~Wu$9n9SpWR!oZy($n*4pAT@yzEmGyd}P?pBqE>}&|OZcKJjz_f>DJVVjV7BZUH zHG96ySF`mE@ul9wy&_WEI+9G*Gurt}7q*Vq)w3t5xDnf$+31q-ZJOybV!`JTLtTk! z$7<}`l4oe^5VQFPuc$Su7N004kDVpBsps$^e4y^7p!xP1JBHAKk!25GyhM5xUp#@z z6Yu&M%X7Tzq8sz%lXY8x4_+l(5IoYzM&x6fwnfZNjr&lzGZ#wcQ59Cbgks79}Q&4KD!`%HlrDJi}r~ctv6(3p-J_ z?f{*^GDXe7H-o3+8MXts@^ ziw52HmR5H#$nYt*bO(aY#K*G8)(7trQJCN6WkVLR4PbAA*#NdGrJhf_S$^mV1J4V5 zG~*t^ORJuSI^5gyH_o{;GQ{Rov;aoeE`2xlXn+o=tEW9s=!am}Unmqo*vw@IRy4@j7CXyu(+z#^l`GeUjle&`1WiSe)}F8LkM=wh6WZ zjs5ivR3S4KPvtvyIbY^SC@nDZoF^1LR(^D|Jb%NO(}+EEro($+$u&I>L%fii<1^$& zJ)7#v`F-56w~gwbwK+Zd5V3pmstUK2k_$TM#<;%Qg8XLMWZ7P)c;Z`;nSo$4KexQ{ zL3rUsNg2NdSNeB~50V}BLoWIJUz7s&V(uzeN9mHQm%_u3m!8Ye4!MPrr!PEZGgZuP zm-&2Eyi|RtYT^!e3KzswyenBt)J9ZmYBioO`5LI_#cJ$hJ^>T+0s8()7tK zAD>No%yFe)u5ah@%td^;jb-n+JLHlonr=_VAY92OlDBGo}SG#yUHy<5Q}Z8}fEK)$f$V8AJBqB@Auv&)kmYkK{cb3;Ybmnc?J+p~eX>ROou z=-a`cP=cX{XU*kfF>ec)(8O?CLl-ho=F`6f4f}8|-TgaUsf)e}asb(8;HS@-9r<|s{2)ocC~n+G=2aoJpt9*XoF+4kOL-a)P`%%|WIM!=HhO&3S`&H%_U#^vY(38_PR)5L@mE7=*! z;}sO}zYGE!Snf)1|JuM(Wx zH=(PPvQ7&xsMIVy#PtLH?yf#xPyJj@I6gp_DwnxJZkIwH{Pi;dG2fH$cb6eGfeXyT zWrUHKz=q2^*EQPZBp5>TEw)2kUOO|ZEx-I=VWlu=$|_6AK>%6CU$AE+3x5|HhX*8> zXx@c76Byj@nA!afJ2_4WqJ9!Hll{iO6L>|iV{B~GOM0U_A^6E(Z_P-83@hrVC~c2# z!qxH`FvdmuQ$7+-g5tIvPKPJ{$!|}e-*bu?Yjl2h^~=ma)z9y+G<>(p7L2i~?Ke}w zu+6Ph2HGN;cj)(y8ol!W?qGC_Voc_M|TPJcMDeC2nlHKocWFuS>jAbbqPL z~L_aO@$-Q$D+0NZr~byjsCkUWwggL;e_0r6vWI+BCNAz;n49(nGqX(g@qmB zF8=lx!sS35S7_zThj_s`X#ThT@4Ne%0iMX8DHg2I`%>9{9*i4L#NwAY*P4iIZpm+b zQC?;cA^^{yU2Vf|c2E+y%dT)^kd zfRS$GU9S?cJHq+%^`qwLYdl`a^f~>Hk^LfavGWh@p9KfeE;$G|MzjJALJlN5*%aA% zfOk~v#Cd(QuhoH9$XJ92B&*vndM@u)_q2n9slP6jrT4`p(HASv z#F%(j_tmYgFw}kBm27D}q`LYRw#+*%QfAZsf)9UeFfv7lXkx(VK-E0oEHdSyFQ2)^ ztcNK*GV}yh{d3Rgqdm67&%O+gh9=(wGL(a&`i}NN)s3+%5 zqEIi+dqGU0m<2YYaKi<~nw^rimt{L8t?#>%Uf5`a8qTrldtts_q0WAKmpqw%@K?_! z8`Lu_=s$+|#e$jVA44Sn72yfgkKGay!Wn`87+-;|?FN}ha=_#Z+Izbe+O*J=Hg>fB z#}H6+0>5Ns1k8MPXX922@fbyE;~Wuh)0Y2*`s3Sxciz_d{AY&G@XV4Y|I4ahB`w?K zw>wbVK>z+WO+ZQjFbX&WGy%0;pgdX?flDY@dKu0I0iK&XHovj`64!jc3qyQU6L(*? zz6|aTUFtk18;ad^85)Z16B(DA9gX-OacyZMXxk}i$C-|f)}4~hj=G(auJ<=Jy7ZG_ zTok)LvJ^P;Z38*~N>8-uCiUfx#+%d>{!Q3j-?H8v+~sYbrkZZa{%s#wzzzR4e;nem zcm>GCmGbN6fG|)3f%1Qf0UjG>5qR>LMdz1v(3rW4SJr=gLLNz=V-%CLs#-^ri$I z(LJ`P&@fuiY#jj(beft1M9gO7etw-Mk(^VQN%*+(nnGd;c)ov|9r2|{ra^P>o-@*a zO?pnl@HP554gI&!=aUwlGcnZrA+;<4pSzyP+@qmzmbnLX<1e$L^UbkG9h6@et?82V zy^iG;Q7B+YLTmp@+2L+~iksPgE&}=8A+@P5eOQ6^Ayb4p2@QZr5r1$qa*-Vg0uk^!th^6IG0oV zDB!r828Te&qL3hU%Kt#1Mo&Ni(=0z;O#=fBt>DHxnN0wWR7c+akDqS@myQYQCpF9o z8Q^&nFGhXyPfYmz6MT4ycO{bS0?hps_T(#R=AR!Sv@L>^pud_!C_x)T{|RCTHviy- zs@laK>y7i73>LFerD{>Xrd;MQk1-D25il>7B2EOPToRfysbqZp6Kl=o);Vg83NrNI zXOc^1*wcSp<5@XMYK=-1(V`$}rm&0~|FGLNThldoNB2UmOWc2l`cL@z97;ey4y&%A z2SIYL_5O3LGWy+e6z;LJg@$QY5(bu9*GA!rp2FL zp=F}$Ui77HQ%|q@M0l`{Eo`1+m__h^=1b4q=$OC$w9PkaY|H&ODM8QBEkK&;6?!aSm+1Lo)A$-R9<}W>wdR(7g#lr5hfoFgk8Q-QaIX>P|EW>~t zv=d(@9|~Z~NLc@({edF=Z-LODJYWLS3~3uuAn69SQ z5e>ny`38@X=#n2KKmUo4PUq4w77Ds5{7jBM-<8A-ZR+bE#7)3Hxl00H+@N+Lj=_T&zSQl#$_4p+237Dd5 z{+7uaBs0NneFT)$&6!?}a&azez#CO4Zi615?yqiF27+ckugnS>cV92`E9%#$IS?2# z72)ImxhCP|ovsYO0~$+YoDA%b z^BZ_6YEmwwW4MVh>2_C$O6It))p$l|kIQllp;Xj!!FQQ-u{9RL%9_KE9z3X}nFpH@ z^@)WanEuC39sM?89}h_r&$ig}g%{GOz}PJ-peSDDqB29IlYoW6*Fk)(T8jrGV2(xx zT+_cVA$WN)B9Q7Jz_PRA!RqFUW5NDe%|5=YX1y)69{-8~rv&>i2AryJ<2XOb&5kXq z;OLDZw0rGx_(JH=T%}q|D}FN@WK8Bu1UMPy^^@WKO9Xfa5CIN5h?@965#SR4M1Y^; zH{m!o4b!N*U!wCgKW0D9hnnhG$vBKf(b34)2&@n8~@F*FRC-Rumf_Z-1zoygHSA0dvd-NIg7|^^a%6sISFk%F>SJg3%@PA1| z8mf7}j}V<0R9=HHqmrXr7*?Jutl=|z{F9#zcA*0 zA}5yZV$AJO=9@Wz3+|p8OrTjG2e>%4{(6m{N>oLvWF1jsfj@JZKERBkh^AAUR1pm_ zUKK&^PVn9q{y@_|nV`QX_9La;91}x+_8|?g7#c2>7y_4mSM>KeOfuDbBH~; zK`VgIRB6~_->V(=bs`xDgQ?(Ruov0OrR^j=OY*~Hx> zzZFhSE1-zh+9!!#>h2HM|~Dzh20phqHM`Y~AfC{vN-4*dXI7?Mx4Bo#-u zNiu8=afIM^j^=0oR-0DIP)?+^#6q}N3R0BBCi>`;@X&~K2rN-)_<_avKS^N^>SUts ztUs`b0utzenLmj!Vm_uL;cK5Y2SQ%Jvc!x2d-?=992cO2P6UjF=tKk_{Il69zoZ zgd2}+V%8D^!hifpe7p&3iG93*N8U}-E!Ya&clor|$#8HB?>gF%UXqjfcOCWrCTq70 zCpeh5FUY!kVxP|aXCq!@L_nVl^V;P`KfK@qRImI2ew}a34B@J9$MD4I&4nPq`Tk3~ z%!K@YP0@6|FnwWv*ud20$PX;T{z(eehui_BUi)MLOgO$Fq!Ymd<%#OWNwfe;Vk^_c z4PfhkI&l@k1klFE=3o7*0R8FaBr!BOUfPd?sJW=KOD-2Q69L`h4gu}7anFQ_G$4l!@tRJ@KcU^WGsIr4=)XDl#~0m5Ft z2zzYkM!Jr2aSf-!ekMM2jt0K<}Rhm{+8{ z8`0t(BKfWIj9Q5;!JoB(1h{YNHxrenh@KUZJTS`q%V!N&6m~c!-oPsRtvDl4 zpZ=*qbf86$Uh0B`{!&fvpZgL_HkbiGEgF(A{fx!jNU!U2#>I-t{mxA!Hf0lNQP ze$w6R0^y+w+IOT0273eFFW#?GFr4j+L4bG<#QZ44Rnyf&ifDmh%y17jnSiUUmY;yC zxtu?!mP$%(n&D^2>f+9=!v~*kG)=x2t$DIDTWr&HcwVIOyh!upPwWKz56+K4SL)t6 zd@pbz0A3T%U)KQRhHTLb+!Jd1puepJr4ejdgPUzC{6Pmk*e5}d&fNA5h!@8jC1IV_ z0O*fxXMnwAYW;^^qSp`s$_^cVgz-gh!}L@T+j%)*eYDro7)dE{iQD833dO@BRze`c zV69$3O(f{-4Se=c@hmASR2TQ4$ZtiJ)B2u7`*Xl3u@gc*am~r&idktb>G{t$AJ3uH zY~XBuB%aT-w+g;49l9dEE-QxYKZ&^>j474vJ|7%p*B&1n6yHBdn8lzia7XKJn~hTY z5BK}hVB}=r4Q}GvbO6Sn|D-?0FaIe&j04yrDEdU$qBrym2?p$V)M8pNsl;dyUvVmDih1k$Kv#J276!DK|=hHuG;Id{MiSCsS#zh~>u5C^dO zE#g*q&#)6f=rU5w-Re5-fUDc;+R+ByzEAj&zChB$6^oWm1`j~B=>UXT^+O`KqFXxm z@9iesa9e=Xhx7pkK>3$!*brdQisw447?S5Yli<6n6yps0++l-xfO>m8fO?GKv_q3X z|G=ga;8sKa)TYUW{8`w_30xU#2p_c8`>yB}{r8t~P8R&755RRNe~)u|(_b;>ep3-~ z0aUZ-| z-=!0PSI5hq-0<$TzX3W^Ilw}k9-e**kcId~U#BsNE|)a!gfMt=>Kt$@O+&s1x{n!c zBEj!+Axagti1LRB`=2eA{u+_-zqeRyI1Ut3E=TtNIh7S(E7f@VngFd5p>7LP$`K1qotze?0pEzajWQ12%{#ynD@@thnok59!%zT%s~;XMqSEdcFb^*fL~VG#E#h<47j)r3CChM0M~9l z6<#-QXs%AWWaHq^7cytBXYI1drEs>P$@?F{)mE&feB^T|lvjSqT>l-40ls*&r7&RM z9*ZxNaLDENHP62>dcQp=_N8zZ#q{d$FL-Y*9jXtd(JsKPuu9uX+t-LRrs=lfky*SP zlcfD3h;%cTXdNyiwO(Zpx8rdK?ffXTNp0G7A< zH$eZVcyb|dHZ-|MJh`$T+(B%dx`rc`v0w62RCPWhGkN}ts1%kj=6%5^KLb+PfK1U# zhC^n~U5t70;B5+@^~5%HE#>Wbcjc4fadP)i+83BL{awA%L8K>+EVBO=BklBhRf!1jWJ&wTnqG6ixknbn*nr z(N#?$V7o-arRhp1gZkem+II7nQx=`>y55Fs7Euj^o^x0f@NLJ+pU<2+$Y`2Fp@oj? za?r@czQ@KBmWf4kfvJXXsKjFk*&Sfqgb|O_quOXB7Ku8-`k<+r8b58IpNOWT?QHU{ z$3Fqh8w#10di^axa57GmPCF9&k&Y{(>RXAZsyjpaVZeQL_q3Yb_~|(*zt-JB1*Do? zrZE)qX*v>}`Y!y(Mnd&nL4*m})LQ2zyH!H;liKaiYe@G<1UdfkdatakhPOwL#E&+d zWamD(QKE3I#=8yzP9VPFbYPqP;H_`hQ2J+^df-&HEpRRjGBBA-W{h{B>T430RnC;T zr;~GY6V|Y1*>LZ@M)}Z0VMON8Qn$oOjYYTOBE$$Y-$zTI0tmSp3K@F9^vW8TF6ji$ z;f)X{FM>mW6QOM!oKY3?ocOa8fCU2U-$A6X?bMKl1 ze}Q%L)+FPewqBl(oI$>Uw54j9z^Y!G$sZ$UKPf zX4d>E%5=M|p6^Q+TP~2!Dc8ri)PNLuzNySW>iBObiFlhACZPgmD*`xE{>mi4$Nwo} z4a_tgUocl-0Pa0|IaW$C_WM}rg3Z-mhgi;b{ykP|G$ZJsU6U6w7*o} zGMftDG_`-e8^`_wm;7#u5~c^J1# zQKEEmmDWt(@7Art5WRc^r&iF=D(z2D^m*Z*@*H|LvOc*eKTwm@5NxdX8#ardUS042 zo8C%YTFue6>yPF)$68DbJPr@_E)4Fj)rgvkY3_L@+U%d)*zjnd+*(|!6;JILyPK>i zQa_A`e%JKD9SI1PS9r9()v#a6>n{9pg$L3+`AOofUd>`w$Urj{B9>rdLNIgCKNV|} zq^OQYjoDGJj+tQAy*W_N8D{1<8^};P3jHx&r~7rOkFM^b*Y>)p$9MLH@CBRO$AyHH zlEc$7(L2HDhBS1450XRl^K922UU{ac53A4WZ?o{F)bF2N@6sHv)~-QU=0Bb!xgVa) zDY`G)4lmnUrJh|MM#ut72C7e4Zg;gNolNfrtVX6Qn3Z~;pQsfiXd?qJA?UcdwLG`+ zxG}ZxHPtyaGRmj*T#fGbd6YmYQqSqJoIKc$WGRO(cP1=tmBI!1&$Dm?T{8tME~h8` zSBERTI@fZtUP-OjyK}cEH|99gAjiA?m4IrNu7yW0^`|S_#a2z+$X%2%Mq1snO$@ik@wizaY# zRSvazF&uqmH&W|qKNP*~k->bXX!zk`V1%U!zhXGD$Kq}gzpWlu3#eH_+cPF@4`{0U z=7WN|QJv>uXvt+O)TO+G$6d8$;S8de^W9m$bF0X)_;Z}+a=j~;r5B1QjpuALo>$}i zx9kIt>*GxeF+%GuswxAQ(!AvTYu=k64K&j6xfO#DSSPlVNz5_>er9@@+G zYT=sp=@ewe!{zmGuH$NP=N8gC^6YV1Qaio7lKu=jU7xe$^lDtPtc{#$>awpNS$V9i z-dwW^T;cJ&a>s8!ZaeF`uE67Yeq2FsD<(*N7>v9-U@4wkxmkGX9%xv>H}V3d;y;|t zA&CIXs<9e0{Dl z`n-1S#V%<0aN`x$<<*8o%k$K`c50snx?A;pEE~z-x;;GQ*`|B6V{L6@fA-C|)pfW# zcEZ2jWlu{zR3x+7Zrf}jWV?pH_eQv(j+wa-bDWXu!uNVctU%~k;W@4cZoV&j=8&*T zNKjgEg4)|`3%qo#eq#D*=}rN^fn;$bi_`YJER}uTlaOCma+Bw>dcI;+db@so%ya5# z$fa&kfEvZ!-CM!+bUkO7Vpy^3kItgi5QWC=`KYny@+n=*os7q$Yh{I|E@i^T<6g+4 zc)|1TQm2KZ=}9ER^S%k%5m76mDRhYPJ^Ai{W2e_GXV&6_=`W!PL}xbK;FOBoqG#v2 zIiTZ8E@NFZ;TI-s5(+JWLs@h!hgs(7Sduq8O8&F@5Y=2^>HVUop3AdHS(Z z`n1X-70_!@JY5UoUh*XCI&SUFzXLry06{s+TJP>2dY$ZA`$ul`PyDTt^OqmBHhBAX zeckWgJU+paRRl8LYPcs7UD-OHi`~h(Zi+GbxZf;z60JmZIU!emwgZ{1x_YoSweWxp zhi)@1su0wEHeyKj4#2p!Ru*5HH)u^GpD9BVBghKRUwC!m%n}nKG>aHPkx@WSv{|L= z9kMJepAmtGOVD-~yqpBD${*A`cX2yzb{5N~LiekS)OxpL?0izJgx_y&*4R+A=;0@Y zGCe*V3FQ;amOENa)H#2AIytxit{0!D3M6)-E91vcq`ULYiJ`+WK8~#vGR5psah&u# z#Ma%u!zIk;Bld9Mm`Iwa^~peGqTtSz^tQv9cuuPa=gHk+!}Mnrtw_ZQZt&dL5aN5t zb%HSSIg2;B_?e@}6Q)XnYa={{k^p6cIsoob6Z?xg2U>KfCa1%3oP$?da7mL`2bD{Q z;^*0iH2k# z>y&%Kv}}WbmnWA9M`7tB`pi=3TNJ{LF|HKI1SN1E*ny%Y@+e$@%$iF?JA8V+hD$HO zMfu7)&9xaPkk!zFLy2gmr>>?L%;9OtJ#GUYM4rq70VLqF6j7;eIi*V!N2xg z-P^?~Hq4D`nc?6iF#Lo&_U4di+jXu8m1n}_?v&{;k%>i0WB|WS%IMsOOree8R>rk9 zW8aDYINQvcTZYWFlw;gvkByBLW;ga@TFJg|W&%ZE8h=i_DsCw2a7|7-U~{^3lmYE7 zdm@`vcm+Em8@%|qa7Cn?%K3e#CXNxFf*0hMeIXQYDQLHE8$1Kw!{(L65>;|3B~VH* zKdn0B@Fv@gd%@Gsf)_lF1#^cGN|03_^qJ&-J?ImnoRi_sN6{Sq$VKtB6P3$URDFO6 zR^lTUgNKXwnBEhejmW9#9`%oF5GZx*at*gXa*SQDTrM(doa0?2g1orG_jfdD#U)Hq z99#&?vx&AzFCp)JIB7>D3AuUEVLzOnkH?6^uh-QP9M%(%_%7}ZRDGq3j<4uyoPlRH`%eV-bS=1 zK{7;B}nMwv6mIuT# zRZ~rvGfegL%B7*`M@MgA;uf$Ov2Q0cj`W$t2`2hSnYN--ny*L=pqT(ZGh z#umak`uVrDmo_*;B^ttSs#4Iv16L7vi|r5uIoLsY%tBuFgcnOjadIDW8K-U?uZ~Y> ziVI1$?^x6L8YnCyA`75xp(aFEpBV09$MU^KF7!G)$2nwau4$a}$8tJX2TvIcE@Sk2 zl~E8PHe_*w^60N-ck|Aw&*Bv#V^f@xZYEfA&(y@S*eWP`o!scI8V2@#i}cx*^LKk^ z_dj*`t(AIq7;nM`Xrc`0RHMlly8)l5K!iW#>aoIiY2#CvLE$BBUZB9)s%` z4~;9+E+XiL9-f}9ju%~tIm$miq-%{?Ya+K#72o@JaL(H3^I*giiyq<k~HS9IAT#!O6nlqunHAB=4=A9`#UFl|%QLLfhR$;Z3OD0o;n(jL7Yf%0vlQw7; zA!&L+=cP2aj1x9$|~fPP42LC)URt|VO-TIxl}fzqoj{3 z_^za=n>_B)iPTnI#(OrzV<;7=YwT*+6PetI#Ld^!^D5{xsyRhP71>vgZl33AQt0ETL z;pps`)X3(k=oPqP%jUYcWG(qt#^Y6Vf239q`Eu^9Gp4t<^U>r=_14B=`W+2FAFa+7 zC0x2z{fc0DQY3n_`diHk*R>DERIYH;ler^1jZd!p-{?tM%%=I=N`x1RiDK1^>y9W$ z!np0nM6**1;@3;D*A}{AskOfa}R4} zBZfl@iWwL#l_lggA-hCs-sac85gOy0%R=y%@=lG&gD>VgH?OC1O`7+TTpYXPQCeo$g>Kspm!O=c*|zZw(2sj-t7C(sV<7YpJUIb6 zl7THdXfa%Qp=$OcWCvq@;hMa}M)E=0Ud6H>QW}m?4N8;NgpA!XgL-2ixLzMvkXlGr zP@%j11YnQ)?jemKinf_cu2DpkeApqzqGef7yNj9ZTe=Q#LQ=9lQW}G)F1<7n)7n|V z$&v7C?mdf8+jvH_5qNGvc;(3{m${?LCIh_v5uq-J9|(17Mtq{(wbaN-^O*}HT9t9J zI@q$FYV1aQFYkg(h?@u$FE8A&@u&Pd>)-W+pW_yG)}QiU;)G%AEJA|>F<3h5Z931` zFjy=@z+WDZwEqPI*xNt7ZHWKFw9L3@ueTAyd7(MQ&Byyq3-yNwfJ|rU_ZKq4b}yaf zV);qHU$Ef>`Yu5DF2#Q=0pYE`)JY}!_MdM@XCb++3q|zOF z+ko=f|B}{oD+!~sSnnb9>hB78`s_6PtUrx-4o^7TWn(EuG+PX!>-^wH0{0ea+{nB& zeR>O4%?amWbrJ~(x-V~6m_cdW>lC0UpOeY;hQW^w%&I>Td5@mW^xl7nJXA>TjjHYF z=w%I7<0xE^pll6cPB1PS^vQ@b?-M4SmbU|F3#20pXHkQ*eH!j!B*h7g)>QB{dgmdP3QHMUs5^XRp-vuBseGaY7a+d z)hoqkh#v!l$--{r@35|m%tRARDEF9P+2Z1+iZ`-$#?liUk_!W-*usRlu`+Df!Lzu2LgM_ znp@eIPeWS^CXx>F14Wpsc|HZVto$V7yh(O>Wv27d5X8K1w)9r_{5e>gHw>YI9 zo4CUi*wJ`)MB;21)%_&jKr3FvxB~Z_&E{3KkV`C&H8Xo2WX|YntpalA!Fulfpq=K( z2~9%>bdBShF-rgMxM~gFE1y~gX%!}Ba?UlGaEibpGjP;b87G(DJiRD!H-N~;_ z^GTg~ZapLC&Ql*eB2hP8SlwNESQO47n9%a7@M7Z2OqV{;J9?4(Ed;OPk0G51l?QSfgw&tz1cz}N7Ie`8C^kr8a)}`jngMhOywsTa**&ePHLf4o1Og zU&X(^+G}@58jj<*N$>oG9zSqyFnF@hE#nGP=al)nJqauiA}r;WQtFHFygZaW^|!h9 zOYrx|;$yejQ2g^24(*JP@b|c;2_3RLGuC_*w(DJZ-3v4ce%q-@*c751leCDte3+F> zYFsoU>;#mUF&RGs3TopU1XXgI?dPpkB-6#vdt9*j$_e|~$s_WhNbXmr1lqY4$qsI4;F5!XK3%WyKboZmJZL_$`V7zERJ7s`3}oD4d%44VIo+4IXROGdy%CrjFxpGIyaP$spIVi`_`t(D_Kw^PKz0Yb zPucZ;Ky|DkChFd~e!Oa|ZK7>$VJnr;T2%MC4zPSa&G7M*f0#K(r(GDbQftc)s{l2* zkUBk|9crCD9zD@oI612|HO22+np|vKHhJY=pH$$RebF}YqJ-e7jZG*k=oPxC4{CM4 z>Vn#8(~@bu74sbP%1^bcb9aJH!hf7(d^{i84ny0?;0Y^`-#Xu4u-Y~9vROJh^3t+A zGTFIaX=!N~8L^tjMYOntT5)x762f|3Yi)5hY<)i4*7CgFwmMrHI!}3adDvW8S$;m6 z(n@f=7U#Mnk|-Rp(rS4)ZTn)fbdG+gRJXFM-gau%=I&5^Rb`ufbF*^C*Z&ZHe|79t zHZpj$N%`y8((L|wOLI#;&Z!C=k8capL7omRyqn3aSeGW&)%XgJM)%kScFKt;i%xZQ zEZ(I+AC`#-1?-GV+E}kw-cVeq`WJ>P>lVD#E9RtJDP@@_dCONI3k<6+XY8H@x@GPx z#IU*%U@>yYc-Q_16mWWFP5&mWuC4@3r`e-XJ@a0+It%_M5i#}NaM0ud`}2jadpURP z`P~O@#5{ok#7k~YymxUTr+#+J!F=Zu{;i&KM^`J~P{VzI>*mcaTV`bgmXj4>4`*SA zxI=SD1`U#P6Y`9fL_S#v#%5C}jYV*E>*OI2lV6IQ^4qdT%FYKi#u^IrC8alL)-#hS z=`iGld{Hn67iG{!&tcSv{>w+ex2xKNz<=JgOL;@olmh%P!dkqE+MgaB|ERn|bD;R4Q z0utC~FtAr(5baRuMqh#ARotu1KM=5&qJtNhHb3qQ$$zweWmbcU!LrYzkrBm46t_(W zQh&;(Ab0qs=bROxdo6PdYMNB5D)r0c>;}G@?~hUH=9zPMKZ1VRPnp+LU-7)=mR!^8 z^Cs0#cDE<;&JHD_oeD-mzD_I@cR8v5th!K#+$TZx%|erAI5hermzIqQkl9&2;;et zfFHi2ac-Y}SHoId2#HsTe~Sb+iPV}PR!+k9-lSSHjH%PLHoz_`L|J2JY1(pJ`GR%$ zT()ubH2a-tg)jYlqspd@i^!PB$B?5%#r?Ug@6KZ;9ENV<`8{+*ks5ON_&xYEP5IpO zjQjj)3ERD%$oL`|**U)gwr~%@7a)ZCEqYZ@Kf)iPB`lU;jvG<$g>>P3?Se+gFgCz) zZq!V%b1&T%C5f9wlQe697UK;XB*UsDgGo_zt8f&?=!)s9{J5wCCh==H&Aj(%(k7-1 zp6v{e3sWjvp8`$ltZ&u4iqc`>R}g0}1Z#v82Pg0GT)%3PcjIEOCKdii`_KOD4nZ)K zle>rUIk&TWALPZtN%35pWo*CH+o#d?%BAlOf6w(*n^6g?^Ox zNa9;Ve6$B;&iIZ813HD?73Jte$wjP8j)O)>c51y!`4&x*W9D~-FPM@nd2zaYE&N!Q zit~w>Ir${J3F*Zr;RDWS0x?zDm~*5nd1G^|R)TqJMW56^gfCSWw20XJstjhnTz5wk zgc7xh&SO(Hn&)vg1KiR0exF-Xm`!>rUo{|YbULwFLN%QHjKJC8{RZnqfbOFXZM)_U z+;M_#c}((|2BN_l%9gOt|DOUEhZhji3+@-1#Ut`p4c1Kz2pgTSeg1Qqi^1|<6pjcOBQevfLo;N zeg%jC+$zElcYKl3eaD4@_{vhQD&`K{k*5KxQ%Q&9;?NEoSTxLm4#Tk9XKYrrv$p%_Lv zEL#6C{@p6cCxe*!nPv7!`k50Hh=5WB7%w~l+<)5jkEFLIpCQ?EUYcs=7XdhY5TJBC zH&87lpjs#|ttSYyp5y)r-b}`jK^NK--wTG!^EH zlH))lx!}Vvj0StK^?hC#A_huJz+W&q{;>t#UZQW_%ynCTSLU~VOxeF1_y4KHd9?S@ zO@_-{Iw{spSF|^hcw$K9H$r$`1|&k`dAF^B4j?)!t7?u?o<441K!gx7ay~<-r|1U!4{}H&0Xp-mJRIa;8-;5zyt50 zJlXJ0KoM`xhsJU4>+rUQdj5q7(t7(W$GX!BGK7U3B&$QAJLa}&2H$u33hvl0`Bmlh z>X17wNjr%}Mz+ttAGAJn>>4LvQf@^1uNE=%&84S!Dk2ie)I&!U@~1fD z_3HMrmlOZ!i5LK}I>$Q6NT?;G4Kca=Dk zwkc!LS`7tRGJ$vMCZPijGh2a^^k(=QByx!1tF3Bm&1tMoH?7ko%?TxMSJFa*v-9kV z+@xyWUJjT>%9zm=Cs&ts@2fE-Z|f!X$aQo<-z~8_KQHSyc${jMhsssDhzR_YuFl*b z4a#FeNEU6fyVF7$a8bBvk7b#s50~uPhbF#`AWb$l8jduY2?jB0T6Qxh&- zBi@Q~G4!Yo*|bUM%MroTYnG|L!n>q#n_j#UF7dBy7Ll5kX)KjJnr4}^z>pCV;|7J& zBa~Ff>q(y^7HT9mtF74SsDlFbCk0}KGBBs+?sceKca}t_$<(tWi9CEHT<0O?DBm}W zhmHorHsnbWnZ^0BPzQdtv!y;QWoNJ@A&}rAgfEv3e8+UDjDW{-zSz})JcIHDfiT^F z_E)fzLQT0?$-m#1#?!sIO#2OzPNLg5zU0(G$*w!qso5ae>%0{ITI9XKN#Y45)pNm= zpJO`uW>2mV!8s1XwtI{QK~xtJf`1KSSR$CQZOmud{Xk)6xFWaQXRIH%|6$0rzYvc841->^v$oA z@*T(VOn0p2CM7Xy&CqsBnp$NnM_0c{T1%0+t7L^qGx%UX8x@G23p-hrYAt8Gvtm!1 zy5+9&BQ-fofzmlSl21Msd<-w0fge5I;53#T!GxI2R`(3Fha<_W&J+tbWUgF8BeAZG zENE9v+3PlTF#E-V%5j$1{}MA253l3S*!5MlVC2RGuWBZLkoxYV`j#9$nP`Z>I7=$? zb~c$i3HXeJX-fiMI%TBLsN+xo;_CJVUg)Y4k6i&SCfKk zss83E7`KcA{_6S-mxAxDqRDY8{}mI_>i9-X4YyKs$FYJYX7A;b70qY0 zJSuJGV<*RKl;`qA;OJ=Nhy+52g=nRytN zB?eZ?Gxd{2@)BBdL#ajIV=aG>8?98gV46PMdjcLMYN;zdDQgU^NI`AbI_OEF^0`NQV-ik9qtaD6A8Zmm5M=<7ApKN1HH(O=;Fwl&R(v~YHDGo4*HDOSx)&}>uS$&0VtcLqnF)63Y z8*Jlq?e{OZj@sbf@DR+Se(1ln!P8icAhC_Bqj1eLhrkUhy_xd>S~09j@7kA)n;F6j zPmaG>{U?(7&5P_zC(}>}Y~y=psJ~eQ^O-jI%X|jN`K^;TJd?)BS9p71KKo1I)>usQ z%tIR07xq~0-~MCW4w+o(1_5YP^f%Xk7}+c0|5x8pVpKzv2S(NmUnYs+|s=AOt><+d1b|OY6ScSiM8OERwemz#_KeD$p9gs!b-E% z^>qE}sP)nB3|}pLzI?&sp26!LkpSA`0mbXtC}Vqjrs8PYYdg~GuHgCJi`8kZ1?Ck3 G*#80yPtY#_ literal 0 HcmV?d00001 diff --git a/scripts/validation/aveva-history-801/README.md b/scripts/validation/aveva-history-801/README.md index 83ac2286a..140b2a926 100644 --- a/scripts/validation/aveva-history-801/README.md +++ b/scripts/validation/aveva-history-801/README.md @@ -8,6 +8,9 @@ are set. It is read-only: the Gateway backend has no updater, and the only Histo `HISTGW_WRITE_SANDBOX_TAG` and is refused by the server before any backend call. Record of the 2026-11-12 run: [`docs/plans/evidence/2026-11-10-opcua2/aveva-history-801.md`](../../../docs/plans/evidence/2026-11-10-opcua2/aveva-history-801.md). +The #886 cases (`events886.*`) wire Galaxy native conditions for `OTOPCUA_AVEVA_GATE_GALAXY_REFS` (default +`DevWinPlatform.CPULoadHiAlarm,Left_001.FRR_Warning`) to one equipment folder, `EQ-886-ZipsenLeft`, that is named after +none of their objects. ## Run (SDK 2, this tree) diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Commons/OpcUa/DeferredAddressSpaceSink.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Commons/OpcUa/DeferredAddressSpaceSink.cs index 4ee8e917a..21be4c4ac 100644 --- a/src/Core/ZB.MOM.WW.OtOpcUa.Commons/OpcUa/DeferredAddressSpaceSink.cs +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Commons/OpcUa/DeferredAddressSpaceSink.cs @@ -59,6 +59,12 @@ public sealed class DeferredAddressSpaceSink : IOpcUaAddressSpaceSink, ISurgical public void WireAlarmNotifiers(string alarmNodeId, AddressSpaceRealm alarmRealm, IReadOnlyList notifierFolderNodeIds, AddressSpaceRealm notifierFolderRealm) => _inner.WireAlarmNotifiers(alarmNodeId, alarmRealm, notifierFolderNodeIds, notifierFolderRealm); + /// + // Forwarded for the same reason as WireAlarmNotifiers: the interface default is a no-op, so a missing forward would + // leave every driver-role host's folders without their Galaxy event-history sources (#886). + public void BindNativeAlarmHistory(string alarmNodeId, AddressSpaceRealm alarmRealm, string alarmReference) + => _inner.BindNativeAlarmHistory(alarmNodeId, alarmRealm, alarmReference); + /// public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName, AddressSpaceRealm realm) => _inner.EnsureFolder(folderNodeId, parentNodeId, displayName, realm); diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Commons/OpcUa/IOpcUaAddressSpaceSink.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Commons/OpcUa/IOpcUaAddressSpaceSink.cs index 7c69c3147..918926023 100644 --- a/src/Core/ZB.MOM.WW.OtOpcUa.Commons/OpcUa/IOpcUaAddressSpaceSink.cs +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Commons/OpcUa/IOpcUaAddressSpaceSink.cs @@ -134,6 +134,21 @@ public interface IOpcUaAddressSpaceSink void WireAlarmNotifiers(string alarmNodeId, AddressSpaceRealm alarmRealm, IReadOnlyList notifierFolderNodeIds, AddressSpaceRealm notifierFolderRealm); + /// + /// Gitea #886 — record the upstream (AVEVA) reference of an already-materialised NATIVE alarm condition: the + /// Galaxy alarm reference (tag_name.AlarmAttribute) the backing raw tag dials. AVEVA stores that alarm's + /// history, and its object's own events, under the reference's object (tag_name), so every folder the + /// condition fires on live (its parent and each folder wired) also reads that + /// object's event history, and an AVEVA history row naming this reference resolves its SourceNode and + /// ConditionName to this condition. Call it after ; idempotent; a + /// condition that is not materialised is a no-op. A rebuild or the condition's removal forgets the reference. + /// The default does nothing (a sink with no event history). + /// + /// The native alarm condition's node id (== the backing tag's RawPath). + /// The namespace realm the condition lives in (Raw for native alarms). + /// The Galaxy alarm reference, e.g. Oven_007.TempHiAlarm. + void BindNativeAlarmHistory(string alarmNodeId, AddressSpaceRealm alarmRealm, string alarmReference) { } + /// /// Ensure a folder node exists under the given parent. Used by AddressSpaceApplier to /// materialise the UNS Area/Line/Equipment hierarchy in the address space. When @@ -288,6 +303,9 @@ public sealed class NullOpcUaAddressSpaceSink : IOpcUaAddressSpaceSink /// public void WireAlarmNotifiers(string alarmNodeId, AddressSpaceRealm alarmRealm, IReadOnlyList notifierFolderNodeIds, AddressSpaceRealm notifierFolderRealm) { } + /// + public void BindNativeAlarmHistory(string alarmNodeId, AddressSpaceRealm alarmRealm, string alarmReference) { } + /// public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName, AddressSpaceRealm realm) { } diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/GalaxyAlarmPriority.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/GalaxyAlarmPriority.cs new file mode 100644 index 000000000..b475e14dd --- /dev/null +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/GalaxyAlarmPriority.cs @@ -0,0 +1,37 @@ +namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions; + +/// +/// The Galaxy alarm priority → OPC UA Part 9 severity mapping, shared by live alarms (the Galaxy driver's +/// MxAccessSeverityMapper) and AVEVA alarm history (priority, Gitea #886) so the two agree. +/// +/// +/// The four-bucket ladder (250 / 500 / 750 / 1000 — Low / Medium / High / Critical) is the one v1's +/// GalaxyAlarmTracker exposed; out-of-range inputs clamp into the nearest bucket. ⚠️ It treats a higher number +/// as more severe, while AVEVA ranks priority 1 as the most urgent: lmxopcua#887 tracks that. Change it here only, so +/// live and history move together. +/// +public static class GalaxyAlarmPriority +{ + /// OPC UA severity of the Low bucket (raw < 250). + public const int OpcUaSeverityLow = 250; + + /// OPC UA severity of the Medium bucket (raw 250–499). + public const int OpcUaSeverityMedium = 500; + + /// OPC UA severity of the High bucket (raw 500–749). + public const int OpcUaSeverityHigh = 750; + + /// OPC UA severity of the Critical bucket (raw 750 and above). + public const int OpcUaSeverityCritical = 1000; + + /// Maps a raw Galaxy priority onto the bucket and its OPC UA severity. + /// The raw value (0–999; clamped). + /// The bucket and its OPC UA Part 9 numeric severity. + public static (AlarmSeverity Bucket, int OpcUaSeverity) Map(int rawPriority) => rawPriority switch + { + < 250 => (AlarmSeverity.Low, OpcUaSeverityLow), + < 500 => (AlarmSeverity.Medium, OpcUaSeverityMedium), + < 750 => (AlarmSeverity.High, OpcUaSeverityHigh), + _ => (AlarmSeverity.Critical, OpcUaSeverityCritical), + }; +} diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/GalaxyAttributeRef.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/GalaxyAttributeRef.cs new file mode 100644 index 000000000..db8a02ed9 --- /dev/null +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/GalaxyAttributeRef.cs @@ -0,0 +1,75 @@ +using System.Text.Json; + +namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions; + +/// +/// The Galaxy () tag reference: the attributeRef a Galaxy raw tag's +/// TagConfig carries, tag_name.AttributeName, which the driver dials through MXAccess. One parser for the +/// driver (which dials it) and the address-space composer (which names the AVEVA event source it implies, Gitea #886), +/// so the two can never disagree about which tags carry one. +/// +public static class GalaxyAttributeRef +{ + /// The TagConfig key (camelCase; matched case-insensitively). + public const string Key = "attributeRef"; + + /// + /// Read the Galaxy dialled address from a tag's TagConfig JSON — the camelCase attributeRef key (the v3 + /// rename of the pre-v3 PascalCase FullName). Case-insensitive on the property name for authoring tolerance; + /// returns null on an absent/blank/non-string value or an unparseable blob. + /// + /// The tag's TagConfig JSON. + /// The attributeRef, or null. + public static string? TryRead(string? tagConfigJson) + { + if (string.IsNullOrWhiteSpace(tagConfigJson)) return null; + try + { + using var doc = JsonDocument.Parse(tagConfigJson); + if (doc.RootElement.ValueKind != JsonValueKind.Object) return null; + foreach (var prop in doc.RootElement.EnumerateObject()) + { + if (!prop.NameEquals(Key) && !string.Equals(prop.Name, Key, StringComparison.OrdinalIgnoreCase)) continue; + if (prop.Value.ValueKind != JsonValueKind.String) return null; + var v = prop.Value.GetString(); + return string.IsNullOrWhiteSpace(v) ? null : v; + } + } + catch (JsonException) + { + // Malformed blob: no dialable address. + } + + return null; + } + + /// + /// The AVEVA alarm reference of a native-alarm tag, or null: the tag's attributeRef when its + /// driver is the Galaxy driver, otherwise null (no other driver's alarms are stored by AVEVA under a Galaxy name). + /// + /// The owning driver's DriverType. + /// The tag's TagConfig JSON. + /// The Galaxy alarm reference (tag_name.AlarmAttribute), or null. + public static string? AlarmReferenceOf(string? driverType, string? tagConfigJson) => + string.Equals(driverType, DriverTypeNames.Galaxy, StringComparison.Ordinal) ? TryRead(tagConfigJson) : null; + + /// + /// The Galaxy object (tag_name) a reference belongs to: the text before the first ., or the whole + /// reference when it has none. This is AVEVA's Source_Object for the object's events and its attributes' + /// alarms. Null for a blank reference or one that starts with a .. + /// + /// A Galaxy reference, e.g. Oven_007.TempHiAlarm. + /// The object name, e.g. Oven_007. + public static string? ObjectNameOf(string? reference) + { + if (string.IsNullOrWhiteSpace(reference)) return null; + var trimmed = reference.Trim(); + var dot = trimmed.IndexOf('.'); + return dot switch + { + < 0 => trimmed, + 0 => null, + _ => trimmed[..dot], + }; + } +} diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/Historian/HistoryReadContracts.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/Historian/HistoryReadContracts.cs index 047d5b83d..ff492fd37 100644 --- a/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/Historian/HistoryReadContracts.cs +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/Historian/HistoryReadContracts.cs @@ -128,6 +128,11 @@ public sealed class HistoryReadLimitExceededException : InvalidOperationExceptio /// The stored alarm id (a native alarm's RawPath / a scripted alarm's id) — what the server resolves the event's /// SourceNode (the condition node) from, when that condition is materialised. /// +/// +/// The upstream system's own reference to the alarm condition, when the backend has one — AVEVA's +/// source_conditionvariable (tag_name.AlarmAttribute) on a Galaxy alarm row. The server resolves it to the +/// native condition that dials the same reference, among those that fire on the folder being read (Gitea #886). +/// public sealed record HistoricalEvent( string EventId, string? SourceName, @@ -137,7 +142,8 @@ public sealed record HistoricalEvent( ushort Severity, string? EventTypeName = null, string? ConditionName = null, - string? AlarmId = null); + string? AlarmId = null, + string? ConditionReference = null); /// Result of a call. /// Events ordered by EventTimeUtc in the read's direction — ascending, or descending for a reverse read. diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/GalaxyDriver.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/GalaxyDriver.cs index a670da70a..277f73a0e 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/GalaxyDriver.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/GalaxyDriver.cs @@ -454,31 +454,7 @@ public sealed class GalaxyDriver /// on the property name for authoring tolerance; returns null on an absent/blank/non-string value /// or unparseable blob (the entry is then skipped, never dialled). /// - private static string? TryReadAttributeRef(string tagConfigJson) - { - if (string.IsNullOrWhiteSpace(tagConfigJson)) return null; - try - { - using var doc = JsonDocument.Parse(tagConfigJson); - if (doc.RootElement.ValueKind != JsonValueKind.Object) return null; - foreach (var prop in doc.RootElement.EnumerateObject()) - { - if (!prop.NameEquals("attributeRef") - && !string.Equals(prop.Name, "attributeRef", StringComparison.OrdinalIgnoreCase)) - { - continue; - } - if (prop.Value.ValueKind != JsonValueKind.String) return null; - var v = prop.Value.GetString(); - return string.IsNullOrWhiteSpace(v) ? null : v; - } - } - catch (JsonException) - { - // Malformed blob — treat as un-dialable; BuildRawTagTables logs the skip. - } - return null; - } + private static string? TryReadAttributeRef(string tagConfigJson) => GalaxyAttributeRef.TryRead(tagConfigJson); /// RawPath → Galaxy attributeRef for MXAccess dialling; identity on a miss. private string ToAttributeRef(string rawPath) => diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/Runtime/MxAccessSeverityMapper.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/Runtime/MxAccessSeverityMapper.cs index 4c5b05f98..ace46a375 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/Runtime/MxAccessSeverityMapper.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/Runtime/MxAccessSeverityMapper.cs @@ -24,13 +24,13 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Runtime; internal static class MxAccessSeverityMapper { /// OPC UA Part 9 numeric severity for the Low bucket (0-249 MxAccess). - public const int OpcUaSeverityLow = 250; + public const int OpcUaSeverityLow = GalaxyAlarmPriority.OpcUaSeverityLow; /// OPC UA Part 9 numeric severity for the Medium bucket (250-499 MxAccess). - public const int OpcUaSeverityMedium = 500; + public const int OpcUaSeverityMedium = GalaxyAlarmPriority.OpcUaSeverityMedium; /// OPC UA Part 9 numeric severity for the High bucket (500-749 MxAccess). - public const int OpcUaSeverityHigh = 750; + public const int OpcUaSeverityHigh = GalaxyAlarmPriority.OpcUaSeverityHigh; /// OPC UA Part 9 numeric severity for the Critical bucket (750+ MxAccess). - public const int OpcUaSeverityCritical = 1000; + public const int OpcUaSeverityCritical = GalaxyAlarmPriority.OpcUaSeverityCritical; /// /// Translate a raw MXAccess severity into the four-bucket @@ -38,20 +38,6 @@ internal static class MxAccessSeverityMapper /// /// The raw MXAccess severity value (0-999 range, clamped if out of range). /// The mapped four-bucket and its OPC UA Part 9 numeric severity. - public static (AlarmSeverity Bucket, int OpcUaSeverity) Map(int rawMxAccessSeverity) - { - if (rawMxAccessSeverity < 250) - { - return (AlarmSeverity.Low, OpcUaSeverityLow); - } - if (rawMxAccessSeverity < 500) - { - return (AlarmSeverity.Medium, OpcUaSeverityMedium); - } - if (rawMxAccessSeverity < 750) - { - return (AlarmSeverity.High, OpcUaSeverityHigh); - } - return (AlarmSeverity.Critical, OpcUaSeverityCritical); - } + // One table for live and history (Gitea #886): GalaxyAlarmPriority, which AVEVA history rows map their priority through. + public static (AlarmSeverity Bucket, int OpcUaSeverity) Map(int rawMxAccessSeverity) => GalaxyAlarmPriority.Map(rawMxAccessSeverity); } diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway/Mapping/EventMapper.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway/Mapping/EventMapper.cs index facbe5f8f..c9840ac7d 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway/Mapping/EventMapper.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway/Mapping/EventMapper.cs @@ -8,6 +8,20 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Mapping; /// Maps a gateway wire event () onto the driver-agnostic /// consumed by the Server's HistoryReadEvents path. /// +/// +/// +/// Property names are matched case-insensitively (Gitea #886). AVEVA stores every event property under a lower-case +/// key: its own A&E rows carry priority, alarm_id, source_conditionvariable, and OtOpcUa's own +/// events, written as AlarmName / AlarmId / Severity / Message, come back as +/// alarmname / alarmid / severity / message. +/// +/// +/// Severity: an AVEVA row's priority maps through , the table live Galaxy alarms +/// use, so a row and its live event agree. AVEVA's own severity on such a row (1–4, 0 for a system event) is not on +/// the OPC UA scale and is ignored. A row with no priority (OtOpcUa's own) takes severity, clamped to +/// 1–1000. +/// +/// internal static class EventMapper { /// OPC UA severity range (Part 9): 1 (lowest) … 1000 (highest). @@ -19,13 +33,16 @@ internal static class EventMapper /// The driver-agnostic historical event. public static HistoricalEvent ToHistoricalEvent(HistorianEvent historianEvent) { + var properties = historianEvent.Properties; + // Message: prefer the "Message" property, else fall back to the event Type (best-effort // render); never null-crash on a missing property. - string? message; - if (historianEvent.Properties.TryGetValue("Message", out var m) && !string.IsNullOrEmpty(m)) - message = m; - else - message = string.IsNullOrEmpty(historianEvent.Type) ? null : historianEvent.Type; + var message = Property(properties, "Message") + ?? (string.IsNullOrEmpty(historianEvent.Type) ? null : historianEvent.Type); + + // AVEVA's reference to the alarm condition (tag_name.AlarmAttribute); the server resolves it to the native + // condition that dials it, when one fires on the folder being read. + var conditionReference = Property(properties, "source_conditionvariable"); return new HistoricalEvent( EventId: historianEvent.Id, @@ -33,15 +50,32 @@ internal static class EventMapper EventTimeUtc: historianEvent.EventTime?.ToDateTime() ?? default, // Utc kind ReceivedTimeUtc: historianEvent.ReceivedTime?.ToDateTime() ?? default, // Utc kind Message: message, - Severity: ParseSeverity(historianEvent.Properties), + Severity: ParseSeverity(properties), // What GatewayAlarmHistorianWriter sent (AlarmEventMapper): Type = AlarmTypeName, AlarmName / AlarmId properties. EventTypeName: string.IsNullOrEmpty(historianEvent.Type) ? null : historianEvent.Type, - ConditionName: Property(historianEvent.Properties, "AlarmName"), - AlarmId: Property(historianEvent.Properties, "AlarmId")); + ConditionName: Property(properties, "AlarmName") ?? AttributeOf(conditionReference), + AlarmId: Property(properties, "AlarmId") ?? Property(properties, "alarm_id"), + ConditionReference: conditionReference); } - private static string? Property(IDictionary properties, string name) => - properties.TryGetValue(name, out var v) && !string.IsNullOrEmpty(v) ? v : null; + /// A property by name, exact first, else case-insensitively; null when absent or empty. + private static string? Property(IDictionary properties, string name) + { + if (properties.TryGetValue(name, out var exact)) return string.IsNullOrEmpty(exact) ? null : exact; + foreach (var (key, value) in properties) + if (string.Equals(key, name, StringComparison.OrdinalIgnoreCase)) + return string.IsNullOrEmpty(value) ? null : value; + return null; + } + + /// The attribute part of a Galaxy reference (Oven_007.TempHiAlarm ⇒ TempHiAlarm), or the whole + /// reference when it has no object prefix. + private static string? AttributeOf(string? reference) + { + if (reference is null) return null; + var dot = reference.IndexOf('.'); + return dot > 0 && dot < reference.Length - 1 ? reference[(dot + 1)..] : reference; + } /// Maps a batch of gateway events to historical events, in order. /// The gateway wire events. @@ -55,20 +89,20 @@ internal static class EventMapper } /// - /// Parses an OPC UA severity from the "Severity" property (else "Priority"), clamped to - /// [1, 1000]. Missing or unparseable values default to the minimum severity (1). + /// The OPC UA severity: an AVEVA row's priority through the live Galaxy table, else the Severity property + /// clamped to [1, 1000]. Missing or unparseable values default to the minimum severity (1). /// private static ushort ParseSeverity(IDictionary properties) { - string? raw = null; - if (properties.TryGetValue("Severity", out var severity)) - raw = severity; - else if (properties.TryGetValue("Priority", out var priority)) - raw = priority; + if (TryParseInt(Property(properties, "Priority"), out var priority)) + return (ushort)GalaxyAlarmPriority.Map(priority).OpcUaSeverity; - if (int.TryParse(raw, NumberStyles.Integer, CultureInfo.InvariantCulture, out var value)) + if (TryParseInt(Property(properties, "Severity"), out var value)) return (ushort)Math.Clamp(value, MinSeverity, MaxSeverity); return MinSeverity; } + + private static bool TryParseInt(string? raw, out int value) => + int.TryParse(raw, NumberStyles.Integer, CultureInfo.InvariantCulture, out value); } diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AddressSpaceApplier.cs b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AddressSpaceApplier.cs index 961319f5b..518a54548 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AddressSpaceApplier.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AddressSpaceApplier.cs @@ -918,8 +918,15 @@ public sealed class AddressSpaceApplier if (!SafeMaterialiseAlarmCondition(t.NodeId, t.ParentNodeId ?? string.Empty, t.Name, t.Alarm.AlarmType, t.Alarm.Severity, isNative: true, AddressSpaceRealm.Raw)) { failed++; + continue; } - else if (t.ReferencingEquipmentPaths.Count > 0) + + // #886: a Galaxy alarm's AVEVA reference, so every folder this condition fires on also reads the + // history AVEVA stores under its object. Bound before the wiring; the folders are read at HistoryRead. + if (t.NativeAlarmReference is { } nativeReference && !SafeBindNativeAlarmHistory(t.NodeId, nativeReference)) + failed++; + + if (t.ReferencingEquipmentPaths.Count > 0) { // Multi-notifier fan-out: wire the SINGLE condition as an event notifier of each // referencing equipment's UNS folder, so one ReportEvent reaches every referencing equipment @@ -1247,6 +1254,12 @@ public sealed class AddressSpaceApplier /// swallowing (and Warning-logging) any sink fault. Returns true on success, false when the /// sink threw — callers tally the false into their pass's failed-node count (archreview 01/S-1). /// true when the notifiers were wired; false when the sink threw. + private bool SafeBindNativeAlarmHistory(string alarmNodeId, string alarmReference) + { + try { _sink.BindNativeAlarmHistory(alarmNodeId, AddressSpaceRealm.Raw, alarmReference); return true; } + catch (Exception ex) { _logger.LogWarning(ex, "AddressSpaceApplier: BindNativeAlarmHistory threw for {Node}", alarmNodeId); return false; } + } + private bool SafeWireAlarmNotifiers(string alarmNodeId, AddressSpaceRealm alarmRealm, IReadOnlyList notifierFolderNodeIds, AddressSpaceRealm notifierFolderRealm) { try { _sink.WireAlarmNotifiers(alarmNodeId, alarmRealm, notifierFolderNodeIds, notifierFolderRealm); return true; } diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AddressSpaceComposer.cs b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AddressSpaceComposer.cs index 44fced51d..84ccc29bc 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AddressSpaceComposer.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AddressSpaceComposer.cs @@ -150,6 +150,11 @@ public sealed record RawContainerNode( /// the DRIVER, deliberately not gated on , so a surgical scalar↔array edit does not /// flip it. It is not in the classifier's surgical whitelist, so a change to it (a driver's type edited in /// place) rebuilds. +/// is the Galaxy alarm reference (tag_name.AlarmAttribute, the tag's +/// attributeRef) of a tag that carries an , and null for +/// every other tag. AVEVA stores the alarm's history under that reference's object, so the node manager adds the +/// object to the event-history sources of every folder the condition fires on (Gitea #886). It is not in the +/// surgical whitelist either: an alarm-bearing tag's delta rebuilds anyway. /// ADDING A FIELD IS A FOUR-PLACE CONTRACT — the hand-written equality below does NOT close /// over new members. Because forces a custom /// Equals/GetHashCode, the compiler's positional equality is suppressed, so a field added @@ -178,7 +183,8 @@ public sealed record RawTagPlan( uint? ArrayLength = null, AddressSpaceRealm Realm = AddressSpaceRealm.Raw, bool ArrayWritable = false, - bool ScalarRefusesArrayValue = false) + bool ScalarRefusesArrayValue = false, + string? NativeAlarmReference = null) { /// // Structural equality: the auto-generated record equality compares ReferencingEquipmentPaths (an @@ -202,6 +208,7 @@ public sealed record RawTagPlan( Realm == other.Realm && ArrayWritable == other.ArrayWritable && ScalarRefusesArrayValue == other.ScalarRefusesArrayValue && + NativeAlarmReference == other.NativeAlarmReference && ReferencingEquipmentPaths.SequenceEqual(other.ReferencingEquipmentPaths, StringComparer.Ordinal); /// @@ -211,7 +218,7 @@ public sealed record RawTagPlan( hash.Add(TagId); hash.Add(NodeId); hash.Add(ParentNodeId); hash.Add(DriverInstanceId); hash.Add(Name); hash.Add(DataType); hash.Add(Writable); hash.Add(Alarm); hash.Add(IsHistorized); hash.Add(HistorianTagname); hash.Add(IsArray); hash.Add(ArrayLength); - hash.Add(Realm); hash.Add(ArrayWritable); hash.Add(ScalarRefusesArrayValue); + hash.Add(Realm); hash.Add(ArrayWritable); hash.Add(ScalarRefusesArrayValue); hash.Add(NativeAlarmReference); foreach (var p in ReferencingEquipmentPaths) hash.Add(p, StringComparer.Ordinal); return hash.ToHashCode(); } @@ -803,6 +810,7 @@ public static class AddressSpaceComposer var equipPathByTag = BuildReferencingEquipmentPathsByTag(unsTagReferences, unsAreas, unsLines, equipment); var arrayWritableFor = BuildArrayWriteCapability(driverInstances, devices); var scalarRefusesArrayFor = BuildScalarArrayRefusal(driverInstances, devices); + var nativeAlarmReferenceFor = BuildNativeAlarmReference(driverInstances, devices); var rawTags = new List(); foreach (var t in tags ?? Array.Empty()) @@ -820,6 +828,8 @@ public static class AddressSpaceComposer ? new EquipmentTagAlarmInfo(a.AlarmType, a.Severity, a.HistorizeToAveva) : null; var refEquipPaths = equipPathByTag.GetValueOrDefault(t.TagId, Array.Empty()); + // #886: the AVEVA history source of a native Galaxy alarm (null for every other tag). + var nativeAlarmReference = alarm is null ? null : nativeAlarmReferenceFor(t); rawTags.Add(new RawTagPlan( TagId: t.TagId, @@ -836,7 +846,8 @@ public static class AddressSpaceComposer IsArray: intent.IsArray, ArrayLength: intent.ArrayLength, ArrayWritable: arrayWritable, - ScalarRefusesArrayValue: scalarRefusesArray)); + ScalarRefusesArrayValue: scalarRefusesArray, + NativeAlarmReference: nativeAlarmReference)); } containers.Sort((x, y) => string.CompareOrdinal(x.NodeId, y.NodeId)); @@ -876,6 +887,17 @@ public static class AddressSpaceComposer return tag => DriverArrayWriteSupport.RefusesArrayValueOnScalarTag(resolve(tag).Driver?.DriverType); } + /// + /// A tag's Galaxy alarm reference (its attributeRef) when its driver is the Galaxy driver, else null — read + /// with the driver's own parser (), so a tag the driver cannot dial names no source. + /// + private static Func BuildNativeAlarmReference( + IReadOnlyList driverInstances, IReadOnlyList? devices) + { + var resolve = BuildTagOwnerResolver(driverInstances, devices); + return tag => GalaxyAttributeRef.AlarmReferenceOf(resolve(tag).Driver?.DriverType, tag.TagConfig); + } + // Tag → (owning driver, owning device); first row wins on a duplicate id, matching the composer's other id maps. private static Func BuildTagOwnerResolver( IReadOnlyList driverInstances, IReadOnlyList? devices) diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/EventSourceFanOut.cs b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/EventSourceFanOut.cs new file mode 100644 index 000000000..58b56c0a0 --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/EventSourceFanOut.cs @@ -0,0 +1,193 @@ +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; + +namespace ZB.MOM.WW.OtOpcUa.OpcUaServer; + +/// A native condition an AVEVA history row can resolve to (Gitea #886). +/// The condition's id (its RawPath), from which the server resolves the row's SourceNode. +/// The live condition's ConditionName. +internal sealed record EventHistoryCondition(string AlarmId, string ConditionName); + +/// +/// What one notifier folder's event history reads (Gitea #886): its event sources, and the native conditions that fire +/// on it live keyed by their Galaxy alarm reference. +/// +internal sealed class EventHistoryScope +{ + private static readonly IReadOnlyDictionary NoConditions = + new Dictionary(); + + private readonly IReadOnlyDictionary _conditions; + + private EventHistoryScope(IReadOnlyList sources, IReadOnlyDictionary conditions) + { + Sources = sources; + _conditions = conditions; + // One source keeps its bare name, so a pre-#886 folder's cursor is exactly what it was. + CursorKey = string.Join('\n', sources); + } + + /// The sources to read: distinct, ordinal-sorted (the folder's own first when it is the only one). + public IReadOnlyList Sources { get; } + + /// The identity a continuation point is bound to: the whole source set. + public string CursorKey { get; } + + /// A scope of one source and no Galaxy conditions (every non-Galaxy folder). + /// The folder's own source. + /// The scope. + public static EventHistoryScope Single(string source) => new([source], NoConditions); + + /// A folder's own source plus the Galaxy objects behind it. + /// The folder's own source. + /// The Galaxy objects of the native conditions that fire on it (duplicates allowed). + /// Those conditions by Galaxy alarm reference (case-insensitive). + /// The scope. + public static EventHistoryScope Create( + string ownSource, IEnumerable? objects, IReadOnlyDictionary conditions) + { + var sources = new SortedSet(StringComparer.Ordinal) { ownSource }; + foreach (var o in objects ?? []) sources.Add(o); + return new EventHistoryScope(sources.ToList(), conditions); + } + + /// + /// Give each row whose names one of this folder's native conditions + /// that condition's id (so its SourceNode resolves) and live ConditionName. Other rows are unchanged. + /// + /// A page of events. + /// The page, resolved. + public IReadOnlyList ResolveConditions(IReadOnlyList events) + { + if (_conditions.Count == 0 || events.Count == 0) return events; + List? resolved = null; + for (var i = 0; i < events.Count; i++) + { + var e = events[i]; + if (e.ConditionReference is not { } reference || !_conditions.TryGetValue(reference, out var condition)) continue; + resolved ??= [.. events]; + resolved[i] = e with { AlarmId = condition.AlarmId, ConditionName = condition.ConditionName }; + } + + return resolved ?? events; + } +} + +/// +/// Reads several event sources as one (Gitea #886): one backend read per source (the historiangw ReadEvents +/// filter is a single predicate, so there is no multi-source request), merged, de-duplicated by EventId and cut to the +/// cap. +/// +/// +/// +/// Merge order is (EventTimeUtc, source, arrival): time in the read's direction, then the source's +/// position in the folder's (ordinal-sorted) source list — reversed for a reverse read — then the order the source +/// returned its events in. A source's own order within a timestamp is all the paging needs to be stable (the +/// Gateway orders by event time only), so ties are never re-sorted by EventId here: re-sorting could move an +/// unread tie in front of one already sent. The reverse form is the forward form backwards, which is what the +/// server's tie-cluster over-fetch (a forward read, reversed) relies on. A walked-back reverse read (Gateway) sorts +/// its ties by EventId itself, as before. +/// +/// +/// Truncation stays honest. A source that flags "more" returned a prefix of its own stream, ending at time H. +/// Its unread events lie at H or later (in the read's direction), and at H they follow what it returned. So the +/// page keeps every event before the earliest such H, then, at H, each source's events in merge order up to and +/// including the first source truncated at H — never an event the unread ones would precede. The page is never +/// empty, because that source returned at least one event at H. When every source is read with the same cap (the +/// server always passes one) a page cut to that cap already stops there; the bound matters for an uncapped or +/// default-capped read. +/// +/// A single source is passed straight through, so a folder with one source reads exactly as before #886. +/// +internal static class EventSourceFanOut +{ + /// Reads over one window and merges them. + /// The backend. + /// The event sources (at least one). + /// The window start (the later bound for a reverse read). + /// The window end. + /// The cap, passed to every source; <= 0 is the backend's default cap. + /// Newest first. + /// Cancellation. + /// The merged events, and a non-null continuation point when more exist. + public static async Task ReadAsync( + IHistorianDataSource source, IReadOnlyList sources, DateTime startUtc, DateTime endUtc, int maxEvents, + bool reverse, CancellationToken ct) + { + if (sources.Count == 1) + return await source.ReadEventsAsync(sources[0], startUtc, endUtc, maxEvents, reverse, ct).ConfigureAwait(false); + + var merged = new List(); + var seen = new HashSet(StringComparer.Ordinal); + var more = false; + // Per source: the time its truncated read ended at, or null when it returned everything it holds. + var truncatedAt = new DateTime?[sources.Count]; + for (var s = 0; s < sources.Count; s++) + { + // Sequential: the folder's sources share the backend's session pool, and the batch already reads folders in + // parallel. + var read = await source.ReadEventsAsync(sources[s], startUtc, endUtc, maxEvents, reverse, ct).ConfigureAwait(false); + for (var i = 0; i < read.Events.Count; i++) + { + var e = read.Events[i]; + if (string.IsNullOrEmpty(e.EventId) || seen.Add(e.EventId)) merged.Add(new Merged(e, s, i)); + } + + if (read.ContinuationPoint is null) continue; + more = true; + // A truncated read with no events gives no bound; the cap below still flags the page incomplete. + if (read.Events.Count > 0) truncatedAt[s] = read.Events[^1].EventTimeUtc; + } + + var order = new MergeOrder(reverse); + merged.Sort(order); + + // H: the earliest (in the read's direction) time a truncated source stopped at. + DateTime? horizon = null; + foreach (var t in truncatedAt) + if (t is { } at && (horizon is not { } h || (reverse ? at > h : at < h))) horizon = at; + + var page = new List(merged.Count); + foreach (var m in merged) + { + if (horizon is { } h) + { + var t = m.Event.EventTimeUtc; + if (reverse ? t < h : t > h) break; + // At H, merge order visits whole sources in turn: stop after the first one that was cut there. + if (t == h && page.Count > 0 && page[^1].EventTimeUtc == h && StoppedAt(merged, page.Count, h)) break; + } + + page.Add(m.Event); + } + + if (maxEvents > 0 && page.Count > maxEvents) + { + page.RemoveRange(maxEvents, page.Count - maxEvents); + more = true; + } + + return new HistoricalEventsResult(page, more ? [0x01] : null); + + // Whether the event before merged[next] closed a source that was truncated at h (and merged[next] is another source's). + bool StoppedAt(List all, int next, DateTime h) + { + var previous = all[next - 1]; + return previous.Source != all[next].Source && truncatedAt[previous.Source] == h; + } + } + + /// One merged event: the event, its source's position in the list, and its position in that source's read. + private readonly record struct Merged(HistoricalEvent Event, int Source, int Arrival); + + /// Time in the read's direction, then source position (reversed for a reverse read), then arrival. + private sealed class MergeOrder(bool reverse) : IComparer + { + public int Compare(Merged x, Merged y) + { + var c = x.Event.EventTimeUtc.CompareTo(y.Event.EventTimeUtc); + if (c == 0) c = x.Source.CompareTo(y.Source); + if (reverse) c = -c; + return c != 0 ? c : x.Arrival.CompareTo(y.Arrival); + } + } +} diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs index 142786423..922680aee 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs @@ -82,6 +82,13 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2, INodeManager3 /// so a re-materialise as the other kind is correct. private readonly HashSet _nativeAlarmNodeIds = new(); /// + /// Gitea #886 — native condition key → its Galaxy alarm reference (tag_name.AlarmAttribute), recorded by + /// . Every folder the condition fires on live reads the history AVEVA stores + /// under the reference's object (). Guarded by Lock; maintained in + /// lock-step with (dropped wherever a condition is, cleared on rebuild). + /// + private readonly Dictionary _nativeAlarmReferences = new(); + /// /// Gitea #797 — the explicit binding from each LIVE scripted condition to the equipment that owns it. /// A scripted condition's NodeId is its opaque ScriptedAlarmId and cannot be split into an ACL /// scope, so with Security:Acl:Enabled its Part 9 commands are authorized at the owner's scope @@ -2319,6 +2326,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2, INodeManager3 // H6a: re-materialising the same id as the OTHER kind (native↔scripted) must reflect the new // kind, so always drop the stale native flag first and only re-add it below when isNative. _nativeAlarmNodeIds.Remove(conditionKey); + _nativeAlarmReferences.Remove(conditionKey); // Gitea #797: the dropped instance's owner binding goes with it; the new one is bound below. _scriptedAlarmOwners.Remove(conditionKey); @@ -2721,6 +2729,21 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2, INodeManager3 } } + /// + public void BindNativeAlarmHistory(string alarmNodeId, AddressSpaceRealm alarmRealm, string alarmReference) + { + ArgumentException.ThrowIfNullOrEmpty(alarmNodeId); + if (string.IsNullOrWhiteSpace(alarmReference)) return; + var key = MapKey(alarmRealm, alarmNodeId); + lock (Lock) + { + // Only a materialised NATIVE condition carries an upstream reference; anything else (a mid-rebuild race, a + // scripted id) is a no-op, never a throw — a deploy must not fault on it. + if (!_alarmConditions.ContainsKey(key) || !_nativeAlarmNodeIds.Contains(key)) return; + _nativeAlarmReferences[key] = alarmReference.Trim(); + } + } + /// Tear down (bidirectionally) every notifier wired for the condition at /// and drop its tracking entry. MUST be called under Lock. Used on condition-removal + full rebuild /// so no inverse-notifier entry leaks. A no-op when the condition has no wired notifiers. @@ -4648,6 +4671,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2, INodeManager3 // H6a: drop the native-alarm flags in lock-step with the conditions they classify, so a // re-materialise on the next apply (possibly as the other kind) starts from a clean slate. _nativeAlarmNodeIds.Clear(); + _nativeAlarmReferences.Clear(); _scriptedAlarmOwners.Clear(); foreach (var f in _folders.Values) PredefinedNodes?.Remove(f.NodeId); @@ -4812,6 +4836,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2, INodeManager3 condition.Parent?.RemoveChild(condition); ForgetPredefinedSubtree(condition); _nativeAlarmNodeIds.Remove(key); + _nativeAlarmReferences.Remove(key); _scriptedAlarmOwners.Remove(key); LowerAllEventsMonitoring(condition); e = BuildNodesRemovedModelChange(alarmNodeId, realm); @@ -4922,6 +4947,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2, INodeManager3 if (c.Parent is { } parent && !removedParents.Contains(parent)) parent.RemoveChild(c); ForgetPredefinedSubtree(c); _nativeAlarmNodeIds.Remove(key); + _nativeAlarmReferences.Remove(key); _scriptedAlarmOwners.Remove(key); LowerAllEventsMonitoring(c); return; @@ -7830,8 +7856,10 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2, INodeManager3 continue; } - var sn = sourceName; - work.Add(() => ServeEventsAsync(handle, sn, details, selectClauses, where, filterContext, nodesToRead, session, + // #886: the folder's own source plus the Galaxy objects of the native conditions that fire on it, taken + // under Lock now so the whole read (every page source, every SourceNode) sees one membership. + var scope = ResolveEventHistoryScope(handle.NodeId, sourceName); + work.Add(() => ServeEventsAsync(handle, scope, details, selectClauses, where, filterContext, nodesToRead, session, results, errors, ct)); } @@ -7839,6 +7867,42 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2, INodeManager3 }); } + /// + /// Gitea #886 — what one notifier folder's event history reads: its own registered source (OtOpcUa's own events, + /// as before) plus the Galaxy object of every native condition that fires on it live — those parented under it + /// and those wired to it — and those conditions by their Galaxy reference, so an + /// AVEVA row resolves to the same condition its live event comes from. Taken under Lock. + /// + /// The notifier folder. + /// The folder's registered event source (its NodeId identifier). + /// The folder's scope; just when no Galaxy condition fires on it. + private EventHistoryScope ResolveEventHistoryScope(NodeId folderNodeId, string ownSource) + { + lock (Lock) + { + if (_nativeAlarmReferences.Count == 0 || !_notifierFolders.TryGetValue(folderNodeId, out var folder)) + return EventHistoryScope.Single(ownSource); + + List? objects = null; + Dictionary? conditions = null; + foreach (var (key, reference) in _nativeAlarmReferences) + { + if (!_alarmConditions.TryGetValue(key, out var alarm)) continue; + var firesHere = ReferenceEquals(alarm.Parent, folder) + || (_alarmNotifierWiring.TryGetValue(key, out var wiring) + && ReferenceEquals(wiring.Alarm, alarm) && wiring.Folders.Contains(folder)); + if (!firesHere) continue; + if (GalaxyAttributeRef.ObjectNameOf(reference) is { } objectName) (objects ??= []).Add(objectName); + // Galaxy names are case-insensitive, and the driver matches its live alarm references the same way. + conditions ??= new Dictionary(StringComparer.OrdinalIgnoreCase); + conditions.TryAdd(reference, new EventHistoryCondition( + key.Id, alarm.ConditionName?.Value ?? alarm.DisplayName.Text ?? key.Id)); + } + + return conditions is null ? EventHistoryScope.Single(ownSource) : EventHistoryScope.Create(ownSource, objects, conditions); + } + } + /// /// Serve one registered event-history notifier handle: reads the source's events over the request /// window, projects them onto the select clauses, and fills the service-level results/errors slots. @@ -7847,7 +7911,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2, INodeManager3 /// BadTimeout. /// /// The notifier node handle; handle.Index indexes results/errors. - /// The registered historian event-source name for this notifier. + /// The notifier's event-history sources and the native conditions that fire on it (#886). /// The event-read details (window + count cap). /// The event-filter select clauses (the fields to emit, in order). /// The event filter's where-clause, or null for none. @@ -7857,7 +7921,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2, INodeManager3 /// Per-request deadline token. private async Task ServeEventsAsync( NodeHandle handle, - string? sourceName, + EventHistoryScope scope, ReadEventDetails details, ArrayOf selectClauses, ContentFilter? where, @@ -7873,6 +7937,12 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2, INodeManager3 var resumed = inboundCp is { Length: > 0 }; // One snapshot of the (hot-swappable) source for the whole read: the capability check and every call must agree. var source = _historianDataSource; + // The folder's sources read as ONE source (#886): a single source is read directly, several are fanned out and + // merged in (EventTimeUtc, source, arrival) order (EventSourceFanOut), so the paging below is the same either way. + // The cursor is bound to the whole source set. + var sourceName = scope.CursorKey; + Task ReadSourcesAsync(DateTime from, DateTime to, int max, bool rev, CancellationToken token) => + EventSourceFanOut.ReadAsync(source, scope.Sources, from, to, max, rev, token); try { var startUtc = SdkValueBoundary.ToClrTimestamp(details.StartTime); @@ -7888,14 +7958,13 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2, INodeManager3 // for THIS node only. var state = _historyContinuationStore.TryTake(session, inboundCp.ToArray()); // A point is bound to the node that issued it: another node's source name is a miss too. - if (state is null || !HistoryContinuationMatches(state, handle.NodeId, historyEpoch) || state.Kind != HistoryCursorKind.Events || state.Tagname != (sourceName ?? string.Empty)) + if (state is null || !HistoryContinuationMatches(state, handle.NodeId, historyEpoch) || state.Kind != HistoryCursorKind.Events || state.Tagname != sourceName) { errors[handle.Index] = StatusCodes.BadContinuationPointInvalid; results[handle.Index] = new SdkHistoryReadResult { StatusCode = StatusCodes.BadContinuationPointInvalid }; return; } - sourceName = state.Tagname; startUtc = state.NextStartUtc; endUtc = state.EndUtc; boundarySkip = state.BoundarySkipCount; @@ -7956,8 +8025,8 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2, INodeManager3 // continuation point (the walk itself cannot flag truncation). var want = numValuesPerNode == 0 ? 0u : (uint)Math.Min(uint.MaxValue - 1L, (long)fetch + 1); var (rows, next) = await ReverseEventsReader.ReadAsync( - source, sourceName, startUtc, endUtc, want, MaxTieClusterOverfetch, Clock.GetUtcNow().UtcDateTime, - walkHint, ct) + (from, to, cap, token) => ReadSourcesAsync(from, to, cap, rev: false, token), source.TimestampResolutionTicks, + sourceName, startUtc, endUtc, want, MaxTieClusterOverfetch, Clock.GetUtcNow().UtcDateTime, walkHint, ct) .ConfigureAwait(false); walkHint = next; var more = want > 0 && rows.Count > fetch; @@ -7966,8 +8035,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2, INodeManager3 } else { - sourceResult = await source.ReadEventsAsync( - sourceName, startUtc, endUtc, fetch, reverse, ct).ConfigureAwait(false); + sourceResult = await ReadSourcesAsync(startUtc, endUtc, fetch, reverse, ct).ConfigureAwait(false); } // Truncation is the SOURCE's word (non-null continuation point) — never inferred from the count, so a @@ -8004,8 +8072,8 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2, INodeManager3 // with an explicit bounded cap and page WITHIN it (same scheme as raw). var resolution = Math.Max(1L, source.TimestampResolutionTicks); var overfetchCap = (int)Math.Min(int.MaxValue - 1L, (long)Math.Max(1, MaxTieClusterOverfetch) + 1); - IReadOnlyList cluster = (await source.ReadEventsAsync( - sourceName, startUtc, startUtc.AddTicks(resolution), overfetchCap, reverse: false, ct) + IReadOnlyList cluster = (await ReadSourcesAsync( + startUtc, startUtc.AddTicks(resolution), overfetchCap, rev: false, ct) .ConfigureAwait(false)).Events; // A walked read emitted its ties sorted by EventId; the gateway returns them in any order and its @@ -8041,7 +8109,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2, INodeManager3 if (nextStartUtc is { } resumeAt) { pendingCursor = new HistoryContinuationState( - sourceName ?? string.Empty, resumeAt, endUtc, nextSkip, numValuesPerNode, HistoryCursorKind.Events, + sourceName, resumeAt, endUtc, nextSkip, numValuesPerNode, HistoryCursorKind.Events, Reverse: reverse, SegmentWidthTicks: walkHint.WidthTicks, EmptyFromTicks: walkHint.EmptyFromTicks, EmptyToTicks: walkHint.EmptyToTicks); } @@ -8054,7 +8122,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2, INodeManager3 var skip = 0; for (var i = events.Count - 1; i >= 0 && events[i].EventTimeUtc == boundary; i--) skip++; pendingCursor = new HistoryContinuationState( - sourceName ?? string.Empty, boundary, endUtc, skip, numValuesPerNode, HistoryCursorKind.Events, + sourceName, boundary, endUtc, skip, numValuesPerNode, HistoryCursorKind.Events, Reverse: reverse, SegmentWidthTicks: walkHint.WidthTicks, EmptyFromTicks: walkHint.EmptyFromTicks, EmptyToTicks: walkHint.EmptyToTicks); } @@ -8062,6 +8130,9 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2, INodeManager3 // The where-clause filters AFTER the page's cursor was taken from the unfiltered events, so paging (time, // boundary skip) is exactly as without a filter: a page may come back with fewer events — even none — and a // continuation point, never skipping or repeating one. + // #886: an AVEVA row naming a native condition that fires on this folder carries that condition's SourceNode and + // ConditionName, as the live event does. Rewritten after paging: EventId and time are untouched. + events = scope.ResolveConditions(events); var historyEvent = ProjectEvents(events, selectClauses, where, filterContext); var outboundCp = pendingCursor is null ? null : SaveHistoryContinuation(session, handle.NodeId, historyEpoch, pendingCursor); results[handle.Index] = new SdkHistoryReadResult diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/ReverseEventsReader.cs b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/ReverseEventsReader.cs index db295d1a4..7d714ffa5 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/ReverseEventsReader.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/ReverseEventsReader.cs @@ -27,9 +27,33 @@ internal static class ReverseEventsReader /// Cancellation. /// The events, newest first, and the hint for the next page. /// One timestamp holds more than the tie budget. - public static async Task ReadAsync( + public static Task ReadAsync( IHistorianDataSource source, string? sourceName, DateTime hi, DateTime lo, uint want, int maxTieCluster, - DateTime nowUtc, ReverseWalkHint hint, CancellationToken ct) + DateTime nowUtc, ReverseWalkHint hint, CancellationToken ct) => + ReadAsync( + (start, end, cap, token) => source.ReadEventsAsync(sourceName, start, end, cap, reverse: false, token), + source.TimestampResolutionTicks, sourceName, hi, lo, want, maxTieCluster, nowUtc, hint, ct); + + /// + /// Reads (lo, hi] newest-first from a forward event reader — the form a folder with several event sources + /// uses (, Gitea #886), whose merged reads stand in for one source's. + /// + /// Reads [start, end] forward with a cap (int.MaxValue = none). + /// The backend's timestamp resolution. + /// The source (or source set) named in a limit message. + /// The later (inclusive) bound. + /// The earlier (exclusive) bound. + /// How many events; 0 = all in the window (one forward read). + /// The tie budget (MaxTieClusterOverfetch) — a ceiling only. + /// Now, for the look-ahead clamp. + /// What the previous page carried (default = none). + /// Cancellation. + /// The events, newest first, and the hint for the next page. + /// One timestamp holds more than the tie budget. + public static async Task ReadAsync( + Func> readForward, long resolutionTicks, + string? sourceName, DateTime hi, DateTime lo, uint want, int maxTieCluster, DateTime nowUtc, ReverseWalkHint hint, + CancellationToken ct) { var budget = Math.Max(1, maxTieCluster); var (rows, next) = await ReverseWalk.WalkAsync( @@ -37,11 +61,10 @@ internal static class ReverseEventsReader { // cap 0 = the whole window: int.MaxValue is the events contract's "no cap" (a non-positive value would // mean the backend's DEFAULT cap). - var r = await source.ReadEventsAsync(sourceName, start, end, cap == 0 ? int.MaxValue : cap, reverse: false, token) - .ConfigureAwait(false); + var r = await readForward(start, end, cap == 0 ? int.MaxValue : cap, token).ConfigureAwait(false); return new ForwardSegmentRead(r.Events, r.ContinuationPoint is not null); }, - ev => ev.EventTimeUtc.Ticks, EventIdTieOrder.Instance, hi, lo, want, budget, source.TimestampResolutionTicks, + ev => ev.EventTimeUtc.Ticks, EventIdTieOrder.Instance, hi, lo, want, budget, resolutionTicks, nowUtc, hint, at => new HistoryReadLimitExceededException(budget, $"A reverse events read of '{sourceName}' at {at:O} found more than {budget} events within one millisecond; the " + diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/SdkAddressSpaceSink.cs b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/SdkAddressSpaceSink.cs index 8e3a9f0d3..c939d9363 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/SdkAddressSpaceSink.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/SdkAddressSpaceSink.cs @@ -54,6 +54,10 @@ public sealed class SdkAddressSpaceSink : IOpcUaAddressSpaceSink, ISurgicalAddre public void WireAlarmNotifiers(string alarmNodeId, AddressSpaceRealm alarmRealm, IReadOnlyList notifierFolderNodeIds, AddressSpaceRealm notifierFolderRealm) => _nodeManager.WireAlarmNotifiers(alarmNodeId, alarmRealm, notifierFolderNodeIds, notifierFolderRealm); + /// + public void BindNativeAlarmHistory(string alarmNodeId, AddressSpaceRealm alarmRealm, string alarmReference) + => _nodeManager.BindNativeAlarmHistory(alarmNodeId, alarmRealm, alarmReference); + /// public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName, AddressSpaceRealm realm) => _nodeManager.EnsureFolder(folderNodeId, parentNodeId, displayName, realm); diff --git a/tests/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions.Tests/GalaxyAttributeRefTests.cs b/tests/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions.Tests/GalaxyAttributeRefTests.cs new file mode 100644 index 000000000..df94e903f --- /dev/null +++ b/tests/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions.Tests/GalaxyAttributeRefTests.cs @@ -0,0 +1,40 @@ +using Shouldly; +using Xunit; + +namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions.Tests; + +/// Gitea #886 — the Galaxy reference parser the driver and the composer share, and the object it names. +public sealed class GalaxyAttributeRefTests +{ + [Theory] + [InlineData("""{"attributeRef":"Oven_007.TempHiAlarm"}""", "Oven_007.TempHiAlarm")] + [InlineData("""{"AttributeRef":"Oven_007.Temp","alarm":{"alarmType":"LimitAlarm"}}""", "Oven_007.Temp")] + [InlineData("""{"attributeRef":" "}""", null)] + [InlineData("""{"attributeRef":7}""", null)] + [InlineData("""{"FullName":"Oven_007.Temp"}""", null)] + [InlineData("""not json""", null)] + [InlineData("", null)] + [InlineData(null, null)] + public void Reads_the_attributeRef(string? tagConfig, string? expected) => + GalaxyAttributeRef.TryRead(tagConfig).ShouldBe(expected); + + [Fact] + public void Only_a_galaxy_tag_names_an_aveva_alarm_reference() + { + const string config = """{"attributeRef":"Oven_007.TempHiAlarm"}"""; + GalaxyAttributeRef.AlarmReferenceOf(DriverTypeNames.Galaxy, config).ShouldBe("Oven_007.TempHiAlarm"); + GalaxyAttributeRef.AlarmReferenceOf(DriverTypeNames.Modbus, config).ShouldBeNull(); + GalaxyAttributeRef.AlarmReferenceOf(null, config).ShouldBeNull(); + } + + [Theory] + [InlineData("Oven_007.TempHiAlarm", "Oven_007")] + [InlineData("Oven_007.Temp.HiHi", "Oven_007")] + [InlineData("Oven_007", "Oven_007")] + [InlineData(" Oven_007.Temp ", "Oven_007")] + [InlineData(".Temp", null)] + [InlineData("", null)] + [InlineData(null, null)] + public void The_object_is_the_reference_up_to_its_first_dot(string? reference, string? expected) => + GalaxyAttributeRef.ObjectNameOf(reference).ShouldBe(expected); +} diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests/Mapping/EventMapperTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests/Mapping/EventMapperTests.cs index b46e44b76..7b46bcd59 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests/Mapping/EventMapperTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests/Mapping/EventMapperTests.cs @@ -43,7 +43,7 @@ public sealed class EventMapperTests } [Theory] - [InlineData("Priority", "999", 999)] + [InlineData("Priority", "999", 1000)] // a Galaxy priority maps through the live table (#886), not clamped as a severity [InlineData("Severity", "0", 1)] // clamp to OPC UA min 1 [InlineData("Severity", "5000", 1000)] // clamp to OPC UA max 1000 [InlineData(null, null, 1)] // missing → default min severity @@ -54,6 +54,95 @@ public sealed class EventMapperTests Assert.Equal((ushort)expected, EventMapper.ToHistoricalEvent(e).Severity); } + // ---- Gitea #886: AVEVA-shaped rows (lower-case keys) ------------------------------------------------------------- + + /// An AVEVA A&E alarm row as vd03's HistorianGateway returns it (2026-11-12 probe, trimmed). + private static HistorianEvent AvevaAlarmRow(string priority = "500") + { + var e = new HistorianEvent + { + Id = "001c0040-0025-0004-5b00-74003f005400", Type = "Alarm.Set", SourceName = "Oven_007.Temp", + Namespace = "Application Server", EventTime = Ts(2026, 11, 5, 15, 19, 52), ReceivedTime = Ts(2026, 11, 5, 15, 19, 53), + }; + e.Properties["alarm_id"] = "001c0040-0025-0004-5b00-74003f005400"; + e.Properties["alarm_state"] = "UNACK_ALM"; + e.Properties["comment"] = "Oven 7 temperature"; + e.Properties["priority"] = priority; + e.Properties["severity"] = "2"; // AVEVA's own 1..4 scale: never the OPC UA Severity + e.Properties["source_conditionvariable"] = "Oven_007.TempHiAlarm"; + e.Properties["source_object"] = "Oven_007"; + e.Properties["source_processvariable"] = "Oven_007.Temp"; + return e; + } + + [Fact] + public void An_aveva_alarm_row_maps_priority_alarm_id_and_its_condition_reference() + { + var h = EventMapper.ToHistoricalEvent(AvevaAlarmRow()); + + Assert.Equal((ushort)750, h.Severity); // priority 500 → the live Galaxy table's High bucket, not AVEVA's severity "2" + Assert.Equal("001c0040-0025-0004-5b00-74003f005400", h.AlarmId); + Assert.Equal("Oven_007.TempHiAlarm", h.ConditionReference); + Assert.Equal("TempHiAlarm", h.ConditionName); // the AVEVA alarm's name until the server resolves a live condition + Assert.Equal("Alarm.Set", h.Message); // no message property: the event type, as before + Assert.Equal("Oven_007.Temp", h.SourceName); + } + + [Theory] + [InlineData("1", 250)] + [InlineData("249", 250)] + [InlineData("250", 500)] + [InlineData("499", 500)] + [InlineData("500", 750)] + [InlineData("749", 750)] + [InlineData("750", 1000)] + [InlineData("999", 1000)] // a System.Stop/Start row (priority 999, AVEVA severity 0) — lmxopcua#887 tracks the direction + public void An_aveva_priority_maps_through_the_live_galaxy_table(string priority, int expected) + { + var h = EventMapper.ToHistoricalEvent(AvevaAlarmRow(priority)); + Assert.Equal((ushort)expected, h.Severity); + // The live Galaxy path maps the same raw value through the same table, so history and live agree. + Assert.Equal(expected, ZB.MOM.WW.OtOpcUa.Core.Abstractions.GalaxyAlarmPriority.Map(int.Parse(priority)).OpcUaSeverity); + } + + [Fact] + public void OtOpcUas_own_event_read_back_from_aveva_with_lower_case_keys_keeps_its_fields() + { + // AVEVA lower-cases the keys OtOpcUa writes (AlarmName → alarmname, …): seen on vd03 2026-11-12. + var e = new HistorianEvent { Id = "E", Type = "LimitAlarm", SourceName = "Area/Line/Eq", + EventTime = Ts(2026, 6, 27, 3, 43, 44), ReceivedTime = Ts(2026, 6, 27, 3, 43, 44) }; + e.Properties["alarmid"] = "Plant/Modbus/dev1/temp_hi"; + e.Properties["alarmname"] = "HiTemp"; + e.Properties["eventkind"] = "Activated"; + e.Properties["message"] = "Temperature high"; + e.Properties["severity"] = "700"; + + var h = EventMapper.ToHistoricalEvent(e); + + Assert.Equal("Plant/Modbus/dev1/temp_hi", h.AlarmId); + Assert.Equal("HiTemp", h.ConditionName); + Assert.Equal("Temperature high", h.Message); + Assert.Equal((ushort)700, h.Severity); + Assert.Null(h.ConditionReference); + } + + [Fact] + public void An_aveva_system_event_has_no_condition() + { + var e = new HistorianEvent { Id = "S", Type = "System.Stop", SourceName = "Oven_007", + EventTime = Ts(2026, 11, 10, 11, 32, 35), ReceivedTime = Ts(2026, 11, 10, 11, 32, 35) }; + e.Properties["priority"] = "999"; + e.Properties["severity"] = "0"; + e.Properties["source_object"] = "Oven_007"; + + var h = EventMapper.ToHistoricalEvent(e); + + Assert.Null(h.ConditionName); + Assert.Null(h.ConditionReference); + Assert.Null(h.AlarmId); + Assert.Equal("System.Stop", h.Message); + } + // Ts(...) builds a Google.Protobuf.WellKnownTypes.Timestamp from UTC parts. private static Timestamp Ts(int y, int mo, int d, int h, int mi, int s) => Timestamp.FromDateTime(new DateTime(y, mo, d, h, mi, s, DateTimeKind.Utc)); diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/AvevaGatewayHistoryLiveGateTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/AvevaGatewayHistoryLiveGateTests.cs index 27c9ad8d4..8c6f84663 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/AvevaGatewayHistoryLiveGateTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/AvevaGatewayHistoryLiveGateTests.cs @@ -33,7 +33,9 @@ namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests; /// OTOPCUA_AVEVA_GATE_OUT (output directory for aveva-gate-*.json), OTOPCUA_AVEVA_GATE_LABEL /// (a run label recorded in the output, e.g. the SDK version), OTOPCUA_AVEVA_GATE_TAGS (comma list of /// historized analog tags), OTOPCUA_AVEVA_GATE_WINDOWS (start/end;start/end, ISO-8601 UTC), -/// OTOPCUA_AVEVA_GATE_EVENT_SOURCES (comma list) and OTOPCUA_AVEVA_GATE_EVENT_WINDOW. The defaults are the DEV +/// OTOPCUA_AVEVA_GATE_EVENT_SOURCES (comma list), OTOPCUA_AVEVA_GATE_EVENT_WINDOW and +/// OTOPCUA_AVEVA_GATE_GALAXY_REFS (#886: comma list of Galaxy alarm references whose native conditions are wired to +/// one equipment folder that is not named after any of their objects). The defaults are the DEV /// (wonder-app-vd03 / wonder-sql-vd03) tags and fixed past windows used for the 2026-11-12 run. /// The local deterministic fixtures are NOT this test; only a real gateway in front of a real AVEVA /// Historian makes its output AVEVA evidence. @@ -53,6 +55,12 @@ public sealed class AvevaGatewayHistoryLiveGateTests // it stays GoodNoData. Recorded (wire must equal the backend), not required to be non-empty. private const string DefaultEventSources = "DevWinPlatform,DevWinPlatform.CPULoadAvg"; private const string DefaultEventWindow = "2026-11-05T15:00:00Z/2026-11-12T00:00:00Z"; + + // #886: a REAL equipment folder — its id is not a Galaxy object name — whose native conditions dial these Galaxy alarm + // references (attributeRef). Two objects with A&E history on DEV in the event window: the platform's CPU-load alarm + // and a ZIPSEN Left_001 machine alarm. The folder must serve both objects' events and attribute alarms. + private const string DefaultGalaxyAlarmRefs = "DevWinPlatform.CPULoadHiAlarm,Left_001.FRR_Warning"; + private const string GalaxyEquipment = "EQ-886-ZipsenLeft"; private const string MissingTag = "OtOpcUa.Gate801.DoesNotExist"; private const double IntervalMs = 60_000; @@ -99,6 +107,8 @@ public sealed class AvevaGatewayHistoryLiveGateTests .Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); var eventWindow = ParseWindows(Env("OTOPCUA_AVEVA_GATE_EVENT_WINDOW") ?? DefaultEventWindow)[0]; var sandboxTag = Env("HISTGW_WRITE_SANDBOX_TAG"); + var galaxyRefs = (Env("OTOPCUA_AVEVA_GATE_GALAXY_REFS") ?? DefaultGalaxyAlarmRefs) + .Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); // The production backend path: the same registry + provider + factory the Host uses for Backend=Gateway. var options = new ServerHistorianOptions @@ -142,6 +152,23 @@ public sealed class AvevaGatewayHistoryLiveGateTests AddressSpaceRealm.Raw, isNative: true); } + // #886: the applier's shape for a Galaxy deployment — each native condition under its raw device folder, bound to + // its Galaxy alarm reference and wired to the one equipment folder that references both. + rig.Sink.EnsureFolder(GalaxyEquipment, parentNodeId: null, displayName: "ZIPSEN Left line", realm: AddressSpaceRealm.Uns); + var galaxyConditions = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var reference in galaxyRefs) + { + var objectName = GalaxyAttributeRef.ObjectNameOf(reference)!; + var name = reference[(reference.IndexOf('.') + 1)..].Replace(".", "_", StringComparison.Ordinal); + var deviceFolder = $"Plant/Galaxy/{objectName}"; + var rawPath = $"{deviceFolder}/{name}"; + rig.Sink.EnsureFolder(deviceFolder, parentNodeId: null, displayName: objectName, realm: AddressSpaceRealm.Raw); + rig.Sink.MaterialiseAlarmCondition(rawPath, deviceFolder, name, "OffNormalAlarm", 500, AddressSpaceRealm.Raw, isNative: true); + rig.Sink.BindNativeAlarmHistory(rawPath, AddressSpaceRealm.Raw, reference); + rig.Sink.WireAlarmNotifiers(rawPath, AddressSpaceRealm.Raw, [GalaxyEquipment], AddressSpaceRealm.Uns); + galaxyConditions[reference] = (rawPath, name); + } + using var session = await rig.ConnectAsync(anonymous: true, ct); var ns = (ushort)session.NamespaceUris.GetIndex(V3NodeIds.RawNamespaceUri); NodeId Node(string id) => new(id, ns); @@ -299,6 +326,78 @@ public sealed class AvevaGatewayHistoryLiveGateTests } } + // ---- #886: a real equipment folder serves the AVEVA history of every Galaxy object behind its native conditions. + { + var (start, end) = eventWindow; + var w = $"{start:o}/{end:o}"; + var objects = galaxyRefs.Select(r => GalaxyAttributeRef.ObjectNameOf(r)!).Distinct(StringComparer.Ordinal).ToList(); + var discovered = await source.ReadEventsAsync(null, start, end, 0, ct); + bool OfAnObject(string? s) => s is not null && objects.Any(o => s == o || s.StartsWith(o + ".", StringComparison.Ordinal)); + var expected = discovered.Events.Where(e => OfAnObject(e.SourceName)).ToList(); + // The backend's own mapping of each event (per object, as the folder reads them): severity and AVEVA's condition. + var direct = new Dictionary(StringComparer.Ordinal); + foreach (var o in objects) + foreach (var e in (await source.ReadEventsAsync(o, start, end, 0, ct)).Events) direct.TryAdd(e.EventId, e); + var folderOwn = await source.ReadEventsAsync(GalaxyEquipment, start, end, 0, ct); + + var uns = new NodeId(GalaxyEquipment, (ushort)session.NamespaceUris.GetIndex(V3NodeIds.UnsNamespaceUri)); + var all = await ReadGalaxyEventsAsync(session, uns, start, end, 0, ct); + var paged1 = await ReadGalaxyEventsAsync(session, uns, start, end, 1, ct); + var paged7 = await ReadGalaxyEventsAsync(session, uns, start, end, 7, ct); + var reverse = await ReadGalaxyEventsAsync(session, uns, end, start, 0, ct); + var reverse5 = await ReadGalaxyEventsAsync(session, uns, end, start, 5, ct); + + var bySource = string.Join(",", all.Rows.GroupBy(r => r.Event.Source ?? "").OrderBy(g => g.Key, StringComparer.Ordinal) + .Select(g => $"{g.Key}={g.Count()}")); + var resolved = all.Rows.Count(r => r.SourceNode is not null); + report.Add(new Case("events886.folderOwnSourceOnly", GalaxyEquipment, w, "direct", "Good", + $"events={folderOwn.Events.Count} (what the folder read before #886)", null)); + report.Add(new Case("events886.all", GalaxyEquipment, w, "wire", all.Status, + $"pages={all.Pages} objects={string.Join('+', objects)} bySource={bySource} sourceNodeResolved={resolved}", null, null, + all.Rows.Select(r => r.Event).ToList())); + report.Add(new Case("events886.paged1", GalaxyEquipment, w, "wire", paged1.Status, $"pages={paged1.Pages}", null, null, + paged1.Rows.Select(r => r.Event).ToList())); + report.Add(new Case("events886.paged7", GalaxyEquipment, w, "wire", paged7.Status, $"pages={paged7.Pages}", null, null, + paged7.Rows.Select(r => r.Event).ToList())); + report.Add(new Case("events886.reverse", GalaxyEquipment, w, "wire", reverse.Status, $"pages={reverse.Pages}", null, null, + reverse.Rows.Select(r => r.Event).ToList())); + report.Add(new Case("events886.reverse5", GalaxyEquipment, w, "wire", reverse5.Status, $"pages={reverse5.Pages}", null, null, + reverse5.Rows.Select(r => r.Event).ToList())); + report.Add(new Case("events886.conditions", GalaxyEquipment, w, "wire", all.Status, + string.Join(";", all.Rows.Where(r => r.SourceNode is not null).GroupBy(r => (r.SourceNode, r.ConditionName)) + .Select(g => $"{g.Key.SourceNode}|{g.Key.ConditionName}={g.Count()}")), null)); + + foreach (var o in objects) + Check(failures, expected.Any(e => e.SourceName == o || e.SourceName!.StartsWith(o + ".", StringComparison.Ordinal)), + $"events886 {o} {w}: expected stored events for every object behind the folder"); + Check(failures, folderOwn.Events.Count == 0, $"events886: the folder id {GalaxyEquipment} must name no AVEVA source"); + var expectedIds = expected.Select(e => e.EventId).OrderBy(id => id, StringComparer.Ordinal).ToList(); + Check(failures, all.Rows.Select(r => r.Event.Id).OrderBy(id => id, StringComparer.Ordinal).SequenceEqual(expectedIds), + $"events886 {w}: wire {all.Rows.Count} events vs {expectedIds.Count} of {string.Join('/', objects)} or their attributes"); + Check(failures, all.Rows.Zip(all.Rows.Skip(1)).All(p => p.First.Event.Time <= p.Second.Event.Time), + "events886: a forward read is in time order across objects"); + var forwardIds = all.Rows.Select(r => r.Event.Id).ToList(); + Check(failures, paged1.Rows.Select(r => r.Event.Id).SequenceEqual(forwardIds), "events886: 1-event pages equal the whole read"); + Check(failures, paged7.Rows.Select(r => r.Event.Id).SequenceEqual(forwardIds), "events886: 7-event pages equal the whole read"); + var reverseIds = expected.OrderByDescending(e => e.EventTimeUtc).ThenByDescending(e => e.EventId, StringComparer.Ordinal) + .Select(e => e.EventId).ToList(); + Check(failures, reverse.Rows.Select(r => r.Event.Id).SequenceEqual(reverseIds), + "events886: a reverse read is newest first (ties by EventId, descending)"); + Check(failures, reverse5.Rows.Select(r => r.Event.Id).SequenceEqual(reverseIds), "events886: 5-event reverse pages equal the reverse read"); + foreach (var r in all.Rows) + { + if (!direct.TryGetValue(r.Event.Id!, out var d)) { failures.Add($"events886: {r.Event.Id} not in the per-object read"); continue; } + Check(failures, r.Event.Severity == d.Severity, $"events886: {r.Event.Id} severity {r.Event.Severity} vs backend {d.Severity}"); + var live = d.ConditionReference is { } cr && galaxyConditions.TryGetValue(cr, out var c) ? c : ((string RawPath, string Name)?)null; + Check(failures, r.SourceNode == live?.RawPath, + $"events886: {r.Event.Id} ({d.ConditionReference}) SourceNode {r.SourceNode ?? "null"} vs {live?.RawPath ?? "null"}"); + if (live is { } lc) + Check(failures, r.ConditionName == lc.Name, $"events886: {r.Event.Id} ConditionName {r.ConditionName} vs {lc.Name}"); + } + + Check(failures, resolved > 0, "events886: at least one row resolves to its live condition"); + } + // ---- HistoryUpdate: refused by the server (no updater, no HistoryWrite bit). Sandbox-bound node only. if (sandboxNode is null) { @@ -463,6 +562,49 @@ public sealed class AvevaGatewayHistoryLiveGateTests return new EventRead(status, rows, pages); } + /// #886: an events read that also returns the SourceNode and ConditionName (paged by continuation points). + private static async Task ReadGalaxyEventsAsync(ISession session, NodeId node, DateTime start, DateTime end, uint page, + CancellationToken ct) + { + var filter = new EventFilter(); + foreach (var field in new[] { "EventId", "Time", "ReceiveTime", "SourceName", "Message", "Severity", "SourceNode", "ConditionName" }) + { + var type = field == "ConditionName" ? ObjectTypeIds.ConditionType : ObjectTypeIds.BaseEventType; + filter.SelectClauses += new SimpleAttributeOperand(type, new QualifiedName(field)) { AttributeId = Attributes.Value }; + } + + var details = new ReadEventDetails { StartTime = start, EndTime = end, NumValuesPerNode = page, Filter = filter }; + var rows = new List(); + var pages = 0; + ByteString cp = default; + string status; + do + { + var response = await session.HistoryReadAsync(null, new ExtensionObject(details), TimestampsToReturn.Both, false, + new HistoryReadValueId[] { new() { NodeId = node, ContinuationPoint = cp } }, ct); + var result = response.Results.ToList().ShouldHaveSingleItem(); + status = Name(result.StatusCode); + cp = result.ContinuationPoint; + pages++; + if (!result.HistoryData.IsNull && ExtensionObject.ToEncodeable(result.HistoryData) is HistoryEvent he) + foreach (var e in he.Events.ToList()) + { + var f = e.EventFields.ToList(); + rows.Add(new GalaxyEventRow( + new EventRow( + f[0].ClrValue() is byte[] id ? System.Text.Encoding.UTF8.GetString(id) : Fmt(f[0].ClrValue()), + f[1].ClrValue() as DateTime? ?? default, f[2].ClrValue() as DateTime? ?? default, + f[3].ClrValue() as string, f[4].ClrValue() is LocalizedText lt ? lt.Text : Fmt(f[4].ClrValue()), + Convert.ToUInt16(f[5].ClrValue() ?? 0, CultureInfo.InvariantCulture)), + f[6].ClrValue() is NodeId sn && !sn.IsNull ? sn.IdentifierAsString : null, + f[7].ClrValue() as string)); + } + if (status.StartsWith("Bad", StringComparison.Ordinal)) break; + } + while (!cp.IsEmpty && pages < 10_000); + return new GalaxyEventRead(status, rows, pages); + } + private static async Task> ReadAttributesAsync(ISession session, NodeId node, CancellationToken ct) { var ids = new (string Name, uint Id)[] @@ -586,6 +728,10 @@ public sealed class AvevaGatewayHistoryLiveGateTests private sealed record EventRead(string Status, List Rows, int Pages); + private sealed record GalaxyEventRow(EventRow Event, string? SourceNode, string? ConditionName); + + private sealed record GalaxyEventRead(string Status, List Rows, int Pages); + private sealed record Case( string Name, string? Tag, string? Window, string Path, string Status, string? Note, Dictionary? Attributes, List? Rows = null, List? Events = null); diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/GalaxyEquipmentEventHistoryWireTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/GalaxyEquipmentEventHistoryWireTests.cs new file mode 100644 index 000000000..410efef9a --- /dev/null +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/GalaxyEquipmentEventHistoryWireTests.cs @@ -0,0 +1,322 @@ +using System.Runtime.CompilerServices; +using Google.Protobuf.WellKnownTypes; +using Microsoft.Extensions.Logging.Abstractions; +using Opc.Ua; +using Opc.Ua.Client; +using Shouldly; +using Xunit; +using ZB.MOM.WW.HistorianGateway.Contracts.Grpc; +using ZB.MOM.WW.OtOpcUa.Commons.OpcUa; +using ZB.MOM.WW.OtOpcUa.Configuration.Enums; +using ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway; +using static ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests.ServerEventRig; + +namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests; + +/// +/// Gitea #886 over a real SDK session, with the production Gateway backend on a deterministic AVEVA-shaped gateway: a +/// REAL equipment folder — whose id (EqA) is not a Galaxy object name — serves the AVEVA history of the Galaxy +/// objects behind its native conditions, here two (Oven_007 and OvenDoor_07): each object's own events +/// and its attribute alarms, never a prefix sibling (Oven_0070), merged in time order across paging and reverse +/// reads, with the live condition's SourceNode / ConditionName and the live Galaxy severity, and only behind the +/// folder's own HistoryRead verdict. +/// +/// +/// Topology, as the applier builds it from a Galaxy deployment: raw folder Plant/Galaxy/Oven7 holds two native +/// conditions, TempHi (attributeRef Oven_007.TempHiAlarm) and DoorOpen +/// (OvenDoor_07.DoorAlarm), both wired to equipment EqA; raw folder Plant/Galaxy/Line3 holds +/// Jam (Line3_Conveyor.JamAlarm), wired to EqB. The fake gateway filters a per-source read more +/// loosely than the real one (Source_Object begins with the source), so the sibling's rows reach OtOpcUa and +/// their absence is OtOpcUa's filter at work. +/// +public sealed class GalaxyEquipmentEventHistoryWireTests +{ + private const string OvenFolder = "Plant/Galaxy/Oven7"; + private const string LineFolder = "Plant/Galaxy/Line3"; + private const string TempHi = OvenFolder + "/TempHi"; + private const string DoorOpen = OvenFolder + "/DoorOpen"; + private const string Jam = LineFolder + "/Jam"; + + private static readonly DateTime Epoch = new(2026, 11, 10, 11, 0, 0, DateTimeKind.Utc); + + /// The stored AVEVA rows. A null condition variable is an object-level system event. + private static readonly AvevaRow[] Stored = + [ + new("o-stop", "Oven_007", "Oven_007", "System.Stop", 0, 999, null), + new("l-set", "Line3_Conveyor.Belt", "Line3_Conveyor", "Alarm.Set", 1, 300, "Line3_Conveyor.JamAlarm"), + new("o-temp-set", "Oven_007.Temp", "Oven_007", "Alarm.Set", 2, 500, "Oven_007.TempHiAlarm"), + new("d-set", "OvenDoor_07.Door", "OvenDoor_07", "Alarm.Set", 2, 700, "OvenDoor_07.DoorAlarm"), // a tie across objects + new("a-oven-tie", "Oven_007.Temp", "Oven_007", "Alarm.Ack", 2, 500, "Oven_007.TempHiAlarm"), // and within one, stored later + new("o-press-set", "Oven_007.Pressure", "Oven_007", "Alarm.Set", 3, 100, "Oven_007.PressureHiAlarm"), // no live condition here + new("s-set", "Oven_0070.Temp", "Oven_0070", "Alarm.Set", 4, 500, "Oven_0070.TempHiAlarm"), // prefix sibling + new("d-clear", "OvenDoor_07.Door", "OvenDoor_07", "Alarm.Clear", 5, 700, "OvenDoor_07.DoorAlarm"), + new("o-temp-clear", "Oven_007.Temp", "Oven_007", "Alarm.Clear", 6, 500, "oven_007.temphialarm"), // Galaxy names: any case + new("l-clear", "Line3_Conveyor.Belt", "Line3_Conveyor", "Alarm.Clear", 7, 300, "Line3_Conveyor.JamAlarm"), + new("o-start", "Oven_007", "Oven_007", "System.Start", 8, 999, null), + ]; + + /// EqA's events forward: by time, then source (the folder's ordinal-sorted list: OvenDoor_07 before + /// Oven_007), then the order the gateway returned them in — so a-oven-tie follows o-temp-set. + private static readonly string[] EqAEvents = + ["o-stop", "d-set", "o-temp-set", "a-oven-tie", "o-press-set", "d-clear", "o-temp-clear", "o-start"]; + + /// EqA's events reverse: newest first; the Gateway's walked-back reverse read orders ties by EventId, + /// descending, as it did before #886. + private static readonly string[] EqAEventsReverse = + ["o-start", "o-temp-clear", "d-clear", "o-press-set", "o-temp-set", "d-set", "a-oven-tie", "o-stop"]; + + [Fact] + public async Task An_equipment_folder_serves_the_aveva_history_of_every_galaxy_object_behind_it() + { + var ct = TestContext.Current.CancellationToken; + await using var rig = await StartAsync("GalaxyEqHistory", encrypted: false, ct); + var gateway = new AvevaShapedGateway(); + rig.Nm.HistorianDataSource = new GatewayHistorianDataSource(gateway, NullLogger.Instance); + Materialise(rig); + rig.Enforce(false, [Key()]); + + using var session = await rig.ConnectAsync(anonymous: true, ct); + var all = await ReadAsync(session, Uns(session, EqA), forward: true, page: 0, ct); + + all.Status.ShouldBe(StatusCodes.Good); + all.Rows.Select(r => r.Id).ShouldBe(EqAEvents); + gateway.Requested.ShouldBe(new[] { EqA, "OvenDoor_07", "Oven_007" }, + "the folder's own source and one request per Galaxy object behind it (no multi-source request exists)"); + gateway.Yielded.ShouldContain("s-set", "the sibling's rows reached OtOpcUa, so their absence is its filter's doing"); + + var byId = all.Rows.ToDictionary(r => r.Id); + // Each event keeps the source the historian reported. + byId["o-temp-set"].Source.ShouldBe("Oven_007.Temp"); + byId["o-stop"].Source.ShouldBe("Oven_007"); + // A row naming a condition that fires on this folder carries that live condition (any-case reference). + byId["o-temp-set"].SourceNode.ShouldBe(TempHi); + byId["o-temp-clear"].SourceNode.ShouldBe(TempHi); + byId["o-temp-set"].ConditionName.ShouldBe("TempHi"); + byId["d-set"].SourceNode.ShouldBe(DoorOpen); + byId["d-set"].ConditionName.ShouldBe("DoorOpen"); + // An alarm of the object with no condition wired here: served (object rule), but names no node. + byId["o-press-set"].SourceNode.ShouldBeNull(); + byId["o-press-set"].ConditionName.ShouldBe("PressureHiAlarm"); + // A system event has no condition at all. + byId["o-stop"].SourceNode.ShouldBeNull(); + byId["o-stop"].ConditionName.ShouldBeNull(); + // Severity follows the live Galaxy table (priority 500 → 750, 700 → 750, 100 → 250, 999 → 1000), not Severity 1. + byId["o-temp-set"].Severity.ShouldBe((ushort)750); + byId["d-set"].Severity.ShouldBe((ushort)750); + byId["o-press-set"].Severity.ShouldBe((ushort)250); + byId["o-stop"].Severity.ShouldBe((ushort)1000); + + // The raw device folder the conditions hang under reads the same objects (live: a condition fires on its parent). + var raw = await ReadAsync(session, Raw(session, OvenFolder), forward: true, page: 0, ct); + raw.Rows.Select(r => r.Id).ShouldBe(EqAEvents); + } + + [Fact] + public async Task Paging_and_reverse_reads_walk_the_merged_sources_exactly_once() + { + var ct = TestContext.Current.CancellationToken; + await using var rig = await StartAsync("GalaxyEqHistoryPaging", encrypted: false, ct); + var gateway = new AvevaShapedGateway(); + rig.Nm.HistorianDataSource = new GatewayHistorianDataSource(gateway, NullLogger.Instance); + Materialise(rig); + rig.Enforce(false, [Key()]); + + using var session = await rig.ConnectAsync(anonymous: true, ct); + var node = Uns(session, EqA); + foreach (var page in new uint[] { 1, 2, 3, 4 }) + { + var paged = await ReadAsync(session, node, forward: true, page, ct); + paged.Rows.Select(r => r.Id).ShouldBe(EqAEvents, $"forward, {page} a page"); + paged.Pages.ShouldBeGreaterThan(1); + + var reverse = await ReadAsync(session, node, forward: false, page, ct); + reverse.Rows.Select(r => r.Id).ShouldBe(EqAEventsReverse, $"reverse, {page} a page (newest first, ties descending)"); + } + + var reverseWhole = await ReadAsync(session, node, forward: false, page: 0, ct); + reverseWhole.Rows.Select(r => r.Id).ShouldBe(EqAEventsReverse); + } + + [Fact] + public async Task With_the_acl_on_a_denied_folder_reads_none_of_its_galaxy_objects() + { + var ct = TestContext.Current.CancellationToken; + await using var rig = await StartAsync("GalaxyEqHistoryAcl", encrypted: false, ct); + var gateway = new AvevaShapedGateway(); + rig.Nm.HistorianDataSource = new GatewayHistorianDataSource(gateway, NullLogger.Instance); + Materialise(rig); + // Anonymous is granted EqB only: EqA, behind which sit Oven_007 and OvenDoor_07, is denied. + rig.Enforce(true, [Key()], EquipmentGrant(EqB, NodePermissions.ReadOnly)); + + using var session = await rig.ConnectAsync(anonymous: true, ct); + var denied = await ReadAsync(session, Uns(session, EqA), forward: true, page: 0, ct); + + denied.Status.ShouldBe(StatusCodes.BadUserAccessDenied); + denied.HistoryDataIsNull.ShouldBeTrue("a denied folder discloses none of its events"); + gateway.Requested.ShouldBeEmpty("the folder verdict is decided before any source is read (fail closed)"); + + // Positive control: the same session, on the folder it IS granted, gets that folder's Galaxy object. + var granted = await ReadAsync(session, Uns(session, EqB), forward: true, page: 0, ct); + granted.Status.ShouldBe(StatusCodes.Good); + granted.Rows.Select(r => (r.Id, r.SourceNode)).ShouldBe(new (string, string?)[] { ("l-set", Jam), ("l-clear", Jam) }); + gateway.Requested.ShouldBe(new[] { EqB, "Line3_Conveyor" }); + } + + [Fact] + public async Task A_rebuild_forgets_the_galaxy_sources_until_the_conditions_are_bound_again() + { + var ct = TestContext.Current.CancellationToken; + await using var rig = await StartAsync("GalaxyEqHistoryRebuild", encrypted: false, ct); + var gateway = new AvevaShapedGateway(); + rig.Nm.HistorianDataSource = new GatewayHistorianDataSource(gateway, NullLogger.Instance); + Materialise(rig); + rig.Enforce(false, [Key()]); + + // The redeploy re-creates the same conditions and wiring, but (here) binds no Galaxy reference: the folder is back + // to its own source alone — a stale binding would keep reading the old objects. + rig.Sink.RebuildAddressSpace(); + Materialise(rig, bind: false); + + using var session = await rig.ConnectAsync(anonymous: true, ct); + var read = await ReadAsync(session, Uns(session, EqA), forward: true, page: 0, ct); + + read.Status.ShouldBe(StatusCodes.GoodNoData); + gateway.Requested.ShouldBe(new[] { EqA }); + } + + /// Materialises the topology the applier builds from the Galaxy deployment (see the class remarks). + private static void Materialise(ServerEventRig rig, bool bind = true) + { + rig.Sink.EnsureFolder(OvenFolder, parentNodeId: null, displayName: "Oven7", realm: AddressSpaceRealm.Raw); + rig.Sink.EnsureFolder(LineFolder, parentNodeId: null, displayName: "Line3", realm: AddressSpaceRealm.Raw); + rig.EnsureEquipment(); + foreach (var (id, folder, name, reference, equipment) in new[] + { + (TempHi, OvenFolder, "TempHi", "Oven_007.TempHiAlarm", EqA), + (DoorOpen, OvenFolder, "DoorOpen", "OvenDoor_07.DoorAlarm", EqA), + (Jam, LineFolder, "Jam", "Line3_Conveyor.JamAlarm", EqB), + }) + { + rig.Sink.MaterialiseAlarmCondition(id, folder, name, "OffNormalAlarm", 700, AddressSpaceRealm.Raw, isNative: true); + if (bind) rig.Sink.BindNativeAlarmHistory(id, AddressSpaceRealm.Raw, reference); + rig.Sink.WireAlarmNotifiers(id, AddressSpaceRealm.Raw, [equipment], AddressSpaceRealm.Uns); + } + } + + private static NodeId Uns(ISession session, string id) => + new(id, (ushort)session.NamespaceUris.GetIndex(V3NodeIds.UnsNamespaceUri)); + + private static NodeId Raw(ISession session, string id) => + new(id, (ushort)session.NamespaceUris.GetIndex(V3NodeIds.RawNamespaceUri)); + + private static async Task ReadAsync(ISession session, NodeId node, bool forward, uint page, CancellationToken ct) + { + var filter = new EventFilter(); + foreach (var leaf in new[] { "EventId", "SourceName", "SourceNode", "Severity", "ConditionName" }) + { + var type = leaf == "ConditionName" ? ObjectTypeIds.ConditionType : ObjectTypeIds.BaseEventType; + filter.SelectClauses += new SimpleAttributeOperand(type, new QualifiedName(leaf)) { AttributeId = Attributes.Value }; + } + + var (from, to) = (Epoch.AddMinutes(-1), Epoch.AddMinutes(1)); + var details = new ReadEventDetails + { + StartTime = forward ? from : to, EndTime = forward ? to : from, NumValuesPerNode = page, Filter = filter, + }; + var rows = new List(); + ByteString point = default; + var pages = 0; + StatusCode first = StatusCodes.Good; + var historyDataIsNull = true; + do + { + var response = await session.HistoryReadAsync(null, new ExtensionObject(details), TimestampsToReturn.Both, false, + new HistoryReadValueId[] { new() { NodeId = node, ContinuationPoint = point } }, ct); + var result = response.Results.ToList().ShouldHaveSingleItem(); + if (pages++ == 0) { first = result.StatusCode; historyDataIsNull = result.HistoryData.IsNull; } + if (StatusCode.IsBad(result.StatusCode)) break; + if (!result.HistoryData.IsNull && ExtensionObject.ToEncodeable(result.HistoryData) is HistoryEvent he) + foreach (var e in he.Events.ToList()) + { + var f = e.EventFields.ToList(); + rows.Add(new Row( + System.Text.Encoding.UTF8.GetString(f[0].GetByteString().Span), + f[1].GetString(), + f[2].IsNull ? null : f[2].GetNodeId().IdentifierAsString, + f[3].GetUInt16(), + f[4].IsNull ? null : f[4].GetString())); + } + + point = result.ContinuationPoint; + } while (!point.IsEmpty && pages < 60); + + return new EventRead(first, historyDataIsNull, rows, pages); + } + + private sealed record AvevaRow(string Id, string Source, string Object, string Type, int Second, int Priority, string? ConditionVariable); + + private sealed record Row(string Id, string? Source, string? SourceNode, ushort Severity, string? ConditionName); + + private sealed record EventRead(StatusCode Status, bool HistoryDataIsNull, List Rows, int Pages); + + /// + /// A deterministic gateway answering ReadEvents with AVEVA-shaped rows — SourceName = + /// Source_ProcessVariable and lower-case properties as vd03's HistorianGateway returns them — filtered loosely + /// on source_object, in event-time order with no intra-timestamp order guarantee (here: storage order). + /// + private sealed class AvevaShapedGateway : IHistorianGatewayClient + { + private readonly List _requested = []; + private readonly List _yielded = []; + + public IReadOnlyList Requested { get { lock (_requested) return _requested.ToList(); } } + + public IReadOnlyList Yielded { get { lock (_yielded) return _yielded.ToList(); } } + + public async IAsyncEnumerable ReadEventsAsync( + string? sourceName, DateTime startUtc, DateTime endUtc, int maxEvents, [EnumeratorCancellation] CancellationToken ct) + { + lock (_requested) _requested.Add(sourceName); + await Task.Yield(); + foreach (var s in Stored.OrderBy(s => s.Second)) + { + var at = Epoch.AddSeconds(s.Second); + if (at < startUtc || at > endUtc) continue; // the gateway's end is inclusive + if (sourceName is not null && !s.Object.StartsWith(sourceName, StringComparison.Ordinal)) continue; + lock (_yielded) _yielded.Add(s.Id); + var ts = Timestamp.FromDateTime(at); + var e = new HistorianEvent + { + Id = s.Id, SourceName = s.Source, Type = s.Type, Namespace = "Application Server", EventTime = ts, ReceivedTime = ts, + }; + e.Properties["source_object"] = s.Object; + e.Properties["source_processvariable"] = s.Source; + e.Properties["priority"] = s.Priority.ToString(System.Globalization.CultureInfo.InvariantCulture); + e.Properties["severity"] = s.ConditionVariable is null ? "0" : "2"; // AVEVA's own scale: never used + if (s.ConditionVariable is not null) + { + e.Properties["source_conditionvariable"] = s.ConditionVariable; + e.Properties["alarm_id"] = $"guid-{s.ConditionVariable.ToLowerInvariant()}"; + } + + yield return e; + } + } + + public Task TagExistsAsync(string tag, CancellationToken ct) => throw new NotSupportedException(); + public IAsyncEnumerable ReadRawAsync(string tag, DateTime startUtc, DateTime endUtc, int maxValues, CancellationToken ct) => + throw new NotSupportedException(); + public IAsyncEnumerable ReadAggregateAsync(string tag, DateTime startUtc, DateTime endUtc, + RetrievalMode mode, TimeSpan interval, CancellationToken ct) => throw new NotSupportedException(); + public Task> ReadAtTimeAsync(string tag, IReadOnlyList timestampsUtc, CancellationToken ct) => + throw new NotSupportedException(); + public Task WriteLiveValuesAsync(string tag, IReadOnlyList values, CancellationToken ct) => + throw new NotSupportedException(); + public Task SendEventAsync(HistorianEvent evt, CancellationToken ct) => throw new NotSupportedException(); + public Task EnsureTagsAsync(IReadOnlyList definitions, CancellationToken ct) => + throw new NotSupportedException(); + public Task ProbeAsync(CancellationToken ct) => Task.FromResult(true); + public Task GetConnectionStatusAsync(CancellationToken ct) => Task.FromResult(new ConnectionStatus()); + public ValueTask DisposeAsync() => ValueTask.CompletedTask; + } +} diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AddressSpaceApplierRawUnsTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AddressSpaceApplierRawUnsTests.cs index 036a642d2..e83c14f0b 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AddressSpaceApplierRawUnsTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AddressSpaceApplierRawUnsTests.cs @@ -154,6 +154,38 @@ public sealed class AddressSpaceApplierRawUnsTests w.FolderNodeIds.ShouldBe(new[] { "EQ-1", "EQ-2" }, ignoreOrder: true); } + /// Gitea #886: a native Galaxy alarm's AVEVA reference reaches the sink, bound to its condition after the + /// condition is materialised and before its notifiers are wired; a native alarm with no reference binds nothing. + [Fact] + public void MaterialiseRawSubtree_binds_a_galaxy_alarms_aveva_reference_to_its_condition() + { + var sink = new RealmRecordingSink(); + var composition = new AddressSpaceComposition( + new[] { new UnsAreaProjection("a1", "filling") }, + new[] { new UnsLineProjection("l1", "a1", "line1") }, + new[] { new EquipmentNode("EQ-1", "station1", "l1") }, + Array.Empty(), + Array.Empty()) + { + RawTags = new[] + { + new RawTagPlan("t1", "Plant/Galaxy/Oven7/TempHi", "Plant/Galaxy/Oven7", "drv-gx", "TempHi", "Boolean", + Writable: false, Alarm: new EquipmentTagAlarmInfo("LimitAlarm", 700), + ReferencingEquipmentPaths: new[] { "filling/line1/station1" }, + NativeAlarmReference: "Oven_007.TempHiAlarm"), + new RawTagPlan("t2", "Plant/Modbus/plc/Hi", "Plant/Modbus/plc", "drv-mb", "Hi", "Boolean", + Writable: false, Alarm: new EquipmentTagAlarmInfo("OffNormalAlarm", 500), + ReferencingEquipmentPaths: Array.Empty()), + }, + }; + + NewApplier(sink).MaterialiseRawSubtree(composition); + + sink.HistoryBindings.ShouldBe(new[] { ("Plant/Galaxy/Oven7/TempHi", AddressSpaceRealm.Raw, "Oven_007.TempHiAlarm") }); + sink.Calls.ShouldBe(new[] { "condition:Plant/Galaxy/Oven7/TempHi", "bind:Plant/Galaxy/Oven7/TempHi", + "wire:Plant/Galaxy/Oven7/TempHi", "condition:Plant/Modbus/plc/Hi" }); + } + /// /// Wave C review M2 — composer↔applier PATH-AGREEMENT (the tripwire for the latent DisplayName==Name /// coupling). The composer builds a native alarm's ReferencingEquipmentPaths from the @@ -489,11 +521,26 @@ public sealed class AddressSpaceApplierRawUnsTests public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, AddressSpaceRealm realm, string? historianTagname = null, bool isArray = false, uint? arrayLength = null, bool refuseArrayOnScalar = false) => Variables.Add((variableNodeId, parentFolderNodeId, writable, historianTagname, realm, isArray, arrayLength)); + public List<(string AlarmNodeId, AddressSpaceRealm AlarmRealm, string Reference)> HistoryBindings { get; } = new(); + public List Calls { get; } = new(); + public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, AddressSpaceRealm realm, bool isNative = false) - => Conditions.Add((alarmNodeId, equipmentNodeId, isNative, realm)); + { + Conditions.Add((alarmNodeId, equipmentNodeId, isNative, realm)); + Calls.Add($"condition:{alarmNodeId}"); + } public void WireAlarmNotifiers(string alarmNodeId, AddressSpaceRealm alarmRealm, IReadOnlyList notifierFolderNodeIds, AddressSpaceRealm notifierFolderRealm) - => NotifierWirings.Add((alarmNodeId, alarmRealm, notifierFolderNodeIds, notifierFolderRealm)); + { + NotifierWirings.Add((alarmNodeId, alarmRealm, notifierFolderNodeIds, notifierFolderRealm)); + Calls.Add($"wire:{alarmNodeId}"); + } + + public void BindNativeAlarmHistory(string alarmNodeId, AddressSpaceRealm alarmRealm, string alarmReference) + { + HistoryBindings.Add((alarmNodeId, alarmRealm, alarmReference)); + Calls.Add($"bind:{alarmNodeId}"); + } public void AddReference(string sourceNodeId, AddressSpaceRealm sourceRealm, string targetNodeId, AddressSpaceRealm targetRealm, string referenceType = "Organizes") => References.Add((sourceNodeId, sourceRealm, targetNodeId, targetRealm, referenceType)); diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AddressSpaceComposerDualNamespaceTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AddressSpaceComposerDualNamespaceTests.cs index a7baea9a3..0230c7e74 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AddressSpaceComposerDualNamespaceTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AddressSpaceComposerDualNamespaceTests.cs @@ -316,6 +316,42 @@ public sealed class AddressSpaceComposerDualNamespaceTests c.UnsReferenceVariables.Single(v => v.UnsTagReferenceId == "r3").BackingScalarRefusesArrayValue.ShouldBeFalse(); } + /// Gitea #886: a Galaxy tag that carries an alarm names its AVEVA alarm reference (its attributeRef), so + /// the folders its condition fires on can read the history AVEVA stores under that object. A Galaxy value tag, and an + /// alarm tag of any other driver (even with an attributeRef-shaped key), name none. + [Fact] + public void A_galaxy_alarm_tag_names_its_aveva_alarm_reference_and_no_other_tag_does() + { + var folder = new RawFolder { RawFolderId = "f", ClusterId = "c1", Name = "Plant" }; + var gx = new DriverInstance { DriverInstanceId = "drv-gx", ClusterId = "c1", RawFolderId = "f", Name = "Galaxy", DriverType = "GalaxyMxGateway", DriverConfig = "{}" }; + var mb = new DriverInstance { DriverInstanceId = "drv-mb", ClusterId = "c1", RawFolderId = "f", Name = "Mb", DriverType = "Modbus", DriverConfig = "{}" }; + var oven = new Device { DeviceId = "d-gx", DriverInstanceId = "drv-gx", Name = "Oven7", DeviceConfig = "{}" }; + var plc = new Device { DeviceId = "d-mb", DriverInstanceId = "drv-mb", Name = "plc", DeviceConfig = "{}" }; + const string alarm = "\"alarm\":{\"alarmType\":\"LimitAlarm\",\"severity\":700}"; + var tags = new[] + { + new Tag { TagId = "t-hi", DeviceId = "d-gx", Name = "TempHi", DataType = "Boolean", AccessLevel = TagAccessLevel.Read, + TagConfig = $"{{\"attributeRef\":\"Oven_007.TempHiAlarm\",{alarm}}}" }, + new Tag { TagId = "t-temp", DeviceId = "d-gx", Name = "Temp", DataType = "Float", AccessLevel = TagAccessLevel.Read, + TagConfig = "{\"attributeRef\":\"Oven_007.Temp\"}" }, + new Tag { TagId = "t-mb", DeviceId = "d-mb", Name = "Hi", DataType = "Boolean", AccessLevel = TagAccessLevel.Read, + TagConfig = $"{{\"region\":\"Coils\",\"address\":0,\"dataType\":\"Bool\",\"attributeRef\":\"Oven_007.X\",{alarm}}}" }, + }; + + var c = AddressSpaceComposer.Compose( + Array.Empty(), Array.Empty(), Array.Empty(), new[] { gx, mb }, Array.Empty(), + tags: tags, rawFolders: new[] { folder }, devices: new[] { oven, plc }); + + c.RawTags.Single(t => t.TagId == "t-hi").NativeAlarmReference.ShouldBe("Oven_007.TempHiAlarm"); + c.RawTags.Single(t => t.TagId == "t-temp").NativeAlarmReference.ShouldBeNull(); + c.RawTags.Single(t => t.TagId == "t-mb").Alarm.ShouldNotBeNull(); + c.RawTags.Single(t => t.TagId == "t-mb").NativeAlarmReference.ShouldBeNull(); + + // The reference is part of the plan's identity: a re-pointed attributeRef is a change, not a no-op redeploy. + var hi = c.RawTags.Single(t => t.TagId == "t-hi"); + hi.Equals(hi with { NativeAlarmReference = "Oven_008.TempHiAlarm" }).ShouldBeFalse(); + } + /// #610 review MINOR-1 — the composer hands the table the owning driver's DriverConfig AND the /// tag's own device's DeviceConfig, so a Modbus write cap configured at either level keeps an array that /// can never fit read-only — on the raw node and its UNS mirror alike. The same tag under an uncapped device is diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/EventSourceFanOutTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/EventSourceFanOutTests.cs new file mode 100644 index 000000000..39c64832e --- /dev/null +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/EventSourceFanOutTests.cs @@ -0,0 +1,202 @@ +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; +using HistorianRead = ZB.MOM.WW.OtOpcUa.Core.Abstractions.HistoryReadResult; + +namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests; + +/// +/// Gitea #886 — a folder's several event sources read as one: merged in (EventTimeUtc, EventId) order, the cap +/// applied to the merge, and "more" never claimed for a stretch a truncated source has not read yet. +/// +public sealed class EventSourceFanOutTests +{ + private static readonly DateTime T0 = new(2026, 11, 12, 10, 0, 0, DateTimeKind.Utc); + + private static HistoricalEvent Ev(string id, string source, int second) => + new(id, source, T0.AddSeconds(second), T0.AddSeconds(second), null, 1); + + [Fact] + public async Task One_source_is_read_straight_through() + { + var backend = new StoredEvents(("A", Ev("a1", "A", 1)), ("A", Ev("a2", "A", 2))); + + var r = await EventSourceFanOut.ReadAsync(backend, ["A"], T0, T0.AddMinutes(1), 10, reverse: false, TestContext.Current.CancellationToken); + + r.Events.Select(e => e.EventId).ShouldBe(["a1", "a2"]); + backend.Requests.ShouldBe(["A"]); + } + + [Fact] + public async Task Two_sources_merge_in_time_then_source_then_arrival_order_and_drop_a_duplicate() + { + // Ties keep each source's own order (z-tie before y-tie: A's arrival), and A's before B's (list order) — never + // re-sorted by EventId, which could move an unread tie in front of one already sent. + var backend = new StoredEvents( + ("A", Ev("a1", "A", 1)), ("A", Ev("a3", "A.X", 3)), ("A", Ev("z-tie", "A", 5)), ("A", Ev("y-tie", "A", 5)), + ("B", Ev("b2", "B.Y", 2)), ("B", Ev("a-tie", "B", 5)), ("B", Ev("a1", "A", 1))); + + var r = await EventSourceFanOut.ReadAsync(backend, ["A", "B"], T0, T0.AddMinutes(1), 10, reverse: false, TestContext.Current.CancellationToken); + + r.Events.Select(e => e.EventId).ShouldBe(["a1", "b2", "a3", "z-tie", "y-tie", "a-tie"]); + r.ContinuationPoint.ShouldBeNull(); + backend.Requests.ShouldBe(["A", "B"]); + } + + [Fact] + public async Task A_reverse_read_is_the_forward_merge_backwards() + { + var backend = new StoredEvents( + ("A", Ev("a1", "A", 1)), ("A", Ev("z-tie", "A", 5)), ("A", Ev("y-tie", "A", 5)), + ("B", Ev("b2", "B", 2)), ("B", Ev("a-tie", "B", 5))); + var ct = TestContext.Current.CancellationToken; + + var forward = await EventSourceFanOut.ReadAsync(backend, ["A", "B"], T0, T0.AddMinutes(1), 10, reverse: false, ct); + var reverse = await EventSourceFanOut.ReadAsync(backend, ["A", "B"], T0.AddMinutes(1), T0, 10, reverse: true, ct); + + // What the server's tie-cluster over-fetch relies on: a forward read of a cluster, reversed, is the reverse order. + reverse.Events.Select(e => e.EventId).ShouldBe(forward.Events.Select(e => e.EventId).Reverse()); + reverse.Events.Select(e => e.EventId).ShouldBe(["a-tie", "y-tie", "z-tie", "b2", "a1"]); + } + + [Fact] + public async Task The_cap_applies_to_the_merge_and_flags_more() + { + var backend = new StoredEvents(("A", Ev("a1", "A", 1)), ("A", Ev("a3", "A", 3)), ("B", Ev("b2", "B", 2))); + + var r = await EventSourceFanOut.ReadAsync(backend, ["A", "B"], T0, T0.AddMinutes(1), 2, reverse: false, TestContext.Current.CancellationToken); + + r.Events.Select(e => e.EventId).ShouldBe(["a1", "b2"]); + r.ContinuationPoint.ShouldNotBeNull(); + } + + [Fact] + public async Task A_truncated_source_bounds_the_page_at_the_time_it_stopped() + { + // A default-capped read (maxEvents 0): A stops after two of its three events (at 2); B returns all of its own. + // Unbounded, the page would run on to b4 although A's a3 (at 3) is still unread, so the next page would start + // after it. The page ends at A's stop, with B's tie at 2 left out because it follows A's (unread) ties there. + var backend = new StoredEvents( + ("A", Ev("a1", "A", 1)), ("A", Ev("a2", "A", 2)), ("A", Ev("a3", "A", 3)), + ("B", Ev("b2", "B", 2)), ("B", Ev("b4", "B", 4))) + { DefaultCaps = { ["A"] = 2 } }; + + var r = await EventSourceFanOut.ReadAsync(backend, ["A", "B"], T0, T0.AddMinutes(1), 0, reverse: false, TestContext.Current.CancellationToken); + + r.Events.Select(e => e.EventId).ShouldBe(["a1", "a2"]); + r.ContinuationPoint.ShouldNotBeNull(); + } + + [Fact] + public async Task At_the_bound_a_complete_source_ahead_of_the_truncated_one_keeps_its_ties() + { + var backend = new StoredEvents( + ("A", Ev("a1", "A", 1)), ("A", Ev("a2", "A", 2)), ("A", Ev("a3", "A", 3)), + ("B", Ev("b2", "B", 2)), ("B", Ev("b4", "B", 4))) + { DefaultCaps = { ["A"] = 2 } }; + + // B is listed first, so at 2 its tie precedes A's in merge order and is complete: it stays. + var r = await EventSourceFanOut.ReadAsync(backend, ["B", "A"], T0, T0.AddMinutes(1), 0, reverse: false, TestContext.Current.CancellationToken); + + r.Events.Select(e => e.EventId).ShouldBe(["a1", "b2", "a2"]); + r.ContinuationPoint.ShouldNotBeNull(); + } + + [Fact] + public async Task A_truncated_source_bounds_a_reverse_page_at_the_oldest_time_it_returned() + { + var backend = new StoredEvents( + ("A", Ev("a3", "A", 3)), ("A", Ev("a4", "A", 4)), ("A", Ev("a5", "A", 5)), + ("B", Ev("b4", "B", 4)), ("B", Ev("b1", "B", 1))) + { DefaultCaps = { ["A"] = 2 } }; + + var r = await EventSourceFanOut.ReadAsync(backend, ["A", "B"], T0.AddMinutes(1), T0, 0, reverse: true, TestContext.Current.CancellationToken); + + // A returned 5, 4 and stopped at 4. Reverse merge order at 4 visits B (listed later) first, then A: b4 is + // complete and kept, A's a4 closes the page, and b1 (older than A's unread a3) is left for the next page. + r.Events.Select(e => e.EventId).ShouldBe(["a5", "b4", "a4"]); + r.ContinuationPoint.ShouldNotBeNull(); + } + + [Fact] + public async Task Paging_the_merge_from_each_last_event_walks_every_event_once() + { + // The server's resume rule: read again from the last event's time INCLUSIVE and skip the ties already sent. + var stored = new List<(string, HistoricalEvent)>(); + for (var i = 0; i < 9; i++) stored.Add(("A", Ev($"a{i}", "A", i))); + for (var i = 0; i < 9; i += 2) stored.Add(("B", Ev($"b{i}", "B.X", i))); + stored.Add(("B", Ev("b-tie", "B", 4))); + var backend = new StoredEvents([.. stored]); + var whole = (await EventSourceFanOut.ReadAsync(backend, ["A", "B"], T0, T0.AddMinutes(1), 100, false, TestContext.Current.CancellationToken)) + .Events.Select(e => e.EventId).ToList(); + + foreach (var cap in new[] { 1, 2, 3, 5 }) + { + var got = new List(); + var start = T0; + var skip = 0; + for (var guard = 0; guard < 100; guard++) + { + var page = await EventSourceFanOut.ReadAsync(backend, ["A", "B"], start, T0.AddMinutes(1), cap + skip, false, TestContext.Current.CancellationToken); + var events = page.Events.SkipWhile((e, i) => i < skip && e.EventTimeUtc == start).Take(cap).ToList(); + got.AddRange(events.Select(e => e.EventId)); + var more = page.ContinuationPoint is not null || page.Events.Count - Math.Min(skip, page.Events.Count) > cap; + if (!more) break; + if (events.Count == 0) { skip += cap; continue; } + var boundary = events[^1].EventTimeUtc; + var trailing = events.AsEnumerable().Reverse().TakeWhile(e => e.EventTimeUtc == boundary).Count(); + skip = boundary == start ? skip + trailing : trailing; + start = boundary; + } + + got.ShouldBe(whole, $"cap {cap}"); + } + } + + /// A backend holding events per source; a read returns that source's events in the window, forward or reverse, + /// cut to the cap with a continuation point when more remain. + private sealed class StoredEvents(params (string Source, HistoricalEvent Event)[] stored) : IHistorianDataSource + { + public List Requests { get; } = []; + + /// A source's own cap for a default-capped read (maxEvents <= 0); uncapped when absent. + public Dictionary DefaultCaps { get; } = []; + + public bool SupportsReverseEvents => true; + + public Task ReadEventsAsync( + string? sourceName, DateTime startUtc, DateTime endUtc, int maxEvents, bool reverse, CancellationToken cancellationToken) + { + Requests.Add(sourceName); + var (lo, hi) = reverse ? (endUtc, startUtc) : (startUtc, endUtc); + var rows = stored.Where(s => s.Source == sourceName) + .Select(s => s.Event) + .Where(e => reverse ? e.EventTimeUtc > lo && e.EventTimeUtc <= hi : e.EventTimeUtc >= lo && e.EventTimeUtc < hi) + .OrderBy(e => e.EventTimeUtc) // stable: ties keep storage order, as the gateway's do + .ToList(); + if (reverse) rows.Reverse(); + var cap = maxEvents > 0 ? maxEvents : DefaultCaps.GetValueOrDefault(sourceName ?? string.Empty, 0); + var more = cap > 0 && rows.Count > cap; + return Task.FromResult(new HistoricalEventsResult(more ? rows.Take(cap).ToList() : rows, more ? [0x01] : null)); + } + + public Task ReadEventsAsync( + string? sourceName, DateTime startUtc, DateTime endUtc, int maxEvents, CancellationToken cancellationToken) => + ReadEventsAsync(sourceName, startUtc, endUtc, maxEvents, reverse: false, cancellationToken); + + public Task ReadRawAsync(string fullReference, DateTime startUtc, DateTime endUtc, uint maxValuesPerNode, + CancellationToken cancellationToken) => throw new NotSupportedException(); + + public Task ReadProcessedAsync(string fullReference, DateTime startUtc, DateTime endUtc, TimeSpan interval, + HistoryAggregateType aggregate, CancellationToken cancellationToken) => throw new NotSupportedException(); + + public Task ReadAtTimeAsync(string fullReference, IReadOnlyList timestampsUtc, + CancellationToken cancellationToken) => throw new NotSupportedException(); + + public HistorianHealthSnapshot GetHealthSnapshot() => NullHistorianDataSource.Instance.GetHealthSnapshot(); + + public void Dispose() + { + } + } +}