feat(heidenhain): AdminUI driver/device forms, typed tag editor, CSV + browse-commit (#803)
- HeidenhainDriverForm over HeidenhainDriverFormModel (JsonObject bag preserves Devices and unknown keys; no enum fields; Validate asks HeidenhainOptionsParser) + DriverConfigFormMap. - HeidenhainDeviceForm/Model (Host, Port 19000, optional per-device password references) + DeviceFormMap. Literal passwords are refused in both forms. - RawDriverTypeDialog entry; HeidenhainTagConfigEditor over HeidenhainTagConfigModel, whose Validate is the driver's own TryFromTagConfig; catalog leaves derive dataType + isArray; registered in TagConfigEditorMap + TagConfigValidator. - CsvColumnMap (HhAddress/HhDataType/HhIsArray) and RawBrowseCommitMapper branch. - Guards: DriverPageJsonConverterTests external-serializer entry, TagConfigNonEnumParityTests runtime map + corpus + freshly-authored rows, TagConfigRoundTrip, HeidenhainAuthoringTests.
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:lsv2/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:lsv2/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:LSV2_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();
|
||||
}
|
||||
@@ -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:lsv2/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:lsv2/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:LSV2_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;
|
||||
|
||||
+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());
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -41,6 +41,9 @@ public sealed class DriverPageJsonConverterTests
|
||||
new Dictionary<Type, JsonSerializerOptions>
|
||||
{
|
||||
[typeof(MqttDriverForm)] = MqttJson.Options,
|
||||
// HeidenhainDriverForm (#803) is a shell over HeidenhainDriverFormModel, which writes through its own
|
||||
// JsonOptions (a JsonObject bag that preserves the Devices collection) — the same pattern as MQTT.
|
||||
[typeof(HeidenhainDriverForm)] = HeidenhainDriverFormModel.JsonOptions,
|
||||
};
|
||||
|
||||
/// <summary>Every concrete <c>*DriverForm</c> in the AdminUI assembly.</summary>
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
using System.Text.Json.Nodes;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers;
|
||||
using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers.DeviceForms;
|
||||
using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers.Forms;
|
||||
using ZB.MOM.WW.OtOpcUa.AdminUI.Uns;
|
||||
using ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Heidenhain;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Uns;
|
||||
|
||||
/// <summary>
|
||||
/// lmxopcua#803: the Heidenhain authoring surfaces (driver form, device form, tag editor, CSV, browse commit)
|
||||
/// write exactly what the driver factory and the deploy gate accept — the "registered but unauthorable" and
|
||||
/// "numerically-serialized enum" defect classes.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class HeidenhainAuthoringTests
|
||||
{
|
||||
[Fact]
|
||||
public void Every_authoring_seam_is_registered()
|
||||
{
|
||||
DriverConfigFormMap.Resolve(DriverTypeNames.Heidenhain).ShouldBe(typeof(HeidenhainDriverForm));
|
||||
DeviceFormMap.Resolve(DriverTypeNames.Heidenhain).ShouldBe(typeof(HeidenhainDeviceForm));
|
||||
TagConfigEditorMap.Resolve(DriverTypeNames.Heidenhain).ShouldBe(typeof(Components.Shared.Uns.TagEditors.HeidenhainTagConfigEditor));
|
||||
TagConfigValidator.Validate(DriverTypeNames.Heidenhain, "{}").ShouldNotBeNull();
|
||||
CsvColumnMap.Resolve(DriverTypeNames.Heidenhain).ShouldNotBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_default_driver_form_save_is_accepted_by_the_factory_and_preserves_devices()
|
||||
{
|
||||
var authored = """{"Devices":[{"Host":"10.0.0.5","DeviceName":"m1"}],"Custom":42}""";
|
||||
var m = HeidenhainDriverFormModel.FromJson(authored);
|
||||
m.Validate().ShouldBeNull();
|
||||
m.InspectPassword = "secret:lsv2/inspect";
|
||||
m.TimersEnabled = true;
|
||||
var json = m.ToJson();
|
||||
var o = HeidenhainOptionsParser.Parse(json);
|
||||
HeidenhainOptionsParser.Validate(o).ShouldBeEmpty(); // the factory's ParseOptions gate
|
||||
o.Devices.Single().Host.ShouldBe("10.0.0.5");
|
||||
o.Access.InspectPassword.ShouldBe("secret:lsv2/inspect");
|
||||
o.Polling.TimersEnabled.ShouldBeTrue();
|
||||
JsonNode.Parse(json)!["Custom"]!.GetValue<int>().ShouldBe(42);
|
||||
// no field is ever written as a number where the parser expects a name — there are no enums at all
|
||||
HeidenhainDriverFormModel.FromJson(json).ToJson().ShouldBe(json);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Driver_form_refuses_literal_passwords_and_out_of_range_values()
|
||||
{
|
||||
var m = HeidenhainDriverFormModel.FromJson("{}");
|
||||
m.DncPassword = "hunter2";
|
||||
m.Validate()!.ShouldNotContain("hunter2");
|
||||
m.DncPassword = null;
|
||||
m.StatusMs = 10;
|
||||
m.Validate().ShouldNotBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Device_form_round_trips_and_validates_with_the_driver_rule()
|
||||
{
|
||||
var d = HeidenhainDeviceModel.FromJson("""{"Host":"cnc-7","Note":"keep"}""");
|
||||
d.Port.ShouldBe(19000);
|
||||
d.Validate().ShouldBeNull();
|
||||
d.DataPassword = "plain";
|
||||
d.Validate().ShouldNotBeNull();
|
||||
d.DataPassword = "env:LSV2_DATA";
|
||||
var json = d.ToJson();
|
||||
JsonNode.Parse(json)!["Note"]!.GetValue<string>().ShouldBe("keep");
|
||||
var opts = HeidenhainOptionsParser.Parse($$"""{"Devices":[{{json}}]}""");
|
||||
opts.Devices.Single().DataPassword.ShouldBe("env:LSV2_DATA");
|
||||
HeidenhainDeviceModel.FromJson("{}").Validate().ShouldNotBeNull(); // host required
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("Axes/Positions")]
|
||||
[InlineData("Alarms/Messages")]
|
||||
[InlineData("Status/ExecutionState")]
|
||||
[InlineData("Capabilities/DncLogin")]
|
||||
public void Catalog_addresses_derive_type_and_rank_so_the_driver_accepts_them(string address)
|
||||
{
|
||||
var json = new HeidenhainTagConfigModel { Address = address }.ToJson();
|
||||
HeidenhainTagDefinitionFactory.TryFromTagConfig("p", "d", json, out var def, out var error).ShouldBeTrue(error);
|
||||
HeidenhainCatalog.TryGet(address, out var e).ShouldBeTrue();
|
||||
def!.DataType.ShouldBe(e!.DataType);
|
||||
def.IsArray.ShouldBe(e.IsArray);
|
||||
TagConfigValidator.Validate(DriverTypeNames.Heidenhain, json).ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Editor_writes_the_data_type_as_a_name_and_drops_a_stray_writable_flag()
|
||||
{
|
||||
var m = HeidenhainTagConfigModel.FromJson("""{"address":"PLC:WORD:2","dataType":"Int64","writable":true,"isHistorized":true}""");
|
||||
m.Validate().ShouldNotBeNull(); // as loaded: the driver refuses writable:true
|
||||
var json = m.ToJson();
|
||||
var o = JsonNode.Parse(json)!.AsObject();
|
||||
o["dataType"]!.GetValue<string>().ShouldBe("Int64");
|
||||
o.ContainsKey("writable").ShouldBeFalse();
|
||||
o["isHistorized"]!.GetValue<bool>().ShouldBeTrue();
|
||||
HeidenhainTagConfigModel.FromJson(json).Validate().ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Editor_refuses_a_narrowing_binding()
|
||||
{
|
||||
var m = new HeidenhainTagConfigModel { Address = "PLC:DWORD:0", DataType = DriverDataType.Int16 };
|
||||
m.Validate().ShouldNotBeNull();
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("Status/ExecutionState")]
|
||||
[InlineData("Axes/Names")]
|
||||
public void Browse_committed_leaves_open_in_the_editor_and_pass_the_driver_gate(string address)
|
||||
{
|
||||
var json = RawBrowseCommitMapper.BuildTagConfig(DriverTypeNames.Heidenhain, address);
|
||||
TagConfigValidator.Validate(DriverTypeNames.Heidenhain, json).ShouldBeNull();
|
||||
HeidenhainTagConfigModel.FromJson(json).Address.ShouldBe(address);
|
||||
}
|
||||
}
|
||||
@@ -74,6 +74,7 @@ public sealed class TagConfigNonEnumParityTests
|
||||
[DriverTypeNames.MTConnect] = j => MTConnectTagDefinitionFactory.TryFromTagConfig(j, out _, out _),
|
||||
[DriverTypeNames.Calculation] = j => CalculationTagDefinitionFactory.TryReadTagConfig(j, out _),
|
||||
[DriverTypeNames.OpcUaClient] = j => OpcUaClientTagDefinitionFactory.TryReadNodeId(j, out _),
|
||||
[DriverTypeNames.Heidenhain] = j => ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.HeidenhainTagDefinitionFactory.TryFromTagConfig(P, "", j, out _, out _),
|
||||
};
|
||||
|
||||
private static bool RuntimeAccepts(string driverType, string blob) => RuntimeParses[driverType](blob);
|
||||
@@ -91,6 +92,7 @@ public sealed class TagConfigNonEnumParityTests
|
||||
DriverTypeNames.Modbus, DriverTypeNames.S7, DriverTypeNames.AbCip, DriverTypeNames.AbLegacy,
|
||||
DriverTypeNames.TwinCAT, DriverTypeNames.FOCAS, DriverTypeNames.Mqtt, DriverTypeNames.Sql,
|
||||
DriverTypeNames.MTConnect, DriverTypeNames.Calculation, DriverTypeNames.OpcUaClient,
|
||||
DriverTypeNames.Heidenhain,
|
||||
];
|
||||
|
||||
foreach (var driverType in mapped)
|
||||
@@ -425,6 +427,20 @@ public sealed class TagConfigNonEnumParityTests
|
||||
(DriverTypeNames.MTConnect, """{"dataItemId":"Xact"}"""),
|
||||
(DriverTypeNames.MTConnect, """{"fullName":"Xact","driverDataType":"Strng"}"""),
|
||||
|
||||
(DriverTypeNames.Heidenhain, "{}"),
|
||||
(DriverTypeNames.Heidenhain, """{"address":"Status/ExecutionState"}"""),
|
||||
(DriverTypeNames.Heidenhain, """{"address":"Status/ExecutionState","dataType":"Int32"}"""),
|
||||
(DriverTypeNames.Heidenhain, """{"address":"Status/ExecutionState","dataType":"String"}"""),
|
||||
(DriverTypeNames.Heidenhain, """{"address":"Axes/Names","isArray":true}"""),
|
||||
(DriverTypeNames.Heidenhain, """{"address":"Axes/Names"}"""),
|
||||
(DriverTypeNames.Heidenhain, """{"address":"PLC:WORD:4","dataType":"Int32"}"""),
|
||||
(DriverTypeNames.Heidenhain, """{"address":"PLC:WORD:4","dataType":"8"}"""),
|
||||
(DriverTypeNames.Heidenhain, """{"address":"PLC:WORD:4"}"""),
|
||||
(DriverTypeNames.Heidenhain, """{"address":"PLC:DWORD:0","dataType":"Int16"}"""),
|
||||
(DriverTypeNames.Heidenhain, """{"address":"DATA:/PLC/memory/W/8","dataType":"Int64"}"""),
|
||||
(DriverTypeNames.Heidenhain, """{"address":"Status/ExecutionState","writable":true}"""),
|
||||
(DriverTypeNames.Heidenhain, Pad + """{"address":"Overrides/Feed"}"""),
|
||||
|
||||
(DriverTypeNames.Calculation, "{}"),
|
||||
(DriverTypeNames.Calculation, """{"scriptId":"S1"}"""),
|
||||
(DriverTypeNames.Calculation, """{"scriptId":"S1","changeTriggered":true,"timerIntervalMs":5000}"""),
|
||||
@@ -602,6 +618,9 @@ public sealed class TagConfigNonEnumParityTests
|
||||
new MTConnectTagConfigModel { FullName = "Xact", DataType = DriverDataType.Float64 }.ToJson());
|
||||
data.Add(DriverTypeNames.Mqtt,
|
||||
new MqttTagConfigModel { Mode = MqttMode.Plain, Topic = "otopcua/fixture/a" }.ToJson());
|
||||
data.Add(DriverTypeNames.Heidenhain, new HeidenhainTagConfigModel { Address = "Axes/Positions" }.ToJson());
|
||||
data.Add(DriverTypeNames.Heidenhain,
|
||||
new HeidenhainTagConfigModel { Address = "PLC:INPUT_WORD:3", DataType = DriverDataType.Int32 }.ToJson());
|
||||
data.Add(DriverTypeNames.Mqtt, new MqttTagConfigModel
|
||||
{
|
||||
Mode = MqttMode.SparkplugB,
|
||||
|
||||
@@ -18,6 +18,7 @@ internal static class TagConfigRoundTrip
|
||||
DriverTypeNames.Sql => SqlTagConfigModel.FromJson(json).ToJson(),
|
||||
DriverTypeNames.Mqtt => MqttTagConfigModel.FromJson(json).ToJson(),
|
||||
DriverTypeNames.MTConnect => MTConnectTagConfigModel.FromJson(json).ToJson(),
|
||||
DriverTypeNames.Heidenhain => HeidenhainTagConfigModel.FromJson(json).ToJson(),
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(driverType)),
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user