Merge commit 'd1f24cf6' into remed/lane-K

This commit is contained in:
Joseph Doherty
2026-10-22 17:13:56 -04:00
105 changed files with 9559 additions and 831 deletions
@@ -35,12 +35,39 @@ namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions;
/// DateTime — and only with a declared <c>arrayLength</c>: an undeclared length materialises an
/// unbounded node, and a short whole-array write to it would RESIZE the Galaxy attribute (the
/// mxaccessgw <c>gateway.md</c> contract), which is never what a client meant.</item>
/// <item>Everything else (AbCip, AbLegacy, TwinCAT, FOCAS, MQTT, MTConnect, Sql, Calculation) — scalar
/// write encoders only; array nodes stay read-only.</item>
/// <item><b>AbCip</b> — <c>dataType</c> SInt / USInt / Int / UInt / DInt / UDInt / Real / Dt / LInt / ULInt /
/// LReal (<c>AbCipArrayCodec</c>, both transports), and not a <c>safetyTag</c>. BOOL arrays (bit-packed
/// DWORDs), STRING and structure arrays have no array encoder.</item>
/// <item><b>AbLegacy</b> — <c>dataType</c> Int / AnalogInt on an N / B / A file, Long on an L file, Float
/// on an F file (the encode width is the DATA FILE's; a type that does not match its file is refused),
/// on a plain <c>&lt;file&gt;&lt;number&gt;:&lt;word&gt;</c> address of at most 256 elements (the PCCC
/// file ceiling the factory clamps to). Refused and so left out: Bit arrays (bit-packed words), String /
/// Timer / Counter / Control arrays (structured; the factory rejects them), <c>/bit</c> and <c>.sub</c>
/// addresses, a PLC-5 device on the native transport (every PLC-5 write is refused before the wire), and
/// the <c>S:</c> / <c>I:</c> / <c>O:</c> files (libplctag refuses their translated names). Indirect
/// addresses (<c>N7:[N7:0]</c>) are kept read-only deliberately: the element moves with the index word
/// and the index read is not atomic with the write.</item>
/// <item><b>TwinCAT</b> — every element type <c>TwinCATArrayCodec</c> lays out (all but Structure, which
/// the factory refuses), STRING / WSTRING included, except a BOOL bit-in-word path
/// (<c>MAIN.Word.3</c>), which is a scalar bit write. A multi-dimensional PLC array is written row-major
/// from the flat 1-D value. The PLC-side shape (element width, declared length ≤ the PLC's) is checked
/// against the resolved symbol at write time, which no compose-time table can know.</item>
/// <item>Everything else (FOCAS, MQTT, MTConnect, Sql, Calculation) — scalar write encoders only; array
/// nodes stay read-only.</item>
/// </list>
/// <para>Modbus, S7 and Galaxy require a declared length — <c>isArray: true</c> AND an integer
/// <c>arrayLength &gt;= 1</c>. The Modbus and S7 tag factories treat anything else as a SCALAR tag (whose
/// <para>Modbus, S7, Galaxy, AbCip, AbLegacy and TwinCAT require a declared length — <c>isArray: true</c> AND
/// an integer <c>arrayLength &gt;= 1</c>. Their tag factories treat anything else as a SCALAR tag (whose
/// scalar encoder cannot take an array value), though the composer still materialises an array node.</para>
/// <para><b>Elements convert; they are not exact-matched.</b> ScadaBridge writes CLR arrays such as
/// <c>int[]</c> / <c>List&lt;int&gt;</c> to Int16 nodes; the node manager converts a numeric array to the
/// node's element type, and the AbCip, AbLegacy and TwinCAT encoders then <c>Convert</c> each element to the
/// controller's type (an element that does not fit is <c>BadOutOfRange</c>). So those rows do not key on an
/// exact node <c>DataType</c>; they leave out only the node types whose elements NEVER convert: a
/// <c>DateTime</c> or <c>String</c> node over a numeric element (AbCip, AbLegacy, TwinCAT — TwinCAT keeps
/// DATE / DT and WSTRING under a DateTime node, and STRING / WSTRING under a String node; a DateTime's text
/// form is culture-dependent, so a STRING under a DateTime node is left out), and a <c>Boolean</c> node on
/// AbLegacy, whose encoder refuses bool elements. An unknown DataType name (served as BaseDataType) hands the
/// driver whatever the client sent and is not refused.</para>
/// <para>Multi-dimensional arrays (<c>ValueRank &gt; 1</c>) are out of scope altogether — every array node
/// is materialised 1-D (<c>ValueRank = OneDimension</c>), so nothing here considers more than one rank.</para>
/// </summary>
@@ -72,6 +99,34 @@ public static class DriverArrayWriteSupport
"Boolean", "Int32", "Int64", "Float", "Double", "String", "DateTime",
};
// AbCipArrayCodec.ElementWidth: the atomic element types with a fixed wire width.
private static readonly HashSet<string> AbCipArrayTypes = new(StringComparer.OrdinalIgnoreCase)
{
"SInt", "USInt", "Int", "UInt", "DInt", "UDInt", "Real", "Dt", "LInt", "ULInt", "LReal",
};
/// <summary>The PCCC data-file element count ceiling (<c>AbLegacyArray.MaxElements</c>): the AbLegacy tag
/// factory CLAMPS a longer <c>arrayLength</c> to it, so a node of the authored (longer) length never matches.</summary>
public const int AbLegacyMaxArrayElements = 256;
// AbLegacyTagDefinitionFactory.ArrayFileElementType for the files the table admits, keyed by file letter, to the
// authored element types that match that file's width (ArrayFileMismatch). I / O / S are deliberately absent:
// libplctag refuses a write to their translated names, and the table does not widen output-image / status-file
// writes on the native transport alone.
private static readonly Dictionary<string, string[]> AbLegacyFileElementTypes = new(StringComparer.OrdinalIgnoreCase)
{
["N"] = ["Int", "AnalogInt"], ["B"] = ["Int", "AnalogInt"], ["A"] = ["Int", "AnalogInt"],
["L"] = ["Long"],
["F"] = ["Float"],
};
// TwinCATArrayCodec.Encode: every TwinCATDataType but Structure (which the factory refuses outright).
private static readonly HashSet<string> TwinCATArrayTypes = new(StringComparer.OrdinalIgnoreCase)
{
"Bool", "SInt", "USInt", "Int", "UInt", "DInt", "UDInt", "LInt", "ULInt", "Real", "LReal",
"String", "WString", "Time", "Date", "DateTime", "TimeOfDay",
};
/// <summary>
/// Whether a driver of <paramref name="driverType"/> can write a 1-D array tag of the given shape.
/// </summary>
@@ -119,11 +174,150 @@ public static class DriverArrayWriteSupport
var native = ReadEnumName(root, "dataType") ?? "Int16";
return S7ArrayTypes.Contains(native);
}
case DriverTypeNames.AbCip:
return AbCipSupports(dataType, tagConfig);
case DriverTypeNames.AbLegacy:
return AbLegacySupports(dataType, tagConfig, driverConfigs);
case DriverTypeNames.TwinCAT:
return TwinCATSupports(dataType, tagConfig);
default:
return false;
}
}
// AbCipTagDefinitionFactory + AbCipDriver.WriteAsync: a non-blank string tagPath, an atomic element type the codec
// has a width for (BOOL arrays are bit-packed DWORDs, STRING / structure elements are structures), a declared
// length, and not a safety tag (refused BadNotWritable). Each element is CONVERTED, so any numeric / Boolean node
// type works; DateTime and (non-numeric) String elements never convert to a Logix integer or REAL.
private static bool AbCipSupports(string? dataType, string? tagConfig)
{
if (!TryParseObject(tagConfig, out var root) || !HasNonBlankString(root, "tagPath")
|| ReadArrayLength(root) is null
|| (root.TryGetProperty("safetyTag", out var safety) && safety.ValueKind == JsonValueKind.True))
return false;
var native = ReadEnumName(root, "dataType")?.Trim() ?? "DInt";
return AbCipArrayTypes.Contains(native) && dataType is not ("String" or "DateTime");
}
// AbLegacyTagDefinitionFactory + AbLegacyDriver.WriteAsync: a flat Int / AnalogInt / Long / Float array on a plain
// `<file><number>:<word>` address whose data file's element width matches (ArrayFileMismatch); no /bit, no .sub,
// no indirect [..] address; at most 256 elements (the factory clamps a longer length, so the node never matches);
// and not a PLC-5 on the native transport (refused before the wire). Elements CONVERT from any numeric node type;
// bool, string and DateTime elements are refused.
private static bool AbLegacySupports(string? dataType, string? tagConfig, string?[]? driverConfigs)
{
if (!TryParseObject(tagConfig, out var root)
|| !root.TryGetProperty("address", out var addressEl) || addressEl.ValueKind != JsonValueKind.String
|| ReadArrayLength(root) is not { } count || count > AbLegacyMaxArrayElements
|| dataType is "Boolean" or "String" or "DateTime")
return false;
var native = ReadEnumName(root, "dataType")?.Trim() ?? "Int";
if (!TryParsePlainPcccAddress(addressEl.GetString(), out var fileLetter)
|| !AbLegacyFileElementTypes.TryGetValue(fileLetter, out var matching)
|| !matching.Contains(native, StringComparer.OrdinalIgnoreCase))
return false;
return !AbLegacyIsNativePlc5(driverConfigs);
}
// `<letters><digits>:<digits>` and nothing else (trimmed) — what AbLegacyAddress.TryParse yields with no bit
// index, no sub-element and no indirection, for the file letters that carry a file number.
private static bool TryParsePlainPcccAddress(string? address, out string fileLetter)
{
fileLetter = "";
if (string.IsNullOrWhiteSpace(address)) return false;
var src = address.Trim();
var colon = src.IndexOf(':');
if (colon <= 0) return false;
var letterEnd = 0;
while (letterEnd < colon && char.IsAsciiLetter(src[letterEnd])) letterEnd++;
if (letterEnd == 0 || letterEnd == colon) return false; // no letter, or no file number
var fileNumber = src[letterEnd..colon];
var word = src[(colon + 1)..];
if (!fileNumber.All(char.IsAsciiDigit) || !int.TryParse(fileNumber, out _)
|| word.Length == 0 || !word.All(char.IsAsciiDigit) || !int.TryParse(word, out _))
return false;
fileLetter = src[..letterEnd];
return true;
}
// Conservative: ANY config naming the native transport AND ANY config naming a PLC-5 (a root `plcFamily`, as a
// DeviceConfig carries it, or one in a `devices` array, as a DriverConfig carries it). Keys and values are matched
// case-insensitively, like the driver's DTO binding and enum parse. An absent transport is libplctag for every
// family (AbLegacyTransportResolver), which writes PLC-5.
private static bool AbLegacyIsNativePlc5(string?[]? driverConfigs)
{
var native = false;
var plc5 = false;
foreach (var config in driverConfigs ?? [])
{
if (!TryParseObject(config, out var root)) continue;
foreach (var prop in root.EnumerateObject())
{
if (IsNamed(prop, "transport") && IsStringValue(prop.Value, "Native")) native = true;
else if (IsNamed(prop, "plcFamily") && IsStringValue(prop.Value, "Plc5")) plc5 = true;
else if (IsNamed(prop, "devices") && prop.Value.ValueKind == JsonValueKind.Array)
{
foreach (var device in prop.Value.EnumerateArray())
{
if (device.ValueKind != JsonValueKind.Object) continue;
foreach (var dp in device.EnumerateObject())
if (IsNamed(dp, "plcFamily") && IsStringValue(dp.Value, "Plc5")) plc5 = true;
}
}
}
}
return native && plc5;
}
// TwinCATTagDefinitionFactory + TwinCATDriver.WriteAsync: a non-blank string symbolPath, a declared length, any
// element type the codec lays out (not Structure, which the factory refuses), and not a BOOL bit-in-word path
// (`MAIN.Word.3`: a scalar bit write, which an array value cannot feed). Elements CONVERT. A String node only feeds
// a STRING / WSTRING element (anything else would depend on the text happening to parse). A DateTime node only
// feeds a DATE / DT element or a WSTRING: its text form is CULTURE-dependent and may carry characters a STRING's
// strict Windows-1252 encoding refuses (ICU's en-US "12:00:00 PM").
private static bool TwinCATSupports(string? dataType, string? tagConfig)
{
if (!TryParseObject(tagConfig, out var root) || !HasNonBlankString(root, "symbolPath")
|| ReadArrayLength(root) is null)
return false;
var native = ReadEnumName(root, "dataType")?.Trim() ?? "DInt";
if (!TwinCATArrayTypes.Contains(native)) return false;
var isWString = native.Equals("WString", StringComparison.OrdinalIgnoreCase);
var isString = isWString || native.Equals("String", StringComparison.OrdinalIgnoreCase);
var isDate = native.Equals("Date", StringComparison.OrdinalIgnoreCase) || native.Equals("DateTime", StringComparison.OrdinalIgnoreCase);
if (dataType == "String" && !isString) return false;
if (dataType == "DateTime" && !isWString && !isDate) return false;
return !(native.Equals("Bool", StringComparison.OrdinalIgnoreCase)
&& HasTwinCATBitSuffix(root.GetProperty("symbolPath").GetString()!));
}
// TwinCATSymbolPath.TryParse's bit-index rule: a last top-level `.` segment of 0..31 with no `[`, behind at least
// one other segment (a TwinCAT 2 global's leading dot belongs to its root segment).
private static bool HasTwinCATBitSuffix(string symbolPath)
{
var src = symbolPath.Trim();
if (src.StartsWith('.')) src = src[1..];
var depth = 0;
var lastDot = -1;
for (var i = 0; i < src.Length; i++)
{
if (src[i] == '[') depth++;
else if (src[i] == ']') depth--;
else if (src[i] == '.' && depth == 0) lastDot = i;
}
if (lastDot < 0) return false;
var last = src[(lastDot + 1)..];
return int.TryParse(last, out var bit) && bit is >= 0 and <= 31 && !last.Contains('[');
}
private static bool HasNonBlankString(JsonElement root, string name) =>
root.TryGetProperty(name, out var el) && el.ValueKind == JsonValueKind.String && !string.IsNullOrWhiteSpace(el.GetString());
private static bool IsNamed(JsonProperty prop, string name) => string.Equals(prop.Name, name, StringComparison.OrdinalIgnoreCase);
private static bool IsStringValue(JsonElement el, string value) =>
el.ValueKind == JsonValueKind.String && string.Equals(el.GetString()?.Trim(), value, StringComparison.OrdinalIgnoreCase);
/// <summary>
/// Whether a driver of <paramref name="driverType"/> is known to REFUSE an
/// array value written to one of its SCALAR tags, so the node manager may refuse such a write inline
@@ -1,11 +1,13 @@
using System.Globalization;
using System.Text.Json;
using Microsoft.Data.Sqlite;
namespace ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian;
/// <summary>
/// DDL for the Part 9 scripted-alarm condition state: one row per alarm identity holding the
/// operator-supplied state (Enabled / Acked / Confirmed / Shelving) plus the ack/confirm audit
/// trail and comment history.
/// DDL for the Part 9 scripted-alarm condition state: one fixed-size row per alarm identity
/// holding the operator-supplied state (Enabled / Acked / Confirmed / Shelving) plus the
/// ack/confirm audit fields, and a separate append-only table holding each alarm's comment trail.
/// </summary>
/// <remarks>
/// <para>
@@ -34,14 +36,58 @@ namespace ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian;
/// the last transition. Timestamps are round-trip ("O") TEXT so lexicographic order is
/// chronological; nullable timestamps and audit fields are stored as NULL.
/// </para>
/// <para>
/// <b>The comment trail is NOT on the condition row.</b> It used to be serialized whole into
/// <c>comments_json</c>, so every transition rewrote — and replicated — the alarm's entire
/// history: O(n) per transition, O(n²) over the alarm's life (review R05:4). It now lives in
/// <see cref="CommentsTable"/>, one row per entry keyed by <c>(scripted_alarm_id, seq)</c>,
/// where <c>seq</c> is the entry's ordinal in the alarm's trail. A transition inserts only the
/// entries it appended, so the condition row stays a fixed size and a transition's write is
/// O(1) however long the history is. <c>comments_json</c> is kept, always written as
/// <c>"[]"</c>, so the condition table's shape — and with it the pair's replication digest
/// for that table — does not change and an old-shape row stays readable;
/// <see cref="MigrateInlineComments"/> copies any inline trail across (the alarm's next
/// save clears the column).
/// </para>
/// <para>
/// <b>Why the ordinal, and not a content hash, is the key.</b> Both halves of a redundant pair
/// run the engine and both apply every operator command, each stamping the entry with its own
/// clock. A content-derived id would keep TWO rows for every logical entry once the pair
/// converged. Keyed by ordinal, the two nodes' copies of entry <i>n</i> share one primary key
/// and LWW collapses them to one row — the convergence the whole-row LWW gave before, now per
/// entry. Entries go in with <c>INSERT OR IGNORE</c> (<see cref="InsertCommentSql"/>), so an
/// entry already held — written locally, or arrived from the peer — is never rewritten. A
/// node that MISSED a peer command keeps its own colliding entry by appending it after the
/// stored trail, with a Warning (see the store's divergence handling).
/// </para>
/// </remarks>
public static class AlarmConditionStateSchema
{
/// <summary>Table holding one condition-state row per scripted-alarm identity.</summary>
public const string StateTable = "alarm_condition_state";
/// <summary>Append-only table holding one row per comment-trail entry, keyed by alarm id + ordinal.</summary>
public const string CommentsTable = "alarm_condition_comment";
/// <summary>What <c>comments_json</c> holds once the trail lives in <see cref="CommentsTable"/>.</summary>
public const string EmptyInlineComments = "[]";
/// <summary>
/// Creates the state table if it does not already exist. Idempotent.
/// The one append statement for <see cref="CommentsTable"/>, shared by the migration and the
/// store. <c>OR IGNORE</c> is what makes the trail append-only: an ordinal already held is
/// never rewritten. Parameters: <c>@Id @Seq @Ts @User @Kind @Text</c>.
/// </summary>
public const string InsertCommentSql =
"""
INSERT OR IGNORE INTO alarm_condition_comment
(scripted_alarm_id, seq, timestamp_utc, user_name, kind, text)
VALUES (@Id, @Seq, @Ts, @User, @Kind, @Text)
""";
private static readonly JsonSerializerOptions InlineJsonOptions = new(JsonSerializerDefaults.Web);
/// <summary>
/// Creates the state table and the comment table if they do not already exist. Idempotent.
/// </summary>
/// <param name="connection">
/// An already-open connection. <c>ILocalDb.CreateConnection()</c> hands out open,
@@ -55,10 +101,11 @@ public static class AlarmConditionStateSchema
// The four *_state columns are always written to a mapped, non-null string (Enabled/Disabled,
// Acknowledged/Unacknowledged, Confirmed/Unconfirmed, Unshelved/OneShotShelved/TimedShelved),
// so they are NOT NULL. comments_json is never null either — an empty trail is the literal
// "[]". The audit user/comment/utc columns and shelving_expires_utc are NULL when the alarm
// has no such history yet. updated_at_utc carries LastTransitionUtc (there is no dedicated
// transition column).
// so they are NOT NULL. comments_json is never null either — it is the literal "[]" since the
// trail moved to alarm_condition_comment (an old-shape row may still carry an inline trail
// until the alarm's next save; MigrateInlineComments copies it without touching this row).
// The audit user/comment/utc columns and shelving_expires_utc are NULL when the alarm has no such history yet. updated_at_utc
// carries LastTransitionUtc (there is no dedicated transition column).
cmd.CommandText = """
CREATE TABLE IF NOT EXISTS alarm_condition_state (
scripted_alarm_id TEXT NOT NULL PRIMARY KEY,
@@ -76,7 +123,161 @@ public static class AlarmConditionStateSchema
comments_json TEXT NOT NULL,
updated_at_utc TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS alarm_condition_comment (
scripted_alarm_id TEXT NOT NULL,
seq INTEGER NOT NULL,
timestamp_utc TEXT NOT NULL,
user_name TEXT NOT NULL,
kind TEXT NOT NULL,
text TEXT NOT NULL,
PRIMARY KEY (scripted_alarm_id, seq)
);
""";
cmd.ExecuteNonQuery();
}
/// <summary>What one <see cref="MigrateInlineComments"/> run did.</summary>
/// <param name="RowsMoved">Condition rows for which at least one inline entry was copied across.</param>
/// <param name="UnparseableAlarmIds">
/// Alarms whose inline trail is not valid JSON and was left where it is — the caller logs these,
/// so an unmigratable history is visible rather than skipped silently on every boot.
/// </param>
public sealed record InlineCommentMigrationResult(int RowsMoved, IReadOnlyList<string> UnparseableAlarmIds);
/// <summary>
/// Copies every condition row's inline <c>comments_json</c> trail into
/// <see cref="CommentsTable"/>. Idempotent; it never writes the condition row itself.
/// </summary>
/// <remarks>
/// <para>
/// <b>Run it AFTER the tables are registered for replication</b> — it writes rows, and a
/// row written before the capture triggers exist never reaches the peer (the same
/// DDL → register → writes rule <c>AlarmSfLegacyMigrator</c> follows).
/// </para>
/// <para>
/// <b>Copy, don't clear.</b> Resetting <c>comments_json</c> here would be a full-row UPDATE
/// that re-stamps the condition row with a fresh HLC — during a pair's rolling upgrade
/// that stale-but-newly-stamped copy would beat a newer peer state (an ack made on the
/// already-upgraded node) under LWW (review m1). The column is left as it is: the store's
/// load merges an inline trail with the table by ordinal (the table wins), and the
/// engine's next save of that alarm writes <c>"[]"</c> with the alarm's real state.
/// </para>
/// <para>
/// Each entry lands at its ordinal in the inline array via <see cref="InsertCommentSql"/>,
/// so a re-run — or a row whose trail was already (partly) copied — neither duplicates nor
/// reorders anything and writes nothing for an ordinal already held. Each row copies in its
/// own transaction, so a crash leaves it fully copied or untouched. A row whose inline JSON
/// does not parse is left exactly as it is (the pre-split store could not load it either)
/// and reported in <see cref="InlineCommentMigrationResult.UnparseableAlarmIds"/> rather
/// than failing host start.
/// </para>
/// </remarks>
/// <param name="connection">An already-open connection from <c>ILocalDb.CreateConnection()</c>.</param>
/// <returns>What was copied, and which rows could not be read.</returns>
public static InlineCommentMigrationResult MigrateInlineComments(SqliteConnection connection)
{
ArgumentNullException.ThrowIfNull(connection);
var pending = new List<(string AlarmId, string Json)>();
using (var select = connection.CreateCommand())
{
select.CommandText =
"SELECT scripted_alarm_id, comments_json FROM alarm_condition_state WHERE comments_json <> @Empty";
select.Parameters.AddWithValue("@Empty", EmptyInlineComments);
using var reader = select.ExecuteReader();
while (reader.Read())
pending.Add((reader.GetString(0), reader.GetString(1)));
}
var migrated = 0;
var unparseable = new List<string>();
foreach (var (alarmId, json) in pending)
{
IReadOnlyList<InlineComment> entries;
try
{
entries = ParseInlineComments(json);
}
catch (JsonException)
{
unparseable.Add(alarmId);
continue;
}
var inserted = 0;
using var tx = connection.BeginTransaction();
for (var seq = 0; seq < entries.Count; seq++)
{
using var insert = connection.CreateCommand();
insert.Transaction = tx;
insert.CommandText = InsertCommentSql;
insert.Parameters.AddWithValue("@Id", alarmId);
insert.Parameters.AddWithValue("@Seq", seq);
insert.Parameters.AddWithValue("@Ts", FormatTimestamp(entries[seq].TimestampUtc));
insert.Parameters.AddWithValue("@User", entries[seq].User);
insert.Parameters.AddWithValue("@Kind", entries[seq].Kind);
insert.Parameters.AddWithValue("@Text", entries[seq].Text);
inserted += insert.ExecuteNonQuery();
}
tx.Commit();
if (inserted > 0)
migrated++;
}
return new InlineCommentMigrationResult(migrated, unparseable);
}
/// <summary>Formats a comment timestamp for <see cref="CommentsTable"/> (round-trip "O" text).</summary>
/// <param name="value">The timestamp.</param>
/// <returns>The round-trip text form.</returns>
public static string FormatTimestamp(DateTime value) => value.ToString("O", CultureInfo.InvariantCulture);
/// <summary>Parses a <see cref="CommentsTable"/> timestamp, preserving its <see cref="DateTimeKind"/>.</summary>
/// <param name="value">The round-trip text form.</param>
/// <returns>The timestamp.</returns>
public static DateTime ParseTimestamp(string value) =>
DateTime.Parse(value, CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind);
/// <summary>
/// Parses a legacy inline <c>comments_json</c> trail exactly as the pre-split store did
/// (<see cref="JsonSerializerDefaults.Web"/>; null strings become empty).
/// </summary>
/// <param name="json">The column value.</param>
/// <returns>The entries in trail order; empty for a blank or <c>"[]"</c> column.</returns>
/// <exception cref="JsonException">The column is not a valid trail.</exception>
public static IReadOnlyList<InlineComment> ParseInlineComments(string? json)
{
if (string.IsNullOrWhiteSpace(json)) return [];
var dtos = JsonSerializer.Deserialize<List<InlineCommentDto>>(json, InlineJsonOptions);
if (dtos is null || dtos.Count == 0) return [];
return dtos
.Select(d => new InlineComment(
d.TimestampUtc, d.User ?? string.Empty, d.Kind ?? string.Empty, d.Text ?? string.Empty))
.ToList();
}
/// <summary>One comment-trail entry as stored — the persistence-side mirror of the engine's <c>AlarmComment</c>.</summary>
/// <param name="TimestampUtc">When the action happened.</param>
/// <param name="User">Identity of the actor.</param>
/// <param name="Kind">Classification (Acknowledge, Confirm, AddComment, …).</param>
/// <param name="Text">Operator-supplied or engine-generated text.</param>
public sealed record InlineComment(DateTime TimestampUtc, string User, string Kind, string Text);
/// <summary>The pre-split on-disk shape of one entry in <c>comments_json</c>.</summary>
private sealed class InlineCommentDto
{
/// <summary>When the comment was recorded.</summary>
public DateTime TimestampUtc { get; set; }
/// <summary>Identity of the actor.</summary>
public string? User { get; set; }
/// <summary>Classification of the entry.</summary>
public string? Kind { get; set; }
/// <summary>Entry text.</summary>
public string? Text { get; set; }
}
}
@@ -57,6 +57,14 @@ public sealed class ReadCommand : ModbusCommandBase
ValidateEndpoint();
var ct = console.RegisterCancellationHandler();
// The driver's tag factory refuses an incompatible region/type pair (the tag would read
// BadNodeIdUnknown) — say why up front instead.
if (!ModbusTagDefinitionFactory.IsRegionTypeCompatible(Region, DataType))
throw new CliFx.Exceptions.CommandException(
Region is ModbusRegion.Coils or ModbusRegion.DiscreteInputs
? $"Region '{Region}' only carries Bool values (--type Bool); got '{DataType}'."
: $"Region '{Region}' carries register types; use --type BitInRegister for a bit of a register, not '{DataType}'.");
var tagName = SynthesiseTagName(Region, Address, DataType);
var tag = new ModbusTagDefinition(
Name: tagName,
@@ -74,6 +74,9 @@ public sealed class WriteCommand : ModbusCommandBase
throw new CliFx.Exceptions.CommandException(
$"Region 'Coils' only supports boolean values (--type Bool). " +
$"Type '{DataType}' cannot represent a single-bit coil write.");
if (!ModbusTagDefinitionFactory.IsRegionTypeCompatible(Region, DataType))
throw new CliFx.Exceptions.CommandException(
$"Region '{Region}' carries register types; use --type BitInRegister for a bit of a register, not '{DataType}'.");
var tagName = ReadCommand.SynthesiseTagName(Region, Address, DataType);
var tag = new ModbusTagDefinition(
@@ -89,13 +89,24 @@ public sealed class ModbusDriverOptions
public bool UseFC16ForSingleRegisterWrites { get; init; } = false;
/// <summary>
/// <b>Reserved / no-op</b> kill-switch for FC23 (Read/Write Multiple Registers). The
/// driver does not currently emit FC23 — toggling this option has no observable effect
/// today. The slot exists so a future block-read-coalescing enhancement that opts into
/// FC23 can be disabled per-deployment without a code change. Default <c>false</c>.
/// Hard kill-switch for FC23 (Read/Write Multiple Registers): when <c>true</c> the driver never
/// emits FC23, even with <see cref="UseFC23ForMultiRegisterWrites"/> set. Default <c>false</c>.
/// FC23 is OPT-IN — a config without <see cref="UseFC23ForMultiRegisterWrites"/> keeps the FC16 wire
/// behaviour, so the persisted <c>"disableFC23": false</c> every form-saved config carries changes
/// nothing on the wire.
/// </summary>
public bool DisableFC23 { get; init; } = false;
/// <summary>
/// Opt-in: send a multi-register holding write (2–121 registers: 32/64-bit values, arrays, strings)
/// as ONE FC23 that writes the block and reads the same block back, so the device confirms what it
/// now holds. A unit that answers FC23 with exception 01 (MELSEC QJ71MT91, S7 Modbus servers) moves
/// to FC16 for that write and every later one until the next initialize; one that fails FC23 at the
/// transport moves too (that one write is reported as a comms failure). Ignored when
/// <see cref="DisableFC23"/> is set. Default <c>false</c> = FC16, the historical behaviour.
/// </summary>
public bool UseFC23ForMultiRegisterWrites { get; init; } = false;
/// <summary>
/// Interval for the background re-probe loop that retries auto-prohibited
/// coalesced ranges. When non-null, every <c>AutoProhibitReprobeInterval</c>
@@ -107,20 +118,32 @@ public sealed class ModbusDriverOptions
public TimeSpan? AutoProhibitReprobeInterval { get; init; } = null;
/// <summary>
/// Block-read coalescing budget. When non-zero, the read planner combines tags
/// in the same (UnitId, Region) group whose addresses are at most this many registers
/// apart into a single FC03/FC04/FC01/FC02 read. The sliced response is then dispatched
/// back to per-tag values. Default <c>0</c> = no coalescing — every tag gets its own
/// PDU (preserves the previous behaviour). Typical opt-in values are 5..32 — large enough
/// to bridge a few unused registers, small enough to avoid trampling protected holes.
/// Block-read coalescing budget. When non-zero, the read planner combines tags in the same
/// (UnitId, Region) group whose addresses are at most this many registers (Coils / DiscreteInputs:
/// bits) apart into a single FC03/FC04/FC01/FC02 read, then slices the response back into per-tag
/// values (several <c>BitInRegister</c> tags on one status word fold into one read). <c>0</c> = no
/// coalescing — an explicitly authored 0 keeps that historical meaning. Default <c>8</c> applies
/// only when the key is ABSENT. A range the device refuses is auto-prohibited and read per tag.
/// <see cref="DisableReadCoalescing"/> also turns it off; <c>coalesceProhibited</c> exempts one tag.
/// </summary>
public ushort MaxReadGap { get; init; } = 0;
public ushort MaxReadGap { get; init; } = DefaultMaxReadGap;
/// <summary>The default <see cref="MaxReadGap"/> (shared by the factory DTO and the AdminUI form).</summary>
public const ushort DefaultMaxReadGap = 8;
/// <summary>
/// PLC family hint that drives the parser's family-native branch. When set to a
/// non-Generic value, address strings using that family's native syntax (DL205 V2000 /
/// MELSEC D100) parse to the right region + offset directly. Defaults to
/// <see cref="ModbusFamily.Generic"/> = Modicon-only behaviour preserved by default.
/// When <c>true</c>, every tag is read with its own PDU (no block coalescing at all) — for a device
/// that mis-answers merged reads in a way the auto-prohibition cannot catch. Default <c>false</c>.
/// </summary>
public bool DisableReadCoalescing { get; init; } = false;
/// <summary>
/// PLC family hint that drives the parser's family-native branch. A STRING-ONLY tag (an
/// <c>addressString</c> with no region/address keys — see <see cref="ModbusTagAddressResolution"/>) is
/// resolved with this family at every initialize, so with a non-Generic
/// value that family's native syntax (DL205 V2000 octal / MELSEC D100) parses to the right
/// region + offset. Defaults to <see cref="ModbusFamily.Generic"/> (Modicon / mnemonic grammar).
/// Tags authored by region + numeric address are unaffected.
/// </summary>
public ModbusFamily Family { get; init; } = ModbusFamily.Generic;
@@ -0,0 +1,251 @@
using System.Text.Json;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus;
/// <summary>
/// Which part of a Modbus <c>TagConfig</c> blob names the tag's address — the output of
/// <see cref="ModbusTagAddressResolution.Classify(JsonElement)"/>.
/// </summary>
public enum ModbusAddressShape
{
/// <summary>
/// Both <c>region</c> and <c>address</c> are present: those keys are the address, whatever
/// <c>addressString</c> / <c>addressResolved</c> sit beside them (classic, editor-pinned, or a pre-#741 row
/// whose string the old driver never read).
/// </summary>
Keys,
/// <summary>
/// The keys are not both present and <c>addressString</c> is a non-blank string: the string is the address,
/// resolved with the DRIVER's family. A family that cannot parse it refuses the tag.
/// </summary>
StringOnly,
/// <summary>The keys are not both present and <c>addressString</c> is present but not a string: refused.</summary>
InvalidAddressString,
/// <summary>Neither the keys nor a usable <c>addressString</c>: refused (the tag names no address).</summary>
NoAddress,
}
/// <summary>The address part of a Modbus tag, as the driver will poll it.</summary>
/// <param name="Shape">Which part of the blob named it (<see cref="ModbusAddressShape.Keys"/> or <see cref="ModbusAddressShape.StringOnly"/>).</param>
/// <param name="Region">The register region.</param>
/// <param name="Address">The zero-based offset.</param>
/// <param name="DataType">The data type (already coerced to <c>Bool</c> for a bit region).</param>
/// <param name="ByteOrder">The word/byte order.</param>
/// <param name="BitIndex">The bit index (BitInRegister).</param>
/// <param name="StringLength">The string length (String).</param>
/// <param name="ArrayCount">The element count, or <c>null</c> for a scalar.</param>
public sealed record ModbusResolvedAddress(
ModbusAddressShape Shape,
ModbusRegion Region,
ushort Address,
ModbusDataType DataType,
ModbusByteOrder ByteOrder,
byte BitIndex,
ushort StringLength,
int? ArrayCount);
/// <summary>The outcome of <see cref="ModbusTagAddressResolution.Resolve(JsonElement, ModbusFamily, MelsecFamily)"/>.</summary>
/// <param name="Shape">The blob's address shape (always set, also on a refusal).</param>
/// <param name="Address">The resolved address, or <c>null</c> when the tag is refused.</param>
/// <param name="Refusal">Why the tag is refused, or <c>null</c> when it resolved.</param>
public readonly record struct ModbusAddressResolution(ModbusAddressShape Shape, ModbusResolvedAddress? Address, string? Refusal)
{
/// <summary>Whether the driver serves the tag at <see cref="Address"/>.</summary>
public bool IsResolved => Address is not null;
}
/// <summary>
/// <b>The one authority for which address a Modbus <c>TagConfig</c> names</b> (#741, review r3 N3). The driver's
/// tag reader, the deploy gate, the AdminUI editor model (load / save / Validate / display) and the CSV
/// import/export all ask this class; none of them restates the precedence.
/// <para>The rule:</para>
/// <list type="number">
/// <item>When <c>region</c> AND <c>address</c> are both present they are the address
/// (<see cref="ModbusAddressShape.Keys"/>). No pre-#741 shape changes the address it resolves to: the old
/// driver read the keys only and ignored any <c>addressString</c>.</item>
/// <item>Otherwise a non-blank string <c>addressString</c> is the address
/// (<see cref="ModbusAddressShape.StringOnly"/>), parsed by <see cref="ModbusAddressParser"/> with the
/// DRIVER's family. If that family cannot parse it the tag is REFUSED — never served as HR[0].</item>
/// <item>Anything else is refused.</item>
/// </list>
/// <c>addressResolved</c> is editor display / CSV state only and never changes the shape.
/// </summary>
public static class ModbusTagAddressResolution
{
/// <summary>The TagConfig key carrying the parser-grammar address (<c>40001:F:CDAB</c>, <c>V2000</c>, <c>D100</c>).</summary>
public const string AddressStringKey = "addressString";
/// <summary>
/// The key the editor writes beside the keys it resolved from an <c>addressString</c>
/// (<c>"addressResolved": true</c>). Display / CSV state only: it never decides the address.
/// </summary>
public const string AddressResolvedKey = "addressResolved";
/// <summary>Every (family, MELSEC sub-family) a driver can be configured with — the family-unaware gates' search space.</summary>
public static IReadOnlyList<(ModbusFamily Family, MelsecFamily Sub)> AllFamilies { get; } =
[
(ModbusFamily.Generic, MelsecFamily.Q_L_iQR),
(ModbusFamily.DL205, MelsecFamily.Q_L_iQR),
(ModbusFamily.MELSEC, MelsecFamily.Q_L_iQR),
(ModbusFamily.MELSEC, MelsecFamily.F_iQF),
];
/// <summary>Classifies an object-rooted TagConfig by which part of it names the address.</summary>
/// <param name="root">The TagConfig root (a non-object is <see cref="ModbusAddressShape.NoAddress"/>).</param>
/// <returns>The address shape.</returns>
public static ModbusAddressShape Classify(JsonElement root)
{
if (root.ValueKind != JsonValueKind.Object) return ModbusAddressShape.NoAddress;
if (root.TryGetProperty("region", out _) && root.TryGetProperty("address", out _)) return ModbusAddressShape.Keys;
if (!root.TryGetProperty(AddressStringKey, out var s) || s.ValueKind == JsonValueKind.Null) return ModbusAddressShape.NoAddress;
if (s.ValueKind != JsonValueKind.String) return ModbusAddressShape.InvalidAddressString;
return string.IsNullOrWhiteSpace(s.GetString()) ? ModbusAddressShape.NoAddress : ModbusAddressShape.StringOnly;
}
/// <summary>Classifies a TagConfig JSON string (blank / malformed / non-object ⇒ <see cref="ModbusAddressShape.NoAddress"/>).</summary>
/// <param name="tagConfig">The TagConfig JSON.</param>
/// <returns>The address shape.</returns>
public static ModbusAddressShape Classify(string? tagConfig)
{
if (string.IsNullOrWhiteSpace(tagConfig)) return ModbusAddressShape.NoAddress;
try
{
using var doc = JsonDocument.Parse(tagConfig);
return Classify(doc.RootElement);
}
catch (JsonException) { return ModbusAddressShape.NoAddress; }
}
/// <summary>Resolves a TagConfig JSON string's address under one family.</summary>
/// <param name="tagConfig">The TagConfig JSON.</param>
/// <param name="family">The driver's PLC family.</param>
/// <param name="melsecSubFamily">The driver's MELSEC sub-family.</param>
/// <returns>The resolution.</returns>
public static ModbusAddressResolution Resolve(string? tagConfig, ModbusFamily family, MelsecFamily melsecSubFamily)
{
if (string.IsNullOrWhiteSpace(tagConfig))
return Refused(ModbusAddressShape.NoAddress, "Modbus TagConfig is blank — the tag names no address.");
try
{
using var doc = JsonDocument.Parse(tagConfig);
return Resolve(doc.RootElement, family, melsecSubFamily);
}
catch (JsonException)
{
return Refused(ModbusAddressShape.NoAddress, "Modbus TagConfig is not valid JSON — the tag will not resolve (BadNodeIdUnknown).");
}
}
/// <summary>
/// Resolves the address of an object-rooted TagConfig under one family — the rule in the class remarks.
/// Key reads are STRICT (a present-but-invalid <c>region</c> / <c>dataType</c> / <c>byteOrder</c>, or an
/// <c>address</c> that is not an integer 0–65535, refuses the tag); a bit region's data type is coerced to
/// <c>Bool</c> (pre-#741 editor blobs persisted <c>Int16</c> there and the old driver ignored it).
/// </summary>
/// <param name="root">The TagConfig root.</param>
/// <param name="family">The driver's PLC family.</param>
/// <param name="melsecSubFamily">The driver's MELSEC sub-family.</param>
/// <returns>The resolution.</returns>
public static ModbusAddressResolution Resolve(JsonElement root, ModbusFamily family, MelsecFamily melsecSubFamily)
{
var shape = Classify(root);
try
{
switch (shape)
{
case ModbusAddressShape.Keys:
return ResolveKeys(root);
case ModbusAddressShape.StringOnly:
{
var text = root.GetProperty(AddressStringKey).GetString()!.Trim();
if (!ModbusAddressParser.TryParse(text, family, melsecSubFamily, out var parsed, out var error) || parsed is null)
return Refused(shape, $"Modbus 'addressString' '{text}' does not parse under the {Describe(family, melsecSubFamily)} family ({error}) — the tag will not resolve (BadNodeIdUnknown).");
return Resolved(shape, parsed.Region, parsed.Offset, parsed.DataType, parsed.ByteOrder,
parsed.Bit ?? 0, parsed.StringLength, parsed.ArrayCount);
}
case ModbusAddressShape.InvalidAddressString:
return Refused(shape, "Modbus TagConfig 'addressString' is not a string — the tag will not resolve (BadNodeIdUnknown).");
default:
return Refused(shape, "Modbus TagConfig names no address: it needs 'region' + 'address' or an 'addressString' — the tag will not resolve (BadNodeIdUnknown).");
}
}
catch (FormatException) { return Refused(shape, "Modbus TagConfig address could not be read — the tag will not resolve (BadNodeIdUnknown)."); }
catch (InvalidOperationException) { return Refused(shape, "Modbus TagConfig address could not be read — the tag will not resolve (BadNodeIdUnknown)."); }
}
/// <summary>Whether the driver serves the blob's address under at least one family (the family-unaware gates' accept rule).</summary>
/// <param name="tagConfig">The TagConfig JSON.</param>
/// <returns><see langword="true"/> when some family resolves it.</returns>
public static bool ResolvesUnderAnyFamily(string? tagConfig)
=> AllFamilies.Any(f => Resolve(tagConfig, f.Family, f.Sub).IsResolved);
/// <summary>
/// Whether <paramref name="addressString"/> parses, under SOME family, to exactly the given address fields —
/// i.e. the string still describes the keys beside it. Used by the CSV import to keep an exported pinned
/// row's string through an unchanged round trip while dropping one a typed cell contradicts. It never
/// decides the address (the keys do, by the rule above).
/// </summary>
/// <param name="addressString">The address string.</param>
/// <param name="region">The keys' region.</param>
/// <param name="address">The keys' offset.</param>
/// <param name="dataType">The keys' data type.</param>
/// <param name="byteOrder">The keys' byte order.</param>
/// <param name="bitIndex">The keys' bit index.</param>
/// <param name="stringLength">The keys' string length.</param>
/// <returns><see langword="true"/> when some family parses the string to those fields.</returns>
public static bool AddressStringDescribes(string? addressString, ModbusRegion region, int address, ModbusDataType dataType,
ModbusByteOrder byteOrder, int bitIndex, int stringLength)
{
if (string.IsNullOrWhiteSpace(addressString)) return false;
foreach (var (family, sub) in AllFamilies)
{
if (!ModbusAddressParser.TryParse(addressString.Trim(), family, sub, out var p, out _) || p is null) continue;
var parsedType = p.Region is ModbusRegion.Coils or ModbusRegion.DiscreteInputs ? ModbusDataType.Bool : p.DataType;
if (p.Region == region && p.Offset == address && parsedType == dataType && p.ByteOrder == byteOrder
&& (p.Bit ?? 0) == bitIndex && p.StringLength == stringLength)
return true;
}
return false;
}
private static ModbusAddressResolution ResolveKeys(JsonElement root)
{
var addr = root.GetProperty("address");
if (addr.ValueKind != JsonValueKind.Number || !addr.TryGetInt32(out var address) || address < 0 || address > ushort.MaxValue)
return Refused(ModbusAddressShape.Keys,
$"value '{addr.GetRawText()}' for 'address' is not a Modbus register address (an integer 0–{ushort.MaxValue}); the tag will not resolve (BadNodeIdUnknown).");
if (!TagConfigJson.TryReadEnumStrict(root, "region", ModbusRegion.HoldingRegisters, out ModbusRegion region))
return Refused(ModbusAddressShape.Keys, TagConfigJson.DescribeInvalidEnum<ModbusRegion>(root, "region") ?? "invalid 'region'");
if (!TagConfigJson.TryReadEnumStrict(root, "dataType", ModbusTagDefinitionFactory.DefaultDataType(region), out ModbusDataType dataType))
return Refused(ModbusAddressShape.Keys, TagConfigJson.DescribeInvalidEnum<ModbusDataType>(root, "dataType") ?? "invalid 'dataType'");
if (!TagConfigJson.TryReadEnumStrict(root, "byteOrder", ModbusByteOrder.BigEndian, out ModbusByteOrder byteOrder))
return Refused(ModbusAddressShape.Keys, TagConfigJson.DescribeInvalidEnum<ModbusByteOrder>(root, "byteOrder") ?? "invalid 'byteOrder'");
// isArray / arrayLength: an array iff isArray:true AND arrayLength >= 1 (a 1-element array is valid).
var isArray = root.TryGetProperty("isArray", out var ia) && ia.ValueKind == JsonValueKind.True;
var arrayLength = ReadInt(root, "arrayLength");
return Resolved(ModbusAddressShape.Keys, region, address, dataType, byteOrder,
(byte)ReadInt(root, "bitIndex"), (ushort)ReadInt(root, "stringLength"), isArray && arrayLength >= 1 ? arrayLength : null);
}
private static ModbusAddressResolution Resolved(ModbusAddressShape shape, ModbusRegion region, int address, ModbusDataType dataType,
ModbusByteOrder byteOrder, int bitIndex, int stringLength, int? arrayCount)
{
// A bit region carries only Bool: coerce, never refuse (pre-#741 coil blobs persisted Int16).
if (region is ModbusRegion.Coils or ModbusRegion.DiscreteInputs) dataType = ModbusDataType.Bool;
return new ModbusAddressResolution(shape,
new ModbusResolvedAddress(shape, region, (ushort)address, dataType, byteOrder, (byte)bitIndex, (ushort)stringLength, arrayCount),
null);
}
private static ModbusAddressResolution Refused(ModbusAddressShape shape, string reason) => new(shape, null, reason);
private static string Describe(ModbusFamily family, MelsecFamily sub)
=> family == ModbusFamily.MELSEC ? $"MELSEC {sub}" : family.ToString();
private static int ReadInt(JsonElement o, string name)
=> o.TryGetProperty(name, out var e) && e.ValueKind == JsonValueKind.Number && e.TryGetInt32(out var v) ? v : 0;
}
@@ -32,6 +32,25 @@ public static class ModbusTagDefinitionFactory
/// <param name="def">The mapped definition when this returns <see langword="true"/>.</param>
/// <returns><see langword="true"/> when <paramref name="tagConfig"/> is a valid Modbus address object.</returns>
public static bool FromTagConfig(string tagConfig, string rawPath, out ModbusTagDefinition def)
=> FromTagConfig(tagConfig, rawPath, ModbusFamily.Generic, MelsecFamily.Q_L_iQR, out def);
/// <summary>
/// Family-aware overload — the call <c>ModbusDriver</c> makes with its own Family / MelsecSubFamily. WHICH
/// address the blob names is decided by <see cref="ModbusTagAddressResolution"/> (the one authority shared
/// with the deploy gate, the AdminUI editor model and the CSV import/export): <c>region</c> + <c>address</c>
/// when both are present, else a string-only <c>addressString</c> resolved with the DRIVER's
/// <paramref name="family"/> / <paramref name="melsecSubFamily"/> (refused when that family cannot parse it).
/// This method adds only the non-address reads and guards (region/type compatibility, String length,
/// BitInRegister index, writable, unitId, deadband, coalesceProhibited).
/// </summary>
/// <param name="tagConfig">The authored equipment-tag TagConfig JSON.</param>
/// <param name="rawPath">The tag's RawPath — becomes the definition's identity (<c>Name</c>).</param>
/// <param name="family">The driver's PLC family (from its <c>DriverConfig</c>).</param>
/// <param name="melsecSubFamily">The driver's MELSEC sub-family; consulted only for MELSEC.</param>
/// <param name="def">The mapped definition when this returns <see langword="true"/>.</param>
/// <returns><see langword="true"/> when <paramref name="tagConfig"/> is a valid Modbus address object.</returns>
public static bool FromTagConfig(string tagConfig, string rawPath, ModbusFamily family, MelsecFamily melsecSubFamily,
out ModbusTagDefinition def)
{
def = null!;
if (string.IsNullOrWhiteSpace(tagConfig)) return false;
@@ -39,22 +58,24 @@ public static class ModbusTagDefinitionFactory
{
using var doc = JsonDocument.Parse(tagConfig);
var root = doc.RootElement;
if (root.ValueKind != JsonValueKind.Object
|| !root.TryGetProperty("region", out _)
|| !root.TryGetProperty("address", out var addr))
return false;
if (addr.ValueKind != JsonValueKind.Number
|| !addr.TryGetInt32(out var address)
|| address < 0 || address > ushort.MaxValue)
return false;
// Strict enum reads: a present-but-invalid (typo'd) enum rejects the tag
// (→ BadNodeIdUnknown) instead of silently defaulting to a wrong-width Good.
if (!TagConfigJson.TryReadEnumStrict(root, "region", ModbusRegion.HoldingRegisters, out var region)) return false;
if (!TagConfigJson.TryReadEnumStrict(root, "dataType", ModbusDataType.Int16, out var dataType)) return false;
if (!TagConfigJson.TryReadEnumStrict(root, "byteOrder", ModbusByteOrder.BigEndian, out var byteOrder)) return false;
if (root.ValueKind != JsonValueKind.Object) return false;
// The address — region, offset, type, order, bit, length, count — comes from the ONE authority
// (ModbusTagAddressResolution: keys when both are present, else the addressString with this family).
var resolution = ModbusTagAddressResolution.Resolve(root, family, melsecSubFamily);
if (resolution.Address is not { } a) return false;
var region = a.Region;
var address = a.Address;
var dataType = a.DataType;
var byteOrder = a.ByteOrder;
var bitIndex = a.BitIndex;
var stringLength = a.StringLength;
var arrayCount = a.ArrayCount;
// Region/type compatibility. A bit region's dataType was already COERCED to Bool by the resolver
// (the pre-#741 editor persisted Int16 for coil tags and the old driver ignored it). A register
// region with Bool is refused: that pair never read (it threw out of the planner).
if (!IsRegionTypeCompatible(region, dataType)) return false;
if (!TagConfigJson.TryReadEnumStrict(root, "stringByteOrder", ModbusStringByteOrder.HighByteFirst, out var stringByteOrder)) return false;
var bitIndex = (byte)ReadInt(root, "bitIndex");
var stringLength = (ushort)ReadInt(root, "stringLength");
// Guard: String tags require StringLength >= 1. RegisterCount = (StringLength+1)/2,
// so StringLength=0 → 0 registers → spec-illegal FC03/FC04 with quantity=0 → PLC
// returns exception 03. Reject here so the driver surfaces BadNodeIdUnknown rather
@@ -63,14 +84,6 @@ public static class ModbusTagDefinitionFactory
// Guard: BitInRegister tags require BitIndex 0–15. Shift by ≥ 16 on an int overflows
// a ushort mask silently — reads always return false, writes have no effect.
if (dataType == ModbusDataType.BitInRegister && bitIndex > 15) return false;
// isArray / arrayLength — optional keys authored by the typed Modbus tag editor.
// Canonical rule: a tag is an array iff isArray:true AND arrayLength >= 1.
// isArray:false (with any arrayLength) is scalar — the foundation materialises a
// scalar OPC UA node, so returning an array value would cause a shape mismatch.
// A 1-element array (isArray:true, arrayLength:1) IS valid and reads as short[1].
var isArray = ReadBool(root, "isArray");
var arrayLength = ReadInt(root, "arrayLength");
int? arrayCount = (isArray && arrayLength >= 1) ? arrayLength : null;
// "writable" defaults to true when absent (today's value) — makes read-only equipment tags
// authorable. Node-level authz remains the effective write gate.
var writable = TagConfigJson.ReadWritable(root);
@@ -82,7 +95,7 @@ public static class ModbusTagDefinitionFactory
var deadband = ReadOptionalDouble(root, "deadband");
var coalesceProhibited = ReadBool(root, "coalesceProhibited");
def = new ModbusTagDefinition(
Name: rawPath, Region: region, Address: (ushort)address, DataType: dataType,
Name: rawPath, Region: region, Address: address, DataType: dataType,
Writable: writable, ByteOrder: byteOrder, BitIndex: bitIndex, StringLength: stringLength,
StringByteOrder: stringByteOrder, WriteIdempotent: false,
ArrayCount: arrayCount, Deadband: deadband, UnitId: unitId,
@@ -94,6 +107,49 @@ public static class ModbusTagDefinitionFactory
catch (InvalidOperationException) { return false; }
}
/// <summary>
/// Family-unaware acceptance, for the gates that do not know the owning driver's family (the deploy
/// gate's <see cref="Inspect"/>, CSV import, the AdminUI parse consult): accepts a blob SOME family
/// resolves, so the gate is never stricter than the driver it guards. Blobs without an
/// <c>addressString</c> behave exactly like <see cref="FromTagConfig(string,string,out ModbusTagDefinition)"/>.
/// </summary>
/// <param name="tagConfig">The authored equipment-tag TagConfig JSON.</param>
/// <param name="rawPath">The tag's RawPath.</param>
/// <param name="def">The definition under the first family that resolves it.</param>
/// <returns><see langword="true"/> when any family's driver would serve the tag.</returns>
public static bool FromTagConfigAnyFamily(string tagConfig, string rawPath, out ModbusTagDefinition def)
{
foreach (var (family, sub) in ModbusTagAddressResolution.AllFamilies)
if (FromTagConfig(tagConfig, rawPath, family, sub, out def)) return true;
def = null!;
return false;
}
/// <summary>
/// The key the tag editor writes beside the resolved <c>region</c> / <c>address</c> (<c>"addressResolved": true</c>).
/// Display / CSV state only — see <see cref="ModbusTagAddressResolution.AddressResolvedKey"/>.
/// </summary>
public const string AddressResolvedKey = ModbusTagAddressResolution.AddressResolvedKey;
/// <summary>The data type an authored tag gets when its <c>dataType</c> key is absent.</summary>
/// <param name="region">The tag's region.</param>
/// <returns><c>Bool</c> for Coils / DiscreteInputs, <c>Int16</c> for the register regions.</returns>
public static ModbusDataType DefaultDataType(ModbusRegion region)
=> region is ModbusRegion.Coils or ModbusRegion.DiscreteInputs ? ModbusDataType.Bool : ModbusDataType.Int16;
/// <summary>
/// Region/type compatibility, the one rule every authoring path and the driver share: Coils and
/// DiscreteInputs carry only <c>Bool</c>; HoldingRegisters and InputRegisters carry every register
/// type (including <c>BitInRegister</c>) but never <c>Bool</c>.
/// </summary>
/// <param name="region">The tag's region.</param>
/// <param name="dataType">The tag's data type.</param>
/// <returns><see langword="true"/> when the driver can read the pair.</returns>
public static bool IsRegionTypeCompatible(ModbusRegion region, ModbusDataType dataType)
=> region is ModbusRegion.Coils or ModbusRegion.DiscreteInputs
? dataType == ModbusDataType.Bool
: dataType != ModbusDataType.Bool;
/// <summary>
/// Inverse of <see cref="FromTagConfig"/>: serialises a typed definition back to the camelCase
/// <c>TagConfig</c> JSON the mapper reads. Used by authoring paths (the Client CLI) that construct a
@@ -169,7 +225,7 @@ public static class ModbusTagDefinitionFactory
// The accept gate: whatever the driver reads draws nothing. See the remarks. The RAW reference is
// handed to the mapper — the driver reads the stored blob, not a trimmed copy of it.
if (FromTagConfig(reference, InspectionRawPath, out _)) return warnings;
if (FromTagConfigAnyFamily(reference, InspectionRawPath, out _)) return warnings;
try
{
@@ -210,6 +266,26 @@ public static class ModbusTagDefinitionFactory
/// <param name="warnings">The list to append warnings to.</param>
private static void DescribeRejection(JsonElement root, List<string> warnings)
{
// Which part of the blob names the address is the resolver's call, never restated here.
switch (ModbusTagAddressResolution.Classify(root))
{
case ModbusAddressShape.InvalidAddressString:
warnings.Add("Modbus TagConfig 'addressString' is not a string — the tag will not resolve (BadNodeIdUnknown).");
return;
case ModbusAddressShape.StringOnly:
{
// String-only blob: the addressString is what the driver resolves (with its own family).
var addressText = root.GetProperty(ModbusTagAddressResolution.AddressStringKey).GetString()!.Trim();
if (!ModbusAddressParser.TryParse(addressText, out _, out var error))
warnings.Add($"Modbus 'addressString' '{addressText}' does not parse under any PLC family ({error}) — "
+ "the tag will not resolve (BadNodeIdUnknown).");
else
warnings.Add($"Modbus 'addressString' '{addressText}' resolves to an unusable tag (check its type against "
+ "the region) — the tag will not resolve (BadNodeIdUnknown).");
return;
}
}
if (!root.TryGetProperty("region", out _))
{
warnings.Add(
@@ -247,7 +323,17 @@ public static class ModbusTagDefinitionFactory
// Type-dependent guards. The byte/ushort NARROWING is part of the rule, not an accident of the
// mapper: it reads `(byte)bitIndex` / `(ushort)stringLength`, so 256 is bit 0 and is ACCEPTED.
// Describing the un-narrowed value here would call a blob invalid that the driver serves.
if (!TagConfigJson.TryReadEnumStrict(root, "dataType", ModbusDataType.Int16, out var dataType)) return;
var region = TagConfigJson.TryReadEnumStrict(root, "region", ModbusRegion.HoldingRegisters, out var r)
? r : ModbusRegion.HoldingRegisters;
if (!TagConfigJson.TryReadEnumStrict(root, "dataType", DefaultDataType(region), out var dataType)) return;
// Only a register region with Bool is refused (a bit region with any type is coerced to Bool).
if (root.TryGetProperty("region", out _) && region is not (ModbusRegion.Coils or ModbusRegion.DiscreteInputs)
&& !IsRegionTypeCompatible(region, dataType))
{
warnings.Add($"Modbus Bool tags only live in Coils / DiscreteInputs; a {region} tag must use a register type "
+ "(use BitInRegister for a bit of a register) — the tag is rejected (BadNodeIdUnknown).");
}
if (dataType == ModbusDataType.String && (ushort)ReadInt(root, "stringLength") < 1)
{
@@ -0,0 +1,17 @@
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus;
/// <summary>
/// The device answered with well-framed data the tag's codec cannot represent (a non-BCD nibble in a
/// BCD register, for example). The device is reachable, so this is a per-tag
/// <c>BadDecodingError</c> with a once-per-tag Warning — never a comms fault, never a health write.
/// </summary>
public sealed class ModbusDecodeException : Exception
{
/// <summary>Initializes a new instance of the <see cref="ModbusDecodeException"/> class.</summary>
/// <param name="message">A human-readable description.</param>
/// <param name="inner">The codec's own exception.</param>
public ModbusDecodeException(string message, Exception? inner = null)
: base(message, inner)
{
}
}
File diff suppressed because it is too large Load Diff
@@ -78,22 +78,24 @@ public static class ModbusDriverFactoryExtensions
Host = dto.Host!,
Port = dto.Port ?? 502,
UnitId = dto.UnitId ?? 1,
Timeout = TimeSpan.FromMilliseconds(dto.TimeoutMs ?? 2_000),
Timeout = Span(dto.TimeoutMs, dto.Timeout) ?? TimeSpan.FromSeconds(2),
MaxRegistersPerRead = dto.MaxRegistersPerRead ?? 125,
MaxRegistersPerWrite = dto.MaxRegistersPerWrite ?? 123,
MaxCoilsPerRead = dto.MaxCoilsPerRead ?? 2000,
UseFC15ForSingleCoilWrites = dto.UseFC15ForSingleCoilWrites ?? false,
UseFC16ForSingleRegisterWrites = dto.UseFC16ForSingleRegisterWrites ?? false,
DisableFC23 = dto.DisableFC23 ?? false,
UseFC23ForMultiRegisterWrites = dto.UseFC23ForMultiRegisterWrites ?? false,
WriteOnChangeOnly = dto.WriteOnChangeOnly ?? false,
MaxReadGap = dto.MaxReadGap ?? 0,
MaxReadGap = dto.MaxReadGap ?? ModbusDriverOptions.DefaultMaxReadGap,
DisableReadCoalescing = dto.DisableReadCoalescing ?? false,
Family = dto.Family is null ? ModbusFamily.Generic
: ParseEnum<ModbusFamily>(dto.Family, "<driver-level>", driverInstanceId, "Family"),
MelsecSubFamily = dto.MelsecSubFamily is null ? MelsecFamily.Q_L_iQR
: ParseEnum<MelsecFamily>(dto.MelsecSubFamily, "<driver-level>", driverInstanceId, "MelsecSubFamily"),
Transport = dto.Transport is null ? ModbusTransportMode.Tcp
: ParseEnum<ModbusTransportMode>(dto.Transport, "<driver-level>", driverInstanceId, "Transport"),
AutoProhibitReprobeInterval = dto.AutoProhibitReprobeMs is { } reprobeMs ? TimeSpan.FromMilliseconds(reprobeMs) : null,
AutoProhibitReprobeInterval = Span(dto.AutoProhibitReprobeMs, dto.AutoProhibitReprobeInterval),
AutoReconnect = dto.AutoReconnect ?? true,
// v3: the deploy artifact (Wave C) populates rawTags with the cluster's authored raw Modbus
// tags. The driver maps each RawTagEntry's TagConfig blob through ModbusTagDefinitionFactory
@@ -103,22 +105,22 @@ public static class ModbusDriverFactoryExtensions
Probe = new ModbusProbeOptions
{
Enabled = dto.Probe?.Enabled ?? true,
Interval = TimeSpan.FromMilliseconds(dto.Probe?.IntervalMs ?? 5_000),
Timeout = TimeSpan.FromMilliseconds(dto.Probe?.TimeoutMs ?? 2_000),
Interval = Span(dto.Probe?.IntervalMs, dto.Probe?.Interval) ?? TimeSpan.FromSeconds(5),
Timeout = Span(dto.Probe?.TimeoutMs, dto.Probe?.Timeout) ?? TimeSpan.FromSeconds(2),
ProbeAddress = dto.Probe?.ProbeAddress ?? 0,
},
KeepAlive = dto.KeepAlive is null ? new ModbusKeepAliveOptions() : new ModbusKeepAliveOptions
{
Enabled = dto.KeepAlive.Enabled ?? true,
Time = TimeSpan.FromMilliseconds(dto.KeepAlive.TimeMs ?? 30_000),
Interval = TimeSpan.FromMilliseconds(dto.KeepAlive.IntervalMs ?? 10_000),
Time = Span(dto.KeepAlive.TimeMs, dto.KeepAlive.Time) ?? TimeSpan.FromSeconds(30),
Interval = Span(dto.KeepAlive.IntervalMs, dto.KeepAlive.Interval) ?? TimeSpan.FromSeconds(10),
RetryCount = dto.KeepAlive.RetryCount ?? 3,
},
IdleDisconnectTimeout = dto.IdleDisconnectMs is { } ms ? TimeSpan.FromMilliseconds(ms) : null,
IdleDisconnectTimeout = Span(dto.IdleDisconnectMs, dto.IdleDisconnectTimeout),
Reconnect = dto.Reconnect is null ? new ModbusReconnectOptions() : new ModbusReconnectOptions
{
InitialDelay = TimeSpan.FromMilliseconds(dto.Reconnect.InitialDelayMs ?? 0),
MaxDelay = TimeSpan.FromMilliseconds(dto.Reconnect.MaxDelayMs ?? 30_000),
InitialDelay = Span(dto.Reconnect.InitialDelayMs, dto.Reconnect.InitialDelay) ?? TimeSpan.Zero,
MaxDelay = Span(dto.Reconnect.MaxDelayMs, dto.Reconnect.MaxDelay) ?? TimeSpan.FromSeconds(30),
BackoffMultiplier = dto.Reconnect.BackoffMultiplier ?? 2.0,
},
};
@@ -126,6 +128,23 @@ public static class ModbusDriverFactoryExtensions
return options;
}
/// <summary>
/// One timing knob, accepted in BOTH shapes the config can carry: the <c>*Ms</c> integer keys (CLI,
/// seed data, hand-authored JSON) and the TimeSpan-string keys the AdminUI driver form emits — it
/// serialises <see cref="ModbusDriverOptions"/> itself, so it writes <c>"timeout":"00:00:07"</c>, not
/// <c>timeoutMs</c>. Before this every timing field on that form was silently ignored. The
/// millisecond key wins when both are present; an unparseable TimeSpan string is ignored (the
/// previous behaviour for these keys), never a reason to fail the driver.
/// </summary>
private static TimeSpan? Span(int? milliseconds, JsonElement? timeSpan)
{
if (milliseconds is { } ms) return TimeSpan.FromMilliseconds(ms);
if (timeSpan is { ValueKind: JsonValueKind.String } e
&& TimeSpan.TryParse(e.GetString(), System.Globalization.CultureInfo.InvariantCulture, out var parsed))
return parsed;
return null;
}
private static T ParseEnum<T>(string? raw, string? tagName, string driverInstanceId, string field) where T : struct, Enum
{
if (string.IsNullOrWhiteSpace(raw))
@@ -155,6 +174,8 @@ public static class ModbusDriverFactoryExtensions
public byte? UnitId { get; init; }
/// <summary>Gets or sets the timeout in milliseconds.</summary>
public int? TimeoutMs { get; init; }
/// <summary>Gets or sets the timeout as a TimeSpan string (the AdminUI form's shape).</summary>
public JsonElement? Timeout { get; init; }
/// <summary>Gets or sets the maximum number of registers per read operation.</summary>
public ushort? MaxRegistersPerRead { get; init; }
/// <summary>Gets or sets the maximum number of registers per write operation.</summary>
@@ -167,10 +188,14 @@ public static class ModbusDriverFactoryExtensions
public bool? UseFC16ForSingleRegisterWrites { get; init; }
/// <summary>Gets or sets a value indicating whether to disable function code 23.</summary>
public bool? DisableFC23 { get; init; }
/// <summary>Gets or sets a value indicating whether multi-register writes opt in to FC23 write + read-back.</summary>
public bool? UseFC23ForMultiRegisterWrites { get; init; }
/// <summary>Gets or sets a value indicating whether to write only on change.</summary>
public bool? WriteOnChangeOnly { get; init; }
/// <summary>Gets or sets the maximum gap between register addresses for coalescing.</summary>
public ushort? MaxReadGap { get; init; }
/// <summary>Gets or sets a value indicating whether block-read coalescing is turned off entirely.</summary>
public bool? DisableReadCoalescing { get; init; }
/// <summary>Gets or sets the Modbus family type.</summary>
public string? Family { get; init; }
/// <summary>Gets or sets the Melsec subfamily.</summary>
@@ -179,6 +204,8 @@ public static class ModbusDriverFactoryExtensions
public string? Transport { get; init; }
/// <summary>Gets or sets the automatic prohibition reprobei interval in milliseconds.</summary>
public int? AutoProhibitReprobeMs { get; init; }
/// <summary>Gets or sets the re-probe interval as a TimeSpan string (the AdminUI form's shape).</summary>
public JsonElement? AutoProhibitReprobeInterval { get; init; }
/// <summary>Gets or sets a value indicating whether automatic reconnection is enabled.</summary>
public bool? AutoReconnect { get; init; }
/// <summary>Gets or sets the authored raw tags (RawPath + TagConfig blob + WriteIdempotent) the
@@ -191,6 +218,8 @@ public static class ModbusDriverFactoryExtensions
public ModbusKeepAliveDto? KeepAlive { get; init; }
/// <summary>Gets or sets the idle disconnect timeout in milliseconds.</summary>
public int? IdleDisconnectMs { get; init; }
/// <summary>Gets or sets the idle disconnect timeout as a TimeSpan string (the AdminUI form's shape).</summary>
public JsonElement? IdleDisconnectTimeout { get; init; }
/// <summary>Gets or sets the reconnect configuration.</summary>
public ModbusReconnectDto? Reconnect { get; init; }
}
@@ -203,6 +232,10 @@ public static class ModbusDriverFactoryExtensions
public int? TimeMs { get; init; }
/// <summary>Gets or sets the keep-alive interval in milliseconds.</summary>
public int? IntervalMs { get; init; }
/// <summary>Gets or sets the keep-alive time as a TimeSpan string (the AdminUI form's shape).</summary>
public JsonElement? Time { get; init; }
/// <summary>Gets or sets the keep-alive interval as a TimeSpan string (the AdminUI form's shape).</summary>
public JsonElement? Interval { get; init; }
/// <summary>Gets or sets the retry count.</summary>
public int? RetryCount { get; init; }
}
@@ -213,6 +246,10 @@ public static class ModbusDriverFactoryExtensions
public int? InitialDelayMs { get; init; }
/// <summary>Gets or sets the maximum reconnect delay in milliseconds.</summary>
public int? MaxDelayMs { get; init; }
/// <summary>Gets or sets the initial reconnect delay as a TimeSpan string (the AdminUI form's shape).</summary>
public JsonElement? InitialDelay { get; init; }
/// <summary>Gets or sets the maximum reconnect delay as a TimeSpan string (the AdminUI form's shape).</summary>
public JsonElement? MaxDelay { get; init; }
/// <summary>Gets or sets the backoff multiplier for exponential reconnect delays.</summary>
public double? BackoffMultiplier { get; init; }
}
@@ -225,6 +262,10 @@ public static class ModbusDriverFactoryExtensions
public int? IntervalMs { get; init; }
/// <summary>Gets or sets the probe timeout in milliseconds.</summary>
public int? TimeoutMs { get; init; }
/// <summary>Gets or sets the probe interval as a TimeSpan string (the AdminUI form's shape).</summary>
public JsonElement? Interval { get; init; }
/// <summary>Gets or sets the probe timeout as a TimeSpan string (the AdminUI form's shape).</summary>
public JsonElement? Timeout { get; init; }
/// <summary>Gets or sets the probe register address.</summary>
public ushort? ProbeAddress { get; init; }
}
@@ -93,9 +93,10 @@ public static class ModbusRtuFraming
// Exception frame: excCode(1) + CRC(2).
trailing = 1 + 2;
}
else if (fc is 0x01 or 0x02 or 0x03 or 0x04)
else if (fc is 0x01 or 0x02 or 0x03 or 0x04 or 0x17)
{
// Read response: byteCount(1) then byteCount data bytes + CRC(2).
// Read response (and FC23 Read/Write Multiple, whose reply is a read): byteCount(1) then
// byteCount data bytes + CRC(2).
var bc = new byte[1];
await ReadExactlyAsync(stream, bc, ct).ConfigureAwait(false);
var byteCount = bc[0];
@@ -105,9 +106,9 @@ public static class ModbusRtuFraming
}
else
{
// Fixed 4-byte echo: correct for the write FCs this driver emits (05/06/0F/10). A future
// variable-length response FC outside 01-04 (e.g. FC23) would be mis-sized here and
// surface as a desync — revisit the FC-shape table if the driver starts emitting one.
// Fixed 4-byte echo: correct for the write FCs this driver emits (05/06/0F/10). FC23 is
// variable-length and handled above; any NEW variable-length response FC must be added there
// or it is mis-sized here and surfaces as a desync.
trailing = 4 + 2;
}
@@ -46,7 +46,7 @@ public sealed class ModbusRtuOverTcpTransport : IModbusTransport
private readonly Stream? _testStream;
private readonly TimeSpan _timeout;
private readonly SemaphoreSlim _gate = new(1, 1);
private bool _disposed;
private int _disposed;
/// <summary>Initializes a new instance of the <see cref="ModbusRtuOverTcpTransport"/> class.</summary>
/// <param name="host">The host address or hostname of the Modbus gateway.</param>
@@ -90,8 +90,8 @@ public sealed class ModbusRtuOverTcpTransport : IModbusTransport
/// <inheritdoc />
public async Task<byte[]> SendAsync(byte unitId, byte[] pdu, CancellationToken ct)
{
if (_disposed) throw new ObjectDisposedException(nameof(ModbusRtuOverTcpTransport));
if (CurrentStream is null) throw new InvalidOperationException("Transport not connected");
if (Volatile.Read(ref _disposed) != 0) throw new ObjectDisposedException(nameof(ModbusRtuOverTcpTransport));
if (_life is null && _testStream is null) throw new InvalidOperationException("Transport not connected");
// Single-flight: RTU carries no transaction id, so at most one transaction may be on the bus
// at a time — serialise every send/receive under the gate.
@@ -101,11 +101,14 @@ public sealed class ModbusRtuOverTcpTransport : IModbusTransport
// Proactive idle-disconnect (real socket only): if the socket has been quiet longer than
// the configured threshold, tear it down + reconnect before this PDU lands. Defends
// against silent NAT / firewall reaping.
if (_life is not null && _life.ShouldReconnectForIdle())
{
if (_life is not null && _life.Stream is not null && _life.ShouldReconnectForIdle())
await _life.TearDownAsync().ConfigureAwait(false);
await _life.ConnectWithBackoffAsync(ct).ConfigureAwait(false);
}
// Self-heal (real socket only): a socket torn down by an earlier failure is re-dialled here,
// once, throttled by the backoff window — same contract as ModbusTcpTransport.
var freshlyConnected = _life is not null && _life.Stream is null;
if (freshlyConnected)
await _life!.EnsureConnectedAsync(ct).ConfigureAwait(false);
try
{
@@ -113,14 +116,16 @@ public sealed class ModbusRtuOverTcpTransport : IModbusTransport
_life?.MarkSuccess();
return result;
}
catch (Exception ex) when (_life is not null && _life.AutoReconnect && ModbusSocketLifecycle.IsSocketLevelFailure(ex))
catch (Exception ex) when (!freshlyConnected && _life is not null && _life.AutoReconnect
&& !ct.IsCancellationRequested
&& ModbusSocketLifecycle.IsSocketLevelFailure(ex))
{
// Mid-transaction drop: tear down the dead socket, reconnect (with backoff if
// configured), resend. Single retry — a second failure propagates so health/status
// reflect reality. Never reached in the ForTest seam (a raw injected stream has no
// lifecycle to reconnect).
// Mid-transaction drop: tear down the dead socket, make ONE throttled reconnect attempt
// (never a sleep inside the gate), resend. Single retry — a second failure propagates so
// health/status reflect reality. Never reached in the ForTest seam (a raw injected stream
// has no lifecycle to reconnect).
await _life.TearDownAsync().ConfigureAwait(false);
await _life.ConnectWithBackoffAsync(ct).ConfigureAwait(false);
await _life.EnsureConnectedAsync(ct).ConfigureAwait(false);
var result = await SendOnceAsync(unitId, pdu, ct).ConfigureAwait(false);
_life.MarkSuccess();
return result;
@@ -182,9 +187,8 @@ public sealed class ModbusRtuOverTcpTransport : IModbusTransport
/// <returns>A task that represents the asynchronous operation.</returns>
public async ValueTask DisposeAsync()
{
if (_disposed) return;
_disposed = true;
if (Interlocked.Exchange(ref _disposed, 1) != 0) return;
if (_life is not null) await _life.DisposeAsync().ConfigureAwait(false);
_gate.Dispose();
// _gate is deliberately NOT disposed: a SendAsync still in flight at teardown releases it in its finally.
}
}
@@ -4,8 +4,8 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus;
/// <summary>
/// Owns the raw TCP socket lifecycle shared by every Modbus-over-TCP transport (MBAP and
/// RTU-over-TCP alike): the IPv4-preference connect, OS-level keep-alive, geometric-backoff
/// reconnect, idle-disconnect tracking, and teardown. It exposes the current
/// RTU-over-TCP alike): the IPv4-preference connect, OS-level keep-alive, throttled
/// reconnect (one attempt per geometric-backoff window), idle-disconnect tracking, and teardown. It exposes the current
/// <see cref="NetworkStream"/> so the composing transport can drive its own framing on top —
/// the lifecycle knows nothing about MBAP / RTU wire layout.
/// </summary>
@@ -14,7 +14,7 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus;
/// Extracted verbatim from <see cref="ModbusTcpTransport"/> so the connect / reconnect /
/// keep-alive / idle behaviour is written once and composed by both transports. The
/// constructor is connection-free — all socket I/O happens in <see cref="ConnectAsync"/> /
/// <see cref="ConnectWithBackoffAsync"/>.
/// <see cref="EnsureConnectedAsync"/> (one throttled attempt per call — never a sleep loop).
/// </para>
/// <para>
/// Why keep-alive matters for DL205/DL260: the AutomationDirect H2-ECOM100 does NOT send
@@ -37,6 +37,9 @@ public sealed class ModbusSocketLifecycle : IAsyncDisposable
private NetworkStream? _stream;
private DateTime _lastSuccessUtc = DateTime.UtcNow;
/// <summary>Monotonic millisecond clock for the reconnect throttle (test seam).</summary>
internal Func<long> NowMs { get; set; } = () => Environment.TickCount64;
/// <summary>Initializes a new instance of the <see cref="ModbusSocketLifecycle"/> class.</summary>
/// <param name="host">The host address or hostname of the Modbus server.</param>
/// <param name="port">The TCP port of the Modbus server.</param>
@@ -71,63 +74,116 @@ public sealed class ModbusSocketLifecycle : IAsyncDisposable
/// <returns>A task representing the asynchronous connection operation.</returns>
public async Task ConnectAsync(CancellationToken ct)
{
// Resolve the host explicitly + prefer IPv4. .NET's TcpClient default-constructor is
// dual-stack (IPv6 first, fallback to IPv4) — but most Modbus TCP devices (PLCs and
// simulators like pymodbus) bind 0.0.0.0 only, so the IPv6 attempt times out and we
// burn the entire ConnectAsync budget before even trying IPv4. Resolving first +
// dialing the IPv4 address directly sidesteps that.
var addresses = await System.Net.Dns.GetHostAddressesAsync(_host, ct).ConfigureAwait(false);
var ipv4 = System.Linq.Enumerable.FirstOrDefault(addresses,
a => a.AddressFamily == System.Net.Sockets.AddressFamily.InterNetwork);
var target = ipv4 ?? (addresses.Length > 0 ? addresses[0] : System.Net.IPAddress.Loopback);
_client = new TcpClient(target.AddressFamily);
EnableKeepAlive(_client, _keepAlive);
// A previous socket (failed, torn down or idle-reaped) is released FIRST: overwriting _client
// used to strand the old TcpClient for the finalizer — up to nine per backoff loop.
ThrowIfDisposed();
await TearDownAsync().ConfigureAwait(false);
// DNS resolution runs under the same per-op deadline as the TCP connect: an unresolvable or
// slow resolver must not hold the transport gate for the OS resolver's own (much longer) timeout.
using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
cts.CancelAfter(_timeout);
await _client.ConnectAsync(target, _port, cts.Token).ConfigureAwait(false);
_stream = _client.GetStream();
_lastSuccessUtc = DateTime.UtcNow;
TcpClient? client = null;
try
{
// Resolve the host explicitly + prefer IPv4. .NET's TcpClient default-constructor is
// dual-stack (IPv6 first, fallback to IPv4) — but most Modbus TCP devices (PLCs and
// simulators like pymodbus) bind 0.0.0.0 only, so the IPv6 attempt times out and we
// burn the entire ConnectAsync budget before even trying IPv4. Resolving first +
// dialing the IPv4 address directly sidesteps that.
var addresses = await System.Net.Dns.GetHostAddressesAsync(_host, cts.Token).ConfigureAwait(false);
var ipv4 = System.Linq.Enumerable.FirstOrDefault(addresses,
a => a.AddressFamily == System.Net.Sockets.AddressFamily.InterNetwork);
var target = ipv4 ?? (addresses.Length > 0 ? addresses[0] : System.Net.IPAddress.Loopback);
client = new TcpClient(target.AddressFamily);
EnableKeepAlive(client, _keepAlive);
await client.ConnectAsync(target, _port, cts.Token).ConfigureAwait(false);
ThrowIfDisposed(); // disposed while dialling — the finally releases the fresh client
_stream = client.GetStream();
_client = client;
client = null; // ownership transferred
if (Volatile.Read(ref _disposed) != 0)
{
// A DisposeAsync that ran between the check above and the publish has already torn down;
// tear the fresh socket down ourselves so it is never stranded for the finalizer.
await TearDownAsync().ConfigureAwait(false);
throw new ObjectDisposedException(nameof(ModbusSocketLifecycle));
}
_lastSuccessUtc = DateTime.UtcNow;
}
catch (OperationCanceledException) when (!ct.IsCancellationRequested)
{
// The per-op deadline, not the caller: normalise to a socket-level failure so every
// classifier treats a connect timeout as a comms fault.
throw new ModbusTransportUnavailableException(
$"Modbus connect to {_host}:{_port} timed out after {_timeout.TotalMilliseconds:0}ms");
}
finally
{
// A failed attempt disposes its own client — never leave it for the finalizer.
client?.Dispose();
}
}
// Reconnect throttle state. Guarded by the composing transport's single-flight gate (every caller of
// EnsureConnectedAsync holds it), so no extra lock is needed.
private long _connectNotBeforeMs;
private TimeSpan _failureDelay = TimeSpan.Zero;
/// <summary>
/// Connect attempt with the configured geometric backoff. The first attempt fires after
/// <see cref="ModbusReconnectOptions.InitialDelay"/> (default zero — immediate); each
/// subsequent attempt sleeps for the previous delay times <c>BackoffMultiplier</c>,
/// capped at <c>MaxDelay</c>. Caller's cancellation token aborts the loop.
/// Ensures a live socket for the next transaction with AT MOST ONE connect attempt, throttled by the
/// configured geometric backoff: after a failed attempt, calls inside the backoff window fail fast
/// with <see cref="ModbusTransportUnavailableException"/> and never dial. The window starts at
/// <see cref="ModbusReconnectOptions.InitialDelay"/> (floored at 1 s) and grows by
/// <c>BackoffMultiplier</c> per consecutive failure up to <c>MaxDelay</c>; a successful connect
/// resets it. Never sleeps — the composing transport calls it inside its gate, and a sleep there
/// used to queue every read, write and probe behind one dead socket for up to ~81 s.
/// </summary>
/// <param name="ct">A cancellation token to observe for cancellation.</param>
/// <returns>A task representing the asynchronous reconnect operation.</returns>
public async Task ConnectWithBackoffAsync(CancellationToken ct)
/// <returns>A task that completes when a socket is live.</returns>
/// <exception cref="ModbusTransportUnavailableException">Auto-reconnect is off, or the call is inside the backoff window.</exception>
public async Task EnsureConnectedAsync(CancellationToken ct)
{
var delay = _reconnect.InitialDelay;
var attempt = 0;
while (true)
if (_stream is not null) return;
if (!_autoReconnect)
throw new ModbusTransportUnavailableException(
$"Modbus transport to {_host}:{_port} is not connected and auto-reconnect is disabled");
var now = NowMs();
if (now < _connectNotBeforeMs)
throw new ModbusTransportUnavailableException(
$"Modbus reconnect to {_host}:{_port} throttled for another {_connectNotBeforeMs - now}ms after a failed connect");
try
{
if (delay > TimeSpan.Zero)
await Task.Delay(delay, ct).ConfigureAwait(false);
try
{
await ConnectAsync(ct).ConfigureAwait(false);
return;
}
catch (Exception ex) when (IsSocketLevelFailure(ex) && _autoReconnect)
{
attempt++;
// Geometric growth, capped. Use Math.Min on ticks so we don't overflow with
// pathological multipliers / long deployments.
var nextTicks = (long)(Math.Max(delay.Ticks, TimeSpan.FromMilliseconds(100).Ticks) * _reconnect.BackoffMultiplier);
delay = TimeSpan.FromTicks(Math.Min(nextTicks, _reconnect.MaxDelay.Ticks));
if (attempt >= 10)
{
// Bail after 10 attempts to surface persistent failure to the caller. With
// the default backoff (1s base, 2.0x mult, 30s cap) this is roughly 4 minutes
// of attempts; with InitialDelay=0 it's immediate up to the same cap.
throw;
}
}
await ConnectAsync(ct).ConfigureAwait(false);
_failureDelay = TimeSpan.Zero;
_connectNotBeforeMs = 0;
}
catch (Exception) when (!ct.IsCancellationRequested)
{
_failureDelay = NextDelay(_failureDelay);
_connectNotBeforeMs = NowMs() + (long)_failureDelay.TotalMilliseconds;
throw;
}
}
/// <summary>Minimum reconnect window after a failed connect — a dead device is dialled at most once per second.</summary>
private static readonly TimeSpan MinReconnectWindow = TimeSpan.FromSeconds(1);
private TimeSpan NextDelay(TimeSpan previous)
{
if (previous <= TimeSpan.Zero)
{
var first = _reconnect.InitialDelay > MinReconnectWindow ? _reconnect.InitialDelay : MinReconnectWindow;
return first < _reconnect.MaxDelay ? first : _reconnect.MaxDelay > TimeSpan.Zero ? _reconnect.MaxDelay : first;
}
// Geometric growth, capped. Math.Min on ticks so a pathological multiplier cannot overflow.
var multiplier = _reconnect.BackoffMultiplier >= 1.0 ? _reconnect.BackoffMultiplier : 1.0;
var nextTicks = previous.Ticks * multiplier;
var cap = _reconnect.MaxDelay > TimeSpan.Zero ? _reconnect.MaxDelay.Ticks : long.MaxValue;
return TimeSpan.FromTicks(nextTicks >= cap ? cap : (long)nextTicks);
}
/// <summary>
@@ -211,11 +267,16 @@ public sealed class ModbusSocketLifecycle : IAsyncDisposable
/// <returns>A task that represents the asynchronous operation.</returns>
public async ValueTask DisposeAsync()
{
try
{
if (_stream is not null) await _stream.DisposeAsync().ConfigureAwait(false);
}
catch { /* best-effort */ }
_client?.Dispose();
// Latch first: a SendAsync still in flight (the driver tearing down under it) must not reconnect
// a fresh socket after this point — ConnectAsync checks the latch both before and after dialling.
Volatile.Write(ref _disposed, 1);
await TearDownAsync().ConfigureAwait(false);
}
private int _disposed;
private void ThrowIfDisposed()
{
if (Volatile.Read(ref _disposed) != 0) throw new ObjectDisposedException(nameof(ModbusSocketLifecycle));
}
}
@@ -36,7 +36,7 @@ public sealed class ModbusTcpTransport : IModbusTransport
private readonly TimeSpan _timeout;
private readonly SemaphoreSlim _gate = new(1, 1);
private ushort _nextTx;
private bool _disposed;
private int _disposed;
/// <summary>Initializes a new instance of the <see cref="ModbusTcpTransport"/> class.</summary>
/// <param name="host">The host address or hostname of the Modbus server.</param>
@@ -56,14 +56,16 @@ public sealed class ModbusTcpTransport : IModbusTransport
_life = new ModbusSocketLifecycle(host, port, timeout, autoReconnect, keepAlive, idleDisconnect, reconnect);
}
/// <summary>The socket lifecycle this transport composes (test seam for its clock).</summary>
internal ModbusSocketLifecycle Lifecycle => _life;
/// <inheritdoc />
public Task ConnectAsync(CancellationToken ct) => _life.ConnectAsync(ct);
/// <inheritdoc />
public async Task<byte[]> SendAsync(byte unitId, byte[] pdu, CancellationToken ct)
{
if (_disposed) throw new ObjectDisposedException(nameof(ModbusTcpTransport));
if (_life.Stream is null) throw new InvalidOperationException("Transport not connected");
if (Volatile.Read(ref _disposed) != 0) throw new ObjectDisposedException(nameof(ModbusTcpTransport));
await _gate.WaitAsync(ct).ConfigureAwait(false);
try
@@ -72,11 +74,16 @@ public sealed class ModbusTcpTransport : IModbusTransport
// threshold, tear it down + reconnect before this PDU lands. Defends against silent
// NAT / firewall reaping where the socket looks alive locally but the upstream side
// dropped it minutes ago.
if (_life.ShouldReconnectForIdle())
{
if (_life.Stream is not null && _life.ShouldReconnectForIdle())
await _life.TearDownAsync().ConfigureAwait(false);
await _life.ConnectWithBackoffAsync(ct).ConfigureAwait(false);
}
// Self-heal: a socket torn down by an earlier failure (two lost replies in a row, a refused
// reconnect) is re-dialled HERE, once, throttled by the backoff window. This used to throw
// "Transport not connected" before the gate, so the transport never recovered on its own and
// every call failed fast until the 90 s comms-loss watchdog re-initialised the whole driver.
var freshlyConnected = _life.Stream is null;
if (freshlyConnected)
await _life.EnsureConnectedAsync(ct).ConfigureAwait(false);
try
{
@@ -84,13 +91,16 @@ public sealed class ModbusTcpTransport : IModbusTransport
_life.MarkSuccess();
return result;
}
catch (Exception ex) when (_life.AutoReconnect && ModbusSocketLifecycle.IsSocketLevelFailure(ex))
catch (Exception ex) when (!freshlyConnected && _life.AutoReconnect
&& !ct.IsCancellationRequested
&& ModbusSocketLifecycle.IsSocketLevelFailure(ex))
{
// Mid-transaction drop: tear down the dead socket, reconnect (with backoff if
// configured), resend. Single retry — if it fails again, let it propagate so
// health/status reflect reality.
// Mid-transaction drop on a socket that had been live: tear down the dead socket, make ONE
// throttled reconnect attempt (never a sleep inside the gate), resend. Single retry — if it
// fails again, let it propagate so health/status reflect reality. A socket this call just
// dialled is not retried: it proved nothing about a stale connection.
await _life.TearDownAsync().ConfigureAwait(false);
await _life.ConnectWithBackoffAsync(ct).ConfigureAwait(false);
await _life.EnsureConnectedAsync(ct).ConfigureAwait(false);
var result = await SendOnceAsync(unitId, pdu, ct).ConfigureAwait(false);
_life.MarkSuccess();
return result;
@@ -162,10 +172,22 @@ public sealed class ModbusTcpTransport : IModbusTransport
var respPdu = new byte[respLen - 1];
await ReadExactlyAsync(stream, respPdu, cts.Token).ConfigureAwait(false);
// Length check before indexing: a PDU must carry at least its function code, and an exception
// PDU its exception code too. Indexing past the end used to throw IndexOutOfRangeException —
// not an IOException, so no teardown/retry, and a malformed reply read as a driver bug.
if (respPdu.Length < 1)
throw new ModbusTransportDesyncException(
ModbusTransportDesyncException.DesyncReason.TruncatedFrame,
"Modbus response carried no PDU (MBAP length 1)");
// Exception PDU: function code has high bit set. This is a well-formed protocol-level
// error — the socket is still coherent, so it MUST propagate without teardown.
if ((respPdu[0] & 0x80) != 0)
{
if (respPdu.Length < 2)
throw new ModbusTransportDesyncException(
ModbusTransportDesyncException.DesyncReason.TruncatedFrame,
$"Modbus exception response fc=0x{respPdu[0]:X2} carried no exception code");
var fc = (byte)(respPdu[0] & 0x7F);
var ex = respPdu[1];
throw new ModbusException(fc, ex, $"Modbus exception fc={fc} code={ex}");
@@ -206,9 +228,8 @@ public sealed class ModbusTcpTransport : IModbusTransport
/// <returns>A task that represents the asynchronous operation.</returns>
public async ValueTask DisposeAsync()
{
if (_disposed) return;
_disposed = true;
if (Interlocked.Exchange(ref _disposed, 1) != 0) return;
await _life.DisposeAsync().ConfigureAwait(false);
_gate.Dispose();
// _gate is deliberately NOT disposed: a SendAsync still in flight at teardown releases it in its finally.
}
}
@@ -0,0 +1,23 @@
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus;
/// <summary>
/// Raised when a Modbus transport has no live socket and cannot get one for this call: the one
/// in-gate connect attempt failed, the reconnect is inside its backoff window, or auto-reconnect is
/// disabled. It says the ENDPOINT is unreachable, so the driver fails the rest of the batch fast
/// instead of paying the same failure once per tag.
/// </summary>
/// <remarks>
/// Derives from <see cref="IOException"/> so every existing socket-level classifier
/// (<c>ModbusSocketLifecycle.IsSocketLevelFailure</c>, the driver's comms-fault mapping) treats it as
/// a communication failure without extra plumbing.
/// </remarks>
public sealed class ModbusTransportUnavailableException : IOException
{
/// <summary>Initializes a new instance of the <see cref="ModbusTransportUnavailableException"/> class.</summary>
/// <param name="message">A human-readable description.</param>
/// <param name="inner">The originating exception, if any.</param>
public ModbusTransportUnavailableException(string message, Exception? inner = null)
: base(message, inner)
{
}
}
@@ -0,0 +1,21 @@
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus;
/// <summary>
/// The driver refused a write BEFORE anything reached the wire (wrong array length, a payload over
/// <c>MaxRegistersPerWrite</c>, an unsupported shape). Carries the OPC UA status the write returns;
/// it never touches driver health — nothing was sent.
/// </summary>
public sealed class ModbusWriteRefusedException : Exception
{
/// <summary>Initializes a new instance of the <see cref="ModbusWriteRefusedException"/> class.</summary>
/// <param name="statusCode">The OPC UA status the write returns.</param>
/// <param name="message">A human-readable description.</param>
public ModbusWriteRefusedException(uint statusCode, string message)
: base(message)
{
StatusCode = statusCode;
}
/// <summary>Gets the OPC UA status the write returns.</summary>
public uint StatusCode { get; }
}
@@ -149,6 +149,16 @@ internal sealed class MqttBrowseSession : IRebirthCapableBrowseSession
/// </summary>
private static readonly UTF8Encoding Utf8Strict = new(encoderShouldEmitUTF8Identifier: false, throwOnInvalidBytes: true);
/// <summary>
/// Ceiling on the rebirth request's wait, below the AdminUI's 20 s <c>BrowserSessionService.PerCallTimeout</c>
/// (review minor 4): a configured window above it could never take effect there, and a rebirth aimed at an
/// offline edge node must not hold the operator's button for the whole window.
/// </summary>
internal static readonly TimeSpan MaxRebirthWait = TimeSpan.FromSeconds(15);
/// <summary>The quiet spell that ends a rebirth request's wait once its births have started arriving.</summary>
private static readonly TimeSpan RebirthQuietInterval = TimeSpan.FromMilliseconds(500);
/// <summary>Bound on the courtesy DISCONNECT issued at dispose — teardown never hangs on a dead broker.</summary>
private static readonly TimeSpan DisconnectTimeout = TimeSpan.FromSeconds(5);
@@ -174,6 +184,7 @@ internal sealed class MqttBrowseSession : IRebirthCapableBrowseSession
private readonly ConcurrentQueue<string> _publishedTopics = new();
private int _disposed;
private long _birthCount;
private long _lastUsedTicksUtc = DateTime.UtcNow.Ticks;
private int _nodeCount;
private int _oversized;
@@ -195,9 +206,16 @@ internal sealed class MqttBrowseSession : IRebirthCapableBrowseSession
/// <param name="client">The connected MQTT client; disconnected and disposed by <see cref="DisposeAsync"/>.</param>
/// <param name="logger">Optional logger; never receives credentials or payload bodies.</param>
/// <param name="nodeCap">Cap on recorded nodes; defaults to <see cref="DefaultNodeCap"/>.</param>
internal MqttBrowseSession(MqttMode mode, IMqttClient? client, ILogger? logger, int nodeCap = DefaultNodeCap)
/// <param name="birthWindow">
/// The Sparkplug birth-observation window: how long <see cref="RequestRebirthAsync"/> waits for the
/// births it asked for. <see cref="TimeSpan.Zero"/> (the test default) returns as soon as the NCMDs
/// are published.
/// </param>
internal MqttBrowseSession(
MqttMode mode, IMqttClient? client, ILogger? logger, int nodeCap = DefaultNodeCap, TimeSpan birthWindow = default)
{
Mode = mode;
BirthWindow = birthWindow;
_client = client;
_logger = logger;
_nodeCap = nodeCap;
@@ -211,6 +229,59 @@ internal sealed class MqttBrowseSession : IRebirthCapableBrowseSession
/// </summary>
internal int PublishCountForTest => Volatile.Read(ref _publishCount);
/// <summary>The Sparkplug birth-observation window this session was opened with (R06:M-4).</summary>
internal TimeSpan BirthWindow { get; }
/// <summary>How long a rebirth request waits for its births: the window, capped at <see cref="MaxRebirthWait"/>.</summary>
internal TimeSpan RebirthWait => BirthWindow < MaxRebirthWait ? BirthWindow : MaxRebirthWait;
/// <summary>How many NBIRTH/DBIRTH messages this session has observed — the settle loop's signal.</summary>
internal long BirthCount => Interlocked.Read(ref _birthCount);
/// <summary>
/// Waits until birth traffic has gone quiet for one <paramref name="quiet"/> interval, bounded by
/// <paramref name="window"/> — the <c>UntilStable</c> cadence of the birth-observation window.
/// </summary>
/// <param name="birthCount">Reads the running birth count.</param>
/// <param name="baseline">The count before the wait began.</param>
/// <param name="awaitFirstBirth">
/// When true, quiet only counts once at least one birth has arrived past <paramref name="baseline"/>
/// — the shape a rebirth request needs, since its answer takes a broker round-trip to arrive.
/// </param>
/// <param name="quiet">The quiet spell that ends the wait.</param>
/// <param name="window">The hard ceiling; non-positive returns at once.</param>
/// <param name="cancellationToken">Cancels the wait.</param>
/// <returns>A task that completes when the births have settled or the window has elapsed.</returns>
internal static async Task SettleBirthsAsync(
Func<long> birthCount,
long baseline,
bool awaitFirstBirth,
TimeSpan quiet,
TimeSpan window,
CancellationToken cancellationToken)
{
if (window <= TimeSpan.Zero) return;
var clock = System.Diagnostics.Stopwatch.StartNew();
var last = baseline;
var sawBirth = !awaitFirstBirth;
while (clock.Elapsed < window)
{
var step = window - clock.Elapsed;
await Task.Delay(step < quiet ? step : quiet, cancellationToken).ConfigureAwait(false);
var now = birthCount();
if (now != last)
{
last = now;
sawBirth = true;
continue;
}
if (sawBirth) return;
}
}
/// <summary>The topics this session published, in order — the discriminating view of the above.</summary>
internal IReadOnlyList<string> PublishedTopicsForTest => [.. _publishedTopics];
@@ -380,16 +451,25 @@ internal sealed class MqttBrowseSession : IRebirthCapableBrowseSession
var mapped = metric.DataType?.ToDriverDataType();
var sparkplugName = metric.DataType?.ToString() ?? nameof(SparkplugDataType.Unknown);
var isArray = metric.DataType?.IsSparkplugArray() ?? false;
var descriptor = new StringBuilder(metric.BirthType.ToString()).Append(" · ").Append(sparkplugName);
if (metric.Alias is { } alias) descriptor.Append(" · alias ").Append(alias);
if (mapped is null) descriptor.Append(" · unsupported in v1 — not bindable");
else if (isArray) descriptor.Append(" · array metrics are not read in v1 — not bindable");
// Unbindable = the ingestor refuses it: an unmapped type, or ANY array variant (SparkplugIngestor
// gates on the array bit and never feeds the tag). Such a leaf states NO address, so the
// browse-commit refuses it at commit instead of writing a tag that sits at
// BadWaitingForInitialData forever (R06:M-5). Labelling it alone was not enough — it still bound.
var bindable = mapped is not null && !isArray;
return new AttributeInfo(
metric.Name,
mapped?.ToString() ?? sparkplugName,
metric.DataType?.IsSparkplugArray() ?? false,
isArray,
descriptor.ToString(),
AddressFields: BuildMetricAddressFields(metric.Name, scope));
AddressFields: bindable ? BuildMetricAddressFields(metric.Name, scope) : null);
}
/// <summary>
@@ -557,6 +637,8 @@ internal sealed class MqttBrowseSession : IRebirthCapableBrowseSession
if (!SparkplugTopic.TryParse(topic, out var parsed)) return;
if (parsed.Type is not (SparkplugMessageType.NBIRTH or SparkplugMessageType.DBIRTH)) return;
Interlocked.Increment(ref _birthCount);
// The bound has to be on the whole message: unlike a plain payload, a birth must be parsed
// entire to yield its metric list, and the parse runs on the shared dispatcher thread.
if (payload.Length > MaxBirthPayloadBytes)
@@ -898,6 +980,7 @@ internal sealed class MqttBrowseSession : IRebirthCapableBrowseSession
ArgumentException.ThrowIfNullOrWhiteSpace(scope);
var targets = ResolveRebirthTargets(scope);
var baseline = BirthCount;
foreach (var (groupId, edgeNodeId) in targets)
{
@@ -908,6 +991,20 @@ internal sealed class MqttBrowseSession : IRebirthCapableBrowseSession
.ConfigureAwait(false);
}
// The birth-observation window (R06:M-4): wait — bounded — for the births just asked for to arrive
// and settle, so the tree the picker re-renders after this call already carries them. A courtesy,
// never a failure: the NCMDs are out, so a caller deadline here still reports the publish.
try
{
await SettleBirthsAsync(
() => BirthCount, baseline, awaitFirstBirth: true, RebirthQuietInterval, RebirthWait, cancellationToken)
.ConfigureAwait(false);
}
catch (OperationCanceledException)
{
// Published; the caller simply stopped waiting for the answer.
}
Touch();
return targets.Count;
}
@@ -39,6 +39,15 @@ public sealed class MqttDriverBrowser : IDriverBrowser
/// <summary>Ceiling on the open budget — the picker must never hang on an unreachable broker.</summary>
internal const int MaxOpenBudgetSeconds = 30;
/// <summary>Ceiling on the Sparkplug birth-observation window — an authored hour must not park a picker.</summary>
internal const int MaxBirthWindowSeconds = 120;
/// <summary>
/// How long the open waits for a quiet spell in birth traffic before handing the session back. An
/// idle plant (births are never retained) costs exactly this, once.
/// </summary>
internal static readonly TimeSpan BirthQuietInterval = TimeSpan.FromSeconds(1);
/// <summary>Marks the transient browse identity in the broker's client-id/session logs.</summary>
internal const string BrowseClientIdPrefix = "-browse-";
@@ -77,7 +86,8 @@ public sealed class MqttDriverBrowser : IDriverBrowser
openCts.CancelAfter(OpenBudget(opts));
var client = new MqttClientFactory().CreateMqttClient();
var session = new MqttBrowseSession(opts.Mode, client, _logger);
var birthWindow = opts.Mode == MqttMode.SparkplugB ? BirthWindow(opts) : TimeSpan.Zero;
var session = new MqttBrowseSession(opts.Mode, client, _logger, birthWindow: birthWindow);
try
{
// Runs on MQTTnet's dispatcher: record and return, nothing else.
@@ -100,6 +110,29 @@ public sealed class MqttDriverBrowser : IDriverBrowser
.Build();
await client.SubscribeAsync(subscribeOptions, openCts.Token).ConfigureAwait(false);
if (opts.Mode == MqttMode.SparkplugB)
{
// The birth-observation window (R06:M-4): births already in flight — an edge node mid-rebirth,
// a primary-host flap — are collected until the traffic goes quiet, bounded by the window, so
// the first RootAsync is not a snapshot taken in the middle of a burst. Passive: nothing is
// published to provoke a birth. An idle plant costs one quiet interval.
try
{
await MqttBrowseSession.SettleBirthsAsync(
() => session.BirthCount,
baseline: session.BirthCount,
awaitFirstBirth: false,
BirthQuietInterval,
birthWindow,
openCts.Token)
.ConfigureAwait(false);
}
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
{
// The open budget ran out mid-settle: what has been observed is still a usable session.
}
}
_logger.LogInformation(
"AdminUI MQTT browse session opened against {Host}:{Port} in {Mode} mode observing "
+ "'{Filter}' (read-only).",
@@ -179,12 +212,30 @@ public sealed class MqttDriverBrowser : IDriverBrowser
options with { CleanSession = true };
/// <summary>
/// Whole-open bound (connect + subscribe), clamped to
/// <see cref="MinOpenBudgetSeconds"/>–<see cref="MaxOpenBudgetSeconds"/> around the config's
/// own connect deadline.
/// Whole-open bound: connect + subscribe, clamped to
/// <see cref="MinOpenBudgetSeconds"/>–<see cref="MaxOpenBudgetSeconds"/> around the config's own
/// connect deadline — plus, in Sparkplug mode, the <see cref="BirthWindow"/> the open may spend
/// collecting births.
/// </summary>
/// <param name="options">The browse configuration.</param>
/// <returns>The clamped open budget.</returns>
internal static TimeSpan OpenBudget(MqttDriverOptions options) =>
TimeSpan.FromSeconds(Math.Clamp(options.ConnectTimeoutSeconds, MinOpenBudgetSeconds, MaxOpenBudgetSeconds));
/// <returns>The open budget.</returns>
internal static TimeSpan OpenBudget(MqttDriverOptions options)
{
var connect = TimeSpan.FromSeconds(
Math.Clamp(options.ConnectTimeoutSeconds, MinOpenBudgetSeconds, MaxOpenBudgetSeconds));
return options.Mode == MqttMode.SparkplugB ? connect + BirthWindow(options) : connect;
}
/// <summary>
/// The authored <c>sparkplug.birthObservationWindowSeconds</c>, clamped to 1–<see cref="MaxBirthWindowSeconds"/>
/// s: how long the browse surface collects NBIRTH/DBIRTH traffic before calling the observed metric set
/// stable (on open, and after an operator rebirth request).
/// </summary>
/// <param name="options">The browse configuration.</param>
/// <returns>The clamped window.</returns>
internal static TimeSpan BirthWindow(MqttDriverOptions options) =>
TimeSpan.FromSeconds(Math.Clamp(
options.Sparkplug?.BirthObservationWindowSeconds ?? new MqttSparkplugOptions().BirthObservationWindowSeconds,
1,
MaxBirthWindowSeconds));
}
@@ -45,6 +45,30 @@ public sealed class LastValueCache
_values[rawPath] = snapshot;
}
/// <summary>How many RawPaths currently hold an entry. Diagnostics and the bounded-growth tests.</summary>
internal int Count => _values.Count;
/// <summary>
/// Drops every entry whose RawPath <paramref name="keep"/> rejects — called by each ingest path's
/// <c>Register</c> with "is this still an authored tag", so the cache is bounded by the CURRENT
/// authored set rather than by the union of every RawPath (and every non-authored reference ever
/// subscribed) across the driver's lifetime (R06:M-2).
/// </summary>
/// <param name="keep">Returns <see langword="true"/> for a RawPath whose entry must survive.</param>
/// <returns>How many entries were dropped.</returns>
public int RetainOnly(Func<string, bool> keep)
{
ArgumentNullException.ThrowIfNull(keep);
var dropped = 0;
foreach (var rawPath in _values.Keys)
{
if (!keep(rawPath) && _values.TryRemove(rawPath, out _))
dropped++;
}
return dropped;
}
/// <summary>
/// Read the last observed value for <paramref name="rawPath"/>. Never throws: a
/// <c>null</c>/empty <paramref name="rawPath"/> or a RawPath never observed both return a
@@ -104,6 +104,12 @@ public sealed class MqttDriver
/// </summary>
private const string DiscoveryFolderName = "Mqtt";
/// <summary>
/// Ceiling on PUBLISHes one write batch keeps in flight (R06:M-8). Bounded so a large batch cannot
/// flood a broker's inflight window; different topics share it, one topic's writes stay sequential.
/// </summary>
internal const int MaxConcurrentPublishes = 8;
/// <summary>How often the host-connectivity poll samples <see cref="MqttConnection.State"/>.</summary>
private static readonly TimeSpan HostProbeInterval = TimeSpan.FromSeconds(1);
@@ -208,6 +214,18 @@ public sealed class MqttDriver
private SparkplugPrimaryHost? _primaryHost;
private DriverHealth _health = new(DriverState.Unknown, null, null);
/// <summary>UTC ticks of the last successful write PUBLISH; 0 = none. Answer evidence for health.</summary>
private long _lastPublishUtcTicks;
/// <summary>Guards the generation bump against the generation-checked health write.</summary>
private readonly object _healthLock = new();
/// <summary>
/// Bumped by every lifecycle operation (initialize inside the gate; shutdown and dispose before
/// waiting for it). The stamp that keeps a superseded operation from publishing state.
/// </summary>
private long _lifecycleGeneration;
private HostState _hostState = HostState.Unknown;
private DateTime _hostStateChangedUtc = DateTime.UtcNow;
private int _disposed;
@@ -289,6 +307,9 @@ public sealed class MqttDriver
/// </summary>
internal Func<CancellationToken, Task>? BeforeConnectHookForTests { get; set; }
/// <summary>Test seam: observes every <see cref="MqttConnection"/> an initialize builds. Null in production.</summary>
internal Action<MqttConnection>? ConnectionCreatedForTests { get; set; }
// ---- IDriver: lifecycle ----
/// <inheritdoc />
@@ -387,6 +408,11 @@ public sealed class MqttDriver
// watchdog's reconnect) is measured against, reporting a driver that was delivering a moment ago
// as one that has never delivered.
var lastMessage = GetHealth().LastSuccessfulRead;
// Taken BEFORE the gate wait: a wedged initialize holding the gate is superseded by this call, so
// when it finally completes it disposes the session it built instead of publishing it behind the
// ungated teardown below (R06:M-10), and its late health write is refused.
var generation = BeginLifecycleOperation();
if (await _lifecycleGate.WaitAsync(TimeSpan.FromSeconds(_options.ConnectTimeoutSeconds), cancellationToken)
.ConfigureAwait(false))
{
@@ -408,8 +434,10 @@ public sealed class MqttDriver
await TeardownAsync().ConfigureAwait(false);
}
WriteHealth(new DriverHealth(DriverState.Unknown, lastMessage, null));
TransitionHost(HostState.Unknown);
if (WriteHealthIfCurrent(generation, new DriverHealth(DriverState.Unknown, lastMessage, null)))
{
TransitionHost(HostState.Unknown);
}
}
/// <inheritdoc />
@@ -427,7 +455,7 @@ public sealed class MqttDriver
return stored;
}
var lastMessage = connection.LastMessageUtc ?? stored.LastSuccessfulRead;
var lastMessage = Latest(connection.LastMessageUtc ?? stored.LastSuccessfulRead, LastPublishUtc);
return connection.State switch
{
MqttConnectionState.Connected => new DriverHealth(DriverState.Healthy, lastMessage, stored.LastError),
@@ -689,9 +717,12 @@ public sealed class MqttDriver
{
ArgumentNullException.ThrowIfNull(writes);
var results = new WriteResult[writes.Count];
var results = new WriteResult?[writes.Count];
var connection = _connection;
// Pass 1 — everything that needs no broker: resolve, encode, refuse. What survives is grouped by
// destination topic in REQUEST order.
var byTopic = new Dictionary<string, List<(int Index, MqttEncodedWrite Encoded, string Reference)>>(StringComparer.Ordinal);
for (var i = 0; i < writes.Count; i++)
{
var w = writes[i];
@@ -719,22 +750,72 @@ public sealed class MqttDriver
continue;
}
if (!byTopic.TryGetValue(encoded.Topic, out var queue))
{
byTopic[encoded.Topic] = queue = [];
}
queue.Add((i, encoded, w.FullReference));
}
// Pass 2 — publish (R06:M-8). Different topics go out concurrently, at most MaxConcurrentPublishes
// in flight, so a stalled broker costs a batch ~one publish deadline instead of one per request.
// Writes to the SAME topic stay strictly sequential in request order: the device applies them in
// arrival order, and the last one must win.
if (byTopic.Count > 0)
{
using var slots = new SemaphoreSlim(MaxConcurrentPublishes, MaxConcurrentPublishes);
await Task.WhenAll(byTopic.Values.Select(queue => PublishQueueAsync(connection!, queue, slots, results, cancellationToken)))
.ConfigureAwait(false);
}
// Every slot is filled — a caller cancellation leaves the unattempted ones BadCommunicationError
// rather than throwing, so the batch shape holds.
var filled = new WriteResult[writes.Count];
for (var i = 0; i < filled.Length; i++)
{
filled[i] = results[i] ?? new WriteResult(StatusBadCommunicationError);
}
return filled;
}
/// <summary>Publishes one topic's writes in request order, each under a shared concurrency slot.</summary>
private async Task PublishQueueAsync(
MqttConnection connection,
List<(int Index, MqttEncodedWrite Encoded, string Reference)> queue,
SemaphoreSlim slots,
WriteResult?[] results,
CancellationToken cancellationToken)
{
foreach (var (index, encoded, reference) in queue)
{
try
{
await slots.WaitAsync(cancellationToken).ConfigureAwait(false);
}
catch (OperationCanceledException)
{
return; // caller-cancelled: the remaining slots are filled by the caller
}
try
{
await connection
.PublishAsync(encoded.Topic, encoded.Payload, encoded.Qos, retain: false, cancellationToken)
.ConfigureAwait(false);
results[i] = new WriteResult(0u);
results[index] = new WriteResult(0u);
// A PUBACK is the broker answering — evidence the session works, so it feeds
// LastSuccessfulRead alongside inbound messages. A completed QoS 0 publish is NOT: it only
// means the bytes reached the local socket buffer, and frequent Sparkplug commands would
// otherwise keep the watchdog's clock fresh over a half-open session (review minor 2).
if (encoded.Qos >= 1) Interlocked.Exchange(ref _lastPublishUtcTicks, DateTime.UtcNow.Ticks);
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
// Caller-cancelled: fill the remainder rather than throwing, so the batch shape holds.
for (var j = i; j < writes.Count; j++)
{
results[j] = new WriteResult(StatusBadCommunicationError);
}
return results;
results[index] = new WriteResult(StatusBadCommunicationError);
return;
}
catch (Exception ex)
{
@@ -742,13 +823,15 @@ public sealed class MqttDriver
ex,
"MQTT driver '{DriverId}': write to '{Reference}' could not be published on '{Topic}'.",
_driverInstanceId,
w.FullReference,
reference,
encoded.Topic);
results[i] = new WriteResult(StatusBadCommunicationError);
results[index] = new WriteResult(StatusBadCommunicationError);
}
finally
{
slots.Release();
}
}
return results;
}
/// <summary>Maps an encode refusal to the status an OPC UA client sees.</summary>
@@ -911,6 +994,9 @@ public sealed class MqttDriver
return;
}
// Supersedes any in-flight lifecycle operation, exactly as ShutdownAsync does.
BeginLifecycleOperation();
if (await _lifecycleGate.WaitAsync(TimeSpan.FromSeconds(_options.ConnectTimeoutSeconds))
.ConfigureAwait(false))
{
@@ -940,7 +1026,12 @@ public sealed class MqttDriver
/// <summary>The gate-held body of <see cref="InitializeAsync"/>.</summary>
private async Task InitializeCoreAsync(string driverConfigJson, CancellationToken cancellationToken)
{
WriteHealth(new DriverHealth(DriverState.Initializing, ReadHealth().LastSuccessfulRead, null));
// Taken INSIDE the gate (the caller holds it), so a second initialize queued behind this one does
// not supersede it; only a ShutdownAsync / DisposeAsync — which take theirs before waiting — can.
var generation = BeginLifecycleOperation();
WriteHealthIfCurrent(generation, new DriverHealth(DriverState.Initializing, ReadHealth().LastSuccessfulRead, null));
MqttConnection? built = null;
var published = false;
try
{
// Tear down whatever this driver already holds BEFORE building a new session. Both fields
@@ -965,6 +1056,8 @@ public sealed class MqttDriver
RegisterAuthoredTags(_options);
var connection = new MqttConnection(_options, _driverInstanceId, _logger);
built = connection;
ConnectionCreatedForTests?.Invoke(connection);
// MUST precede the connect: this wires message delivery AND the reconnect re-subscribe.
// The handler is passed through unwrapped on purpose — its throw-on-total-failure is what
@@ -992,10 +1085,13 @@ public sealed class MqttDriver
// disposing what we just built, never leaking it.
if (Volatile.Read(ref _disposed) != 0)
{
await connection.DisposeAsync().ConfigureAwait(false);
throw new ObjectDisposedException(nameof(MqttDriver));
}
// The same race against a ShutdownAsync whose bounded gate wait expired: it has already torn
// down ungated, so publishing now would leave a live session no later shutdown can see.
ThrowIfSuperseded(generation);
// Sparkplug subscribes ONCE, here, rather than on the OPC UA server's first subscribe: the
// driver must see birth certificates whether or not anything is subscribed yet, because a
// birth is where every alias and datatype comes from. This also issues the late-join
@@ -1008,28 +1104,47 @@ public sealed class MqttDriver
// MqttConnection's own remarks describe having already fixed once.
if (_sparkplug is { } sparkplug)
{
try
{
await sparkplug.EstablishAsync(cancellationToken).ConfigureAwait(false);
}
catch
{
await connection.DisposeAsync().ConfigureAwait(false);
throw;
}
await sparkplug.EstablishAsync(cancellationToken).ConfigureAwait(false);
}
ThrowIfSuperseded(generation);
_connection = connection;
published = true;
if (WriteHealthIfCurrent(generation, new DriverHealth(DriverState.Healthy, connection.LastMessageUtc, null)))
{
TransitionHost(HostState.Running);
}
WriteHealth(new DriverHealth(DriverState.Healthy, connection.LastMessageUtc, null));
TransitionHost(HostState.Running);
StartHostProbe();
await StartPrimaryHostAsync(cancellationToken).ConfigureAwait(false);
// Re-checked after publishing: a shutdown whose gate wait expired between the check above and
// here captured whatever it found and left these fields for us. Intended consequence (review
// minor 8): a perfectly healthy init that is just completing when a ShutdownAsync arrives also
// releases itself and throws OperationCanceledException — the queued shutdown would tear it down
// anyway, and the actor serialises init/shutdown, so only an abandoned init can meet this. We still hold the gate, so no
// newer initialize can have published anything — everything in them is ours to release.
if (!IsCurrentLifecycle(generation))
{
await TeardownAsync().ConfigureAwait(false);
ThrowIfSuperseded(generation);
}
}
catch (Exception ex)
{
WriteHealth(new DriverHealth(DriverState.Faulted, ReadHealth().LastSuccessfulRead, ex.Message));
TransitionHost(HostState.Faulted);
// R06:M-3: a connection built but never published is disposed here — a refused connect, a
// failed Sparkplug establish, a supersede. The published one belongs to TeardownAsync.
if (!published && built is not null)
{
await built.DisposeAsync().ConfigureAwait(false);
}
if (WriteHealthIfCurrent(generation, new DriverHealth(DriverState.Faulted, ReadHealth().LastSuccessfulRead, ex.Message)))
{
TransitionHost(HostState.Faulted);
}
throw;
}
}
@@ -1380,24 +1495,28 @@ public sealed class MqttDriver
/// <summary>Shared teardown for <see cref="ShutdownAsync"/>, <see cref="DisposeAsync"/> and the session rebuild.</summary>
private async Task TeardownAsync()
{
// Every field is CAPTURED before anything is disposed. A shutdown that timed out on the gate runs
// this ungated; capturing up front means it only ever releases what existed when it started, and
// never reaches into fields a later initialize publishes while the slow disposals below run.
var primaryHost = Interlocked.Exchange(ref _primaryHost, null);
var probe = Interlocked.Exchange(ref _hostProbeCts, null);
var connection = Interlocked.Exchange(ref _connection, null);
// Stand the host claim down FIRST, while the broker is still reachable and before the ingest
// session goes. Its explicit retained OFFLINE is what makes the transition immediate for edge
// nodes; leaving it to the Will costs them up to 1.5 keep-alive periods of believing a host
// that has already stopped is still online.
var primaryHost = Interlocked.Exchange(ref _primaryHost, null);
if (primaryHost is not null)
{
await primaryHost.DisposeAsync().ConfigureAwait(false);
}
var probe = Interlocked.Exchange(ref _hostProbeCts, null);
if (probe is not null)
{
await probe.CancelAsync().ConfigureAwait(false);
probe.Dispose();
}
var connection = Interlocked.Exchange(ref _connection, null);
if (connection is not null)
{
await connection.DisposeAsync().ConfigureAwait(false);
@@ -1476,9 +1595,66 @@ public sealed class MqttDriver
OnHostStatusChanged?.Invoke(this, new HostStatusChangedEventArgs(HostName, previous, next));
}
/// <summary>The last successful write publish, or null.</summary>
private DateTime? LastPublishUtc
{
get
{
var ticks = Interlocked.Read(ref _lastPublishUtcTicks);
return ticks == 0 ? null : new DateTime(ticks, DateTimeKind.Utc);
}
}
/// <summary>The later of two optional instants.</summary>
private static DateTime? Latest(DateTime? a, DateTime? b)
=> a is null ? b : b is null ? a : (a > b ? a : b);
/// <summary>Barrier-protected read of the multi-threaded health field.</summary>
private DriverHealth ReadHealth() => Volatile.Read(ref _health);
/// <summary>Barrier-protected publish of a new health snapshot.</summary>
private void WriteHealth(DriverHealth value) => Volatile.Write(ref _health, value);
/// <summary>
/// Starts a lifecycle operation and returns its generation. Every later operation supersedes it:
/// its health / host-state writes are refused and an initialize stops before publishing a session.
/// </summary>
private long BeginLifecycleOperation()
{
lock (_healthLock)
{
return ++_lifecycleGeneration;
}
}
/// <summary>Whether <paramref name="generation"/> is still the newest lifecycle operation.</summary>
private bool IsCurrentLifecycle(long generation) => Interlocked.Read(ref _lifecycleGeneration) == generation;
/// <summary>Throws when a newer lifecycle operation has superseded <paramref name="generation"/>.</summary>
private void ThrowIfSuperseded(long generation)
{
if (!IsCurrentLifecycle(generation))
{
throw new OperationCanceledException(
$"MQTT driver '{_driverInstanceId}': this initialize was superseded by a later shutdown/dispose; "
+ "the session it built has been released.");
}
}
/// <summary>
/// Publishes a health snapshot only while <paramref name="generation"/> is the newest lifecycle
/// operation — the check and the write are atomic against <see cref="BeginLifecycleOperation"/>, so a
/// superseded initialize or shutdown can never overwrite a newer one's state.
/// </summary>
/// <returns><see langword="true"/> when the write was applied.</returns>
private bool WriteHealthIfCurrent(long generation, DriverHealth value)
{
lock (_healthLock)
{
if (_lifecycleGeneration != generation)
{
return false;
}
Volatile.Write(ref _health, value);
return true;
}
}
}
@@ -48,7 +48,8 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Mqtt;
/// accepted by the parser (warned at deploy, see <c>MqttTagDefinitionFactory.Inspect</c>), so
/// matching uses MQTTnet's own <see cref="MqttTopicFilterComparer"/> rather than string
/// equality — the same comparer a broker applies. Concrete topics stay a single dictionary
/// lookup; the linear wildcard scan runs only when a wildcard tag actually exists.
/// lookup; wildcard filters live in a level trie (<c>TopicFilterTrie</c>) whose candidates the comparer
/// confirms, so a thousand wildcard tags cost a walk over the topic's levels, not a thousand compares.
/// </para>
/// <para>
/// <b>Sparkplug seam.</b> P1 ingests plain topics only. Sparkplug B decodes a
@@ -74,6 +75,13 @@ public sealed class MqttSubscriptionManager
/// </summary>
public const int DefaultQos = 1;
/// <summary>
/// Throw-on-invalid UTF-8, shared (R06:M-7): it is stateless and thread-safe, and allocating one per
/// Raw/Scalar delivery was per-message garbage on the dispatcher thread. Same choice
/// <c>MqttBrowseSession</c> makes.
/// </summary>
private static readonly UTF8Encoding Utf8Strict = new(encoderShouldEmitUTF8Identifier: false, throwOnInvalidBytes: true);
// OPC UA status codes (values verified against Opc.Ua.StatusCodes, not recalled).
private const uint StatusGood = 0x00000000u;
@@ -113,6 +121,14 @@ public sealed class MqttSubscriptionManager
/// <summary>Topics already SUBSCRIBEd on the live session — the dedupe set and the re-subscribe set.</summary>
private readonly ConcurrentDictionary<string, byte> _subscribedTopics = new(StringComparer.Ordinal);
/// <summary>
/// Topics a subscribe asked for whose SUBSCRIBE failed (refused, or no verdict at all). Retried by
/// every later <see cref="SubscribeAsync"/> and by <see cref="OnReconnectedAsync"/>; moved to
/// <see cref="_subscribedTopics"/> when granted. Without it a failed topic left every retry set and
/// stayed dark behind a Healthy connection until a redeploy.
/// </summary>
private readonly ConcurrentDictionary<string, byte> _pendingTopics = new(StringComparer.Ordinal);
/// <summary>RawPaths whose extraction failure has already been logged loudly once.</summary>
private readonly ConcurrentDictionary<string, byte> _warnedRefs = new(StringComparer.Ordinal);
@@ -212,8 +228,8 @@ public sealed class MqttSubscriptionManager
/// The manager does not own the connection's lifetime and never detaches — the driver disposes
/// both together. Call once per connection, and <b>before</b> the first
/// <see cref="SubscribeAsync"/>: a subscribe issued with no transport attached records its
/// intent (and warns) but sends nothing to a broker, and would only be established if the
/// connection later dropped and recovered.
/// topics as <i>pending</i> (and warns) but sends nothing to a broker; the next subscribe or
/// reconnect on an attached session retries them.
/// </para>
/// <para>
/// <b>Binding to a session forgets the previous session's established filters</b> — the same
@@ -317,6 +333,11 @@ public sealed class MqttSubscriptionManager
_authored = table;
_warnedRefs.Clear();
// Bound the shared last-value cache by the CURRENT authored set (R06:M-2): a dropped tag's value
// and every non-authored reference's BadNodeIdUnknown marker would otherwise live forever. A
// still-authored tag keeps its value, so a redeploy never blanks what has been observed.
Values.RetainOnly(table.ByRawPath.ContainsKey);
// Drop state belonging to tags this deploy removed. Without this, a topic whose last tag was
// deleted stays in _subscribedTopics and is dutifully re-SUBSCRIBEd on every future reconnect,
// forever — and its RawPaths keep a handle mapping that can never fire again.
@@ -328,6 +349,14 @@ public sealed class MqttSubscriptionManager
}
}
foreach (var topic in _pendingTopics.Keys)
{
if (!table.ByFilter.ContainsKey(topic))
{
_pendingTopics.TryRemove(topic, out _);
}
}
foreach (var rawPath in _handleByRawPath.Keys)
{
if (!table.ByRawPath.ContainsKey(rawPath))
@@ -356,12 +385,13 @@ public sealed class MqttSubscriptionManager
/// opening SUBSCRIBE itself; <see cref="OnReconnectedAsync"/> only re-establishes it later.
/// </para>
/// <para>
/// <b>Never throws, never hangs.</b> A reference that is not an authored tag is recorded as
/// <c>BadNodeIdUnknown</c>; a SUBACK rejection, transport failure or subscribe deadline
/// degrades exactly the affected references to <c>BadCommunicationError</c>. Either way a
/// handle is returned — the OPC UA server's subscribe call must not fail because one topic
/// is ACL-denied. Recovery for a transient failure comes from the reconnect path;
/// a persistent broker-side refusal is visible as a Bad status on the affected nodes.
/// <b>Never hangs; throws only when the broker gave no verdict.</b> A reference that is not an
/// authored tag is recorded as <c>BadNodeIdUnknown</c>. A SUBACK rejection degrades exactly the
/// affected references to <c>BadCommunicationError</c> and still returns a handle — the OPC UA
/// server's subscribe must not fail because one topic is ACL-denied. A transport failure or
/// subscribe deadline degrades the same way and then <b>throws</b>, so the host holds no handle
/// and its reconcile re-subscribes. Every failed topic also joins a pending set that the next
/// subscribe and every reconnect retry.
/// </para>
/// </remarks>
/// <param name="fullReferences">The RawPath references to subscribe.</param>
@@ -414,12 +444,56 @@ public sealed class MqttSubscriptionManager
// One SUBSCRIBE per distinct topic, and only for topics not already live: re-SUBSCRIBEing an
// established filter is legal but pointless, and it re-triggers the retained seed.
var pending = defs.Where(d => !_subscribedTopics.ContainsKey(d.Topic)).ToList();
await EstablishAsync(BuildFilters(pending, _defaultQos), cancellationToken).ConfigureAwait(false);
// Topics an EARLIER subscribe failed to establish ride along: a redeploy's bulk re-subscribe is then a
// retry of a refusal the broker may since have lifted, instead of a no-op that leaves them dark.
var authored = _authored;
var pending = defs.Where(d => !_subscribedTopics.ContainsKey(d.Topic))
.Concat(PendingDefinitions(authored))
.ToList();
var outcome = await EstablishAsync(BuildFilters(pending, _defaultQos), cancellationToken).ConfigureAwait(false);
if (outcome.TransportFailure is { } failure)
{
// The refs are already Bad and pending either way. But a cancel of the CALLER's token (a pipeline
// timeout, host shutdown) is a cancellation, not a broker fault: surfacing it as
// InvalidOperationException made the resilience breaker count it as a failure (review minor 3).
var callerCancelled = cancellationToken.IsCancellationRequested;
// No verdict at all (transport throw / SUBSCRIBE deadline): THROW, so the caller holds no handle
// and DriverInstanceActor's health-tick reconcile re-subscribes. Returning a handle here was the
// silent-death case — the actor thought the subscription live and never retried. The refs already
// carry BadCommunicationError (raised under this handle above), so the throw costs no visibility.
_refsByHandle.TryRemove(handle, out _);
foreach (var def in defs)
{
_handleByRawPath.TryRemove(new KeyValuePair<string, ISubscriptionHandle>(def.Name, handle));
}
if (callerCancelled)
{
throw new OperationCanceledException(
$"MQTT driver '{_driverId}': SUBSCRIBE for {pending.Count} tag(s) was cancelled by the caller; "
+ "the topics stay pending and will be retried.",
failure,
cancellationToken);
}
throw new InvalidOperationException(
$"MQTT driver '{_driverId}': SUBSCRIBE for {pending.Count} tag(s) got no broker verdict "
+ $"({failure.GetType().Name}); the subscription will be retried.",
failure);
}
// A SUBACK refusal (an ACL denial, say) does NOT throw: the broker answered, the refused topics are
// Bad + pending, and failing the whole subscribe would take every other topic in it dark too.
return handle;
}
/// <summary>The authored definitions behind every still-authored pending topic.</summary>
private IEnumerable<MqttTagDefinition> PendingDefinitions(AuthoredTable authored)
=> _pendingTopics.Keys
.Select(topic => authored.ByFilter.TryGetValue(topic, out var defs) ? defs : [])
.SelectMany(defs => defs);
/// <summary>
/// Stops attributing notifications to <paramref name="handle"/>. References another live
/// subscription also covers keep publishing.
@@ -498,8 +572,13 @@ public sealed class MqttSubscriptionManager
.SelectMany(defs => defs)
.ToList();
var filters = BuildFilters(live, _defaultQos);
if (filters.Count == 0)
// Pending topics (a failed earlier SUBSCRIBE) are retried here too — a reconnect re-subscribing
// only the granted set is how a failed topic stayed dark forever. They do NOT count toward the
// total-failure verdict below: re-refusing a topic that was never live loses nothing, and throwing
// for it would turn one ACL-denied topic into a reconnect loop for the whole driver.
var pendingDefs = PendingDefinitions(authored).ToList();
var filters = BuildFilters(live.Concat(pendingDefs), _defaultQos);
if (live.Count == 0 && pendingDefs.Count == 0)
{
// Silence here is how the wildcard defect hid: no exception, no log, no SUBSCRIBE. Live
// topics resolving to zero filters re-establishes nothing, which is the total-failure case
@@ -519,13 +598,17 @@ public sealed class MqttSubscriptionManager
return;
}
var granted = await EstablishAsync(filters, cancellationToken).ConfigureAwait(false);
if (granted == 0)
var outcome = await EstablishAsync(filters, cancellationToken).ConfigureAwait(false);
var liveTopics = live.Select(d => d.Topic).Distinct(StringComparer.Ordinal).ToList();
var liveLost = outcome.TransportFailure is not null
|| (subscribed.Count > 0 && (liveTopics.Count == 0 || !liveTopics.Any(outcome.Granted.Contains)));
if (liveLost)
{
throw new InvalidOperationException(
$"MQTT driver '{_driverId}': no subscription could be re-established after reconnect "
+ $"({filters.Count} filter(s) requested); tearing the session down rather than serving a "
+ "connection that receives nothing.");
+ "connection that receives nothing.",
outcome.TransportFailure);
}
}
@@ -582,13 +665,14 @@ public sealed class MqttSubscriptionManager
}
// A published topic name may not itself contain a wildcard, so a wildcard-authored
// definition can never also be an exact-index hit: no tag is delivered twice.
foreach (var def in authored.Wildcards)
// definition can never also be an exact-index hit: no tag is delivered twice. The trie walks
// the topic's levels (R06:M-6 — this used to be one comparer call per wildcard tag, per
// message, on the shared dispatcher thread) and every candidate it yields is confirmed by
// MQTTnet's own comparer inside the visitor, so the semantics are the broker's.
if (authored.WildcardTrie.Count > 0)
{
if (MqttTopicFilterComparer.Compare(topic, def.Topic) == MqttTopicFilterCompareResult.IsMatch)
{
Deliver(def, payload, retained, now, oversized, ref json);
}
var visitor = new WildcardDelivery(this, topic, payload, retained, now, oversized, ref json);
authored.WildcardTrie.Match(topic, ref visitor);
}
}
finally
@@ -633,35 +717,35 @@ public sealed class MqttSubscriptionManager
/// </summary>
/// <param name="filters">The filters to establish; an empty list is a no-op.</param>
/// <param name="cancellationToken">Cancellation for the round-trip.</param>
/// <returns>How many filters the broker granted.</returns>
private async Task<int> EstablishAsync(
/// <returns>The granted topics, plus the transport failure when the broker gave no verdict at all.</returns>
private async Task<EstablishOutcome> EstablishAsync(
IReadOnlyList<MqttTopicSubscription> filters,
CancellationToken cancellationToken)
{
if (filters.Count == 0)
{
return 0;
return EstablishOutcome.Nothing;
}
var transport = _transport;
if (transport is null)
{
// No transport: the intent is recorded so the reconnect path establishes it, but NOTHING
// reaches a broker now. Loud, because outside tests this means the composition order is
// wrong — AttachTo must run before the OPC UA server's first subscribe, or these topics
// stay unestablished until the connection happens to drop and recover.
// No transport: NOTHING reaches a broker, so nothing is recorded as subscribed — recording it
// was a silent "subscribed, receives nothing" if the AttachTo-before-subscribe ordering ever
// changed. The topics wait in the pending set, which the next subscribe and every reconnect
// retry. Loud, because outside tests this means the composition order is wrong.
foreach (var filter in filters)
{
_subscribedTopics[filter.Topic] = 0;
_pendingTopics[filter.Topic] = 0;
}
_logger?.LogWarning(
"MQTT driver '{DriverId}': {FilterCount} subscription(s) recorded with no connection attached; "
+ "nothing was sent to a broker. Call AttachTo before subscribing.",
"MQTT driver '{DriverId}': {FilterCount} subscription(s) requested with no connection attached; "
+ "nothing was sent to a broker and they stay pending. Call AttachTo before subscribing.",
_driverId,
filters.Count);
return filters.Count;
return EstablishOutcome.Nothing;
}
IReadOnlyList<MqttSubscribeOutcome> outcomes;
@@ -681,16 +765,17 @@ public sealed class MqttSubscriptionManager
DegradeTopic(filter.Topic, ex.GetType().Name);
}
return 0;
return new EstablishOutcome(new HashSet<string>(StringComparer.Ordinal), ex);
}
var granted = 0;
var granted = new HashSet<string>(StringComparer.Ordinal);
foreach (var outcome in outcomes)
{
if (outcome.Granted)
{
_subscribedTopics[outcome.Topic] = 0;
granted++;
_pendingTopics.TryRemove(outcome.Topic, out _);
granted.Add(outcome.Topic);
}
else
{
@@ -703,7 +788,16 @@ public sealed class MqttSubscriptionManager
}
}
return granted;
return new EstablishOutcome(granted, null);
}
/// <summary>One SUBSCRIBE round-trip's verdict.</summary>
/// <param name="Granted">The filters the broker granted.</param>
/// <param name="TransportFailure">Set when the call produced no verdict at all (throw / deadline).</param>
private sealed record EstablishOutcome(IReadOnlySet<string> Granted, Exception? TransportFailure)
{
public static readonly EstablishOutcome Nothing =
new(new HashSet<string>(StringComparer.Ordinal), null);
}
/// <summary>Marks every authored reference on <paramref name="topic"/> as unreachable.</summary>
@@ -722,6 +816,7 @@ public sealed class MqttSubscriptionManager
if (!_authored.ByFilter.TryGetValue(topic, out var defs))
{
_pendingTopics.TryRemove(topic, out _);
_logger?.LogWarning(
"MQTT driver '{DriverId}': cannot degrade tags for unestablished filter '{Topic}' ({Reason}) — "
+ "no authored tag resolves to it.",
@@ -731,6 +826,9 @@ public sealed class MqttSubscriptionManager
return;
}
// Still authored, so it must be retried — see _pendingTopics.
_pendingTopics[topic] = 0;
var now = DateTime.UtcNow;
var snapshot = new DataValueSnapshot(null, StatusBadCommunicationError, null, now);
foreach (var def in defs)
@@ -1371,8 +1469,7 @@ public sealed class MqttSubscriptionManager
{
try
{
text = new UTF8Encoding(encoderShouldEmitUTF8Identifier: false, throwOnInvalidBytes: true)
.GetString(payload);
text = Utf8Strict.GetString(payload);
return true;
}
catch (DecoderFallbackException)
@@ -1400,6 +1497,47 @@ public sealed class MqttSubscriptionManager
return $"mqtt:{_mode}:{id}:[{named}{overflow}]";
}
/// <summary>
/// Delivers each wildcard-trie candidate whose filter the MQTTnet comparer confirms. A <c>ref struct</c>
/// so the payload span and the fan-out's shared JSON parse ride along without a closure or a list.
/// </summary>
private ref struct WildcardDelivery : ITopicFilterVisitor<MqttTagDefinition>
{
private readonly MqttSubscriptionManager _manager;
private readonly string _topic;
private readonly ReadOnlySpan<byte> _payload;
private readonly bool _retained;
private readonly DateTime _now;
private readonly bool _oversized;
private ref PayloadJson _json;
public WildcardDelivery(
MqttSubscriptionManager manager,
string topic,
ReadOnlySpan<byte> payload,
bool retained,
DateTime now,
bool oversized,
ref PayloadJson json)
{
_manager = manager;
_topic = topic;
_payload = payload;
_retained = retained;
_now = now;
_oversized = oversized;
_json = ref json;
}
public void Visit(MqttTagDefinition def)
{
if (MqttTopicFilterComparer.Compare(_topic, def.Topic) == MqttTopicFilterCompareResult.IsMatch)
{
_manager.Deliver(def, _payload, _retained, _now, _oversized, ref _json);
}
}
}
/// <summary>Opaque subscription identity; the diagnostic id names the mode and its topics.</summary>
/// <param name="DiagnosticId">The human-readable identity surfaced for diagnostics.</param>
private sealed record MqttSubscriptionHandle(string DiagnosticId) : ISubscriptionHandle;
@@ -1411,7 +1549,8 @@ public sealed class MqttSubscriptionManager
/// <remarks>
/// <b>Two topic indexes, and the difference is load-bearing.</b> Delivery matches an
/// <i>incoming published topic</i>, which never carries a wildcard, so it uses
/// <see cref="ByExactTopic"/> (concrete only) plus a comparer scan of <see cref="Wildcards"/>.
/// <see cref="ByExactTopic"/> (concrete only) plus a <see cref="WildcardTrie"/> walk whose candidates the
/// comparer confirms.
/// Every path that instead reconstructs state from a <i>subscribed filter string</i> —
/// reconnect re-subscribe and per-topic degradation — must use <see cref="ByFilter"/>, which is
/// keyed by the tag's authored topic <b>including wildcard patterns</b>, because that is what
@@ -1425,12 +1564,12 @@ public sealed class MqttSubscriptionManager
/// included. Keyed the same way <c>_subscribedTopics</c> and SUBACK outcomes are.
/// </param>
/// <param name="ByExactTopic">Concrete topic → its definitions; the delivery fast path.</param>
/// <param name="Wildcards">Definitions whose authored topic carries an MQTT wildcard.</param>
/// <param name="WildcardTrie">Definitions whose authored topic carries an MQTT wildcard, by filter level.</param>
private sealed record AuthoredTable(
FrozenDictionary<string, MqttTagDefinition> ByRawPath,
FrozenDictionary<string, MqttTagDefinition[]> ByFilter,
FrozenDictionary<string, MqttTagDefinition[]> ByExactTopic,
MqttTagDefinition[] Wildcards)
TopicFilterTrie<MqttTagDefinition> WildcardTrie)
{
public static readonly AuthoredTable Empty =
Build(new Dictionary<string, MqttTagDefinition>(StringComparer.Ordinal));
@@ -1480,7 +1619,7 @@ public sealed class MqttSubscriptionManager
byRawPath.ToFrozenDictionary(StringComparer.Ordinal),
byFilter.ToFrozenDictionary(kv => kv.Key, kv => kv.Value.ToArray(), StringComparer.Ordinal),
concrete.ToFrozenDictionary(kv => kv.Key, kv => kv.Value.ToArray(), StringComparer.Ordinal),
[.. wildcards]);
TopicFilterTrie<MqttTagDefinition>.Build(wildcards.Select(d => (d.Topic, d))));
}
}
}
@@ -416,6 +416,11 @@ public sealed class SparkplugIngestor
_authored = table;
_warned.Clear();
// Bound the shared last-value cache by the CURRENT authored set (R06:M-2): a dropped tag's value
// and every non-authored reference's BadNodeIdUnknown marker would otherwise live forever. A
// still-authored tag keeps its value, so a redeploy never blanks what has been observed.
Values.RetainOnly(table.ByRawPath.ContainsKey);
// Drop state belonging to edge nodes this deploy removed. Without this a tracker (and its
// rebirth debounce slot) for a node nobody reads survives every future redeploy.
foreach (var key in _trackers.Keys)
@@ -0,0 +1,152 @@
using System.Collections.Frozen;
namespace ZB.MOM.WW.OtOpcUa.Driver.Mqtt;
/// <summary>Receives each candidate a <see cref="TopicFilterTrie{T}"/> match produces.</summary>
/// <typeparam name="T">The value type stored against each filter.</typeparam>
internal interface ITopicFilterVisitor<in T>
{
/// <summary>Called once per value whose filter may match the topic.</summary>
/// <param name="value">The candidate.</param>
void Visit(T value);
}
/// <summary>
/// An immutable trie over MQTT topic FILTERS (<c>+</c> / <c>#</c> wildcards), so matching one published
/// topic costs a walk over its levels instead of a comparer call per wildcard-authored tag (R06:M-6:
/// the linear scan ran on MQTTnet's shared dispatcher thread for every inbound message).
/// </summary>
/// <remarks>
/// <para>
/// <b>A candidate generator, not the authority.</b> The trie implements the plain MQTT 3.1.1 §4.7
/// level rules — <c>+</c> is exactly one level (an empty one included), <c>#</c> is the parent level
/// and everything under it — and deliberately nothing subtler (the <c>$</c>-topic exclusion, shared
/// subscriptions). It can therefore only ever return a SUPERSET of the true matches, and every
/// caller confirms each candidate with MQTTnet's own <c>MqttTopicFilterComparer</c>, the comparer a
/// broker applies. Semantics stay byte-identical to the scan it replaces; only the work changes.
/// A differential test pins both halves against that comparer.
/// </para>
/// <para>
/// <b>Allocation-free per match.</b> Levels are walked as spans and looked up through the
/// dictionary's span alternate lookup; candidates are pushed to a caller-supplied visitor (a
/// <c>ref struct</c> in production) rather than collected into a list.
/// </para>
/// </remarks>
/// <typeparam name="T">The value type stored against each filter.</typeparam>
internal sealed class TopicFilterTrie<T>
{
private readonly Node _root;
private TopicFilterTrie(Node root, int count)
{
_root = root;
Count = count;
}
/// <summary>An empty trie.</summary>
public static TopicFilterTrie<T> Empty { get; } = Build([]);
/// <summary>How many values were inserted.</summary>
public int Count { get; }
/// <summary>Builds a trie from <c>(filter, value)</c> pairs.</summary>
/// <param name="entries">The filters and their values.</param>
/// <returns>The immutable trie.</returns>
public static TopicFilterTrie<T> Build(IEnumerable<(string Filter, T Value)> entries)
{
var root = new Builder();
var count = 0;
foreach (var (filter, value) in entries)
{
var node = root;
var levels = filter.Split('/');
var terminated = false;
for (var i = 0; i < levels.Length; i++)
{
var level = levels[i];
if (level == "#")
{
// '#' must be the last level; anything after it is a malformed filter the comparer
// will refuse anyway, so treating '#' as terminal here can only over-generate.
node.Hash.Add(value);
terminated = true;
break;
}
node = level == "+"
? node.Plus ??= new Builder()
: node.Children.TryGetValue(level, out var child) ? child : node.Children[level] = new Builder();
}
if (!terminated) node.Exact.Add(value);
count++;
}
return new TopicFilterTrie<T>(root.Freeze(), count);
}
/// <summary>Pushes every value whose filter may match <paramref name="topic"/> to <paramref name="visitor"/>.</summary>
/// <typeparam name="TVisitor">The visitor; a <c>ref struct</c> is allowed.</typeparam>
/// <param name="topic">A concrete published topic (never a filter).</param>
/// <param name="visitor">Receives each candidate — a superset of the true matches; confirm each one.</param>
public void Match<TVisitor>(string topic, ref TVisitor visitor)
where TVisitor : ITopicFilterVisitor<T>, allows ref struct
{
if (Count == 0 || topic is null) return;
Walk(_root, topic.AsSpan(), exhausted: false, ref visitor);
}
private static void Walk<TVisitor>(Node node, ReadOnlySpan<char> rest, bool exhausted, ref TVisitor visitor)
where TVisitor : ITopicFilterVisitor<T>, allows ref struct
{
// '#' here covers this level's parent-match AND every deeper level.
foreach (var value in node.Hash) visitor.Visit(value);
if (exhausted)
{
foreach (var value in node.Exact) visitor.Visit(value);
return;
}
var slash = rest.IndexOf('/');
var level = slash < 0 ? rest : rest[..slash];
var next = slash < 0 ? ReadOnlySpan<char>.Empty : rest[(slash + 1)..];
var nextExhausted = slash < 0;
if (node.Children.Count > 0
&& node.Children.GetAlternateLookup<ReadOnlySpan<char>>().TryGetValue(level, out var child))
{
Walk(child, next, nextExhausted, ref visitor);
}
if (node.Plus is { } plus) Walk(plus, next, nextExhausted, ref visitor);
}
private sealed class Node(FrozenDictionary<string, Node> children, Node? plus, T[] exact, T[] hash)
{
public FrozenDictionary<string, Node> Children { get; } = children;
public Node? Plus { get; } = plus;
public T[] Exact { get; } = exact;
public T[] Hash { get; } = hash;
}
private sealed class Builder
{
public Dictionary<string, Builder> Children { get; } = new(StringComparer.Ordinal);
public Builder? Plus { get; set; }
public List<T> Exact { get; } = [];
public List<T> Hash { get; } = [];
public Node Freeze() => new(
Children.ToFrozenDictionary(kv => kv.Key, kv => kv.Value.Freeze(), StringComparer.Ordinal),
Plus?.Freeze(),
[.. Exact],
[.. Hash]);
}
}
@@ -48,6 +48,12 @@ internal sealed class SqlBrowseSession : IBrowseSession
/// <summary>Column alias carrying the SQL type family name.</summary>
private const string DataTypeColumn = "DATA_TYPE";
/// <summary>
/// Column alias carrying <c>YES</c>/<c>NO</c> nullability. Every shipped dialect projects it (Oracle
/// normalises its <c>Y</c>/<c>N</c>); it used to be read by nothing (R06:S-5).
/// </summary>
private const string IsNullableColumn = "IS_NULLABLE";
/// <summary>The <c>TABLE_TYPE</c> value marking a view rather than a base table.</summary>
private const string ViewTableType = "VIEW";
@@ -158,7 +164,7 @@ internal sealed class SqlBrowseSession : IBrowseSession
reference.Schema, reference.Table!, cancellationToken).ConfigureAwait(false);
// Computed from the columns ALREADY READ for this call — the catalog is not queried a second time.
var timestampColumn = PreferredTimestampColumn(columns);
var timestampColumn = PreferredTimestampColumn([.. columns.Select(c => (c.Name, c.DataType))]);
// Ordinal: a column NodeId is only ever minted from this same catalog output, so its case is the
// catalog's own. A case-insensitive match would pick the wrong column on a case-sensitive collation
@@ -191,7 +197,18 @@ internal sealed class SqlBrowseSession : IBrowseSession
Name: column.Name,
DriverDataType: _dialect.MapColumnType(column.DataType).ToString(),
IsArray: false,
SecurityClass: ReadOnlySecurityClass,
// ⚠ SecurityClass is (ab)used as the picker's descriptor line ("{type} · {SecurityClass}"),
// exactly as the MQTT browser does. Every consumer today is display-only; a future one that
// parses it as SecurityClassification must not read the Sql value (review m-6 — a dedicated
// descriptor field on AttributeInfo is the Commons follow-up). Display only. Nullability is
// what tells an operator a cell can come back NULL, which publishes Uncertain (or Bad under
// nullIsBad), before they commit the tag.
SecurityClass: column.IsNullable switch
{
true => ReadOnlySecurityClass + " · nullable",
false => ReadOnlySecurityClass + " · NOT NULL",
null => ReadOnlySecurityClass,
},
IsAlarm: false,
AddressFields: addressFields),
];
@@ -356,7 +373,7 @@ internal sealed class SqlBrowseSession : IBrowseSession
/// than an error: the catalog answering "no rows" is indistinguishable from a genuinely column-less
/// relation, and neither is a reason to fail an operator's click.
/// </summary>
private Task<List<(string Name, string DataType)>> ReadColumnsAsync(
private Task<List<(string Name, string DataType, bool? IsNullable)>> ReadColumnsAsync(
string schema, string table, CancellationToken ct) =>
QueryAsync(
_dialect.ListColumnsSql,
@@ -367,9 +384,30 @@ internal sealed class SqlBrowseSession : IBrowseSession
},
static reader => (
Name: ReadString(reader, ColumnNameColumn),
DataType: ReadOptionalString(reader, DataTypeColumn) ?? string.Empty),
DataType: ReadOptionalString(reader, DataTypeColumn) ?? string.Empty,
IsNullable: ReadNullability(reader)),
ct);
/// <summary>
/// Reads <c>IS_NULLABLE</c> as a tri-state: <see langword="null"/> when a (custom) dialect does not
/// project it or the value is neither <c>YES</c> nor <c>NO</c> — "unknown" is said by omission, never
/// guessed.
/// </summary>
private static bool? ReadNullability(DbDataReader reader)
{
for (var i = 0; i < reader.FieldCount; i++)
{
if (!string.Equals(reader.GetName(i), IsNullableColumn, StringComparison.OrdinalIgnoreCase)) continue;
if (reader.IsDBNull(i)) return null;
var text = reader.GetValue(i)?.ToString()?.Trim();
if (string.Equals(text, "YES", StringComparison.OrdinalIgnoreCase)) return true;
if (string.Equals(text, "NO", StringComparison.OrdinalIgnoreCase)) return false;
return null;
}
return null;
}
/// <summary>
/// Runs one catalog query under the gate and projects every row. The disposed check happens
/// <em>inside</em> the gate wait so a dispose racing an in-flight call cannot be missed.
@@ -177,6 +177,31 @@ public interface ISqlDialect
/// </summary>
string SingleRowLimitSuffix { get; }
/// <summary>
/// The <see cref="SingleRowLimitPrefix"/> position's fragment for an <paramref name="rows"/>-row limit —
/// T-SQL's <c>"TOP (n) "</c>; empty for a dialect that limits at the end. Same trailing-space rule.
/// </summary>
/// <remarks>
/// Added for the key-value / where-pair row cap (R06:S-1): those plans used to carry no limit, so an
/// ambiguous selector against a history table materialised every matching row on every poll. The
/// planner asks for <c>N + 1</c> rows — one more than the plan can use — so ambiguity stays observable
/// (the reader sees the extra row and publishes a configuration error) without unbounded reads.
/// </remarks>
/// <param name="rows">The row limit; at least 1.</param>
/// <returns>The fragment, possibly empty.</returns>
/// <exception cref="ArgumentOutOfRangeException"><paramref name="rows"/> is below 1.</exception>
string RowLimitPrefix(int rows);
/// <summary>
/// The <see cref="SingleRowLimitSuffix"/> position's fragment for an <paramref name="rows"/>-row limit —
/// <c>" LIMIT n"</c> / <c>" FETCH FIRST n ROWS ONLY"</c>; empty for a dialect that limits after
/// <c>SELECT</c>. Same leading-space rule. See <see cref="RowLimitPrefix"/>.
/// </summary>
/// <param name="rows">The row limit; at least 1.</param>
/// <returns>The fragment, possibly empty.</returns>
/// <exception cref="ArgumentOutOfRangeException"><paramref name="rows"/> is below 1.</exception>
string RowLimitSuffix(int rows);
/// <summary>Catalog query listing schemas — the browser's <c>RootAsync</c> level. Takes no parameters.</summary>
string ListSchemasSql { get; }
@@ -124,6 +124,21 @@ public sealed class OracleDialect : ISqlDialect
// statement shape, which this seam deliberately leaves with the planner.
public string SingleRowLimitSuffix => " FETCH FIRST 1 ROWS ONLY";
/// <inheritdoc />
public string RowLimitPrefix(int rows)
{
ArgumentOutOfRangeException.ThrowIfLessThan(rows, 1);
return string.Empty;
}
/// <inheritdoc />
// The same 12c+ row-limiting clause as SingleRowLimitSuffix; valid after a WHERE with no ORDER BY.
public string RowLimitSuffix(int rows)
{
ArgumentOutOfRangeException.ThrowIfLessThan(rows, 1);
return " FETCH FIRST " + rows.ToString(System.Globalization.CultureInfo.InvariantCulture) + " ROWS ONLY";
}
/// <inheritdoc />
// Schemas visible to the connecting principal, from ALL_USERS (Oracle equates a schema with a
// user), aliased to the TABLE_SCHEMA column name the catalog readers project by.
@@ -86,6 +86,20 @@ public sealed class PostgresDialect : ISqlDialect
/// <inheritdoc />
public string SingleRowLimitSuffix => " LIMIT 1";
/// <inheritdoc />
public string RowLimitPrefix(int rows)
{
ArgumentOutOfRangeException.ThrowIfLessThan(rows, 1);
return string.Empty;
}
/// <inheritdoc />
public string RowLimitSuffix(int rows)
{
ArgumentOutOfRangeException.ThrowIfLessThan(rows, 1);
return " LIMIT " + rows.ToString(System.Globalization.CultureInfo.InvariantCulture);
}
// The schemas holding relations this connection can actually see, system schemas excluded.
//
// Sourced from information_schema.tables, not information_schema.schemata - a deliberate
@@ -87,6 +87,22 @@ public sealed class SqlServerDialect : ISqlDialect
/// <inheritdoc />
public string SingleRowLimitSuffix => string.Empty;
/// <inheritdoc />
// Parenthesised: TOP (n) is the documented modern form, and TOP n without parentheses is the
// backwards-compatibility spelling.
public string RowLimitPrefix(int rows)
{
ArgumentOutOfRangeException.ThrowIfLessThan(rows, 1);
return "TOP (" + rows.ToString(System.Globalization.CultureInfo.InvariantCulture) + ") ";
}
/// <inheritdoc />
public string RowLimitSuffix(int rows)
{
ArgumentOutOfRangeException.ThrowIfLessThan(rows, 1);
return string.Empty;
}
/// <inheritdoc/>
public string ListSchemasSql =>
"SELECT DISTINCT TABLE_SCHEMA FROM INFORMATION_SCHEMA.TABLES ORDER BY TABLE_SCHEMA";
@@ -28,7 +28,7 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Sql;
/// manufacture an exception to force backoff.</para>
/// </summary>
public sealed class SqlDriver
: IDriver, ITagDiscovery, IReadable, ISubscribable, IHostConnectivityProbe, ISkippedTagReporter, IAsyncDisposable
: IDriver, ITagDiscovery, IReadable, IBatchSubscribable, IHostConnectivityProbe, ISkippedTagReporter, IAsyncDisposable
{
/// <summary>Authored tags this driver could not build into its poll table.</summary>
private readonly SkippedTagLedger _skippedTags = new();
@@ -156,6 +156,16 @@ public sealed class SqlDriver
private DriverHealth _health = new(DriverState.Unknown, null, null);
/// <summary>Guards the lifecycle-generation bump against the generation-checked lifecycle health write.</summary>
private readonly object _healthLock = new();
/// <summary>
/// Bumped by every <see cref="InitializeAsync"/> and <see cref="ShutdownAsync"/>. A lifecycle write from
/// a superseded call, and a poll that STARTED under an older generation (a stale poll outliving the
/// engine's 5 s teardown wait, R06:S-9), can no longer overwrite the newer state.
/// </summary>
private long _lifecycleGeneration;
/// <summary>
/// The exception instance <see cref="ReadAsync"/> has already turned into a health verdict, held only
/// until <see cref="HandlePollError"/> — which the poll engine calls with that same object — has
@@ -173,6 +183,9 @@ public sealed class SqlDriver
/// <summary>Occurs when a subscribed tag's value or quality changes.</summary>
public event EventHandler<DataChangeEventArgs>? OnDataChange;
/// <summary>Occurs once per poll cycle that produced at least one change, carrying every change of that cycle.</summary>
public event EventHandler<DataChangeBatchEventArgs>? OnDataChangeBatch;
/// <summary>Occurs when the database endpoint transitions between reachable and unreachable.</summary>
public event EventHandler<HostStatusChangedEventArgs>? OnHostStatusChanged;
@@ -230,6 +243,9 @@ public sealed class SqlDriver
OnDataChange?.Invoke(this, new DataChangeEventArgs(handle, tagRef, snapshot)),
onError: HandlePollError,
backoffCap: PollBackoffCap,
// R02:P1 — one batch per poll burst, so DriverInstanceActor forwards one message per cycle instead
// of one per tag (it subscribes exactly one of the two events).
onBatch: (handle, items) => OnDataChangeBatch?.Invoke(this, new DataChangeBatchEventArgs(handle, items)),
// The engine owns no Meter (Core.Abstractions takes no metrics dependency), so this
// closure IS the production consumer of its overrun signal.
onOverrun: _ => OtOpcUaTelemetry.PollOverruns.Add(1,
@@ -261,6 +277,10 @@ public sealed class SqlDriver
_factory = _explicitFactory ?? dialect.Factory;
_connectionString = connectionString;
Endpoint = DescribeEndpoint(connectionString);
// The reader being replaced owns a SemaphoreSlim (R06:S-3). Disposing it is safe even with a stale
// poll still inside it: the gate goes only when the last in-flight group releases it.
var previous = _reader;
_reader = new SqlPollReader(
_factory,
connectionString,
@@ -271,6 +291,7 @@ public sealed class SqlDriver
nullIsBad: options.NullIsBad,
resolve: rawPath => _resolver.TryResolve(rawPath, out var definition) ? definition : null,
logger: _logger);
previous?.Dispose();
}
/// <summary>True when the supplied DriverConfig JSON carries a real body. The bootstrapper always passes
@@ -347,20 +368,39 @@ public sealed class SqlDriver
// identifier.
public async Task InitializeAsync(string driverConfigJson, CancellationToken cancellationToken)
{
WriteHealth(new DriverHealth(DriverState.Initializing, null, null));
var generation = BeginLifecycle();
// LastSuccessfulRead is PRESERVED across the lifecycle states — it is the watchdog's evidence clock.
WriteHealthIfCurrent(generation, new DriverHealth(DriverState.Initializing, ReadHealth().LastSuccessfulRead, null));
// adopt a changed config BEFORE the tag table is built, so the table and the connection it
// will be polled over come from the same revision. ApplyBinding takes the options, dialect and
// connection string as one unit; adopting options alone would poll a NEW tag set through the OLD
// database, which is why this driver was excluded from the first pass. A null rebinder (direct
// construction) or an empty/placeholder document keeps the constructor-supplied trio.
if (_rebind is not null && HasConfigBody(driverConfigJson))
try
{
var binding = _rebind(driverConfigJson);
ApplyBinding(binding.Options, binding.Dialect, binding.ConnectionString);
}
if (_rebind is not null && HasConfigBody(driverConfigJson))
{
var binding = _rebind(driverConfigJson);
ApplyBinding(binding.Options, binding.Dialect, binding.ConnectionString);
}
BuildTagTable();
BuildTagTable();
}
catch (Exception ex)
{
// Review m-4: an unresolvable connectionStringRef or a malformed config threw here AFTER Initializing
// was written, and no poll may end Initializing — so the driver latched it with no LastError. Fault
// it like the liveness stage does: exception TYPE only on the operator surface (the factory's own
// messages are already sanitized, but this method cannot rely on that), full exception to the log.
var message =
$"Sql driver '{_driverInstanceId}' could not adopt its configuration: {ex.GetType().Name}. " +
"Check the connectionStringRef and the driver config JSON.";
_logger.LogError(ex, "Sql driver {DriverInstanceId} configuration could not be adopted.", _driverInstanceId);
if (WriteHealthIfCurrent(generation, new DriverHealth(DriverState.Faulted, ReadHealth().LastSuccessfulRead, message)))
TransitionHostTo(HostState.Stopped);
throw;
}
try
{
@@ -369,7 +409,7 @@ public sealed class SqlDriver
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
// The caller tore the initialization down; not a database verdict.
WriteHealth(new DriverHealth(DriverState.Unknown, null, null));
WriteHealthIfCurrent(generation, new DriverHealth(DriverState.Unknown, ReadHealth().LastSuccessfulRead, null));
throw;
}
catch (Exception ex)
@@ -385,8 +425,8 @@ public sealed class SqlDriver
$"Check the database is reachable and the connection string is valid.";
_logger.LogError(ex, "Sql driver {DriverInstanceId} liveness check against {Endpoint} failed.",
_driverInstanceId, Endpoint);
WriteHealth(new DriverHealth(DriverState.Faulted, null, message));
TransitionHostTo(HostState.Stopped);
if (WriteHealthIfCurrent(generation, new DriverHealth(DriverState.Faulted, ReadHealth().LastSuccessfulRead, message)))
TransitionHostTo(HostState.Stopped);
throw new InvalidOperationException(message, ex);
}
@@ -399,7 +439,7 @@ public sealed class SqlDriver
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
WriteHealth(new DriverHealth(DriverState.Unknown, null, null));
WriteHealthIfCurrent(generation, new DriverHealth(DriverState.Unknown, ReadHealth().LastSuccessfulRead, null));
throw;
}
catch (Exception ex)
@@ -415,13 +455,13 @@ public sealed class SqlDriver
_logger.LogError(ex,
"Sql driver {DriverInstanceId} catalog validation against {Endpoint} failed.",
_driverInstanceId, Endpoint);
WriteHealth(new DriverHealth(DriverState.Faulted, null, message));
TransitionHostTo(HostState.Stopped);
if (WriteHealthIfCurrent(generation, new DriverHealth(DriverState.Faulted, ReadHealth().LastSuccessfulRead, message)))
TransitionHostTo(HostState.Stopped);
throw new InvalidOperationException(message, ex);
}
WriteHealth(new DriverHealth(DriverState.Healthy, DateTime.UtcNow, null));
TransitionHostTo(HostState.Running);
if (WriteHealthIfCurrent(generation, new DriverHealth(DriverState.Healthy, DateTime.UtcNow, null)))
TransitionHostTo(HostState.Running);
_logger.LogInformation(
"Sql driver {DriverInstanceId} initialized against {Endpoint} with {TagCount} authored tag(s).",
_driverInstanceId, Endpoint, Tags.Count);
@@ -437,9 +477,10 @@ public sealed class SqlDriver
/// <inheritdoc />
public async Task ShutdownAsync(CancellationToken cancellationToken)
{
var generation = BeginLifecycle();
var lastRead = ReadHealth().LastSuccessfulRead;
await TeardownAsync().ConfigureAwait(false);
WriteHealth(new DriverHealth(DriverState.Unknown, lastRead, null));
WriteHealthIfCurrent(generation, new DriverHealth(DriverState.Unknown, lastRead, null));
}
/// <inheritdoc />
@@ -512,11 +553,37 @@ public sealed class SqlDriver
// unreachable" and degrade a perfectly healthy driver.
ArgumentNullException.ThrowIfNull(fullReferences);
// The generation this read started under: if a Shutdown/Initialize lands while it is in flight, its
// verdict describes a superseded configuration and must not touch the newer health (R06:S-9).
var generation = Interlocked.Read(ref _lifecycleGeneration);
return await ReadThroughAsync(_reader, fullReferences, generation, cancellationToken).ConfigureAwait(false);
}
/// <summary>
/// <see cref="ReadAsync"/>'s body against a specific reader — the one the caller captured. Internal so a
/// test can drive the "reader replaced between capture and use" race deterministically.
/// </summary>
internal async Task<IReadOnlyList<DataValueSnapshot>> ReadThroughAsync(
SqlPollReader reader, IReadOnlyList<string> fullReferences, long generation, CancellationToken cancellationToken)
{
try
{
var snapshots = await _reader.ReadAsync(fullReferences, cancellationToken).ConfigureAwait(false);
ObservePollOutcome(snapshots);
return snapshots;
var result = await reader.ReadDetailedAsync(fullReferences, cancellationToken).ConfigureAwait(false);
ObservePollOutcome(result.Snapshots, result.AnsweredRefusalGroups, generation);
return result.Snapshots;
}
catch (SqlPollReaderDisposedException ex) when (!cancellationToken.IsCancellationRequested)
{
// A rebind swapped the reader under this poll (review m-1: a redeploy re-initialises without
// teardown, so the engine keeps polling). Retry ONCE on the current reader; if there is no newer
// one (the driver itself was disposed), hand the engine an exception it is told not to classify —
// it used to Degrade the driver as "poll failed: OperationCanceledException".
var current = Volatile.Read(ref _reader);
if (!ReferenceEquals(current, reader))
return await ReadThroughAsync(current, fullReferences, generation, cancellationToken).ConfigureAwait(false);
Volatile.Write(ref _classifiedByRead, ex);
throw;
}
catch (OperationCanceledException)
{
@@ -530,8 +597,12 @@ public sealed class SqlDriver
_driverInstanceId, Endpoint);
// Type name only — never ex.Message (see InitializeAsync for why); the full exception is already
// on the log sink via the LogWarning exception parameter above.
Degrade($"Sql driver '{_driverInstanceId}' could not reach {Endpoint}: {ex.GetType().Name}.");
TransitionHostTo(HostState.Stopped);
if (IsCurrentLifecycle(generation))
{
Degrade($"Sql driver '{_driverInstanceId}' could not reach {Endpoint}: {ex.GetType().Name}.");
TransitionHostTo(HostState.Stopped);
}
// Mark THIS exception object as already classified before it goes back to the poll engine, which
// routes it to HandlePollError next. The mark is what makes the deferral there exact — see that
// method for why inferring it from the exception's type was not.
@@ -603,9 +674,21 @@ public sealed class SqlDriver
/// cadence rather than backed off from.</para>
/// </summary>
/// <param name="snapshots">The snapshots the reader returned for one poll.</param>
internal void ObservePollOutcome(IReadOnlyList<DataValueSnapshot> snapshots)
/// <param name="answeredRefusals">
/// Groups the database answered but the driver refused (an over-cap result set, R06:S-1). Their
/// members carry <see cref="SqlStatusCodes.BadConfigurationError"/>, which alone is not answer evidence
/// (the planner mints it before any I/O), so the count is what makes them count as the database
/// answering.
/// </param>
/// <param name="generation">
/// The lifecycle generation the poll started under; a superseded one is ignored outright. Null (tests)
/// means "current".
/// </param>
internal void ObservePollOutcome(
IReadOnlyList<DataValueSnapshot> snapshots, int answeredRefusals = 0, long? generation = null)
{
if (snapshots.Count == 0) return;
if (generation is { } started && !IsCurrentLifecycle(started)) return;
var good = 0;
var unreachable = 0;
@@ -620,6 +703,8 @@ public sealed class SqlDriver
if (IsAnswerEvidence(snapshot.StatusCode)) answered++;
}
answered += answeredRefusals;
if (unreachable > 0)
{
Degrade(
@@ -655,17 +740,12 @@ public sealed class SqlDriver
if (answered == 0) return; // authoring-class Bad only — not a statement about the database.
if (good == 0)
{
// The database answered, just not with a value: an absent row, a NULL cell, a cell that would
// not coerce. Those are data/authoring facts, so the health STATE is deliberately left exactly
// as it was — but they are still proof the transport works, and the evidence clock the
// comms-loss watchdog reads must say so.
TouchLastSuccessfulRead();
TransitionHostTo(HostState.Running);
return;
}
// Good values, OR a database that answered with refusals only — an absent row, a NULL cell, an
// uncoercible cell, an over-cap result set. A refusal is the database answering: it advances the
// evidence clock AND restores Healthy after a comms fault, exactly as a Good read does. Leaving the
// state as it was latched a recovered database Degraded behind every poll that read only refusals.
//
// Route through the Faulted guard, not a raw WriteHealth: a poll still in flight when a concurrent
// ReinitializeAsync has recorded Faulted (the engine's ~5 s teardown wait is shorter than the 15 s
// default operationTimeout, so a stale poll can still complete afterwards) must not flip Faulted back
@@ -792,7 +872,11 @@ public sealed class SqlDriver
/// </summary>
/// <param name="project">Builds the snapshot to publish from the current one.</param>
private void UpdateHealthUnlessFaulted(Func<DriverHealth, DriverHealth> project)
=> UpdateHealth(current => current.State == DriverState.Faulted ? null : project(current));
// Initializing too: only the initialize's own final write may end it — a poll landing mid-init
// (a re-init without shutdown keeps the engine running) used to flip Initializing → Healthy.
=> UpdateHealth(current => current.State is DriverState.Faulted or DriverState.Initializing
? null
: project(current));
/// <summary>
/// The one read-modify-write of the health field, as a compare-and-swap retry loop.
@@ -823,13 +907,31 @@ public sealed class SqlDriver
/// <summary>Barrier-protected read of the multi-threaded health field.</summary>
private DriverHealth ReadHealth() => Volatile.Read(ref _health);
/// <summary>Starts a lifecycle operation and returns its generation; every later one supersedes it.</summary>
private long BeginLifecycle()
{
lock (_healthLock) return ++_lifecycleGeneration;
}
/// <summary>Whether <paramref name="generation"/> is still the newest lifecycle operation.</summary>
private bool IsCurrentLifecycle(long generation) => Interlocked.Read(ref _lifecycleGeneration) == generation;
/// <summary>The current lifecycle generation. Test observation of the S-9 guard.</summary>
internal long LifecycleGenerationForTests => Interlocked.Read(ref _lifecycleGeneration);
/// <summary>
/// Unconditional publish of a new health snapshot, for the lifecycle transitions that are
/// last-writer-wins by definition (Initialize / Shutdown). Everything driven by a poll goes through
/// <see cref="UpdateHealth"/> instead, because it has to read the current snapshot first.
/// A lifecycle write that applies only while <paramref name="generation"/> is current — atomic against
/// <see cref="BeginLifecycle"/>, so a superseded initialize/shutdown never overwrites a newer one.
/// </summary>
/// <param name="value">The health snapshot to publish.</param>
private void WriteHealth(DriverHealth value) => Volatile.Write(ref _health, value);
private bool WriteHealthIfCurrent(long generation, DriverHealth value)
{
lock (_healthLock)
{
if (_lifecycleGeneration != generation) return false;
Volatile.Write(ref _health, value);
return true;
}
}
/// <summary>Records a host-state change and raises <see cref="OnHostStatusChanged"/> — on transitions only.</summary>
private void TransitionHostTo(HostState newState)
@@ -867,10 +969,15 @@ public sealed class SqlDriver
table[entry.RawPath] = definition;
else
{
blobSkips.Add(new SkippedTag(entry.RawPath, "TagConfig did not map to a definition"));
// The reason is the DEPLOY GATE's own explanation (R06:S-4) — SqlTagDefinitionFactory.Inspect
// asks this same parser and then says why — so /hosts names e.g. the deferred Query model
// instead of a generic "did not map" for a tag the gate already warned about.
var reason = SqlTagDefinitionFactory.Inspect(entry.TagConfig).FirstOrDefault()
?? "TagConfig did not map to a definition";
blobSkips.Add(new SkippedTag(entry.RawPath, reason));
_logger.LogWarning(
"Sql tag config did not map to a definition; skipping. Driver={DriverInstanceId} RawPath={RawPath}",
_driverInstanceId, entry.RawPath);
"Sql tag config did not map to a definition; skipping. Driver={DriverInstanceId} RawPath={RawPath} Reason={Reason}",
_driverInstanceId, entry.RawPath, reason);
}
}
catch (Exception ex)
@@ -1150,7 +1257,20 @@ public sealed class SqlDriver
/// <c>await using</c> does not leak the poll loops.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
public async ValueTask DisposeAsync() => await TeardownAsync().ConfigureAwait(false);
public async ValueTask DisposeAsync()
{
await TeardownAsync().ConfigureAwait(false);
// Only on the FINAL dispose: a Shutdown → Initialize cycle on the same instance keeps polling
// through this reader when the init does not rebind (no rebinder / placeholder config).
_reader.Dispose();
}
/// <summary>Re-runs <see cref="ApplyBinding"/> on the current binding — a real reader swap, for the m-1 race test.</summary>
internal void RebindForTests() => ApplyBinding(_options, _dialect, _connectionString);
/// <summary>The live poll reader. Test observation of the S-3 lifecycle only.</summary>
internal SqlPollReader ReaderForTests => _reader;
/// <summary>
/// Shared teardown: stops every polled subscription and drops the authored table. Idempotent — safe
@@ -194,8 +194,14 @@ public static class SqlDriverFactoryExtensions
}
catch (JsonException ex)
{
// The parser's message AND the exception carrying it are withheld (R06:S-8), matching
// SqlDriverProbe and SqlDriverBrowser: System.Text.Json quotes the offending token, and a pasted
// connection-string literal is exactly the malformed text that lands here. This message becomes
// InitializeFailed -> health LastError -> /hosts. Line/byte position carry no content, so they stay.
throw new InvalidOperationException(
$"Sql driver config for '{driverInstanceId}' is not valid JSON: {ex.Message}", ex);
$"Sql driver config for '{driverInstanceId}' is not valid JSON (line "
+ $"{(ex.LineNumber is { } line ? line + 1 : (object)"?")}, byte {ex.BytePositionInLine?.ToString(System.Globalization.CultureInfo.InvariantCulture) ?? "?"}). The parser's message is withheld because the malformed text may "
+ "carry a connection string.");
}
}
@@ -230,15 +230,23 @@ public static class SqlGroupPlanner
values.Add(keyValue);
}
// Row cap (R06:S-1): one row per distinct key is all this plan can use. It reads up to TWICE that, so a
// handful of stray duplicates stays inside the cap and is attributed PER KEY by the reader (only the
// duplicated key's tags go BadConfigurationError) — and asks for one row more than that, so only a
// gross excess (a history/events table, unattributable by construction because the rest is unread)
// fails the whole group. Memory stays O(keys) either way.
var rowCap = values.Count * 2;
var sql = new StringBuilder("SELECT ")
.Append(dialect.RowLimitPrefix(rowCap + 1))
.Append(QuoteList(selected, dialect))
.Append(" FROM ").Append(QuoteQualifiedName(first.Table, dialect))
.Append(" WHERE ").Append(dialect.QuoteIdentifier(keyColumn))
.Append(" IN (").AppendJoin(", ", markers).Append(')')
.Append(dialect.RowLimitSuffix(rowCap + 1))
.ToString();
return new SqlQueryPlan(
SqlTagModel.KeyValue, groupKey, sql, markers, bindNames, values, members, selected);
SqlTagModel.KeyValue, groupKey, sql, markers, bindNames, values, members, selected, rowCap);
}
/// <summary>
@@ -270,14 +278,18 @@ public static class SqlGroupPlanner
// A where-pair wins over topByTimestamp; the parser already guarantees exactly one is populated.
if (!Blank(first.RowSelectorColumn) && first.RowSelectorValue is not null)
{
// Capped at TWO rows (R06:S-1): the selector must identify one row, and the second is the proof it
// does not. A TOP 1 alone would hide that ambiguity (see SqlPollReader.SelectWideRow); no cap at all
// materialised every matching row of an events table on every poll.
var marker = dialect.ParameterMarker(RowSelectorBaseName);
var sql = string.Concat(
"SELECT ", columns, " FROM ", table,
" WHERE ", dialect.QuoteIdentifier(first.RowSelectorColumn!), " = ", marker);
"SELECT ", dialect.RowLimitPrefix(2), columns, " FROM ", table,
" WHERE ", dialect.QuoteIdentifier(first.RowSelectorColumn!), " = ", marker,
dialect.RowLimitSuffix(2));
return new SqlQueryPlan(
SqlTagModel.WideRow, groupKey, sql,
[marker], [dialect.ParameterBindName(RowSelectorBaseName)],
[first.RowSelectorValue], members, selected);
[first.RowSelectorValue], members, selected, RowCap: 1);
}
if (!Blank(first.RowSelectorTopByTimestamp))
@@ -1,5 +1,7 @@
using System.Collections.Concurrent;
using System.Data.Common;
using System.Diagnostics;
using System.Runtime.CompilerServices;
using System.Globalization;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
@@ -36,7 +38,7 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Sql;
/// no subscription state and no connection between polls, so the driver shell can wrap it in
/// <see cref="PollGroupEngine"/> and own those concerns.</para>
/// </summary>
public sealed class SqlPollReader
public sealed class SqlPollReader : IDisposable
{
/// <summary>Minimum spacing between "your source violates the query contract" warnings, per reader.</summary>
private static readonly TimeSpan ContractWarningInterval = TimeSpan.FromMinutes(1);
@@ -52,13 +54,41 @@ public sealed class SqlPollReader
/// <summary>
/// Caps concurrently executing group queries — and therefore concurrently held connections
/// (design §8.4). Never disposed: a <see cref="SemaphoreSlim"/> only needs disposal once its
/// <c>AvailableWaitHandle</c> has been touched, and not disposing it also removes the hazard of a
/// timed-out-but-still-running group releasing into a disposed semaphore.
/// (design §8.4). Disposed by <see cref="Dispose"/> only once every group that ever acquired it has
/// released it (<see cref="_refs"/>), so a timed-out-but-still-running group can never release into a
/// disposed semaphore (R06:S-3 — it used to be never disposed, one per reinitialize).
/// </summary>
private readonly SemaphoreSlim _gate;
private long _lastContractWarningTicks;
/// <summary>
/// Owner reference (1) plus one per in-flight group. <see cref="Dispose"/> drops the owner's; whoever
/// takes it to zero disposes <see cref="_gate"/>. Zero is terminal — no group can start after it.
/// </summary>
private int _refs = 1;
private int _disposed;
/// <summary>
/// Per-key last-warned instant for the per-poll warnings (R06:S-6): a frozen group's BadTimeout and a
/// failing group's query error used to log every poll, per group, indefinitely — 20 groups at 1 s was
/// 20 lines a second carrying SQL text. Keyed by the plan's SQL text, so bounded by the plan count.
/// </summary>
private readonly ConcurrentDictionary<string, long> _lastWarnedTicks = new(StringComparer.Ordinal);
/// <summary>Tags whose uncoercible cell has been warned about — once per tag per reader (the refusal rule).</summary>
private readonly ConcurrentDictionary<string, byte> _coerceWarned = new(StringComparer.Ordinal);
/// <summary>
/// The resolved definitions, slot map and plans for each reference list a caller polls (R06:S-7). A
/// poll loop passes the SAME list instance every cycle, so it is planned once instead of every poll.
/// Weakly keyed: an unsubscribed list (and its entry) is collected with it. Invalidation is structural —
/// each entry remembers the definition INSTANCES it was built from, and any reference resolving to a
/// different instance (a catalog-gate swap) rebuilds it.
/// </summary>
private readonly ConditionalWeakTable<IReadOnlyList<string>, PollShape> _shapes = new();
private int _plansBuilt;
/// <summary>Initializes a new instance of the <see cref="SqlPollReader"/> class.</summary>
/// <param name="factory">
@@ -154,26 +184,87 @@ public sealed class SqlPollReader
/// <exception cref="DbException">The database could not be reached (opening a connection failed).</exception>
public async Task<IReadOnlyList<DataValueSnapshot>> ReadAsync(
IReadOnlyList<string> fullReferences, CancellationToken cancellationToken)
=> (await ReadDetailedAsync(fullReferences, cancellationToken).ConfigureAwait(false)).Snapshots;
/// <summary>
/// <see cref="ReadAsync"/> plus what the snapshots alone cannot say: how many groups the database
/// ANSWERED with more rows than the plan can use (R06:S-1). Those groups publish
/// <see cref="SqlStatusCodes.BadConfigurationError"/> — the same code an un-plannable definition gets
/// before any I/O — so the driver needs this count to treat them as the answer evidence they are.
/// </summary>
/// <param name="fullReferences">The RawPaths to read.</param>
/// <param name="cancellationToken">The caller's token; see <see cref="ReadAsync"/>.</param>
/// <returns>The snapshots and the answered-refusal count.</returns>
public async Task<SqlPollResult> ReadDetailedAsync(
IReadOnlyList<string> fullReferences, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(fullReferences);
var results = new DataValueSnapshot[fullReferences.Count];
if (results.Length == 0) return results;
if (results.Length == 0) return new SqlPollResult(results, 0);
var readAt = DateTime.UtcNow;
var shape = GetShape(fullReferences);
// Resolve first. A miss is this tag's own problem (BadNodeIdUnknown) and must not reach the planner,
// whose members are the sole thing the slice-back walks.
// A miss is this tag's own problem (BadNodeIdUnknown) and never reaches the planner, whose members
// are the sole thing the slice-back walks.
for (var i = 0; i < shape.Resolved.Length; i++)
{
if (shape.Resolved[i] is null)
results[i] = new DataValueSnapshot(null, SqlStatusCodes.BadNodeIdUnknown, null, readAt);
}
var answeredRefusals = shape.Definitions.Count > 0
? await ReadGroupsAsync(shape, results, readAt, cancellationToken).ConfigureAwait(false)
: 0;
// Fail-safe. Nothing above should leave a hole, but "N in, N out" is a contract the caller indexes
// blind — a null here would be a NullReferenceException in the publish fan-out, far from its cause.
for (var i = 0; i < results.Length; i++)
results[i] ??= new DataValueSnapshot(null, SqlStatusCodes.BadInternalError, null, readAt);
return new SqlPollResult(results, answeredRefusals);
}
/// <summary>How many times a reference list has been (re)planned. Test observation of the S-7 cache.</summary>
internal int PlansBuiltForTests => Volatile.Read(ref _plansBuilt);
/// <summary>
/// Returns the cached shape for <paramref name="fullReferences"/>, rebuilding it when any reference now
/// resolves to a different definition instance than the one it was built from.
/// </summary>
private PollShape GetShape(IReadOnlyList<string> fullReferences)
{
if (_shapes.TryGetValue(fullReferences, out var cached) && cached.Resolved.Length == fullReferences.Count)
{
var current = true;
for (var i = 0; i < fullReferences.Count && current; i++)
{
var definition = fullReferences[i] is { } reference ? _resolve(reference) : null;
current = ReferenceEquals(definition, cached.Resolved[i]);
}
if (current) return cached;
}
var shape = BuildShape(fullReferences);
_shapes.AddOrUpdate(fullReferences, shape);
return shape;
}
/// <summary>Resolves, slots and plans one reference list.</summary>
private PollShape BuildShape(IReadOnlyList<string> fullReferences)
{
Interlocked.Increment(ref _plansBuilt);
var resolved = new SqlTagDefinition?[fullReferences.Count];
var definitions = new List<SqlTagDefinition>(fullReferences.Count);
var slots = new Dictionary<SqlTagDefinition, List<int>>();
for (var i = 0; i < fullReferences.Count; i++)
{
var definition = fullReferences[i] is { } reference ? _resolve(reference) : null;
if (definition is null)
{
results[i] = new DataValueSnapshot(null, SqlStatusCodes.BadNodeIdUnknown, null, readAt);
continue;
}
resolved[i] = definition;
if (definition is null) continue;
definitions.Add(definition);
if (!slots.TryGetValue(definition, out var positions))
@@ -181,45 +272,54 @@ public sealed class SqlPollReader
positions.Add(i);
}
if (definitions.Count > 0)
await ReadGroupsAsync(definitions, slots, results, readAt, cancellationToken).ConfigureAwait(false);
if (definitions.Count == 0) return new PollShape(resolved, definitions, slots, [], PlanningFailed: false);
// Fail-safe. Nothing above should leave a hole, but "N in, N out" is a contract the caller indexes
// blind — a null here would be a NullReferenceException in the publish fan-out, far from its cause.
for (var i = 0; i < results.Length; i++)
results[i] ??= new DataValueSnapshot(null, SqlStatusCodes.BadInternalError, null, readAt);
return results;
}
/// <summary>Plans the resolved definitions, runs every group concurrently, and slices the results back.</summary>
private async Task ReadGroupsAsync(
List<SqlTagDefinition> definitions,
Dictionary<SqlTagDefinition, List<int>> slots,
DataValueSnapshot[] results,
DateTime readAt,
CancellationToken cancellationToken)
{
IReadOnlyList<SqlQueryPlan> plans;
try
{
plans = SqlGroupPlanner.Plan(definitions, _dialect);
return new PollShape(resolved, definitions, slots, SqlGroupPlanner.Plan(definitions, _dialect), PlanningFailed: false);
}
catch (Exception ex) when (ex is ArgumentException or NotSupportedException)
{
// A definition with a broken invariant — SqlEquipmentTagParser rejects all of these up front, so
// reaching here means a definition was built past the parser. It is an authoring fault, not a
// database fault: Bad-code the planned tags rather than throwing and triggering connection backoff.
// Logged once per (re)plan, which the cache makes once per shape rather than once per poll.
_logger.LogError(ex, "Sql poll planning failed for {Count} tag(s); publishing BadConfigurationError.",
definitions.Count);
foreach (var definition in definitions)
return new PollShape(resolved, definitions, slots, [], PlanningFailed: true);
}
}
/// <summary>One reference list's resolved definitions, slot map and plans — immutable once built.</summary>
private sealed record PollShape(
SqlTagDefinition?[] Resolved,
List<SqlTagDefinition> Definitions,
Dictionary<SqlTagDefinition, List<int>> Slots,
IReadOnlyList<SqlQueryPlan> Plans,
bool PlanningFailed);
/// <summary>
/// Runs every planned group concurrently and slices the results back. Returns how many groups came
/// back with an answered refusal (over the row cap, or a duplicated key).
/// </summary>
private async Task<int> ReadGroupsAsync(
PollShape shape,
DataValueSnapshot[] results,
DateTime readAt,
CancellationToken cancellationToken)
{
var slots = shape.Slots;
var plans = shape.Plans;
if (shape.PlanningFailed)
{
foreach (var definition in shape.Definitions)
{
if (!slots.TryGetValue(definition, out var positions)) continue;
var snapshot = new DataValueSnapshot(null, SqlStatusCodes.BadConfigurationError, null, readAt);
foreach (var position in positions) results[position] = snapshot;
}
return;
return 0;
}
// Groups run concurrently; _gate — not the task count — is what bounds open connections. Each group
@@ -233,8 +333,18 @@ public sealed class SqlPollReader
// leaves a sibling group running against a connection nobody is watching.
var outcomes = await Task.WhenAll(groups).ConfigureAwait(false);
// Surfaced AFTER WhenAll, as one typed exception: a group throwing it would turn WhenAll into a plain
// TaskCanceledException and the caller could no longer tell "superseded reader" from a caller cancel.
if (outcomes.Any(o => ReferenceEquals(o, GroupResult.ReaderDisposed))) throw new SqlPollReaderDisposedException();
var answeredRefusals = 0;
for (var g = 0; g < plans.Count; g++)
Slice(plans[g], outcomes[g], slots, results, readAt);
{
var refusedSome = Slice(plans[g], outcomes[g], slots, results, readAt);
if (refusedSome || ReferenceEquals(outcomes[g], GroupResult.RowCapExceeded)) answeredRefusals++;
}
return answeredRefusals;
}
/// <summary>
@@ -273,6 +383,22 @@ public sealed class SqlPollReader
/// inside the deadline, so a poll behind a wedged group still returns on time — as BadTimeout.</para>
/// </summary>
private async Task<GroupResult> RunGroupAsync(SqlQueryPlan plan, CancellationToken cancellationToken)
{
// A reader replaced by a reinitialize (and disposed) can still be holding a stale poll: it runs no
// new group. Cancellation is the honest answer — the poll that asked has been superseded.
if (!TryAddRef()) return GroupResult.ReaderDisposed;
try
{
return await RunGroupCoreAsync(plan, cancellationToken).ConfigureAwait(false);
}
finally
{
ReleaseRef();
}
}
private async Task<GroupResult> RunGroupCoreAsync(SqlQueryPlan plan, CancellationToken cancellationToken)
{
var clock = Stopwatch.StartNew();
@@ -288,6 +414,9 @@ public sealed class SqlPollReader
if (budget <= TimeSpan.Zero) return GroupResult.TimedOut;
deadline.CancelAfter(budget);
// The work holds its OWN reference: it can outlive this method (a timed-out group is abandoned
// still running) and its finally releases the slot into _gate, which must therefore still exist.
AddRefForWork();
work = Task.Run(
async () =>
{
@@ -299,6 +428,7 @@ public sealed class SqlPollReader
{
_gate.Release();
deadline.Dispose();
ReleaseRef();
}
},
CancellationToken.None);
@@ -334,6 +464,41 @@ public sealed class SqlPollReader
}
}
/// <summary>
/// Releases the concurrency gate once no group still holds it. Idempotent; safe while groups are in
/// flight (the last one out disposes it). A disposed reader starts no new group.
/// </summary>
public void Dispose()
{
if (Interlocked.Exchange(ref _disposed, 1) == 0) ReleaseRef();
}
/// <summary>True once <see cref="_gate"/> has actually been disposed. Test observation only.</summary>
internal bool GateDisposedForTests => Volatile.Read(ref _refs) == 0;
/// <summary>Takes a reference unless the reader is already fully released.</summary>
private bool TryAddRef()
{
while (true)
{
// A disposed reader starts no new group even while older ones are still in flight (review m-2):
// the gate would survive until they finish, but a replaced reader must not take new work.
if (Volatile.Read(ref _disposed) != 0) return false;
var current = Volatile.Read(ref _refs);
if (current == 0) return false;
if (Interlocked.CompareExchange(ref _refs, current + 1, current) == current) return true;
}
}
/// <summary>A further reference for work started under an already-held one (so never from zero).</summary>
private void AddRefForWork() => Interlocked.Increment(ref _refs);
/// <summary>Drops a reference; the one that reaches zero disposes the gate.</summary>
private void ReleaseRef()
{
if (Interlocked.Decrement(ref _refs) == 0) _gate.Dispose();
}
/// <summary>How much of this group's <c>operationTimeout</c> budget is left; never negative.</summary>
private TimeSpan Remaining(Stopwatch clock)
{
@@ -346,11 +511,14 @@ public sealed class SqlPollReader
{
// The table is named for the same reason the contract warnings name it: under a partial outage this
// line repeats every poll, and "which source is frozen" is the only question the operator has.
_logger.LogWarning(
"Sql poll group ({Model}, '{Table}', {Members} tag(s)) exceeded the {Timeout} ms operation " +
"timeout; publishing BadTimeout.",
plan.Model, plan.Members[0].Table, plan.Members.Count,
(int)_operationTimeout.TotalMilliseconds);
if (ShouldWarn("timeout:" + plan.SqlText))
{
_logger.LogWarning(
"Sql poll group ({Model}, '{Table}', {Members} tag(s)) exceeded the {Timeout} ms operation " +
"timeout; publishing BadTimeout. Repeats for this group are suppressed for {Interval} s.",
plan.Model, plan.Members[0].Table, plan.Members.Count,
(int)_operationTimeout.TotalMilliseconds, (int)ContractWarningInterval.TotalSeconds);
}
AbandonedTask.Detach(work);
return GroupResult.TimedOut;
@@ -412,9 +580,13 @@ public sealed class SqlPollReader
{
// The connection opened, so the server is up — this query is what failed (an identifier that
// is not in the catalog, a lock, a permission). That is this group's problem alone.
_logger.LogWarning(
ex, "Sql poll group ({Model}, {Members} tag(s)) failed: {Sql}",
plan.Model, plan.Members.Count, plan.SqlText);
if (ShouldWarn("failed:" + plan.SqlText))
{
_logger.LogWarning(
ex, "Sql poll group ({Model}, {Members} tag(s)) failed: {Sql}. Repeats for this group are " +
"suppressed for {Interval} s.",
plan.Model, plan.Members.Count, plan.SqlText, (int)ContractWarningInterval.TotalSeconds);
}
return GroupResult.Failed;
}
}
@@ -442,8 +614,11 @@ public sealed class SqlPollReader
columnIndex[selected[c]] = c;
}
// The client-side half of the row cap (R06:S-1): the statement already asks for RowCap + 1 rows, but a
// provider that cannot spell a limit must not turn that into an unbounded read either.
var limit = plan.RowCap is { } cap ? cap + 1 : int.MaxValue;
var rows = new List<object?[]>();
while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
while (rows.Count < limit && await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
{
var row = new object?[selected.Count];
for (var c = 0; c < selected.Count; c++)
@@ -451,6 +626,27 @@ public sealed class SqlPollReader
rows.Add(row);
}
if (plan.RowCap is { } rowCap && rows.Count > rowCap)
{
// Per plan, not reader-wide: with two ambiguous groups the reader-wide limiter could name only the
// first table forever, and with health still Healthy this line is the operator's only pointer.
if (ShouldWarn("rowcap:" + plan.SqlText))
{
var first = plan.Members[0];
var selector = plan.Model == SqlTagModel.KeyValue
? string.Concat(first.KeyColumn, " IN (", (rowCap / 2).ToString(CultureInfo.InvariantCulture), " key(s))")
: string.Concat(first.RowSelectorColumn, " = ", first.RowSelectorValue);
_logger.LogWarning(
"Sql poll source '{Table}' returned more rows than the {Model} selector '{Selector}' can attribute " +
"(at most {Cap}) in one poll, so the selector is ambiguous — a history/events table, or a " +
"where column that is not unique. Its {Members} tag(s) publish BadConfigurationError and " +
"nothing past row {Limit} is read. Point the tag at a current-values table or a latest-row view.",
first.Table, plan.Model, selector, rowCap, plan.Members.Count, rowCap + 1);
}
return GroupResult.RowCapExceeded;
}
return GroupResult.Ok(rows, columnIndex, typeNames);
}
@@ -470,8 +666,11 @@ public sealed class SqlPollReader
}
}
/// <summary>Maps one group's outcome onto every slot its members occupy in the caller's list.</summary>
private void Slice(
/// <summary>
/// Maps one group's outcome onto every slot its members occupy in the caller's list. Returns whether any
/// member was refused on an ANSWERED result (a duplicated key) — answer evidence the driver must count.
/// </summary>
private bool Slice(
SqlQueryPlan plan,
GroupResult outcome,
Dictionary<SqlTagDefinition, List<int>> slots,
@@ -481,9 +680,11 @@ public sealed class SqlPollReader
// KeyValue indexes rows by the key column; WideRow's members all read the one selected row, so the
// where-column is not even in the result set (design §5.3). Both selections happen ONCE per group —
// they are group-wide facts, and doing them per member would report a contract violation N times.
HashSet<string>? duplicatedKeys = null;
var byKey = outcome.Succeeded && plan.Model == SqlTagModel.KeyValue
? IndexByKey(plan, outcome)
? IndexByKey(plan, outcome, out duplicatedKeys)
: null;
var refusedSome = false;
var wideRow = outcome.Succeeded && plan.Model == SqlTagModel.WideRow
? SelectWideRow(plan, outcome)
: null;
@@ -493,26 +694,45 @@ public sealed class SqlPollReader
// Members may repeat (two tags on one keyValue, or the same ref twice) — every occurrence is fed.
if (!slots.TryGetValue(member, out var positions)) continue;
var snapshot = outcome.Succeeded
? MapMember(plan, member, outcome, byKey, wideRow, readAt)
: new DataValueSnapshot(null, outcome.FailureStatus, null, readAt);
DataValueSnapshot snapshot;
if (!outcome.Succeeded)
{
snapshot = new DataValueSnapshot(null, outcome.FailureStatus, null, readAt);
}
else if (duplicatedKeys is not null && member.KeyValue is { } key && duplicatedKeys.Contains(key))
{
// More than one row for THIS tag's key: its selector is ambiguous — the same rule a where-pair
// follows — so it is a configuration error for this key's tags only. Every other key in the
// group is unaffected, whatever else is present or absent (R06:S-1 review I-1: it used to be
// last-row-wins Good, or the whole group Bad, depending on an unrelated key's absence).
snapshot = new DataValueSnapshot(null, SqlStatusCodes.BadConfigurationError, null, readAt);
refusedSome = true;
}
else
{
snapshot = MapMember(plan, member, outcome, byKey, wideRow, readAt);
}
foreach (var position in positions) results[position] = snapshot;
}
return refusedSome;
}
/// <summary>
/// Builds the key → row index a key-value plan slices through. Later rows win, so a source that
/// breaks the one-row-per-key contract (design §3.6) still yields a deterministic value; the
/// violation is reported through a rate-limited warning rather than silently.
/// Builds the key → row index a key-value plan slices through, and the set of keys that came back more
/// than once — a source breaking the one-row-per-key contract (design §3.6). Those keys' tags publish
/// <see cref="SqlStatusCodes.BadConfigurationError"/>; the violation is also reported through a
/// per-plan rate-limited warning.
/// </summary>
private Dictionary<string, object?[]> IndexByKey(SqlQueryPlan plan, GroupResult outcome)
private Dictionary<string, object?[]> IndexByKey(
SqlQueryPlan plan, GroupResult outcome, out HashSet<string>? duplicatedKeys)
{
duplicatedKeys = null;
var index = new Dictionary<string, object?[]>(StringComparer.Ordinal);
if (plan.KeyColumn is null || !outcome.ColumnIndex.TryGetValue(plan.KeyColumn, out var keyAt))
return index;
var duplicated = 0;
foreach (var row in outcome.Rows)
{
// A NULL key cell indexes nothing: Convert.ToString would fold it to "", which would then be
@@ -523,20 +743,16 @@ public sealed class SqlPollReader
// (an INTEGER station id, say) — the provider coerces on the way in, so match on the way out.
var key = Convert.ToString(keyCell, CultureInfo.InvariantCulture);
if (key is null) continue;
if (!index.TryAdd(key, row))
{
index[key] = row;
duplicated++;
}
if (!index.TryAdd(key, row)) (duplicatedKeys ??= new HashSet<string>(StringComparer.Ordinal)).Add(key);
}
if (duplicated > 0 && ShouldWarnAboutContract())
if (duplicatedKeys is not null && ShouldWarn("dupkey:" + plan.SqlText))
{
_logger.LogWarning(
"Sql poll source '{Table}' returned more than one row for {Count} key(s) in one poll; the " +
"last row wins. The key-value model requires one row per key — point the tag at a " +
"current-values table or an operator-authored latest-per-key view.",
plan.Members[0].Table, duplicated);
"Sql poll source '{Table}' returned more than one row for {Count} key(s) in one poll (e.g. " +
"'{Example}'); those keys' tags publish BadConfigurationError. The key-value model requires one " +
"row per key — point the tag at a current-values table or an operator-authored latest-per-key view.",
plan.Members[0].Table, duplicatedKeys.Count, duplicatedKeys.First());
}
return index;
@@ -549,15 +765,14 @@ public sealed class SqlPollReader
/// emits no <c>ORDER BY</c>, so across polls the "last" row is whatever the server returned last,
/// i.e. storage order. That is why more than one row is a reported contract violation and not merely
/// a tie to break — the model's premise (design §5.3) is that the selector identifies one row.</para>
/// <para><b>Why the where-pair form is deliberately NOT given the dialect's single-row limit.</b> A
/// <c>TOP 1</c> with no <c>ORDER BY</c> is not deterministic either — it returns whichever row the
/// plan happens to produce first, so it would trade one arbitrary row for a differently arbitrary
/// one. Worse, it would destroy the only evidence this method has that the selector is ambiguous
/// (<c>Rows.Count &gt; 1</c>), converting a loud misconfiguration into a permanently silent one —
/// precisely the failure mode this class's summary calls its top risk. The <c>topByTimestamp</c>
/// form keeps its row limit because there the <c>ORDER BY</c> at least fixes <em>which</em> rows can
/// be first. The accepted cost is that an ambiguous selector materialises every matching row for one
/// poll; the warning is what gets it fixed.</para>
/// <para><b>Why the where-pair form is capped at TWO rows, not one (R06:S-1).</b> A <c>TOP 1</c> with no
/// <c>ORDER BY</c> is not deterministic — it returns whichever row the plan produces first — and it would
/// destroy the only evidence that the selector is ambiguous, converting a loud misconfiguration into a
/// permanently silent one. No limit at all materialised every matching row of an events table on every
/// poll. Two rows keeps the evidence and bounds the read: a second row makes the group publish
/// <c>BadConfigurationError</c> in <c>MaterialiseAsync</c>, so a where-pair group never reaches this
/// method with more than one row. The multi-row warning below is now reached only by a plan with no
/// cap.</para>
/// <para><b>⚠ The <c>topByTimestamp</c> answer is only defined when the ordering column is unique</b>
/// <c>ORDER BY ts DESC</c> plus a one-row limit over a column with ties returns an
/// arbitrary row among the tied ones, and — unlike the where-pair form — this method can never
@@ -572,7 +787,7 @@ public sealed class SqlPollReader
private object?[]? SelectWideRow(SqlQueryPlan plan, GroupResult outcome)
{
if (outcome.Rows.Count == 0) return null;
if (outcome.Rows.Count > 1 && ShouldWarnAboutContract())
if (outcome.Rows.Count > 1 && ShouldWarn("multirow:" + plan.SqlText))
{
var first = plan.Members[0];
var selector = string.IsNullOrWhiteSpace(first.RowSelectorColumn)
@@ -642,9 +857,14 @@ public sealed class SqlPollReader
var target = member.DeclaredType ?? _dialect.MapColumnType(outcome.TypeNames[valueAt]);
if (!TryCoerce(cell, target, out var value))
{
_logger.LogWarning(
"Sql tag '{Tag}' could not coerce a {Actual} cell to its {Declared} type.",
member.Name, cell.GetType().Name, target);
// A per-tag refusal: warned ONCE per tag (per reader), never per poll (R06:S-6).
if (_coerceWarned.TryAdd(member.Name, 0))
{
_logger.LogWarning(
"Sql tag '{Tag}' could not coerce a {Actual} cell to its {Declared} type; it publishes " +
"BadTypeMismatch (warned once per tag).",
member.Name, cell.GetType().Name, target);
}
return new DataValueSnapshot(null, SqlStatusCodes.BadTypeMismatch, sourceTimestamp, readAt);
}
@@ -711,13 +931,21 @@ public sealed class SqlPollReader
Convert.ToDateTime(cell, CultureInfo.InvariantCulture), DateTimeKind.Utc),
};
/// <summary>Rate-limits the source-contract warning so a misconfigured table cannot flood the log.</summary>
private bool ShouldWarnAboutContract()
/// <summary>Rate-limits one per-poll warning by key; true at most once per <see cref="ContractWarningInterval"/>.</summary>
private bool ShouldWarn(string key)
{
var now = Stopwatch.GetTimestamp();
var last = Interlocked.Read(ref _lastContractWarningTicks);
if (last != 0 && Stopwatch.GetElapsedTime(last, now) < ContractWarningInterval) return false;
return Interlocked.CompareExchange(ref _lastContractWarningTicks, now, last) == last;
while (true)
{
if (!_lastWarnedTicks.TryGetValue(key, out var last))
{
if (_lastWarnedTicks.TryAdd(key, now)) return true;
continue;
}
if (Stopwatch.GetElapsedTime(last, now) < ContractWarningInterval) return false;
if (_lastWarnedTicks.TryUpdate(key, now, last)) return true;
}
}
/// <summary>One group's outcome: either a materialised result set, or the status its members inherit.</summary>
@@ -747,6 +975,17 @@ public sealed class SqlPollReader
public static GroupResult Failed { get; } =
new(SqlStatusCodes.BadCommunicationError, NoRows, NoColumns, []);
/// <summary>The reader was disposed before this group could start; surfaced as a typed exception.</summary>
public static GroupResult ReaderDisposed { get; } =
new(SqlStatusCodes.BadInternalError, NoRows, NoColumns, []);
/// <summary>
/// The database answered with more rows than the plan can use — an ambiguous selector (R06:S-1).
/// A configuration fact, not a database fault; the driver counts it as answer evidence.
/// </summary>
public static GroupResult RowCapExceeded { get; } =
new(SqlStatusCodes.BadConfigurationError, NoRows, NoColumns, []);
/// <summary>The status every member inherits when <see cref="Succeeded"/> is false.</summary>
public uint FailureStatus { get; }
@@ -775,6 +1014,21 @@ public sealed class SqlPollReader
}
}
/// <summary>
/// A poll reached a <see cref="SqlPollReader"/> after it was disposed — a rebind replaced it, or the driver
/// was disposed. Not a database verdict: the driver retries on the current reader, and never degrades for it.
/// </summary>
public sealed class SqlPollReaderDisposedException()
: OperationCanceledException("The Sql poll reader was disposed (replaced by a rebind, or the driver was disposed).");
/// <summary>One poll's snapshots plus the answer evidence they cannot carry themselves.</summary>
/// <param name="Snapshots">One snapshot per requested reference, at the same index.</param>
/// <param name="AnsweredRefusalGroups">
/// Groups the database ANSWERED but whose result the reader refused in whole or part — an over-cap result
/// set, or a duplicated key. Those members carry <see cref="SqlStatusCodes.BadConfigurationError"/>.
/// </param>
public sealed record SqlPollResult(IReadOnlyList<DataValueSnapshot> Snapshots, int AnsweredRefusalGroups);
/// <summary>
/// The OPC UA status codes the Sql driver publishes, and the quality-class predicates its tests read
/// them back with.
@@ -55,6 +55,15 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Sql;
/// The bare (unquoted) column names in <see cref="SqlText"/>'s SELECT list, in order and distinct — the
/// reader's map from result-set ordinal to source column.
/// </param>
/// <param name="RowCap">
/// The most rows the reader will attribute before refusing the whole group — twice the distinct key count
/// (key-value: headroom so stray duplicates are attributed per key) or 1 (a where-pair wide row: one
/// selector, so any second row is the group's ambiguity); <see langword="null"/> when the statement
/// already limits itself (topByTimestamp). <see cref="SqlText"/> asks for <c>RowCap + 1</c>, and the
/// reader stops there: receiving the extra row means a history/events table or a non-unique where column,
/// and the group publishes <c>BadConfigurationError</c> instead of materialising every matching row on
/// every poll (R06:S-1).
/// </param>
public sealed record SqlQueryPlan(
SqlTagModel Model,
object GroupKey,
@@ -63,7 +72,8 @@ public sealed record SqlQueryPlan(
IReadOnlyList<string> ParameterBindNames,
IReadOnlyList<object?> Parameters,
IReadOnlyList<SqlTagDefinition> Members,
IReadOnlyList<string> SelectedColumns)
IReadOnlyList<string> SelectedColumns,
int? RowCap = null)
{
// A plan is documented as safe to cache and reuse across polls, so it must not stay aliased to the
// planner's mutable working lists — an IReadOnlyList<T> holding a List<T> is downcastable, and a
@@ -80,7 +80,13 @@
<div class="col-md-3">
<label class="form-label">Max read gap (coalescing)</label>
<InputNumber @bind-Value="_form.MaxReadGap" @bind-Value:after="EmitAsync" class="form-control form-control-sm" />
<div class="form-text">0 = no coalescing. Typical 5–32.</div>
<div class="form-text">0 = no coalescing. Default 8. Refused ranges fall back per tag.</div>
</div>
<div class="col-md-3">
<div class="form-check form-switch mt-4">
<InputCheckbox @bind-Value="_form.DisableReadCoalescing" @bind-Value:after="EmitAsync" class="form-check-input" id="disableCoalescing" />
<label class="form-check-label" for="disableCoalescing">Disable read coalescing</label>
</div>
</div>
</div>
</div>
@@ -106,7 +112,13 @@
<div class="col-md-4">
<div class="form-check form-switch">
<InputCheckbox @bind-Value="_form.DisableFC23" @bind-Value:after="EmitAsync" class="form-check-input" id="disableFC23" />
<label class="form-check-label" for="disableFC23">Disable FC23 (reserved — no effect today)</label>
<label class="form-check-label" for="disableFC23">Disable FC23 (never send FC23, even when opted in below)</label>
</div>
</div>
<div class="col-md-4">
<div class="form-check form-switch">
<InputCheckbox @bind-Value="_form.UseFC23ForMultiRegisterWrites" @bind-Value:after="EmitAsync" class="form-check-input" id="useFC23" />
<label class="form-check-label" for="useFC23">Use FC23 write + read-back for multi-register writes (opt-in; default FC16)</label>
</div>
</div>
<div class="col-md-4">
@@ -311,12 +323,14 @@
public int MaxRegistersPerRead { get; set; } = 125;
public int MaxRegistersPerWrite { get; set; } = 123;
public int MaxCoilsPerRead { get; set; } = 2000;
public int MaxReadGap { get; set; } = 0;
public int MaxReadGap { get; set; } = ModbusDriverOptions.DefaultMaxReadGap;
public bool DisableReadCoalescing { get; set; } = false;
// Write options
public bool UseFC15ForSingleCoilWrites { get; set; } = false;
public bool UseFC16ForSingleRegisterWrites { get; set; } = false;
public bool DisableFC23 { get; set; } = false;
public bool UseFC23ForMultiRegisterWrites { get; set; } = false;
public bool WriteOnChangeOnly { get; set; } = false;
// Keep-alive
@@ -357,9 +371,11 @@
MaxRegistersPerWrite = o.MaxRegistersPerWrite,
MaxCoilsPerRead = o.MaxCoilsPerRead,
MaxReadGap = o.MaxReadGap,
DisableReadCoalescing = o.DisableReadCoalescing,
UseFC15ForSingleCoilWrites = o.UseFC15ForSingleCoilWrites,
UseFC16ForSingleRegisterWrites = o.UseFC16ForSingleRegisterWrites,
DisableFC23 = o.DisableFC23,
UseFC23ForMultiRegisterWrites = o.UseFC23ForMultiRegisterWrites,
WriteOnChangeOnly = o.WriteOnChangeOnly,
KeepAliveEnabled = o.KeepAlive.Enabled,
KeepAliveTimeSeconds = (int)o.KeepAlive.Time.TotalSeconds,
@@ -396,9 +412,11 @@
UseFC15ForSingleCoilWrites = UseFC15ForSingleCoilWrites,
UseFC16ForSingleRegisterWrites = UseFC16ForSingleRegisterWrites,
DisableFC23 = DisableFC23,
UseFC23ForMultiRegisterWrites = UseFC23ForMultiRegisterWrites,
AutoProhibitReprobeInterval = AutoProhibitReprobeIntervalSeconds > 0
? TimeSpan.FromSeconds(AutoProhibitReprobeIntervalSeconds) : null,
MaxReadGap = (ushort)Math.Clamp(MaxReadGap, 0, 65535),
DisableReadCoalescing = DisableReadCoalescing,
Family = Family,
MelsecSubFamily = MelsecSubFamily,
Transport = Transport,
@@ -4,24 +4,60 @@
@using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers.Pickers
<div class="row g-2">
<div class="col-md-8"><label class="form-label">Address (text, optional)</label>
<input type="text" class="form-control form-control-sm" placeholder="e.g. 40001:F:CDAB, V2000 (DL205), D100 (MELSEC)"
value="@_addressText" @onchange="@(e => _addressText = e.Value?.ToString())" /></div>
<div class="col-md-4 d-flex align-items-end">
<button type="button" class="btn btn-sm btn-outline-secondary" @onclick="ResolveAddressAsync">Resolve with driver family</button>
</div>
@if (_addressError is not null)
{
<div class="col-12"><div class="form-text text-danger">@_addressError</div></div>
}
else if (_m.IsStringOnly)
{
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.IsPinned)
{
<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 => Update(() => _m.Region = ParseEnum(e.Value, ModbusRegion.HoldingRegisters)))">
<select class="form-select form-select-sm" value="@_m.Region" @onchange="@(e => UpdateAddress(() => _m.SetRegion(ParseEnum(e.Value, ModbusRegion.HoldingRegisters))))">
@foreach (var v in Enum.GetValues<ModbusRegion>()) { <option value="@v">@v</option> }
</select></div>
<div class="col-md-2"><label class="form-label">Address</label>
<input type="number" class="form-control form-control-sm" value="@_m.Address" @onchange="@(e => Update(() => _m.Address = ParseInt(e.Value)))" /></div>
<input type="number" class="form-control form-control-sm" value="@_m.Address" @onchange="@(e => UpdateAddress(() => _m.Address = ParseInt(e.Value)))" /></div>
<div class="col-md-3"><label class="form-label">Data type</label>
<select class="form-select form-select-sm" value="@_m.DataType" @onchange="@(e => Update(() => _m.DataType = ParseEnum(e.Value, ModbusDataType.Int16)))">
<select class="form-select form-select-sm" value="@_m.DataType" @onchange="@(e => UpdateAddress(() => _m.DataType = ParseEnum(e.Value, ModbusDataType.Int16)))">
@foreach (var v in Enum.GetValues<ModbusDataType>()) { <option value="@v">@v</option> }
</select></div>
<div class="col-md-3"><label class="form-label">Byte order</label>
<select class="form-select form-select-sm" value="@_m.ByteOrder" @onchange="@(e => Update(() => _m.ByteOrder = ParseEnum(e.Value, ModbusByteOrder.BigEndian)))">
<select class="form-select form-select-sm" value="@_m.ByteOrder" @onchange="@(e => UpdateAddress(() => _m.ByteOrder = ParseEnum(e.Value, ModbusByteOrder.BigEndian)))">
@foreach (var v in Enum.GetValues<ModbusByteOrder>()) { <option value="@v">@v</option> }
</select></div>
<div class="col-md-2"><label class="form-label">Bit index</label>
<input type="number" class="form-control form-control-sm" value="@_m.BitIndex" @onchange="@(e => Update(() => _m.BitIndex = ParseInt(e.Value)))" /></div>
<input type="number" class="form-control form-control-sm" value="@_m.BitIndex" @onchange="@(e => UpdateAddress(() => _m.BitIndex = ParseInt(e.Value)))" /></div>
<div class="col-md-2"><label class="form-label">String len</label>
<input type="number" class="form-control form-control-sm" value="@_m.StringLength" @onchange="@(e => Update(() => _m.StringLength = ParseInt(e.Value)))" /></div>
<input type="number" class="form-control form-control-sm" value="@_m.StringLength" @onchange="@(e => UpdateAddress(() => _m.StringLength = ParseInt(e.Value)))" /></div>
<div class="col-12 mt-1">
<button type="button" class="btn btn-sm btn-outline-secondary" @onclick="@(() => _showPicker = true)">Build address</button>
</div>
@@ -66,6 +102,15 @@
if (ConfigJson == _lastConfigJson) { return; }
_lastConfigJson = ConfigJson;
_m = ModbusTagConfigModel.FromJson(ConfigJson);
// 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, _melsecSub) = ModbusTagConfigModel.ReadFamily(GetDriverConfigJson());
}
private static int ParseInt(object? v, int fallback = 0) => int.TryParse(v?.ToString(), out var i) ? i : fallback;
@@ -74,10 +119,38 @@
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;
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.
private Task UpdateAddress(Action apply) => Update(() => { _m.ClearAddressString(); _addressError = null; apply(); });
// Resolve the typed address with the OWNING driver's family (DL205 octal V-memory, MELSEC D/X/Y) and persist
// both the address string and the resolved region/offset/type fields.
private async Task ResolveAddressAsync()
{
var (family, sub) = ModbusTagConfigModel.ReadFamily(GetDriverConfigJson());
(_family, _melsecSub) = (family, sub);
var error = _m.ResolveAddress(_addressText, family, sub);
_addressError = error;
if (error is null) await EmitAsync();
}
// Parse the builder string (e.g. "4x00001-1") back into Region + Address fields.
@@ -94,7 +167,7 @@
var offsetStr = dashIdx >= 0 ? rest[..dashIdx] : rest;
if (int.TryParse(offsetStr, out var offset))
{
await Update(() => { _m.Region = region; _m.Address = offset; });
await UpdateAddress(() => { _m.Region = region; _m.Address = offset; });
}
}
}
@@ -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"));
@@ -28,11 +28,19 @@ 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 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,
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.
@@ -161,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();
}
@@ -223,9 +237,12 @@ 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); }
}
}
if (fired.Count > 0) { _afterTypedColumns?.Invoke(model, fired); }
var merged = TagConfigJson.ParseOrNew(_toJson(model));
foreach (var (key, node) in baseObj)
{
@@ -459,24 +476,35 @@ public static class CsvColumnMap
{
var maps = new CsvDriverTagMap[]
{
// 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[]
{
ModbusCol("Region", "region", nameof(ModbusTagConfigModel.Region),
m => 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)),
ModbusCol("ModbusDataType", "dataType", nameof(ModbusTagConfigModel.DataType),
m => 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)),
ModbusCol("BitIndex", "bitIndex", nameof(ModbusTagConfigModel.BitIndex),
m => 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)),
// 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.HasKeyAddress ? null : m.Region.ToString(), (m, s) => m.Region = CsvValues.ParseEnum<ModbusRegion>("Region", s)),
ModbusAddrCol("Address", "address", nameof(ModbusTagConfigModel.Address),
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.HasKeyAddress ? null : m.DataType.ToString(), (m, s) => m.DataType = CsvValues.ParseEnum<ModbusDataType>("ModbusDataType", s)),
ModbusAddrCol("ByteOrder", "byteOrder", nameof(ModbusTagConfigModel.ByteOrder),
m => !m.HasKeyAddress ? null : m.ByteOrder.ToString(), (m, s) => m.ByteOrder = CsvValues.ParseEnum<ModbusByteOrder>("ByteOrder", s)),
ModbusAddrCol("BitIndex", "bitIndex", nameof(ModbusTagConfigModel.BitIndex),
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.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>(
@@ -617,6 +645,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, set);
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);
@@ -7,29 +7,188 @@ 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
/// key stays absent.</summary>
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 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>
/// 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>
/// 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 ModbusAddressShape AddressShape => ModbusTagAddressResolution.Classify(ToJson());
/// <summary>
/// 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 IsStringOnly => AddressShape == ModbusAddressShape.StringOnly;
/// <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
/// family and copies the result into the region / offset / type / bit / string-length / byte-order /
/// array fields — the "persist the resolved region/offset" half of the family wiring, so the blob is
/// readable on its own and the editor shows what the driver will poll.
/// </summary>
/// <param name="text">The address text the operator typed.</param>
/// <param name="family">The driver's PLC family.</param>
/// <param name="melsecSubFamily">The driver's MELSEC sub-family.</param>
/// <returns><c>null</c> on success, else the parser's error.</returns>
public string? ResolveAddress(string? text, ModbusFamily family, MelsecFamily melsecSubFamily)
{
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;
_authorsKeys = true;
Region = parsed.Region;
Address = parsed.Offset;
DataType = parsed.DataType;
ByteOrder = parsed.ByteOrder;
BitIndex = parsed.Bit ?? 0;
StringLength = parsed.StringLength;
if (parsed.ArrayCount is { } count)
{
TagConfigJson.Set(_bag, "isArray", true);
TagConfigJson.Set(_bag, "arrayLength", count);
}
else
{
_bag.Remove("isArray");
_bag.Remove("arrayLength");
}
return null;
}
/// <summary>Drops the address string so the hand-edited region / address fields are what the driver reads.</summary>
public void ClearAddressString()
{
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>
/// Reads the owning driver's <c>family</c> / <c>melsecSubFamily</c> from its DriverConfig JSON (keys
/// matched case-insensitively, like the driver's DTO binding); Generic / Q_L_iQR when absent or unknown.
/// </summary>
/// <param name="driverConfigJson">The driver's DriverConfig JSON.</param>
/// <returns>The family pair the driver will resolve address strings with.</returns>
public static (ModbusFamily Family, MelsecFamily MelsecSubFamily) ReadFamily(string? driverConfigJson)
{
var family = ModbusFamily.Generic;
var sub = MelsecFamily.Q_L_iQR;
var o = TagConfigJson.ParseOrNew(driverConfigJson);
foreach (var (key, node) in o)
{
var text = node is JsonValue v && v.TryGetValue<string>(out var str) ? str : null;
if (text is null) continue;
if (string.Equals(key, "family", StringComparison.OrdinalIgnoreCase)
&& Enum.TryParse<ModbusFamily>(text, ignoreCase: true, out var f)) family = f;
else if (string.Equals(key, "melsecSubFamily", StringComparison.OrdinalIgnoreCase)
&& Enum.TryParse<MelsecFamily>(text, ignoreCase: true, out var m)) sub = m;
}
return (family, sub);
}
/// <summary>
/// Changes the region and keeps the data type compatible with it (the driver refuses a bit region
/// with a non-Bool type, and a register region with Bool): switching to Coils / DiscreteInputs selects
/// Bool, switching from them back to a register region selects Int16.
/// </summary>
/// <param name="region">The newly selected region.</param>
public void SetRegion(ModbusRegion region)
{
Region = region;
if (!ModbusTagDefinitionFactory.IsRegionTypeCompatible(region, DataType))
DataType = ModbusTagDefinitionFactory.DefaultDataType(region);
}
private JsonObject _bag = new();
// The blob this model was LOADED FROM, or null when it was built by object initialiser rather than
@@ -44,16 +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"),
DataType = TagConfigJson.GetEnum(o, "dataType", ModbusDataType.Int16),
ByteOrder = TagConfigJson.GetEnum(o, "byteOrder", ModbusByteOrder.BigEndian),
BitIndex = TagConfigJson.GetInt(o, "bitIndex"),
StringLength = TagConfigJson.GetInt(o, "stringLength"),
_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 = 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"),
Writable = TagConfigJson.GetBoolNullable(o, "writable"),
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,
};
}
@@ -63,14 +235,28 @@ 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);
TagConfigJson.Set(_bag, "writable", Writable);
return TagConfigJson.Serialize(_bag);
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(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(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>
@@ -103,13 +289,15 @@ 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,
j => ModbusTagDefinitionFactory.FromTagConfig(j, TagConfigParseGate.AuthoringRawPath, out _),
j => ModbusTagDefinitionFactory.FromTagConfigAnyFamily(j, TagConfigParseGate.AuthoringRawPath, out _),
ModbusTagDefinitionFactory.Inspect,
"Modbus");
}
@@ -7,7 +7,7 @@ namespace ZB.MOM.WW.OtOpcUa.Host.Configuration;
/// <summary>
/// The <c>onReady</c> callback handed to <c>AddZbLocalDb</c>: creates the deployment-cache,
/// alarm store-and-forward, and scripted-alarm condition-state tables and — when this node
/// alarm store-and-forward, and scripted-alarm condition-state + comment tables and — when this node
/// actually replicates — opts them into change capture.
/// </summary>
/// <remarks>
@@ -21,7 +21,7 @@ namespace ZB.MOM.WW.OtOpcUa.Host.Configuration;
public static class LocalDbSetup
{
/// <summary>
/// The four tables that participate in replication — registered when this node is configured
/// The five tables that participate in replication — registered when this node is configured
/// to replicate, and deregistered (stale capture triggers dropped) when it is not.
/// </summary>
/// <remarks>
@@ -36,6 +36,7 @@ public static class LocalDbSetup
DeploymentCacheSchema.PointerTable,
AlarmSfSchema.EventsTable,
AlarmConditionStateSchema.StateTable,
AlarmConditionStateSchema.CommentsTable,
];
/// <summary>
@@ -47,8 +48,9 @@ public static class LocalDbSetup
/// <c>RegisterReplicated</c> is what installs the three AFTER triggers that capture
/// changes into the oplog. Any row written before that call is never captured, so it
/// never reaches the peer — silently, and permanently, because nothing ever revisits
/// history. <see cref="AlarmSfLegacyMigrator"/> therefore runs <b>last</b>, after every
/// registration — it writes rows, and they must be captured like any other write.
/// history. <see cref="AlarmConditionStateSchema.MigrateInlineComments"/> and
/// <see cref="AlarmSfLegacyMigrator"/> therefore run <b>last</b>, after every
/// registration — they write rows, and those must be captured like any other write.
/// </para>
/// <para>
/// <b>Registration is CONDITIONAL on <see cref="ReplicationIsConfigured"/>.</b> It used
@@ -68,7 +70,7 @@ public static class LocalDbSetup
/// </para>
/// <para>
/// <b>A failure part-way through either loop fails host start, and that is deliberate.</b>
/// Neither branch catches, so a throw on the third of four tables leaves two flipped and
/// Neither branch catches, so a throw on the third of five tables leaves two flipped and
/// propagates out of the <c>AddZbLocalDb</c> singleton factory — a node that came up
/// anyway would be capturing (or not) on an arbitrary subset, which is silently partial
/// replication with nothing in the log to say so. Both library calls are idempotent, so
@@ -116,7 +118,36 @@ public static class LocalDbSetup
else
RemoveStaleCapture(db, logger ?? Serilog.Log.Logger);
// LAST, and only here. This is the one call in OnReady that writes rows.
// LAST, and only here. These are the only calls in OnReady that write rows.
// Copies any pre-split inline comment trail (alarm_condition_state.comments_json) into the
// append-only alarm_condition_comment table (review R05:4). Idempotent: an ordinal already
// held is never rewritten, and the condition row itself is never touched (the alarm's next
// save clears the column). An unreadable trail is reported, not skipped silently.
AlarmConditionStateSchema.InlineCommentMigrationResult commentMigration;
using (var connection = db.CreateConnection())
{
commentMigration = AlarmConditionStateSchema.MigrateInlineComments(connection);
}
var migrationLog = logger ?? Serilog.Log.Logger;
if (commentMigration.RowsMoved > 0)
{
migrationLog.Information(
"LocalDb: copied the inline comment trail of {AlarmCount} scripted alarm(s) into {Table}.",
commentMigration.RowsMoved,
AlarmConditionStateSchema.CommentsTable);
}
foreach (var alarmId in commentMigration.UnparseableAlarmIds)
{
migrationLog.Warning(
"LocalDb: scripted alarm {AlarmId} has an unreadable inline comment trail "
+ "(alarm_condition_state.comments_json is not valid JSON); its history was NOT moved to {Table} "
+ "and the alarm's state will not load until the row is repaired.",
alarmId,
AlarmConditionStateSchema.CommentsTable);
}
AlarmSfLegacyMigrator.Migrate(db, configuration);
}
@@ -1,6 +1,4 @@
using System.Collections.Immutable;
using System.Globalization;
using System.Text.Json;
using Microsoft.Data.Sqlite;
using Microsoft.Extensions.Logging;
using ZB.MOM.WW.LocalDb;
@@ -11,9 +9,11 @@ namespace ZB.MOM.WW.OtOpcUa.Runtime.ScriptedAlarms;
/// <summary>
/// Node-local <see cref="IAlarmStateStore"/> backed by the replicated
/// <see cref="AlarmConditionStateSchema.StateTable"/> table in the node's consolidated LocalDb.
/// Maps the full Part 9 <see cref="AlarmConditionState"/> — Enabled / Acked / Confirmed /
/// Shelving + the ack/confirm audit trail + operator comments.
/// <see cref="AlarmConditionStateSchema.StateTable"/> and
/// <see cref="AlarmConditionStateSchema.CommentsTable"/> tables in the node's consolidated
/// LocalDb. Maps the full Part 9 <see cref="AlarmConditionState"/> — Enabled / Acked / Confirmed /
/// Shelving + the ack/confirm audit fields on the condition row, and the append-only comment
/// trail one row per entry.
/// </summary>
/// <remarks>
/// <para>
@@ -24,44 +24,75 @@ namespace ZB.MOM.WW.OtOpcUa.Runtime.ScriptedAlarms;
/// state then mirrors to its redundant pair peer instead of depending on central SQL.
/// </para>
/// <para>
/// <b>The round-trip is byte-identical to the EF store</b> — the enum↔string mappers and the
/// comment serialization below are duplicated verbatim from it (it is deleted in a later
/// task; the ported round-trip tests prove parity). The persistence rules carry over
/// unchanged:
/// </para>
/// <para>
/// <b>ActiveState is NOT persisted</b> — there is no column; on <see cref="LoadAsync"/> it is
/// restored as <see cref="AlarmActiveState.Inactive"/> and the engine re-derives it from the
/// live predicate. <b>LastTransitionUtc ↔ updated_at_utc</b>: no dedicated transition column,
/// so the last transition rides the row-write timestamp. <b>LastActiveUtc / LastClearedUtc</b>
/// have no columns and default to <c>null</c> on load. <see cref="AlarmConditionState.Comments"/>
/// serializes to/from <c>comments_json</c>; an empty list round-trips as <c>"[]"</c>.
/// have no columns and default to <c>null</c> on load.
/// </para>
/// <para>
/// <b>Save is a single unconditional PK upsert.</b> No application-level last-write-wins or
/// HLC comparison is layered on top — the LocalDb replication HLC handles convergence, and an
/// unconditional upsert keyed by the primary key is HLC-safe (the same discipline the
/// alarm-sf sink and the deployment pointer follow).
/// <b>The comment trail is written incrementally (review R05:4).</b> Each save writes only
/// the entries the table does not yet hold, so a transition's write — and its replication
/// payload — is constant however long the history is; the condition row's
/// <c>comments_json</c> is always written as <c>"[]"</c>. What is "already stored" is read
/// from the table inside the save's transaction (a bounded read of the trail's tail) rather
/// than cached, so the store holds no per-alarm state. The pair peer's copy of the same
/// command is recognised and not duplicated; a local entry that collides with a peer entry
/// this node never applied is appended after it with a Warning, never dropped — see
/// <c>AppendNewCommentsAsync</c> and, for why the ordinal is the key,
/// <see cref="AlarmConditionStateSchema"/>.
/// </para>
/// <para>
/// <b>Remove vs. a lagging peer.</b> <see cref="RemoveAsync"/> deletes the whole trail; if the
/// pair peer has not yet applied the same deploy, its next save finds no stored trail and
/// re-inserts every entry, which replicates back. The next deploy removes them again — the
/// same resurrection class the single-row store had, with n+1 rows instead of one (review m3).
/// </para>
/// <para>
/// <b>Old-shape rows load exactly as before.</b> A row still carrying an inline
/// <c>comments_json</c> trail (written by the pre-split store and not yet moved by
/// <see cref="AlarmConditionStateSchema.MigrateInlineComments"/>) is read through the same
/// parser the old store used, merged by ordinal with whatever the comment table holds (the
/// table wins an ordinal both hold), and moved to the table on that alarm's next save.
/// </para>
/// <para>
/// <b>Save is one transaction:</b> the comment append and an unconditional PK upsert of the
/// condition row. No application-level last-write-wins or HLC comparison is layered on top —
/// the LocalDb replication HLC handles convergence.
/// </para>
/// </remarks>
public sealed class LocalDbAlarmConditionStateStore : IAlarmStateStore
{
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
// Fixed column order shared by every read — the reader map below indexes by these ordinals.
private const string SelectColumns =
"scripted_alarm_id, enabled_state, acked_state, confirmed_state, shelving_state, " +
"shelving_expires_utc, last_ack_user, last_ack_comment, last_ack_utc, " +
"last_confirm_user, last_confirm_comment, last_confirm_utc, comments_json, updated_at_utc";
private const string SelectCommentColumns = "scripted_alarm_id, seq, timestamp_utc, user_name, kind, text";
/// <summary>
/// How many trailing in-memory entries each save reconciles against the stored trail by
/// content. The engine appends at most one entry per save, so this comfortably covers a few
/// missed commands while keeping each save's read bounded.
/// </summary>
private const int MatchWindow = 16;
/// <summary>
/// How far apart two nodes' timestamps for the SAME command may be and still count as one
/// entry: command fan-out latency plus inter-node clock skew. Beyond it both copies are kept
/// (the safe direction — a duplicate, never a loss).
/// </summary>
private static readonly TimeSpan SameCommandSkew = TimeSpan.FromMinutes(5);
private readonly ILocalDb _db;
private readonly ILogger<LocalDbAlarmConditionStateStore> _logger;
/// <summary>Initializes a new instance of the <see cref="LocalDbAlarmConditionStateStore"/> class.</summary>
/// <param name="db">
/// The node's local database. Its <see cref="AlarmConditionStateSchema.StateTable"/> table
/// must already exist and be registered for replication — the host does both in
/// <c>LocalDbSetup.OnReady</c>, before any consumer can resolve this store.
/// The node's local database. Its condition-state and comment tables must already exist and
/// be registered for replication — the host does both in <c>LocalDbSetup.OnReady</c>, before
/// any consumer can resolve this store.
/// </param>
/// <param name="logger">The logger instance.</param>
public LocalDbAlarmConditionStateStore(ILocalDb db, ILogger<LocalDbAlarmConditionStateStore> logger)
@@ -79,7 +110,15 @@ public sealed class LocalDbAlarmConditionStateStore : IAlarmStateStore
MapRow,
new { Id = alarmId },
ct).ConfigureAwait(false);
return rows.Count > 0 ? rows[0] : null;
if (rows.Count == 0) return null;
var comments = await _db.QueryAsync(
$"SELECT {SelectCommentColumns} FROM alarm_condition_comment WHERE scripted_alarm_id = @Id ORDER BY seq",
MapComment,
new { Id = alarmId },
ct).ConfigureAwait(false);
return WithComments(rows[0], comments);
}
/// <inheritdoc />
@@ -90,7 +129,19 @@ public sealed class LocalDbAlarmConditionStateStore : IAlarmStateStore
MapRow,
parameters: null,
ct).ConfigureAwait(false);
return rows;
var comments = await _db.QueryAsync(
$"SELECT {SelectCommentColumns} FROM alarm_condition_comment ORDER BY scripted_alarm_id, seq",
MapComment,
parameters: null,
ct).ConfigureAwait(false);
var byAlarm = comments
.GroupBy(c => c.AlarmId, StringComparer.Ordinal)
.ToDictionary(g => g.Key, g => (IReadOnlyList<StoredComment>)g.ToList(), StringComparer.Ordinal);
return rows
.Select(r => WithComments(r, byAlarm.TryGetValue(r.State.AlarmId, out var c) ? c : []))
.ToList();
}
/// <inheritdoc />
@@ -98,10 +149,15 @@ public sealed class LocalDbAlarmConditionStateStore : IAlarmStateStore
{
ArgumentNullException.ThrowIfNull(state);
await using var tx = await _db.BeginTransactionAsync(ct).ConfigureAwait(false);
await AppendNewCommentsAsync(tx, state, ct).ConfigureAwait(false);
// A single unconditional upsert keyed on the alarm identity. Convergence across the pair is
// last-writer-wins over the primary key, handled by the LocalDb replication HLC — no
// app-level LWW here (see the class remarks).
await _db.ExecuteAsync(
// app-level LWW here (see the class remarks). comments_json is always "[]": the trail lives
// in alarm_condition_comment, which is what keeps this row a fixed size.
await tx.ExecuteAsync(
"""
INSERT INTO alarm_condition_state
(scripted_alarm_id, enabled_state, acked_state, confirmed_state, shelving_state,
@@ -140,60 +196,243 @@ public sealed class LocalDbAlarmConditionStateStore : IAlarmStateStore
LastConfirmUser = state.LastConfirmUser,
LastConfirmComment = state.LastConfirmComment,
LastConfirmUtc = FormatNullable(state.LastConfirmUtc),
CommentsJson = SerializeComments(state.Comments),
CommentsJson = AlarmConditionStateSchema.EmptyInlineComments,
// No dedicated transition column — persist LastTransitionUtc into updated_at_utc.
UpdatedAtUtc = Format(state.LastTransitionUtc),
},
ct).ConfigureAwait(false);
await tx.CommitAsync(ct).ConfigureAwait(false);
_logger.LogTrace("Persisted alarm-condition state for {AlarmId}", state.AlarmId);
}
/// <summary>
/// Persists the trail entries <paramref name="state"/> holds that the table does not, writing
/// a bounded number of rows however long the trail is.
/// </summary>
/// <remarks>
/// <para>
/// <b>Normal case.</b> The in-memory trail and the stored trail agree position by
/// position; the only new entries are past <c>MAX(seq)</c> and land at their own ordinal.
/// </para>
/// <para>
/// <b>Divergence (review I-1).</b> If this node missed a command its peer applied, the
/// peer's entry arrives in this table at an ordinal this node's in-memory trail does not
/// know about, and this node's NEXT entry has an in-memory index at or below
/// <c>MAX(seq)</c>. Positional "already stored" would silently drop it — the survivor's
/// newest operator action, in the failover case. So the tail of the in-memory trail
/// (the last <see cref="MatchWindow"/> entries) is matched against the stored rows from
/// that position on, by content: same Kind, User and Text, timestamps within
/// <see cref="SameCommandSkew"/> (each node stamps the same command with its own clock).
/// A matched stored row is consumed, so repeated identical entries are counted, not
/// merged. An entry with no match is new: at its own ordinal when that is past the stored
/// trail, otherwise appended after it (<c>MAX(seq)+1</c>) with a Warning — kept, never
/// dropped, and never silent.
/// </para>
/// <para>
/// <b>Why this cannot double-count.</b> Both pair nodes apply every command; the second
/// node to save finds the first's copy among the stored rows (same Kind/User/Text, close
/// timestamp) and consumes it instead of appending. A node's own earlier divergence-append
/// is found the same way, so a re-save writes nothing. What it cannot tell apart: a peer
/// entry this node missed and a byte-identical entry this node later records within the
/// skew window — that pair is kept once. A clock skew between the nodes beyond the window
/// errs the other way (both copies kept), never towards loss. True union semantics need an
/// originator-assigned entry id on the alarm command (follow-up F3).
/// </para>
/// <para>
/// ⚠️ <b>The in-memory index is the durable ordinal.</b> Trimming
/// <see cref="AlarmConditionState.Comments"/> in the engine without carrying a persisted
/// base offset would make every later save look "already stored" — do not bound that list
/// without changing this method.
/// </para>
/// </remarks>
private async Task AppendNewCommentsAsync(ILocalDbTransaction tx, AlarmConditionState state, CancellationToken ct)
{
var comments = state.Comments;
var count = comments.Count;
// An indexed MAX over the (scripted_alarm_id, seq) primary key — a read, so no replication cost.
var max = (await tx.QueryAsync(
"SELECT COALESCE(MAX(seq), -1) FROM alarm_condition_comment WHERE scripted_alarm_id = @Id",
static r => r.GetInt64(0),
new { Id = state.AlarmId },
ct).ConfigureAwait(false))[0];
var windowStart = Math.Max(0, count - MatchWindow);
// Entries below the window that lie past the stored trail (a first save of a long trail, an
// old-shape row's lazy move): plain appends at their own ordinal — nothing is stored there.
for (var seq = (int)Math.Max(max + 1, 0); seq < windowStart; seq++)
{
await InsertCommentAsync(tx, state.AlarmId, seq, comments[seq], ct).ConfigureAwait(false);
max = seq;
}
if (windowStart >= count) return;
// The stored rows the window can match: ordinal >= windowStart. Bounded by the window plus the
// number of commands this node missed.
var stored = max >= windowStart
? await tx.QueryAsync(
$"SELECT {SelectCommentColumns} FROM alarm_condition_comment WHERE scripted_alarm_id = @Id AND seq >= @Lo ORDER BY seq",
MapComment,
new { Id = state.AlarmId, Lo = windowStart },
ct).ConfigureAwait(false)
: [];
var consumed = new bool[stored.Count];
for (var i = windowStart; i < count; i++)
{
var entry = comments[i];
var match = -1;
for (var j = 0; j < stored.Count; j++)
{
if (!consumed[j] && IsSameEntry(stored[j].Comment, entry))
{
match = j;
break;
}
}
if (match >= 0)
{
consumed[match] = true;
continue;
}
if (i > max)
{
await InsertCommentAsync(tx, state.AlarmId, i, entry, ct).ConfigureAwait(false);
max = i;
continue;
}
var target = max + 1;
await InsertCommentAsync(tx, state.AlarmId, target, entry, ct).ConfigureAwait(false);
max = target;
_logger.LogWarning(
"Alarm {AlarmId}: comment trail diverged from the pair peer — the {Kind} entry by {User} at in-memory "
+ "position {Position} collides with a stored entry this node never applied (a missed command). "
+ "Kept: appended at ordinal {Ordinal}.",
state.AlarmId, entry.Kind, entry.User, i, target);
}
}
private static bool IsSameEntry(AlarmComment stored, AlarmComment local) =>
string.Equals(stored.Kind, local.Kind, StringComparison.Ordinal)
&& string.Equals(stored.User, local.User, StringComparison.Ordinal)
&& string.Equals(stored.Text, local.Text, StringComparison.Ordinal)
&& (stored.TimestampUtc - local.TimestampUtc).Duration() <= SameCommandSkew;
private static Task<int> InsertCommentAsync(
ILocalDbTransaction tx, string alarmId, long seq, AlarmComment c, CancellationToken ct) =>
tx.ExecuteAsync(
AlarmConditionStateSchema.InsertCommentSql,
new
{
Id = alarmId,
Seq = seq,
// AlarmComment.TimestampUtc must be DateTimeKind.Utc for a Utc round-trip; the engine
// always creates comments with Utc kind.
Ts = AlarmConditionStateSchema.FormatTimestamp(c.TimestampUtc),
User = c.User,
Kind = c.Kind,
Text = c.Text,
},
ct);
/// <inheritdoc />
public async Task RemoveAsync(string alarmId, CancellationToken ct)
{
ArgumentNullException.ThrowIfNull(alarmId);
await _db.ExecuteAsync(
// The trail goes with the condition, as it did when it was a column of the row.
await using var tx = await _db.BeginTransactionAsync(ct).ConfigureAwait(false);
await tx.ExecuteAsync(
"DELETE FROM alarm_condition_comment WHERE scripted_alarm_id = @Id",
new { Id = alarmId },
ct).ConfigureAwait(false);
await tx.ExecuteAsync(
"DELETE FROM alarm_condition_state WHERE scripted_alarm_id = @Id",
new { Id = alarmId },
ct).ConfigureAwait(false);
await tx.CommitAsync(ct).ConfigureAwait(false);
}
private static AlarmConditionState MapRow(SqliteDataReader r) => new(
/// <summary>A condition row as read, before its trail is attached.</summary>
private sealed record ConditionRow(AlarmConditionState State, string InlineCommentsJson);
/// <summary>One <c>alarm_condition_comment</c> row.</summary>
private sealed record StoredComment(string AlarmId, long Seq, AlarmComment Comment);
/// <summary>
/// Attaches the trail: the comment table's entries by ordinal, plus — for an old-shape row not
/// yet migrated — any inline entry at an ordinal the table does not hold.
/// </summary>
private static AlarmConditionState WithComments(ConditionRow row, IReadOnlyList<StoredComment> stored)
{
var inline = AlarmConditionStateSchema.ParseInlineComments(row.InlineCommentsJson);
if (inline.Count == 0)
return row.State with { Comments = stored.Select(s => s.Comment).ToImmutableList() };
var merged = new SortedDictionary<long, AlarmComment>();
foreach (var s in stored)
merged[s.Seq] = s.Comment;
for (var i = 0; i < inline.Count; i++)
{
var c = inline[i];
merged.TryAdd(i, new AlarmComment(c.TimestampUtc, c.User, c.Kind, c.Text));
}
return row.State with { Comments = merged.Values.ToImmutableList() };
}
private static StoredComment MapComment(SqliteDataReader r) => new(
AlarmId: r.GetString(0),
Enabled: string.Equals(r.GetString(1), "Disabled", StringComparison.Ordinal)
? AlarmEnabledState.Disabled
: AlarmEnabledState.Enabled, // unknown string → Enabled (safe default)
// Active is not persisted — the engine re-derives it from the predicate at startup.
Active: AlarmActiveState.Inactive,
Acked: string.Equals(r.GetString(2), "Acknowledged", StringComparison.Ordinal)
? AlarmAckedState.Acknowledged
: AlarmAckedState.Unacknowledged, // unknown string → Unacknowledged (safe default)
Confirmed: string.Equals(r.GetString(3), "Confirmed", StringComparison.Ordinal)
? AlarmConfirmedState.Confirmed
: AlarmConfirmedState.Unconfirmed, // unknown string → Unconfirmed (safe default)
Shelving: new ShelvingState(MapShelvingFromColumn(r.GetString(4)), ParseNullable(r, 5)),
// No transition column — updated_at_utc carries the last transition timestamp.
LastTransitionUtc: Parse(r.GetString(13)),
// LastActiveUtc / LastClearedUtc have no columns — they re-derive with Active, so null on load.
LastActiveUtc: null,
LastClearedUtc: null,
LastAckUtc: ParseNullable(r, 8),
LastAckUser: r.IsDBNull(6) ? null : r.GetString(6),
LastAckComment: r.IsDBNull(7) ? null : r.GetString(7),
LastConfirmUtc: ParseNullable(r, 11),
LastConfirmUser: r.IsDBNull(9) ? null : r.GetString(9),
LastConfirmComment: r.IsDBNull(10) ? null : r.GetString(10),
Comments: DeserializeComments(r.GetString(12)));
Seq: r.GetInt64(1),
Comment: new AlarmComment(
AlarmConditionStateSchema.ParseTimestamp(r.GetString(2)),
r.GetString(3),
r.GetString(4),
r.GetString(5)));
private static ConditionRow MapRow(SqliteDataReader r) => new(
new AlarmConditionState(
AlarmId: r.GetString(0),
Enabled: string.Equals(r.GetString(1), "Disabled", StringComparison.Ordinal)
? AlarmEnabledState.Disabled
: AlarmEnabledState.Enabled, // unknown string → Enabled (safe default)
// Active is not persisted — the engine re-derives it from the predicate at startup.
Active: AlarmActiveState.Inactive,
Acked: string.Equals(r.GetString(2), "Acknowledged", StringComparison.Ordinal)
? AlarmAckedState.Acknowledged
: AlarmAckedState.Unacknowledged, // unknown string → Unacknowledged (safe default)
Confirmed: string.Equals(r.GetString(3), "Confirmed", StringComparison.Ordinal)
? AlarmConfirmedState.Confirmed
: AlarmConfirmedState.Unconfirmed, // unknown string → Unconfirmed (safe default)
Shelving: new ShelvingState(MapShelvingFromColumn(r.GetString(4)), ParseNullable(r, 5)),
// No transition column — updated_at_utc carries the last transition timestamp.
LastTransitionUtc: Parse(r.GetString(13)),
// LastActiveUtc / LastClearedUtc have no columns — they re-derive with Active, so null on load.
LastActiveUtc: null,
LastClearedUtc: null,
LastAckUtc: ParseNullable(r, 8),
LastAckUser: r.IsDBNull(6) ? null : r.GetString(6),
LastAckComment: r.IsDBNull(7) ? null : r.GetString(7),
LastConfirmUtc: ParseNullable(r, 11),
LastConfirmUser: r.IsDBNull(9) ? null : r.GetString(9),
LastConfirmComment: r.IsDBNull(10) ? null : r.GetString(10),
Comments: ImmutableList<AlarmComment>.Empty),
InlineCommentsJson: r.GetString(12));
// Timestamps are stored as round-trip ("O") TEXT so lexicographic order stays chronological and
// the DateTimeKind.Utc round-trips exactly.
private static string Format(DateTime value) => value.ToString("O", CultureInfo.InvariantCulture);
private static string Format(DateTime value) => AlarmConditionStateSchema.FormatTimestamp(value);
private static string? FormatNullable(DateTime? value) => value?.ToString("O", CultureInfo.InvariantCulture);
private static string? FormatNullable(DateTime? value) => value is { } v ? Format(v) : null;
private static DateTime Parse(string value) =>
DateTime.Parse(value, CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind);
private static DateTime Parse(string value) => AlarmConditionStateSchema.ParseTimestamp(value);
private static DateTime? ParseNullable(SqliteDataReader r, int ordinal) =>
r.IsDBNull(ordinal) ? null : Parse(r.GetString(ordinal));
@@ -220,45 +459,4 @@ public sealed class LocalDbAlarmConditionStateStore : IAlarmStateStore
"TimedShelved" => ShelvingKind.Timed,
_ => ShelvingKind.Unshelved, // unknown string → Unshelved (safe default)
};
private static string SerializeComments(ImmutableList<AlarmComment> comments)
{
if (comments.IsEmpty) return "[]";
var dtos = comments.Select(c => new CommentDto
{
// AlarmComment.TimestampUtc must be DateTimeKind.Utc for correct ISO-8601 round-trip;
// the engine always creates AlarmComment instances with Utc kind.
TimestampUtc = c.TimestampUtc,
User = c.User,
Kind = c.Kind,
Text = c.Text,
});
return JsonSerializer.Serialize(dtos, JsonOptions);
}
private static ImmutableList<AlarmComment> DeserializeComments(string? json)
{
if (string.IsNullOrWhiteSpace(json)) return ImmutableList<AlarmComment>.Empty;
var dtos = JsonSerializer.Deserialize<List<CommentDto>>(json, JsonOptions);
if (dtos is null || dtos.Count == 0) return ImmutableList<AlarmComment>.Empty;
return dtos
.Select(d => new AlarmComment(d.TimestampUtc, d.User ?? string.Empty, d.Kind ?? string.Empty, d.Text ?? string.Empty))
.ToImmutableList();
}
/// <summary>Stable on-disk shape for a persisted <see cref="AlarmComment"/> in <c>comments_json</c>.</summary>
private sealed class CommentDto
{
/// <summary>When the comment was recorded (UTC).</summary>
public DateTime TimestampUtc { get; set; }
/// <summary>Identity of the actor that wrote the comment.</summary>
public string? User { get; set; }
/// <summary>Human-readable classification of the comment (Acknowledge, Confirm, …).</summary>
public string? Kind { get; set; }
/// <summary>Operator-supplied or engine-generated comment text.</summary>
public string? Text { get; set; }
}
}