fix(modbus): review r1 - resolved address keys win only when the editor pinned them

N1: the m2 precedence trusted region/address whenever the keys were present,
but the AdminUI model stamps defaulted HoldingRegisters/0 on every save, so a
string-only tag (CSV import, or any editor toggle) silently became HR[0] -
reads AND writes. The resolved keys are now authoritative only when the editor
marked them ("addressResolved": true, written by Resolve); an unmarked
addressString is always resolved through the parser with the driver's family.
The model no longer stamps address fields for a string-only tag, CSV export
leaves its address cells blank, and the editor hint distinguishes string-only
from pinned tags. Tests: CSV import and editor save of a string-only tag, a
stamped-default blob, and the pinned positive control across families.
n1: docs/v2/modbus-addressing.md updated (FC23 opt-in, maxReadGap semantics,
coalescing scope, addressString/family rules, coil coercion, refusals).
n2: form round-trip tests for UseFC23ForMultiRegisterWrites /
DisableReadCoalescing. n4: the editor keeps unresolved address text when the
parent echoes back its own emit. n3: noted, no change.

Refs #741
Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
This commit is contained in:
Joseph Doherty
2026-10-22 16:06:02 -04:00
parent fd6ecf8293
commit 3531834cce
9 changed files with 219 additions and 36 deletions
@@ -35,10 +35,11 @@ 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
/// present — the editor resolves an <c>addressString</c> with the driver's family and persists the
/// result, so a later Family / MelsecSubFamily change can never silently remap a live tag. A blob that
/// carries ONLY an <c>addressString</c> (CSV / API authoring, the Modicon / mnemonic / family-native
/// Family-aware overload. The persisted <c>region</c> + <c>address</c> keys are AUTHORITATIVE when they
/// were actually authored — a classic tag with no <c>addressString</c>, or one the editor resolved and
/// marked (<see cref="AddressResolvedKey"/>) — so a later Family / MelsecSubFamily change can never
/// silently remap such a tag. A blob whose <c>addressString</c> is unmarked (CSV / API authoring, even if
/// a model round-trip stamped default region/address beside it; 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
@@ -69,7 +70,7 @@ public static class ModbusTagDefinitionFactory
ushort stringLength;
int? arrayCount;
var addressText = ReadAddressString(root, out var addressStringInvalid);
var hasResolvedKeys = root.TryGetProperty("region", out _) && root.TryGetProperty("address", out _);
var hasResolvedKeys = ResolvedKeysAuthoritative(root, addressText);
if (!hasResolvedKeys && addressStringInvalid) return false;
if (!hasResolvedKeys && addressText is not null)
{
@@ -174,6 +175,28 @@ public static class ModbusTagDefinitionFactory
(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>). Its presence — not the
/// mere presence of <c>region</c> / <c>address</c> — is what makes the resolved keys authoritative over
/// the string: the AdminUI model stamps DEFAULTED <c>region</c>/<c>address</c> (HoldingRegisters / 0) on
/// every save, so key presence alone turned a string-only CSV/API tag into HR[0] on its first edit.
/// </summary>
public const string AddressResolvedKey = "addressResolved";
/// <summary>
/// Whether the persisted <c>region</c> / <c>address</c> are the tag's real address: they are present AND
/// either there is no <c>addressString</c> (a classic region + address tag) or the editor marked them as
/// resolved from it (<see cref="AddressResolvedKey"/>). A blob carrying an <c>addressString</c> without
/// the marker resolves the string through the parser, never the (possibly defaulted) keys.
/// </summary>
private static bool ResolvedKeysAuthoritative(JsonElement root, string? addressText)
{
if (!root.TryGetProperty("region", out _) || !root.TryGetProperty("address", out _)) return false;
if (addressText is null) return true;
return root.TryGetProperty(AddressResolvedKey, out var marker) && marker.ValueKind == JsonValueKind.True;
}
/// <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)
{
@@ -320,7 +343,7 @@ public static class ModbusTagDefinitionFactory
private static void DescribeRejection(JsonElement root, List<string> warnings)
{
var addressText = ReadAddressString(root, out var addressStringInvalid);
var hasResolvedKeys = root.TryGetProperty("region", out _) && root.TryGetProperty("address", out _);
var hasResolvedKeys = ResolvedKeysAuthoritative(root, addressText);
if (!hasResolvedKeys && addressStringInvalid)
{
warnings.Add("Modbus TagConfig 'addressString' is not a string — the tag will not resolve (BadNodeIdUnknown).");