fix(adminui): Heidenhain authoring UX — CSV preview runs the save-path row rule, dictated data type is locked, credential error shown once (#830)

- 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.
This commit is contained in:
Joseph Doherty
2026-11-11 01:37:51 -05:00
parent 2b0eed1a8f
commit e6cba4bc18
10 changed files with 406 additions and 47 deletions
+89 -9
View File
@@ -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=<r>[/GROUP=<r>][/NUMBER=<r>]` (any subset, any order, each key once) | an alarm matches every named field |
`<r>` 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)).
@@ -42,12 +42,14 @@ public sealed class HeidenhainDeviceModel
return TagConfigJson.Serialize(_bag);
}
/// <summary>
/// The endpoint's verdict (host / port). Credential fields are deliberately NOT reported here (lmxopcua#830):
/// the hosting <c>DeviceModal</c> shows the save gate's refusal (<c>DriverConfigSaveGate</c>, which covers every
/// password field) once and disables Save — reporting it here too printed the same error twice.
/// </summary>
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/<name> or env:OTOPCUA_HEIDENHAIN_<NAME> — never a literal.");
var errors = HeidenhainOptionsParser.ValidateDevice(new HeidenhainDeviceOptions { Host = Host.Trim(), Port = Port });
return errors.Count == 0 ? null : string.Join(" ", errors);
}
@@ -49,13 +49,23 @@
}
</td>
<td>
<select class="form-select form-select-sm" value="@row.DataType"
@onchange="@(e => row.DataType = e.Value?.ToString() ?? "Float")">
@foreach (var dt in DataTypes)
{
<option value="@dt">@dt</option>
}
</select>
@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. *@
<input class="form-control form-control-sm mono" value="@dictated" disabled readonly
title="Set by the address — the driver publishes this type" />
}
else
{
<select class="form-select form-select-sm" value="@row.DataType"
@onchange="@(e => row.DataType = e.Value?.ToString() ?? "Float")">
@foreach (var dt in DataTypes)
{
<option value="@dt">@dt</option>
}
</select>
}
</td>
<td>
<select class="form-select form-select-sm" value="@row.AccessLevel"
@@ -246,7 +256,13 @@
if (nameError is not null) { row.Error = nameError; anyClientError = true; continue; }
if (!seen.Add(row.Name)) { row.Error = "Duplicate name in this batch."; anyClientError = true; continue; }
var configError = TagConfigValidator.Validate(DriverType, row.TagConfig);
if (configError is not null) { row.Error = configError; anyClientError = true; }
if (configError is not null) { row.Error = configError; anyClientError = true; continue; }
// The dictated fields are locked to the driver's values (applied on every config edit), so this
// re-application changes nothing the operator could see; the row rule then runs exactly as the
// server's will — a mismatch is SHOWN, never silently rewritten.
RawTagRowDefaults.Apply(DriverType, row.TagConfig, a => row.AccessLevel = a, d => row.DataType = d);
var rowError = RawTagRowRules.Problem(DriverType, row.AccessLevel, row.DataType, row.TagConfig);
if (rowError is not null) { row.Error = rowError; anyClientError = true; }
}
if (anyClientError)
{
@@ -260,7 +276,6 @@
var failed = 0;
foreach (var row in _rows)
{
RawTagRowDefaults.Apply(DriverType, row.TagConfig, a => row.AccessLevel = a, d => row.DataType = d);
var input = new RawTagInput(
row.Name,
row.DataType,
@@ -46,12 +46,21 @@
</div>
<div class="col-md-6 mb-3">
<label class="form-label" for="raw-tag-dtype">Data type</label>
<InputSelect id="raw-tag-dtype" @bind-Value="_form.DataType" class="form-select form-select-sm">
@foreach (var dt in DataTypes)
{
<option value="@dt">@dt</option>
}
</InputSelect>
@if (RawTagRowDefaults.DictatedDataType(DriverType, _form.TagConfig) is { } dictated)
{
@* lmxopcua#830: locked to what the driver publishes for this address. *@
<input id="raw-tag-dtype" class="form-control form-control-sm mono" value="@dictated" disabled readonly />
<div class="form-text">Set by the address.</div>
}
else
{
<InputSelect id="raw-tag-dtype" @bind-Value="_form.DataType" class="form-select form-select-sm">
@foreach (var dt in DataTypes)
{
<option value="@dt">@dt</option>
}
</InputSelect>
}
</div>
</div>
<div class="row">
@@ -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
@@ -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;
/// <summary>
/// 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 <c>Tag.DataType</c> 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 (<c>RawTreeService</c>) 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 <c>Tag.DataType</c> 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 <see cref="RawTagRowRules"/>.
/// </summary>
public static class RawTagRowDefaults
{
/// <summary>True for a driver whose tags can never be writable.</summary>
/// <param name="driverType">The driver type.</param>
/// <returns>Whether the access level is fixed at Read.</returns>
public static bool IsReadOnlyDriver(string? driverType) =>
string.Equals(driverType, DriverTypeNames.Heidenhain, StringComparison.OrdinalIgnoreCase);
public static bool IsReadOnlyDriver(string? driverType) => RawTagRowRules.IsReadOnlyDriver(driverType);
/// <summary>The data type the driver dictates for this TagConfig (lock the field to it), or null.</summary>
/// <param name="driverType">The driver type.</param>
/// <param name="tagConfig">The row's TagConfig.</param>
/// <returns>The dictated type, or null when the operator chooses.</returns>
public static string? DictatedDataType(string? driverType, string? tagConfig) =>
RawTagRowRules.DictatedDataType(driverType, tagConfig);
/// <summary>Applies the driver-dictated access level and data type for a row, when the driver dictates them.</summary>
/// <param name="driverType">The driver type.</param>
@@ -25,8 +30,7 @@ public static class RawTagRowDefaults
/// <param name="setDataType">Sets the row's <c>Tag.DataType</c>.</param>
public static void Apply(string? driverType, string? tagConfig, Action<TagAccessLevel> setAccess, Action<string> 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);
}
}
@@ -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;
/// <summary>
/// The ONE authority on driver-specific Tag-ROW rules — rules about the row's access level and
/// <c>Tag.DataType</c>, beyond its TagConfig (lmxopcua#830). Every authoring path asks it: the server save path
/// (<see cref="RawTreeService"/> create / update / import), the CSV import's staged review grid
/// (<see cref="RawTagCsvMapper"/>), and the manual-entry grid + tag modal (through <see cref="RawTagRowDefaults"/>,
/// which locks the dictated fields). A preview that skipped it used to show "OK" for a row the commit then refused.
/// </summary>
/// <remarks>
/// A driver with row rules registers its functions in <see cref="Rules"/>; the call sites never name a driver.
/// The deploy gate applies the same driver functions (e.g. <c>HeidenhainDeployChecks</c> →
/// <see cref="HeidenhainTagRules"/>), so authoring and deploy cannot disagree.
/// </remarks>
public static class RawTagRowRules
{
/// <summary>One driver's row rule: (readWrite, tagDataType, tagConfig) → refusal or null.</summary>
/// <param name="readWrite">Whether the row is ReadWrite.</param>
/// <param name="tagDataType">The row's <c>Tag.DataType</c>.</param>
/// <param name="tagConfig">The row's TagConfig.</param>
public delegate string? RowRule(bool readWrite, string? tagDataType, string? tagConfig);
/// <summary>One driver's dictated data type for a TagConfig, or null when the operator chooses.</summary>
/// <param name="tagConfig">The row's TagConfig.</param>
public delegate string? DataTypeRule(string? tagConfig);
private static readonly Dictionary<string, (RowRule Row, DataTypeRule DataType, bool ReadOnly)> Rules =
new(StringComparer.OrdinalIgnoreCase)
{
[DriverTypeNames.Heidenhain] = (HeidenhainTagRules.DescribeRowProblem, HeidenhainTagRules.ExpectedTagDataType, true),
};
/// <summary>The driver types that carry row rules (test seam / documentation).</summary>
public static IReadOnlyCollection<string> DriverTypesWithRules => Rules.Keys;
/// <summary>Why a row may not be saved for this driver, or null when it may.</summary>
/// <param name="driverType">The owning driver's type.</param>
/// <param name="accessLevel">The row's access level.</param>
/// <param name="tagDataType">The row's <c>Tag.DataType</c>.</param>
/// <param name="tagConfig">The row's (normalized) TagConfig.</param>
/// <returns>The refusal, or null.</returns>
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;
/// <summary>True for a driver whose tags can never be writable (the access level is fixed at Read).</summary>
/// <param name="driverType">The driver type.</param>
/// <returns>Whether the access level is fixed.</returns>
public static bool IsReadOnlyDriver(string? driverType) =>
driverType is not null && Rules.TryGetValue(driverType, out var r) && r.ReadOnly;
/// <summary>
/// The <c>Tag.DataType</c> 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).
/// </summary>
/// <param name="driverType">The driver type.</param>
/// <param name="tagConfig">The TagConfig.</param>
/// <returns>The dictated type name, or null.</returns>
public static string? DictatedDataType(string? driverType, string? tagConfig) =>
driverType is not null && Rules.TryGetValue(driverType, out var r) ? r.DataType(tagConfig) : null;
}
@@ -1272,14 +1272,11 @@ public sealed class RawTreeService(IDbContextFactory<OtOpcUaConfigDbContext> dbF
.FirstOrDefaultAsync(ct);
/// <summary>
/// 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) — <see cref="RawTagRowRules"/>, the
/// same authority the CSV review grid previews with, and the same driver rule the deploy gate applies.
/// </summary>
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);
/// <summary>
/// The driver-typed <c>TagConfig</c> gate, applied on the SERVER for every tag-writing path —
@@ -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<string>().ShouldBe("keep");
@@ -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;
/// <summary>
/// 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).
/// </summary>
[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<TagAccessLevel>(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");
}
}