diff --git a/docs/v2/modbus-addressing.md b/docs/v2/modbus-addressing.md index 602d5170..74fbfe2c 100644 --- a/docs/v2/modbus-addressing.md +++ b/docs/v2/modbus-addressing.md @@ -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 diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Contracts/ModbusDriverOptions.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Contracts/ModbusDriverOptions.cs index fa24f271..331aa28a 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Contracts/ModbusDriverOptions.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Contracts/ModbusDriverOptions.cs @@ -138,8 +138,9 @@ public sealed class ModbusDriverOptions public bool DisableReadCoalescing { get; init; } = false; /// - /// PLC family hint that drives the parser's family-native branch. A tag authored with an - /// addressString 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 + /// addressString with no region/address keys — see ) 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 (Modicon / mnemonic grammar). /// Tags authored by region + numeric address are unaffected. diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Contracts/ModbusTagAddressResolution.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Contracts/ModbusTagAddressResolution.cs new file mode 100644 index 00000000..a18dc4b4 --- /dev/null +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Contracts/ModbusTagAddressResolution.cs @@ -0,0 +1,251 @@ +using System.Text.Json; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; + +namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus; + +/// +/// Which part of a Modbus TagConfig blob names the tag's address — the output of +/// . +/// +public enum ModbusAddressShape +{ + /// + /// Both region and address are present: those keys are the address, whatever + /// addressString / addressResolved sit beside them (classic, editor-pinned, or a pre-#741 row + /// whose string the old driver never read). + /// + Keys, + + /// + /// The keys are not both present and addressString 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. + /// + StringOnly, + + /// The keys are not both present and addressString is present but not a string: refused. + InvalidAddressString, + + /// Neither the keys nor a usable addressString: refused (the tag names no address). + NoAddress, +} + +/// The address part of a Modbus tag, as the driver will poll it. +/// Which part of the blob named it ( or ). +/// The register region. +/// The zero-based offset. +/// The data type (already coerced to Bool for a bit region). +/// The word/byte order. +/// The bit index (BitInRegister). +/// The string length (String). +/// The element count, or null for a scalar. +public sealed record ModbusResolvedAddress( + ModbusAddressShape Shape, + ModbusRegion Region, + ushort Address, + ModbusDataType DataType, + ModbusByteOrder ByteOrder, + byte BitIndex, + ushort StringLength, + int? ArrayCount); + +/// The outcome of . +/// The blob's address shape (always set, also on a refusal). +/// The resolved address, or null when the tag is refused. +/// Why the tag is refused, or null when it resolved. +public readonly record struct ModbusAddressResolution(ModbusAddressShape Shape, ModbusResolvedAddress? Address, string? Refusal) +{ + /// Whether the driver serves the tag at . + public bool IsResolved => Address is not null; +} + +/// +/// The one authority for which address a Modbus TagConfig names (#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. +/// The rule: +/// +/// When region AND address are both present they are the address +/// (). No pre-#741 shape changes the address it resolves to: the old +/// driver read the keys only and ignored any addressString. +/// Otherwise a non-blank string addressString is the address +/// (), parsed by with the +/// DRIVER's family. If that family cannot parse it the tag is REFUSED — never served as HR[0]. +/// Anything else is refused. +/// +/// addressResolved is editor display / CSV state only and never changes the shape. +/// +public static class ModbusTagAddressResolution +{ + /// The TagConfig key carrying the parser-grammar address (40001:F:CDAB, V2000, D100). + public const string AddressStringKey = "addressString"; + + /// + /// The key the editor writes beside the keys it resolved from an addressString + /// ("addressResolved": true). Display / CSV state only: it never decides the address. + /// + public const string AddressResolvedKey = "addressResolved"; + + /// Every (family, MELSEC sub-family) a driver can be configured with — the family-unaware gates' search space. + 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), + ]; + + /// Classifies an object-rooted TagConfig by which part of it names the address. + /// The TagConfig root (a non-object is ). + /// The address shape. + 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; + } + + /// Classifies a TagConfig JSON string (blank / malformed / non-object ⇒ ). + /// The TagConfig JSON. + /// The address shape. + 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; } + } + + /// Resolves a TagConfig JSON string's address under one family. + /// The TagConfig JSON. + /// The driver's PLC family. + /// The driver's MELSEC sub-family. + /// The resolution. + 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)."); + } + } + + /// + /// 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 region / dataType / byteOrder, or an + /// address that is not an integer 0–65535, refuses the tag); a bit region's data type is coerced to + /// Bool (pre-#741 editor blobs persisted Int16 there and the old driver ignored it). + /// + /// The TagConfig root. + /// The driver's PLC family. + /// The driver's MELSEC sub-family. + /// The resolution. + 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)."); } + } + + /// Whether the driver serves the blob's address under at least one family (the family-unaware gates' accept rule). + /// The TagConfig JSON. + /// when some family resolves it. + public static bool ResolvesUnderAnyFamily(string? tagConfig) + => AllFamilies.Any(f => Resolve(tagConfig, f.Family, f.Sub).IsResolved); + + /// + /// Whether 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). + /// + /// The address string. + /// The keys' region. + /// The keys' offset. + /// The keys' data type. + /// The keys' byte order. + /// The keys' bit index. + /// The keys' string length. + /// when some family parses the string to those fields. + 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(root, "region") ?? "invalid 'region'"); + if (!TagConfigJson.TryReadEnumStrict(root, "dataType", ModbusTagDefinitionFactory.DefaultDataType(region), out ModbusDataType dataType)) + return Refused(ModbusAddressShape.Keys, TagConfigJson.DescribeInvalidEnum(root, "dataType") ?? "invalid 'dataType'"); + if (!TagConfigJson.TryReadEnumStrict(root, "byteOrder", ModbusByteOrder.BigEndian, out ModbusByteOrder byteOrder)) + return Refused(ModbusAddressShape.Keys, TagConfigJson.DescribeInvalidEnum(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; +} diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Contracts/ModbusTagDefinitionFactory.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Contracts/ModbusTagDefinitionFactory.cs index ff21ef5e..7ae5af32 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Contracts/ModbusTagDefinitionFactory.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Contracts/ModbusTagDefinitionFactory.cs @@ -35,14 +35,13 @@ public static class ModbusTagDefinitionFactory => FromTagConfig(tagConfig, rawPath, ModbusFamily.Generic, MelsecFamily.Q_L_iQR, out def); /// - /// Family-aware overload. The persisted region + address 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 - /// addressString and NO region/address keys (CSV / API string-only authoring; the Modicon / mnemonic / family-native - /// grammar of — 40001:F:CDAB, DL205 V2000, MELSEC - /// D100) is resolved here with the DRIVER's / - /// . Without an addressString this is exactly the 3-argument - /// overload. + /// Family-aware overload — the call ModbusDriver makes with its own Family / MelsecSubFamily. WHICH + /// address the blob names is decided by (the one authority shared + /// with the deploy gate, the AdminUI editor model and the CSV import/export): region + address + /// when both are present, else a string-only addressString resolved with the DRIVER's + /// / (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). /// /// The authored equipment-tag TagConfig JSON. /// The tag's RawPath — becomes the definition's identity (Name). @@ -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 /// when any family's driver would serve the tag. 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), - ]; - /// - /// The key the tag editor writes beside the resolved region / address when it resolved an - /// addressString with the driver's family ("addressResolved": true). It records the editor's - /// pinned state (display / CSV) — the DRIVER rule is key presence (see - /// ), 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 region / address ("addressResolved": true). + /// Display / CSV state only — see . /// - public const string AddressResolvedKey = "addressResolved"; - - /// - /// Whether the persisted region / address 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 addressString 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]. - /// - private static bool ResolvedKeysAuthoritative(JsonElement root, string? addressText) - => root.TryGetProperty("region", out _) && root.TryGetProperty("address", out _); - - /// Reads the optional addressString: null when absent or blank; flags a non-string value. - 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; /// The data type an authored tag gets when its dataType key is absent. /// The tag's region. @@ -339,23 +266,24 @@ public static class ModbusTagDefinitionFactory /// The list to append warnings to. private static void DescribeRejection(JsonElement root, List 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 _)) diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/TagEditors/ModbusTagConfigEditor.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/TagEditors/ModbusTagConfigEditor.razor index 06b6eb3b..40ac37ea 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/TagEditors/ModbusTagConfigEditor.razor +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/TagEditors/ModbusTagConfigEditor.razor @@ -16,15 +16,29 @@ } else if (_m.IsStringOnly) { -
String-only tag: the driver resolves @_m.AddressString 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.
+ var served = _m.ResolveWith(_family, _melsecSub); +
String-only tag: the driver resolves @_m.AddressString with its own family (here @_family) + @if (served.Address is { } a) + { + → @($"{a.Region}[{a.Address}] {a.DataType}"). + } + else + { + — that family cannot parse it, so the driver refuses the tag. + } + The fields below are NOT its address — press Resolve to pin them, or edit a field to replace the string.
} else if (_m.HasLegacyAddressString) {
This tag carries a legacy address string @_m.AddressString 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.
} - else if (_m.AddressString is not null) + else if (_m.IsPinned) { -
Resolved from @_m.AddressString (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.
+
Resolved from @_m.AddressString 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.
+ } + else if (!_m.HasKeyAddress) + { +
This tag names no address yet: set a field below or resolve an address string. The driver refuses it until then.
}