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:
@@ -243,19 +243,36 @@ than asking users to rewrite spreadsheets.
|
||||
|
||||
## Address strings, families and region/type rules (#741)
|
||||
|
||||
- **One authority: `ModbusTagAddressResolution` (Driver.Modbus.Contracts).** The
|
||||
driver's tag reader, the deploy gate (`Inspect`), the AdminUI editor model
|
||||
(load / save / `Validate` / the hint text) and the CSV import/export all ask
|
||||
it which part of a blob is the address; none restates the rule. Its
|
||||
agreement is pinned by one table-driven test that runs every persisted shape
|
||||
through every consumer (`ModbusAddressAuthorityAgreementTests`).
|
||||
- **Whenever a tag carries both `region` and `address`, those keys are its
|
||||
address** — a classic tag, an editor-pinned tag, and a pre-#741 row that
|
||||
carried an `addressString` beside its keys (the old driver never read the
|
||||
string) all load from the keys, exactly as before, and a family change never
|
||||
remaps them. Only a **string-only** blob (an `addressString` with NO
|
||||
`region`/`address` — CSV / API authoring) is resolved through
|
||||
`ModbusAddressParser` with the **driver's** `Family` / `MelsecSubFamily`; the
|
||||
AdminUI never stamps default keys onto it, so it cannot collapse onto HR[0],
|
||||
and a family that cannot parse it refuses the tag (BadNodeIdUnknown).
|
||||
`ModbusAddressParser` with the **driver's** `Family` / `MelsecSubFamily`; a
|
||||
family that cannot parse it refuses the tag (BadNodeIdUnknown). It is never
|
||||
served as HR[0].
|
||||
- `"addressResolved": true` is editor display / CSV state only: it never makes
|
||||
keys authoritative, and a marker on a blob WITHOUT keys is ignored (the blob
|
||||
is string-only) and dropped on the next editor save.
|
||||
- The AdminUI never writes default keys it did not resolve: the editor model
|
||||
stamps `region`/`address`/type only when the loaded blob had the keys, or when
|
||||
a field is set (hand edit, CSV address cell, Resolve). A string-only,
|
||||
non-string-`addressString` or address-less blob keeps its keys unwritten, and
|
||||
its CSV export leaves the address cells blank.
|
||||
- The editor's "Resolve with driver family" persists the resolved
|
||||
`region`/`address`/type fields plus `"addressResolved": true` (the pinned
|
||||
state). Editing a resolved field by hand — in the editor or a typed CSV
|
||||
address cell — drops the string and the marker.
|
||||
state). Editing a resolved field by hand in the editor drops the string and
|
||||
the marker. A typed CSV address cell keeps them only while the string still
|
||||
describes the cells under some family (so an unchanged export → import of a
|
||||
pinned row is lossless); a contradicting cell drops them, and the import
|
||||
review grid reports that column as an override.
|
||||
- Coils / DiscreteInputs carry only Bool. A bit-region tag persisted with a
|
||||
register `dataType` (the pre-#741 editor saved `Int16`) is coerced to Bool,
|
||||
never refused. A register region with `Bool` is refused (use
|
||||
|
||||
@@ -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 _))
|
||||
|
||||
+20
-5
@@ -16,15 +16,29 @@
|
||||
}
|
||||
else if (_m.IsStringOnly)
|
||||
{
|
||||
<div class="col-12"><div class="form-text text-warning">String-only tag: the driver resolves <code>@_m.AddressString</code> with its own family (here @_family). The fields below are NOT its address — press Resolve to pin them, or edit a field to replace the string.</div></div>
|
||||
var served = _m.ResolveWith(_family, _melsecSub);
|
||||
<div class="col-12"><div class="form-text text-warning">String-only tag: the driver resolves <code>@_m.AddressString</code> with its own family (here @_family)
|
||||
@if (served.Address is { } a)
|
||||
{
|
||||
<span> → <b>@($"{a.Region}[{a.Address}] {a.DataType}")</b>.</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span> — that family cannot parse it, so the driver <b>refuses</b> the tag.</span>
|
||||
}
|
||||
The fields below are NOT its address — press Resolve to pin them, or edit a field to replace the string.</div></div>
|
||||
}
|
||||
else if (_m.HasLegacyAddressString)
|
||||
{
|
||||
<div class="col-12"><div class="form-text">This tag carries a legacy address string <code>@_m.AddressString</code> that the driver does not use: the fields below are its address. Press Resolve to re-pin it from the string with the driver family.</div></div>
|
||||
}
|
||||
else if (_m.AddressString is not null)
|
||||
else if (_m.IsPinned)
|
||||
{
|
||||
<div class="col-12"><div class="form-text">Resolved from <code>@_m.AddressString</code> (family @_family) and pinned: the fields below are what the driver polls, so a later family change does not remap the tag. Editing a field drops the string.</div></div>
|
||||
<div class="col-12"><div class="form-text">Resolved from <code>@_m.AddressString</code> and pinned: the fields below are what the driver polls, so a later family change does not remap the tag. Editing a field drops the string.</div></div>
|
||||
}
|
||||
else if (!_m.HasKeyAddress)
|
||||
{
|
||||
<div class="col-12"><div class="form-text text-warning">This tag names no address yet: set a field below or resolve an address string. The driver refuses it until then.</div></div>
|
||||
}
|
||||
<div class="col-md-4"><label class="form-label">Region</label>
|
||||
<select class="form-select form-select-sm" value="@_m.Region" @onchange="@(e => UpdateAddress(() => _m.SetRegion(ParseEnum(e.Value, ModbusRegion.HoldingRegisters))))">
|
||||
@@ -96,7 +110,7 @@
|
||||
_addressText = _m.AddressString;
|
||||
_addressError = null;
|
||||
}
|
||||
_family = ModbusTagConfigModel.ReadFamily(GetDriverConfigJson()).Family;
|
||||
(_family, _melsecSub) = ModbusTagConfigModel.ReadFamily(GetDriverConfigJson());
|
||||
}
|
||||
|
||||
private static int ParseInt(object? v, int fallback = 0) => int.TryParse(v?.ToString(), out var i) ? i : fallback;
|
||||
@@ -122,6 +136,7 @@
|
||||
private string? _addressText;
|
||||
private string? _addressError;
|
||||
private ModbusFamily _family = ModbusFamily.Generic;
|
||||
private MelsecFamily _melsecSub = MelsecFamily.Q_L_iQR;
|
||||
|
||||
// A hand edit of any resolved field makes those fields authoritative: drop the address string, which the
|
||||
// driver would otherwise re-resolve over them.
|
||||
@@ -132,7 +147,7 @@
|
||||
private async Task ResolveAddressAsync()
|
||||
{
|
||||
var (family, sub) = ModbusTagConfigModel.ReadFamily(GetDriverConfigJson());
|
||||
_family = family;
|
||||
(_family, _melsecSub) = (family, sub);
|
||||
var error = _m.ResolveAddress(_addressText, family, sub);
|
||||
_addressError = error;
|
||||
if (error is null) await EmitAsync();
|
||||
|
||||
@@ -188,6 +188,24 @@ public static class RawTagCsvMapper
|
||||
var json = map?.ApplyTypedColumns(baseJson.Length == 0 ? "{}" : baseJson, row)
|
||||
?? (baseJson.Length == 0 ? "{}" : baseJson);
|
||||
|
||||
// A typed column that handed base keys to the model (CsvTypedColumn.ClearsKeys — Modbus's address
|
||||
// cells and the addressString they contradict) and thereby DROPPED one is reported like any other
|
||||
// override, so the operator sees the string go rather than losing it silently (review r3 m1).
|
||||
if (map is not null)
|
||||
{
|
||||
var mergedKeys = ReadRootKeys(json, out _);
|
||||
foreach (var col in map.Columns)
|
||||
{
|
||||
if (typedUsed.Contains(col.Header)
|
||||
&& col.ClearsKeys is { Count: > 0 } handed
|
||||
&& handed.Any(k => baseKeys.Contains(k) && !mergedKeys.Contains(k))
|
||||
&& !overrodeFallback.Contains(col.Header))
|
||||
{
|
||||
overrodeFallback.Add(col.Header);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Historize flags.
|
||||
var isHistorized = ParseFlag("IsHistorized", Cell("IsHistorized"));
|
||||
json = TagHistorizeConfig.Set(json, isHistorized, Cell("HistorianTagname"));
|
||||
|
||||
@@ -29,10 +29,11 @@ namespace ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors;
|
||||
/// path has to know the whole set and not just the one it writes.
|
||||
/// </param>
|
||||
/// <param name="ClearsKeys">
|
||||
/// Root keys a NON-BLANK cell in this column invalidates: they are neither restored from the base cell nor
|
||||
/// owned for export. Modbus's address columns use it for <c>addressString</c> / <c>addressResolved</c> — a
|
||||
/// typed address cell is an authored address, exactly like a hand edit in the editor, so a stale address
|
||||
/// string (and its pinned marker) must not survive beside it.
|
||||
/// Root keys a NON-BLANK cell in this column hands to the MODEL: they are not restored from the base cell
|
||||
/// (the model's serialisation decides them), and they are not owned for export. Modbus's address columns use
|
||||
/// it for <c>addressString</c> / <c>addressResolved</c> — a typed address cell is an authored key address, and
|
||||
/// the model keeps the string (and marker) only while it still describes the cells, dropping one a cell
|
||||
/// contradicts. The import review grid reports a column that dropped such a key.
|
||||
/// </param>
|
||||
public sealed record CsvTypedColumn(
|
||||
string Header,
|
||||
@@ -168,22 +169,28 @@ public sealed class ModelBackedCsvDriverTagMap<TModel> : CsvDriverTagMap
|
||||
private readonly Func<string?, TModel> _fromJson;
|
||||
private readonly Func<TModel, string> _toJson;
|
||||
private readonly IReadOnlyList<CsvModelColumn<TModel>> _columns;
|
||||
private readonly Action<TModel, IReadOnlyList<CsvTypedColumn>>? _afterTypedColumns;
|
||||
|
||||
/// <summary>Builds a model-backed map.</summary>
|
||||
/// <param name="driverType">The driver-type string served.</param>
|
||||
/// <param name="fromJson">The model's <c>FromJson</c> loader (preserves unknown keys).</param>
|
||||
/// <param name="toJson">The model's <c>ToJson</c> serialiser.</param>
|
||||
/// <param name="columns">The typed columns (metadata + read/write pairs).</param>
|
||||
/// <param name="afterTypedColumns">Optional model hook run once after a row's non-blank typed cells were applied
|
||||
/// (given the columns that fired), before serialisation. Modbus uses it to reconcile the address string with
|
||||
/// the authored address cells.</param>
|
||||
public ModelBackedCsvDriverTagMap(
|
||||
string driverType,
|
||||
Func<string?, TModel> fromJson,
|
||||
Func<TModel, string> toJson,
|
||||
IReadOnlyList<CsvModelColumn<TModel>> columns)
|
||||
IReadOnlyList<CsvModelColumn<TModel>> columns,
|
||||
Action<TModel, IReadOnlyList<CsvTypedColumn>>? afterTypedColumns = null)
|
||||
{
|
||||
DriverType = driverType;
|
||||
_fromJson = fromJson;
|
||||
_toJson = toJson;
|
||||
_columns = columns;
|
||||
_afterTypedColumns = afterTypedColumns;
|
||||
Columns = columns.Select(c => c.Meta).ToArray();
|
||||
}
|
||||
|
||||
@@ -234,6 +241,8 @@ public sealed class ModelBackedCsvDriverTagMap<TModel> : CsvDriverTagMap
|
||||
}
|
||||
}
|
||||
|
||||
if (fired.Count > 0) { _afterTypedColumns?.Invoke(model, fired); }
|
||||
|
||||
var merged = TagConfigJson.ParseOrNew(_toJson(model));
|
||||
foreach (var (key, node) in baseObj)
|
||||
{
|
||||
@@ -467,30 +476,35 @@ public static class CsvColumnMap
|
||||
{
|
||||
var maps = new CsvDriverTagMap[]
|
||||
{
|
||||
// A string-only Modbus tag (addressString, no addressResolved marker) exports BLANK address cells:
|
||||
// its model fields are load-time defaults, and exporting them as HoldingRegisters / 0 invited a
|
||||
// re-import that looked like an authored HR[0] address. The driver resolves the string.
|
||||
// A Modbus tag whose blob has no region + address keys (string-only, or naming no usable address — the
|
||||
// one authority, ModbusTagAddressResolution, decides) exports BLANK address cells: its model fields are
|
||||
// load-time defaults, and exporting them as HoldingRegisters / 0 invited a re-import that authored an
|
||||
// HR[0] address. The driver resolves the string (or refuses the tag).
|
||||
new ModelBackedCsvDriverTagMap<ModbusTagConfigModel>(
|
||||
DriverTypeNames.Modbus, ModbusTagConfigModel.FromJson, m => m.ToJson(),
|
||||
new[]
|
||||
{
|
||||
// The six address columns: a non-blank cell is an AUTHORED address (like a hand edit in the
|
||||
// editor), so it drops the address string and its pinned marker (ClearAddressString +
|
||||
// ClearsKeys) — otherwise a string-only row silently discarded the cell.
|
||||
// The six address columns: a non-blank cell is an AUTHORED key address. The string and its
|
||||
// marker survive only while they still describe the cells (ReconcileAddressStringWithKeys,
|
||||
// below) — so an unchanged export → import is lossless and a contradicting cell drops them.
|
||||
ModbusAddrCol("Region", "region", nameof(ModbusTagConfigModel.Region),
|
||||
m => m.IsStringOnly ? null : m.Region.ToString(), (m, s) => m.Region = CsvValues.ParseEnum<ModbusRegion>("Region", s)),
|
||||
m => !m.HasKeyAddress ? null : m.Region.ToString(), (m, s) => m.Region = CsvValues.ParseEnum<ModbusRegion>("Region", s)),
|
||||
ModbusAddrCol("Address", "address", nameof(ModbusTagConfigModel.Address),
|
||||
m => m.IsStringOnly ? null : m.Address.ToString(CultureInfo.InvariantCulture), (m, s) => m.Address = CsvValues.ParseInt("Address", s)),
|
||||
m => !m.HasKeyAddress ? null : m.Address.ToString(CultureInfo.InvariantCulture), (m, s) => m.Address = CsvValues.ParseInt("Address", s)),
|
||||
ModbusAddrCol("ModbusDataType", "dataType", nameof(ModbusTagConfigModel.DataType),
|
||||
m => m.IsStringOnly ? null : m.DataType.ToString(), (m, s) => m.DataType = CsvValues.ParseEnum<ModbusDataType>("ModbusDataType", s)),
|
||||
m => !m.HasKeyAddress ? null : m.DataType.ToString(), (m, s) => m.DataType = CsvValues.ParseEnum<ModbusDataType>("ModbusDataType", s)),
|
||||
ModbusAddrCol("ByteOrder", "byteOrder", nameof(ModbusTagConfigModel.ByteOrder),
|
||||
m => m.IsStringOnly ? null : m.ByteOrder.ToString(), (m, s) => m.ByteOrder = CsvValues.ParseEnum<ModbusByteOrder>("ByteOrder", s)),
|
||||
m => !m.HasKeyAddress ? null : m.ByteOrder.ToString(), (m, s) => m.ByteOrder = CsvValues.ParseEnum<ModbusByteOrder>("ByteOrder", s)),
|
||||
ModbusAddrCol("BitIndex", "bitIndex", nameof(ModbusTagConfigModel.BitIndex),
|
||||
m => m.IsStringOnly ? null : m.BitIndex.ToString(CultureInfo.InvariantCulture), (m, s) => m.BitIndex = CsvValues.ParseInt("BitIndex", s)),
|
||||
m => !m.HasKeyAddress ? null : m.BitIndex.ToString(CultureInfo.InvariantCulture), (m, s) => m.BitIndex = CsvValues.ParseInt("BitIndex", s)),
|
||||
ModbusAddrCol("StringLength", "stringLength", nameof(ModbusTagConfigModel.StringLength),
|
||||
m => m.IsStringOnly ? null : m.StringLength.ToString(CultureInfo.InvariantCulture), (m, s) => m.StringLength = CsvValues.ParseInt("StringLength", s)),
|
||||
m => !m.HasKeyAddress ? null : m.StringLength.ToString(CultureInfo.InvariantCulture), (m, s) => m.StringLength = CsvValues.ParseInt("StringLength", s)),
|
||||
ModbusCol("Writable", "writable", nameof(ModbusTagConfigModel.Writable),
|
||||
m => CsvValues.FormatNullableBool(m.Writable), (m, s) => m.Writable = CsvValues.ParseBool("Writable", s)),
|
||||
},
|
||||
(m, fired) =>
|
||||
{
|
||||
if (fired.Any(c => c.ClearsKeys is { Count: > 0 })) { m.ReconcileAddressStringWithKeys(); }
|
||||
}),
|
||||
|
||||
new ModelBackedCsvDriverTagMap<S7TagConfigModel>(
|
||||
@@ -634,7 +648,7 @@ public static class CsvColumnMap
|
||||
private static CsvModelColumn<ModbusTagConfigModel> ModbusAddrCol(
|
||||
string h, string k, string p, Func<ModbusTagConfigModel, string?> get, Action<ModbusTagConfigModel, string> set)
|
||||
=> new(new CsvTypedColumn(h, k, p, ClearsKeys: ["addressString", ModbusTagDefinitionFactory.AddressResolvedKey]),
|
||||
get, (m, s) => { m.ClearAddressString(); set(m, s); });
|
||||
get, set);
|
||||
|
||||
private static CsvModelColumn<S7TagConfigModel> S7Col(
|
||||
string h, string k, string p, Func<S7TagConfigModel, string?> get, Action<S7TagConfigModel, string> set)
|
||||
|
||||
@@ -7,23 +7,32 @@ namespace ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors;
|
||||
/// fields; name/writable live on the Tag entity). Preserves unrecognised JSON keys across a load→save.</summary>
|
||||
public sealed class ModbusTagConfigModel
|
||||
{
|
||||
// Every address field is backed so that SETTING one is an authored address (see _authorsKeys): a hand edit,
|
||||
// a CSV address cell, ResolveAddress or an object initialiser. FromJson assigns the backing fields directly.
|
||||
private ModbusRegion _region = ModbusRegion.HoldingRegisters;
|
||||
private int _address;
|
||||
private ModbusDataType _dataType = ModbusDataType.Int16;
|
||||
private ModbusByteOrder _byteOrder = ModbusByteOrder.BigEndian;
|
||||
private int _bitIndex;
|
||||
private int _stringLength;
|
||||
|
||||
/// <summary>Register region (HoldingRegisters/InputRegisters/Coils/DiscreteInputs).</summary>
|
||||
public ModbusRegion Region { get; set; } = ModbusRegion.HoldingRegisters;
|
||||
public ModbusRegion Region { get => _region; set { _region = value; _authorsKeys = true; } }
|
||||
|
||||
/// <summary>Starting register/coil address.</summary>
|
||||
public int Address { get; set; }
|
||||
public int Address { get => _address; set { _address = value; _authorsKeys = true; } }
|
||||
|
||||
/// <summary>Wire value type the register block decodes to.</summary>
|
||||
public ModbusDataType DataType { get; set; } = ModbusDataType.Int16;
|
||||
public ModbusDataType DataType { get => _dataType; set { _dataType = value; _authorsKeys = true; } }
|
||||
|
||||
/// <summary>Word/byte ordering for multi-register values.</summary>
|
||||
public ModbusByteOrder ByteOrder { get; set; } = ModbusByteOrder.BigEndian;
|
||||
public ModbusByteOrder ByteOrder { get => _byteOrder; set { _byteOrder = value; _authorsKeys = true; } }
|
||||
|
||||
/// <summary>Bit index (0-15) for BitInRegister tags.</summary>
|
||||
public int BitIndex { get; set; }
|
||||
public int BitIndex { get => _bitIndex; set { _bitIndex = value; _authorsKeys = true; } }
|
||||
|
||||
/// <summary>String length in characters for String tags.</summary>
|
||||
public int StringLength { get; set; }
|
||||
public int StringLength { get => _stringLength; set { _stringLength = value; _authorsKeys = true; } }
|
||||
|
||||
/// <summary>Optional writable flag. <c>null</c> ⇒ the key is omitted (driver default true);
|
||||
/// an explicit <c>false</c> makes the equipment tag read-only. Preserved across load→save so an absent
|
||||
@@ -31,37 +40,61 @@ public sealed class ModbusTagConfigModel
|
||||
public bool? Writable { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The address in the parser grammar (<c>40001:F:CDAB</c>, DL205 <c>V2000</c>, MELSEC <c>D100</c>) when
|
||||
/// the tag was authored that way; <c>null</c> for a tag authored by region + numeric address. When
|
||||
/// <see cref="AddressResolved"/> the persisted resolved fields are what the driver reads; otherwise the
|
||||
/// driver resolves the string with its own family (see <see cref="IsStringOnly"/>). Editing any resolved
|
||||
/// field by hand clears it (<see cref="ClearAddressString"/>) so the fields become the authored address.
|
||||
/// The address in the parser grammar (<c>40001:F:CDAB</c>, DL205 <c>V2000</c>, MELSEC <c>D100</c>) when the
|
||||
/// tag carries one; <c>null</c> otherwise. Whether it IS the address is not this model's call — it is
|
||||
/// <see cref="AddressShape"/>, asked of <see cref="ModbusTagAddressResolution"/>: the string is the address
|
||||
/// only when the saved blob has no <c>region</c> + <c>address</c> keys (<see cref="IsStringOnly"/>).
|
||||
/// </summary>
|
||||
public string? AddressString { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// True when the region / offset / type fields were resolved FROM <see cref="AddressString"/> by
|
||||
/// <see cref="ResolveAddress"/> (persisted as <c>"addressResolved": true</c>). Only then are the persisted
|
||||
/// fields authoritative over the string; an unmarked string is resolved by the driver with its own family.
|
||||
/// The editor's <c>"addressResolved": true</c> marker: the keys were resolved FROM <see cref="AddressString"/>
|
||||
/// by <see cref="ResolveAddress"/>. Display / CSV state only — it never makes keys authoritative (keys win
|
||||
/// by presence) and a marker on a blob without keys is ignored (review r3 N3).
|
||||
/// </summary>
|
||||
public bool AddressResolved { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// A string-only tag (CSV / API authored): it carries an <see cref="AddressString"/> the driver resolves,
|
||||
/// and its region / offset / type fields are NOT the tag's address — <see cref="ToJson"/> never stamps
|
||||
/// them, so a round-trip (CSV import, a Writable toggle) cannot turn the tag into HoldingRegisters[0].
|
||||
/// Which part of the blob <see cref="ToJson"/> would save names the address — the one authority's verdict
|
||||
/// (<see cref="ModbusTagAddressResolution.Classify(string)"/>) on this model's own output, so the editor,
|
||||
/// the CSV export and the driver can never disagree about it.
|
||||
/// </summary>
|
||||
public bool IsStringOnly => AddressString is not null && !AddressResolved && !_loadedWithAddressKeys;
|
||||
public ModbusAddressShape AddressShape => ModbusTagAddressResolution.Classify(ToJson());
|
||||
|
||||
/// <summary>
|
||||
/// A row that carries an <see cref="AddressString"/> BESIDE region/address keys without the pinned marker
|
||||
/// — a pre-#741 shape (the old driver never read the string). Its keys are the address (the driver loads
|
||||
/// from them, as it always did); the string is informational only.
|
||||
/// A string-only tag (CSV / API authored, no <c>region</c> + <c>address</c> keys): the driver resolves
|
||||
/// <see cref="AddressString"/> with its own family, and the region / offset / type fields are NOT the address
|
||||
/// — <see cref="ToJson"/> never stamps them, so no round trip can turn the tag into HoldingRegisters[0].
|
||||
/// </summary>
|
||||
public bool HasLegacyAddressString => AddressString is not null && !AddressResolved && _loadedWithAddressKeys;
|
||||
public bool IsStringOnly => AddressShape == ModbusAddressShape.StringOnly;
|
||||
|
||||
// Whether the blob this model was loaded from carried BOTH region and address keys.
|
||||
private bool _loadedWithAddressKeys;
|
||||
/// <summary>
|
||||
/// Whether the fields are the tag's address: the saved blob carries <c>region</c> + <c>address</c>. False for
|
||||
/// a string-only tag and for a blob that names no usable address (the fields are then load-time defaults
|
||||
/// and must not be presented — or exported — as an address).
|
||||
/// </summary>
|
||||
public bool HasKeyAddress => AddressShape == ModbusAddressShape.Keys;
|
||||
|
||||
/// <summary>The string was resolved into the keys and pinned (marker + keys).</summary>
|
||||
public bool IsPinned => AddressString is not null && AddressResolved && HasKeyAddress;
|
||||
|
||||
/// <summary>
|
||||
/// Keys beside an unmarked <see cref="AddressString"/> — a pre-#741 row (the old driver never read the
|
||||
/// string). The keys are the address; the string is informational only.
|
||||
/// </summary>
|
||||
public bool HasLegacyAddressString => AddressString is not null && !AddressResolved && HasKeyAddress;
|
||||
|
||||
/// <summary>What the driver serves this tag as under a family — the one authority, on the blob this model saves.</summary>
|
||||
/// <param name="family">The driver's PLC family.</param>
|
||||
/// <param name="melsecSubFamily">The driver's MELSEC sub-family.</param>
|
||||
/// <returns>The resolution (refused when the family cannot parse a string-only tag).</returns>
|
||||
public ModbusAddressResolution ResolveWith(ModbusFamily family, MelsecFamily melsecSubFamily)
|
||||
=> ModbusTagAddressResolution.Resolve(ToJson(), family, melsecSubFamily);
|
||||
|
||||
// Whether ToJson writes the six address fields: the loaded blob carried the keys (or named no address at all
|
||||
// AND the operator then authored a field), or an address field was set since. Never true for a string-only or
|
||||
// invalid-string blob that nobody edited: the editor must not write default keys it did not resolve.
|
||||
private bool _authorsKeys = true;
|
||||
|
||||
/// <summary>
|
||||
/// Resolves an address string through <see cref="ModbusAddressParser"/> with the owning driver's
|
||||
@@ -79,6 +112,7 @@ public sealed class ModbusTagConfigModel
|
||||
return error ?? "Address did not parse.";
|
||||
AddressString = text!.Trim();
|
||||
AddressResolved = true;
|
||||
_authorsKeys = true;
|
||||
Region = parsed.Region;
|
||||
Address = parsed.Offset;
|
||||
DataType = parsed.DataType;
|
||||
@@ -103,6 +137,20 @@ public sealed class ModbusTagConfigModel
|
||||
{
|
||||
AddressString = null;
|
||||
AddressResolved = false;
|
||||
_authorsKeys = true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// After a CSV import's typed address cells were applied (they are an authored key address): keeps the
|
||||
/// <see cref="AddressString"/> (and its marker) only when it still describes those fields under some family
|
||||
/// — so an unchanged export → import of a pinned row is lossless (review r3 m1) — and drops it when a cell
|
||||
/// contradicts it. Either way the keys are the address; this only decides the metadata.
|
||||
/// </summary>
|
||||
public void ReconcileAddressStringWithKeys()
|
||||
{
|
||||
if (AddressString is null) return;
|
||||
if (!ModbusTagAddressResolution.AddressStringDescribes(AddressString, Region, Address, DataType, ByteOrder, BitIndex, StringLength))
|
||||
ClearAddressString();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -155,25 +203,29 @@ public sealed class ModbusTagConfigModel
|
||||
public static ModbusTagConfigModel FromJson(string? json)
|
||||
{
|
||||
var o = TagConfigJson.ParseOrNew(json);
|
||||
var shape = ModbusTagAddressResolution.Classify(TagConfigJson.Serialize(o));
|
||||
var region = TagConfigJson.GetEnum(o, "region", ModbusRegion.HoldingRegisters);
|
||||
return new ModbusTagConfigModel
|
||||
{
|
||||
_source = json ?? "",
|
||||
Region = TagConfigJson.GetEnum(o, "region", ModbusRegion.HoldingRegisters),
|
||||
Address = TagConfigJson.GetInt(o, "address"),
|
||||
_region = region,
|
||||
_address = TagConfigJson.GetInt(o, "address"),
|
||||
// Absent dataType defaults per region, exactly as the driver's factory does (Bool for a bit region),
|
||||
// and a bit region shows Bool whatever was persisted — the driver coerces a legacy coil/DI
|
||||
// "dataType":"Int16" (what the pre-#741 editor saved) to Bool, so the editor shows what is polled.
|
||||
DataType = TagConfigJson.GetEnum(o, "region", ModbusRegion.HoldingRegisters) is ModbusRegion.Coils or ModbusRegion.DiscreteInputs
|
||||
_dataType = region is ModbusRegion.Coils or ModbusRegion.DiscreteInputs
|
||||
? ModbusDataType.Bool
|
||||
: TagConfigJson.GetEnum(o, "dataType", ModbusDataType.Int16),
|
||||
ByteOrder = TagConfigJson.GetEnum(o, "byteOrder", ModbusByteOrder.BigEndian),
|
||||
BitIndex = TagConfigJson.GetInt(o, "bitIndex"),
|
||||
StringLength = TagConfigJson.GetInt(o, "stringLength"),
|
||||
_byteOrder = TagConfigJson.GetEnum(o, "byteOrder", ModbusByteOrder.BigEndian),
|
||||
_bitIndex = TagConfigJson.GetInt(o, "bitIndex"),
|
||||
_stringLength = TagConfigJson.GetInt(o, "stringLength"),
|
||||
Writable = TagConfigJson.GetBoolNullable(o, "writable"),
|
||||
AddressString = string.IsNullOrWhiteSpace(TagConfigJson.GetString(o, "addressString"))
|
||||
? null : TagConfigJson.GetString(o, "addressString"),
|
||||
AddressResolved = TagConfigJson.GetBoolNullable(o, ModbusTagDefinitionFactory.AddressResolvedKey) == true,
|
||||
_loadedWithAddressKeys = o.ContainsKey("region") && o.ContainsKey("address"),
|
||||
AddressString = string.IsNullOrWhiteSpace(TagConfigJson.GetString(o, ModbusTagAddressResolution.AddressStringKey))
|
||||
? null : TagConfigJson.GetString(o, ModbusTagAddressResolution.AddressStringKey),
|
||||
AddressResolved = TagConfigJson.GetBoolNullable(o, ModbusTagAddressResolution.AddressResolvedKey) == true,
|
||||
// The one authority decides whether the loaded fields are an address: only a blob with the keys is.
|
||||
// A string-only / invalid-string / address-less blob keeps its keys unwritten until a field is authored.
|
||||
_authorsKeys = shape == ModbusAddressShape.Keys,
|
||||
_bag = o,
|
||||
};
|
||||
}
|
||||
@@ -183,21 +235,28 @@ public sealed class ModbusTagConfigModel
|
||||
/// <returns>The serialised TagConfig JSON string.</returns>
|
||||
public string ToJson()
|
||||
{
|
||||
// A string-only tag's address fields are not its address (they are load-time defaults): never stamp
|
||||
// them, or the defaulted region/address would sit beside the string looking authored.
|
||||
if (!IsStringOnly)
|
||||
var o = (JsonObject)_bag.DeepClone();
|
||||
// Only an authored key address is stamped (see _authorsKeys). A string-only tag's fields are load-time
|
||||
// defaults, not its address: stamping them would sit HoldingRegisters/0 beside the string, and by the key
|
||||
// rule that would WIN (review r3 N3).
|
||||
if (_authorsKeys)
|
||||
{
|
||||
TagConfigJson.Set(_bag, "region", Region);
|
||||
TagConfigJson.Set(_bag, "address", Address);
|
||||
TagConfigJson.Set(_bag, "dataType", DataType);
|
||||
TagConfigJson.Set(_bag, "byteOrder", ByteOrder);
|
||||
TagConfigJson.Set(_bag, "bitIndex", BitIndex);
|
||||
TagConfigJson.Set(_bag, "stringLength", StringLength);
|
||||
TagConfigJson.Set(o, "region", Region);
|
||||
TagConfigJson.Set(o, "address", Address);
|
||||
TagConfigJson.Set(o, "dataType", DataType);
|
||||
TagConfigJson.Set(o, "byteOrder", ByteOrder);
|
||||
TagConfigJson.Set(o, "bitIndex", BitIndex);
|
||||
TagConfigJson.Set(o, "stringLength", StringLength);
|
||||
}
|
||||
TagConfigJson.Set(_bag, "writable", Writable);
|
||||
TagConfigJson.Set(_bag, "addressString", AddressString);
|
||||
TagConfigJson.Set(_bag, ModbusTagDefinitionFactory.AddressResolvedKey, AddressString is not null && AddressResolved ? true : null);
|
||||
return TagConfigJson.Serialize(_bag);
|
||||
TagConfigJson.Set(o, "writable", Writable);
|
||||
// A non-string addressString the model could not load is left as persisted (the driver refuses it, and
|
||||
// dropping it would change the deploy gate's explanation) unless the address was authored since.
|
||||
if (AddressString is not null || _authorsKeys)
|
||||
TagConfigJson.Set(o, ModbusTagAddressResolution.AddressStringKey, AddressString);
|
||||
// The marker only means something beside keys; a marker on a keyless blob is a stale lie and is dropped.
|
||||
TagConfigJson.Set(o, ModbusTagAddressResolution.AddressResolvedKey,
|
||||
AddressString is not null && AddressResolved && _authorsKeys ? true : null);
|
||||
return TagConfigJson.Serialize(o);
|
||||
}
|
||||
|
||||
/// <summary>Validation hook; returns an error message or null when the model is valid.</summary>
|
||||
|
||||
@@ -55,7 +55,8 @@ public sealed class ModbusFamilyWiringTests
|
||||
|
||||
/// <summary>
|
||||
/// Fix round 1 (review m2): persisted region/address are authoritative over an addressString, so a
|
||||
/// later Family / MelsecSubFamily change can never silently remap a live tag the editor resolved.
|
||||
/// later Family / MelsecSubFamily change can never silently remap a live tag the editor resolved. (Since
|
||||
/// fix round 3 the keys win by PRESENCE; the marker in this blob is display state only.)
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Persisted_resolved_keys_win_over_the_address_string_so_a_family_change_cannot_remap()
|
||||
@@ -69,11 +70,6 @@ public sealed class ModbusFamilyWiringTests
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fix round 2 (review r1 N1): region/address that were merely STAMPED (an unmarked string beside them —
|
||||
/// what a model round-trip of a string-only tag used to produce) never win: the string is resolved with
|
||||
/// the driver's family, so the tag cannot collapse onto HoldingRegisters[0].
|
||||
/// </summary>
|
||||
/// <summary>
|
||||
/// Fix round 3 (review r2 N2): the persisted-shape matrix. Every shape that carries region + address loads
|
||||
/// from them under every family — including the pre-#741 "keys + addressString, no marker" row, which the
|
||||
|
||||
+64
@@ -0,0 +1,64 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// #741 fix round 4 (review r3 N3): <see cref="ModbusTagAddressResolution"/> is the one authority for which part
|
||||
/// of a Modbus TagConfig names the address. The cross-consumer agreement matrix lives in AdminUI.Tests
|
||||
/// (<c>ModbusAddressAuthorityAgreementTests</c>); these pin the resolver's own classification and refusals.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class ModbusTagAddressResolutionTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData("""{"region":"InputRegisters","address":5}""", ModbusAddressShape.Keys)]
|
||||
[InlineData("""{"region":"HoldingRegisters","address":0,"addressString":"D100"}""", ModbusAddressShape.Keys)]
|
||||
[InlineData("""{"addressString":"D100","addressResolved":true,"region":"HoldingRegisters","address":100}""", ModbusAddressShape.Keys)]
|
||||
[InlineData("""{"addressString":"40011:F"}""", ModbusAddressShape.StringOnly)]
|
||||
[InlineData("""{"addressString":"40011:F","addressResolved":true}""", ModbusAddressShape.StringOnly)]
|
||||
[InlineData("""{"addressString":"40011:F","region":"InputRegisters"}""", ModbusAddressShape.StringOnly)]
|
||||
[InlineData("""{"addressString":"ZZ9"}""", ModbusAddressShape.StringOnly)]
|
||||
[InlineData("""{"addressString":5}""", ModbusAddressShape.InvalidAddressString)]
|
||||
[InlineData("""{"addressString":" "}""", ModbusAddressShape.NoAddress)]
|
||||
[InlineData("""{"addressString":null}""", ModbusAddressShape.NoAddress)]
|
||||
[InlineData("""{}""", ModbusAddressShape.NoAddress)]
|
||||
[InlineData("""[1]""", ModbusAddressShape.NoAddress)]
|
||||
[InlineData("""not json""", ModbusAddressShape.NoAddress)]
|
||||
public void Classify_decides_shape_by_key_presence_never_by_the_marker(string json, ModbusAddressShape shape)
|
||||
=> ModbusTagAddressResolution.Classify(json).ShouldBe(shape);
|
||||
|
||||
[Fact]
|
||||
public void A_string_only_blob_is_refused_with_a_reason_when_the_family_cannot_parse_it()
|
||||
{
|
||||
var r = ModbusTagAddressResolution.Resolve("""{"addressString":"D100"}""", ModbusFamily.Generic, MelsecFamily.Q_L_iQR);
|
||||
r.IsResolved.ShouldBeFalse();
|
||||
r.Shape.ShouldBe(ModbusAddressShape.StringOnly);
|
||||
r.Refusal.ShouldNotBeNull().ShouldContain("D100");
|
||||
|
||||
var melsec = ModbusTagAddressResolution.Resolve("""{"addressString":"D100"}""", ModbusFamily.MELSEC, MelsecFamily.Q_L_iQR);
|
||||
melsec.Address.ShouldNotBeNull().Address.ShouldBe((ushort)100);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Keys_are_read_strictly_and_a_bit_region_is_coerced_to_Bool()
|
||||
{
|
||||
ModbusTagAddressResolution.Resolve("""{"region":"Holding","address":1}""", ModbusFamily.Generic, MelsecFamily.Q_L_iQR)
|
||||
.IsResolved.ShouldBeFalse();
|
||||
ModbusTagAddressResolution.Resolve("""{"region":"HoldingRegisters","address":70000}""", ModbusFamily.Generic, MelsecFamily.Q_L_iQR)
|
||||
.IsResolved.ShouldBeFalse();
|
||||
var coil = ModbusTagAddressResolution.Resolve("""{"region":"Coils","address":3,"dataType":"Int16"}""", ModbusFamily.Generic, MelsecFamily.Q_L_iQR);
|
||||
coil.Address.ShouldNotBeNull().DataType.ShouldBe(ModbusDataType.Bool);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddressStringDescribes_matches_only_the_fields_some_family_parses_it_to()
|
||||
{
|
||||
ModbusTagAddressResolution.AddressStringDescribes("D100", ModbusRegion.HoldingRegisters, 100, ModbusDataType.Int16,
|
||||
ModbusByteOrder.BigEndian, 0, 0).ShouldBeTrue();
|
||||
ModbusTagAddressResolution.AddressStringDescribes("D100", ModbusRegion.HoldingRegisters, 101, ModbusDataType.Int16,
|
||||
ModbusByteOrder.BigEndian, 0, 0).ShouldBeFalse();
|
||||
ModbusTagAddressResolution.AddressStringDescribes("ZZ9", ModbusRegion.HoldingRegisters, 0, ModbusDataType.Int16,
|
||||
ModbusByteOrder.BigEndian, 0, 0).ShouldBeFalse();
|
||||
}
|
||||
}
|
||||
+230
@@ -0,0 +1,230 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.AdminUI.Uns;
|
||||
using ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
||||
using ZB.MOM.WW.OtOpcUa.ControlPlane.AdminOperations;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Modbus;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Uns;
|
||||
|
||||
/// <summary>
|
||||
/// Review r3 N3 (#741, fix round 4): <b>one authority for a Modbus tag's address</b>. Every persisted
|
||||
/// <c>TagConfig</c> shape goes through EVERY consumer that decides, displays or rewrites the address. They
|
||||
/// must all agree with the driver, under every family:
|
||||
/// <list type="bullet">
|
||||
/// <item>the driver's tag reader (<c>ModbusTagDefinitionFactory.FromTagConfig</c> with the driver's
|
||||
/// family, the exact call <c>ModbusDriver</c> makes at initialize);</item>
|
||||
/// <item>the deploy gate (<c>EquipmentTagConfigInspector.Inspect</c>);</item>
|
||||
/// <item>the editor model's load (what its fields claim), save (incl. a Writable toggle) and
|
||||
/// <c>Validate</c>;</item>
|
||||
/// <item>the CSV export → import round trip through the real <c>RawTagCsvMapper</c> (the export residual,
|
||||
/// not the stored blob, is the import base).</item>
|
||||
/// </list>
|
||||
/// The expected verdict per family is pinned in the table, so the authority itself cannot drift either.
|
||||
/// Three rounds converged on the rule one consumer at a time; this matrix is what makes a new divergence
|
||||
/// fail in one place.
|
||||
/// </summary>
|
||||
public sealed class ModbusAddressAuthorityAgreementTests
|
||||
{
|
||||
private static readonly (ModbusFamily Family, MelsecFamily Sub, string Label)[] Families =
|
||||
[
|
||||
(ModbusFamily.Generic, MelsecFamily.Q_L_iQR, "Generic"),
|
||||
(ModbusFamily.DL205, MelsecFamily.Q_L_iQR, "DL205"),
|
||||
(ModbusFamily.MELSEC, MelsecFamily.Q_L_iQR, "MELSEC-Q"),
|
||||
(ModbusFamily.MELSEC, MelsecFamily.F_iQF, "MELSEC-F"),
|
||||
];
|
||||
|
||||
/// <summary>
|
||||
/// Shape name, persisted blob, and the expected driver verdict per family in the order of
|
||||
/// <see cref="Families"/> (<c>IR5</c> = InputRegisters[5], <c>-</c> = refused).
|
||||
/// </summary>
|
||||
public static TheoryData<string, string, string> Shapes => new()
|
||||
{
|
||||
{ "keys only", """{"region":"InputRegisters","address":5}""", "IR5|IR5|IR5|IR5" },
|
||||
{ "pre-lane keys + string", """{"region":"InputRegisters","address":5,"dataType":"Int16","addressString":"40011:F"}""", "IR5|IR5|IR5|IR5" },
|
||||
{ "pre-lane stamped HR/0 + string",
|
||||
"""{"region":"HoldingRegisters","address":0,"dataType":"Int16","byteOrder":"BigEndian","bitIndex":0,"stringLength":0,"addressString":"D100"}""",
|
||||
"HR0|HR0|HR0|HR0" },
|
||||
{ "string only", """{"addressString":"40011:F"}""", "HR10|HR10|HR10|HR10" },
|
||||
{ "string only, family-native", """{"addressString":"D100"}""", "-|-|HR100|HR100" },
|
||||
{ "string + marker + keys", """{"addressString":"D100","addressResolved":true,"region":"HoldingRegisters","address":100,"dataType":"Int16"}""", "HR100|HR100|HR100|HR100" },
|
||||
{ "string + marker, no keys", """{"addressString":"40011:F","addressResolved":true}""", "HR10|HR10|HR10|HR10" },
|
||||
{ "string + marker, no keys, family-native", """{"addressString":"D100","addressResolved":true}""", "-|-|HR100|HR100" },
|
||||
{ "malformed string", """{"addressString":"ZZ9"}""", "-|-|-|-" },
|
||||
{ "malformed string + marker", """{"addressString":"ZZ9","addressResolved":true}""", "-|-|-|-" },
|
||||
{ "malformed string + keys", """{"region":"InputRegisters","address":5,"addressString":"ZZ9"}""", "IR5|IR5|IR5|IR5" },
|
||||
{ "non-string addressString", """{"addressString":5}""", "-|-|-|-" },
|
||||
{ "string + one key", """{"addressString":"40011:F","region":"InputRegisters"}""", "HR10|HR10|HR10|HR10" },
|
||||
{ "no address", """{"writable":true}""", "-|-|-|-" },
|
||||
{ "bit region, legacy Int16", """{"region":"Coils","address":3,"dataType":"Int16"}""", "CO3|CO3|CO3|CO3" },
|
||||
};
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(Shapes))]
|
||||
public void Every_consumer_agrees_with_the_driver_on_every_persisted_shape(string shape, string blob, string expected)
|
||||
{
|
||||
var want = expected.Split('|');
|
||||
var anyServed = want.Any(v => v != "-");
|
||||
|
||||
// 1. The driver (the authority) — pinned, so the authority cannot drift unnoticed either.
|
||||
for (var i = 0; i < Families.Length; i++)
|
||||
{
|
||||
Verdict(blob, i).ShouldBe(want[i], $"{shape}: driver / {Families[i].Label}");
|
||||
}
|
||||
|
||||
// 2. The deploy gate: clean exactly when some family's driver serves the tag.
|
||||
(EquipmentTagConfigInspector.Inspect(DriverTypeNames.Modbus, blob).Count == 0)
|
||||
.ShouldBe(anyServed, $"{shape}: deploy gate accepts ⇔ some driver serves it");
|
||||
|
||||
// 3. Editor load: when the model presents its fields as the address, they ARE the driver's address.
|
||||
var loaded = ModbusTagConfigModel.FromJson(blob);
|
||||
if (!loaded.IsStringOnly)
|
||||
{
|
||||
for (var i = 0; i < Families.Length; i++)
|
||||
{
|
||||
if (want[i] == "-") continue;
|
||||
FieldsVerdict(loaded).ShouldBe(want[i], $"{shape}: editor fields / {Families[i].Label}");
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Editor save, untouched and after a Writable toggle (the round trip that remapped N1 / N3).
|
||||
var untouched = ModbusTagConfigModel.FromJson(blob).ToJson();
|
||||
var toggled = ModbusTagConfigModel.FromJson(blob);
|
||||
toggled.Writable = false;
|
||||
var toggledJson = toggled.ToJson();
|
||||
for (var i = 0; i < Families.Length; i++)
|
||||
{
|
||||
Verdict(untouched, i).ShouldBe(want[i], $"{shape}: editor save / {Families[i].Label}");
|
||||
Verdict(toggledJson, i).ShouldBe(want[i], $"{shape}: editor save after Writable toggle / {Families[i].Label}");
|
||||
}
|
||||
|
||||
// 5. Validate: accepts exactly what some family's driver serves.
|
||||
(ModbusTagConfigModel.FromJson(blob).Validate() is null).ShouldBe(anyServed, $"{shape}: Validate");
|
||||
|
||||
// 6. CSV export → import through the production mapper (the residual is the import base).
|
||||
var csv = RawTagCsvMapper.Export(DriverTypeNames.Modbus,
|
||||
[new RawCsvExportTag(null, "t", "Int16", TagAccessLevel.ReadWrite, false, null, blob)]);
|
||||
var parsed = RawTagCsvMapper.Parse(DriverTypeNames.Modbus, csv);
|
||||
parsed.FatalError.ShouldBeNull();
|
||||
var row = parsed.Rows.ShouldHaveSingleItem();
|
||||
if (!row.Ok)
|
||||
{
|
||||
anyServed.ShouldBeFalse($"{shape}: CSV import refused a tag the driver serves ({row.Error})");
|
||||
}
|
||||
else
|
||||
{
|
||||
for (var i = 0; i < Families.Length; i++)
|
||||
{
|
||||
Verdict(row.ImportRow!.Tag.TagConfig, i).ShouldBe(want[i], $"{shape}: CSV export → import / {Families[i].Label}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static string Verdict(string json, int familyIndex)
|
||||
{
|
||||
var (family, sub, _) = Families[familyIndex];
|
||||
return ModbusTagDefinitionFactory.FromTagConfig(json, "T", family, sub, out var def)
|
||||
? Short(def.Region) + def.Address
|
||||
: "-";
|
||||
}
|
||||
|
||||
private static string FieldsVerdict(ModbusTagConfigModel m) => Short(m.Region) + m.Address;
|
||||
|
||||
private static string Short(ModbusRegion r) => r switch
|
||||
{
|
||||
ModbusRegion.HoldingRegisters => "HR",
|
||||
ModbusRegion.InputRegisters => "IR",
|
||||
ModbusRegion.Coils => "CO",
|
||||
ModbusRegion.DiscreteInputs => "DI",
|
||||
_ => r.ToString(),
|
||||
};
|
||||
|
||||
// ------------------------------------------------------------------ CSV metadata (review r3 m1 / m3)
|
||||
|
||||
private static RawCsvReviewRow RoundTrip(string blob, Func<string, string>? editCsv = null)
|
||||
{
|
||||
var csv = RawTagCsvMapper.Export(DriverTypeNames.Modbus,
|
||||
[new RawCsvExportTag(null, "t", "Int16", TagAccessLevel.ReadWrite, false, null, blob)]);
|
||||
if (editCsv is not null) csv = editCsv(csv);
|
||||
return RawTagCsvMapper.Parse(DriverTypeNames.Modbus, csv).Rows.ShouldHaveSingleItem();
|
||||
}
|
||||
|
||||
/// <summary>m1: an UNCHANGED export → import of a pinned row keeps its address string and marker.</summary>
|
||||
[Fact]
|
||||
public void An_unchanged_csv_round_trip_of_a_pinned_row_keeps_its_address_string_and_marker()
|
||||
{
|
||||
const string pinned = """{"addressString":"D100","addressResolved":true,"region":"HoldingRegisters","address":100,"dataType":"Int16","byteOrder":"BigEndian","bitIndex":0,"stringLength":0}""";
|
||||
var row = RoundTrip(pinned);
|
||||
row.Ok.ShouldBeTrue(row.Error);
|
||||
var json = row.ImportRow!.Tag.TagConfig;
|
||||
json.ShouldContain("\"addressString\":\"D100\"");
|
||||
json.ShouldContain("\"addressResolved\":true");
|
||||
row.TypedOverrodeFallback.ShouldBeEmpty("nothing was overridden");
|
||||
ModbusTagConfigModel.FromJson(json).IsPinned.ShouldBeTrue();
|
||||
}
|
||||
|
||||
/// <summary>m1 positive control: an address cell that CONTRADICTS the string drops it, and the grid says so.</summary>
|
||||
[Fact]
|
||||
public void A_csv_address_cell_that_contradicts_the_string_drops_it_and_the_review_grid_reports_it()
|
||||
{
|
||||
const string pinned = """{"addressString":"D100","addressResolved":true,"region":"HoldingRegisters","address":100,"dataType":"Int16","byteOrder":"BigEndian","bitIndex":0,"stringLength":0}""";
|
||||
// Edit the exported Address cell 100 → 101.
|
||||
var row = RoundTrip(pinned, csv => csv.Replace(",HoldingRegisters,100,", ",HoldingRegisters,101,", StringComparison.Ordinal));
|
||||
row.Ok.ShouldBeTrue(row.Error);
|
||||
var json = row.ImportRow!.Tag.TagConfig;
|
||||
json.ShouldNotContain("addressString");
|
||||
json.ShouldNotContain("addressResolved");
|
||||
row.TypedOverrodeFallback.ShouldNotBeEmpty("the dropped string is reported, not silent");
|
||||
ModbusTagDefinitionFactory.FromTagConfig(json, "T", ModbusFamily.MELSEC, MelsecFamily.Q_L_iQR, out var def).ShouldBeTrue();
|
||||
def.Address.ShouldBe((ushort)101);
|
||||
}
|
||||
|
||||
/// <summary>m3: the n5 case on an UNMARKED string-only row — typed address cells are applied and drop the string.</summary>
|
||||
[Fact]
|
||||
public void A_typed_csv_address_cell_on_an_unmarked_string_only_row_is_applied_and_drops_the_string()
|
||||
{
|
||||
var map = CsvColumnMap.Resolve(DriverTypeNames.Modbus).ShouldNotBeNull();
|
||||
var merged = map.ApplyTypedColumns("""{"addressString":"40011:F"}""",
|
||||
new Dictionary<string, string>(StringComparer.Ordinal) { ["Region"] = "InputRegisters", ["Address"] = "7" });
|
||||
|
||||
merged.ShouldNotContain("addressString");
|
||||
foreach (var (family, sub, label) in Families)
|
||||
{
|
||||
ModbusTagDefinitionFactory.FromTagConfig(merged, "T", family, sub, out var def).ShouldBeTrue(label);
|
||||
def.Region.ShouldBe(ModbusRegion.InputRegisters, label);
|
||||
def.Address.ShouldBe((ushort)7, label);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>N3, by name: the marker without keys is string-only for the editor, its save and its CSV export.</summary>
|
||||
[Fact]
|
||||
public void A_marker_without_keys_is_string_only_and_never_saved_as_HR0()
|
||||
{
|
||||
var m = ModbusTagConfigModel.FromJson("""{"addressString":"40011:F","addressResolved":true}""");
|
||||
m.IsStringOnly.ShouldBeTrue();
|
||||
m.IsPinned.ShouldBeFalse("a marker without keys pins nothing");
|
||||
m.Writable = false;
|
||||
var saved = m.ToJson();
|
||||
saved.ShouldNotContain("\"address\":");
|
||||
saved.ShouldNotContain("\"region\":");
|
||||
CsvColumnMap.Resolve(DriverTypeNames.Modbus)!.ReadTypedColumns(saved).ShouldNotContainKey("Address");
|
||||
}
|
||||
|
||||
/// <summary>The editor never writes default keys it did not resolve: a Writable toggle on an address-less blob
|
||||
/// does not author HoldingRegisters[0]; setting a field does.</summary>
|
||||
[Fact]
|
||||
public void An_address_less_blob_gains_keys_only_when_a_field_is_authored()
|
||||
{
|
||||
var m = ModbusTagConfigModel.FromJson("{}");
|
||||
m.Writable = false;
|
||||
m.ToJson().ShouldNotContain("\"region\":");
|
||||
m.HasKeyAddress.ShouldBeFalse();
|
||||
|
||||
m.Address = 0; // an explicit authoring of HR[0] is honoured
|
||||
m.HasKeyAddress.ShouldBeTrue();
|
||||
ModbusTagDefinitionFactory.FromTagConfig(m.ToJson(), "T", out var def).ShouldBeTrue();
|
||||
def.Address.ShouldBe((ushort)0);
|
||||
}
|
||||
}
|
||||
@@ -247,11 +247,18 @@ public sealed class ModbusTagConfigModelTests
|
||||
m.Writable = false;
|
||||
var saved = m.ToJson();
|
||||
|
||||
var map = CsvColumnMap.Resolve(ZB.MOM.WW.OtOpcUa.Core.Abstractions.DriverTypeNames.Modbus).ShouldNotBeNull();
|
||||
var cells = map.ReadTypedColumns(PreLaneRow);
|
||||
cells["Region"].ShouldBe("InputRegisters");
|
||||
cells["Address"].ShouldBe("5");
|
||||
var reimported = map.ApplyTypedColumns(PreLaneRow, cells);
|
||||
// The production export → import path (review r3 m2): the import base is the export RESIDUAL, not the row.
|
||||
var csv = ZB.MOM.WW.OtOpcUa.AdminUI.Uns.RawTagCsvMapper.Export(ZB.MOM.WW.OtOpcUa.Core.Abstractions.DriverTypeNames.Modbus,
|
||||
[new ZB.MOM.WW.OtOpcUa.AdminUI.Uns.RawCsvExportTag(null, "t", "Int16",
|
||||
ZB.MOM.WW.OtOpcUa.Configuration.Enums.TagAccessLevel.ReadWrite, false, null, PreLaneRow)]);
|
||||
csv.ShouldContain(",InputRegisters,5,");
|
||||
var row = ZB.MOM.WW.OtOpcUa.AdminUI.Uns.RawTagCsvMapper.Parse(ZB.MOM.WW.OtOpcUa.Core.Abstractions.DriverTypeNames.Modbus, csv)
|
||||
.Rows.ShouldHaveSingleItem();
|
||||
row.Ok.ShouldBeTrue(row.Error);
|
||||
var reimported = row.ImportRow!.Tag.TagConfig;
|
||||
// The legacy string contradicts the keys under every family, so the import drops it — reported, not silent.
|
||||
reimported.ShouldNotContain("addressString");
|
||||
row.TypedOverrodeFallback.ShouldNotBeEmpty();
|
||||
|
||||
foreach (var json in new[] { saved, reimported })
|
||||
foreach (var family in new[] { ModbusFamily.Generic, ModbusFamily.DL205, ModbusFamily.MELSEC })
|
||||
@@ -262,10 +269,11 @@ public sealed class ModbusTagConfigModelTests
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Review r2 n5: a typed CSV address cell on a string-only row is an authored address — it is applied
|
||||
/// and drops the string, mirroring a hand edit in the editor (it used to be silently discarded).</summary>
|
||||
/// <summary>Review r2 n5: a typed CSV address cell on a keyless row carrying a (stale) marker is an authored
|
||||
/// address — it is applied and drops the string it contradicts. The unmarked string-only case is
|
||||
/// <c>ModbusAddressAuthorityAgreementTests.A_typed_csv_address_cell_on_an_unmarked_string_only_row_…</c> (r3 m3).</summary>
|
||||
[Fact]
|
||||
public void A_typed_csv_address_cell_on_a_string_only_row_is_applied_and_drops_the_string()
|
||||
public void A_typed_csv_address_cell_on_a_marker_bearing_keyless_row_is_applied_and_drops_the_string()
|
||||
{
|
||||
var map = CsvColumnMap.Resolve(ZB.MOM.WW.OtOpcUa.Core.Abstractions.DriverTypeNames.Modbus).ShouldNotBeNull();
|
||||
var merged = map.ApplyTypedColumns("""{"addressString":"40011:F","addressResolved":true}""",
|
||||
|
||||
Reference in New Issue
Block a user