Merge feat/803-heidenhain-lsv2

This commit is contained in:
Joseph Doherty
2026-11-11 00:03:43 -05:00
77 changed files with 7405 additions and 10 deletions
@@ -0,0 +1,76 @@
@* Embeddable Heidenhain device (one LSV2 control) form body — authors Host/Port and optional per-device
password REFERENCES into the Device's DeviceConfig (PascalCase, matching HeidenhainDeviceOptions). Hosted by
DeviceModal. Literal passwords are refused here and at the deploy gate (HeidenhainCredentialPersisted). *@
@using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers.DeviceForms
<section class="panel rise" style="animation-delay:.04s">
<div class="panel-head">LSV2 endpoint</div>
<div style="padding:1rem">
<div class="row g-3">
<div class="col-md-8">
<label class="form-label" for="hhHost">Host</label>
<InputText id="hhHost" @bind-Value="_model.Host" @bind-Value:after="EmitAsync" class="form-control form-control-sm mono" placeholder="10.20.30.40" />
<div class="form-text">Control hostname or IP. No default endpoint. Plain LSV2 is unencrypted — route it over an externally managed secure network.</div>
</div>
<div class="col-md-4">
<label class="form-label" for="hhPort">Port</label>
<InputNumber id="hhPort" @bind-Value="_model.Port" @bind-Value:after="EmitAsync" class="form-control form-control-sm" />
<div class="form-text">Default 19000.</div>
</div>
</div>
</div>
</section>
<section class="panel rise mt-3" style="animation-delay:.06s">
<div class="panel-head">Per-device credentials (optional overrides)</div>
<div style="padding:1rem">
<div class="row g-3">
<div class="col-md-4">
<label class="form-label" for="hhInspect">INSPECT password ref</label>
<InputText id="hhInspect" @bind-Value="_model.InspectPassword" @bind-Value:after="EmitAsync" class="form-control form-control-sm mono" placeholder="secret:heidenhain/inspect" />
</div>
<div class="col-md-4">
<label class="form-label" for="hhDnc">DNC password ref</label>
<InputText id="hhDnc" @bind-Value="_model.DncPassword" @bind-Value:after="EmitAsync" class="form-control form-control-sm mono" placeholder="secret:heidenhain/dnc" />
</div>
<div class="col-md-4">
<label class="form-label" for="hhData">DATA password ref</label>
<InputText id="hhData" @bind-Value="_model.DataPassword" @bind-Value:after="EmitAsync" class="form-control form-control-sm mono" placeholder="env:OTOPCUA_HEIDENHAIN_DATA_PASSWORD" />
</div>
</div>
<div class="form-text">Blank = use the driver-level reference. <span class="mono">secret:&lt;name&gt;</span> resolves from the node's secret store, <span class="mono">env:&lt;VAR&gt;</span> from the environment, at each login. A literal password is never stored.</div>
@if (_model.Validate() is { } error)
{
<div class="text-danger small mt-2">@error</div>
}
</div>
</section>
@code {
/// <summary>The device's DeviceConfig JSON.</summary>
[Parameter] public string DeviceConfigJson { get; set; } = "{}";
/// <summary>Fired whenever the endpoint changes — enables @bind-DeviceConfigJson.</summary>
[Parameter] public EventCallback<string> DeviceConfigJsonChanged { get; set; }
private HeidenhainDeviceModel _model = new();
private string? _lastParsed;
protected override void OnParametersSet()
{
if (!string.Equals(_lastParsed, DeviceConfigJson, StringComparison.Ordinal))
{
_model = HeidenhainDeviceModel.FromJson(DeviceConfigJson);
_lastParsed = DeviceConfigJson;
}
}
/// <summary>Serializes the current endpoint to DeviceConfig JSON.</summary>
public string GetConfigJson() => _model.ToJson();
private async Task EmitAsync()
{
var json = GetConfigJson();
_lastParsed = json;
await DeviceConfigJsonChanged.InvokeAsync(json);
}
}
@@ -0,0 +1,55 @@
using ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors;
using ZB.MOM.WW.OtOpcUa.Driver.Heidenhain;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers.DeviceForms;
/// <summary>
/// The Heidenhain device (one control's endpoint) DeviceConfig, PascalCase keys matching
/// <see cref="HeidenhainDeviceOptions"/>. Unknown keys are preserved. Password fields hold REFERENCES only
/// (blank / <c>secret:</c> / <c>env:</c>); <see cref="Validate"/> uses the driver's own rule.
/// </summary>
public sealed class HeidenhainDeviceModel
{
public string Host { get; set; } = "";
public int Port { get; set; } = HeidenhainDriverOptions.DefaultPort;
public string? InspectPassword { get; set; }
public string? DncPassword { get; set; }
public string? DataPassword { get; set; }
private System.Text.Json.Nodes.JsonObject _bag = new();
public static HeidenhainDeviceModel FromJson(string? json)
{
var o = TagConfigJson.ParseOrNew(json);
return new HeidenhainDeviceModel
{
Host = TagConfigJson.GetString(o, "Host") ?? "",
Port = TagConfigJson.GetInt(o, "Port", HeidenhainDriverOptions.DefaultPort),
InspectPassword = TagConfigJson.GetString(o, "InspectPassword"),
DncPassword = TagConfigJson.GetString(o, "DncPassword"),
DataPassword = TagConfigJson.GetString(o, "DataPassword"),
_bag = o,
};
}
public string ToJson()
{
TagConfigJson.Set(_bag, "Host", Host.Trim());
TagConfigJson.Set(_bag, "Port", Port);
TagConfigJson.Set(_bag, "InspectPassword", Blank(InspectPassword));
TagConfigJson.Set(_bag, "DncPassword", Blank(DncPassword));
TagConfigJson.Set(_bag, "DataPassword", Blank(DataPassword));
return TagConfigJson.Serialize(_bag);
}
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:<name> or env:<VARIABLE> — never a literal.");
return errors.Count == 0 ? null : string.Join(" ", errors);
}
private static string? Blank(string? v) => string.IsNullOrWhiteSpace(v) ? null : v.Trim();
}
@@ -62,6 +62,10 @@
<DriverTestConnectButton DriverType="@_driverType" GetConfigJson="@BuildMergedProbeConfig" />
</div>
@if (DriverConfigSaveGate.Validate(_driverType, _deviceConfigJson) is { } gateError)
{
<div class="panel notice notice-alert mt-3">@gateError</div>
}
@if (_saveError is not null)
{
<div class="panel notice notice-alert mt-3">@_saveError</div>
@@ -73,7 +77,8 @@
<button type="button" class="btn btn-outline-secondary" @onclick="CloseAsync" disabled="@_busy">Cancel</button>
@if (_loadError is null)
{
<button type="button" class="btn btn-primary" @onclick="SaveAsync" disabled="@_busy">
<button type="button" class="btn btn-primary" @onclick="SaveAsync"
disabled="@(_busy || DriverConfigSaveGate.Validate(_driverType, _deviceConfigJson) is not null)">
@if (_busy) { <span class="spinner-border spinner-border-sm me-1"></span> }
@(_isNew ? "Create device" : "Save changes")
</button>
@@ -195,7 +200,9 @@
private async Task SaveAsync()
{
_busy = true; _saveError = null;
_saveError = DriverConfigSaveGate.Validate(_driverType, _deviceConfigJson);
if (_saveError is not null) return;
_busy = true;
try
{
UnsMutationResult result;
@@ -63,6 +63,10 @@
</p>
}
@if (DriverConfigSaveGate.Validate(_driverType, _driverConfigJson) is { } gateError)
{
<div class="panel notice notice-alert mt-3">@gateError</div>
}
@if (_saveError is not null)
{
<div class="panel notice notice-alert mt-3">@_saveError</div>
@@ -74,7 +78,8 @@
<button type="button" class="btn btn-outline-secondary" @onclick="CloseAsync" disabled="@_busy">Cancel</button>
@if (_loadError is null)
{
<button type="button" class="btn btn-primary" @onclick="SaveAsync" disabled="@_busy">
<button type="button" class="btn btn-primary" @onclick="SaveAsync"
disabled="@(_busy || DriverConfigSaveGate.Validate(_driverType, _driverConfigJson) is not null)">
@if (_busy) { <span class="spinner-border spinner-border-sm me-1"></span> }
@(_isNew ? "Create driver" : "Save changes")
</button>
@@ -185,7 +190,9 @@
private async Task SaveAsync()
{
_busy = true; _saveError = null;
_saveError = DriverConfigSaveGate.Validate(_driverType, _driverConfigJson);
if (_saveError is not null) return;
_busy = true;
try
{
UnsMutationResult result;
@@ -31,6 +31,7 @@ public static class DriverConfigFormMap
[DriverTypeNames.Sql] = typeof(SqlDriverForm),
[DriverTypeNames.Mqtt] = typeof(MqttDriverForm),
[DriverTypeNames.MTConnect] = typeof(MTConnectDriverForm),
[DriverTypeNames.Heidenhain] = typeof(HeidenhainDriverForm),
[DriverTypeNames.Calculation] = typeof(CalculationDriverForm),
};
@@ -67,6 +68,7 @@ public static class DeviceFormMap
[DriverTypeNames.OpcUaClient] = typeof(OpcUaClientDeviceForm),
[DriverTypeNames.Galaxy] = typeof(GalaxyDeviceForm),
[DriverTypeNames.Mqtt] = typeof(MqttDeviceForm),
[DriverTypeNames.Heidenhain] = typeof(HeidenhainDeviceForm),
};
/// <summary>
@@ -0,0 +1,152 @@
@* Embeddable Heidenhain LSV2 driver config form body (lmxopcua#803). The per-control endpoint (Host/Port)
lives on HeidenhainDeviceForm (one /raw Device per control). The driver is READ-ONLY: there is no write
setting to author — the outbound LSV2 allowlist admits only logins and read requests. A thin shell over
HeidenhainDriverFormModel, which owns every rule and preserves keys it does not own (Devices). *@
@using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers
<section class="panel rise mt-3" style="animation-delay:.05s">
<div class="panel-head">Connection</div>
<div style="padding:1rem">
<div class="row">
<div class="col-md-3 mb-3">
<label class="form-label" for="hhTimeout">Transaction timeout (ms)</label>
<InputNumber id="hhTimeout" @bind-Value="_m.TimeoutMs" @bind-Value:after="EmitAsync" class="form-control form-control-sm" />
<div class="form-text">100–60000. Bounds connect and each complete response. Default 5000.</div>
</div>
<div class="col-md-3 mb-3">
<label class="form-label" for="hhReconnect">Reconnect initial delay (ms)</label>
<InputNumber id="hhReconnect" @bind-Value="_m.ReconnectInitialMs" @bind-Value:after="EmitAsync" class="form-control form-control-sm" />
</div>
<div class="col-md-3 mb-3">
<label class="form-label" for="hhReconnectMax">Reconnect max delay (ms)</label>
<InputNumber id="hhReconnectMax" @bind-Value="_m.ReconnectMaxMs" @bind-Value:after="EmitAsync" class="form-control form-control-sm" />
<div class="form-text">Backoff doubles from the initial delay up to this ceiling.</div>
</div>
<div class="col-md-3 mb-3">
<label class="form-label" for="hhProbe">Test Connect timeout (s)</label>
<InputNumber id="hhProbe" @bind-Value="_m.ProbeTimeoutSeconds" @bind-Value:after="EmitAsync" class="form-control form-control-sm" />
</div>
</div>
</div>
</section>
<section class="panel rise mt-3" style="animation-delay:.08s">
<div class="panel-head">Access (read-only roles)</div>
<div style="padding:1rem">
<div class="row">
<div class="col-md-4 mb-3">
<div class="form-check form-switch mt-2">
<InputCheckbox id="hhTelemetry" @bind-Value="_m.TelemetryEnabled" @bind-Value:after="EmitAsync" class="form-check-input" />
<label class="form-check-label" for="hhTelemetry">Telemetry reads</label>
</div>
<div class="form-text">Execution/program state, program, overrides, axes, tool, alarms. Off = no runtime-info requests.</div>
</div>
<div class="col-md-4 mb-3">
<div class="form-check form-switch mt-2">
<InputCheckbox id="hhDnc" @bind-Value="_m.DncLoginEnabled" @bind-Value:after="EmitAsync" class="form-check-input" />
<label class="form-check-label" for="hhDnc">Optional DNC login</label>
</div>
<div class="form-text">A refused DNC login never blocks reads the control permits under INSPECT.</div>
</div>
<div class="col-md-4 mb-3">
<div class="form-check form-switch mt-2">
<InputCheckbox id="hhData" @bind-Value="_m.DataEnabled" @bind-Value:after="EmitAsync" class="form-check-input" />
<label class="form-check-label" for="hhData">DATA path reads</label>
</div>
<div class="form-text">Opt-in DATA login, only when a <span class="mono">DATA:</span> binding needs it (reference-derived; iTNC only).</div>
</div>
</div>
<div class="row">
<div class="col-md-4 mb-3">
<label class="form-label" for="hhInspectPw">INSPECT password ref</label>
<InputText id="hhInspectPw" @bind-Value="_m.InspectPassword" @bind-Value:after="EmitAsync" class="form-control form-control-sm mono" placeholder="secret:heidenhain/inspect" />
</div>
<div class="col-md-4 mb-3">
<label class="form-label" for="hhDncPw">DNC password ref</label>
<InputText id="hhDncPw" @bind-Value="_m.DncPassword" @bind-Value:after="EmitAsync" class="form-control form-control-sm mono" placeholder="secret:heidenhain/dnc" />
</div>
<div class="col-md-4 mb-3">
<label class="form-label" for="hhDataPw">DATA password ref</label>
<InputText id="hhDataPw" @bind-Value="_m.DataPassword" @bind-Value:after="EmitAsync" class="form-control form-control-sm mono" placeholder="env:OTOPCUA_HEIDENHAIN_DATA_PASSWORD" />
</div>
</div>
<div class="form-text">References only: blank, <span class="mono">secret:&lt;name&gt;</span> (node secret store) or <span class="mono">env:&lt;VAR&gt;</span>. Resolved at each login; a literal password is refused.</div>
</div>
</section>
<section class="panel rise mt-3" style="animation-delay:.11s">
<div class="panel-head">Polling &amp; limits</div>
<div style="padding:1rem">
<div class="row">
<div class="col-md-2 mb-3">
<label class="form-label" for="hhStatus">Status (ms)</label>
<InputNumber id="hhStatus" @bind-Value="_m.StatusMs" @bind-Value:after="EmitAsync" class="form-control form-control-sm" />
</div>
<div class="col-md-2 mb-3">
<label class="form-label" for="hhCustom">Custom (ms)</label>
<InputNumber id="hhCustom" @bind-Value="_m.CustomMs" @bind-Value:after="EmitAsync" class="form-control form-control-sm" />
</div>
<div class="col-md-2 mb-3">
<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="hhAlarms">Max alarms</label>
<InputNumber id="hhAlarms" @bind-Value="_m.MaxAlarms" @bind-Value:after="EmitAsync" class="form-control form-control-sm" />
</div>
<div class="col-md-4 mb-3">
<div class="form-check form-switch mt-4">
<InputCheckbox id="hhTimers" @bind-Value="_m.TimersEnabled" @bind-Value:after="EmitAsync" class="form-check-input" />
<label class="form-check-label" for="hhTimers">Experimental timer reads</label>
</div>
<div class="form-text">Selectors 30–34. Units/semantics unverified on real controls — confirm before using as durations.</div>
</div>
</div>
<div class="form-text">Intervals 100–3600000 ms. OPC reads and subscriptions are served from the cache; sampling cannot exceed these cadences.</div>
@if (_m.Validate() is { } error)
{
<div class="text-danger small mt-2">@error</div>
}
</div>
</section>
<DriverResilienceSection ResilienceConfig="@ResilienceConfig" ResilienceConfigChanged="OnResilienceChanged" />
@code {
/// <summary>The driver-level DriverConfig JSON.</summary>
[Parameter] public string DriverConfigJson { get; set; } = "{}";
/// <summary>Fired whenever a field changes — enables @bind-DriverConfigJson.</summary>
[Parameter] public EventCallback<string> DriverConfigJsonChanged { get; set; }
/// <summary>The per-instance resilience-pipeline overrides JSON, or null.</summary>
[Parameter] public string? ResilienceConfig { get; set; }
/// <summary>Fired when the resilience overrides change.</summary>
[Parameter] public EventCallback<string?> ResilienceConfigChanged { get; set; }
private HeidenhainDriverFormModel _m = new();
private string? _lastParsed;
protected override void OnParametersSet()
{
if (!string.Equals(_lastParsed, DriverConfigJson, StringComparison.Ordinal))
{
_m = HeidenhainDriverFormModel.FromJson(DriverConfigJson);
_lastParsed = DriverConfigJson;
}
}
/// <summary>Serializes the current config.</summary>
public string GetConfigJson() => _m.ToJson();
private async Task EmitAsync()
{
var json = GetConfigJson();
_lastParsed = json;
await DriverConfigJsonChanged.InvokeAsync(json);
}
private async Task OnResilienceChanged(string? r)
{
ResilienceConfig = r;
await ResilienceConfigChanged.InvokeAsync(r);
}
}
@@ -0,0 +1,114 @@
using System.Text.Json.Nodes;
using ZB.MOM.WW.OtOpcUa.Driver.Heidenhain;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers.Forms;
/// <summary>
/// Flat, bindable model of the Heidenhain driver-level DriverConfig. Round-trips through a JSON bag so every key
/// the form does not own (the multi-device <c>Devices</c> collection, future keys) survives a load→save. Every
/// field is an int/bool/string — there is no enum to serialize numerically — and <see cref="Validate"/> asks the
/// driver's own <see cref="HeidenhainOptionsParser"/>, so the form accepts exactly what the factory accepts.
/// </summary>
public sealed class HeidenhainDriverFormModel
{
public int TimeoutMs { get; set; } = 5000;
public int ProbeTimeoutSeconds { get; set; } = 10;
public bool TelemetryEnabled { get; set; } = true;
public bool DncLoginEnabled { get; set; } = true;
public bool DataEnabled { get; set; }
public string? InspectPassword { get; set; }
public string? DncPassword { get; set; }
public string? DataPassword { get; set; }
public int StatusMs { get; set; } = 1000;
public int CustomMs { get; set; } = 1000;
public int SlowMs { get; set; } = 10000;
public int MaxAlarms { get; set; } = 64;
public bool TimersEnabled { get; set; }
public int ReconnectInitialMs { get; set; } = 1000;
public int ReconnectMaxMs { get; set; } = 30000;
/// <summary>
/// The one serializer this form writes config through. The document carries no enum today; the string-enum
/// converter is registered anyway so a future enum field can never serialize as a number (the repo-wide
/// form/factory trap) — guarded by <c>DriverPageJsonConverterTests</c>.
/// </summary>
public static readonly System.Text.Json.JsonSerializerOptions JsonOptions = new()
{
Converters = { new System.Text.Json.Serialization.JsonStringEnumConverter() },
};
private JsonObject _bag = new();
public static HeidenhainDriverFormModel FromJson(string? json)
{
JsonObject bag;
try { bag = JsonNode.Parse(string.IsNullOrWhiteSpace(json) ? "{}" : json) as JsonObject ?? new JsonObject(); }
catch (System.Text.Json.JsonException) { bag = new JsonObject(); }
HeidenhainDriverOptions o;
try { o = HeidenhainOptionsParser.Parse(bag.ToJsonString()); }
catch (InvalidOperationException) { o = new HeidenhainDriverOptions(); }
return new HeidenhainDriverFormModel
{
TimeoutMs = o.TimeoutMs,
ProbeTimeoutSeconds = o.ProbeTimeoutSeconds,
TelemetryEnabled = o.Access.TelemetryEnabled,
DncLoginEnabled = o.Access.DncLoginEnabled,
DataEnabled = o.Access.DataEnabled,
InspectPassword = o.Access.InspectPassword,
DncPassword = o.Access.DncPassword,
DataPassword = o.Access.DataPassword,
StatusMs = o.Polling.StatusMs,
CustomMs = o.Polling.CustomMs,
SlowMs = o.Polling.SlowMs,
MaxAlarms = o.Polling.MaxAlarms,
TimersEnabled = o.Polling.TimersEnabled,
ReconnectInitialMs = o.Reconnect.InitialDelayMs,
ReconnectMaxMs = o.Reconnect.MaxDelayMs,
_bag = bag,
};
}
public string ToJson()
{
// Remove any case-variant of the keys this form owns so the bag never carries two spellings.
foreach (var k in new[] { "TimeoutMs", "ProbeTimeoutSeconds", "Access", "Polling", "Reconnect" })
foreach (var existing in _bag.Select(p => p.Key).Where(p => string.Equals(p, k, StringComparison.OrdinalIgnoreCase)).ToList())
_bag.Remove(existing);
_bag["TimeoutMs"] = TimeoutMs;
_bag["ProbeTimeoutSeconds"] = ProbeTimeoutSeconds;
var access = new JsonObject
{
["TelemetryEnabled"] = TelemetryEnabled,
["DncLoginEnabled"] = DncLoginEnabled,
["DataEnabled"] = DataEnabled,
};
if (Blank(InspectPassword) is { } i) access["InspectPassword"] = i;
if (Blank(DncPassword) is { } d) access["DncPassword"] = d;
if (Blank(DataPassword) is { } a) access["DataPassword"] = a;
_bag["Access"] = access;
_bag["Polling"] = new JsonObject
{
["StatusMs"] = StatusMs,
["CustomMs"] = CustomMs,
["SlowMs"] = SlowMs,
["MaxAlarms"] = MaxAlarms,
["TimersEnabled"] = TimersEnabled,
};
_bag["Reconnect"] = new JsonObject { ["InitialDelayMs"] = ReconnectInitialMs, ["MaxDelayMs"] = ReconnectMaxMs };
return _bag.ToJsonString(JsonOptions);
}
/// <summary>The driver's own verdict on this config (device endpoints are validated on the device form).</summary>
public string? Validate()
{
var o = HeidenhainOptionsParser.Parse(ToJson());
var errors = HeidenhainOptionsParser.Validate(new HeidenhainDriverOptions
{
TimeoutMs = o.TimeoutMs, ProbeTimeoutSeconds = o.ProbeTimeoutSeconds, Access = o.Access,
Polling = o.Polling, Reconnect = o.Reconnect,
});
return errors.Count == 0 ? null : string.Join(" ", errors);
}
private static string? Blank(string? v) => string.IsNullOrWhiteSpace(v) ? null : v.Trim();
}
@@ -73,6 +73,7 @@
("Sql", DriverTypeNames.Sql),
("Calculation", DriverTypeNames.Calculation),
("MTConnect", DriverTypeNames.MTConnect),
("Heidenhain", DriverTypeNames.Heidenhain),
];
private string _type = DriverTypeNames.Modbus;
@@ -59,6 +59,7 @@
</td>
<td>
<select class="form-select form-select-sm" value="@row.AccessLevel"
disabled="@RawTagRowDefaults.IsReadOnlyDriver(DriverType)"
@onchange="@(e => row.AccessLevel = ParseAccess(e.Value))">
<option value="@TagAccessLevel.Read">Read</option>
<option value="@TagAccessLevel.ReadWrite">ReadWrite</option>
@@ -209,7 +210,11 @@
private IDictionary<string, object> BuildEditorParameters(RowModel row) => new Dictionary<string, object>
{
["ConfigJson"] = row.TagConfig,
["ConfigJsonChanged"] = EventCallback.Factory.Create<string>(this, v => row.TagConfig = v),
["ConfigJsonChanged"] = EventCallback.Factory.Create<string>(this, v =>
{
row.TagConfig = v;
RawTagRowDefaults.Apply(DriverType, row.TagConfig, a => row.AccessLevel = a, d => row.DataType = d);
}),
["DriverType"] = DriverType,
["GetDriverConfigJson"] = (Func<string>)(() => _mergedConfigJson ?? "{}"),
};
@@ -255,6 +260,7 @@
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,
@@ -57,10 +57,18 @@
<div class="row">
<div class="col-md-6 mb-3">
<label class="form-label" for="raw-tag-access">Access level</label>
<InputSelect id="raw-tag-access" @bind-Value="_form.AccessLevel" class="form-select form-select-sm">
<option value="@TagAccessLevel.Read">Read</option>
<option value="@TagAccessLevel.ReadWrite">ReadWrite</option>
</InputSelect>
@if (IsReadOnlyDriver)
{
<input id="raw-tag-access" class="form-control form-control-sm" value="Read" disabled readonly />
<div class="form-text">This driver is read-only.</div>
}
else
{
<InputSelect id="raw-tag-access" @bind-Value="_form.AccessLevel" class="form-select form-select-sm">
<option value="@TagAccessLevel.Read">Read</option>
<option value="@TagAccessLevel.ReadWrite">ReadWrite</option>
</InputSelect>
}
</div>
<div class="col-md-6 mb-3">
<label class="form-label">WriteIdempotent</label>
@@ -226,7 +234,11 @@
{
["ConfigJson"] = _form.TagConfig,
["ConfigJsonChanged"] = _configJsonChanged ??=
EventCallback.Factory.Create<string>(this, v => _form.TagConfig = v),
EventCallback.Factory.Create<string>(this, v =>
{
_form.TagConfig = v;
RawTagRowDefaults.Apply(DriverType, _form.TagConfig, a => _form.AccessLevel = a, d => _form.DataType = d);
}),
["DriverType"] = DriverType ?? "",
// Live accessor, not a snapshot: the picker reads it when the operator clicks Browse.
["GetDriverConfigJson"] = _getDriverConfigJson ??= () => _mergedConfigJson ?? "{}",
@@ -258,6 +270,7 @@
return;
}
RawTagRowDefaults.Apply(DriverType, _form.TagConfig, a => _form.AccessLevel = a, d => _form.DataType = d);
var input = new RawTagInput(
_form.Name,
_form.DataType,
@@ -283,6 +296,8 @@
}
}
private bool IsReadOnlyDriver => RawTagRowDefaults.IsReadOnlyDriver(DriverType);
private async Task CloseAsync()
{
Visible = false;
@@ -0,0 +1,78 @@
@* Typed tag-config editor for a Heidenhain LSV2 tag (lmxopcua#803). A thin shell over HeidenhainTagConfigModel —
every rule lives there (and, through it, in the driver's HeidenhainTagDefinitionFactory).
The driver is READ-ONLY: there is no Writable toggle, and the deploy gate refuses a ReadWrite access level.
Fixed leaves take the catalog's data type and array rank; only PLC:/DATA: bindings choose a type. *@
@using ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors
@using ZB.MOM.WW.OtOpcUa.Core.Abstractions
@using ZB.MOM.WW.OtOpcUa.Driver.Heidenhain
<div class="row g-2">
<div class="col-md-8">
<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" />
<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).
</div>
</div>
<div class="col-md-4">
<label class="form-label" for="hh-datatype">Data type</label>
@if (_m.IsCatalogAddress)
{
<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
{
<select id="hh-datatype" class="form-select form-select-sm" value="@_m.DataType"
@onchange="@(e => Update(() => _m.DataType = ParseEnum(e.Value, DriverDataType.Int32)))">
@foreach (var v in HeidenhainAddress.CustomDataTypes) { <option value="@v">@v</option> }
</select>
<div class="form-text">Must hold the area's native range (no narrowing): Boolean for bit areas, String for STRING.</div>
}
</div>
</div>
@if (_m.Validate() is { } error)
{
<div class="text-danger small mt-2">@error</div>
}
@code {
/// <summary>The tag's raw TagConfig JSON (supports <c>@bind-ConfigJson</c>).</summary>
[Parameter] public string? ConfigJson { get; set; }
/// <summary>Two-way config callback.</summary>
[Parameter] public EventCallback<string> ConfigJsonChanged { get; set; }
/// <summary>DriverType of the selected driver — supplied by the hosting modal.</summary>
[Parameter] public string DriverType { get; set; } = "";
/// <summary>Live accessor for the selected driver's DriverConfig JSON.</summary>
[Parameter] public Func<string> GetDriverConfigJson { get; set; } = () => "{}";
private HeidenhainTagConfigModel _m = new();
private string? _lastConfigJson;
protected override void OnParametersSet()
{
if (ConfigJson == _lastConfigJson) { return; }
_lastConfigJson = ConfigJson;
_m = HeidenhainTagConfigModel.FromJson(ConfigJson);
}
private string CatalogShape() =>
HeidenhainCatalog.TryGet(_m.Address.Trim(), out var e) ? (e.IsArray ? $"{e.DataType}[]" : e.DataType.ToString()) : "";
private static TEnum ParseEnum<TEnum>(object? v, TEnum fallback) where TEnum : struct, Enum
=> Enum.TryParse<TEnum>(v?.ToString(), out var r) ? r : fallback;
private async Task Update(Action apply)
{
apply();
await ConfigJsonChanged.InvokeAsync(_m.ToJson());
}
}
@@ -0,0 +1,29 @@
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.Heidenhain;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Uns;
/// <summary>
/// Server-side refusal of a DriverConfig / DeviceConfig that must never reach the config database. Called by
/// <see cref="RawTreeService"/> on every driver/device create + update (the authority — a modal that skips
/// its own check, the API or a hand-crafted circuit message still hits it) and by the driver/device modals to
/// block Save while the form is invalid.
/// </summary>
/// <remarks>
/// Today it carries one rule: a Heidenhain password field must be blank or a reference in the Heidenhain
/// namespace (<see cref="HeidenhainOptionsParser.DescribeCredentialProblems"/>) — otherwise a literal password
/// would be persisted in a draft (and replicated) even though the deploy gate would later refuse it.
/// </remarks>
public static class DriverConfigSaveGate
{
/// <summary>The reason a config may not be saved for a driver type, or null when it may.</summary>
/// <param name="driverType">The owning driver's type.</param>
/// <param name="configJson">The DriverConfig or DeviceConfig JSON about to be stored.</param>
/// <returns>The refusal (never containing a credential value), or null.</returns>
public static string? Validate(string? driverType, string? configJson)
{
if (!string.Equals(driverType, DriverTypeNames.Heidenhain, StringComparison.OrdinalIgnoreCase)) return null;
var problems = HeidenhainOptionsParser.DescribeCredentialProblems(configJson);
return problems.Count == 0 ? null : string.Join(" ", problems);
}
}
@@ -280,6 +280,10 @@ public static class RawBrowseCommitMapper
}.ToJson();
if (Is(driverType, DriverTypeNames.Galaxy))
return WriteSingleKey("attributeRef", address);
// Heidenhain: a browsed leaf is a fixed catalog address; the typed model derives its data type and array
// flag from the catalog, so the committed tag opens in the editor and passes the driver's gate unchanged.
if (Is(driverType, DriverTypeNames.Heidenhain))
return new HeidenhainTagConfigModel { Address = address }.ToJson();
// Sql: a browsed leaf is a COLUMN, and a column name alone cannot address a tag — SqlTagConfigModel
// needs the table too. SqlBrowseSession therefore travels schema/table/columnName in AddressFields.
// Without this branch a Sql commit fell through to the generic "address" key below, which the typed
@@ -0,0 +1,32 @@
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.
/// </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);
/// <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>
/// <param name="tagConfig">The row's TagConfig.</param>
/// <param name="setAccess">Sets the row's access level.</param>
/// <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);
}
}
@@ -483,6 +483,8 @@ public sealed class RawTreeService(IDbContextFactory<OtOpcUaConfigDbContext> dbF
if (nameError is not null) return new UnsMutationResult(false, nameError);
if (string.IsNullOrWhiteSpace(driverType))
return new UnsMutationResult(false, "Pick a driver type.");
if (DriverConfigSaveGate.Validate(driverType, driverConfigJson) is { } configRefusal)
return new UnsMutationResult(false, configRefusal);
await using var db = await dbFactory.CreateDbContextAsync(ct);
@@ -619,6 +621,8 @@ public sealed class RawTreeService(IDbContextFactory<OtOpcUaConfigDbContext> dbF
var owner = await db.DriverInstances.FirstOrDefaultAsync(d => d.DriverInstanceId == driverInstanceId, ct);
if (owner is null)
return new UnsMutationResult(false, $"Driver '{driverInstanceId}' not found.");
if (DriverConfigSaveGate.Validate(owner.DriverType, deviceConfigJson) is { } configRefusal)
return new UnsMutationResult(false, configRefusal);
// MTConnect owns one agent client per driver — mirror the deploy gate
// (DraftValidator.ValidateMTConnectSingleDevice) so the second Device is refused where it is authored.
@@ -855,6 +859,8 @@ public sealed class RawTreeService(IDbContextFactory<OtOpcUaConfigDbContext> dbF
if (await ValidateTagConfigAsync(db, deviceId, tagConfig, ct) is { } configError)
return new UnsMutationResult(false, configError);
if (TagRowProblem(await ResolveDriverTypeAsync(db, deviceId, ct), input, tagConfig) is { } rowError)
return new UnsMutationResult(false, rowError);
var id = NewId("TAG");
db.Tags.Add(new Tag
@@ -896,6 +902,8 @@ public sealed class RawTreeService(IDbContextFactory<OtOpcUaConfigDbContext> dbF
if (await ValidateTagConfigAsync(db, entity.DeviceId, tagConfig, ct) is { } configError)
return new UnsMutationResult(false, configError);
if (TagRowProblem(await ResolveDriverTypeAsync(db, entity.DeviceId, ct), input, tagConfig) is { } rowError)
return new UnsMutationResult(false, rowError);
db.Entry(entity).Property(e => e.RowVersion).OriginalValue = rowVersion;
entity.Name = input.Name;
@@ -989,6 +997,12 @@ public sealed class RawTreeService(IDbContextFactory<OtOpcUaConfigDbContext> dbF
continue;
}
if (TagRowProblem(driverType, row.Tag, tagConfig) is { } rowError)
{
errors.Add($"{rowLabel}: {rowError}");
continue;
}
if (!takenNames.Add((groupId, row.Tag.Name)))
{
errors.Add($"{rowLabel}: a tag named '{row.Tag.Name}' already exists at that path.");
@@ -1102,6 +1116,8 @@ public sealed class RawTreeService(IDbContextFactory<OtOpcUaConfigDbContext> dbF
var entity = await db.DriverInstances.FirstOrDefaultAsync(d => d.DriverInstanceId == driverInstanceId, ct);
if (entity is null) return new UnsMutationResult(false, "Row no longer exists.");
if (DriverConfigSaveGate.Validate(entity.DriverType, driverConfigJson) is { } configRefusal)
return new UnsMutationResult(false, configRefusal);
// Sibling uniqueness in the same scope RenameDriverAsync uses (cluster + folder).
if (await db.DriverInstances.AnyAsync(
@@ -1162,6 +1178,8 @@ public sealed class RawTreeService(IDbContextFactory<OtOpcUaConfigDbContext> dbF
var entity = await db.Devices.FirstOrDefaultAsync(dev => dev.DeviceId == deviceId, ct);
if (entity is null) return new UnsMutationResult(false, "Row no longer exists.");
if (DriverConfigSaveGate.Validate(await ResolveDriverTypeAsync(db, deviceId, ct), deviceConfigJson) is { } configRefusal)
return new UnsMutationResult(false, configRefusal);
if (await db.Devices.AnyAsync(
dev => dev.DriverInstanceId == entity.DriverInstanceId && dev.Name == name && dev.DeviceId != deviceId, ct))
@@ -1275,6 +1293,16 @@ public sealed class RawTreeService(IDbContextFactory<OtOpcUaConfigDbContext> dbF
/// <param name="tagConfig">The normalised TagConfig JSON about to be persisted.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>An operator-facing error, or <see langword="null"/> when the config is acceptable.</returns>
/// <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.
/// </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;
private static async Task<string?> ValidateTagConfigAsync(
OtOpcUaConfigDbContext db, string deviceId, string tagConfig, CancellationToken ct)
{
@@ -635,6 +635,15 @@ public static class CsvColumnMap
new CsvRawKeyColumn(new CsvTypedColumn("MtDevice", "mtDevice", null), CsvRawKeyKind.String),
new CsvRawKeyColumn(new CsvTypedColumn("MtComponent", "mtComponent", null), CsvRawKeyKind.String),
}),
// Heidenhain (#803): address + the binding data type NAME + the array flag the catalog's array leaves
// require. The driver's HeidenhainTagDefinitionFactory is the gate a CSV row is validated against.
new RawKeyCsvDriverTagMap(DriverTypeNames.Heidenhain, new[]
{
new CsvRawKeyColumn(new CsvTypedColumn("HhAddress", "address", null), CsvRawKeyKind.String),
new CsvRawKeyColumn(new CsvTypedColumn("HhDataType", "dataType", null), CsvRawKeyKind.String),
new CsvRawKeyColumn(new CsvTypedColumn("HhIsArray", "isArray", null), CsvRawKeyKind.Bool),
}),
};
return maps.ToDictionary(m => m.DriverType, m => m, StringComparer.OrdinalIgnoreCase);
@@ -0,0 +1,82 @@
using System.Text.Json.Nodes;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.Heidenhain;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors;
/// <summary>
/// Typed model of a Heidenhain raw tag's <c>TagConfig</c> (lmxopcua#803). Keys: <c>address</c>, <c>dataType</c>
/// (a NAME), <c>isArray</c>. Unknown keys (historian intent, …) are preserved.
/// </summary>
/// <remarks>
/// <para><b>The driver is the only authority.</b> <see cref="Validate"/> asks
/// <see cref="HeidenhainTagDefinitionFactory.TryFromTagConfig"/> about the blob this model would save (or, on
/// first load, the blob it was loaded from — CSV import and the API save verbatim), so the editor, the deploy
/// gate and the runtime accept exactly the same tags.</para>
/// <para><b>Shape is derived, not authored, for fixed leaves.</b> For a catalog address <see cref="ToJson"/>
/// writes the catalog's data type and its array flag, so the node's rank always matches what the driver
/// publishes. A PLC/DATA binding carries the operator-chosen type. A stray <c>writable</c> key is dropped —
/// the driver is read-only.</para>
/// </remarks>
public sealed class HeidenhainTagConfigModel
{
/// <summary>The address: a catalog leaf, <c>PLC:AREA:INDEX</c> or <c>DATA:/path</c>.</summary>
public string Address { get; set; } = "";
/// <summary>The binding data type (ignored for fixed leaves, whose type is the catalog's).</summary>
public DriverDataType DataType { get; set; } = DriverDataType.Int32;
private JsonObject _bag = new();
private string? _source;
/// <summary>True when <see cref="Address"/> is a fixed catalog leaf.</summary>
public bool IsCatalogAddress => HeidenhainCatalog.TryGet(Address.Trim(), out _);
public static HeidenhainTagConfigModel FromJson(string? json)
{
var o = TagConfigJson.ParseOrNew(json);
var address = TagConfigJson.GetString(o, "address") ?? "";
var typeText = TagConfigJson.GetString(o, "dataType");
DriverDataType type;
if (HeidenhainCatalog.TryGet(address.Trim(), out var entry)) type = entry.DataType;
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 ?? "" };
}
public string ToJson()
{
var address = Address.Trim();
TagConfigJson.Set(_bag, "address", address);
TagConfigJson.Set(_bag, "writable", null);
if (HeidenhainCatalog.TryGet(address, out var entry))
{
TagConfigJson.Set(_bag, "dataType", entry.DataType);
TagConfigJson.Set(_bag, "isArray", entry.IsArray ? true : null);
}
else
{
TagConfigJson.Set(_bag, "dataType", DataType);
TagConfigJson.Set(_bag, "isArray", null);
}
return _source = TagConfigJson.Serialize(_bag);
}
public string? Validate()
{
if (string.IsNullOrWhiteSpace(Address)) return "An address is required (e.g. Status/ExecutionState, PLC:WORD:4, DATA:/PLC/memory/W/8).";
var blob = _source is null ? ToJsonPreview() : _source;
return HeidenhainTagDefinitionFactory.TryFromTagConfig(TagConfigParseGate.AuthoringRawPath, "", blob, out _, out var error)
? null
: error;
}
private string ToJsonPreview()
{
var saved = _source;
var json = ToJson();
_source = saved;
return json;
}
}
@@ -25,6 +25,7 @@ public static class TagConfigEditorMap
[DriverTypeNames.Sql] = typeof(Components.Shared.Uns.TagEditors.SqlTagConfigEditor),
[DriverTypeNames.Mqtt] = typeof(Components.Shared.Uns.TagEditors.MqttTagConfigEditor),
[DriverTypeNames.MTConnect] = typeof(Components.Shared.Uns.TagEditors.MTConnectTagConfigEditor),
[DriverTypeNames.Heidenhain] = typeof(Components.Shared.Uns.TagEditors.HeidenhainTagConfigEditor),
};
/// <summary>Returns the editor component type for a driver type, or null if none is registered.</summary>
@@ -27,6 +27,7 @@ public static class TagConfigValidator
[DriverTypeNames.Sql] = j => SqlTagConfigModel.FromJson(j).Validate(),
[DriverTypeNames.Mqtt] = j => MqttTagConfigModel.FromJson(j).Validate(),
[DriverTypeNames.MTConnect] = j => MTConnectTagConfigModel.FromJson(j).Validate(),
[DriverTypeNames.Heidenhain] = j => HeidenhainTagConfigModel.FromJson(j).Validate(),
};
/// <summary>
@@ -52,6 +52,7 @@
<ProjectReference Include="..\..\Drivers\ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Contracts\ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Contracts.csproj"/>
<ProjectReference Include="..\..\Drivers\ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Contracts\ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Contracts.csproj"/>
<ProjectReference Include="..\..\Drivers\ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Contracts\ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Contracts.csproj"/>
<ProjectReference Include="..\..\Drivers\ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Contracts\ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Contracts.csproj"/>
<ProjectReference Include="..\..\Drivers\ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser\ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser.csproj"/>
<ProjectReference Include="..\..\Drivers\ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browser\ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browser.csproj"/>
<!-- Sql schema-browse (IDriverBrowser, SqlBrowseNodeId codec, SqlEquipmentTagParser). Pulls
@@ -65,6 +65,7 @@ public static class EquipmentTagConfigInspector
[DriverTypeNames.MTConnect] = MTConnectTagDefinitionFactory.Inspect,
[DriverTypeNames.Calculation] = CalculationTagDefinitionFactory.Inspect,
[DriverTypeNames.OpcUaClient] = OpcUaClientTagDefinitionFactory.Inspect,
[DriverTypeNames.Heidenhain] = Driver.Heidenhain.HeidenhainTagDefinitionFactory.Inspect,
};
/// <summary>Inspects a tag's <c>TagConfig</c> for the given driver type. Returns the driver's warnings,
@@ -57,6 +57,7 @@
used to pull in transitively left with NamespaceMap, to Driver.OpcUaClient.Session. -->
<ProjectReference Include="..\..\Drivers\ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts\ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts.csproj"/>
<ProjectReference Include="..\..\Drivers\ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Contracts\ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Contracts.csproj"/>
<ProjectReference Include="..\..\Drivers\ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Contracts\ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Contracts.csproj"/>
<ProjectReference Include="..\..\Drivers\ZB.MOM.WW.OtOpcUa.Driver.Calculation.Contracts\ZB.MOM.WW.OtOpcUa.Driver.Calculation.Contracts.csproj"/>
<ProjectReference Include="..\..\Drivers\ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Contracts\ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Contracts.csproj"/>
</ItemGroup>
@@ -24,6 +24,7 @@ using CalculationProbe = Driver.Calculation.CalculationDriverProbe;
using SqlProbe = Driver.Sql.SqlDriverProbe;
using MqttProbe = Driver.Mqtt.MqttDriverProbe;
using MTConnectProbe = Driver.MTConnect.MTConnectDriverProbe;
using HeidenhainProbe = Driver.Heidenhain.HeidenhainDriverProbe;
/// <summary>
/// Wires every cross-platform driver assembly's <c>Register(registry, loggerFactory)</c>
@@ -195,6 +196,11 @@ public static class DriverFactoryBootstrap
services.TryAddEnumerable(ServiceDescriptor.Singleton<IDriverProbe, SqlProbe>());
services.TryAddEnumerable(ServiceDescriptor.Singleton<IDriverProbe, MqttProbe>());
services.TryAddEnumerable(ServiceDescriptor.Singleton<IDriverProbe, MTConnectProbe>());
// Factory-built so the probe resolves secret: INSPECT password references through the node's resolver
// (registered on every node by AddZbSecrets; optional so a test graph without one still builds — a
// secret: ref then fails closed with a clear probe message rather than sending the literal).
services.TryAddEnumerable(ServiceDescriptor.Singleton<IDriverProbe, HeidenhainProbe>(
sp => new HeidenhainProbe(sp.GetService<ISecretResolver>())));
return services;
}
@@ -253,6 +259,9 @@ public static class DriverFactoryBootstrap
// tier either — SubscribeAsync returns synchronously after starting the pump on a background
// task, so the Tier A 5s Subscribe budget never covers the stream's lifetime.
Driver.MTConnect.MTConnectDriverFactoryExtensions.Register(registry, loggerFactory);
// Tier A (default): managed TCP only, no native SDK. Read-only by construction (no IWritable; LSV2
// outbound allowlist). The resolver backs secret: password references, resolved per login.
Driver.Heidenhain.HeidenhainDriverFactoryExtensions.Register(registry, loggerFactory, secretResolver);
Driver.OpcUaClient.OpcUaClientDriverFactoryExtensions.Register(registry, loggerFactory, secretResolver);
Driver.S7.S7DriverFactoryExtensions.Register(registry, loggerFactory);
Driver.Sql.SqlDriverFactoryExtensions.Register(registry, loggerFactory);
@@ -85,6 +85,7 @@
<ProjectReference Include="..\..\Drivers\ZB.MOM.WW.OtOpcUa.Driver.Modbus\ZB.MOM.WW.OtOpcUa.Driver.Modbus.csproj"/>
<ProjectReference Include="..\..\Drivers\ZB.MOM.WW.OtOpcUa.Driver.Mqtt\ZB.MOM.WW.OtOpcUa.Driver.Mqtt.csproj"/>
<ProjectReference Include="..\..\Drivers\ZB.MOM.WW.OtOpcUa.Driver.MTConnect\ZB.MOM.WW.OtOpcUa.Driver.MTConnect.csproj"/>
<ProjectReference Include="..\..\Drivers\ZB.MOM.WW.OtOpcUa.Driver.Heidenhain\ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.csproj"/>
<ProjectReference Include="..\..\Drivers\ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient\ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.csproj"/>
<ProjectReference Include="..\..\Drivers\ZB.MOM.WW.OtOpcUa.Driver.S7\ZB.MOM.WW.OtOpcUa.Driver.S7.csproj"/>
<ProjectReference Include="..\..\Drivers\ZB.MOM.WW.OtOpcUa.Driver.Sql\ZB.MOM.WW.OtOpcUa.Driver.Sql.csproj"/>