fix(modbus): review r3 - one address authority (ModbusTagAddressResolution)
N3: a keyless blob carrying the addressResolved marker was served from its string by the driver but saved as HR[0] by the editor model / CSV path. The precedence rule now lives in ONE pure resolver in Driver.Modbus.Contracts (ModbusTagAddressResolution.Classify/Resolve): keys when region+address are both present, else a string-only addressString resolved with the driver's family (refused when it cannot parse), else refused. The marker never decides. Consumers that now ask it instead of restating it: the driver's tag reader (ModbusTagDefinitionFactory.FromTagConfig), the deploy gate (Inspect / DescribeRejection), the editor model (AddressShape / IsStringOnly / HasKeyAddress / ResolveWith, ToJson stamping, Validate), the CSV export cells and the editor hint. The model writes keys only when it loaded them or a field was authored - never defaults it did not resolve (also closes two divergences the new matrix found: malformed-string+marker and non-string addressString were saved as HR[0]). One table-driven test (ModbusAddressAuthorityAgreementTests) runs 15 persisted shapes through driver, deploy gate, model load/save/Validate and the real CSV export->import under all four families; breaking the CSV export or reverting the model to the marker rule fails it. Minors: m1 CSV address cells keep the string+marker while it still describes them (lossless unchanged round trip), drop it when contradicted, and the review grid reports the drop; m2 the pre-lane test uses the real export residual; m3 unmarked string-only + typed-cell case added; m4 stale docs/summary and the unused factory parameter removed. Refs #741 Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
This commit is contained in:
+20
-5
@@ -16,15 +16,29 @@
|
||||
}
|
||||
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>
|
||||
var served = _m.ResolveWith(_family, _melsecSub);
|
||||
<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)
|
||||
@if (served.Address is { } a)
|
||||
{
|
||||
<span> → <b>@($"{a.Region}[{a.Address}] {a.DataType}")</b>.</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span> — that family cannot parse it, so the driver <b>refuses</b> the tag.</span>
|
||||
}
|
||||
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)
|
||||
else if (_m.IsPinned)
|
||||
{
|
||||
<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-12"><div class="form-text">Resolved from <code>@_m.AddressString</code> 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>
|
||||
}
|
||||
else if (!_m.HasKeyAddress)
|
||||
{
|
||||
<div class="col-12"><div class="form-text text-warning">This tag names no address yet: set a field below or resolve an address string. The driver refuses it until then.</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))))">
|
||||
@@ -96,7 +110,7 @@
|
||||
_addressText = _m.AddressString;
|
||||
_addressError = null;
|
||||
}
|
||||
_family = ModbusTagConfigModel.ReadFamily(GetDriverConfigJson()).Family;
|
||||
(_family, _melsecSub) = ModbusTagConfigModel.ReadFamily(GetDriverConfigJson());
|
||||
}
|
||||
|
||||
private static int ParseInt(object? v, int fallback = 0) => int.TryParse(v?.ToString(), out var i) ? i : fallback;
|
||||
@@ -122,6 +136,7 @@
|
||||
private string? _addressText;
|
||||
private string? _addressError;
|
||||
private ModbusFamily _family = ModbusFamily.Generic;
|
||||
private MelsecFamily _melsecSub = MelsecFamily.Q_L_iQR;
|
||||
|
||||
// A hand edit of any resolved field makes those fields authoritative: drop the address string, which the
|
||||
// driver would otherwise re-resolve over them.
|
||||
@@ -132,7 +147,7 @@
|
||||
private async Task ResolveAddressAsync()
|
||||
{
|
||||
var (family, sub) = ModbusTagConfigModel.ReadFamily(GetDriverConfigJson());
|
||||
_family = family;
|
||||
(_family, _melsecSub) = (family, sub);
|
||||
var error = _m.ResolveAddress(_addressText, family, sub);
|
||||
_addressError = error;
|
||||
if (error is null) await EmitAsync();
|
||||
|
||||
@@ -188,6 +188,24 @@ public static class RawTagCsvMapper
|
||||
var json = map?.ApplyTypedColumns(baseJson.Length == 0 ? "{}" : baseJson, row)
|
||||
?? (baseJson.Length == 0 ? "{}" : baseJson);
|
||||
|
||||
// A typed column that handed base keys to the model (CsvTypedColumn.ClearsKeys — Modbus's address
|
||||
// cells and the addressString they contradict) and thereby DROPPED one is reported like any other
|
||||
// override, so the operator sees the string go rather than losing it silently (review r3 m1).
|
||||
if (map is not null)
|
||||
{
|
||||
var mergedKeys = ReadRootKeys(json, out _);
|
||||
foreach (var col in map.Columns)
|
||||
{
|
||||
if (typedUsed.Contains(col.Header)
|
||||
&& col.ClearsKeys is { Count: > 0 } handed
|
||||
&& handed.Any(k => baseKeys.Contains(k) && !mergedKeys.Contains(k))
|
||||
&& !overrodeFallback.Contains(col.Header))
|
||||
{
|
||||
overrodeFallback.Add(col.Header);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Historize flags.
|
||||
var isHistorized = ParseFlag("IsHistorized", Cell("IsHistorized"));
|
||||
json = TagHistorizeConfig.Set(json, isHistorized, Cell("HistorianTagname"));
|
||||
|
||||
@@ -29,10 +29,11 @@ namespace ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors;
|
||||
/// 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.
|
||||
/// Root keys a NON-BLANK cell in this column hands to the MODEL: they are not restored from the base cell
|
||||
/// (the model's serialisation decides them), and they are not owned for export. Modbus's address columns use
|
||||
/// it for <c>addressString</c> / <c>addressResolved</c> — a typed address cell is an authored key address, and
|
||||
/// the model keeps the string (and marker) only while it still describes the cells, dropping one a cell
|
||||
/// contradicts. The import review grid reports a column that dropped such a key.
|
||||
/// </param>
|
||||
public sealed record CsvTypedColumn(
|
||||
string Header,
|
||||
@@ -168,22 +169,28 @@ public sealed class ModelBackedCsvDriverTagMap<TModel> : CsvDriverTagMap
|
||||
private readonly Func<string?, TModel> _fromJson;
|
||||
private readonly Func<TModel, string> _toJson;
|
||||
private readonly IReadOnlyList<CsvModelColumn<TModel>> _columns;
|
||||
private readonly Action<TModel, IReadOnlyList<CsvTypedColumn>>? _afterTypedColumns;
|
||||
|
||||
/// <summary>Builds a model-backed map.</summary>
|
||||
/// <param name="driverType">The driver-type string served.</param>
|
||||
/// <param name="fromJson">The model's <c>FromJson</c> loader (preserves unknown keys).</param>
|
||||
/// <param name="toJson">The model's <c>ToJson</c> serialiser.</param>
|
||||
/// <param name="columns">The typed columns (metadata + read/write pairs).</param>
|
||||
/// <param name="afterTypedColumns">Optional model hook run once after a row's non-blank typed cells were applied
|
||||
/// (given the columns that fired), before serialisation. Modbus uses it to reconcile the address string with
|
||||
/// the authored address cells.</param>
|
||||
public ModelBackedCsvDriverTagMap(
|
||||
string driverType,
|
||||
Func<string?, TModel> fromJson,
|
||||
Func<TModel, string> toJson,
|
||||
IReadOnlyList<CsvModelColumn<TModel>> columns)
|
||||
IReadOnlyList<CsvModelColumn<TModel>> columns,
|
||||
Action<TModel, IReadOnlyList<CsvTypedColumn>>? afterTypedColumns = null)
|
||||
{
|
||||
DriverType = driverType;
|
||||
_fromJson = fromJson;
|
||||
_toJson = toJson;
|
||||
_columns = columns;
|
||||
_afterTypedColumns = afterTypedColumns;
|
||||
Columns = columns.Select(c => c.Meta).ToArray();
|
||||
}
|
||||
|
||||
@@ -234,6 +241,8 @@ public sealed class ModelBackedCsvDriverTagMap<TModel> : CsvDriverTagMap
|
||||
}
|
||||
}
|
||||
|
||||
if (fired.Count > 0) { _afterTypedColumns?.Invoke(model, fired); }
|
||||
|
||||
var merged = TagConfigJson.ParseOrNew(_toJson(model));
|
||||
foreach (var (key, node) in baseObj)
|
||||
{
|
||||
@@ -467,30 +476,35 @@ 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.
|
||||
// A Modbus tag whose blob has no region + address keys (string-only, or naming no usable address — the
|
||||
// one authority, ModbusTagAddressResolution, decides) exports BLANK address cells: its model fields are
|
||||
// load-time defaults, and exporting them as HoldingRegisters / 0 invited a re-import that authored an
|
||||
// HR[0] address. The driver resolves the string (or refuses the tag).
|
||||
new ModelBackedCsvDriverTagMap<ModbusTagConfigModel>(
|
||||
DriverTypeNames.Modbus, ModbusTagConfigModel.FromJson, m => m.ToJson(),
|
||||
new[]
|
||||
{
|
||||
// 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.
|
||||
// The six address columns: a non-blank cell is an AUTHORED key address. The string and its
|
||||
// marker survive only while they still describe the cells (ReconcileAddressStringWithKeys,
|
||||
// below) — so an unchanged export → import is lossless and a contradicting cell drops them.
|
||||
ModbusAddrCol("Region", "region", nameof(ModbusTagConfigModel.Region),
|
||||
m => m.IsStringOnly ? null : m.Region.ToString(), (m, s) => m.Region = CsvValues.ParseEnum<ModbusRegion>("Region", s)),
|
||||
m => !m.HasKeyAddress ? null : m.Region.ToString(), (m, s) => m.Region = CsvValues.ParseEnum<ModbusRegion>("Region", s)),
|
||||
ModbusAddrCol("Address", "address", nameof(ModbusTagConfigModel.Address),
|
||||
m => m.IsStringOnly ? null : m.Address.ToString(CultureInfo.InvariantCulture), (m, s) => m.Address = CsvValues.ParseInt("Address", s)),
|
||||
m => !m.HasKeyAddress ? null : m.Address.ToString(CultureInfo.InvariantCulture), (m, s) => m.Address = CsvValues.ParseInt("Address", s)),
|
||||
ModbusAddrCol("ModbusDataType", "dataType", nameof(ModbusTagConfigModel.DataType),
|
||||
m => m.IsStringOnly ? null : m.DataType.ToString(), (m, s) => m.DataType = CsvValues.ParseEnum<ModbusDataType>("ModbusDataType", s)),
|
||||
m => !m.HasKeyAddress ? null : m.DataType.ToString(), (m, s) => m.DataType = CsvValues.ParseEnum<ModbusDataType>("ModbusDataType", s)),
|
||||
ModbusAddrCol("ByteOrder", "byteOrder", nameof(ModbusTagConfigModel.ByteOrder),
|
||||
m => m.IsStringOnly ? null : m.ByteOrder.ToString(), (m, s) => m.ByteOrder = CsvValues.ParseEnum<ModbusByteOrder>("ByteOrder", s)),
|
||||
m => !m.HasKeyAddress ? null : m.ByteOrder.ToString(), (m, s) => m.ByteOrder = CsvValues.ParseEnum<ModbusByteOrder>("ByteOrder", s)),
|
||||
ModbusAddrCol("BitIndex", "bitIndex", nameof(ModbusTagConfigModel.BitIndex),
|
||||
m => m.IsStringOnly ? null : m.BitIndex.ToString(CultureInfo.InvariantCulture), (m, s) => m.BitIndex = CsvValues.ParseInt("BitIndex", s)),
|
||||
m => !m.HasKeyAddress ? null : m.BitIndex.ToString(CultureInfo.InvariantCulture), (m, s) => m.BitIndex = CsvValues.ParseInt("BitIndex", s)),
|
||||
ModbusAddrCol("StringLength", "stringLength", nameof(ModbusTagConfigModel.StringLength),
|
||||
m => m.IsStringOnly ? null : m.StringLength.ToString(CultureInfo.InvariantCulture), (m, s) => m.StringLength = CsvValues.ParseInt("StringLength", s)),
|
||||
m => !m.HasKeyAddress ? 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)),
|
||||
},
|
||||
(m, fired) =>
|
||||
{
|
||||
if (fired.Any(c => c.ClearsKeys is { Count: > 0 })) { m.ReconcileAddressStringWithKeys(); }
|
||||
}),
|
||||
|
||||
new ModelBackedCsvDriverTagMap<S7TagConfigModel>(
|
||||
@@ -634,7 +648,7 @@ public static class CsvColumnMap
|
||||
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); });
|
||||
get, set);
|
||||
|
||||
private static CsvModelColumn<S7TagConfigModel> S7Col(
|
||||
string h, string k, string p, Func<S7TagConfigModel, string?> get, Action<S7TagConfigModel, string> set)
|
||||
|
||||
@@ -7,23 +7,32 @@ namespace ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors;
|
||||
/// fields; name/writable live on the Tag entity). Preserves unrecognised JSON keys across a load→save.</summary>
|
||||
public sealed class ModbusTagConfigModel
|
||||
{
|
||||
// Every address field is backed so that SETTING one is an authored address (see _authorsKeys): a hand edit,
|
||||
// a CSV address cell, ResolveAddress or an object initialiser. FromJson assigns the backing fields directly.
|
||||
private ModbusRegion _region = ModbusRegion.HoldingRegisters;
|
||||
private int _address;
|
||||
private ModbusDataType _dataType = ModbusDataType.Int16;
|
||||
private ModbusByteOrder _byteOrder = ModbusByteOrder.BigEndian;
|
||||
private int _bitIndex;
|
||||
private int _stringLength;
|
||||
|
||||
/// <summary>Register region (HoldingRegisters/InputRegisters/Coils/DiscreteInputs).</summary>
|
||||
public ModbusRegion Region { get; set; } = ModbusRegion.HoldingRegisters;
|
||||
public ModbusRegion Region { get => _region; set { _region = value; _authorsKeys = true; } }
|
||||
|
||||
/// <summary>Starting register/coil address.</summary>
|
||||
public int Address { get; set; }
|
||||
public int Address { get => _address; set { _address = value; _authorsKeys = true; } }
|
||||
|
||||
/// <summary>Wire value type the register block decodes to.</summary>
|
||||
public ModbusDataType DataType { get; set; } = ModbusDataType.Int16;
|
||||
public ModbusDataType DataType { get => _dataType; set { _dataType = value; _authorsKeys = true; } }
|
||||
|
||||
/// <summary>Word/byte ordering for multi-register values.</summary>
|
||||
public ModbusByteOrder ByteOrder { get; set; } = ModbusByteOrder.BigEndian;
|
||||
public ModbusByteOrder ByteOrder { get => _byteOrder; set { _byteOrder = value; _authorsKeys = true; } }
|
||||
|
||||
/// <summary>Bit index (0-15) for BitInRegister tags.</summary>
|
||||
public int BitIndex { get; set; }
|
||||
public int BitIndex { get => _bitIndex; set { _bitIndex = value; _authorsKeys = true; } }
|
||||
|
||||
/// <summary>String length in characters for String tags.</summary>
|
||||
public int StringLength { get; set; }
|
||||
public int StringLength { get => _stringLength; set { _stringLength = value; _authorsKeys = true; } }
|
||||
|
||||
/// <summary>Optional writable flag. <c>null</c> ⇒ the key is omitted (driver default true);
|
||||
/// an explicit <c>false</c> makes the equipment tag read-only. Preserved across load→save so an absent
|
||||
@@ -31,37 +40,61 @@ public sealed class ModbusTagConfigModel
|
||||
public bool? Writable { get; set; }
|
||||
|
||||
/// <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. 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.
|
||||
/// The address in the parser grammar (<c>40001:F:CDAB</c>, DL205 <c>V2000</c>, MELSEC <c>D100</c>) when the
|
||||
/// tag carries one; <c>null</c> otherwise. Whether it IS the address is not this model's call — it is
|
||||
/// <see cref="AddressShape"/>, asked of <see cref="ModbusTagAddressResolution"/>: the string is the address
|
||||
/// only when the saved blob has no <c>region</c> + <c>address</c> keys (<see cref="IsStringOnly"/>).
|
||||
/// </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.
|
||||
/// The editor's <c>"addressResolved": true</c> marker: the keys were resolved FROM <see cref="AddressString"/>
|
||||
/// by <see cref="ResolveAddress"/>. Display / CSV state only — it never makes keys authoritative (keys win
|
||||
/// by presence) and a marker on a blob without keys is ignored (review r3 N3).
|
||||
/// </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].
|
||||
/// Which part of the blob <see cref="ToJson"/> would save names the address — the one authority's verdict
|
||||
/// (<see cref="ModbusTagAddressResolution.Classify(string)"/>) on this model's own output, so the editor,
|
||||
/// the CSV export and the driver can never disagree about it.
|
||||
/// </summary>
|
||||
public bool IsStringOnly => AddressString is not null && !AddressResolved && !_loadedWithAddressKeys;
|
||||
public ModbusAddressShape AddressShape => ModbusTagAddressResolution.Classify(ToJson());
|
||||
|
||||
/// <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.
|
||||
/// A string-only tag (CSV / API authored, no <c>region</c> + <c>address</c> keys): the driver resolves
|
||||
/// <see cref="AddressString"/> with its own family, and the region / offset / type fields are NOT the address
|
||||
/// — <see cref="ToJson"/> never stamps them, so no round trip can turn the tag into HoldingRegisters[0].
|
||||
/// </summary>
|
||||
public bool HasLegacyAddressString => AddressString is not null && !AddressResolved && _loadedWithAddressKeys;
|
||||
public bool IsStringOnly => AddressShape == ModbusAddressShape.StringOnly;
|
||||
|
||||
// Whether the blob this model was loaded from carried BOTH region and address keys.
|
||||
private bool _loadedWithAddressKeys;
|
||||
/// <summary>
|
||||
/// Whether the fields are the tag's address: the saved blob carries <c>region</c> + <c>address</c>. False for
|
||||
/// a string-only tag and for a blob that names no usable address (the fields are then load-time defaults
|
||||
/// and must not be presented — or exported — as an address).
|
||||
/// </summary>
|
||||
public bool HasKeyAddress => AddressShape == ModbusAddressShape.Keys;
|
||||
|
||||
/// <summary>The string was resolved into the keys and pinned (marker + keys).</summary>
|
||||
public bool IsPinned => AddressString is not null && AddressResolved && HasKeyAddress;
|
||||
|
||||
/// <summary>
|
||||
/// Keys beside an unmarked <see cref="AddressString"/> — a pre-#741 row (the old driver never read the
|
||||
/// string). The keys are the address; the string is informational only.
|
||||
/// </summary>
|
||||
public bool HasLegacyAddressString => AddressString is not null && !AddressResolved && HasKeyAddress;
|
||||
|
||||
/// <summary>What the driver serves this tag as under a family — the one authority, on the blob this model saves.</summary>
|
||||
/// <param name="family">The driver's PLC family.</param>
|
||||
/// <param name="melsecSubFamily">The driver's MELSEC sub-family.</param>
|
||||
/// <returns>The resolution (refused when the family cannot parse a string-only tag).</returns>
|
||||
public ModbusAddressResolution ResolveWith(ModbusFamily family, MelsecFamily melsecSubFamily)
|
||||
=> ModbusTagAddressResolution.Resolve(ToJson(), family, melsecSubFamily);
|
||||
|
||||
// Whether ToJson writes the six address fields: the loaded blob carried the keys (or named no address at all
|
||||
// AND the operator then authored a field), or an address field was set since. Never true for a string-only or
|
||||
// invalid-string blob that nobody edited: the editor must not write default keys it did not resolve.
|
||||
private bool _authorsKeys = true;
|
||||
|
||||
/// <summary>
|
||||
/// Resolves an address string through <see cref="ModbusAddressParser"/> with the owning driver's
|
||||
@@ -79,6 +112,7 @@ public sealed class ModbusTagConfigModel
|
||||
return error ?? "Address did not parse.";
|
||||
AddressString = text!.Trim();
|
||||
AddressResolved = true;
|
||||
_authorsKeys = true;
|
||||
Region = parsed.Region;
|
||||
Address = parsed.Offset;
|
||||
DataType = parsed.DataType;
|
||||
@@ -103,6 +137,20 @@ public sealed class ModbusTagConfigModel
|
||||
{
|
||||
AddressString = null;
|
||||
AddressResolved = false;
|
||||
_authorsKeys = true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// After a CSV import's typed address cells were applied (they are an authored key address): keeps the
|
||||
/// <see cref="AddressString"/> (and its marker) only when it still describes those fields under some family
|
||||
/// — so an unchanged export → import of a pinned row is lossless (review r3 m1) — and drops it when a cell
|
||||
/// contradicts it. Either way the keys are the address; this only decides the metadata.
|
||||
/// </summary>
|
||||
public void ReconcileAddressStringWithKeys()
|
||||
{
|
||||
if (AddressString is null) return;
|
||||
if (!ModbusTagAddressResolution.AddressStringDescribes(AddressString, Region, Address, DataType, ByteOrder, BitIndex, StringLength))
|
||||
ClearAddressString();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -155,25 +203,29 @@ public sealed class ModbusTagConfigModel
|
||||
public static ModbusTagConfigModel FromJson(string? json)
|
||||
{
|
||||
var o = TagConfigJson.ParseOrNew(json);
|
||||
var shape = ModbusTagAddressResolution.Classify(TagConfigJson.Serialize(o));
|
||||
var region = TagConfigJson.GetEnum(o, "region", ModbusRegion.HoldingRegisters);
|
||||
return new ModbusTagConfigModel
|
||||
{
|
||||
_source = json ?? "",
|
||||
Region = TagConfigJson.GetEnum(o, "region", ModbusRegion.HoldingRegisters),
|
||||
Address = TagConfigJson.GetInt(o, "address"),
|
||||
_region = region,
|
||||
_address = TagConfigJson.GetInt(o, "address"),
|
||||
// Absent dataType defaults per region, exactly as the driver's factory does (Bool for a bit region),
|
||||
// and a bit region shows Bool whatever was persisted — the driver coerces a legacy coil/DI
|
||||
// "dataType":"Int16" (what the pre-#741 editor saved) to Bool, so the editor shows what is polled.
|
||||
DataType = TagConfigJson.GetEnum(o, "region", ModbusRegion.HoldingRegisters) is ModbusRegion.Coils or ModbusRegion.DiscreteInputs
|
||||
_dataType = region is ModbusRegion.Coils or ModbusRegion.DiscreteInputs
|
||||
? ModbusDataType.Bool
|
||||
: TagConfigJson.GetEnum(o, "dataType", ModbusDataType.Int16),
|
||||
ByteOrder = TagConfigJson.GetEnum(o, "byteOrder", ModbusByteOrder.BigEndian),
|
||||
BitIndex = TagConfigJson.GetInt(o, "bitIndex"),
|
||||
StringLength = TagConfigJson.GetInt(o, "stringLength"),
|
||||
_byteOrder = TagConfigJson.GetEnum(o, "byteOrder", ModbusByteOrder.BigEndian),
|
||||
_bitIndex = TagConfigJson.GetInt(o, "bitIndex"),
|
||||
_stringLength = TagConfigJson.GetInt(o, "stringLength"),
|
||||
Writable = TagConfigJson.GetBoolNullable(o, "writable"),
|
||||
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"),
|
||||
AddressString = string.IsNullOrWhiteSpace(TagConfigJson.GetString(o, ModbusTagAddressResolution.AddressStringKey))
|
||||
? null : TagConfigJson.GetString(o, ModbusTagAddressResolution.AddressStringKey),
|
||||
AddressResolved = TagConfigJson.GetBoolNullable(o, ModbusTagAddressResolution.AddressResolvedKey) == true,
|
||||
// The one authority decides whether the loaded fields are an address: only a blob with the keys is.
|
||||
// A string-only / invalid-string / address-less blob keeps its keys unwritten until a field is authored.
|
||||
_authorsKeys = shape == ModbusAddressShape.Keys,
|
||||
_bag = o,
|
||||
};
|
||||
}
|
||||
@@ -183,21 +235,28 @@ public sealed class ModbusTagConfigModel
|
||||
/// <returns>The serialised TagConfig JSON string.</returns>
|
||||
public string ToJson()
|
||||
{
|
||||
// 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)
|
||||
var o = (JsonObject)_bag.DeepClone();
|
||||
// Only an authored key address is stamped (see _authorsKeys). A string-only tag's fields are load-time
|
||||
// defaults, not its address: stamping them would sit HoldingRegisters/0 beside the string, and by the key
|
||||
// rule that would WIN (review r3 N3).
|
||||
if (_authorsKeys)
|
||||
{
|
||||
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(o, "region", Region);
|
||||
TagConfigJson.Set(o, "address", Address);
|
||||
TagConfigJson.Set(o, "dataType", DataType);
|
||||
TagConfigJson.Set(o, "byteOrder", ByteOrder);
|
||||
TagConfigJson.Set(o, "bitIndex", BitIndex);
|
||||
TagConfigJson.Set(o, "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);
|
||||
TagConfigJson.Set(o, "writable", Writable);
|
||||
// A non-string addressString the model could not load is left as persisted (the driver refuses it, and
|
||||
// dropping it would change the deploy gate's explanation) unless the address was authored since.
|
||||
if (AddressString is not null || _authorsKeys)
|
||||
TagConfigJson.Set(o, ModbusTagAddressResolution.AddressStringKey, AddressString);
|
||||
// The marker only means something beside keys; a marker on a keyless blob is a stale lie and is dropped.
|
||||
TagConfigJson.Set(o, ModbusTagAddressResolution.AddressResolvedKey,
|
||||
AddressString is not null && AddressResolved && _authorsKeys ? true : null);
|
||||
return TagConfigJson.Serialize(o);
|
||||
}
|
||||
|
||||
/// <summary>Validation hook; returns an error message or null when the model is valid.</summary>
|
||||
|
||||
Reference in New Issue
Block a user