fix(modbus): review r3 - one address authority (ModbusTagAddressResolution)
N3: a keyless blob carrying the addressResolved marker was served from its string by the driver but saved as HR[0] by the editor model / CSV path. The precedence rule now lives in ONE pure resolver in Driver.Modbus.Contracts (ModbusTagAddressResolution.Classify/Resolve): keys when region+address are both present, else a string-only addressString resolved with the driver's family (refused when it cannot parse), else refused. The marker never decides. Consumers that now ask it instead of restating it: the driver's tag reader (ModbusTagDefinitionFactory.FromTagConfig), the deploy gate (Inspect / DescribeRejection), the editor model (AddressShape / IsStringOnly / HasKeyAddress / ResolveWith, ToJson stamping, Validate), the CSV export cells and the editor hint. The model writes keys only when it loaded them or a field was authored - never defaults it did not resolve (also closes two divergences the new matrix found: malformed-string+marker and non-string addressString were saved as HR[0]). One table-driven test (ModbusAddressAuthorityAgreementTests) runs 15 persisted shapes through driver, deploy gate, model load/save/Validate and the real CSV export->import under all four families; breaking the CSV export or reverting the model to the marker rule fails it. Minors: m1 CSV address cells keep the string+marker while it still describes them (lossless unchanged round trip), drop it when contradicted, and the review grid reports the drop; m2 the pre-lane test uses the real export residual; m3 unmarked string-only + typed-cell case added; m4 stale docs/summary and the unused factory parameter removed. Refs #741 Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
This commit is contained in:
@@ -138,8 +138,9 @@ public sealed class ModbusDriverOptions
|
||||
public bool DisableReadCoalescing { get; init; } = false;
|
||||
|
||||
/// <summary>
|
||||
/// PLC family hint that drives the parser's family-native branch. A tag authored with an
|
||||
/// <c>addressString</c> is resolved with this family at every initialize, so with a non-Generic
|
||||
/// PLC family hint that drives the parser's family-native branch. A STRING-ONLY tag (an
|
||||
/// <c>addressString</c> with no region/address keys — see <see cref="ModbusTagAddressResolution"/>) is
|
||||
/// resolved with this family at every initialize, so with a non-Generic
|
||||
/// value that family's native syntax (DL205 V2000 octal / MELSEC D100) parses to the right
|
||||
/// region + offset. Defaults to <see cref="ModbusFamily.Generic"/> (Modicon / mnemonic grammar).
|
||||
/// Tags authored by region + numeric address are unaffected.
|
||||
|
||||
@@ -0,0 +1,251 @@
|
||||
using System.Text.Json;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus;
|
||||
|
||||
/// <summary>
|
||||
/// Which part of a Modbus <c>TagConfig</c> blob names the tag's address — the output of
|
||||
/// <see cref="ModbusTagAddressResolution.Classify(JsonElement)"/>.
|
||||
/// </summary>
|
||||
public enum ModbusAddressShape
|
||||
{
|
||||
/// <summary>
|
||||
/// Both <c>region</c> and <c>address</c> are present: those keys are the address, whatever
|
||||
/// <c>addressString</c> / <c>addressResolved</c> sit beside them (classic, editor-pinned, or a pre-#741 row
|
||||
/// whose string the old driver never read).
|
||||
/// </summary>
|
||||
Keys,
|
||||
|
||||
/// <summary>
|
||||
/// The keys are not both present and <c>addressString</c> is a non-blank string: the string is the address,
|
||||
/// resolved with the DRIVER's family. A family that cannot parse it refuses the tag.
|
||||
/// </summary>
|
||||
StringOnly,
|
||||
|
||||
/// <summary>The keys are not both present and <c>addressString</c> is present but not a string: refused.</summary>
|
||||
InvalidAddressString,
|
||||
|
||||
/// <summary>Neither the keys nor a usable <c>addressString</c>: refused (the tag names no address).</summary>
|
||||
NoAddress,
|
||||
}
|
||||
|
||||
/// <summary>The address part of a Modbus tag, as the driver will poll it.</summary>
|
||||
/// <param name="Shape">Which part of the blob named it (<see cref="ModbusAddressShape.Keys"/> or <see cref="ModbusAddressShape.StringOnly"/>).</param>
|
||||
/// <param name="Region">The register region.</param>
|
||||
/// <param name="Address">The zero-based offset.</param>
|
||||
/// <param name="DataType">The data type (already coerced to <c>Bool</c> for a bit region).</param>
|
||||
/// <param name="ByteOrder">The word/byte order.</param>
|
||||
/// <param name="BitIndex">The bit index (BitInRegister).</param>
|
||||
/// <param name="StringLength">The string length (String).</param>
|
||||
/// <param name="ArrayCount">The element count, or <c>null</c> for a scalar.</param>
|
||||
public sealed record ModbusResolvedAddress(
|
||||
ModbusAddressShape Shape,
|
||||
ModbusRegion Region,
|
||||
ushort Address,
|
||||
ModbusDataType DataType,
|
||||
ModbusByteOrder ByteOrder,
|
||||
byte BitIndex,
|
||||
ushort StringLength,
|
||||
int? ArrayCount);
|
||||
|
||||
/// <summary>The outcome of <see cref="ModbusTagAddressResolution.Resolve(JsonElement, ModbusFamily, MelsecFamily)"/>.</summary>
|
||||
/// <param name="Shape">The blob's address shape (always set, also on a refusal).</param>
|
||||
/// <param name="Address">The resolved address, or <c>null</c> when the tag is refused.</param>
|
||||
/// <param name="Refusal">Why the tag is refused, or <c>null</c> when it resolved.</param>
|
||||
public readonly record struct ModbusAddressResolution(ModbusAddressShape Shape, ModbusResolvedAddress? Address, string? Refusal)
|
||||
{
|
||||
/// <summary>Whether the driver serves the tag at <see cref="Address"/>.</summary>
|
||||
public bool IsResolved => Address is not null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// <b>The one authority for which address a Modbus <c>TagConfig</c> names</b> (#741, review r3 N3). The driver's
|
||||
/// tag reader, the deploy gate, the AdminUI editor model (load / save / Validate / display) and the CSV
|
||||
/// import/export all ask this class; none of them restates the precedence.
|
||||
/// <para>The rule:</para>
|
||||
/// <list type="number">
|
||||
/// <item>When <c>region</c> AND <c>address</c> are both present they are the address
|
||||
/// (<see cref="ModbusAddressShape.Keys"/>). No pre-#741 shape changes the address it resolves to: the old
|
||||
/// driver read the keys only and ignored any <c>addressString</c>.</item>
|
||||
/// <item>Otherwise a non-blank string <c>addressString</c> is the address
|
||||
/// (<see cref="ModbusAddressShape.StringOnly"/>), parsed by <see cref="ModbusAddressParser"/> with the
|
||||
/// DRIVER's family. If that family cannot parse it the tag is REFUSED — never served as HR[0].</item>
|
||||
/// <item>Anything else is refused.</item>
|
||||
/// </list>
|
||||
/// <c>addressResolved</c> is editor display / CSV state only and never changes the shape.
|
||||
/// </summary>
|
||||
public static class ModbusTagAddressResolution
|
||||
{
|
||||
/// <summary>The TagConfig key carrying the parser-grammar address (<c>40001:F:CDAB</c>, <c>V2000</c>, <c>D100</c>).</summary>
|
||||
public const string AddressStringKey = "addressString";
|
||||
|
||||
/// <summary>
|
||||
/// The key the editor writes beside the keys it resolved from an <c>addressString</c>
|
||||
/// (<c>"addressResolved": true</c>). Display / CSV state only: it never decides the address.
|
||||
/// </summary>
|
||||
public const string AddressResolvedKey = "addressResolved";
|
||||
|
||||
/// <summary>Every (family, MELSEC sub-family) a driver can be configured with — the family-unaware gates' search space.</summary>
|
||||
public static IReadOnlyList<(ModbusFamily Family, MelsecFamily Sub)> AllFamilies { get; } =
|
||||
[
|
||||
(ModbusFamily.Generic, MelsecFamily.Q_L_iQR),
|
||||
(ModbusFamily.DL205, MelsecFamily.Q_L_iQR),
|
||||
(ModbusFamily.MELSEC, MelsecFamily.Q_L_iQR),
|
||||
(ModbusFamily.MELSEC, MelsecFamily.F_iQF),
|
||||
];
|
||||
|
||||
/// <summary>Classifies an object-rooted TagConfig by which part of it names the address.</summary>
|
||||
/// <param name="root">The TagConfig root (a non-object is <see cref="ModbusAddressShape.NoAddress"/>).</param>
|
||||
/// <returns>The address shape.</returns>
|
||||
public static ModbusAddressShape Classify(JsonElement root)
|
||||
{
|
||||
if (root.ValueKind != JsonValueKind.Object) return ModbusAddressShape.NoAddress;
|
||||
if (root.TryGetProperty("region", out _) && root.TryGetProperty("address", out _)) return ModbusAddressShape.Keys;
|
||||
if (!root.TryGetProperty(AddressStringKey, out var s) || s.ValueKind == JsonValueKind.Null) return ModbusAddressShape.NoAddress;
|
||||
if (s.ValueKind != JsonValueKind.String) return ModbusAddressShape.InvalidAddressString;
|
||||
return string.IsNullOrWhiteSpace(s.GetString()) ? ModbusAddressShape.NoAddress : ModbusAddressShape.StringOnly;
|
||||
}
|
||||
|
||||
/// <summary>Classifies a TagConfig JSON string (blank / malformed / non-object ⇒ <see cref="ModbusAddressShape.NoAddress"/>).</summary>
|
||||
/// <param name="tagConfig">The TagConfig JSON.</param>
|
||||
/// <returns>The address shape.</returns>
|
||||
public static ModbusAddressShape Classify(string? tagConfig)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(tagConfig)) return ModbusAddressShape.NoAddress;
|
||||
try
|
||||
{
|
||||
using var doc = JsonDocument.Parse(tagConfig);
|
||||
return Classify(doc.RootElement);
|
||||
}
|
||||
catch (JsonException) { return ModbusAddressShape.NoAddress; }
|
||||
}
|
||||
|
||||
/// <summary>Resolves a TagConfig JSON string's address under one family.</summary>
|
||||
/// <param name="tagConfig">The TagConfig JSON.</param>
|
||||
/// <param name="family">The driver's PLC family.</param>
|
||||
/// <param name="melsecSubFamily">The driver's MELSEC sub-family.</param>
|
||||
/// <returns>The resolution.</returns>
|
||||
public static ModbusAddressResolution Resolve(string? tagConfig, ModbusFamily family, MelsecFamily melsecSubFamily)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(tagConfig))
|
||||
return Refused(ModbusAddressShape.NoAddress, "Modbus TagConfig is blank — the tag names no address.");
|
||||
try
|
||||
{
|
||||
using var doc = JsonDocument.Parse(tagConfig);
|
||||
return Resolve(doc.RootElement, family, melsecSubFamily);
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return Refused(ModbusAddressShape.NoAddress, "Modbus TagConfig is not valid JSON — the tag will not resolve (BadNodeIdUnknown).");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolves the address of an object-rooted TagConfig under one family — the rule in the class remarks.
|
||||
/// Key reads are STRICT (a present-but-invalid <c>region</c> / <c>dataType</c> / <c>byteOrder</c>, or an
|
||||
/// <c>address</c> that is not an integer 0–65535, refuses the tag); a bit region's data type is coerced to
|
||||
/// <c>Bool</c> (pre-#741 editor blobs persisted <c>Int16</c> there and the old driver ignored it).
|
||||
/// </summary>
|
||||
/// <param name="root">The TagConfig root.</param>
|
||||
/// <param name="family">The driver's PLC family.</param>
|
||||
/// <param name="melsecSubFamily">The driver's MELSEC sub-family.</param>
|
||||
/// <returns>The resolution.</returns>
|
||||
public static ModbusAddressResolution Resolve(JsonElement root, ModbusFamily family, MelsecFamily melsecSubFamily)
|
||||
{
|
||||
var shape = Classify(root);
|
||||
try
|
||||
{
|
||||
switch (shape)
|
||||
{
|
||||
case ModbusAddressShape.Keys:
|
||||
return ResolveKeys(root);
|
||||
case ModbusAddressShape.StringOnly:
|
||||
{
|
||||
var text = root.GetProperty(AddressStringKey).GetString()!.Trim();
|
||||
if (!ModbusAddressParser.TryParse(text, family, melsecSubFamily, out var parsed, out var error) || parsed is null)
|
||||
return Refused(shape, $"Modbus 'addressString' '{text}' does not parse under the {Describe(family, melsecSubFamily)} family ({error}) — the tag will not resolve (BadNodeIdUnknown).");
|
||||
return Resolved(shape, parsed.Region, parsed.Offset, parsed.DataType, parsed.ByteOrder,
|
||||
parsed.Bit ?? 0, parsed.StringLength, parsed.ArrayCount);
|
||||
}
|
||||
case ModbusAddressShape.InvalidAddressString:
|
||||
return Refused(shape, "Modbus TagConfig 'addressString' is not a string — the tag will not resolve (BadNodeIdUnknown).");
|
||||
default:
|
||||
return Refused(shape, "Modbus TagConfig names no address: it needs 'region' + 'address' or an 'addressString' — the tag will not resolve (BadNodeIdUnknown).");
|
||||
}
|
||||
}
|
||||
catch (FormatException) { return Refused(shape, "Modbus TagConfig address could not be read — the tag will not resolve (BadNodeIdUnknown)."); }
|
||||
catch (InvalidOperationException) { return Refused(shape, "Modbus TagConfig address could not be read — the tag will not resolve (BadNodeIdUnknown)."); }
|
||||
}
|
||||
|
||||
/// <summary>Whether the driver serves the blob's address under at least one family (the family-unaware gates' accept rule).</summary>
|
||||
/// <param name="tagConfig">The TagConfig JSON.</param>
|
||||
/// <returns><see langword="true"/> when some family resolves it.</returns>
|
||||
public static bool ResolvesUnderAnyFamily(string? tagConfig)
|
||||
=> AllFamilies.Any(f => Resolve(tagConfig, f.Family, f.Sub).IsResolved);
|
||||
|
||||
/// <summary>
|
||||
/// Whether <paramref name="addressString"/> parses, under SOME family, to exactly the given address fields —
|
||||
/// i.e. the string still describes the keys beside it. Used by the CSV import to keep an exported pinned
|
||||
/// row's string through an unchanged round trip while dropping one a typed cell contradicts. It never
|
||||
/// decides the address (the keys do, by the rule above).
|
||||
/// </summary>
|
||||
/// <param name="addressString">The address string.</param>
|
||||
/// <param name="region">The keys' region.</param>
|
||||
/// <param name="address">The keys' offset.</param>
|
||||
/// <param name="dataType">The keys' data type.</param>
|
||||
/// <param name="byteOrder">The keys' byte order.</param>
|
||||
/// <param name="bitIndex">The keys' bit index.</param>
|
||||
/// <param name="stringLength">The keys' string length.</param>
|
||||
/// <returns><see langword="true"/> when some family parses the string to those fields.</returns>
|
||||
public static bool AddressStringDescribes(string? addressString, ModbusRegion region, int address, ModbusDataType dataType,
|
||||
ModbusByteOrder byteOrder, int bitIndex, int stringLength)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(addressString)) return false;
|
||||
foreach (var (family, sub) in AllFamilies)
|
||||
{
|
||||
if (!ModbusAddressParser.TryParse(addressString.Trim(), family, sub, out var p, out _) || p is null) continue;
|
||||
var parsedType = p.Region is ModbusRegion.Coils or ModbusRegion.DiscreteInputs ? ModbusDataType.Bool : p.DataType;
|
||||
if (p.Region == region && p.Offset == address && parsedType == dataType && p.ByteOrder == byteOrder
|
||||
&& (p.Bit ?? 0) == bitIndex && p.StringLength == stringLength)
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private static ModbusAddressResolution ResolveKeys(JsonElement root)
|
||||
{
|
||||
var addr = root.GetProperty("address");
|
||||
if (addr.ValueKind != JsonValueKind.Number || !addr.TryGetInt32(out var address) || address < 0 || address > ushort.MaxValue)
|
||||
return Refused(ModbusAddressShape.Keys,
|
||||
$"value '{addr.GetRawText()}' for 'address' is not a Modbus register address (an integer 0–{ushort.MaxValue}); the tag will not resolve (BadNodeIdUnknown).");
|
||||
if (!TagConfigJson.TryReadEnumStrict(root, "region", ModbusRegion.HoldingRegisters, out ModbusRegion region))
|
||||
return Refused(ModbusAddressShape.Keys, TagConfigJson.DescribeInvalidEnum<ModbusRegion>(root, "region") ?? "invalid 'region'");
|
||||
if (!TagConfigJson.TryReadEnumStrict(root, "dataType", ModbusTagDefinitionFactory.DefaultDataType(region), out ModbusDataType dataType))
|
||||
return Refused(ModbusAddressShape.Keys, TagConfigJson.DescribeInvalidEnum<ModbusDataType>(root, "dataType") ?? "invalid 'dataType'");
|
||||
if (!TagConfigJson.TryReadEnumStrict(root, "byteOrder", ModbusByteOrder.BigEndian, out ModbusByteOrder byteOrder))
|
||||
return Refused(ModbusAddressShape.Keys, TagConfigJson.DescribeInvalidEnum<ModbusByteOrder>(root, "byteOrder") ?? "invalid 'byteOrder'");
|
||||
// isArray / arrayLength: an array iff isArray:true AND arrayLength >= 1 (a 1-element array is valid).
|
||||
var isArray = root.TryGetProperty("isArray", out var ia) && ia.ValueKind == JsonValueKind.True;
|
||||
var arrayLength = ReadInt(root, "arrayLength");
|
||||
return Resolved(ModbusAddressShape.Keys, region, address, dataType, byteOrder,
|
||||
(byte)ReadInt(root, "bitIndex"), (ushort)ReadInt(root, "stringLength"), isArray && arrayLength >= 1 ? arrayLength : null);
|
||||
}
|
||||
|
||||
private static ModbusAddressResolution Resolved(ModbusAddressShape shape, ModbusRegion region, int address, ModbusDataType dataType,
|
||||
ModbusByteOrder byteOrder, int bitIndex, int stringLength, int? arrayCount)
|
||||
{
|
||||
// A bit region carries only Bool: coerce, never refuse (pre-#741 coil blobs persisted Int16).
|
||||
if (region is ModbusRegion.Coils or ModbusRegion.DiscreteInputs) dataType = ModbusDataType.Bool;
|
||||
return new ModbusAddressResolution(shape,
|
||||
new ModbusResolvedAddress(shape, region, (ushort)address, dataType, byteOrder, (byte)bitIndex, (ushort)stringLength, arrayCount),
|
||||
null);
|
||||
}
|
||||
|
||||
private static ModbusAddressResolution Refused(ModbusAddressShape shape, string reason) => new(shape, null, reason);
|
||||
|
||||
private static string Describe(ModbusFamily family, MelsecFamily sub)
|
||||
=> family == ModbusFamily.MELSEC ? $"MELSEC {sub}" : family.ToString();
|
||||
|
||||
private static int ReadInt(JsonElement o, string name)
|
||||
=> o.TryGetProperty(name, out var e) && e.ValueKind == JsonValueKind.Number && e.TryGetInt32(out var v) ? v : 0;
|
||||
}
|
||||
+43
-115
@@ -35,14 +35,13 @@ public static class ModbusTagDefinitionFactory
|
||||
=> FromTagConfig(tagConfig, rawPath, ModbusFamily.Generic, MelsecFamily.Q_L_iQR, out def);
|
||||
|
||||
/// <summary>
|
||||
/// Family-aware overload. The persisted <c>region</c> + <c>address</c> keys are AUTHORITATIVE whenever
|
||||
/// both are present (classic, editor-pinned, or a pre-#741 row with a string beside them), so a later
|
||||
/// Family / MelsecSubFamily change can never silently remap such a tag. A blob carrying an
|
||||
/// <c>addressString</c> and NO region/address keys (CSV / API string-only authoring; the Modicon / mnemonic / family-native
|
||||
/// grammar of <see cref="ModbusAddressParser"/> — <c>40001:F:CDAB</c>, DL205 <c>V2000</c>, MELSEC
|
||||
/// <c>D100</c>) is resolved here with the DRIVER's <paramref name="family"/> /
|
||||
/// <paramref name="melsecSubFamily"/>. Without an <c>addressString</c> this is exactly the 3-argument
|
||||
/// overload.
|
||||
/// Family-aware overload — the call <c>ModbusDriver</c> makes with its own Family / MelsecSubFamily. WHICH
|
||||
/// address the blob names is decided by <see cref="ModbusTagAddressResolution"/> (the one authority shared
|
||||
/// with the deploy gate, the AdminUI editor model and the CSV import/export): <c>region</c> + <c>address</c>
|
||||
/// when both are present, else a string-only <c>addressString</c> resolved with the DRIVER's
|
||||
/// <paramref name="family"/> / <paramref name="melsecSubFamily"/> (refused when that family cannot parse it).
|
||||
/// This method adds only the non-address reads and guards (region/type compatibility, String length,
|
||||
/// BitInRegister index, writable, unitId, deadband, coalesceProhibited).
|
||||
/// </summary>
|
||||
/// <param name="tagConfig">The authored equipment-tag TagConfig JSON.</param>
|
||||
/// <param name="rawPath">The tag's RawPath — becomes the definition's identity (<c>Name</c>).</param>
|
||||
@@ -61,60 +60,20 @@ public static class ModbusTagDefinitionFactory
|
||||
var root = doc.RootElement;
|
||||
if (root.ValueKind != JsonValueKind.Object) return false;
|
||||
|
||||
ModbusRegion region;
|
||||
int address;
|
||||
ModbusDataType dataType;
|
||||
ModbusByteOrder byteOrder;
|
||||
byte bitIndex;
|
||||
ushort stringLength;
|
||||
int? arrayCount;
|
||||
var addressText = ReadAddressString(root, out var addressStringInvalid);
|
||||
var hasResolvedKeys = ResolvedKeysAuthoritative(root, addressText);
|
||||
if (!hasResolvedKeys && addressStringInvalid) return false;
|
||||
if (!hasResolvedKeys && addressText is not null)
|
||||
{
|
||||
if (!ModbusAddressParser.TryParse(addressText, family, melsecSubFamily, out var parsed, out _) || parsed is null)
|
||||
return false;
|
||||
region = parsed.Region;
|
||||
address = parsed.Offset;
|
||||
dataType = parsed.DataType;
|
||||
byteOrder = parsed.ByteOrder;
|
||||
bitIndex = parsed.Bit ?? 0;
|
||||
stringLength = parsed.StringLength;
|
||||
arrayCount = parsed.ArrayCount;
|
||||
}
|
||||
else
|
||||
{
|
||||
if (!root.TryGetProperty("region", out _) || !root.TryGetProperty("address", out var addr))
|
||||
return false;
|
||||
if (addr.ValueKind != JsonValueKind.Number
|
||||
|| !addr.TryGetInt32(out address)
|
||||
|| address < 0 || address > ushort.MaxValue)
|
||||
return false;
|
||||
// Strict enum reads: a present-but-invalid (typo'd) enum rejects the tag
|
||||
// (→ BadNodeIdUnknown) instead of silently defaulting to a wrong-width Good.
|
||||
if (!TagConfigJson.TryReadEnumStrict(root, "region", ModbusRegion.HoldingRegisters, out region)) return false;
|
||||
// An absent dataType defaults per region: a bit region (Coils / DiscreteInputs) only carries
|
||||
// Bool, so defaulting it to Int16 would reject every bit tag authored without the key.
|
||||
if (!TagConfigJson.TryReadEnumStrict(root, "dataType", DefaultDataType(region), out dataType)) return false;
|
||||
if (!TagConfigJson.TryReadEnumStrict(root, "byteOrder", ModbusByteOrder.BigEndian, out byteOrder)) return false;
|
||||
bitIndex = (byte)ReadInt(root, "bitIndex");
|
||||
stringLength = (ushort)ReadInt(root, "stringLength");
|
||||
// isArray / arrayLength — optional keys authored by the typed Modbus tag editor.
|
||||
// Canonical rule: a tag is an array iff isArray:true AND arrayLength >= 1.
|
||||
// isArray:false (with any arrayLength) is scalar — the foundation materialises a
|
||||
// scalar OPC UA node, so returning an array value would cause a shape mismatch.
|
||||
// A 1-element array (isArray:true, arrayLength:1) IS valid and reads as short[1].
|
||||
var isArray = ReadBool(root, "isArray");
|
||||
var arrayLength = ReadInt(root, "arrayLength");
|
||||
arrayCount = (isArray && arrayLength >= 1) ? arrayLength : null;
|
||||
}
|
||||
// Region/type compatibility. A bit region (Coils / DiscreteInputs) carries only Bool: a non-Bool
|
||||
// dataType there is COERCED to Bool, never refused — the pre-#741 editor defaulted dataType to
|
||||
// Int16 and persisted it for coil tags, and the old driver ignored dataType for bit regions, so
|
||||
// those tags read and wrote correctly and must keep doing so. A register region with Bool is
|
||||
// refused: that pair never read (it threw out of the planner and killed every tag's poll).
|
||||
if (region is ModbusRegion.Coils or ModbusRegion.DiscreteInputs) dataType = ModbusDataType.Bool;
|
||||
// The address — region, offset, type, order, bit, length, count — comes from the ONE authority
|
||||
// (ModbusTagAddressResolution: keys when both are present, else the addressString with this family).
|
||||
var resolution = ModbusTagAddressResolution.Resolve(root, family, melsecSubFamily);
|
||||
if (resolution.Address is not { } a) return false;
|
||||
var region = a.Region;
|
||||
var address = a.Address;
|
||||
var dataType = a.DataType;
|
||||
var byteOrder = a.ByteOrder;
|
||||
var bitIndex = a.BitIndex;
|
||||
var stringLength = a.StringLength;
|
||||
var arrayCount = a.ArrayCount;
|
||||
// Region/type compatibility. A bit region's dataType was already COERCED to Bool by the resolver
|
||||
// (the pre-#741 editor persisted Int16 for coil tags and the old driver ignored it). A register
|
||||
// region with Bool is refused: that pair never read (it threw out of the planner).
|
||||
if (!IsRegionTypeCompatible(region, dataType)) return false;
|
||||
if (!TagConfigJson.TryReadEnumStrict(root, "stringByteOrder", ModbusStringByteOrder.HighByteFirst, out var stringByteOrder)) return false;
|
||||
// Guard: String tags require StringLength >= 1. RegisterCount = (StringLength+1)/2,
|
||||
@@ -136,7 +95,7 @@ public static class ModbusTagDefinitionFactory
|
||||
var deadband = ReadOptionalDouble(root, "deadband");
|
||||
var coalesceProhibited = ReadBool(root, "coalesceProhibited");
|
||||
def = new ModbusTagDefinition(
|
||||
Name: rawPath, Region: region, Address: (ushort)address, DataType: dataType,
|
||||
Name: rawPath, Region: region, Address: address, DataType: dataType,
|
||||
Writable: writable, ByteOrder: byteOrder, BitIndex: bitIndex, StringLength: stringLength,
|
||||
StringByteOrder: stringByteOrder, WriteIdempotent: false,
|
||||
ArrayCount: arrayCount, Deadband: deadband, UnitId: unitId,
|
||||
@@ -160,49 +119,17 @@ public static class ModbusTagDefinitionFactory
|
||||
/// <returns><see langword="true"/> when any family's driver would serve the tag.</returns>
|
||||
public static bool FromTagConfigAnyFamily(string tagConfig, string rawPath, out ModbusTagDefinition def)
|
||||
{
|
||||
foreach (var (family, sub) in AllFamilies)
|
||||
foreach (var (family, sub) in ModbusTagAddressResolution.AllFamilies)
|
||||
if (FromTagConfig(tagConfig, rawPath, family, sub, out def)) return true;
|
||||
def = null!;
|
||||
return false;
|
||||
}
|
||||
|
||||
private static readonly (ModbusFamily Family, MelsecFamily Sub)[] AllFamilies =
|
||||
[
|
||||
(ModbusFamily.Generic, MelsecFamily.Q_L_iQR),
|
||||
(ModbusFamily.DL205, MelsecFamily.Q_L_iQR),
|
||||
(ModbusFamily.MELSEC, MelsecFamily.Q_L_iQR),
|
||||
(ModbusFamily.MELSEC, MelsecFamily.F_iQF),
|
||||
];
|
||||
|
||||
/// <summary>
|
||||
/// The key the tag editor writes beside the resolved <c>region</c> / <c>address</c> when it resolved an
|
||||
/// <c>addressString</c> with the driver's family (<c>"addressResolved": true</c>). It records the editor's
|
||||
/// pinned state (display / CSV) — the DRIVER rule is key presence (see
|
||||
/// <see cref="ResolvedKeysAuthoritative"/>), because pre-#741 rows carry keys + string with no marker and
|
||||
/// must keep loading from their keys.
|
||||
/// The key the tag editor writes beside the resolved <c>region</c> / <c>address</c> (<c>"addressResolved": true</c>).
|
||||
/// Display / CSV state only — see <see cref="ModbusTagAddressResolution.AddressResolvedKey"/>.
|
||||
/// </summary>
|
||||
public const string AddressResolvedKey = "addressResolved";
|
||||
|
||||
/// <summary>
|
||||
/// Whether the persisted <c>region</c> / <c>address</c> are the tag's address: whenever BOTH keys are
|
||||
/// present. Every shape that carries them — a classic tag, an editor-pinned tag, and a pre-#741 row that
|
||||
/// carried an <c>addressString</c> beside its keys (the old driver never read the string) — loads from
|
||||
/// the keys, exactly as before the lane. "String-only" means the keys are ABSENT; only that shape is
|
||||
/// resolved through the parser with the driver's family, and the AdminUI model never stamps keys onto
|
||||
/// it, so it cannot collapse onto HR[0].
|
||||
/// </summary>
|
||||
private static bool ResolvedKeysAuthoritative(JsonElement root, string? addressText)
|
||||
=> root.TryGetProperty("region", out _) && root.TryGetProperty("address", out _);
|
||||
|
||||
/// <summary>Reads the optional <c>addressString</c>: null when absent or blank; flags a non-string value.</summary>
|
||||
private static string? ReadAddressString(JsonElement root, out bool invalid)
|
||||
{
|
||||
invalid = false;
|
||||
if (!root.TryGetProperty("addressString", out var e) || e.ValueKind == JsonValueKind.Null) return null;
|
||||
if (e.ValueKind != JsonValueKind.String) { invalid = true; return null; }
|
||||
var text = e.GetString();
|
||||
return string.IsNullOrWhiteSpace(text) ? null : text.Trim();
|
||||
}
|
||||
public const string AddressResolvedKey = ModbusTagAddressResolution.AddressResolvedKey;
|
||||
|
||||
/// <summary>The data type an authored tag gets when its <c>dataType</c> key is absent.</summary>
|
||||
/// <param name="region">The tag's region.</param>
|
||||
@@ -339,23 +266,24 @@ public static class ModbusTagDefinitionFactory
|
||||
/// <param name="warnings">The list to append warnings to.</param>
|
||||
private static void DescribeRejection(JsonElement root, List<string> warnings)
|
||||
{
|
||||
var addressText = ReadAddressString(root, out var addressStringInvalid);
|
||||
var hasResolvedKeys = ResolvedKeysAuthoritative(root, addressText);
|
||||
if (!hasResolvedKeys && addressStringInvalid)
|
||||
// Which part of the blob names the address is the resolver's call, never restated here.
|
||||
switch (ModbusTagAddressResolution.Classify(root))
|
||||
{
|
||||
warnings.Add("Modbus TagConfig 'addressString' is not a string — the tag will not resolve (BadNodeIdUnknown).");
|
||||
return;
|
||||
}
|
||||
if (!hasResolvedKeys && addressText is not null)
|
||||
{
|
||||
// String-only blob: the addressString is what the driver resolves.
|
||||
if (!ModbusAddressParser.TryParse(addressText, out _, out var error))
|
||||
warnings.Add($"Modbus 'addressString' '{addressText}' does not parse under any PLC family ({error}) — "
|
||||
+ "the tag will not resolve (BadNodeIdUnknown).");
|
||||
else
|
||||
warnings.Add($"Modbus 'addressString' '{addressText}' resolves to an unusable tag (check its type against "
|
||||
+ "the region) — the tag will not resolve (BadNodeIdUnknown).");
|
||||
return;
|
||||
case ModbusAddressShape.InvalidAddressString:
|
||||
warnings.Add("Modbus TagConfig 'addressString' is not a string — the tag will not resolve (BadNodeIdUnknown).");
|
||||
return;
|
||||
case ModbusAddressShape.StringOnly:
|
||||
{
|
||||
// String-only blob: the addressString is what the driver resolves (with its own family).
|
||||
var addressText = root.GetProperty(ModbusTagAddressResolution.AddressStringKey).GetString()!.Trim();
|
||||
if (!ModbusAddressParser.TryParse(addressText, out _, out var error))
|
||||
warnings.Add($"Modbus 'addressString' '{addressText}' does not parse under any PLC family ({error}) — "
|
||||
+ "the tag will not resolve (BadNodeIdUnknown).");
|
||||
else
|
||||
warnings.Add($"Modbus 'addressString' '{addressText}' resolves to an unusable tag (check its type against "
|
||||
+ "the region) — the tag will not resolve (BadNodeIdUnknown).");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (!root.TryGetProperty("region", out _))
|
||||
|
||||
Reference in New Issue
Block a user