Merge feat/830-heidenhain-followups

This commit is contained in:
Joseph Doherty
2026-11-11 01:55:10 -05:00
33 changed files with 1802 additions and 82 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);
}
@@ -90,6 +90,10 @@
<label class="form-label" for="hhSlow">Slow (ms)</label>
<InputNumber id="hhSlow" @bind-Value="_m.SlowMs" @bind-Value:after="EmitAsync" class="form-control form-control-sm" />
</div>
<div class="col-md-2 mb-3">
<label class="form-label" for="hhAlarmMs" title="Cadence of the alarm-list walk behind ALARM: Part 9 conditions; only polled when the control carries an ALARM: tag">Alarm conditions (ms)</label>
<InputNumber id="hhAlarmMs" @bind-Value="_m.AlarmMs" @bind-Value:after="EmitAsync" class="form-control form-control-sm" />
</div>
<div class="col-md-2 mb-3">
<label class="form-label" for="hhAlarms">Max alarms</label>
<InputNumber id="hhAlarms" @bind-Value="_m.MaxAlarms" @bind-Value:after="EmitAsync" class="form-control form-control-sm" />
@@ -22,6 +22,7 @@ public sealed class HeidenhainDriverFormModel
public int StatusMs { get; set; } = 1000;
public int CustomMs { get; set; } = 1000;
public int SlowMs { get; set; } = 10000;
public int AlarmMs { get; set; } = 2000;
public int MaxAlarms { get; set; } = 64;
public bool TimersEnabled { get; set; }
public int ReconnectInitialMs { get; set; } = 1000;
@@ -60,6 +61,7 @@ public sealed class HeidenhainDriverFormModel
StatusMs = o.Polling.StatusMs,
CustomMs = o.Polling.CustomMs,
SlowMs = o.Polling.SlowMs,
AlarmMs = o.Polling.AlarmMs,
MaxAlarms = o.Polling.MaxAlarms,
TimersEnabled = o.Polling.TimersEnabled,
ReconnectInitialMs = o.Reconnect.InitialDelayMs,
@@ -91,6 +93,7 @@ public sealed class HeidenhainDriverFormModel
["StatusMs"] = StatusMs,
["CustomMs"] = CustomMs,
["SlowMs"] = SlowMs,
["AlarmMs"] = AlarmMs,
["MaxAlarms"] = MaxAlarms,
["TimersEnabled"] = TimersEnabled,
};
@@ -98,13 +101,23 @@ public sealed class HeidenhainDriverFormModel
return _bag.ToJsonString(JsonOptions);
}
/// <summary>The driver's own verdict on this config (device endpoints are validated on the device form).</summary>
/// <summary>
/// The driver's own verdict on this config's ranges (device endpoints are validated on the device form).
/// Credential fields are deliberately NOT reported here (lmxopcua#830): the hosting <c>DriverConfigModal</c>
/// shows the save gate's refusal (<c>DriverConfigSaveGate</c>, which scans every password field of the stored
/// document) once and disables Save — reporting it here too printed the same error twice.
/// </summary>
public string? Validate()
{
var o = HeidenhainOptionsParser.Parse(ToJson());
var errors = HeidenhainOptionsParser.Validate(new HeidenhainDriverOptions
{
TimeoutMs = o.TimeoutMs, ProbeTimeoutSeconds = o.ProbeTimeoutSeconds, Access = o.Access,
TimeoutMs = o.TimeoutMs, ProbeTimeoutSeconds = o.ProbeTimeoutSeconds,
Access = new HeidenhainAccessOptions
{
TelemetryEnabled = o.Access.TelemetryEnabled, DncLoginEnabled = o.Access.DncLoginEnabled,
DataEnabled = o.Access.DataEnabled,
},
Polling = o.Polling, Reconnect = o.Reconnect,
});
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">
@@ -12,13 +12,15 @@
<label class="form-label" for="hh-address">Address</label>
<input id="hh-address" type="text" class="form-control form-control-sm mono" list="hh-catalog"
value="@_m.Address" @onchange="@(e => Update(() => _m.Address = e.Value?.ToString() ?? ""))"
placeholder="Status/ExecutionState · PLC:WORD:4 · DATA:/PLC/memory/W/8" />
placeholder="Status/ExecutionState · PLC:WORD:4 · DATA:/PLC/memory/W/8 · ALARM:*" />
<datalist id="hh-catalog">
@foreach (var e in HeidenhainCatalog.Entries) { <option value="@e.Address"></option> }
</datalist>
<div class="form-text">
A fixed leaf, <span class="mono">PLC:AREA:INDEX</span> (element index into the control's advertised region; AREA one of
@string.Join(", ", HeidenhainAddress.AreaTokens)) or <span class="mono">DATA:/path</span> (needs DATA access enabled on the driver).
@string.Join(", ", HeidenhainAddress.AreaTokens)), <span class="mono">DATA:/path</span> (needs DATA access enabled on the driver),
or an alarm condition <span class="mono">ALARM:*</span> · <span class="mono">ALARM:OTHER</span> ·
<span class="mono">ALARM:CLASS=7[/GROUP=4][/NUMBER=100..199]</span> (enable <em>Native alarm</em> below; acknowledgement is server-side only).
</div>
</div>
<div class="col-md-4">
@@ -28,6 +30,11 @@
<input id="hh-datatype" type="text" class="form-control form-control-sm mono" value="@CatalogShape()" disabled readonly />
<div class="form-text">Fixed by the namespace catalog.</div>
}
else if (_m.IsAlarmAddress)
{
<input id="hh-datatype" type="text" class="form-control form-control-sm mono" value="@HeidenhainTagDefinitionFactory.AlarmConditionDataType" disabled readonly />
<div class="form-text">A Part 9 condition (active / inactive), not a value.</div>
}
else
{
<select id="hh-datatype" class="form-select form-select-sm" value="@_m.DataType"
@@ -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 —
@@ -32,6 +32,21 @@ public sealed class HeidenhainTagConfigModel
/// <summary>True when <see cref="Address"/> is a fixed catalog leaf.</summary>
public bool IsCatalogAddress => HeidenhainCatalog.TryGet(Address.Trim(), out _);
/// <summary>
/// True when <see cref="Address"/> is an <c>ALARM:</c> condition selector (lmxopcua#831): its data type is fixed
/// (<see cref="HeidenhainTagDefinitionFactory.AlarmConditionDataType"/>) and it needs the tag's native alarm enabled.
/// </summary>
public bool IsAlarmAddress => Address.Trim().StartsWith(HeidenhainAlarmSelector.Prefix, StringComparison.Ordinal);
/// <summary>True when the data type is dictated by the address (catalog leaf or alarm condition), not chosen.</summary>
public bool HasFixedShape => IsCatalogAddress || IsAlarmAddress;
/// <summary>The Part 9 subtype seeded for a freshly picked <c>ALARM:</c> selector.</summary>
public const string DefaultAlarmType = "AlarmCondition";
/// <summary>The severity (1..1000) seeded for a freshly picked <c>ALARM:</c> selector.</summary>
public const int DefaultAlarmSeverity = 700;
public static HeidenhainTagConfigModel FromJson(string? json)
{
var o = TagConfigJson.ParseOrNew(json);
@@ -39,6 +54,7 @@ public sealed class HeidenhainTagConfigModel
var typeText = TagConfigJson.GetString(o, "dataType");
DriverDataType type;
if (HeidenhainCatalog.TryGet(address.Trim(), out var entry)) type = entry.DataType;
else if (address.Trim().StartsWith(HeidenhainAlarmSelector.Prefix, StringComparison.Ordinal)) type = HeidenhainTagDefinitionFactory.AlarmConditionDataType;
else if (typeText is not null && HeidenhainTagDefinitionFactory.TryParseDataTypeName(typeText, out var parsed)) type = parsed;
else type = DriverDataType.Int32;
return new HeidenhainTagConfigModel { Address = address, DataType = type, _bag = o, _source = json ?? "" };
@@ -54,12 +70,28 @@ public sealed class HeidenhainTagConfigModel
TagConfigJson.Set(_bag, "dataType", entry.DataType);
TagConfigJson.Set(_bag, "isArray", entry.IsArray ? true : null);
}
else if (IsAlarmAddress)
{
TagConfigJson.Set(_bag, "dataType", HeidenhainTagDefinitionFactory.AlarmConditionDataType);
TagConfigJson.Set(_bag, "isArray", null);
}
else
{
TagConfigJson.Set(_bag, "dataType", DataType);
TagConfigJson.Set(_bag, "isArray", null);
}
// An ALARM: selector and the cross-driver alarm object only work together (the driver refuses either alone),
// so an edit keeps them paired: picking a selector seeds a ready-to-author condition (the Galaxy picker
// convenience — every authoring path, incl. the manual-entry grid that has no alarm section, can then save it),
// and leaving the selector drops the object that could no longer be raised. An authored object is never
// overwritten.
var hasAlarm = _bag.TryGetPropertyValue("alarm", out var alarmNode) && alarmNode is JsonObject;
if (IsAlarmAddress && !hasAlarm)
_bag["alarm"] = new JsonObject { ["alarmType"] = DefaultAlarmType, ["severity"] = DefaultAlarmSeverity };
else if (!IsAlarmAddress && hasAlarm)
_bag.Remove("alarm");
return _source = TagConfigJson.Serialize(_bag);
}
@@ -132,7 +132,7 @@ public sealed class NativeAlarmModel
{
Core.Abstractions.DriverTypeNames.MTConnect, Core.Abstractions.DriverTypeNames.AbCip,
Core.Abstractions.DriverTypeNames.FOCAS, Core.Abstractions.DriverTypeNames.OpcUaClient,
Core.Abstractions.DriverTypeNames.Galaxy,
Core.Abstractions.DriverTypeNames.Galaxy, Core.Abstractions.DriverTypeNames.Heidenhain,
};
/// <summary>True when the driver type can emit native alarms.</summary>