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:
@@ -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.
|
||||
|
||||
+29
-6
@@ -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).");
|
||||
|
||||
+23
-7
@@ -14,9 +14,13 @@
|
||||
{
|
||||
<div class="col-12"><div class="form-text text-danger">@_addressError</div></div>
|
||||
}
|
||||
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>
|
||||
}
|
||||
else if (_m.AddressString is not null)
|
||||
{
|
||||
<div class="col-12"><div class="form-text">Resolved from <code>@_m.AddressString</code> (family @_family) — the driver re-resolves it with its own family. Editing a field below drops it.</div></div>
|
||||
<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-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))))">
|
||||
@@ -80,10 +84,14 @@
|
||||
if (ConfigJson == _lastConfigJson) { return; }
|
||||
_lastConfigJson = ConfigJson;
|
||||
_m = ModbusTagConfigModel.FromJson(ConfigJson);
|
||||
// Reset on EVERY config change: a reused editor instance switched to another tag must not show the
|
||||
// previous tag's address text.
|
||||
_addressText = _m.AddressString;
|
||||
_addressError = null;
|
||||
// Reset the address box only when a DIFFERENT config arrives (another tag in a reused instance) — not
|
||||
// when the parent echoes back what this editor just emitted, which would wipe text the operator typed
|
||||
// but has not resolved yet.
|
||||
if (ConfigJson != _lastEmitted)
|
||||
{
|
||||
_addressText = _m.AddressString;
|
||||
_addressError = null;
|
||||
}
|
||||
_family = ModbusTagConfigModel.ReadFamily(GetDriverConfigJson()).Family;
|
||||
}
|
||||
|
||||
@@ -93,10 +101,18 @@
|
||||
private static TEnum ParseEnum<TEnum>(object? v, TEnum fallback) where TEnum : struct, Enum
|
||||
=> Enum.TryParse<TEnum>(v?.ToString(), out var r) ? r : fallback;
|
||||
|
||||
private string? _lastEmitted;
|
||||
|
||||
private async Task Update(Action apply)
|
||||
{
|
||||
apply();
|
||||
await ConfigJsonChanged.InvokeAsync(_m.ToJson());
|
||||
await EmitAsync();
|
||||
}
|
||||
|
||||
private async Task EmitAsync()
|
||||
{
|
||||
_lastEmitted = _m.ToJson();
|
||||
await ConfigJsonChanged.InvokeAsync(_lastEmitted);
|
||||
}
|
||||
|
||||
private string? _addressText;
|
||||
@@ -115,7 +131,7 @@
|
||||
_family = family;
|
||||
var error = _m.ResolveAddress(_addressText, family, sub);
|
||||
_addressError = error;
|
||||
if (error is null) await ConfigJsonChanged.InvokeAsync(_m.ToJson());
|
||||
if (error is null) await EmitAsync();
|
||||
}
|
||||
|
||||
// Parse the builder string (e.g. "4x00001-1") back into Region + Address fields.
|
||||
|
||||
@@ -459,22 +459,25 @@ 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.
|
||||
new ModelBackedCsvDriverTagMap<ModbusTagConfigModel>(
|
||||
DriverTypeNames.Modbus, ModbusTagConfigModel.FromJson, m => m.ToJson(),
|
||||
new[]
|
||||
{
|
||||
ModbusCol("Region", "region", nameof(ModbusTagConfigModel.Region),
|
||||
m => m.Region.ToString(), (m, s) => m.Region = CsvValues.ParseEnum<ModbusRegion>("Region", s)),
|
||||
m => m.IsStringOnly ? null : m.Region.ToString(), (m, s) => m.Region = CsvValues.ParseEnum<ModbusRegion>("Region", s)),
|
||||
ModbusCol("Address", "address", nameof(ModbusTagConfigModel.Address),
|
||||
m => m.Address.ToString(CultureInfo.InvariantCulture), (m, s) => m.Address = CsvValues.ParseInt("Address", s)),
|
||||
m => m.IsStringOnly ? null : m.Address.ToString(CultureInfo.InvariantCulture), (m, s) => m.Address = CsvValues.ParseInt("Address", s)),
|
||||
ModbusCol("ModbusDataType", "dataType", nameof(ModbusTagConfigModel.DataType),
|
||||
m => m.DataType.ToString(), (m, s) => m.DataType = CsvValues.ParseEnum<ModbusDataType>("ModbusDataType", s)),
|
||||
m => m.IsStringOnly ? null : m.DataType.ToString(), (m, s) => m.DataType = CsvValues.ParseEnum<ModbusDataType>("ModbusDataType", s)),
|
||||
ModbusCol("ByteOrder", "byteOrder", nameof(ModbusTagConfigModel.ByteOrder),
|
||||
m => m.ByteOrder.ToString(), (m, s) => m.ByteOrder = CsvValues.ParseEnum<ModbusByteOrder>("ByteOrder", s)),
|
||||
m => m.IsStringOnly ? null : m.ByteOrder.ToString(), (m, s) => m.ByteOrder = CsvValues.ParseEnum<ModbusByteOrder>("ByteOrder", s)),
|
||||
ModbusCol("BitIndex", "bitIndex", nameof(ModbusTagConfigModel.BitIndex),
|
||||
m => m.BitIndex.ToString(CultureInfo.InvariantCulture), (m, s) => m.BitIndex = CsvValues.ParseInt("BitIndex", s)),
|
||||
m => m.IsStringOnly ? null : m.BitIndex.ToString(CultureInfo.InvariantCulture), (m, s) => m.BitIndex = CsvValues.ParseInt("BitIndex", s)),
|
||||
ModbusCol("StringLength", "stringLength", nameof(ModbusTagConfigModel.StringLength),
|
||||
m => m.StringLength.ToString(CultureInfo.InvariantCulture), (m, s) => m.StringLength = CsvValues.ParseInt("StringLength", s)),
|
||||
m => m.IsStringOnly ? 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)),
|
||||
}),
|
||||
|
||||
@@ -32,13 +32,27 @@ public sealed class ModbusTagConfigModel
|
||||
|
||||
/// <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. The
|
||||
/// persisted resolved fields are what the driver reads (the string is only resolved by the driver for a
|
||||
/// string-only blob), so editing any resolved field by hand clears it (<see cref="ClearAddressString"/>)
|
||||
/// to keep the two from disagreeing.
|
||||
/// 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.
|
||||
/// </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.
|
||||
/// </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].
|
||||
/// </summary>
|
||||
public bool IsStringOnly => AddressString is not null && !AddressResolved;
|
||||
|
||||
/// <summary>
|
||||
/// Resolves an address string through <see cref="ModbusAddressParser"/> with the owning driver's
|
||||
/// family and copies the result into the region / offset / type / bit / string-length / byte-order /
|
||||
@@ -54,6 +68,7 @@ public sealed class ModbusTagConfigModel
|
||||
if (!ModbusAddressParser.TryParse(text, family, melsecSubFamily, out var parsed, out var error) || parsed is null)
|
||||
return error ?? "Address did not parse.";
|
||||
AddressString = text!.Trim();
|
||||
AddressResolved = true;
|
||||
Region = parsed.Region;
|
||||
Address = parsed.Offset;
|
||||
DataType = parsed.DataType;
|
||||
@@ -74,7 +89,11 @@ public sealed class ModbusTagConfigModel
|
||||
}
|
||||
|
||||
/// <summary>Drops the address string so the hand-edited region / address fields are what the driver reads.</summary>
|
||||
public void ClearAddressString() => AddressString = null;
|
||||
public void ClearAddressString()
|
||||
{
|
||||
AddressString = null;
|
||||
AddressResolved = false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reads the owning driver's <c>family</c> / <c>melsecSubFamily</c> from its DriverConfig JSON (keys
|
||||
@@ -143,6 +162,7 @@ public sealed class ModbusTagConfigModel
|
||||
Writable = TagConfigJson.GetBoolNullable(o, "writable"),
|
||||
AddressString = string.IsNullOrWhiteSpace(TagConfigJson.GetString(o, "addressString"))
|
||||
? null : TagConfigJson.GetString(o, "addressString"),
|
||||
AddressResolved = TagConfigJson.GetBoolNullable(o, ModbusTagDefinitionFactory.AddressResolvedKey) == true,
|
||||
_bag = o,
|
||||
};
|
||||
}
|
||||
@@ -152,14 +172,20 @@ public sealed class ModbusTagConfigModel
|
||||
/// <returns>The serialised TagConfig JSON string.</returns>
|
||||
public string ToJson()
|
||||
{
|
||||
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);
|
||||
// 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)
|
||||
{
|
||||
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(_bag, "writable", Writable);
|
||||
TagConfigJson.Set(_bag, "addressString", AddressString);
|
||||
TagConfigJson.Set(_bag, ModbusTagDefinitionFactory.AddressResolvedKey, AddressString is not null && AddressResolved ? true : null);
|
||||
return TagConfigJson.Serialize(_bag);
|
||||
}
|
||||
|
||||
|
||||
@@ -60,8 +60,8 @@ public sealed class ModbusFamilyWiringTests
|
||||
[Fact]
|
||||
public void Persisted_resolved_keys_win_over_the_address_string_so_a_family_change_cannot_remap()
|
||||
{
|
||||
// The editor resolved D100 under MELSEC and persisted HR 100; the driver is later switched to DL205.
|
||||
const string json = """{"addressString":"D100","region":"HoldingRegisters","address":100,"dataType":"Int16"}""";
|
||||
// The editor resolved D100 under MELSEC, persisted HR 100 and MARKED it; the driver is later switched.
|
||||
const string json = """{"addressString":"D100","addressResolved":true,"region":"HoldingRegisters","address":100,"dataType":"Int16"}""";
|
||||
foreach (var family in new[] { ModbusFamily.MELSEC, ModbusFamily.DL205, ModbusFamily.Generic })
|
||||
{
|
||||
ModbusTagDefinitionFactory.FromTagConfig(json, "T", family, MelsecFamily.Q_L_iQR, out var def).ShouldBeTrue();
|
||||
@@ -69,6 +69,21 @@ 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>
|
||||
[Fact]
|
||||
public void Defaulted_region_and_address_beside_an_unmarked_string_do_not_win()
|
||||
{
|
||||
const string json = """{"addressString":"D100","region":"HoldingRegisters","address":0,"dataType":"Int16"}""";
|
||||
ModbusTagDefinitionFactory.FromTagConfig(json, "T", ModbusFamily.MELSEC, MelsecFamily.Q_L_iQR, out var def).ShouldBeTrue();
|
||||
def.Address.ShouldBe((ushort)100);
|
||||
// Under a family that cannot parse it the tag is refused (BadNodeIdUnknown), never served as HR[0].
|
||||
ModbusTagDefinitionFactory.FromTagConfig(json, "T", ModbusFamily.Generic, MelsecFamily.Q_L_iQR, out _).ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void An_unparseable_address_string_rejects_the_tag_and_the_deploy_gate_says_why()
|
||||
{
|
||||
|
||||
@@ -30,6 +30,8 @@ public sealed class ModbusDriverPageFormSerializationTests
|
||||
UseFC15ForSingleCoilWrites = true,
|
||||
UseFC16ForSingleRegisterWrites = true,
|
||||
DisableFC23 = true,
|
||||
UseFC23ForMultiRegisterWrites = true,
|
||||
DisableReadCoalescing = true,
|
||||
WriteOnChangeOnly = true,
|
||||
AutoReconnect = false,
|
||||
Family = ModbusFamily.DL205,
|
||||
@@ -72,6 +74,8 @@ public sealed class ModbusDriverPageFormSerializationTests
|
||||
back.UseFC15ForSingleCoilWrites.ShouldBeTrue();
|
||||
back.UseFC16ForSingleRegisterWrites.ShouldBeTrue();
|
||||
back.DisableFC23.ShouldBeTrue();
|
||||
back.UseFC23ForMultiRegisterWrites.ShouldBeTrue();
|
||||
back.DisableReadCoalescing.ShouldBeTrue();
|
||||
back.WriteOnChangeOnly.ShouldBeTrue();
|
||||
back.AutoReconnect.ShouldBeFalse();
|
||||
back.Family.ShouldBe(ModbusFamily.DL205);
|
||||
|
||||
@@ -22,6 +22,23 @@ public sealed class ModbusDriverFormModelTests
|
||||
Converters = { new JsonStringEnumConverter() },
|
||||
};
|
||||
|
||||
/// <summary>Review r1 n2: the lane's new form keys survive FromOptions → ToOptions, and their defaults keep
|
||||
/// the historical wire (FC16, coalescing on at gap 8 only when the key is absent).</summary>
|
||||
[Fact]
|
||||
public void Form_round_trips_the_FC23_opt_in_and_coalescing_keys()
|
||||
{
|
||||
var form = new ModbusDriverForm.FormModel { UseFC23ForMultiRegisterWrites = true, DisableFC23 = true, DisableReadCoalescing = true, MaxReadGap = 0 };
|
||||
var back = ModbusDriverForm.FormModel.FromOptions(form.ToOptions());
|
||||
back.UseFC23ForMultiRegisterWrites.ShouldBeTrue();
|
||||
back.DisableFC23.ShouldBeTrue();
|
||||
back.DisableReadCoalescing.ShouldBeTrue();
|
||||
back.MaxReadGap.ShouldBe(0);
|
||||
|
||||
var defaults = new ModbusDriverForm.FormModel().ToOptions();
|
||||
defaults.UseFC23ForMultiRegisterWrites.ShouldBeFalse();
|
||||
defaults.MaxReadGap.ShouldBe((ushort)8);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Round_trip_preserves_channel_fields_and_enum()
|
||||
{
|
||||
|
||||
@@ -182,6 +182,58 @@ public sealed class ModbusTagConfigModelTests
|
||||
g.AddressString.ShouldBeNull();
|
||||
}
|
||||
|
||||
/// <summary>Fix round 2 (review r1 N1): a string-only tag through CSV import must still resolve its string
|
||||
/// with the driver's family — never the HoldingRegisters/0 defaults the model loads it with.</summary>
|
||||
[Fact]
|
||||
public void Csv_import_of_a_string_only_tag_keeps_resolving_the_string()
|
||||
{
|
||||
var map = CsvColumnMap.Resolve(ZB.MOM.WW.OtOpcUa.Core.Abstractions.DriverTypeNames.Modbus).ShouldNotBeNull();
|
||||
var merged = map.ApplyTypedColumns("""{"addressString":"D100"}""",
|
||||
new Dictionary<string, string>(StringComparer.Ordinal) { ["Writable"] = "false" });
|
||||
|
||||
ModbusTagDefinitionFactory.FromTagConfig(merged, "T", ModbusFamily.MELSEC, MelsecFamily.Q_L_iQR, out var melsec).ShouldBeTrue();
|
||||
melsec.Address.ShouldBe((ushort)100, "not HR[0]");
|
||||
melsec.Writable.ShouldBeFalse("the typed column still applied");
|
||||
// Under another family the string is resolved by THAT family (here it does not parse), never HR[0].
|
||||
ModbusTagDefinitionFactory.FromTagConfig(merged, "T", ModbusFamily.Generic, MelsecFamily.Q_L_iQR, out _).ShouldBeFalse();
|
||||
// Export shows blank address cells for it, so an export → re-import cannot author an HR[0] address.
|
||||
map.ReadTypedColumns(merged).ShouldNotContainKey("Region");
|
||||
map.ReadTypedColumns(merged).ShouldNotContainKey("Address");
|
||||
}
|
||||
|
||||
/// <summary>Fix round 2 (review r1 N1): an editor save (e.g. the Writable toggle) of a string-only tag.</summary>
|
||||
[Fact]
|
||||
public void Editor_save_of_a_string_only_tag_keeps_resolving_the_string()
|
||||
{
|
||||
var m = ModbusTagConfigModel.FromJson("""{"addressString":"40011:F"}""");
|
||||
m.IsStringOnly.ShouldBeTrue();
|
||||
m.Writable = false;
|
||||
var json = m.ToJson();
|
||||
|
||||
ModbusTagDefinitionFactory.FromTagConfig(json, "T", out var def).ShouldBeTrue();
|
||||
def.Region.ShouldBe(ModbusRegion.HoldingRegisters);
|
||||
def.Address.ShouldBe((ushort)10);
|
||||
def.DataType.ShouldBe(ModbusDataType.Float32);
|
||||
def.Writable.ShouldBeFalse();
|
||||
json.ShouldNotContain("\"address\":");
|
||||
}
|
||||
|
||||
/// <summary>Positive control for the two above: Resolve pins the fields (marker), and a family change then
|
||||
/// cannot remap the tag.</summary>
|
||||
[Fact]
|
||||
public void A_resolved_tag_is_pinned_against_a_later_family_change()
|
||||
{
|
||||
var m = ModbusTagConfigModel.FromJson("{}");
|
||||
m.ResolveAddress("D100", ModbusFamily.MELSEC, MelsecFamily.Q_L_iQR).ShouldBeNull();
|
||||
var json = m.ToJson();
|
||||
json.ShouldContain("\"addressResolved\":true");
|
||||
foreach (var family in new[] { ModbusFamily.MELSEC, ModbusFamily.DL205, ModbusFamily.Generic })
|
||||
{
|
||||
ModbusTagDefinitionFactory.FromTagConfig(json, "T", family, MelsecFamily.Q_L_iQR, out var def).ShouldBeTrue();
|
||||
def.Address.ShouldBe((ushort)100, $"family {family}");
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_hand_edit_after_resolving_drops_the_address_string()
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user