diff --git a/docs/v2/modbus-addressing.md b/docs/v2/modbus-addressing.md index 20950a38..42f3fab8 100644 --- a/docs/v2/modbus-addressing.md +++ b/docs/v2/modbus-addressing.md @@ -149,7 +149,11 @@ Beyond per-tag addressing, `ModbusDriverOptions` exposes (#139–#143): - `UseFC15ForSingleCoilWrites` — force FC15 (write multiple coils qty=1) for single-coil writes. Safety/audit PLCs may require this. - `UseFC16ForSingleRegisterWrites` — same for FC16 vs FC06. -- `DisableFC23` — kill switch for FC23 (currently unused; reserved). +- `UseFC23ForMultiRegisterWrites` (default `false`) — opt-in: a 2–121-register + holding write is sent as one FC23 (write + read-back). A unit answering + exception 01 moves to FC16 until the next re-init. Without the key the + wire stays FC16 (#741). +- `DisableFC23` — hard kill switch: never send FC23, even when opted in. ### Subscribe (#141) - Per-tag `Deadband` — suppress sub-threshold publishes on numeric tags. @@ -165,9 +169,13 @@ Beyond per-tag addressing, `ModbusDriverOptions` exposes (#139–#143): this tag in isolation regardless of `MaxReadGap`). ### Block-read coalescing (#143) -- `MaxReadGap` (default 0 = off) — gap budget the planner is willing to - bridge between adjacent register tags. With `MaxReadGap=10`, three tags - at HR 100/102/110 collapse into one FC03 of quantity 11. +- `MaxReadGap` — gap budget the planner is willing to bridge between + adjacent tags of one (unit, region). With `MaxReadGap=10`, three tags at + HR 100/102/110 collapse into one FC03 of quantity 11. An explicit `0` = + coalescing off (its historical meaning); the default `8` applies only when + the key is absent (#741). Coils / DiscreteInputs (gap in bits), `String` + scalars and same-word `BitInRegister` tags coalesce too. +- `DisableReadCoalescing` — turn coalescing off regardless of the gap. ### Coalescing auto-recovery (#148 / #150 / #151 / #152) - A coalesced read that fails with a Modbus exception (write-only or @@ -231,3 +239,22 @@ the current Kepware "Modbus Ethernet Driver Help" PDF and Ignition's "Modbus Addressing" user-manual page — if a critical tool's mnemonics have shifted, add aliases in `ModbusAddressParser.TryParseType` rather than asking users to rewrite spreadsheets. + + +## Address strings, families and region/type rules (#741) + +- A tag's `addressString` is resolved through `ModbusAddressParser` with the + **driver's** `Family` / `MelsecSubFamily`. When the AdminUI editor resolves + it ("Resolve with driver family") it persists the resolved + `region`/`address`/type fields plus `"addressResolved": true`; those pinned + fields are then authoritative, so a later family change never remaps the + tag. A blob with an **unmarked** `addressString` (CSV / API authoring) is + always resolved from the string — any `region`/`address` beside it is + ignored, so it can never collapse onto HR[0]. Editing a resolved field by + hand drops the string. +- 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 + `BitInRegister`). +- Exception 01/02/03 are device refusals: per-tag Bad, one Warning per tag, + no driver Degraded; reads and writes share one comms-fault health slot. 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 633d6931..32b4adf6 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,10 +35,11 @@ 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 - /// present — the editor resolves an addressString 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 addressString (CSV / API authoring, the Modicon / mnemonic / family-native + /// Family-aware overload. The persisted region + address keys are AUTHORITATIVE when they + /// were actually authored — a classic tag with no addressString, or one the editor resolved and + /// marked () — so a later Family / MelsecSubFamily change can never + /// silently remap such a tag. A blob whose addressString is unmarked (CSV / API authoring, even if + /// a model round-trip stamped default region/address beside it; 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 @@ -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), ]; + /// + /// The key the tag editor writes beside the resolved region / address when it resolved an + /// addressString with the driver's family ("addressResolved": true). Its presence — not the + /// mere presence of region / address — is what makes the resolved keys authoritative over + /// the string: the AdminUI model stamps DEFAULTED region/address (HoldingRegisters / 0) on + /// every save, so key presence alone turned a string-only CSV/API tag into HR[0] on its first edit. + /// + public const string AddressResolvedKey = "addressResolved"; + + /// + /// Whether the persisted region / address are the tag's real address: they are present AND + /// either there is no addressString (a classic region + address tag) or the editor marked them as + /// resolved from it (). A blob carrying an addressString without + /// the marker resolves the string through the parser, never the (possibly defaulted) keys. + /// + 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; + } + /// Reads the optional addressString: null when absent or blank; flags a non-string value. private static string? ReadAddressString(JsonElement root, out bool invalid) { @@ -320,7 +343,7 @@ public static class ModbusTagDefinitionFactory private static void DescribeRejection(JsonElement root, List 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)."); 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 33eae3fe..42fb01eb 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 @@ -14,9 +14,13 @@ {
@_addressError
} + 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.
+ } else if (_m.AddressString is not null) { -
Resolved from @_m.AddressString (family @_family) — the driver re-resolves it with its own family. Editing a field below drops it.
+
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.
}