Merge b626-ablegacy-focas (batch #626+)

Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
This commit is contained in:
Joseph Doherty
2026-10-08 14:43:44 -04:00
17 changed files with 946 additions and 176 deletions
@@ -68,8 +68,11 @@ public sealed class AbLegacyTagConfigModel
if (TagConfigJson.DescribeInvalidEnum<AbLegacyDataType>(_bag, "dataType") is { } dtError)
return dtError;
if (string.IsNullOrWhiteSpace(Address))
return "Address is required.";
// The editor cannot see the device's PLC family, so it checks only what no family accepts (the same
// AbLegacyAddress authority the deploy gate and runtime use) — never stricter than the gate, which adds the
// family-specific verdict (PLC-5 octal I/O, MicroLogix-only function files).
if (AbLegacyAddress.Validate(Address, family: null) is { } addressError)
return addressError;
// DELIBERATELY STRICTER THAN THE RUNTIME, and re-confirmed as such by the Gitea #554 survey — do not
// "align" it. PCCC data-file elements are limited to AbLegacyArray.MaxElements (256). The reader
@@ -262,12 +262,16 @@ public sealed class AdminOperationsActor : ReceiveActor
var driverByDevice = draft.Devices
.GroupBy(d => d.DeviceId, StringComparer.Ordinal)
.ToDictionary(g => g.Key, g => g.First().DriverInstanceId, StringComparer.Ordinal);
var deviceConfigById = draft.Devices
.GroupBy(d => d.DeviceId, StringComparer.Ordinal)
.ToDictionary(g => g.Key, g => g.First().DeviceConfig, StringComparer.Ordinal);
var tagConfigWarnings = new List<(string TagId, string Text)>();
foreach (var t in draft.Tags)
{
if (!driverByDevice.TryGetValue(t.DeviceId, out var driverInstanceId)) continue;
if (!driverTypeById.TryGetValue(driverInstanceId, out var driverType)) continue;
foreach (var w in EquipmentTagConfigInspector.Inspect(driverType, t.TagConfig))
foreach (var w in EquipmentTagConfigInspector.Inspect(
driverType, t.TagConfig, deviceConfigById.GetValueOrDefault(t.DeviceId)))
tagConfigWarnings.Add((t.TagId, $"tag '{t.TagId}': {w}"));
}
if (tagConfigWarnings.Count > 0)
@@ -1,6 +1,7 @@
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.AbCip;
using ZB.MOM.WW.OtOpcUa.Driver.AbLegacy;
using ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.PlcFamilies;
using ZB.MOM.WW.OtOpcUa.Driver.Calculation;
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
using ZB.MOM.WW.OtOpcUa.Driver.Modbus;
@@ -78,6 +79,47 @@ public static class EquipmentTagConfigInspector
return Inspectors.TryGetValue(driverType, out var inspect) ? inspect(tagConfig) : Array.Empty<string>();
}
/// <summary>
/// <see cref="Inspect(string?, string?)"/> with the owning device's <c>DeviceConfig</c>, so a driver whose
/// address grammar depends on the device (AbLegacy: the <c>PlcFamily</c>) is checked against the family the
/// runtime will use. Other drivers ignore the device config. A missing family key is the runtime default
/// (SLC 500); an unrecognised value is "unknown" and only the family-independent check applies — the gate
/// is never stricter than the runtime.
/// </summary>
/// <param name="driverType">The bound driver's <c>DriverType</c>.</param>
/// <param name="tagConfig">The equipment tag's <c>TagConfig</c> JSON.</param>
/// <param name="deviceConfig">The owning device's <c>DeviceConfig</c> JSON (may be null).</param>
/// <returns>The warnings; empty when clean.</returns>
public static IReadOnlyList<string> Inspect(string? driverType, string? tagConfig, string? deviceConfig)
{
if (string.IsNullOrEmpty(driverType) || tagConfig is null) return Array.Empty<string>();
if (string.Equals(driverType, DriverTypeNames.AbLegacy, StringComparison.OrdinalIgnoreCase))
return AbLegacyTagDefinitionFactory.Inspect(tagConfig, ReadAbLegacyFamily(deviceConfig));
return Inspect(driverType, tagConfig);
}
private static AbLegacyPlcFamily? ReadAbLegacyFamily(string? deviceConfig)
{
if (string.IsNullOrWhiteSpace(deviceConfig)) return AbLegacyPlcFamily.Slc500;
try
{
using var doc = System.Text.Json.JsonDocument.Parse(deviceConfig);
if (doc.RootElement.ValueKind != System.Text.Json.JsonValueKind.Object) return AbLegacyPlcFamily.Slc500;
foreach (var prop in doc.RootElement.EnumerateObject())
{
if (!string.Equals(prop.Name, "PlcFamily", StringComparison.OrdinalIgnoreCase)) continue;
var raw = prop.Value.ValueKind == System.Text.Json.JsonValueKind.String ? prop.Value.GetString() : null;
if (string.IsNullOrWhiteSpace(raw)) return AbLegacyPlcFamily.Slc500;
return Enum.TryParse<AbLegacyPlcFamily>(raw, ignoreCase: true, out var f) && Enum.IsDefined(f) ? f : null;
}
return AbLegacyPlcFamily.Slc500;
}
catch (System.Text.Json.JsonException)
{
return null;
}
}
/// <summary>Whether the given driver type has a mapped inspector.</summary>
/// <param name="driverType">The bound driver's <c>DriverType</c>.</param>
/// <returns><see langword="true"/> when an inspector is registered for the driver type.</returns>