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:
@@ -304,11 +304,29 @@ public static class MqttWriteEncoder
|
||||
return MqttWriteEncodeError.None;
|
||||
|
||||
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.LongValue = (ulong)new DateTimeOffset(
|
||||
Convert.ToDateTime(value, CultureInfo.InvariantCulture).ToUniversalTime())
|
||||
.ToUnixTimeMilliseconds();
|
||||
metric.LongValue = (ulong)unixMs;
|
||||
return MqttWriteEncodeError.None;
|
||||
}
|
||||
|
||||
default:
|
||||
// DriverDataType.Reference and anything added later: refuse rather than guess a
|
||||
|
||||
Reference in New Issue
Block a user