Merge R2-11 TagConfig consolidation (arch-review round 2) [PR #434]
v2-ci / build (push) Successful in 3m55s
v2-ci / unit-tests (push) Failing after 8m14s

Findings 01/C-1, 01/P-1 (single TagConfigIntent.Parse in Commons, 4 copies
delegate, parse-once) + 05 CONV-2/UNDER-1/UNDER-6 (shared strict TagConfigJson
readers, per-driver Inspect() + writable key, FOCAS capability pre-flight,
Modbus probe timeoutMs, deploy-gate Warn|Error). Phase C runtime-strict flip
deferred. T22/T24 deferred. Auto-merged AddressSpaceApplier.cs + DriverHostActor.cs
with R2-04/R2-10; verified OpcUaServer.Tests 286/286 + Runtime.Tests 396/396.
Build clean.
This commit is contained in:
Joseph Doherty
2026-07-13 11:29:32 -04:00
74 changed files with 2228 additions and 682 deletions
@@ -1,4 +1,5 @@
using System.Text.Json;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip;
@@ -42,7 +43,7 @@ public static class AbCipEquipmentTagParser
// resolves the full tag path (e.g. "Motor.Speed") without enumerating UDT members.
// The address space emits a placeholder String variable; UDT member declarations are
// not supported in the equipment-tag flow.
var dataType = ReadEnum(root, "dataType", AbCipDataType.DInt);
var dataType = TagConfigJson.ReadEnumOrDefault(root, "dataType", AbCipDataType.DInt);
// Review I-1 — an equipment tag is an ARRAY ⟺ isArray:true AND arrayLength >= 1. A
// 1-element array (isArray:true, arrayLength:1) is a VALID 1-element array — the
// foundation materialises a [1] OPC UA array node — so it must read as an array, not a
@@ -50,8 +51,9 @@ public static class AbCipEquipmentTagParser
// have a count of 1), so the explicit IsArray flag does.
var (isArray, elementCount) = ReadArrayShape(root);
// "writable" defaults to true when absent — matches AbCipTagDefinition.Writable default.
var writable = !root.TryGetProperty("writable", out var writableEl)
|| writableEl.ValueKind != JsonValueKind.False;
// Now via the shared TagConfigJson.ReadWritable (explicit-false-only), byte-identical to the
// hand-rolled read it replaces.
var writable = TagConfigJson.ReadWritable(root);
def = new AbCipTagDefinition(
Name: reference, DeviceHostAddress: deviceHostAddress, TagPath: tagPath,
DataType: dataType, Writable: writable, ElementCount: elementCount, IsArray: isArray);
@@ -83,9 +85,35 @@ public static class AbCipEquipmentTagParser
return (false, 1);
}
private static TEnum ReadEnum<TEnum>(JsonElement o, string name, TEnum fallback) where TEnum : struct, Enum
=> o.TryGetProperty(name, out var e) && e.ValueKind == JsonValueKind.String
&& Enum.TryParse<TEnum>(e.GetString(), ignoreCase: true, out var v) ? v : fallback;
/// <summary>
/// Deploy-time inspection (05/CONV-2): warns on a present-but-invalid <c>dataType</c> (silently
/// defaulted by the lenient runtime) and on a structurally unparseable TagConfig. Empty when clean
/// or not an equipment-tag object. Never throws.
/// </summary>
/// <param name="reference">The equipment tag's TagConfig JSON.</param>
/// <returns>The warnings; empty when clean.</returns>
public static IReadOnlyList<string> Inspect(string reference)
{
var warnings = new List<string>();
if (string.IsNullOrWhiteSpace(reference) || reference[0] != '{') return warnings;
try
{
using var doc = JsonDocument.Parse(reference);
var root = doc.RootElement;
if (root.ValueKind != JsonValueKind.Object)
{
warnings.Add("AbCip TagConfig root is not a JSON object — the tag will not resolve (BadNodeIdUnknown).");
return warnings;
}
var w = TagConfigJson.DescribeInvalidEnum<AbCipDataType>(root, "dataType");
if (w is not null) warnings.Add(w);
}
catch (JsonException)
{
warnings.Add("AbCip TagConfig is not valid JSON — the tag will not resolve (BadNodeIdUnknown).");
}
return warnings;
}
private static string ReadString(JsonElement o, string name)
=> o.TryGetProperty(name, out var e) && e.ValueKind == JsonValueKind.String
@@ -5,5 +5,9 @@
<ImplicitUsings>enable</ImplicitUsings>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
</PropertyGroup>
<!-- NO PackageReference. -->
<!-- NO PackageReference. ProjectReference only to the zero-dependency Core.Abstractions leaf
(R2-11 05/CONV-2 — shared TagConfigJson readers). -->
<ItemGroup>
<ProjectReference Include="..\..\Core\ZB.MOM.WW.OtOpcUa.Core.Abstractions\ZB.MOM.WW.OtOpcUa.Core.Abstractions.csproj"/>
</ItemGroup>
</Project>
@@ -1,4 +1,5 @@
using System.Text.Json;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy;
@@ -38,7 +39,7 @@ public static class AbLegacyEquipmentTagParser
return false;
var address = addr.GetString();
if (string.IsNullOrWhiteSpace(address)) return false;
var dataType = ReadEnum(root, "dataType", AbLegacyDataType.Int);
var dataType = TagConfigJson.ReadEnumOrDefault(root, "dataType", AbLegacyDataType.Int);
var deviceHostAddress = ReadString(root, "deviceHostAddress");
int? arrayLength = null;
if (IsArrayFlag(root))
@@ -47,9 +48,12 @@ public static class AbLegacyEquipmentTagParser
if (rawLength >= 1)
arrayLength = Math.Min(rawLength, AbLegacyArray.MaxElements);
}
// "writable" defaults to true when absent (today's value) — makes read-only equipment tags
// authorable (UNDER-6).
var writable = TagConfigJson.ReadWritable(root);
def = new AbLegacyTagDefinition(
Name: reference, DeviceHostAddress: deviceHostAddress, Address: address,
DataType: dataType, Writable: true, ArrayLength: arrayLength);
DataType: dataType, Writable: writable, ArrayLength: arrayLength);
return true;
}
catch (JsonException) { return false; }
@@ -57,9 +61,35 @@ public static class AbLegacyEquipmentTagParser
catch (InvalidOperationException) { return false; }
}
private static TEnum ReadEnum<TEnum>(JsonElement o, string name, TEnum fallback) where TEnum : struct, Enum
=> o.TryGetProperty(name, out var e) && e.ValueKind == JsonValueKind.String
&& Enum.TryParse<TEnum>(e.GetString(), ignoreCase: true, out var v) ? v : fallback;
/// <summary>
/// Deploy-time inspection (05/CONV-2): warns on a present-but-invalid <c>dataType</c> (silently
/// defaulted by the lenient runtime) and on a structurally unparseable TagConfig. Empty when clean
/// or not an equipment-tag object. Never throws.
/// </summary>
/// <param name="reference">The equipment tag's TagConfig JSON.</param>
/// <returns>The warnings; empty when clean.</returns>
public static IReadOnlyList<string> Inspect(string reference)
{
var warnings = new List<string>();
if (string.IsNullOrWhiteSpace(reference) || reference[0] != '{') return warnings;
try
{
using var doc = JsonDocument.Parse(reference);
var root = doc.RootElement;
if (root.ValueKind != JsonValueKind.Object)
{
warnings.Add("AbLegacy TagConfig root is not a JSON object — the tag will not resolve (BadNodeIdUnknown).");
return warnings;
}
var w = TagConfigJson.DescribeInvalidEnum<AbLegacyDataType>(root, "dataType");
if (w is not null) warnings.Add(w);
}
catch (JsonException)
{
warnings.Add("AbLegacy TagConfig is not valid JSON — the tag will not resolve (BadNodeIdUnknown).");
}
return warnings;
}
private static string ReadString(JsonElement o, string name)
=> o.TryGetProperty(name, out var e) && e.ValueKind == JsonValueKind.String
@@ -5,5 +5,9 @@
<ImplicitUsings>enable</ImplicitUsings>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
</PropertyGroup>
<!-- NO PackageReference. -->
<!-- NO PackageReference. ProjectReference only to the zero-dependency Core.Abstractions leaf
(R2-11 05/CONV-2 — shared TagConfigJson readers). -->
<ItemGroup>
<ProjectReference Include="..\..\Core\ZB.MOM.WW.OtOpcUa.Core.Abstractions\ZB.MOM.WW.OtOpcUa.Core.Abstractions.csproj"/>
</ItemGroup>
</Project>
@@ -1,4 +1,5 @@
using System.Text.Json;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
@@ -28,11 +29,17 @@ public static class FocasEquipmentTagParser
return false;
var address = addr.GetString();
if (string.IsNullOrWhiteSpace(address)) return false;
var dataType = ReadEnum(root, "dataType", FocasDataType.Int32);
var dataType = TagConfigJson.ReadEnumOrDefault(root, "dataType", FocasDataType.Int32);
var deviceHostAddress = ReadString(root, "deviceHostAddress") ?? "";
def = new FocasTagDefinition(
Name: reference, DeviceHostAddress: deviceHostAddress, Address: address,
DataType: dataType, Writable: true);
// FOCAS equipment tags are FORCED read-only (05/UNDER-1): the only wire client
// (WireFocasClient.cs:71-73) returns BadNotWritable for EVERY address, so advertising a
// writable node is a lie — a write that used to reach the backend and always fail with
// BadNotWritable now fails at the driver seam with the same family of Bad status (no
// successful operation changes). Any authored `writable:true` is ignored + warned by
// Inspect. Honour the key here once PMC writes ship in the wire client.
DataType: dataType, Writable: false);
return true;
}
catch (JsonException) { return false; }
@@ -40,9 +47,38 @@ public static class FocasEquipmentTagParser
catch (InvalidOperationException) { return false; }
}
private static TEnum ReadEnum<TEnum>(JsonElement o, string name, TEnum fallback) where TEnum : struct, Enum
=> o.TryGetProperty(name, out var e) && e.ValueKind == JsonValueKind.String
&& Enum.TryParse<TEnum>(e.GetString(), ignoreCase: true, out var v) ? v : fallback;
/// <summary>
/// Deploy-time inspection (05/CONV-2, 05/UNDER-1): warns on a present-but-invalid <c>dataType</c>
/// (silently defaulted by the lenient runtime), on an authored <c>writable:true</c> (FOCAS writes
/// are unsupported — the tag is forced read-only), and on a structurally unparseable TagConfig.
/// Empty when clean or not an equipment-tag object. Never throws.
/// </summary>
/// <param name="reference">The equipment tag's TagConfig JSON.</param>
/// <returns>The warnings; empty when clean.</returns>
public static IReadOnlyList<string> Inspect(string reference)
{
var warnings = new List<string>();
if (string.IsNullOrWhiteSpace(reference) || reference[0] != '{') return warnings;
try
{
using var doc = JsonDocument.Parse(reference);
var root = doc.RootElement;
if (root.ValueKind != JsonValueKind.Object)
{
warnings.Add("FOCAS TagConfig root is not a JSON object — the tag will not resolve (BadNodeIdUnknown).");
return warnings;
}
var w = TagConfigJson.DescribeInvalidEnum<FocasDataType>(root, "dataType");
if (w is not null) warnings.Add(w);
if (root.TryGetProperty("writable", out var wr) && wr.ValueKind == JsonValueKind.True)
warnings.Add("FOCAS writes unsupported — 'writable:true' is ignored; the tag is forced read-only until PMC writes exist.");
}
catch (JsonException)
{
warnings.Add("FOCAS TagConfig is not valid JSON — the tag will not resolve (BadNodeIdUnknown).");
}
return warnings;
}
private static string? ReadString(JsonElement o, string name)
=> o.TryGetProperty(name, out var e) && e.ValueKind == JsonValueKind.String ? e.GetString() : null;
@@ -5,5 +5,10 @@
<ImplicitUsings>enable</ImplicitUsings>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
</PropertyGroup>
<!-- NO PackageReference. NO ProjectReference. -->
<!-- NO PackageReference. ProjectReference only to the zero-dependency Core.Abstractions leaf
(R2-11 05/CONV-2 — shared TagConfigJson readers beside EquipmentTagRefResolver; no
dependency-closure growth for lightweight AdminUI/Cli consumers). -->
<ItemGroup>
<ProjectReference Include="..\..\Core\ZB.MOM.WW.OtOpcUa.Core.Abstractions\ZB.MOM.WW.OtOpcUa.Core.Abstractions.csproj"/>
</ItemGroup>
</Project>
@@ -68,7 +68,7 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
_logger = logger ?? NullLogger<FocasDriver>.Instance;
_resolver = new EquipmentTagRefResolver<FocasTagDefinition>(
r => _tagsByName.TryGetValue(r, out var t) ? t : null,
r => FocasEquipmentTagParser.TryParse(r, out var d) ? d : null);
ResolveEquipmentTagRef);
_poll = new PollGroupEngine(
reader: ReadAsync,
onChange: (handle, tagRef, snapshot) =>
@@ -234,6 +234,20 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
internal bool IsParsedAddressCached(string reference) =>
_parsedAddressesByTagName.ContainsKey(reference);
// Resolves an equipment-tag TagConfig reference into a FocasTagDefinition, applying the SAME
// capability pre-flight authored tags get at InitializeAsync (:105-119) — R2-11 (05/UNDER-6). A
// reference whose address is unparseable, whose bound device is unknown, or whose address violates
// the device series' capability matrix returns null ⇒ resolver miss ⇒ BadNodeIdUnknown at read time,
// instead of a wrong-status read reaching the wire. The negative is cached by the resolver.
private FocasTagDefinition? ResolveEquipmentTagRef(string reference)
{
if (!FocasEquipmentTagParser.TryParse(reference, out var def)) return null;
var parsed = FocasAddress.TryParse(def.Address);
if (parsed is null) return null;
if (!_devices.TryGetValue(def.DeviceHostAddress, out var device)) return null;
return FocasCapabilityMatrix.Validate(device.Options.Series, parsed) is null ? def : null;
}
// Resolves a tag definition to its parsed FocasAddress, caching the result so that
// equipment tags (resolver-produced, not seeded at InitializeAsync) don't re-parse the
// address string on every ReadAsync / WriteAsync hot-path call.
@@ -1,4 +1,5 @@
using System.Text.Json;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus;
@@ -29,9 +30,9 @@ public static class ModbusEquipmentTagParser
|| !addr.TryGetInt32(out var address)
|| address < 0 || address > ushort.MaxValue)
return false;
var region = ReadEnum(root, "region", ModbusRegion.HoldingRegisters);
var dataType = ReadEnum(root, "dataType", ModbusDataType.Int16);
var byteOrder = ReadEnum(root, "byteOrder", ModbusByteOrder.BigEndian);
var region = TagConfigJson.ReadEnumOrDefault(root, "region", ModbusRegion.HoldingRegisters);
var dataType = TagConfigJson.ReadEnumOrDefault(root, "dataType", ModbusDataType.Int16);
var byteOrder = TagConfigJson.ReadEnumOrDefault(root, "byteOrder", ModbusByteOrder.BigEndian);
var bitIndex = (byte)ReadInt(root, "bitIndex");
var stringLength = (ushort)ReadInt(root, "stringLength");
// Guard: String tags require StringLength >= 1. RegisterCount = (StringLength+1)/2,
@@ -51,9 +52,12 @@ public static class ModbusEquipmentTagParser
var isArray = ReadBool(root, "isArray");
var arrayLength = ReadInt(root, "arrayLength");
int? arrayCount = (isArray && arrayLength >= 1) ? arrayLength : null;
// "writable" defaults to true when absent (today's value) — makes read-only equipment tags
// authorable (UNDER-6). Node-level authz remains the effective write gate.
var writable = TagConfigJson.ReadWritable(root);
def = new ModbusTagDefinition(
Name: reference, Region: region, Address: (ushort)address, DataType: dataType,
Writable: true, ByteOrder: byteOrder, BitIndex: bitIndex, StringLength: stringLength,
Writable: writable, ByteOrder: byteOrder, BitIndex: bitIndex, StringLength: stringLength,
ArrayCount: arrayCount);
return true;
}
@@ -62,9 +66,44 @@ public static class ModbusEquipmentTagParser
catch (InvalidOperationException) { return false; }
}
private static TEnum ReadEnum<TEnum>(JsonElement o, string name, TEnum fallback) where TEnum : struct, Enum
=> o.TryGetProperty(name, out var e) && e.ValueKind == JsonValueKind.String
&& Enum.TryParse<TEnum>(e.GetString(), ignoreCase: true, out var v) ? v : fallback;
/// <summary>
/// Deploy-time inspection of an equipment-tag <c>TagConfig</c> blob (05/CONV-2). Returns
/// human-readable warnings for present-but-invalid enum values (<c>region</c> / <c>dataType</c> /
/// <c>byteOrder</c> — which the lenient runtime silently defaults) and for a structurally
/// unparseable TagConfig (which the runtime turns into a silent <c>BadNodeIdUnknown</c>). Empty when
/// the blob is clean or is not an equipment-tag TagConfig object. Never throws.
/// </summary>
/// <param name="reference">The equipment tag's TagConfig JSON.</param>
/// <returns>The warnings; empty when clean.</returns>
public static IReadOnlyList<string> Inspect(string reference)
{
var warnings = new List<string>();
if (string.IsNullOrWhiteSpace(reference) || reference[0] != '{') return warnings;
try
{
using var doc = JsonDocument.Parse(reference);
var root = doc.RootElement;
if (root.ValueKind != JsonValueKind.Object)
{
warnings.Add("Modbus TagConfig root is not a JSON object — the tag will not resolve (BadNodeIdUnknown).");
return warnings;
}
foreach (var w in new[]
{
TagConfigJson.DescribeInvalidEnum<ModbusRegion>(root, "region"),
TagConfigJson.DescribeInvalidEnum<ModbusDataType>(root, "dataType"),
TagConfigJson.DescribeInvalidEnum<ModbusByteOrder>(root, "byteOrder"),
})
{
if (w is not null) warnings.Add(w);
}
}
catch (JsonException)
{
warnings.Add("Modbus TagConfig is not valid JSON — the tag will not resolve (BadNodeIdUnknown).");
}
return warnings;
}
private static int ReadInt(JsonElement o, string name)
=> o.TryGetProperty(name, out var e) && e.ValueKind == JsonValueKind.Number
@@ -12,6 +12,9 @@
and was designed for exactly this use — Admin can reference it without transport-layer deps. -->
<ItemGroup>
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Driver.Modbus.Addressing\ZB.MOM.WW.OtOpcUa.Driver.Modbus.Addressing.csproj"/>
<!-- R2-11 (05/CONV-2): shared TagConfigJson field readers live in the zero-dependency
Core.Abstractions leaf beside EquipmentTagRefResolver. -->
<ProjectReference Include="..\..\Core\ZB.MOM.WW.OtOpcUa.Core.Abstractions\ZB.MOM.WW.OtOpcUa.Core.Abstractions.csproj"/>
</ItemGroup>
</Project>
@@ -29,19 +29,44 @@ public sealed class ModbusDriverProbe : IDriverProbe
/// <inheritdoc />
public string DriverType => "Modbus";
/// <summary>The parsed probe target — host/port/unitId + the effective connection timeout, all read
/// from the SAME factory DTO shape (<c>ModbusDriverConfigDto</c>) the driver factory parses, so
/// <c>timeoutMs</c> binds identically to the factory (R2-11, 05/CONV-2; the OpcUaClient parity rule).</summary>
internal readonly record struct ProbeTarget(string Host, int Port, byte UnitId, TimeSpan Timeout);
/// <summary>Parse the driver config JSON into a <see cref="ProbeTarget"/> using the factory DTO shape.
/// Returns a null target + an error message when the JSON is invalid or has no host/port. The effective
/// timeout mirrors the factory (<c>TimeSpan.FromMilliseconds(dto.TimeoutMs ?? …)</c>); when the config
/// omits <c>timeoutMs</c> the caller's <paramref name="fallbackTimeout"/> is used.</summary>
/// <param name="configJson">The driver config JSON (factory DTO shape).</param>
/// <param name="fallbackTimeout">The timeout used when the config omits <c>timeoutMs</c>.</param>
/// <returns>The parsed target, or a null target with an error string.</returns>
internal static (ProbeTarget? Target, string? Error) TryParseProbeTarget(string configJson, TimeSpan fallbackTimeout)
{
ModbusDriverFactoryExtensions.ModbusDriverConfigDto? dto;
try { dto = JsonSerializer.Deserialize<ModbusDriverFactoryExtensions.ModbusDriverConfigDto>(configJson, _opts); }
catch (Exception ex) { return (null, $"Config JSON is invalid: {ex.Message}"); }
if (dto is null) return (null, "Config JSON deserialized to null.");
var port = dto.Port ?? 502;
if (string.IsNullOrWhiteSpace(dto.Host) || port <= 0)
return (null, "Config has no host/port to probe.");
var timeout = dto.TimeoutMs is { } ms && ms > 0 ? TimeSpan.FromMilliseconds(ms) : fallbackTimeout;
return (new ProbeTarget(dto.Host!, port, dto.UnitId ?? 1, timeout), null);
}
/// <inheritdoc />
public async Task<DriverProbeResult> ProbeAsync(string configJson, TimeSpan timeout, CancellationToken ct)
{
ModbusDriverOptions? opts;
try { opts = JsonSerializer.Deserialize<ModbusDriverOptions>(configJson, _opts); }
catch (Exception ex) { return new(false, $"Config JSON is invalid: {ex.Message}", null); }
if (opts is null) return new(false, "Config JSON deserialized to null.", null);
var (target, error) = TryParseProbeTarget(configJson, timeout);
if (target is not { } t) return new(false, error!, null);
var (host, port) = ExtractTarget(opts);
if (string.IsNullOrWhiteSpace(host) || port <= 0)
return new(false, "Config has no host/port to probe.", null);
var unitId = opts.UnitId;
var host = t.Host;
var port = t.Port;
var unitId = t.UnitId;
// Honour the config's timeoutMs (factory parity) — the caller's timeout is the fallback.
timeout = t.Timeout;
var sw = Stopwatch.StartNew();
using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
@@ -93,7 +118,4 @@ public sealed class ModbusDriverProbe : IDriverProbe
}
}
}
private static (string host, int port) ExtractTarget(ModbusDriverOptions opts)
=> (opts.Host, opts.Port);
}
@@ -1,4 +1,5 @@
using System.Text.Json;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.S7;
@@ -31,7 +32,7 @@ public static class S7EquipmentTagParser
return false;
var address = addrEl.GetString();
if (string.IsNullOrWhiteSpace(address)) return false;
var dataType = ReadEnum(root, "dataType", S7DataType.Int16);
var dataType = TagConfigJson.ReadEnumOrDefault(root, "dataType", S7DataType.Int16);
var stringLength = ReadInt(root, "stringLength");
// Range-guard applies only to String tags: an S7 string can't exceed 254 chars, and a
// negative length is meaningless. For non-String types stringLength is irrelevant and any
@@ -42,11 +43,14 @@ public static class S7EquipmentTagParser
// that here so the driver's transient def agrees byte-for-byte with the materialised
// OPC UA node's ValueRank/ArrayDimensions. Absent / isArray=false ⇒ null (scalar).
var arrayCount = ReadArrayCount(root);
// "writable" defaults to true when absent (today's value); node-level authz still governs
// writes. Honouring the key makes read-only equipment tags authorable (UNDER-6).
var writable = TagConfigJson.ReadWritable(root);
def = new S7TagDefinition(
Name: reference,
Address: address,
DataType: dataType,
Writable: true, // node-level authz governs writes
Writable: writable,
StringLength: stringLength == 0 ? MaxStringLength : stringLength,
ArrayCount: arrayCount);
return true;
@@ -56,9 +60,35 @@ public static class S7EquipmentTagParser
catch (InvalidOperationException) { return false; }
}
private static TEnum ReadEnum<TEnum>(JsonElement o, string name, TEnum fallback) where TEnum : struct, Enum
=> o.TryGetProperty(name, out var e) && e.ValueKind == JsonValueKind.String
&& Enum.TryParse<TEnum>(e.GetString(), ignoreCase: true, out var v) ? v : fallback;
/// <summary>
/// Deploy-time inspection (05/CONV-2): warns on a present-but-invalid <c>dataType</c> (silently
/// defaulted by the lenient runtime) and on a structurally unparseable TagConfig (a silent
/// <c>BadNodeIdUnknown</c> at runtime). Empty when clean or not an equipment-tag object. Never throws.
/// </summary>
/// <param name="reference">The equipment tag's TagConfig JSON.</param>
/// <returns>The warnings; empty when clean.</returns>
public static IReadOnlyList<string> Inspect(string reference)
{
var warnings = new List<string>();
if (string.IsNullOrWhiteSpace(reference) || reference[0] != '{') return warnings;
try
{
using var doc = JsonDocument.Parse(reference);
var root = doc.RootElement;
if (root.ValueKind != JsonValueKind.Object)
{
warnings.Add("S7 TagConfig root is not a JSON object — the tag will not resolve (BadNodeIdUnknown).");
return warnings;
}
var w = TagConfigJson.DescribeInvalidEnum<S7DataType>(root, "dataType");
if (w is not null) warnings.Add(w);
}
catch (JsonException)
{
warnings.Add("S7 TagConfig is not valid JSON — the tag will not resolve (BadNodeIdUnknown).");
}
return warnings;
}
private static int ReadInt(JsonElement o, string name)
=> o.TryGetProperty(name, out var e) && e.ValueKind == JsonValueKind.Number
@@ -7,6 +7,11 @@
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
</PropertyGroup>
<!-- NO PackageReference. NO ProjectReference. -->
<!-- NO PackageReference. ProjectReference only to the zero-dependency Core.Abstractions leaf
(R2-11 05/CONV-2 — shared TagConfigJson readers beside EquipmentTagRefResolver; adds no
dependency-closure growth for lightweight AdminUI/Cli consumers). -->
<ItemGroup>
<ProjectReference Include="..\..\Core\ZB.MOM.WW.OtOpcUa.Core.Abstractions\ZB.MOM.WW.OtOpcUa.Core.Abstractions.csproj"/>
</ItemGroup>
</Project>
@@ -1,4 +1,5 @@
using System.Text.Json;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT;
@@ -28,15 +29,18 @@ public static class TwinCATEquipmentTagParser
var symbolPath = symbol.GetString();
if (string.IsNullOrWhiteSpace(symbolPath)) return false;
var deviceHostAddress = ReadString(root, "deviceHostAddress");
var dataType = ReadEnum(root, "dataType", TwinCATDataType.DInt);
var dataType = TagConfigJson.ReadEnumOrDefault(root, "dataType", TwinCATDataType.DInt);
// Array intent — same shape the runtime/OPC-UA foundation parses (camelCase
// `isArray` bool + `arrayLength` uint). arrayLength is honoured ONLY when isArray
// is true AND it is a positive JSON number, so a stale length behind a cleared
// isArray never produces an orphan array tag (Phase 4c).
var arrayLength = ReadArrayLength(root);
// "writable" defaults to true when absent (today's value) — makes read-only equipment tags
// authorable (UNDER-6).
var writable = TagConfigJson.ReadWritable(root);
def = new TwinCATTagDefinition(
Name: reference, DeviceHostAddress: deviceHostAddress, SymbolPath: symbolPath,
DataType: dataType, Writable: true, ArrayLength: arrayLength);
DataType: dataType, Writable: writable, ArrayLength: arrayLength);
return true;
}
catch (JsonException) { return false; }
@@ -44,9 +48,35 @@ public static class TwinCATEquipmentTagParser
catch (InvalidOperationException) { return false; }
}
private static TEnum ReadEnum<TEnum>(JsonElement o, string name, TEnum fallback) where TEnum : struct, Enum
=> o.TryGetProperty(name, out var e) && e.ValueKind == JsonValueKind.String
&& Enum.TryParse<TEnum>(e.GetString(), ignoreCase: true, out var v) ? v : fallback;
/// <summary>
/// Deploy-time inspection (05/CONV-2): warns on a present-but-invalid <c>dataType</c> (silently
/// defaulted by the lenient runtime) and on a structurally unparseable TagConfig. Empty when clean
/// or not an equipment-tag object. Never throws.
/// </summary>
/// <param name="reference">The equipment tag's TagConfig JSON.</param>
/// <returns>The warnings; empty when clean.</returns>
public static IReadOnlyList<string> Inspect(string reference)
{
var warnings = new List<string>();
if (string.IsNullOrWhiteSpace(reference) || reference[0] != '{') return warnings;
try
{
using var doc = JsonDocument.Parse(reference);
var root = doc.RootElement;
if (root.ValueKind != JsonValueKind.Object)
{
warnings.Add("TwinCAT TagConfig root is not a JSON object — the tag will not resolve (BadNodeIdUnknown).");
return warnings;
}
var w = TagConfigJson.DescribeInvalidEnum<TwinCATDataType>(root, "dataType");
if (w is not null) warnings.Add(w);
}
catch (JsonException)
{
warnings.Add("TwinCAT TagConfig is not valid JSON — the tag will not resolve (BadNodeIdUnknown).");
}
return warnings;
}
private static string ReadString(JsonElement o, string name)
=> o.TryGetProperty(name, out var e) && e.ValueKind == JsonValueKind.String
@@ -5,5 +5,9 @@
<ImplicitUsings>enable</ImplicitUsings>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
</PropertyGroup>
<!-- NO PackageReference. -->
<!-- NO PackageReference. ProjectReference only to the zero-dependency Core.Abstractions leaf
(R2-11 05/CONV-2 — shared TagConfigJson readers). -->
<ItemGroup>
<ProjectReference Include="..\..\Core\ZB.MOM.WW.OtOpcUa.Core.Abstractions\ZB.MOM.WW.OtOpcUa.Core.Abstractions.csproj"/>
</ItemGroup>
</Project>