Merge feat/803-heidenhain-lsv2
This commit is contained in:
+76
@@ -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:<name></span> resolves from the node's secret store, <span class="mono">env:<VAR></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);
|
||||
}
|
||||
}
|
||||
+55
@@ -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;
|
||||
|
||||
+9
-2
@@ -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>
|
||||
|
||||
+152
@@ -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:<name></span> (node secret store) or <span class="mono">env:<VAR></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 & 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);
|
||||
}
|
||||
}
|
||||
+114
@@ -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;
|
||||
|
||||
+7
-1
@@ -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;
|
||||
|
||||
+78
@@ -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
|
||||
|
||||
+1
@@ -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"/>
|
||||
|
||||
Reference in New Issue
Block a user