feat(heidenhain): controller alarms as native Part 9 conditions; rediscovery on identity change (#831)

- ALARM: selectors (HeidenhainAlarmSelector, Contracts): ALARM:*, ALARM:OTHER,
  ALARM:CLASS=<r>[/GROUP=<r>][/NUMBER=<r>] (<r> = n | lo..hi; negative numbers allowed).
  An ALARM: address and the cross-driver alarm object only work together — the factory
  refuses either alone, so editor, save path, CSV and deploy inspector agree. Data type
  fixed Boolean; never a value (ReadAsync BadNotSupported), never in a data subscription.
- HeidenhainDriver implements IAlarmSource: one condition per ALARM: tag at its RawPath,
  driven by the SAME allowlisted R_RI 27/28 walk on the control's single loop, on the new
  Polling.AlarmMs cadence (default 2000 ms, only for controls carrying an ALARM: tag).
  FOCAS #647 transition rules (Raise / Retrigger / Clear), identity = class+group+number+text,
  deterministic display order, told-record on the driver instance, recorded only while a
  handler listens. A failed walk (refusal, overflow, comms loss) is quality-only, never a clear.
- Acknowledge is LOCAL-ONLY: AcknowledgeAsync is a no-op success (FOCAS/MTConnect posture);
  Lsv2ReadOnlyPolicy is unchanged — no ack or write request exists.
- Opt-in per tag, no driver flag (FOCAS #647 / MTConnect #626 precedent); Alarms/* arrays unchanged.
- IRediscoverable: a reconnect whose R_VR identity (model / NC / PLC / options) differs from
  the previous session raises OnRediscoveryNeeded once (advisory re-browse chip). No extra traffic.
- AdminUI: Heidenhain joins NativeAlarmModel's alarm drivers; the typed editor fixes ALARM:
  tags at Boolean and seeds/drops the alarm object with the selector; driver form gains
  "Alarm conditions (ms)". (HeidenhainDriverFormModel also carries the #830 credential-notice change.)

Tests: HeidenhainAlarmTests (grammar, pairing, rules, simulator e2e with the captured iTNC 530
alarm record, local-only ack = zero bytes sent, quality-only outage/refusal, rediscovery),
Runtime.Tests HeidenhainNativeAlarmEndToEndTests (real DriverHostActor condition), a
DraftValidator case, and the NativeAlarmDriverSetGuard now scans the Heidenhain driver.
This commit is contained in:
Joseph Doherty
2026-11-11 01:37:41 -05:00
parent 5bbe1e3c7a
commit 2b0eed1a8f
22 changed files with 1379 additions and 31 deletions
@@ -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);
@@ -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"
@@ -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>