diff --git a/docs/Driver.Modbus.Cli.md b/docs/Driver.Modbus.Cli.md index 48d8ee0..3c9321f 100644 --- a/docs/Driver.Modbus.Cli.md +++ b/docs/Driver.Modbus.Cli.md @@ -135,15 +135,21 @@ Quick examples: 40001:F Float32 40001:F:CDAB Float32 word-swapped 40001:STR20 20-char ASCII string -40001:I:5 Int16[5] array (3-field shorthand) +40001:S:5 Int16[5] array (3-field shorthand) 40001:F:CDAB:10 Float32[10] with explicit word-swap (4-field strict) 40001.5 bit 5 of HR[0] -HR1:DI Int32 via mnemonic region prefix +HR1:I Int32 via mnemonic region prefix (matches Wonderware) C100 Coil 100 (mnemonic, 1-based) V2000:F:CDAB DL205 V-memory at PDU 1024 + Float32 + word-swap (Family=DL205) -D100:I MELSEC D-register 100 (Family=MELSEC) +D100:I MELSEC D-register 100, Int32 (Family=MELSEC) ``` +**Type-code reminder** (post-#146): `:I` is **Int32** (matches Wonderware +DASMBTCP + Ignition `HRI`). The explicit Int16 code is `:S`. Bare HR/IR +with no type still defaults to Int16. Pre-#146 codes `:DI` / `:L` / +`:UDI` / `:UL` / `:LI` / `:ULI` / `:LBCD` are removed; configs that use +them get a clear "Unknown type code" diagnostic at parse time. + In `DriverConfig` JSON, set the per-tag `addressString` field instead of the structured `region` + `address` + `dataType` fields. Both styles can coexist within one driver instance. diff --git a/docs/v2/modbus-addressing.md b/docs/v2/modbus-addressing.md index a5a11f8..ec7a457 100644 --- a/docs/v2/modbus-addressing.md +++ b/docs/v2/modbus-addressing.md @@ -41,24 +41,37 @@ mixing with an explicit type or array-count is rejected. ### Type code `:T` -| Code | Type | Registers | -|---|---|---| -| `BOOL` | Boolean | 1 (region must be Coils / DiscreteInputs) | -| `I` | Int16 | 1 | -| `UI` | UInt16 | 1 | -| `DI`, `L` | Int32 | 2 | -| `UDI`, `UL` | UInt32 | 2 | -| `LI` | Int64 | 4 | -| `ULI` | UInt64 | 4 | -| `F` | Float32 | 2 | -| `D` | Float64 | 4 | -| `BCD` | 16-bit BCD | 1 | -| `LBCD` | 32-bit BCD | 2 | -| `STR` | ASCII string, `len` chars (2 chars / register) | `ceil(len/2)` | +Codes verified 2026-04-25 against [Wonderware DASMBTCP user +guide](https://cdn.logic-control.com/media/DASMBTCP.pdf) and the +[Ignition Modbus addressing +manual](https://www.docs.inductiveautomation.com/docs/8.1/ignition-modules/opc-ua/opc-ua-drivers/modbus/modbus-addressing). +The `I` / `UI` / `I_64` / `UI_64` / `BCD_32` shapes match Wonderware's +suffix convention and Ignition's underscore-N prefix variants where +those vendors agree. + +| Code | Type | Registers | Vendor reference | +|---|---|---|---| +| `BOOL` | Boolean | 1 (region must be Coils / DiscreteInputs) | universal | +| `S` | Int16 | 1 | Wonderware DASMBTCP `S` = 16-bit signed | +| `US` | UInt16 | 1 | Ignition `HRUS` = Unsigned Short | +| `I` | Int32 | 2 | Wonderware DASMBTCP `I` = 32-bit signed; Ignition `HRI` | +| `UI` | UInt32 | 2 | Ignition `HRUI` | +| `I_64` | Int64 | 4 | Ignition `HRI_64` | +| `UI_64` | UInt64 | 4 | Ignition `HRUI_64` | +| `F` | Float32 | 2 | Wonderware `F`; Ignition `HRF` | +| `D` | Float64 | 4 | Ignition `HRD` | +| `BCD` | 16-bit BCD | 1 | Ignition `HRBCD` | +| `BCD_32` | 32-bit BCD | 2 | Ignition `HRBCD_32` | +| `STR` | ASCII string, `len` chars (2 chars / register) | `ceil(len/2)` | analogous to Ignition `HRS:` | Default when omitted: - Coils / DiscreteInputs → `BOOL` -- HoldingRegisters / InputRegisters → `I` (Int16) +- HoldingRegisters / InputRegisters → `S` (Int16) — matches Ignition's bare-`HR` default + +**Codes removed in #146** (silent wrong-data risk, never compatible with the +two reference vendors): `:DI`, `:L`, `:UDI`, `:UL`, `:LI`, `:ULI`, `:LBCD`. +Pre-#146 configs that use these get a clear "Unknown type code" diagnostic at +parse time; rewrite to the post-#146 codes per the table above. ### Byte order `:O` diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Addressing/ModbusAddressParser.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Addressing/ModbusAddressParser.cs index dbc26eb..bb98f56 100644 --- a/src/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Addressing/ModbusAddressParser.cs +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Addressing/ModbusAddressParser.cs @@ -15,7 +15,7 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus; /// spreadsheets from any of those tools without per-tag manual translation. /// /// -/// Examples: +/// Examples (post-#146 type codes — verified against Wonderware DASMBTCP + Ignition): /// /// 40001 — HoldingRegisters[0], Int16 (default). /// 400001 — HoldingRegisters[0], Int16 (6-digit form). @@ -23,9 +23,9 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus; /// 40001:F — Float32 starting at HR[0] (consumes HR[0..1]). /// 40001:F:CDAB — same with word-swap byte order. /// 40001:STR20 — 20-char ASCII string. -/// HR1:DI — Int32 at HR[0] using mnemonic region. +/// HR1:I — Int32 at HR[0] using mnemonic region (Wonderware-aligned). /// 40001:F:5 — Float32[5] array (consumes HR[0..9]). -/// 40001:I::10 — Int16[10] using default byte order (empty order field). +/// 40001:S::10 — Int16[10] using default byte order (empty order field). /// C100 — Coils[99] (mnemonic). /// /// @@ -365,25 +365,37 @@ public static class ModbusAddressParser return true; } + // #146 — codes aligned with Wonderware DASMBTCP + Ignition Modbus driver after the + // 2026-04-25 vendor-doc verification: + // - `:I` is Int32 (Wonderware: "letter 'I' follow ... 32-bit signed quantity, two + // consecutive registers"). Ignition's HRI is also Int32. The pre-#146 mapping + // `:I` = Int16 silently produced wrong-typed data + offset-shifted neighbours when + // a tag spreadsheet was pasted from another vendor. + // - `:S` is the explicit Int16 code (Wonderware: "letter 'S' ... 16-bit signed"). + // - `:US` is UInt16 (Ignition: HRUS = "Unsigned Short"). + // - `:UI` is UInt32 (parallel to `:I` shape; matches Ignition HRUI). + // - `:I_64` / `:UI_64` for 64-bit (Ignition HRI_64 / HRUI_64 underscore-N convention). + // - `:BCD_32` for 32-bit BCD (Ignition HRBCD_32). The pre-#146 `:LBCD` is dropped. + // - HR/IR with no explicit type still default to Int16 (matches Ignition `HR`). type = text.ToUpperInvariant() switch { "BOOL" => ModbusDataType.Bool, - "I" => ModbusDataType.Int16, - "UI" => ModbusDataType.UInt16, - "DI" or "L" => ModbusDataType.Int32, - "UDI" or "UL" => ModbusDataType.UInt32, - "LI" => ModbusDataType.Int64, - "ULI" => ModbusDataType.UInt64, + "S" => ModbusDataType.Int16, + "US" => ModbusDataType.UInt16, + "I" => ModbusDataType.Int32, + "UI" => ModbusDataType.UInt32, + "I_64" => ModbusDataType.Int64, + "UI_64" => ModbusDataType.UInt64, "F" => ModbusDataType.Float32, "D" => ModbusDataType.Float64, "BCD" => ModbusDataType.Bcd16, - "LBCD" => ModbusDataType.Bcd32, + "BCD_32" => ModbusDataType.Bcd32, _ => (ModbusDataType)(-1), }; if ((int)type == -1) { - error = $"Unknown type code '{text}'. Valid: BOOL, I, UI, DI, L, UDI, UL, LI, ULI, F, D, BCD, LBCD, STR"; + error = $"Unknown type code '{text}'. Valid: BOOL, S, US, I, UI, I_64, UI_64, F, D, BCD, BCD_32, STR"; return false; } diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.Modbus/ModbusDriverFactoryExtensions.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.Modbus/ModbusDriverFactoryExtensions.cs index 23e6f6e..276ebcf 100644 --- a/src/ZB.MOM.WW.OtOpcUa.Driver.Modbus/ModbusDriverFactoryExtensions.cs +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.Modbus/ModbusDriverFactoryExtensions.cs @@ -208,7 +208,7 @@ public static class ModbusDriverFactoryExtensions /// Address grammar string per ModbusAddressParser — when present, takes /// precedence over the structured Region/Address/DataType/ByteOrder/BitIndex/ /// StringLength/ArrayCount fields. Examples: "40001", "40001:F", - /// "40001:F:CDAB:5", "HR1:DI", "C100". + /// "40001:F:CDAB:5", "HR1:I", "C100". /// public string? AddressString { get; init; } diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Addressing.Tests/ModbusAddressParserTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Addressing.Tests/ModbusAddressParserTests.cs index e4e8773..1a7e7ec 100644 --- a/tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Addressing.Tests/ModbusAddressParserTests.cs +++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Addressing.Tests/ModbusAddressParserTests.cs @@ -74,26 +74,48 @@ public sealed class ModbusAddressParserTests // ----- Type codes ----- + // #146 — codes verified against current Wonderware DASMBTCP + Ignition Modbus docs + // (2026-04-25). `:I` = Int32 (was Int16) per Wonderware; `:S` is the explicit Int16 code. + // `:US` for UInt16 (Ignition HRUS). `:I_64` / `:UI_64` for 64-bit (Ignition HRI_64). + // `:BCD_32` for 32-bit BCD (Ignition HRBCD_32). The pre-#146 `:DI`/`:L`/`:UDI`/`:UL`/ + // `:LI`/`:ULI`/`:LBCD` aliases are removed — they conflict with the Wonderware mapping + // and have no clear vendor precedent. [Theory] - [InlineData("40001:BOOL", ModbusDataType.Bool)] - [InlineData("40001:I", ModbusDataType.Int16)] - [InlineData("40001:UI", ModbusDataType.UInt16)] - [InlineData("40001:DI", ModbusDataType.Int32)] - [InlineData("40001:L", ModbusDataType.Int32)] - [InlineData("40001:UDI", ModbusDataType.UInt32)] - [InlineData("40001:UL", ModbusDataType.UInt32)] - [InlineData("40001:LI", ModbusDataType.Int64)] - [InlineData("40001:ULI", ModbusDataType.UInt64)] - [InlineData("40001:F", ModbusDataType.Float32)] - [InlineData("40001:D", ModbusDataType.Float64)] - [InlineData("40001:BCD", ModbusDataType.Bcd16)] - [InlineData("40001:LBCD", ModbusDataType.Bcd32)] - [InlineData("40001:f", ModbusDataType.Float32)] // lowercase + [InlineData("40001:BOOL", ModbusDataType.Bool)] + [InlineData("40001:S", ModbusDataType.Int16)] + [InlineData("40001:US", ModbusDataType.UInt16)] + [InlineData("40001:I", ModbusDataType.Int32)] + [InlineData("40001:UI", ModbusDataType.UInt32)] + [InlineData("40001:I_64", ModbusDataType.Int64)] + [InlineData("40001:UI_64", ModbusDataType.UInt64)] + [InlineData("40001:F", ModbusDataType.Float32)] + [InlineData("40001:D", ModbusDataType.Float64)] + [InlineData("40001:BCD", ModbusDataType.Bcd16)] + [InlineData("40001:BCD_32", ModbusDataType.Bcd32)] + [InlineData("40001:f", ModbusDataType.Float32)] // lowercase + [InlineData("40001:i_64", ModbusDataType.Int64)] // lowercase + underscore form public void Type_Codes_Parse(string addr, ModbusDataType expected) { ModbusAddressParser.Parse(addr).DataType.ShouldBe(expected); } + [Theory] + [InlineData("40001:DI")] // pre-#146 alias removed + [InlineData("40001:L")] + [InlineData("40001:UDI")] + [InlineData("40001:UL")] + [InlineData("40001:LI")] + [InlineData("40001:ULI")] + [InlineData("40001:LBCD")] + public void Removed_Aliases_Are_Rejected(string addr) + { + // Defensive — these used to be accepted; now they fail with a clear "Unknown type code" + // diagnostic so users with legacy spreadsheets get a fast surface-level error rather + // than silent wrong-typed reads. + Should.Throw(() => ModbusAddressParser.Parse(addr)) + .Message.ShouldContain("Unknown type code"); + } + [Theory] [InlineData("40001:STR1", 1)] [InlineData("40001:STR20", 20)] @@ -123,7 +145,7 @@ public sealed class ModbusAddressParserTests public void Unknown_Type_Code_Rejected_With_Catalog() { Should.Throw(() => ModbusAddressParser.Parse("40001:WIDGET")) - .Message.ShouldContain("Valid: BOOL, I,"); + .Message.ShouldContain("Valid: BOOL, S, US, I,"); } // ----- Region-type compatibility ----- @@ -165,10 +187,13 @@ public sealed class ModbusAddressParserTests [Fact] public void Empty_Order_Field_Means_Default() { - // 40001:I::5 → Int16 array, no order override, default (BigEndian). + // 40001:I::5 → Int32 array (5 elements), no order override, default (BigEndian). + // The empty middle field is the strict-mode marker that "third position is array count, + // not byte order" — exercises the 4-field positional parse without a byte-order value. var p = ModbusAddressParser.Parse("40001:I::5"); p.ByteOrder.ShouldBe(ModbusByteOrder.BigEndian); p.ArrayCount.ShouldBe(5); + p.DataType.ShouldBe(ModbusDataType.Int32); } // ----- Array count ----- @@ -177,7 +202,7 @@ public sealed class ModbusAddressParserTests [InlineData("40001:I:ABCD:1", 1)] [InlineData("40001:F:5", 5)] [InlineData("40001:F:CDAB:10", 10)] - [InlineData("40001:DI:100", 100)] + [InlineData("40001:S:100", 100)] // Int16[100] via the post-#146 :S code public void Array_Count_Parses(string addr, int expectedCount) { ModbusAddressParser.Parse(addr).ArrayCount.ShouldBe(expectedCount); @@ -220,13 +245,25 @@ public sealed class ModbusAddressParserTests [Fact] public void Worked_Example_Int16_Array() { - var p = ModbusAddressParser.Parse("40001:I::10"); + // Post-#146: :S is the explicit Int16 code. (`:I` is now Int32 per Wonderware.) + var p = ModbusAddressParser.Parse("40001:S::10"); p.Region.ShouldBe(ModbusRegion.HoldingRegisters); p.DataType.ShouldBe(ModbusDataType.Int16); p.ArrayCount.ShouldBe(10); p.ByteOrder.ShouldBe(ModbusByteOrder.BigEndian); } + [Fact] + public void Worked_Example_Int32_Array_Via_I_Code() + { + // Companion to the above — `:I` now means Int32 (matches Wonderware DASMBTCP and + // Ignition HRI). Anyone with `:I` in legacy spreadsheets gets a different type than + // pre-#146; that's the intended semantic alignment. + var p = ModbusAddressParser.Parse("40001:I::10"); + p.DataType.ShouldBe(ModbusDataType.Int32); + p.ArrayCount.ShouldBe(10); + } + [Fact] public void Worked_Example_Float_Array_Word_Swap_6_Digit() {