From e6cba4bc18ad32ca36090300fe582f2424c78e33 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Wed, 11 Nov 2026 01:37:51 -0500 Subject: [PATCH] =?UTF-8?q?fix(adminui):=20Heidenhain=20authoring=20UX=20?= =?UTF-8?q?=E2=80=94=20CSV=20preview=20runs=20the=20save-path=20row=20rule?= =?UTF-8?q?,=20dictated=20data=20type=20is=20locked,=20credential=20error?= =?UTF-8?q?=20shown=20once=20(#830)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - RawTagRowRules: the ONE driver-dispatched authority on Tag-ROW rules (access level / Tag.DataType). RawTreeService's save path, the CSV import's staged review grid (RawTagCsvMapper) and the manual-entry grid all call it; no call site names a driver. A ReadWrite or mistyped Heidenhain row is now invalid IN THE PREVIEW instead of only at Commit. - Manual-entry grid + tag modal: when the driver dictates the data type for the row's address (RawTagRowDefaults.DictatedDataType), the field is a locked read-only value instead of a select that commit silently replaced; the grid runs the row rule client-side and no longer rewrites the row at commit. - Driver/device modals: the credential refusal is shown once (the modal's save-gate notice); HeidenhainDriverFormModel / HeidenhainDeviceModel.Validate report only ranges / endpoint. - docs/drivers/Heidenhain.md: #831 alarm conditions + rediscovery, #830 authoring behaviour. Tests: HeidenhainAuthoringUxTests (CSV preview == ImportTagsAsync row-for-row, dictated type is what the save path accepts, single credential surface, ALARM: seeding, CSV alarm rows, AlarmMs round-trip); HeidenhainAuthoringTests updated for the single-notice rule. --- docs/drivers/Heidenhain.md | 98 +++++++++- .../DeviceForms/HeidenhainDeviceModel.cs | 10 +- .../Shared/Raw/RawManualTagEntryModal.razor | 33 +++- .../Components/Shared/Raw/RawTagModal.razor | 21 ++- .../Uns/RawTagCsvMapper.cs | 7 + .../Uns/RawTagRowDefaults.cs | 26 +-- .../Uns/RawTagRowRules.cs | 66 +++++++ .../Uns/RawTreeService.cs | 9 +- .../Uns/HeidenhainAuthoringTests.cs | 7 +- .../Uns/HeidenhainAuthoringUxTests.cs | 176 ++++++++++++++++++ 10 files changed, 406 insertions(+), 47 deletions(-) create mode 100644 src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/RawTagRowRules.cs create mode 100644 tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/HeidenhainAuthoringUxTests.cs diff --git a/docs/drivers/Heidenhain.md b/docs/drivers/Heidenhain.md index 978088a90..a73701084 100644 --- a/docs/drivers/Heidenhain.md +++ b/docs/drivers/Heidenhain.md @@ -1,7 +1,7 @@ # Heidenhain LSV2 driver (read-only) `DriverType = "Heidenhain"` (`DriverTypeNames.Heidenhain`) · projects `Driver.Heidenhain` (+ `.Contracts`) · -Gitea **#803**. +Gitea **#803** (native alarms + rediscovery: **#831**; authoring UX: **#830**). A native, **read-only** Heidenhain LSV2 client over TCP (default port **19000**) for iTNC/TNC controls. No Java/Python runtime and no vendor SDK (RemoTools/DNC) — a managed `System.Net.Sockets` client. It is a port of the @@ -21,9 +21,12 @@ Three independent layers, each sufficient on its own for its concern: manual-entry grid also fix the access level at Read — and again by the deploy gate `HeidenhainTagWritable`, so no node is ever advertised writable. A `"writable": true` TagConfig key is rejected by the tag reader and dropped by the typed editor. The same paths tie `Tag.DataType` to what the driver publishes (the catalog type for a fixed - address — `Float64` ⇒ `Double` — or the binding's declared type): the modals default it from the address, the - save path refuses a mismatch, and the deploy gate `HeidenhainTagDataTypeMismatch` catches anything older - (one rule, `HeidenhainTagRules`). + address — `Float64` ⇒ `Double` — or the binding's declared type; `Boolean` for an `ALARM:` condition): the tag + modal and the manual-entry grid **lock** the access level and data type to the driver's values (#830 — the grid + used to offer a choice it silently replaced on commit), the CSV import's staged review grid flags a ReadWrite or + mistyped row **in the preview**, the save path refuses a mismatch, and the deploy gate + `HeidenhainTagDataTypeMismatch` catches anything older. One rule (`HeidenhainTagRules`), reached by every AdminUI + path through the driver-dispatched `RawTagRowRules` (no call site names a driver). 3. **Payload-aware outbound allowlist** (`Lsv2ReadOnlyPolicy`). `Lsv2Codec.Frame` — the only frame builder — checks command **and** payload immediately before any byte is written. There is no raw-command API (`Lsv2Client`'s public surface is pinned by a reflection test) and no configuration knob that widens it. @@ -66,6 +69,7 @@ enums, so the numeric-enum form/factory trap cannot occur): | `Access.DataEnabled` | false | Opt-in DATA login, attempted only when a `DATA:` binding exists (iTNC only) | | `Access.InspectPassword` / `DncPassword` / `DataPassword` | — | **References only** (below) | | `Polling.StatusMs` / `CustomMs` / `SlowMs` | 1000 / 1000 / 10000 | each 100–3600000 | +| `Polling.AlarmMs` | 2000 | 100–3600000; the alarm walk behind `ALARM:` conditions — only for a control carrying an `ALARM:` tag ([Alarm conditions](#alarm-conditions-alarm-gitea-831)) | | `Polling.MaxAlarms` | 64 | 1–256 | | `Polling.TimersEnabled` | **false** | Experimental selectors 30–34 | | `Reconnect.InitialDelayMs` / `MaxDelayMs` | 1000 / 30000 | doubling backoff, bounded | @@ -90,8 +94,9 @@ Host at their own machine. Anything outside the namespace is refused by **every* (`HeidenhainOptionsParser`, so the factory and the probe), the resolver **again at resolve time** (`HeidenhainCredentials`, fail-closed independently of earlier validation), the AdminUI **save path** (`RawTreeService` create/update driver + device refuse the config server-side via `DriverConfigSaveGate`, and the -driver/device modals disable Save while it is invalid — so a literal never reaches the config database), and the -deploy gate `HeidenhainCredentialPersisted`. Messages never echo the value. +driver/device modals disable Save while it is invalid and show the refusal **once**, below the form — the typed +forms report only ranges/endpoints, #830 — so a literal never reaches the config database), and the deploy gate +`HeidenhainCredentialPersisted`. Messages never echo the value. Resolved **at each login**, never held in options or session state. Fail-closed: an absent secret or unset variable fails the login rather than sending the reference text. An unusable **INSPECT** credential fails the @@ -147,6 +152,70 @@ truncates or wraps — otherwise `BadTypeMismatch`. At most 256 PLC/DATA bindings per control (excess are skipped and reported on `/hosts`). +## Alarm conditions (`ALARM:`, Gitea #831) + +The control exposes one flat active-alarm list (`R_RI 27` first, `28` next — class, group, number, text; only refusal +57 ends it). Besides the `Alarms/*` typed arrays (unchanged), the list can drive **native OPC UA Part 9 conditions**: +a raw tag whose address is an `ALARM:` selector **and** whose `TagConfig` carries the cross-driver `alarm` object is +exactly one condition, `ConditionId` = the tag's RawPath (the key the host routes and fans on — raw device folder + +every referencing equipment folder, per the v3 native-alarm model). Same opt-in-per-tag shape as FOCAS #647 and +MTConnect #626. + +```jsonc +{ "address": "ALARM:CLASS=7", "dataType": "Boolean", + "alarm": { "alarmType": "AlarmCondition", "severity": 800 } } +``` + +| Address | Condition is active while… | +|---|---| +| `ALARM:*` | any alarm is active on the control (summary) | +| `ALARM:OTHER` | an alarm is active that no **filter** selector on the same control claims | +| `ALARM:CLASS=[/GROUP=][/NUMBER=]` (any subset, any order, each key once) | an alarm matches every named field | + +`` is `n` or `lo..hi` (inclusive; `..` because numbers can be negative — a real iTNC 530 reports `-1`). CLASS/GROUP +are 0–65535 (wire uint16), NUMBER is int32. **No semantic class/group names**: their meaning is undocumented in the +reference (pyLSV2 / the Java port keep them raw), so nothing is guessed — observe `Alarms/Classes` / `Alarms/Groups` or +a condition message, then filter. One parser (`HeidenhainAlarmSelector`) backs the editor, the deploy inspector and the +driver. + +- **Opt-in is the tag — nothing is on by default.** An existing config gains no condition and loses nothing; the + `Alarms/*` arrays keep publishing. This follows FOCAS #647 (`AlarmProjection.Enabled` became a no-op: the authored + tag is the switch) and MTConnect #626 (opt-in because alarm tags leave the value subscription). There is no + driver-level enable flag. +- **Pairing is enforced** (`HeidenhainTagDefinitionFactory`, so the editor, the save path, CSV and the deploy inspector + agree): an `ALARM:` address without the `alarm` object, or an `alarm` object on any other address, is refused. The + tag's data type is fixed at `Boolean`; it is never a value (`ReadAsync` answers `BadNotSupported`), never in a data + subscription, never writable (the `HeidenhainTagWritable` gate still applies). The typed editor seeds + `{"alarmType":"AlarmCondition","severity":700}` when you pick an `ALARM:` selector and drops it when you leave one. +- **Identity** = (class, group, number, **text**) — the number alone is not unique (`-1` above). **Displayed alarm** is + deterministic (ascending class, group, number, text), never wire order. Message: + `-1 Run test of cutout channels (class 7, group 4) [+N more]`. +- **Transitions** (the FOCAS #647 rules): inactive → active `Raise`; a key not in the told set appears, or the displayed + alarm leaves while others remain → `Retrigger` (un-acknowledges — a statement the operator has not seen); a + non-displayed member leaves → nothing; nothing matches → `Clear`. +- **Severity**: an authored integer `alarm.severity` is the exact Part 9 severity; otherwise High (700). No + class→severity table is invented. +- **Diffed against what the server was TOLD**, recorded only while a handler is attached; the record lives on the + driver instance (survives a reconnect / same-set re-subscribe / in-place re-init). A deploy respawns the driver and its + first walk re-states still-active alarms with `Raise`, which the host treats as a re-statement (ack kept, + `NativeAlarmProjector` #647). +- **A failed walk is not "no alarms."** A refusal (e.g. 23/49 access denied → `BadUserAccessDenied`, 22/24 → + `BadNotSupported`), a list overflow (`MaxAlarms` → `BadOutOfRange`), a decode error, telemetry disabled, or comms loss + (`BadNoCommunication`) keeps every condition's Active/Acked state and annotates its **quality** once (quality-only + event, no `/alerts` row, one Warning per episode); the next good walk lifts it to Good before diffing. Only a down + session degrades health (the existing rule) — a refusal never does. +- **Acknowledgement is local-only.** LSV2 has no acknowledge request and the read-only allowlist is **not** widened: + `AcknowledgeAsync` is a no-op success, so the Part 9 Acknowledge / AddComment the host routes to it is applied (and + pair-replicated) server-side — the FOCAS / MTConnect posture. Confirm, the shelve methods and Enable/Disable are + refused `BadNotSupported` by the host for every native condition. The control clears an alarm when its cause is reset; + an operator ack never reaches it. +- **Traffic:** the walk is the same allowlisted `R_RI 27/28` the arrays use, on the control's single acquisition loop + every `Polling.AlarmMs`, and only for a control that carries an `ALARM:` tag. No new command type exists + (`HeidenhainAlarmTests` asserts the wire log is a subset of the read set and `Lsv2ReadOnlyPolicy` is unchanged). +- **Authoring:** the `/raw` tag modal (`Native alarm` section + the typed editor), manual entry (the editor seeds the + alarm object) and CSV (`Alarm.AlarmType` / `Alarm.Severity` columns). The browse picker offers only catalog leaves. +- E2E: `Runtime.Tests/Drivers/HeidenhainNativeAlarmEndToEndTests` (real driver → simulator → real `DriverHostActor`). + ## Quality | Condition | Value status / capability | @@ -183,6 +252,11 @@ overrides, axes, tool, the whole alarm walk) and aliased custom addresses are re - Subscriptions: the initial push and later publications are ordered under one lock, so a subscriber never receives an older value after a newer one. A re-init disposes every control loop first; a late start of a disposed loop is a no-op. +- **Rediscovery signal (`IRediscoverable`, #831):** every session already reads `R_VR` at connect; when a control + reconnects reporting a **different** identity (model, NC/PLC software version or option string) than the previous + session on this driver instance, the driver raises `OnRediscoveryNeeded` once with both identities in the reason — + the `/hosts` "re-browse" chip. Advisory only (drivers.md §Change Detection): nothing is re-materialised. The first + session records the baseline; a deploy respawn forgets it (the next session is a new baseline). No extra traffic. - **Health:** `Degraded` only while a control's session is down (connect failing / lost), with `LastSuccessfulRead` frozen — the conjunction the #527 comms-loss watchdog needs. Per-tag refusals, denials and decode errors are quality, not health (else the watchdog would reconnect-loop a control that merely denies one @@ -202,7 +276,13 @@ session to the first device: INSPECT login + `R_VR`. A bare TCP listener does no any non-allowlisted telegram. The Java fixtures' captured iTNC 530 replies (`Fixtures/*.properties`) are replayed as golden vectors (inspect-only telemetry on two controls; DATA boundary replies on three). - `Runtime.Tests/Drivers/HeidenhainDriverEndToEndTests` — the real driver inside `DriverInstanceActor`. -- `Configuration.Tests/DraftValidatorHeidenhainTests`, `AdminUI.Tests/Uns/HeidenhainAuthoringTests` + the fleet +- `HeidenhainAlarmTests` (#831) — selector grammar, pairing, transition rules, and against the simulator: the captured + iTNC 530 alarm record raising/clearing conditions, local-only ack (zero bytes sent), quality-only comms-loss/refusal, + handler-detached recording, identity-change rediscovery; every case asserts the wire log stays inside the read set. +- `Runtime.Tests/Drivers/HeidenhainNativeAlarmEndToEndTests` — driver → simulator → real `DriverHostActor` condition. +- `Configuration.Tests/DraftValidatorHeidenhainTests`, `AdminUI.Tests/Uns/HeidenhainAuthoringTests`, + `AdminUI.Tests/Uns/HeidenhainAuthoringUxTests` (#830 CSV preview ⇔ commit parity, data-type lock, single + credential notice) + the fleet parity suites. - **LiveIntegration** (`--filter Category=LiveIntegration`, skipped unless `HEIDENHAIN_LSV2_HOST` is set; optional `HEIDENHAIN_LSV2_PORT`, `OTOPCUA_HEIDENHAIN_INSPECT_PASSWORD`): read-only INSPECT identity/selector/layout survey and a @@ -223,5 +303,5 @@ No real control was available for this port. Still open: Writes of any kind, file upload/download, remote input/keyboard/motion, parameter edits, PLCDEBUG/MONITOR/FILE roles, scope setup, arbitrary requests, buffer-size negotiation, vendor SDK, serial transport, built-in SSH, -PLC-wide symbol discovery / memory scanning, OEE mapping, and native alarms as Part 9 conditions (alarms are -published as typed arrays). +PLC-wide symbol discovery / memory scanning, OEE mapping, and any controller-side alarm acknowledgement (native alarm +conditions are acknowledged server-side only — see [Alarm conditions](#alarm-conditions-alarm-gitea-831)). diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/DeviceForms/HeidenhainDeviceModel.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/DeviceForms/HeidenhainDeviceModel.cs index 4d9b43db2..d4f13462e 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/DeviceForms/HeidenhainDeviceModel.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/DeviceForms/HeidenhainDeviceModel.cs @@ -42,12 +42,14 @@ public sealed class HeidenhainDeviceModel return TagConfigJson.Serialize(_bag); } + /// + /// The endpoint's verdict (host / port). Credential fields are deliberately NOT reported here (lmxopcua#830): + /// the hosting DeviceModal shows the save gate's refusal (DriverConfigSaveGate, which covers every + /// password field) once and disables Save — reporting it here too printed the same error twice. + /// public string? Validate() { - var errors = HeidenhainOptionsParser.ValidateDevice(new HeidenhainDeviceOptions { Host = Host.Trim(), Port = Port }).ToList(); - foreach (var (name, v) in new[] { ("INSPECT", InspectPassword), ("DNC", DncPassword), ("DATA", DataPassword) }) - if (!HeidenhainOptionsParser.IsCredentialReference(Blank(v))) - errors.Add($"{name} password must be blank, secret:heidenhain/ or env:OTOPCUA_HEIDENHAIN_ — never a literal."); + var errors = HeidenhainOptionsParser.ValidateDevice(new HeidenhainDeviceOptions { Host = Host.Trim(), Port = Port }); return errors.Count == 0 ? null : string.Join(" ", errors); } diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Raw/RawManualTagEntryModal.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Raw/RawManualTagEntryModal.razor index abfe80c7c..fafc9658b 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Raw/RawManualTagEntryModal.razor +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Raw/RawManualTagEntryModal.razor @@ -49,13 +49,23 @@ } - + @if (RawTagRowDefaults.DictatedDataType(DriverType, row.TagConfig) is { } dictated) + { + @* lmxopcua#830: the driver dictates the type for this address — show it, locked, + rather than offer a choice the commit would replace. *@ + + } + else + { + + } +
Set by the address.
+ } + else + { + + @foreach (var dt in DataTypes) + { + + } + + }
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/RawTagCsvMapper.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/RawTagCsvMapper.cs index c9d39f804..8ddb93541 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/RawTagCsvMapper.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/RawTagCsvMapper.cs @@ -246,6 +246,13 @@ public static class RawTagCsvMapper return Invalid(rowNumber, cells, $"TagConfig: {configError}"); } + // The driver's Tag-ROW rules (access level / data type — lmxopcua#830): the SAME authority the commit + // (RawTreeService.ImportTagsAsync) applies, so a row the commit would refuse is never previewed as OK. + if (RawTagRowRules.Problem(driverType, access, dataType, json) is { } rowError) + { + return Invalid(rowNumber, cells, rowError); + } + var importRow = new RawTagImportRow(groupPath, new RawTagInput(name, dataType, access, writeIdempotent, pollGroupId, json)); return new RawCsvReviewRow diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/RawTagRowDefaults.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/RawTagRowDefaults.cs index 5718c756c..66e3de04f 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/RawTagRowDefaults.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/RawTagRowDefaults.cs @@ -1,22 +1,27 @@ using ZB.MOM.WW.OtOpcUa.Configuration.Enums; -using ZB.MOM.WW.OtOpcUa.Core.Abstractions; -using ZB.MOM.WW.OtOpcUa.Driver.Heidenhain; namespace ZB.MOM.WW.OtOpcUa.AdminUI.Uns; /// -/// Authoring-time defaults for a raw tag ROW that a driver dictates (lmxopcua#803): for a Heidenhain tag the -/// access level is always Read and Tag.DataType follows the address (catalog type, or the binding's -/// declared type). Used by the tag modal and the manual-entry grid so the operator never has to pick values the -/// server (RawTreeService) and the deploy gate would refuse. +/// Authoring-time defaults for a raw tag ROW that a driver dictates (lmxopcua#803/#830): for a Heidenhain tag the +/// access level is always Read and Tag.DataType follows the address (catalog type, the binding's declared +/// type, or Boolean for an alarm condition). Used by the tag modal and the manual-entry grid, which LOCK both +/// fields while they are dictated — so the operator can neither pick a value the save path would refuse nor have +/// one silently replaced on commit. The rules themselves live in . /// public static class RawTagRowDefaults { /// True for a driver whose tags can never be writable. /// The driver type. /// Whether the access level is fixed at Read. - public static bool IsReadOnlyDriver(string? driverType) => - string.Equals(driverType, DriverTypeNames.Heidenhain, StringComparison.OrdinalIgnoreCase); + public static bool IsReadOnlyDriver(string? driverType) => RawTagRowRules.IsReadOnlyDriver(driverType); + + /// The data type the driver dictates for this TagConfig (lock the field to it), or null. + /// The driver type. + /// The row's TagConfig. + /// The dictated type, or null when the operator chooses. + public static string? DictatedDataType(string? driverType, string? tagConfig) => + RawTagRowRules.DictatedDataType(driverType, tagConfig); /// Applies the driver-dictated access level and data type for a row, when the driver dictates them. /// The driver type. @@ -25,8 +30,7 @@ public static class RawTagRowDefaults /// Sets the row's Tag.DataType. public static void Apply(string? driverType, string? tagConfig, Action setAccess, Action setDataType) { - if (!IsReadOnlyDriver(driverType)) return; - setAccess(TagAccessLevel.Read); - if (HeidenhainTagRules.ExpectedTagDataType(tagConfig) is { } dataType) setDataType(dataType); + if (IsReadOnlyDriver(driverType)) setAccess(TagAccessLevel.Read); + if (DictatedDataType(driverType, tagConfig) is { } dataType) setDataType(dataType); } } diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/RawTagRowRules.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/RawTagRowRules.cs new file mode 100644 index 000000000..159e838d3 --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/RawTagRowRules.cs @@ -0,0 +1,66 @@ +using ZB.MOM.WW.OtOpcUa.Configuration.Enums; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; +using ZB.MOM.WW.OtOpcUa.Driver.Heidenhain; + +namespace ZB.MOM.WW.OtOpcUa.AdminUI.Uns; + +/// +/// The ONE authority on driver-specific Tag-ROW rules — rules about the row's access level and +/// Tag.DataType, beyond its TagConfig (lmxopcua#830). Every authoring path asks it: the server save path +/// ( create / update / import), the CSV import's staged review grid +/// (), and the manual-entry grid + tag modal (through , +/// which locks the dictated fields). A preview that skipped it used to show "OK" for a row the commit then refused. +/// +/// +/// A driver with row rules registers its functions in ; the call sites never name a driver. +/// The deploy gate applies the same driver functions (e.g. HeidenhainDeployChecks → +/// ), so authoring and deploy cannot disagree. +/// +public static class RawTagRowRules +{ + /// One driver's row rule: (readWrite, tagDataType, tagConfig) → refusal or null. + /// Whether the row is ReadWrite. + /// The row's Tag.DataType. + /// The row's TagConfig. + public delegate string? RowRule(bool readWrite, string? tagDataType, string? tagConfig); + + /// One driver's dictated data type for a TagConfig, or null when the operator chooses. + /// The row's TagConfig. + public delegate string? DataTypeRule(string? tagConfig); + + private static readonly Dictionary Rules = + new(StringComparer.OrdinalIgnoreCase) + { + [DriverTypeNames.Heidenhain] = (HeidenhainTagRules.DescribeRowProblem, HeidenhainTagRules.ExpectedTagDataType, true), + }; + + /// The driver types that carry row rules (test seam / documentation). + public static IReadOnlyCollection DriverTypesWithRules => Rules.Keys; + + /// Why a row may not be saved for this driver, or null when it may. + /// The owning driver's type. + /// The row's access level. + /// The row's Tag.DataType. + /// The row's (normalized) TagConfig. + /// The refusal, or null. + public static string? Problem(string? driverType, TagAccessLevel accessLevel, string? tagDataType, string? tagConfig) => + driverType is not null && Rules.TryGetValue(driverType, out var r) + ? r.Row(accessLevel == TagAccessLevel.ReadWrite, tagDataType, tagConfig) + : null; + + /// True for a driver whose tags can never be writable (the access level is fixed at Read). + /// The driver type. + /// Whether the access level is fixed. + public static bool IsReadOnlyDriver(string? driverType) => + driverType is not null && Rules.TryGetValue(driverType, out var r) && r.ReadOnly; + + /// + /// The Tag.DataType the driver dictates for this TagConfig (so a form must lock the field to it), or + /// null when the operator chooses (any driver without a rule, or a TagConfig that does not resolve yet). + /// + /// The driver type. + /// The TagConfig. + /// The dictated type name, or null. + public static string? DictatedDataType(string? driverType, string? tagConfig) => + driverType is not null && Rules.TryGetValue(driverType, out var r) ? r.DataType(tagConfig) : null; +} diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/RawTreeService.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/RawTreeService.cs index 148a37a39..6b5a9064d 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/RawTreeService.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/RawTreeService.cs @@ -1272,14 +1272,11 @@ public sealed class RawTreeService(IDbContextFactory dbF .FirstOrDefaultAsync(ct); /// - /// Driver-specific Tag-ROW rules beyond the TagConfig (lmxopcua#803): a Heidenhain tag must be Read-only - /// and carry the data type the driver publishes — the same rule the deploy gate applies. + /// Driver-specific Tag-ROW rules beyond the TagConfig (lmxopcua#803/#830) — , the + /// same authority the CSV review grid previews with, and the same driver rule the deploy gate applies. /// private static string? TagRowProblem(string? driverType, RawTagInput input, string tagConfig) => - string.Equals(driverType, DriverTypeNames.Heidenhain, StringComparison.OrdinalIgnoreCase) - ? Driver.Heidenhain.HeidenhainTagRules.DescribeRowProblem( - input.AccessLevel == Configuration.Enums.TagAccessLevel.ReadWrite, input.DataType, tagConfig) - : null; + RawTagRowRules.Problem(driverType, input.AccessLevel, input.DataType, tagConfig); /// /// The driver-typed TagConfig gate, applied on the SERVER for every tag-writing path — diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/HeidenhainAuthoringTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/HeidenhainAuthoringTests.cs index d7b59a66d..0c2c5262d 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/HeidenhainAuthoringTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/HeidenhainAuthoringTests.cs @@ -53,7 +53,10 @@ public sealed class HeidenhainAuthoringTests { var m = HeidenhainDriverFormModel.FromJson("{}"); m.DncPassword = "hunter2"; - m.Validate()!.ShouldNotContain("hunter2"); + // #830: the credential refusal is the hosting modal's (the save gate), reported once — not the form's too. + var refusal = DriverConfigSaveGate.Validate(DriverTypeNames.Heidenhain, m.ToJson()); + refusal.ShouldNotBeNull(); + refusal.ShouldNotContain("hunter2"); m.DncPassword = null; m.StatusMs = 10; m.Validate().ShouldNotBeNull(); @@ -66,7 +69,7 @@ public sealed class HeidenhainAuthoringTests d.Port.ShouldBe(19000); d.Validate().ShouldBeNull(); d.DataPassword = "plain"; - d.Validate().ShouldNotBeNull(); + DriverConfigSaveGate.Validate(DriverTypeNames.Heidenhain, d.ToJson()).ShouldNotBeNull(); // #830: the modal's gate d.DataPassword = "env:OTOPCUA_HEIDENHAIN_DATA"; var json = d.ToJson(); JsonNode.Parse(json)!["Note"]!.GetValue().ShouldBe("keep"); diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/HeidenhainAuthoringUxTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/HeidenhainAuthoringUxTests.cs new file mode 100644 index 000000000..b5a48a91a --- /dev/null +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/HeidenhainAuthoringUxTests.cs @@ -0,0 +1,176 @@ +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers.DeviceForms; +using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers.Forms; +using ZB.MOM.WW.OtOpcUa.AdminUI.Uns; +using ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors; +using ZB.MOM.WW.OtOpcUa.Configuration.Enums; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; +using ZB.MOM.WW.OtOpcUa.Driver.Heidenhain; + +namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Uns; + +/// +/// lmxopcua#830 authoring UX: (a) the CSV review grid previews the SAME row rule the commit applies, (b) the +/// driver-dictated data type is a lock, never a silent rewrite, (c) a credential refusal is reported once. +/// Plus the #831 ALARM: authoring seams (editor seeding, native-alarm editor visibility). +/// +[Trait("Category", "Unit")] +public sealed class HeidenhainAuthoringUxTests +{ + private static CancellationToken Ct => TestContext.Current.CancellationToken; + + private const string Header = "Name,DataType,AccessLevel,TagConfigJson"; + + private static string Csv(params string[] rows) => Header + "\n" + string.Join("\n", rows); + + private static string Json(string json) => "\"" + json.Replace("\"", "\"\"") + "\""; + + // ---- (a) CSV preview == commit ---- + + [Fact] + public void Csv_preview_flags_ReadWrite_and_data_type_mismatch_rows_like_the_commit() + { + var exec = Json("""{"address":"Status/ExecutionState"}"""); + var result = RawTagCsvMapper.Parse(DriverTypeNames.Heidenhain, Csv( + $"ok,Int32,Read,{exec}", + $"rw,Int32,ReadWrite,{exec}", + $"dt,Double,Read,{exec}", + $"feed,Int32,Read,{Json("""{"address":"Overrides/Feed"}""")}")); + + result.FatalError.ShouldBeNull(); + var rows = result.Rows; + rows[0].Ok.ShouldBeTrue(rows[0].Error); + rows[1].Error!.ShouldContain("read-only"); + rows[2].Error!.ShouldContain("Int32"); + rows[3].Error!.ShouldContain("Double"); // a Float64 leaf publishes Double — picked as Int32 it used to preview OK + result.CanCommit.ShouldBeFalse(); + } + + [Fact] + public async Task Csv_preview_and_ImportTags_agree_row_for_row() + { + var exec = Json("""{"address":"Status/ExecutionState"}"""); + var csv = Csv($"ok,Int32,Read,{exec}", $"rw,Int32,ReadWrite,{exec}", $"dt,String,Read,{exec}", + $"plc,Int64,Read,{Json("""{"address":"PLC:DWORD:0","dataType":"Int64"}""")}"); + var preview = RawTagCsvMapper.Parse(DriverTypeNames.Heidenhain, csv); + + var name = RawTreeTestDb.SeedFresh(); + var svc = new RawTreeService(RawTreeTestDb.Factory(name)); + var created = await svc.CreateDriverAsync(RawTreeTestDb.ClusterId, null, "hh1", DriverTypeNames.Heidenhain, "{}", Ct); + string deviceId; + using (var db = RawTreeTestDb.CreateNamed(name)) deviceId = db.Devices.Single(d => d.DriverInstanceId == created.CreatedId).DeviceId; + + // Feed EVERY parsed row (the invalid ones too, rebuilt from their cells) to the commit and compare verdicts. + foreach (var row in preview.Rows) + { + var dataType = row.Cells[1]; + var access = Enum.Parse(row.Cells[2]); + var input = new RawTagInput(row.Cells[0], dataType, access, false, null, row.Cells[3]); + var outcome = await svc.ImportTagsAsync(deviceId, [new RawTagImportRow(null, input)], Ct); + (outcome.Errors.Count == 0).ShouldBe(row.Ok, $"{row.Name}: preview {row.Error ?? "OK"} vs commit {string.Join("; ", outcome.Errors)}"); + } + } + + [Fact] + public void Row_rules_are_driver_dispatched_not_named_at_call_sites() + { + RawTagRowRules.DriverTypesWithRules.ShouldContain(DriverTypeNames.Heidenhain); + RawTagRowRules.Problem(DriverTypeNames.Modbus, TagAccessLevel.ReadWrite, "Float", "{}").ShouldBeNull(); + RawTagRowRules.Problem(DriverTypeNames.Heidenhain, TagAccessLevel.ReadWrite, "Int32", """{"address":"Status/ExecutionState"}""") + .ShouldBe(HeidenhainTagRules.DescribeRowProblem(true, "Int32", """{"address":"Status/ExecutionState"}""")); + RawTagRowRules.IsReadOnlyDriver(DriverTypeNames.Heidenhain).ShouldBeTrue(); + RawTagRowRules.IsReadOnlyDriver(DriverTypeNames.S7).ShouldBeFalse(); + } + + // ---- (b) dictated data type is a lock ---- + + [Theory] + [InlineData("""{"address":"Axes/Positions","isArray":true}""", "Double")] + [InlineData("""{"address":"PLC:WORD:3","dataType":"Int16"}""", "Int16")] + [InlineData("""{"address":"ALARM:*","alarm":{}}""", "Boolean")] + public void The_dictated_data_type_is_what_the_grid_and_modal_lock_to(string config, string expected) + { + RawTagRowDefaults.DictatedDataType(DriverTypeNames.Heidenhain, config).ShouldBe(expected); + // ...and the locked value is exactly what the save path accepts — nothing to rewrite on commit. + RawTagRowRules.Problem(DriverTypeNames.Heidenhain, TagAccessLevel.Read, expected, config).ShouldBeNull(); + } + + [Fact] + public void An_unresolved_config_or_another_driver_leaves_the_data_type_to_the_operator() + { + RawTagRowDefaults.DictatedDataType(DriverTypeNames.Heidenhain, "{}").ShouldBeNull(); + RawTagRowDefaults.DictatedDataType(DriverTypeNames.Modbus, """{"address":"Axes/Positions","isArray":true}""").ShouldBeNull(); + } + + // ---- (c) credential error shown once ---- + + [Fact] + public void A_literal_password_is_reported_by_the_modal_save_gate_only_not_also_by_the_form() + { + var driver = HeidenhainDriverFormModel.FromJson("{}"); + driver.InspectPassword = "hunter2"; + var json = driver.ToJson(); + DriverConfigSaveGate.Validate(DriverTypeNames.Heidenhain, json).ShouldNotBeNull(); // the modal's one notice + Save disabled + driver.Validate().ShouldBeNull(); // the form no longer repeats it + driver.StatusMs = 10; + driver.Validate()!.ShouldContain("StatusMs"); // non-credential problems still show in the form + + var device = HeidenhainDeviceModel.FromJson("""{"Host":"cnc-7"}"""); + device.DncPassword = "hunter2"; + DriverConfigSaveGate.Validate(DriverTypeNames.Heidenhain, device.ToJson()).ShouldNotBeNull(); + device.Validate().ShouldBeNull(); + HeidenhainDeviceModel.FromJson("{}").Validate().ShouldNotBeNull(); // host still required in the form + } + + // ---- #831 authoring seams ---- + + [Fact] + public void Picking_an_alarm_selector_seeds_the_alarm_object_and_leaving_it_drops_it() + { + var m = HeidenhainTagConfigModel.FromJson("{}"); + m.Address = "ALARM:CLASS=7"; + var json = m.ToJson(); + m.Validate().ShouldBeNull(); + TagConfigValidator.Validate(DriverTypeNames.Heidenhain, json).ShouldBeNull(); + NativeAlarmModel.FromJson(json).IsAlarm.ShouldBeTrue(); + HeidenhainTagDefinitionFactory.TryFromTagConfig("p", "d", json, out var def, out _).ShouldBeTrue(); + def!.Alarm!.Severity.ShouldBe(HeidenhainTagConfigModel.DefaultAlarmSeverity); + + // An authored severity is never overwritten by a later edit. + var edited = NativeAlarmModel.Edit(json, a => a.Severity = 950); + var again = HeidenhainTagConfigModel.FromJson(edited); + again.Address = "ALARM:*"; + HeidenhainTagDefinitionFactory.TryFromTagConfig("p", "d", again.ToJson(), out def, out _).ShouldBeTrue(); + def!.Alarm!.Severity.ShouldBe(950); + + again.Address = "Status/ExecutionState"; + var plain = again.ToJson(); + NativeAlarmModel.FromJson(plain).IsAlarm.ShouldBeFalse(); + TagConfigValidator.Validate(DriverTypeNames.Heidenhain, plain).ShouldBeNull(); + } + + [Fact] + public void Heidenhain_offers_the_native_alarm_editor_and_a_csv_alarm_row_imports() + { + NativeAlarmModel.SupportsNativeAlarms(DriverTypeNames.Heidenhain).ShouldBeTrue(); + var result = RawTagCsvMapper.Parse(DriverTypeNames.Heidenhain, + "Name,DataType,AccessLevel,TagConfigJson,Alarm.AlarmType,Alarm.Severity\n" + + $"Any,Boolean,Read,{Json("""{"address":"ALARM:*"}""")},AlarmCondition,800\n" + + $"NoAlarm,Boolean,Read,{Json("""{"address":"ALARM:*"}""")},,\n"); + result.Rows[0].Ok.ShouldBeTrue(result.Rows[0].Error); + result.Rows[1].Ok.ShouldBeFalse(); // a selector without the alarm object is refused in the preview + } + + [Fact] + public void The_driver_form_round_trips_the_alarm_cadence() + { + var m = HeidenhainDriverFormModel.FromJson("{}"); + m.AlarmMs.ShouldBe(2000); + m.AlarmMs = 500; + var o = HeidenhainOptionsParser.Parse(m.ToJson()); + o.Polling.AlarmMs.ShouldBe(500); + m.AlarmMs = 50; + m.Validate()!.ShouldContain("AlarmMs"); + } +}