fix(abcip,adminui): ParseEnum device wording + libplctag probe assertion (#574); AB forms preserve unknown keys + MaxConcurrentReads (#576); probe off for new AbCip driver (#577); docs T4:0.DN (#584)

Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
This commit is contained in:
Joseph Doherty
2026-10-07 22:53:04 -04:00
parent 04b6563286
commit e588443ced
9 changed files with 251 additions and 15 deletions
@@ -0,0 +1,65 @@
using System.Text.Json;
using System.Text.Json.Nodes;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers;
/// <summary>
/// Preserve-unknown save for the typed driver forms (#576). A form deserialises the stored DriverConfig
/// into a typed options object and re-serialises it on save, which erases every key the type does not
/// model (a newer option, a hand-edited key, <c>rawTags</c>, a device's extra keys…). This overlays only
/// the paths the form OWNS onto the ORIGINAL JSON object: an owned path is replaced by the form's value,
/// or removed when the form emits nothing/null ("Default"); every other key in the original survives.
/// </summary>
internal static class DriverConfigJsonOverlay
{
/// <summary>Overlays the <paramref name="ownedPaths"/> of <paramref name="typedJson"/> onto <paramref name="originalJson"/>.</summary>
/// <param name="originalJson">The stored config as loaded; blank or non-object input yields <paramref name="typedJson"/> unchanged.</param>
/// <param name="typedJson">The form's own serialised options.</param>
/// <param name="ownedPaths">Dot-separated, case-insensitive property paths the form edits (e.g. <c>transport</c>, <c>probe.enabled</c>).</param>
public static string Apply(string? originalJson, string typedJson, IEnumerable<string> ownedPaths)
{
if (string.IsNullOrWhiteSpace(originalJson)) return typedJson;
JsonObject? original;
try { original = JsonNode.Parse(originalJson) as JsonObject; }
catch (JsonException) { return typedJson; }
if (original is null || JsonNode.Parse(typedJson) is not JsonObject typed) return typedJson;
foreach (var path in ownedPaths)
{
var segments = path.Split('.');
JsonObject? from = typed;
JsonObject? to = original;
for (var i = 0; i < segments.Length - 1 && to is not null; i++)
{
from = from is null ? null : Find(from, segments[i]) as JsonObject;
var next = Find(to, segments[i]) as JsonObject;
if (next is null && from is not null)
{
next = [];
to[segments[i]] = next;
}
to = next;
}
if (to is null) continue;
var leaf = segments[^1];
var value = from is null ? null : Find(from, leaf);
Remove(to, leaf);
if (value is not null) to[leaf] = value.DeepClone();
}
return original.ToJsonString();
}
private static JsonNode? Find(JsonObject o, string key)
{
foreach (var kv in o)
if (string.Equals(kv.Key, key, StringComparison.OrdinalIgnoreCase)) return kv.Value;
return null;
}
private static void Remove(JsonObject o, string key)
{
var hit = o.Select(kv => kv.Key).FirstOrDefault(k => string.Equals(k, key, StringComparison.OrdinalIgnoreCase));
if (hit is not null) o.Remove(hit);
}
}
@@ -18,6 +18,11 @@
<InputNumber @bind-Value="_form.TimeoutSeconds" @bind-Value:after="EmitAsync" class="form-control form-control-sm" />
<div class="form-text">Default libplctag call timeout. Default 2 s.</div>
</div>
<div class="col-md-3">
<label class="form-label">Max concurrent reads</label>
<InputNumber @bind-Value="_form.MaxConcurrentReads" @bind-Value:after="EmitAsync" class="form-control form-control-sm" />
<div class="form-text">Read work items in flight at once. Default 8; 1 = sequential.</div>
</div>
<div class="col-md-3">
<div class="form-check form-switch mt-4">
<InputCheckbox @bind-Value="_form.EnableControllerBrowse" @bind-Value:after="EmitAsync" class="form-check-input" id="enableBrowse" />
@@ -124,6 +129,20 @@
private FormModel _form = new();
private string? _lastParsed;
// The config exactly as loaded; the save overlays the form-owned keys onto it so keys this form does not
// model (unknown/newer options, rawTags, per-device extras) survive an unrelated edit (#576).
private string? _originalJson;
private static bool IsBlankConfig(string? json)
=> string.IsNullOrWhiteSpace(json) || json.AsSpan().Trim().SequenceEqual("{}");
/// <summary>Top-level/nested JSON paths this form edits; everything else in the stored config is preserved.</summary>
internal static readonly string[] OwnedPaths =
[
"timeout", "transport", "enableControllerBrowse", "enableDeclarationOnlyUdtGrouping",
"enableAlarmProjection", "alarmPollInterval", "maxConcurrentReads", "probeTimeoutSeconds",
"probe.enabled", "probe.interval", "probe.timeout", "probe.probeTagPath",
];
// Preserve the driver's existing Devices across a load→save (the device form authors the real endpoints;
// the merge reconstructs the Devices array from the device rows at deploy/probe time).
@@ -136,6 +155,11 @@
{
var opts = TryDeserialize(DriverConfigJson) ?? new AbCipDriverOptions();
_form = FormModel.FromOptions(opts);
// #577: a brand-new driver (nothing authored yet) must not default the probe ON with no tag path —
// that logs an operator warning per driver and runs no probe. An existing config keeps whatever
// it stores (an absent "probe" key still means "enabled" to the driver, so don't flip it on edit).
if (IsBlankConfig(DriverConfigJson)) _form.ProbeEnabled = false;
_originalJson = DriverConfigJson;
_preservedDevices = opts.Devices;
_lastParsed = DriverConfigJson;
}
@@ -145,16 +169,17 @@
/// Devices array forward (the device form authors the real endpoints; the merge reconstructs the array
/// from the device rows at deploy/probe time).</summary>
public string GetConfigJson()
=> Serialize(_form.ToOptions(_preservedDevices));
=> Serialize(_form.ToOptions(_preservedDevices), _originalJson);
/// <summary>Serializes options with the form's JSON settings (camelCase, enums as strings).</summary>
internal static string Serialize(AbCipDriverOptions options)
=> JsonSerializer.Serialize(options, _jsonOpts);
internal static string Serialize(AbCipDriverOptions options, string? originalJson = null)
=> DriverConfigJsonOverlay.Apply(originalJson, JsonSerializer.Serialize(options, _jsonOpts), OwnedPaths);
private async Task EmitAsync()
{
var json = GetConfigJson();
_lastParsed = json;
_originalJson = json;
await DriverConfigJsonChanged.InvokeAsync(json);
}
@@ -180,6 +205,7 @@
// PLC-5 / LogixPccc exclusion still applies). Never re-emit a concrete default here — that would pin
// every edited driver to it.
public AbCipTransport? Transport { get; set; }
public int MaxConcurrentReads { get; set; } = 8;
public bool EnableControllerBrowse { get; set; } = false;
public bool EnableDeclarationOnlyUdtGrouping { get; set; } = false;
@@ -200,6 +226,7 @@
{
TimeoutSeconds = (int)o.Timeout.TotalSeconds,
Transport = o.Transport,
MaxConcurrentReads = o.MaxConcurrentReads,
EnableControllerBrowse = o.EnableControllerBrowse,
EnableDeclarationOnlyUdtGrouping = o.EnableDeclarationOnlyUdtGrouping,
EnableAlarmProjection = o.EnableAlarmProjection,
@@ -224,6 +251,7 @@
},
Timeout = TimeSpan.FromSeconds(TimeoutSeconds),
Transport = Transport,
MaxConcurrentReads = MaxConcurrentReads,
EnableControllerBrowse = EnableControllerBrowse,
EnableAlarmProjection = EnableAlarmProjection,
AlarmPollInterval = TimeSpan.FromSeconds(AlarmPollIntervalSeconds),
@@ -90,6 +90,16 @@
private FormModel _form = new();
private string? _lastParsed;
// The config exactly as loaded; the save overlays the form-owned keys onto it so keys this form does not
// model (unknown/newer options, rawTags, per-device extras) survive an unrelated edit (#576).
private string? _originalJson;
/// <summary>JSON paths this form edits; everything else in the stored config is preserved.</summary>
internal static readonly string[] OwnedPaths =
[
"timeout", "transport", "probeTimeoutSeconds",
"probe.enabled", "probe.interval", "probe.timeout", "probe.probeAddress",
];
// Devices are separate v3 entities — this form doesn't edit them, but preserves the driver's existing
// Devices array verbatim across a load→save (the merge rebuilds it at deploy/probe time).
@@ -102,6 +112,7 @@
{
var opts = TryDeserialize(DriverConfigJson) ?? new AbLegacyDriverOptions();
_form = FormModel.FromOptions(opts);
_originalJson = DriverConfigJson;
_preservedDevices = opts.Devices;
_lastParsed = DriverConfigJson;
}
@@ -110,16 +121,17 @@
/// <summary>Serializes the current channel/protocol config to camelCase JSON, preserving the driver's
/// existing Devices array (authored as separate v3 entities).</summary>
public string GetConfigJson()
=> Serialize(_form.ToOptions(_preservedDevices));
=> Serialize(_form.ToOptions(_preservedDevices), _originalJson);
/// <summary>Serializes options with the form's JSON settings (camelCase, enums as strings).</summary>
internal static string Serialize(AbLegacyDriverOptions options)
=> JsonSerializer.Serialize(options, _jsonOpts);
internal static string Serialize(AbLegacyDriverOptions options, string? originalJson = null)
=> DriverConfigJsonOverlay.Apply(originalJson, JsonSerializer.Serialize(options, _jsonOpts), OwnedPaths);
private async Task EmitAsync()
{
var json = GetConfigJson();
_lastParsed = json;
_originalJson = json;
await DriverConfigJsonChanged.InvokeAsync(json);
}