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
@@ -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 —