fix(modbus): review r2 - region+address keys always win; string-only means no keys
N2: pre-#741 rows carry region/address beside an addressString with no marker (the old driver never read the string, the old model preserved it as an unknown key). The r1 rule resolved those from the string - a silent remap or BadNodeIdUnknown on upgrade. The driver now loads from region+address whenever both are present (classic, editor-pinned, pre-lane); only a blob with NO keys is string-only and resolved through the parser. This is safe because the model no longer stamps keys onto a string-only tag. The model distinguishes string-only / legacy-string-beside-keys / pinned, and the editor hint says so. Test matrix over every persisted shape (keys only, pre-lane keys+string, pre-lane stamped HR/0+string, string only, marker+keys, marker no keys) under every family; the pre-lane row also through a model save and a CSV export->import. n5: a typed CSV address cell is an authored address - it drops addressString and the marker (new CsvTypedColumn.ClearsKeys) instead of being discarded. n6: Validate skips the region/dataType/byteOrder enum checks on a string-only tag (the driver ignores them). n7: docs/v2/modbus-addressing.md restates the key-presence rule. Refs #741 Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
This commit is contained in:
+4
@@ -18,6 +18,10 @@
|
||||
{
|
||||
<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.HasLegacyAddressString)
|
||||
{
|
||||
<div class="col-12"><div class="form-text">This tag carries a legacy address string <code>@_m.AddressString</code> 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.</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) 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>
|
||||
|
||||
@@ -28,11 +28,18 @@ namespace ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors;
|
||||
/// and <c>fullName</c>/<c>dataItemId</c>/<c>address</c>) — see <see cref="OwnedJsonKeys"/> for why the CSV
|
||||
/// path has to know the whole set and not just the one it writes.
|
||||
/// </param>
|
||||
/// <param name="ClearsKeys">
|
||||
/// Root keys a NON-BLANK cell in this column invalidates: they are neither restored from the base cell nor
|
||||
/// owned for export. Modbus's address columns use it for <c>addressString</c> / <c>addressResolved</c> — a
|
||||
/// typed address cell is an authored address, exactly like a hand edit in the editor, so a stale address
|
||||
/// string (and its pinned marker) must not survive beside it.
|
||||
/// </param>
|
||||
public sealed record CsvTypedColumn(
|
||||
string Header,
|
||||
string JsonKey,
|
||||
string? ModelProperty,
|
||||
IReadOnlyList<string>? ReadKeys = null)
|
||||
IReadOnlyList<string>? ReadKeys = null,
|
||||
IReadOnlyList<string>? ClearsKeys = null)
|
||||
{
|
||||
/// <summary>
|
||||
/// Every root JSON key this column speaks for, in the <b>runtime's</b> read precedence — i.e.
|
||||
@@ -223,6 +230,7 @@ public sealed class ModelBackedCsvDriverTagMap<TModel> : CsvDriverTagMap
|
||||
col.Set(model, raw.Trim());
|
||||
fired.Add(col.Meta);
|
||||
foreach (var key in col.Meta.OwnedJsonKeys) { overridden.Add(key); }
|
||||
foreach (var key in col.Meta.ClearsKeys ?? []) { overridden.Add(key); }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -466,17 +474,20 @@ public static class CsvColumnMap
|
||||
DriverTypeNames.Modbus, ModbusTagConfigModel.FromJson, m => m.ToJson(),
|
||||
new[]
|
||||
{
|
||||
ModbusCol("Region", "region", nameof(ModbusTagConfigModel.Region),
|
||||
// The six address columns: a non-blank cell is an AUTHORED address (like a hand edit in the
|
||||
// editor), so it drops the address string and its pinned marker (ClearAddressString +
|
||||
// ClearsKeys) — otherwise a string-only row silently discarded the cell.
|
||||
ModbusAddrCol("Region", "region", nameof(ModbusTagConfigModel.Region),
|
||||
m => m.IsStringOnly ? null : m.Region.ToString(), (m, s) => m.Region = CsvValues.ParseEnum<ModbusRegion>("Region", s)),
|
||||
ModbusCol("Address", "address", nameof(ModbusTagConfigModel.Address),
|
||||
ModbusAddrCol("Address", "address", nameof(ModbusTagConfigModel.Address),
|
||||
m => m.IsStringOnly ? null : m.Address.ToString(CultureInfo.InvariantCulture), (m, s) => m.Address = CsvValues.ParseInt("Address", s)),
|
||||
ModbusCol("ModbusDataType", "dataType", nameof(ModbusTagConfigModel.DataType),
|
||||
ModbusAddrCol("ModbusDataType", "dataType", nameof(ModbusTagConfigModel.DataType),
|
||||
m => m.IsStringOnly ? null : m.DataType.ToString(), (m, s) => m.DataType = CsvValues.ParseEnum<ModbusDataType>("ModbusDataType", s)),
|
||||
ModbusCol("ByteOrder", "byteOrder", nameof(ModbusTagConfigModel.ByteOrder),
|
||||
ModbusAddrCol("ByteOrder", "byteOrder", nameof(ModbusTagConfigModel.ByteOrder),
|
||||
m => m.IsStringOnly ? null : m.ByteOrder.ToString(), (m, s) => m.ByteOrder = CsvValues.ParseEnum<ModbusByteOrder>("ByteOrder", s)),
|
||||
ModbusCol("BitIndex", "bitIndex", nameof(ModbusTagConfigModel.BitIndex),
|
||||
ModbusAddrCol("BitIndex", "bitIndex", nameof(ModbusTagConfigModel.BitIndex),
|
||||
m => m.IsStringOnly ? null : m.BitIndex.ToString(CultureInfo.InvariantCulture), (m, s) => m.BitIndex = CsvValues.ParseInt("BitIndex", s)),
|
||||
ModbusCol("StringLength", "stringLength", nameof(ModbusTagConfigModel.StringLength),
|
||||
ModbusAddrCol("StringLength", "stringLength", nameof(ModbusTagConfigModel.StringLength),
|
||||
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)),
|
||||
@@ -620,6 +631,11 @@ public static class CsvColumnMap
|
||||
string h, string k, string p, Func<ModbusTagConfigModel, string?> get, Action<ModbusTagConfigModel, string> set)
|
||||
=> new(new CsvTypedColumn(h, k, p), get, set);
|
||||
|
||||
private static CsvModelColumn<ModbusTagConfigModel> ModbusAddrCol(
|
||||
string h, string k, string p, Func<ModbusTagConfigModel, string?> get, Action<ModbusTagConfigModel, string> set)
|
||||
=> new(new CsvTypedColumn(h, k, p, ClearsKeys: ["addressString", ModbusTagDefinitionFactory.AddressResolvedKey]),
|
||||
get, (m, s) => { m.ClearAddressString(); set(m, s); });
|
||||
|
||||
private static CsvModelColumn<S7TagConfigModel> S7Col(
|
||||
string h, string k, string p, Func<S7TagConfigModel, string?> get, Action<S7TagConfigModel, string> set)
|
||||
=> new(new CsvTypedColumn(h, k, p), get, set);
|
||||
|
||||
@@ -51,7 +51,17 @@ public sealed class ModbusTagConfigModel
|
||||
/// 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;
|
||||
public bool IsStringOnly => AddressString is not null && !AddressResolved && !_loadedWithAddressKeys;
|
||||
|
||||
/// <summary>
|
||||
/// A row that carries an <see cref="AddressString"/> BESIDE region/address keys without the pinned marker
|
||||
/// — a pre-#741 shape (the old driver never read the string). Its keys are the address (the driver loads
|
||||
/// from them, as it always did); the string is informational only.
|
||||
/// </summary>
|
||||
public bool HasLegacyAddressString => AddressString is not null && !AddressResolved && _loadedWithAddressKeys;
|
||||
|
||||
// Whether the blob this model was loaded from carried BOTH region and address keys.
|
||||
private bool _loadedWithAddressKeys;
|
||||
|
||||
/// <summary>
|
||||
/// Resolves an address string through <see cref="ModbusAddressParser"/> with the owning driver's
|
||||
@@ -163,6 +173,7 @@ public sealed class ModbusTagConfigModel
|
||||
AddressString = string.IsNullOrWhiteSpace(TagConfigJson.GetString(o, "addressString"))
|
||||
? null : TagConfigJson.GetString(o, "addressString"),
|
||||
AddressResolved = TagConfigJson.GetBoolNullable(o, ModbusTagDefinitionFactory.AddressResolvedKey) == true,
|
||||
_loadedWithAddressKeys = o.ContainsKey("region") && o.ContainsKey("address"),
|
||||
_bag = o,
|
||||
};
|
||||
}
|
||||
@@ -219,9 +230,11 @@ public sealed class ModbusTagConfigModel
|
||||
/// </remarks>
|
||||
/// <returns>An error message describing the validation failure, or <c>null</c> when the model is valid.</returns>
|
||||
public string? Validate()
|
||||
=> TagConfigJson.DescribeInvalidEnum<ModbusRegion>(_bag, "region")
|
||||
?? TagConfigJson.DescribeInvalidEnum<ModbusDataType>(_bag, "dataType")
|
||||
?? TagConfigJson.DescribeInvalidEnum<ModbusByteOrder>(_bag, "byteOrder")
|
||||
// A string-only tag's region / dataType / byteOrder keys are not read by the driver (the string is the
|
||||
// address), so judging them would make authoring stricter than the runtime (review r2 n6).
|
||||
=> (IsStringOnly ? null : TagConfigJson.DescribeInvalidEnum<ModbusRegion>(_bag, "region"))
|
||||
?? (IsStringOnly ? null : TagConfigJson.DescribeInvalidEnum<ModbusDataType>(_bag, "dataType"))
|
||||
?? (IsStringOnly ? null : TagConfigJson.DescribeInvalidEnum<ModbusByteOrder>(_bag, "byteOrder"))
|
||||
?? TagConfigJson.DescribeInvalidEnum<ModbusStringByteOrder>(_bag, "stringByteOrder")
|
||||
?? TagConfigParseGate.Describe(
|
||||
_source,
|
||||
|
||||
Reference in New Issue
Block a user