fix(mqtt-contracts): strict retainSeed, real bounds instead of dead [Range]s, DateTime edges, closed topic formatting
Driver.Mqtt.Contracts-004/-005/-006/-007. -004: retainSeed was the last behavioural key read leniently, so "retainSeed":"false" — what a CSV import produces — silently became the default TRUE, the INVERSE of the authored intent. Both mappers now read it through TryReadBoolStrict (the qos absent/valid/present-but-invalid split) and DescribeRejection names it. The authoring side needed no hand-mirrored rule: MqttTagConfigModel.Validate already consults this same parse through TagConfigParseGate, so the save is blocked with the inspector's wording — pinned by a new AdminUI parity theory pair. -005: the seven [Range] attributes on MqttDriverOptions/MqttPlainOptions never ran — the options are bound by JsonSerializer, and nothing in the four MQTT projects calls Validator.TryValidateObject. Two of them CONTRADICTED the real authority (KeepAlive's [Range(1,…)] forbade the zero Normalize admits as legal MQTT; MaxPayloadBytes' forbade the non-positive value its own remarks document as falling back to 1 MiB), so wiring them up would have broken deliberate behaviour. Deleted, with each property naming where its bound actually lives. The one escape they papered over is closed: an out-of-range defaultQos rode into the SUBSCRIBE verbatim, and MqttSubscriptionManager's constructor now clamps it exactly as the AdminUI driver form already persists. -006: the DateTime write arm cast a negative long to ulong, so 1969-12-31 published a timestamp ~584 million years in the future; it is now refused as ValueNotRepresentable. An Unspecified kind was handed to ToUniversalTime(), which reads it as LOCAL — the same authored instant encoded differently on the two nodes of a redundant pair; it is now read as UTC. -007: Format/FormatState enforced only null/empty, and nothing at all on deviceId, so the type was not round-trip-closed. They now apply the parser's IsValidSegment (plus '/', which only the format side can see). MqttWriteEncoder's own guards stay — TryEncode returns an error rather than throwing, and that contract does not change.
This commit is contained in:
@@ -1,4 +1,3 @@
|
|||||||
using System.ComponentModel.DataAnnotations;
|
|
||||||
using System.Text;
|
using System.Text;
|
||||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||||
|
|
||||||
@@ -15,14 +14,30 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Mqtt;
|
|||||||
/// <c>OpcUaClientDriverOptions</c>. <see cref="Mode"/> selects the ingest shape;
|
/// <c>OpcUaClientDriverOptions</c>. <see cref="Mode"/> selects the ingest shape;
|
||||||
/// <see cref="Sparkplug"/> and <see cref="Plain"/> are nullable sub-objects — only the one
|
/// <see cref="Sparkplug"/> and <see cref="Plain"/> are nullable sub-objects — only the one
|
||||||
/// matching <see cref="Mode"/> is populated.
|
/// matching <see cref="Mode"/> is populated.
|
||||||
|
/// <para>
|
||||||
|
/// ⚠️ <b>There are deliberately no <c>DataAnnotations</c> range attributes on this DTO, and adding
|
||||||
|
/// any would be documentation that reads like enforcement.</b> The options are bound by
|
||||||
|
/// <c>JsonSerializer.Deserialize</c> through <see cref="MqttJson.Options"/>, never by
|
||||||
|
/// <c>IOptions</c> + <c>ValidateDataAnnotations</c>, and nothing in the four MQTT projects calls
|
||||||
|
/// <c>Validator.TryValidateObject</c> — so the seven <c>[Range]</c>s this type used to carry never
|
||||||
|
/// ran once. Worse, two of them <i>contradicted</i> the real authority: <c>[Range(1, …)]</c> on
|
||||||
|
/// <see cref="KeepAliveSeconds"/> forbade the zero that <c>MqttDriver.Normalize</c> deliberately
|
||||||
|
/// admits as legal MQTT, and the same on <see cref="MaxPayloadBytes"/> forbade the non-positive
|
||||||
|
/// value this type's own remarks document as "falls back to the 1 MiB default". Wiring them up
|
||||||
|
/// would therefore have broken deliberate behaviour, so the bounds live where they are actually
|
||||||
|
/// applied — <c>MqttDriver.Normalize</c>, <c>MqttSubscriptionManager</c>'s constructor and
|
||||||
|
/// <c>MqttConnection.NextBackoff</c> — and each property below names its own.
|
||||||
|
/// </para>
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
public sealed record MqttDriverOptions
|
public sealed record MqttDriverOptions
|
||||||
{
|
{
|
||||||
/// <summary>Broker hostname or IP address.</summary>
|
/// <summary>Broker hostname or IP address.</summary>
|
||||||
public string Host { get; init; } = "localhost";
|
public string Host { get; init; } = "localhost";
|
||||||
|
|
||||||
/// <summary>Broker TCP port.</summary>
|
/// <summary>
|
||||||
[Range(1, 65535)]
|
/// Broker TCP port. Range-checked by <c>MqttDriverProbe</c> and the AdminUI driver form; there is
|
||||||
|
/// no <c>[Range]</c> attribute here because nothing would ever run it — see the type remarks.
|
||||||
|
/// </summary>
|
||||||
public int Port { get; init; } = 8883;
|
public int Port { get; init; } = 8883;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -67,20 +82,22 @@ public sealed record MqttDriverOptions
|
|||||||
/// <summary>Whether to request a clean session (v3.1.1) / clean start (v5.0) at CONNECT.</summary>
|
/// <summary>Whether to request a clean session (v3.1.1) / clean start (v5.0) at CONNECT.</summary>
|
||||||
public bool CleanSession { get; init; } = true;
|
public bool CleanSession { get; init; } = true;
|
||||||
|
|
||||||
/// <summary>Keep-alive interval sent at CONNECT.</summary>
|
/// <summary>
|
||||||
[Range(1, int.MaxValue)]
|
/// Keep-alive interval sent at CONNECT. Bounded by <c>MqttDriver.Normalize</c>, which admits
|
||||||
|
/// <b>zero</b> — legal MQTT for "no keep-alive" — and only replaces a negative value.
|
||||||
|
/// </summary>
|
||||||
public int KeepAliveSeconds { get; init; } = 30;
|
public int KeepAliveSeconds { get; init; } = 30;
|
||||||
|
|
||||||
/// <summary>Bounded connect deadline (see design §8) — the driver never hangs past this.</summary>
|
/// <summary>
|
||||||
[Range(1, int.MaxValue)]
|
/// Bounded connect deadline (see design §8) — the driver never hangs past this. A non-positive
|
||||||
|
/// value is replaced by <c>MqttDriver.Normalize</c>, which is the enforcement point.
|
||||||
|
/// </summary>
|
||||||
public int ConnectTimeoutSeconds { get; init; } = 15;
|
public int ConnectTimeoutSeconds { get; init; } = 15;
|
||||||
|
|
||||||
/// <summary>Initial reconnect backoff after a connection drop.</summary>
|
/// <summary>Initial reconnect backoff after a connection drop; clamped by <c>MqttConnection.NextBackoff</c>.</summary>
|
||||||
[Range(1, int.MaxValue)]
|
|
||||||
public int ReconnectMinBackoffSeconds { get; init; } = 1;
|
public int ReconnectMinBackoffSeconds { get; init; } = 1;
|
||||||
|
|
||||||
/// <summary>Cap on the exponential reconnect backoff.</summary>
|
/// <summary>Cap on the exponential reconnect backoff; clamped by <c>MqttConnection.NextBackoff</c>.</summary>
|
||||||
[Range(1, int.MaxValue)]
|
|
||||||
public int ReconnectMaxBackoffSeconds { get; init; } = 30;
|
public int ReconnectMaxBackoffSeconds { get; init; } = 30;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -96,7 +113,6 @@ public sealed record MqttDriverOptions
|
|||||||
/// because this <c>.Contracts</c> assembly is <i>referenced by</i> the driver assembly and
|
/// because this <c>.Contracts</c> assembly is <i>referenced by</i> the driver assembly and
|
||||||
/// cannot reference it back; the manager's constructor is the single enforcement point.
|
/// cannot reference it back; the manager's constructor is the single enforcement point.
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
[Range(1, int.MaxValue)]
|
|
||||||
public int MaxPayloadBytes { get; init; } = 1024 * 1024;
|
public int MaxPayloadBytes { get; init; } = 1024 * 1024;
|
||||||
|
|
||||||
/// <summary>Selects the ingest shape — Plain topics or Sparkplug B.</summary>
|
/// <summary>Selects the ingest shape — Plain topics or Sparkplug B.</summary>
|
||||||
@@ -208,7 +224,6 @@ public sealed record MqttSparkplugOptions
|
|||||||
/// How long, in seconds, browse/discovery collects NBIRTH/DBIRTH traffic before
|
/// How long, in seconds, browse/discovery collects NBIRTH/DBIRTH traffic before
|
||||||
/// considering the observed metric set stable.
|
/// considering the observed metric set stable.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[Range(1, int.MaxValue)]
|
|
||||||
public int BirthObservationWindowSeconds { get; init; } = 15;
|
public int BirthObservationWindowSeconds { get; init; } = 15;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -225,7 +240,10 @@ public sealed record MqttPlainOptions
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public string TopicPrefix { get; init; } = "";
|
public string TopicPrefix { get; init; } = "";
|
||||||
|
|
||||||
/// <summary>Default QoS used when a tag's own <c>qos</c> is unset.</summary>
|
/// <summary>
|
||||||
[Range(0, 2)]
|
/// Default QoS used when a tag's own <c>qos</c> is unset. MQTT defines exactly three levels;
|
||||||
|
/// <c>MqttSubscriptionManager</c>'s constructor clamps this to 0–2 before it can reach a
|
||||||
|
/// SUBSCRIBE, matching what the AdminUI driver form persists.
|
||||||
|
/// </summary>
|
||||||
public int DefaultQos { get; init; } = 1;
|
public int DefaultQos { get; init; } = 1;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -61,10 +61,11 @@ public static class MqttTagDefinitionFactory
|
|||||||
/// and framing is decided solely by <c>JsonDocument.Parse</c> plus the
|
/// and framing is decided solely by <c>JsonDocument.Parse</c> plus the
|
||||||
/// root-<see cref="JsonValueKind.Object"/> check — so leading whitespace is skipped exactly as the JSON
|
/// root-<see cref="JsonValueKind.Object"/> check — so leading whitespace is skipped exactly as the JSON
|
||||||
/// spec says, and a padded blob reaches the same verdict its unpadded form does (Gitea #559).
|
/// spec says, and a padded blob reaches the same verdict its unpadded form does (Gitea #559).
|
||||||
/// Enum fields (<c>payloadFormat</c> / <c>dataType</c>) and <c>qos</c> are read STRICTLY — a
|
/// Enum fields (<c>payloadFormat</c> / <c>dataType</c>), <c>qos</c> and <c>retainSeed</c> are read
|
||||||
/// present-but-invalid (typo'd) value rejects the whole tag (returns <see langword="false"/> ⇒ the
|
/// STRICTLY — a present-but-invalid (typo'd) value rejects the whole tag (returns
|
||||||
/// driver surfaces <c>BadNodeIdUnknown</c>) rather than silently defaulting to a wrong-typed Good or
|
/// <see langword="false"/> ⇒ the driver surfaces <c>BadNodeIdUnknown</c>) rather than silently
|
||||||
/// a downgraded delivery guarantee. Never throws.
|
/// defaulting to a wrong-typed Good, a downgraded delivery guarantee, or the inverse of the
|
||||||
|
/// authored seeding behaviour. Never throws.
|
||||||
/// <para>
|
/// <para>
|
||||||
/// A <b>wildcard</b> topic (<c>+</c> / <c>#</c>) is deliberately NOT rejected here: it is
|
/// A <b>wildcard</b> topic (<c>+</c> / <c>#</c>) is deliberately NOT rejected here: it is
|
||||||
/// ambiguous rather than unparseable, and <see cref="Inspect"/> is the operator-visible surface
|
/// ambiguous rather than unparseable, and <see cref="Inspect"/> is the operator-visible surface
|
||||||
@@ -119,6 +120,12 @@ public static class MqttTagDefinitionFactory
|
|||||||
// WEAKER delivery guarantee than the one they authored, with nothing to show for it.
|
// WEAKER delivery guarantee than the one they authored, with nothing to show for it.
|
||||||
if (!TryReadQosStrict(root, out var qos)) return false;
|
if (!TryReadQosStrict(root, out var qos)) return false;
|
||||||
|
|
||||||
|
// …and so is retainSeed, the last behavioural key that was not. A lenient read turned
|
||||||
|
// "retainSeed":"false" — which a CSV import or a hand-edited blob produces naturally — into
|
||||||
|
// the default TRUE, the OPPOSITE of what was authored, and the tag then seeded its initial
|
||||||
|
// value from the broker's retained message the author asked it to ignore.
|
||||||
|
if (!TryReadBoolStrict(root, "retainSeed", defaultValue: true, out var retainSeed)) return false;
|
||||||
|
|
||||||
var jsonPath = ReadString(root, "jsonPath");
|
var jsonPath = ReadString(root, "jsonPath");
|
||||||
if (string.IsNullOrEmpty(jsonPath)) jsonPath = RootJsonPath;
|
if (string.IsNullOrEmpty(jsonPath)) jsonPath = RootJsonPath;
|
||||||
|
|
||||||
@@ -129,7 +136,7 @@ public static class MqttTagDefinitionFactory
|
|||||||
JsonPath: jsonPath,
|
JsonPath: jsonPath,
|
||||||
DataType: dataType,
|
DataType: dataType,
|
||||||
Qos: qos,
|
Qos: qos,
|
||||||
RetainSeed: ReadBoolOrDefault(root, "retainSeed", defaultValue: true),
|
RetainSeed: retainSeed,
|
||||||
DataTypeAuthored: dataTypeAuthored,
|
DataTypeAuthored: dataTypeAuthored,
|
||||||
// Optional, and absence is meaningful: no writeTopic ⇒ not writable (Gitea #508).
|
// Optional, and absence is meaningful: no writeTopic ⇒ not writable (Gitea #508).
|
||||||
// Blank normalises to null so "" and absent cannot mean different things.
|
// Blank normalises to null so "" and absent cannot mean different things.
|
||||||
@@ -230,6 +237,10 @@ public static class MqttTagDefinitionFactory
|
|||||||
|
|
||||||
if (!TryReadQosStrict(root, out var qos)) return false;
|
if (!TryReadQosStrict(root, out var qos)) return false;
|
||||||
|
|
||||||
|
// Same key, same strictness — retainSeed is read identically by both shapes, so a blob's
|
||||||
|
// verdict on it must not depend on which reader took it.
|
||||||
|
if (!TryReadBoolStrict(root, "retainSeed", defaultValue: true, out var retainSeed)) return false;
|
||||||
|
|
||||||
def = new MqttTagDefinition(
|
def = new MqttTagDefinition(
|
||||||
Name: rawPath,
|
Name: rawPath,
|
||||||
// Plain-shape fields carried through verbatim; the Sparkplug ingest path reads none of
|
// Plain-shape fields carried through verbatim; the Sparkplug ingest path reads none of
|
||||||
@@ -239,7 +250,7 @@ public static class MqttTagDefinitionFactory
|
|||||||
JsonPath: RootJsonPath,
|
JsonPath: RootJsonPath,
|
||||||
DataType: dataType,
|
DataType: dataType,
|
||||||
Qos: qos,
|
Qos: qos,
|
||||||
RetainSeed: ReadBoolOrDefault(root, "retainSeed", defaultValue: true),
|
RetainSeed: retainSeed,
|
||||||
DataTypeAuthored: dataTypeAuthored,
|
DataTypeAuthored: dataTypeAuthored,
|
||||||
GroupId: groupId,
|
GroupId: groupId,
|
||||||
EdgeNodeId: edgeNodeId,
|
EdgeNodeId: edgeNodeId,
|
||||||
@@ -267,7 +278,7 @@ public static class MqttTagDefinitionFactory
|
|||||||
/// <item>
|
/// <item>
|
||||||
/// <b>Rejections</b> — a blob NEITHER runtime mapper would read: an unparseable or
|
/// <b>Rejections</b> — a blob NEITHER runtime mapper would read: an unparseable or
|
||||||
/// non-object document, present-but-invalid <c>payloadFormat</c> / <c>dataType</c> /
|
/// non-object document, present-but-invalid <c>payloadFormat</c> / <c>dataType</c> /
|
||||||
/// <c>qos</c> values (all read strictly), or an address that names neither a Plain
|
/// <c>qos</c> / <c>retainSeed</c> values (all read strictly), or an address that names neither a Plain
|
||||||
/// <c>topic</c> nor a complete Sparkplug <c>groupId</c>/<c>edgeNodeId</c>/<c>metricName</c>
|
/// <c>topic</c> nor a complete Sparkplug <c>groupId</c>/<c>edgeNodeId</c>/<c>metricName</c>
|
||||||
/// tuple. Such a tag is skipped by the driver and reads <c>BadNodeIdUnknown</c> forever.
|
/// tuple. Such a tag is skipped by the driver and reads <c>BadNodeIdUnknown</c> forever.
|
||||||
/// </item>
|
/// </item>
|
||||||
@@ -446,6 +457,9 @@ public static class MqttTagDefinitionFactory
|
|||||||
TagConfigJson.DescribeInvalidEnum<MqttPayloadFormat>(root, "payloadFormat"),
|
TagConfigJson.DescribeInvalidEnum<MqttPayloadFormat>(root, "payloadFormat"),
|
||||||
TagConfigJson.DescribeInvalidEnum<DriverDataType>(root, "dataType"),
|
TagConfigJson.DescribeInvalidEnum<DriverDataType>(root, "dataType"),
|
||||||
DescribeInvalidQos(root),
|
DescribeInvalidQos(root),
|
||||||
|
// Read strictly by BOTH mappers, so — unlike payloadFormat — it can never land on an
|
||||||
|
// accepted blob and is a rejection cause outright.
|
||||||
|
DescribeInvalidBool(root, "retainSeed"),
|
||||||
})
|
})
|
||||||
{
|
{
|
||||||
if (w is not null) rejections.Add(w);
|
if (w is not null) rejections.Add(w);
|
||||||
@@ -515,8 +529,45 @@ public static class MqttTagDefinitionFactory
|
|||||||
private static string? NullIfBlank(string value)
|
private static string? NullIfBlank(string value)
|
||||||
=> string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
=> string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
||||||
|
|
||||||
private static bool ReadBoolOrDefault(JsonElement o, string name, bool defaultValue)
|
/// <summary>
|
||||||
=> o.TryGetProperty(name, out var e) && e.ValueKind is JsonValueKind.True or JsonValueKind.False
|
/// Strict boolean read, mirroring <see cref="TryReadQosStrict"/>'s absent / valid /
|
||||||
? e.GetBoolean()
|
/// present-but-invalid split: absent ⇒ <paramref name="defaultValue"/>; a JSON <c>true</c>/
|
||||||
: defaultValue;
|
/// <c>false</c> ⇒ that value; anything else present (a string, a number, an explicit
|
||||||
|
/// <c>null</c>) ⇒ the read FAILS.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="o">The TagConfig root object.</param>
|
||||||
|
/// <param name="name">The property name to read.</param>
|
||||||
|
/// <param name="defaultValue">The value an ABSENT key takes.</param>
|
||||||
|
/// <param name="value">The parsed flag.</param>
|
||||||
|
/// <returns><see langword="false"/> only when the key is present but not a JSON boolean.</returns>
|
||||||
|
/// <remarks>
|
||||||
|
/// Lenient-with-a-default is the wrong shape for a BEHAVIOURAL flag, which is the whole
|
||||||
|
/// <c>qos</c> argument again: absorbing a malformed value into the default silently hands the
|
||||||
|
/// operator the opposite of what they authored, with nothing to show for it. It is worse here
|
||||||
|
/// than for <c>qos</c>, in fact — a defaulted <c>qos</c> is a weaker delivery guarantee, whereas
|
||||||
|
/// a defaulted <c>retainSeed</c> is the INVERSE of the authored intent whenever the author wrote
|
||||||
|
/// <c>false</c>, which is the only value worth writing at all.
|
||||||
|
/// </remarks>
|
||||||
|
private static bool TryReadBoolStrict(JsonElement o, string name, bool defaultValue, out bool value)
|
||||||
|
{
|
||||||
|
value = defaultValue;
|
||||||
|
if (!o.TryGetProperty(name, out var e)) return true; // absent
|
||||||
|
if (e.ValueKind is not (JsonValueKind.True or JsonValueKind.False)) return false;
|
||||||
|
value = e.GetBoolean();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A human-readable warning for a present-but-invalid boolean field, or <see langword="null"/>
|
||||||
|
/// when it is absent or valid — the boolean counterpart of <see cref="DescribeInvalidQos"/>.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="o">The TagConfig root object.</param>
|
||||||
|
/// <param name="name">The property name to describe.</param>
|
||||||
|
/// <returns>The warning text, or <see langword="null"/>.</returns>
|
||||||
|
private static string? DescribeInvalidBool(JsonElement o, string name)
|
||||||
|
{
|
||||||
|
if (!o.TryGetProperty(name, out var e)) return null;
|
||||||
|
if (e.ValueKind is JsonValueKind.True or JsonValueKind.False) return null;
|
||||||
|
return $"value '{e.GetRawText()}' for '{name}' is not a JSON boolean; valid: true, false";
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -304,11 +304,29 @@ public static class MqttWriteEncoder
|
|||||||
return MqttWriteEncodeError.None;
|
return MqttWriteEncodeError.None;
|
||||||
|
|
||||||
case DriverDataType.DateTime:
|
case DriverDataType.DateTime:
|
||||||
|
{
|
||||||
|
var authored = Convert.ToDateTime(value, CultureInfo.InvariantCulture);
|
||||||
|
|
||||||
|
// An Unspecified kind is read as UTC, not passed to ToUniversalTime() — that treats
|
||||||
|
// it as LOCAL, so the identical authored instant would encode differently on the two
|
||||||
|
// nodes of a redundant pair, which are not required to share a timezone. Reading it
|
||||||
|
// as UTC rather than refusing it keeps an ordinary authored "2026-08-10T12:00:00"
|
||||||
|
// writable: Convert.ToDateTime produces exactly that kind for one.
|
||||||
|
var utc = authored.Kind == DateTimeKind.Unspecified
|
||||||
|
? DateTime.SpecifyKind(authored, DateTimeKind.Utc)
|
||||||
|
: authored.ToUniversalTime();
|
||||||
|
|
||||||
|
// Sparkplug's DateTime is an UNSIGNED milliseconds-since-epoch, so a pre-1970 instant
|
||||||
|
// has no representation at all — the cast below would wrap a negative long into a
|
||||||
|
// timestamp ~584 million years in the future. Refuse it: unrepresentable values are
|
||||||
|
// refused, never coerced.
|
||||||
|
var unixMs = new DateTimeOffset(utc).ToUnixTimeMilliseconds();
|
||||||
|
if (unixMs < 0) return MqttWriteEncodeError.ValueNotRepresentable;
|
||||||
|
|
||||||
metric.Datatype = (uint)TahuDataType.DateTime;
|
metric.Datatype = (uint)TahuDataType.DateTime;
|
||||||
metric.LongValue = (ulong)new DateTimeOffset(
|
metric.LongValue = (ulong)unixMs;
|
||||||
Convert.ToDateTime(value, CultureInfo.InvariantCulture).ToUniversalTime())
|
|
||||||
.ToUnixTimeMilliseconds();
|
|
||||||
return MqttWriteEncodeError.None;
|
return MqttWriteEncodeError.None;
|
||||||
|
}
|
||||||
|
|
||||||
default:
|
default:
|
||||||
// DriverDataType.Reference and anything added later: refuse rather than guess a
|
// DriverDataType.Reference and anything added later: refuse rather than guess a
|
||||||
|
|||||||
@@ -195,10 +195,24 @@ public sealed record SparkplugTopic(
|
|||||||
/// it does not have to hand-concatenate segments itself.
|
/// it does not have to hand-concatenate segments itself.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <exception cref="ArgumentException">
|
/// <exception cref="ArgumentException">
|
||||||
/// <paramref name="groupId"/>/<paramref name="edgeNodeId"/> is null/empty, or
|
/// <paramref name="groupId"/>/<paramref name="edgeNodeId"/> is null/empty, any supplied id is
|
||||||
|
/// not a legal single topic segment (see <see cref="IsValidSegment"/>), or
|
||||||
/// <paramref name="type"/> is <see cref="SparkplugMessageType.STATE"/> (use
|
/// <paramref name="type"/> is <see cref="SparkplugMessageType.STATE"/> (use
|
||||||
/// <see cref="FormatState"/> instead — STATE has no group/edge-node/device segments).
|
/// <see cref="FormatState"/> instead — STATE has no group/edge-node/device segments).
|
||||||
/// </exception>
|
/// </exception>
|
||||||
|
/// <remarks>
|
||||||
|
/// <b>The per-segment rule is enforced here, not left to the caller</b>, so the type is
|
||||||
|
/// round-trip-closed: everything this emits re-parses through <see cref="TryParse"/> to the
|
||||||
|
/// instance it was built from. Without it, a <c>+</c>/<c>#</c> id produced a topic MQTT-3.3.2-2
|
||||||
|
/// forbids in a PUBLISH — a protocol violation a conformant broker answers by dropping the
|
||||||
|
/// connection — and a <c>deviceId</c> containing <c>/</c> silently added a segment, i.e. a
|
||||||
|
/// well-formed topic addressing a different device than the caller named.
|
||||||
|
/// <para>
|
||||||
|
/// ⚠️ This THROWS where it previously succeeded, so a caller that must not throw keeps its own
|
||||||
|
/// guard: <c>MqttWriteEncoder.TryEncode</c> returns <c>NoWriteDestination</c> for these ids
|
||||||
|
/// rather than propagating, and that contract does not change.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
public static string Format(string groupId, SparkplugMessageType type, string edgeNodeId, string? deviceId = null)
|
public static string Format(string groupId, SparkplugMessageType type, string edgeNodeId, string? deviceId = null)
|
||||||
{
|
{
|
||||||
ArgumentException.ThrowIfNullOrEmpty(groupId);
|
ArgumentException.ThrowIfNullOrEmpty(groupId);
|
||||||
@@ -208,15 +222,26 @@ public sealed record SparkplugTopic(
|
|||||||
throw new ArgumentException("Use FormatState to build a STATE topic.", nameof(type));
|
throw new ArgumentException("Use FormatState to build a STATE topic.", nameof(type));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ThrowIfNotASegment(groupId, nameof(groupId));
|
||||||
|
ThrowIfNotASegment(edgeNodeId, nameof(edgeNodeId));
|
||||||
|
if (deviceId is not null) ThrowIfNotASegment(deviceId, nameof(deviceId));
|
||||||
|
|
||||||
return deviceId is null
|
return deviceId is null
|
||||||
? $"{Namespace}/{groupId}/{type}/{edgeNodeId}"
|
? $"{Namespace}/{groupId}/{type}/{edgeNodeId}"
|
||||||
: $"{Namespace}/{groupId}/{type}/{edgeNodeId}/{deviceId}";
|
: $"{Namespace}/{groupId}/{type}/{edgeNodeId}/{deviceId}";
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Builds the v3.0 STATE topic string <c>spBv1.0/STATE/{hostId}</c>.</summary>
|
/// <summary>Builds the v3.0 STATE topic string <c>spBv1.0/STATE/{hostId}</c>.</summary>
|
||||||
|
/// <param name="hostId">The primary-host id; must be a legal single topic segment.</param>
|
||||||
|
/// <returns>The STATE topic string.</returns>
|
||||||
|
/// <exception cref="ArgumentException">
|
||||||
|
/// <paramref name="hostId"/> is null/empty or is not a legal single topic segment — see the
|
||||||
|
/// round-trip-closure remarks on <see cref="Format"/>.
|
||||||
|
/// </exception>
|
||||||
public static string FormatState(string hostId)
|
public static string FormatState(string hostId)
|
||||||
{
|
{
|
||||||
ArgumentException.ThrowIfNullOrEmpty(hostId);
|
ArgumentException.ThrowIfNullOrEmpty(hostId);
|
||||||
|
ThrowIfNotASegment(hostId, nameof(hostId));
|
||||||
return $"{Namespace}/STATE/{hostId}";
|
return $"{Namespace}/STATE/{hostId}";
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -288,4 +313,27 @@ public sealed record SparkplugTopic(
|
|||||||
|
|
||||||
private static bool IsValidSegment(string segment) =>
|
private static bool IsValidSegment(string segment) =>
|
||||||
segment.Length > 0 && segment.IndexOf('+') < 0 && segment.IndexOf('#') < 0;
|
segment.Length > 0 && segment.IndexOf('+') < 0 && segment.IndexOf('#') < 0;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Applies <see cref="IsValidSegment"/> on the FORMAT side, so the parser and the formatter judge
|
||||||
|
/// an id by one rule — plus <c>/</c>, which only the format side can see.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="segment">The candidate segment.</param>
|
||||||
|
/// <param name="paramName">The argument name to report.</param>
|
||||||
|
/// <exception cref="ArgumentException">The segment is empty, carries an MQTT wildcard, or carries <c>/</c>.</exception>
|
||||||
|
/// <remarks>
|
||||||
|
/// <c>/</c> cannot appear in a parsed id — <see cref="TryParse"/> has already split on it — so
|
||||||
|
/// <see cref="IsValidSegment"/> has no reason to test for one and does not. On this side it is
|
||||||
|
/// the difference between an id and two ids: a formatted value carrying one produces a topic with
|
||||||
|
/// an extra segment, which addresses something other than what the caller named and no longer
|
||||||
|
/// re-parses at all. Rejecting it is what makes the round trip total.
|
||||||
|
/// </remarks>
|
||||||
|
private static void ThrowIfNotASegment(string segment, string paramName)
|
||||||
|
{
|
||||||
|
if (IsValidSegment(segment) && segment.IndexOf('/') < 0) return;
|
||||||
|
throw new ArgumentException(
|
||||||
|
$"'{segment}' is not a legal Sparkplug topic segment: it must be non-empty and carry no '/' "
|
||||||
|
+ "and no MQTT wildcard (+ or #).",
|
||||||
|
paramName);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -65,6 +65,14 @@ public sealed class MqttSubscriptionManager
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public const int DefaultMaxPayloadBytes = 1024 * 1024;
|
public const int DefaultMaxPayloadBytes = 1024 * 1024;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Subscription QoS applied when neither the tag nor the driver's <c>Plain</c> section authored
|
||||||
|
/// one. Mirrors <see cref="MqttPlainOptions.DefaultQos"/>' own default — duplicated rather than
|
||||||
|
/// referenced because that value is an instance property, and this is the fallback for the case
|
||||||
|
/// where the whole section is absent.
|
||||||
|
/// </summary>
|
||||||
|
public const int DefaultQos = 1;
|
||||||
|
|
||||||
// OPC UA status codes (values verified against Opc.Ua.StatusCodes, not recalled).
|
// OPC UA status codes (values verified against Opc.Ua.StatusCodes, not recalled).
|
||||||
private const uint StatusGood = 0x00000000u;
|
private const uint StatusGood = 0x00000000u;
|
||||||
|
|
||||||
@@ -137,7 +145,13 @@ public sealed class MqttSubscriptionManager
|
|||||||
|
|
||||||
MaxPayloadBytes = maxPayloadBytes > 0 ? maxPayloadBytes : DefaultMaxPayloadBytes;
|
MaxPayloadBytes = maxPayloadBytes > 0 ? maxPayloadBytes : DefaultMaxPayloadBytes;
|
||||||
_mode = options.Mode;
|
_mode = options.Mode;
|
||||||
_defaultQos = options.Plain?.DefaultQos ?? 1;
|
// Clamped here for the same reason MaxPayloadBytes is: this constructor is the single
|
||||||
|
// enforcement point for the driver options the manager captures, and MQTT defines exactly three
|
||||||
|
// QoS levels — an out-of-range value would otherwise ride into BuildFilters and out onto the
|
||||||
|
// SUBSCRIBE verbatim. Clamp rather than fall back to the default, matching what
|
||||||
|
// MqttDriverFormModel.Normalize already persists, so the authoring surface and the driver cannot
|
||||||
|
// reach different answers for the same authored number.
|
||||||
|
_defaultQos = Math.Clamp(options.Plain?.DefaultQos ?? DefaultQos, 0, 2);
|
||||||
_driverId = driverId;
|
_driverId = driverId;
|
||||||
_transport = transport;
|
_transport = transport;
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
|
|||||||
@@ -117,6 +117,14 @@ public sealed class MqttTagConfigModel
|
|||||||
/// the key and inherit the driver's default (<c>true</c>). An absent key stays absent. Shared by
|
/// the key and inherit the driver's default (<c>true</c>). An absent key stays absent. Shared by
|
||||||
/// both modes — <c>MqttTagDefinitionFactory</c> reads <c>retainSeed</c> identically for Plain and
|
/// both modes — <c>MqttTagDefinitionFactory</c> reads <c>retainSeed</c> identically for Plain and
|
||||||
/// Sparkplug.
|
/// Sparkplug.
|
||||||
|
/// <para>
|
||||||
|
/// The <b>load</b> is lenient (a non-boolean value reads as <c>null</c>, i.e. "unset"), but the
|
||||||
|
/// driver reads the key STRICTLY — <c>"retainSeed":"false"</c>, which a CSV import produces
|
||||||
|
/// naturally, rejects the whole tag rather than silently taking the default, which is the
|
||||||
|
/// INVERSE of that author's intent. No hand-mirrored rule is needed here for that:
|
||||||
|
/// <see cref="Validate"/>'s <see cref="TagConfigParseGate"/> consult asks the driver's own reader
|
||||||
|
/// about the loaded blob, so the save is blocked with the inspector's own wording.
|
||||||
|
/// </para>
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public bool? RetainSeed { get; set; }
|
public bool? RetainSeed { get; set; }
|
||||||
|
|
||||||
|
|||||||
@@ -423,6 +423,38 @@ public sealed class MqttSubscriptionManagerTests
|
|||||||
press.SeedRetained.ShouldBeFalse();
|
press.SeedRetained.ShouldBeFalse();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// An out-of-range driver-level <c>defaultQos</c> must never reach the wire. MQTT defines exactly
|
||||||
|
/// three QoS levels, and this manager's constructor is the single enforcement point for the
|
||||||
|
/// driver options it captures — the same place <c>MaxPayloadBytes</c> is bounded. The
|
||||||
|
/// <c>[Range(0, 2)]</c> attribute on <c>MqttPlainOptions.DefaultQos</c> never ran: nothing in the
|
||||||
|
/// four MQTT projects calls <c>Validator.TryValidateObject</c>, and the options arrive by
|
||||||
|
/// <c>JsonSerializer.Deserialize</c>, so a hand-edited or API-supplied driver config put an
|
||||||
|
/// illegal QoS straight into the SUBSCRIBE. Clamping (rather than falling back to 1) matches what
|
||||||
|
/// the AdminUI driver form already persists, so the two cannot disagree.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="authored">The authored driver-level default QoS.</param>
|
||||||
|
/// <param name="expected">The QoS the SUBSCRIBE must carry.</param>
|
||||||
|
[Theory]
|
||||||
|
[InlineData(5, 2)]
|
||||||
|
[InlineData(-1, 0)]
|
||||||
|
[InlineData(0, 0)]
|
||||||
|
[InlineData(2, 2)]
|
||||||
|
public async Task DefaultQos_OutsideTheLegalRange_NeverReachesTheSubscribe(int authored, int expected)
|
||||||
|
{
|
||||||
|
var transport = new FakeTransport();
|
||||||
|
var mgr = new MqttSubscriptionManager(
|
||||||
|
new MqttDriverOptions { Mode = MqttMode.Plain, Plain = new MqttPlainOptions { DefaultQos = authored } },
|
||||||
|
"mqtt-1",
|
||||||
|
transport);
|
||||||
|
|
||||||
|
// The tag authors no qos of its own, so the driver-level default is what rides out.
|
||||||
|
mgr.Register([Tag(OvenTempPath, $$"""{"topic":"{{OvenTopic}}","payloadFormat":"Raw"}""")]);
|
||||||
|
await mgr.SubscribeAsync([OvenTempPath], TimeSpan.FromSeconds(1), TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
transport.Calls.ShouldHaveSingleItem().ShouldHaveSingleItem().Qos.ShouldBe(expected);
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------------
|
||||||
// Subscribe / SUBACK contract
|
// Subscribe / SUBACK contract
|
||||||
// ---------------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -177,6 +177,63 @@ public sealed class MqttTagDefinitionFactoryTests
|
|||||||
=> MqttTagDefinitionFactory.FromTagConfig(
|
=> MqttTagDefinitionFactory.FromTagConfig(
|
||||||
$$"""{"topic":"a/b","payloadFormat":"Raw","qos":{{qosToken}}}""", RawPath, out _).ShouldBeFalse();
|
$$"""{"topic":"a/b","payloadFormat":"Raw","qos":{{qosToken}}}""", RawPath, out _).ShouldBeFalse();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// <c>retainSeed</c> is a BEHAVIOURAL key and is held to the same strictness as
|
||||||
|
/// <c>qos</c> / <c>payloadFormat</c> / <c>dataType</c>. It was the last one read leniently, so
|
||||||
|
/// <c>"retainSeed":"false"</c> — which a CSV import or a hand-edited blob produces naturally —
|
||||||
|
/// silently became <see langword="true"/>, the OPPOSITE of what was authored: the tag then seeds
|
||||||
|
/// its initial value from the broker's retained message when the author asked it not to.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="token">The present-but-invalid <c>retainSeed</c> value.</param>
|
||||||
|
[Theory]
|
||||||
|
[InlineData("\"false\"")] // stringly-typed bool — what a CSV import produces
|
||||||
|
[InlineData("\"true\"")]
|
||||||
|
[InlineData("0")] // wrong JSON type
|
||||||
|
[InlineData("1")]
|
||||||
|
[InlineData("\"yes\"")]
|
||||||
|
[InlineData("null")] // an explicit null is not "absent"
|
||||||
|
public void FromTagConfig_MalformedRetainSeed_RejectsStrict(string token)
|
||||||
|
=> MqttTagDefinitionFactory.FromTagConfig(
|
||||||
|
$$"""{"topic":"a/b","payloadFormat":"Raw","retainSeed":{{token}}}""", RawPath, out _)
|
||||||
|
.ShouldBeFalse();
|
||||||
|
|
||||||
|
/// <summary>The Sparkplug reader reads the same key and must reach the same verdict.</summary>
|
||||||
|
/// <param name="token">The present-but-invalid <c>retainSeed</c> value.</param>
|
||||||
|
[Theory]
|
||||||
|
[InlineData("\"false\"")]
|
||||||
|
[InlineData("0")]
|
||||||
|
[InlineData("null")]
|
||||||
|
public void FromSparkplugTagConfig_MalformedRetainSeed_RejectsStrict(string token)
|
||||||
|
=> MqttTagDefinitionFactory.FromSparkplugTagConfig(
|
||||||
|
$$"""{"groupId":"G","edgeNodeId":"E","metricName":"M","retainSeed":{{token}}}""", RawPath, out _)
|
||||||
|
.ShouldBeFalse();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Falsification control: the strict read must still take both real booleans and an absent key.
|
||||||
|
/// A reader that simply rejected every blob carrying the key would pass the theories above.
|
||||||
|
/// </summary>
|
||||||
|
[Theory]
|
||||||
|
[InlineData("true", true)]
|
||||||
|
[InlineData("false", false)]
|
||||||
|
public void FromTagConfig_WellFormedRetainSeed_IsStillHonoured(string token, bool expected)
|
||||||
|
{
|
||||||
|
MqttTagDefinitionFactory.FromTagConfig(
|
||||||
|
$$"""{"topic":"a/b","payloadFormat":"Raw","retainSeed":{{token}}}""", RawPath, out var def)
|
||||||
|
.ShouldBeTrue();
|
||||||
|
def.RetainSeed.ShouldBe(expected);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("\"false\"")]
|
||||||
|
[InlineData("0")]
|
||||||
|
public void Inspect_MalformedRetainSeed_Warns(string token)
|
||||||
|
{
|
||||||
|
// Otherwise clean, so retainSeed is the ONLY thing that can produce a warning.
|
||||||
|
var warnings = MqttTagDefinitionFactory.Inspect(
|
||||||
|
$$"""{"topic":"a/b","payloadFormat":"Raw","retainSeed":{{token}}}""");
|
||||||
|
warnings.ShouldHaveSingleItem().ShouldContain("retainSeed", Case.Sensitive);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Inspect_CleanBlob_ReturnsEmpty()
|
public void Inspect_CleanBlob_ReturnsEmpty()
|
||||||
=> MqttTagDefinitionFactory.Inspect(
|
=> MqttTagDefinitionFactory.Inspect(
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
using System.Globalization;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
using Google.Protobuf;
|
using Google.Protobuf;
|
||||||
using Org.Eclipse.Tahu.Protobuf;
|
using Org.Eclipse.Tahu.Protobuf;
|
||||||
@@ -218,6 +219,66 @@ public sealed class MqttWriteEncoderTests
|
|||||||
=> MqttWriteEncoder.TryEncode(MqttMode.SparkplugB, Sparkplug("Filler1"), "not-a-number", out _)
|
=> MqttWriteEncoder.TryEncode(MqttMode.SparkplugB, Sparkplug("Filler1"), "not-a-number", out _)
|
||||||
.ShouldBe(MqttWriteEncodeError.ValueNotRepresentable);
|
.ShouldBe(MqttWriteEncodeError.ValueNotRepresentable);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A Sparkplug <c>DateTime</c> is an UNSIGNED milliseconds-since-epoch, so a pre-1970 instant has
|
||||||
|
/// no representation at all. The <c>(ulong)</c> cast wrapped the negative <c>long</c> around, so
|
||||||
|
/// writing <c>1969-12-31</c> published a timestamp roughly 584 million years in the future — a
|
||||||
|
/// coercion, and the file's own rule is that unrepresentable values are refused, never coerced.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="iso">The authored instant, in UTC.</param>
|
||||||
|
[Theory]
|
||||||
|
[InlineData("1969-12-31T23:59:59Z")]
|
||||||
|
[InlineData("1900-01-01T00:00:00Z")]
|
||||||
|
[InlineData("0001-01-01T00:00:00Z")]
|
||||||
|
public void A_pre_epoch_date_time_is_refused_rather_than_wrapped(string iso)
|
||||||
|
{
|
||||||
|
var value = DateTime.Parse(iso, CultureInfo.InvariantCulture, DateTimeStyles.AdjustToUniversal | DateTimeStyles.AssumeUniversal);
|
||||||
|
|
||||||
|
MqttWriteEncoder.TryEncode(
|
||||||
|
MqttMode.SparkplugB, Sparkplug("Filler1", DriverDataType.DateTime), value, out _)
|
||||||
|
.ShouldBe(MqttWriteEncodeError.ValueNotRepresentable);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Falsification control for the epoch bound: a post-1970 instant still encodes, and encodes to
|
||||||
|
/// the right number. A guard that refused every <c>DateTime</c> would satisfy the theory above.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public void A_post_epoch_date_time_still_encodes_to_its_unix_milliseconds()
|
||||||
|
{
|
||||||
|
var value = new DateTime(2026, 8, 10, 12, 0, 0, DateTimeKind.Utc);
|
||||||
|
|
||||||
|
var payload = Payload.Parser.ParseFrom(EncodeOrThrow(
|
||||||
|
MqttMode.SparkplugB, Sparkplug("Filler1", DriverDataType.DateTime), value));
|
||||||
|
|
||||||
|
payload.Metrics[0].Datatype.ShouldBe((uint)TahuDataType.DateTime);
|
||||||
|
payload.Metrics[0].LongValue.ShouldBe((ulong)new DateTimeOffset(value).ToUnixTimeMilliseconds());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A <see cref="DateTimeKind.Unspecified"/> instant must encode to the SAME wire value on every
|
||||||
|
/// node. <c>ToUniversalTime()</c> treats an unspecified kind as LOCAL, so the identical authored
|
||||||
|
/// value encoded differently depending on the node's timezone — which the two nodes of a
|
||||||
|
/// redundant pair are not required to share. It is read as UTC rather than refused because
|
||||||
|
/// <c>Convert.ToDateTime("2026-08-10T12:00:00")</c> — an ordinary authored string — produces
|
||||||
|
/// exactly this kind.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public void An_unspecified_kind_date_time_encodes_as_utc_not_as_node_local_time()
|
||||||
|
{
|
||||||
|
var unspecified = new DateTime(2026, 8, 10, 12, 0, 0, DateTimeKind.Unspecified);
|
||||||
|
var sameInstantUtc = DateTime.SpecifyKind(unspecified, DateTimeKind.Utc);
|
||||||
|
|
||||||
|
var fromUnspecified = Payload.Parser.ParseFrom(EncodeOrThrow(
|
||||||
|
MqttMode.SparkplugB, Sparkplug("Filler1", DriverDataType.DateTime), unspecified));
|
||||||
|
var fromUtc = Payload.Parser.ParseFrom(EncodeOrThrow(
|
||||||
|
MqttMode.SparkplugB, Sparkplug("Filler1", DriverDataType.DateTime), sameInstantUtc));
|
||||||
|
|
||||||
|
fromUnspecified.Metrics[0].LongValue.ShouldBe(fromUtc.Metrics[0].LongValue);
|
||||||
|
fromUnspecified.Metrics[0].LongValue
|
||||||
|
.ShouldBe((ulong)new DateTimeOffset(sameInstantUtc).ToUnixTimeMilliseconds());
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void A_type_with_no_command_encoding_is_refused_rather_than_stringified()
|
public void A_type_with_no_command_encoding_is_refused_rather_than_stringified()
|
||||||
// Reference has no Sparkplug wire representation. Falling back to String would put a value on
|
// Reference has no Sparkplug wire representation. Falling back to String would put a value on
|
||||||
|
|||||||
@@ -150,6 +150,78 @@ public sealed class SparkplugTopicTests
|
|||||||
public void ToTopicString_RoundTrips(string topic)
|
public void ToTopicString_RoundTrips(string topic)
|
||||||
=> SparkplugTopic.Parse(topic).ToTopicString().ShouldBe(topic);
|
=> SparkplugTopic.Parse(topic).ToTopicString().ShouldBe(topic);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// <see cref="SparkplugTopic.Format"/> must enforce the same per-segment rule
|
||||||
|
/// <see cref="SparkplugTopic.TryParse"/> does. It validated only null/empty on
|
||||||
|
/// <c>groupId</c>/<c>edgeNodeId</c> and NOTHING at all on <c>deviceId</c>, so the type was not
|
||||||
|
/// round-trip-closed: a <c>+</c>/<c>#</c> id produced a topic MQTT-3.3.2-2 forbids in a PUBLISH,
|
||||||
|
/// and a <c>/</c> in a <c>deviceId</c> silently added a segment. Every production caller is
|
||||||
|
/// already guarded, so the invariant belongs on the type that owns it rather than on the next
|
||||||
|
/// caller to arrive.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="groupId">The group id to format with.</param>
|
||||||
|
/// <param name="edgeNodeId">The edge-node id to format with.</param>
|
||||||
|
/// <param name="deviceId">The device id to format with, or <see langword="null"/>.</param>
|
||||||
|
[Theory]
|
||||||
|
[InlineData("Plant+1", "EdgeA", null)]
|
||||||
|
[InlineData("Plant#", "EdgeA", null)]
|
||||||
|
[InlineData("Plant1", "Edge+A", null)]
|
||||||
|
[InlineData("Plant1", "Edge#A", null)]
|
||||||
|
[InlineData("Plant1", "EdgeA", "Fill+er")]
|
||||||
|
[InlineData("Plant1", "EdgeA", "Fill#er")]
|
||||||
|
[InlineData("Ot/Sim", "EdgeA", null)] // two segments, not one — a DIFFERENT node than named
|
||||||
|
[InlineData("Plant1", "EdgeA", "Fill/er")]
|
||||||
|
[InlineData("Plant1", "EdgeA", "")] // would emit a trailing '/', which never re-parses
|
||||||
|
public void Format_SegmentThatIsNotALegalTopicSegment_Throws(string groupId, string edgeNodeId, string? deviceId)
|
||||||
|
=> Should.Throw<ArgumentException>(
|
||||||
|
() => SparkplugTopic.Format(groupId, SparkplugMessageType.DCMD, edgeNodeId, deviceId));
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("host+1")]
|
||||||
|
[InlineData("host#")]
|
||||||
|
public void FormatState_HostIdThatIsNotALegalTopicSegment_Throws(string hostId)
|
||||||
|
=> Should.Throw<ArgumentException>(() => SparkplugTopic.FormatState(hostId));
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Falsification control: the rule is the parser's <c>IsValidSegment</c>, not "reject anything
|
||||||
|
/// unusual". A guard that rejected every id would satisfy both theories above, and a Sparkplug id
|
||||||
|
/// is otherwise an opaque identifier — spaces, dots and dashes are all legitimate.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="id">A legal, if unusual, segment.</param>
|
||||||
|
[Theory]
|
||||||
|
[InlineData("Plant 1")]
|
||||||
|
[InlineData("plant.one")]
|
||||||
|
[InlineData("plant-one_2")]
|
||||||
|
[InlineData("usiné")]
|
||||||
|
public void Format_UnusualButLegalSegments_AreStillAccepted(string id)
|
||||||
|
=> SparkplugTopic.Parse(SparkplugTopic.Format(id, SparkplugMessageType.DCMD, id, id))
|
||||||
|
.ShouldBe(new SparkplugTopic(SparkplugMessageType.DCMD, id, id, id, null));
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The closure property the asymmetry broke: anything <see cref="SparkplugTopic.Format"/> emits
|
||||||
|
/// must parse back to the instance it was built from. It is asserted over the FORMATTER's own
|
||||||
|
/// output rather than over a hand-written topic list, so a future formatting change cannot leave
|
||||||
|
/// the corpus describing a shape the type no longer emits.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public void Format_Output_AlwaysReParsesToTheSameInstance()
|
||||||
|
{
|
||||||
|
SparkplugMessageType[] deviceScoped =
|
||||||
|
[SparkplugMessageType.DBIRTH, SparkplugMessageType.DDATA, SparkplugMessageType.DDEATH, SparkplugMessageType.DCMD];
|
||||||
|
|
||||||
|
foreach (var type in Enum.GetValues<SparkplugMessageType>())
|
||||||
|
{
|
||||||
|
if (type == SparkplugMessageType.STATE) continue;
|
||||||
|
var deviceId = deviceScoped.Contains(type) ? "Filler 1" : null;
|
||||||
|
var topic = SparkplugTopic.Format("Plant 1", type, "Edge.A", deviceId);
|
||||||
|
|
||||||
|
SparkplugTopic.Parse(topic).ShouldBe(new SparkplugTopic(type, "Plant 1", "Edge.A", deviceId, null));
|
||||||
|
}
|
||||||
|
|
||||||
|
SparkplugTopic.Parse(SparkplugTopic.FormatState("otopcua host 1"))
|
||||||
|
.ShouldBe(new SparkplugTopic(SparkplugMessageType.STATE, null, null, null, "otopcua host 1"));
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void ToTopicString_LegacyStateTopic_NormalisesToV3Form()
|
public void ToTopicString_LegacyStateTopic_NormalisesToV3Form()
|
||||||
// Parsed from the legacy no-prefix form, but formatting always emits the v3.0 shape.
|
// Parsed from the legacy no-prefix form, but formatting always emits the v3.0 shape.
|
||||||
|
|||||||
@@ -260,6 +260,37 @@ public sealed class TagConfigNonEnumParityTests
|
|||||||
TagConfigValidator.Validate(DriverTypeNames.Mqtt, blob).ShouldBeNull();
|
TagConfigValidator.Validate(DriverTypeNames.Mqtt, blob).ShouldBeNull();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// MQTT's <c>retainSeed</c>, held to the same rule as <c>qos</c>: a JSON boolean or absent, and
|
||||||
|
/// nothing else. The stringly-typed form is what a CSV import produces, and a lenient read turned
|
||||||
|
/// it into the default <c>true</c> — the INVERSE of the authored intent. The model reads the key
|
||||||
|
/// leniently into its own nullable field, so this parity holds only because
|
||||||
|
/// <c>TagConfigParseGate</c> asks the driver's own reader rather than the model's typed fields.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="blob">A blob whose only fault is <c>retainSeed</c>.</param>
|
||||||
|
[Theory]
|
||||||
|
[InlineData("""{"topic":"otopcua/fixture/a","retainSeed":"false"}""")]
|
||||||
|
[InlineData("""{"topic":"otopcua/fixture/a","retainSeed":0}""")]
|
||||||
|
[InlineData("""{"topic":"otopcua/fixture/a","retainSeed":null}""")]
|
||||||
|
[InlineData("""{"groupId":"OtOpcUaSim","edgeNodeId":"EdgeA","metricName":"M","retainSeed":"false"}""")]
|
||||||
|
public void Mqtt_refuses_a_retainSeed_its_reader_refuses(string blob)
|
||||||
|
{
|
||||||
|
RuntimeAccepts(DriverTypeNames.Mqtt, blob).ShouldBeFalse();
|
||||||
|
TagConfigValidator.Validate(DriverTypeNames.Mqtt, blob).ShouldNotBeNullOrEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The paired acceptances: both real booleans, and an absent key.</summary>
|
||||||
|
/// <param name="blob">A blob whose <c>retainSeed</c> is well-formed or absent.</param>
|
||||||
|
[Theory]
|
||||||
|
[InlineData("""{"topic":"otopcua/fixture/a","retainSeed":true}""")]
|
||||||
|
[InlineData("""{"topic":"otopcua/fixture/a","retainSeed":false}""")]
|
||||||
|
[InlineData("""{"topic":"otopcua/fixture/a"}""")]
|
||||||
|
public void Mqtt_accepts_a_retainSeed_its_reader_accepts(string blob)
|
||||||
|
{
|
||||||
|
RuntimeAccepts(DriverTypeNames.Mqtt, blob).ShouldBeTrue();
|
||||||
|
TagConfigValidator.Validate(DriverTypeNames.Mqtt, blob).ShouldBeNull();
|
||||||
|
}
|
||||||
|
|
||||||
// =============================================================================================
|
// =============================================================================================
|
||||||
// 2. The advisory pin — the caution that shapes the whole implementation.
|
// 2. The advisory pin — the caution that shapes the whole implementation.
|
||||||
// =============================================================================================
|
// =============================================================================================
|
||||||
|
|||||||
Reference in New Issue
Block a user