72be19225d
MqttDriver implemented no IWritable, so every MQTT node materialized read-only
regardless of what the operator authored. It now publishes.
Plain -> the tag's writeTopic; UTF-8 text (Raw/Scalar) or a bare JSON
literal (Json)
Sparkplug -> DCMD for a device metric, NCMD for an edge-node metric, carrying
the metric BY NAME with an explicit Datatype and no seq
Both at QoS 0, retain:false. Retain is deliberately not configurable — a retained
command is replayed by the broker on every future subscribe, including the
device's own reconnect, so one stale write would be re-applied forever.
writeTopic is a new, separate TagConfig key, and its ABSENCE is meaningful: the
write is refused BadNotWritable and the node is discovered ViewOnly. The issue's
"do not skip" warning drove the design — the one outcome worse than read-only
nodes is a node that looks writable, returns Good and publishes nothing. Reusing
the read topic would be worse still: it would echo back and read as a confirmed
device value. Browse-time writability is computed by the SAME encoder the write
path uses, so a node cannot advertise writability it would then refuse.
Good means published, not applied — MQTT has no application-level write ack, so
this is Galaxy's optimistic-Good posture and inherits its property: a failed
DEVICE write can never be surfaced. A failed PUBLISH is reported Bad; the
optimism covers what the device did with the command, never whether it was sent.
WriteIdempotent stays default-off.
The encoder is pure and lives in .Contracts, so both wire shapes are testable
without a broker. Unrepresentable values and types with no Sparkplug encoding are
refused rather than coerced — a Reference must not silently become a String on
the wire, and "not-a-number" must not become 0 on a live plant.
LIVE GATE PASSED against the real Mosquitto fixture on 10.100.0.35: 21/21
integration tests, including 6 new ones that observe the published message with
an INDEPENDENT broker subscriber — a fake asserting "publish was called" could
not tell the feature working from the feature publishing to the wrong topic.
The gate earned its keep: the NCMD test initially failed because NCMD is a SHARED
channel — the driver publishes its own rebirth request (Node Control/Rebirth) on
the very topic an edge-node metric write uses, and it arrived first. Correct
driver behaviour, wrong test; the watcher now matches on metric name. Documented,
because anything consuming that topic needs to know. DCMD has no contention.
Offline: 24 encoder + 9 driver tests. The four new status constants are NAMED so
StatusCodeParityTests can see them (#497/#519) — it went 258 -> 262 and validated
each against the pinned SDK.
Also corrects the Mqtt.md "Known gaps" table: prunes the six rows closed in this
round, and fixes the #507 row, which still described rediscovery as inert after
that issue was closed as superseded and its injection path deleted.
Claude-Session: https://claude.ai/code/session_015p7wGqy3YpZNCpDzTpGMKo
161 lines
6.7 KiB
C#
161 lines
6.7 KiB
C#
using Shouldly;
|
|
using Xunit;
|
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests;
|
|
|
|
/// <summary>
|
|
/// Unit tests for <see cref="MqttDriver"/>'s <see cref="IWritable"/> leg (Gitea #508) — the batch
|
|
/// contract and the per-slot refusals, up to (not including) the broker publish.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// The publish itself is the live gate's job: it needs a broker, and asserting it here would only
|
|
/// prove that a fake was called. What IS worth pinning offline is everything that decides whether a
|
|
/// write should reach a broker at all — because that is where a silent no-op would hide.
|
|
/// </remarks>
|
|
[Trait("Category", "Unit")]
|
|
public sealed class MqttDriverWriteTests
|
|
{
|
|
private const string Path = "Plant/Mqtt/dev1/Setpoint";
|
|
private const string ReadOnlyPath = "Plant/Mqtt/dev1/Temp";
|
|
|
|
private static RawTagEntry PlainTag(string rawPath, string? writeTopic)
|
|
{
|
|
var writeKey = writeTopic is null ? "" : $$""","writeTopic":"{{writeTopic}}" """;
|
|
return new RawTagEntry(
|
|
rawPath,
|
|
$$"""{"topic":"factory/oven/temp","payloadFormat":"Scalar","dataType":"Float64"{{writeKey}}}""",
|
|
WriteIdempotent: false);
|
|
}
|
|
|
|
private static MqttDriver PlainDriver(params RawTagEntry[] tags)
|
|
=> new(new MqttDriverOptions { Mode = MqttMode.Plain, RawTags = tags }, "d", null);
|
|
|
|
[Fact]
|
|
public void MqttDriver_implements_IWritable()
|
|
// The interface's presence is the feature: without it every MQTT node materialises read-only,
|
|
// regardless of what the operator authored on the tag.
|
|
=> PlainDriver().ShouldBeAssignableTo<IWritable>();
|
|
|
|
[Fact]
|
|
public async Task An_unknown_reference_is_refused_in_its_own_slot()
|
|
{
|
|
var results = await PlainDriver(PlainTag(Path, "oven/set"))
|
|
.WriteAsync([new WriteRequest("Plant/Mqtt/dev1/NotATag", 1.0)], default);
|
|
|
|
results.Count.ShouldBe(1);
|
|
results[0].StatusCode.ShouldBe(0x80340000u); // BadNodeIdUnknown
|
|
}
|
|
|
|
[Fact]
|
|
public async Task A_tag_with_no_write_topic_is_refused_as_not_writable()
|
|
{
|
|
// NOT BadCommunicationError and NOT Good: the tag is addressable and the broker is irrelevant —
|
|
// the operator simply never said where a write should go. The status has to say that, or the
|
|
// only diagnosis available is "writes don't work".
|
|
var results = await PlainDriver(PlainTag(ReadOnlyPath, writeTopic: null))
|
|
.WriteAsync([new WriteRequest(ReadOnlyPath, 1.0)], default);
|
|
|
|
results[0].StatusCode.ShouldBe(0x803B0000u); // BadNotWritable
|
|
}
|
|
|
|
[Fact]
|
|
public async Task A_writable_tag_on_a_disconnected_driver_reports_a_communication_failure()
|
|
{
|
|
// Reported, never queued. A write buffered now and flushed at an unbounded later moment is the
|
|
// replay hazard WriteIdempotent exists to prevent — and this would be the driver deciding it on
|
|
// the operator's behalf.
|
|
var results = await PlainDriver(PlainTag(Path, "oven/set"))
|
|
.WriteAsync([new WriteRequest(Path, 42.5)], default);
|
|
|
|
results[0].StatusCode.ShouldBe(0x80050000u); // BadCommunicationError
|
|
}
|
|
|
|
[Fact]
|
|
public async Task One_bad_request_does_not_fail_the_others_in_the_batch()
|
|
{
|
|
// Mirrors ReadAsync: failing the whole call would let one stale reference reject every other
|
|
// write in the same OPC UA request.
|
|
var driver = PlainDriver(PlainTag(Path, "oven/set"), PlainTag(ReadOnlyPath, writeTopic: null));
|
|
|
|
var results = await driver.WriteAsync(
|
|
[
|
|
new WriteRequest("Plant/Mqtt/dev1/NotATag", 1.0),
|
|
new WriteRequest(ReadOnlyPath, 1.0),
|
|
new WriteRequest(Path, 42.5),
|
|
],
|
|
default);
|
|
|
|
// Order-preserving, one status per request, each naming its own distinct cause.
|
|
results.Count.ShouldBe(3);
|
|
results[0].StatusCode.ShouldBe(0x80340000u); // BadNodeIdUnknown
|
|
results[1].StatusCode.ShouldBe(0x803B0000u); // BadNotWritable
|
|
results[2].StatusCode.ShouldBe(0x80050000u); // BadCommunicationError — resolved and encodable
|
|
}
|
|
|
|
[Fact]
|
|
public async Task An_empty_batch_is_answered_with_an_empty_result()
|
|
=> (await PlainDriver().WriteAsync([], default)).ShouldBeEmpty();
|
|
|
|
[Fact]
|
|
public async Task WriteAsync_rejects_a_null_batch()
|
|
=> await Should.ThrowAsync<ArgumentNullException>(
|
|
async () => await PlainDriver().WriteAsync(null!, default));
|
|
|
|
// ---- Discovery advertises writability consistently with the write path ------------------------
|
|
|
|
[Fact]
|
|
public async Task A_tag_with_a_write_topic_is_discovered_as_an_operate_node()
|
|
{
|
|
var builder = new WriteCapturingBuilder();
|
|
|
|
await PlainDriver(PlainTag(Path, "oven/set")).DiscoverAsync(builder, default);
|
|
|
|
builder.Variables.ShouldHaveSingleItem();
|
|
builder.Variables[0].SecurityClass.ShouldBe(SecurityClassification.Operate);
|
|
// Default-off regardless: MQTT gives no application-level write ack, and a Sparkplug command is
|
|
// not necessarily idempotent. Opting in is the operator's call, per tag.
|
|
builder.Variables[0].WriteIdempotent.ShouldBeFalse();
|
|
}
|
|
|
|
[Fact]
|
|
public async Task A_tag_without_a_write_topic_stays_view_only()
|
|
{
|
|
// The browse-time answer must match the runtime one — a node advertised Operate that then
|
|
// refuses every write would be the silent-no-op failure in a new costume.
|
|
var builder = new WriteCapturingBuilder();
|
|
|
|
await PlainDriver(PlainTag(ReadOnlyPath, writeTopic: null)).DiscoverAsync(builder, default);
|
|
|
|
builder.Variables[0].SecurityClass.ShouldBe(SecurityClassification.ViewOnly);
|
|
}
|
|
|
|
/// <summary>Minimal capturing builder — this project does not reference Commons' own.</summary>
|
|
private sealed class WriteCapturingBuilder : IAddressSpaceBuilder
|
|
{
|
|
public List<DriverAttributeInfo> Variables { get; } = [];
|
|
|
|
public IAddressSpaceBuilder Folder(string browseName, string displayName) => this;
|
|
|
|
public IVariableHandle Variable(string browseName, string displayName, DriverAttributeInfo attributeInfo)
|
|
{
|
|
Variables.Add(attributeInfo);
|
|
return new Handle(attributeInfo.FullName);
|
|
}
|
|
|
|
public void AddProperty(string browseName, DriverDataType dataType, object? value) { }
|
|
|
|
private sealed class Handle(string fullRef) : IVariableHandle
|
|
{
|
|
public string FullReference => fullRef;
|
|
|
|
public IAlarmConditionSink MarkAsAlarmCondition(AlarmConditionInfo info) => new NullSink();
|
|
}
|
|
|
|
private sealed class NullSink : IAlarmConditionSink
|
|
{
|
|
public void OnTransition(AlarmEventArgs args) { }
|
|
}
|
|
}
|
|
}
|