Merge branch 'feat/mqtt-sparkplug-driver'
v2-ci / build (push) Successful in 5m36s
v2-ci / unit-tests (push) Failing after 22m40s

# Conflicts:
#	src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/DriverTypeNames.cs
#	src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/DriverConfigModal.razor
#	src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/EndpointRouteBuilderExtensions.cs
#	src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/TagConfigEditorMap.cs
#	src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/TagConfigValidator.cs
#	src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ZB.MOM.WW.OtOpcUa.AdminUI.csproj
#	src/Server/ZB.MOM.WW.OtOpcUa.Host/Drivers/DriverFactoryBootstrap.cs
This commit is contained in:
Joseph Doherty
2026-07-27 13:31:39 -04:00
109 changed files with 27428 additions and 62 deletions
@@ -29,9 +29,31 @@ public enum BrowseNodeKind
/// <c>AlarmExtension</c> primitive). The picker pre-fills a default native-alarm <c>alarm</c> object
/// into the TagConfig when an alarm attribute is selected. Defaults to false so non-alarm-aware
/// drivers (e.g. the OPC UA client browser) aren't forced to flow a flag they don't produce.</param>
/// <param name="AddressFields">
/// The <b>structured</b> address this leaf binds by, as <c>TagConfig</c> key → value in the
/// driver's own key vocabulary, for a driver whose address is a <i>tuple</i> rather than a single
/// reference string. Null (the default) for every driver whose address IS the
/// <see cref="BrowseNode.NodeId"/> — nothing changes for them.
/// <para>
/// It exists because a tuple cannot be recovered from an id string in general. MQTT/Sparkplug
/// is the case in point: a browse node id is
/// <c>{group}/{node}[/{device}]::{metric}</c>, and a metric name may itself contain <c>/</c>
/// (<c>Node Control/Rebirth</c>) — so the session keeps the decomposition it already has and
/// <b>states</b> it here, rather than the AdminUI's browse-commit mapper re-deriving it by
/// splitting the id and hoping. Same discipline as the v3 address space carrying
/// <c>AddressSpaceRealm</c> explicitly instead of parsing it out of a NodeId.
/// </para>
/// <para>
/// The producer states the binding <em>shape</em> too, by which keys it emits — so a consumer
/// never has to infer a driver's sub-mode from the tree's geometry. Consumers must read the
/// keys they know and ignore the rest; this is a description, not a config blob to splat into
/// a persisted TagConfig.
/// </para>
/// </param>
public sealed record AttributeInfo(
string Name,
string DriverDataType,
bool IsArray,
string SecurityClass,
bool IsAlarm = false);
bool IsAlarm = false,
IReadOnlyDictionary<string, string>? AddressFields = null);
@@ -35,3 +35,66 @@ public interface IBrowseSession : IAsyncDisposable
/// <returns>The attributes of the node.</returns>
Task<IReadOnlyList<AttributeInfo>> AttributesAsync(string nodeId, CancellationToken cancellationToken);
}
/// <summary>
/// An <see cref="IBrowseSession"/> whose protocol offers an explicit, operator-triggered
/// <b>re-announce</b> action: a request that the remote peer republish its self-description so the
/// observation window can fill without waiting for the next natural announcement. Implemented
/// today only by the MQTT/Sparkplug browser, whose tree is built from observed NBIRTH/DBIRTH
/// certificates.
/// </summary>
/// <remarks>
/// <para>
/// <b>This is the only interface in the browse contract that causes an outbound message.</b>
/// Every other browse member is strictly read-only, and for the MQTT browser that read-only
/// property is load-bearing: a picker opened by an operator runs against a live production
/// broker. It is a separate interface, rather than an optional member on
/// <see cref="IBrowseSession"/>, precisely so "does this session write?" stays a type
/// question a caller cannot forget to ask.
/// </para>
/// <para>
/// <b>Callers must authorize before invoking it.</b> The AdminUI routes it through
/// <c>BrowserSessionService.RequestRebirthAsync</c>, which enforces the same
/// <c>DriverOperator</c> policy that gates the picker's Browse affordance — an implementation
/// cannot check that itself, since it holds no user identity.
/// </para>
/// </remarks>
public interface IRebirthCapableBrowseSession : IBrowseSession
{
/// <summary>
/// Whether this <i>particular</i> session can actually re-announce — the runtime half of the
/// type question, and the one a UI must ask before offering the affordance.
/// </summary>
/// <remarks>
/// One session class may serve several protocol shapes: the MQTT browser opens the same session
/// type for Plain and for Sparkplug B, and a Plain MQTT window publishes <b>nothing, ever</b> —
/// there is no plain-MQTT re-announce to offer. Implementing the interface therefore means "this
/// session type may re-announce"; this property means "this instance will". A UI gating on the
/// type alone would draw a button that can only ever throw.
/// <para>
/// This is <b>not</b> an authorization signal — see the interface remarks. False here hides
/// the affordance; the caller still authorizes before invoking
/// <see cref="RequestRebirthAsync"/>, and the implementation still refuses a call it cannot
/// serve.
/// </para>
/// </remarks>
bool RebirthAvailable { get; }
/// <summary>
/// Asks the addressed remote peer(s) to re-announce themselves.
/// </summary>
/// <param name="scope">
/// The protocol-specific target. For Sparkplug: a browse <c>NodeId</c> from this session's own
/// tree (group, edge node, device or metric — resolved up to the owning edge node), or a bare
/// <c>{group}/{edgeNode}</c> pair for a node that has not been observed yet.
/// </param>
/// <param name="cancellationToken">Cancellation token for the operation.</param>
/// <returns>The number of request messages published.</returns>
/// <exception cref="ArgumentException"><paramref name="scope"/> is empty or unusable.</exception>
/// <exception cref="InvalidOperationException">
/// The scope resolves to no target, or to more targets than the implementation will fan out to
/// in one action. Nothing is published in either case.
/// </exception>
/// <exception cref="NotSupportedException">This session's mode has no re-announce action.</exception>
Task<int> RequestRebirthAsync(string scope, CancellationToken cancellationToken);
}
@@ -55,6 +55,8 @@ public static class DriverTypeNames
/// <summary>Read-only SQL Server table/view poller — publishes columns as OPC UA variables.</summary>
public const string Sql = "Sql";
/// <summary>MQTT / Sparkplug B broker-subscription driver.</summary>
public const string Mqtt = "Mqtt";
/// <summary>
/// Every driver-type string declared above, for callers that need to enumerate
@@ -72,5 +74,6 @@ public static class DriverTypeNames
Galaxy,
Calculation,
Sql,
Mqtt,
];
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,192 @@
using System.Buffers;
using System.Text.Json;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using MQTTnet;
using MQTTnet.Protocol;
using ZB.MOM.WW.OtOpcUa.Commons.Browsing;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Browser;
/// <summary>
/// Bespoke MQTT address-picker browser: opens a short-lived, <b>strictly passive</b> observation
/// window against the broker named by the form's JSON.
/// <para>
/// MQTT has no discovery protocol, so there is nothing to enumerate — the browser subscribes
/// to a wildcard (<c>#</c> / <c>{topicPrefix}/#</c> in plain mode, <c>spBv1.0/{groupId}/#</c>
/// in Sparkplug mode) and lets <see cref="MqttBrowseSession"/> accumulate whatever arrives:
/// a topic segment tree in plain mode, a Group → EdgeNode → Device → Metric tree decoded from
/// observed birth certificates in Sparkplug mode. Nothing on any browse path publishes — in
/// either mode: an operator opening a picker must not be able to inject a message into a
/// running plant. The session's operator-triggered <c>RequestRebirthAsync</c> is the sole
/// sanctioned publish, and it is never reached by <c>OpenAsync</c> or by any browse call.
/// </para>
/// <para>
/// <b>Layering note.</b> Unlike every other bespoke browser here, this project references the
/// runtime <c>.Driver</c> project (to reuse <c>MqttConnection.BuildClientOptions</c> rather
/// than fork the TLS / CA-pin path). That is a known, deliberate exception with a documented
/// cost and a documented clean fix — see the comment on that <c>ProjectReference</c> in
/// <c>ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Browser.csproj</c> before copying this project as a
/// template.
/// </para>
/// </summary>
public sealed class MqttDriverBrowser : IDriverBrowser
{
/// <summary>Floor on the open budget — a 1 s <c>ConnectTimeoutSeconds</c> would make browse unusable.</summary>
internal const int MinOpenBudgetSeconds = 5;
/// <summary>Ceiling on the open budget — the picker must never hang on an unreachable broker.</summary>
internal const int MaxOpenBudgetSeconds = 30;
/// <summary>Marks the transient browse identity in the broker's client-id/session logs.</summary>
internal const string BrowseClientIdPrefix = "-browse-";
private readonly ILogger<MqttDriverBrowser> _logger;
/// <summary>
/// Creates a browser. <b>Connection-free by contract</b> — the universal browser's
/// <c>CanBrowse</c> constructs a throwaway instance purely to ask which driver type it
/// handles, so the constructor must never touch the network.
/// </summary>
/// <param name="logger">Optional logger; defaults to <see cref="NullLogger{T}"/>. Never receives credentials.</param>
public MqttDriverBrowser(ILogger<MqttDriverBrowser>? logger = null) =>
_logger = logger ?? NullLogger<MqttDriverBrowser>.Instance;
/// <inheritdoc />
public string DriverType => DriverTypeNames.Mqtt;
/// <inheritdoc />
/// <remarks>
/// Connects with a browse-only client id, subscribes the wildcard at QoS 0, and hands back a
/// session that serves whatever the subscription observes. The whole open is bounded by
/// <see cref="OpenBudget"/>; nothing here publishes.
/// </remarks>
public async Task<IBrowseSession> OpenAsync(string configJson, CancellationToken cancellationToken)
{
// MqttJson.Options — the ONE shared instance across factory / probe / driver / browser
// (see its remarks). Parsing the same DriverConfig blob through a second, divergent
// JsonSerializerOptions is this repo's documented systemic enum bug: the picker would accept
// a `mode` / `protocolVersion` spelling the deployed driver rejects, or vice versa.
var opts = JsonSerializer.Deserialize<MqttDriverOptions>(configJson, MqttJson.Options)
?? throw new InvalidOperationException("Mqtt options deserialized to null.");
var filter = BuildRootFilter(opts);
var suffix = BuildBrowseClientIdSuffix();
var clientOptions = MqttConnection.BuildClientOptions(ToBrowseOptions(opts), suffix, _logger);
using var openCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
openCts.CancelAfter(OpenBudget(opts));
var client = new MqttClientFactory().CreateMqttClient();
var session = new MqttBrowseSession(opts.Mode, client, _logger);
try
{
// Runs on MQTTnet's dispatcher: record and return, nothing else.
client.ApplicationMessageReceivedAsync += args =>
{
var payload = args.ApplicationMessage.Payload;
ReadOnlySpan<byte> body = payload.IsSingleSegment ? payload.FirstSpan : payload.ToArray();
session.Observe(args.ApplicationMessage.Topic, body);
return Task.CompletedTask;
};
await client.ConnectAsync(clientOptions, openCts.Token).ConfigureAwait(false);
// QoS 0: a transient observation window has no delivery guarantee to offer and should
// cost the broker as little as possible.
var subscribeOptions = new MqttClientSubscribeOptionsBuilder()
.WithTopicFilter(f => f
.WithTopic(filter)
.WithQualityOfServiceLevel(MqttQualityOfServiceLevel.AtMostOnce))
.Build();
await client.SubscribeAsync(subscribeOptions, openCts.Token).ConfigureAwait(false);
_logger.LogInformation(
"AdminUI MQTT browse session opened against {Host}:{Port} in {Mode} mode observing "
+ "'{Filter}' (read-only).",
opts.Host, opts.Port, opts.Mode, filter);
return session;
}
catch
{
await session.DisposeAsync().ConfigureAwait(false); // owns the client — disconnects + disposes
throw;
}
}
/// <summary>
/// The wildcard the observation window subscribes to.
/// <para>
/// <b>Plain mode:</b> the whole broker (<c>#</c>) unless the config scopes it with a
/// topic prefix.
/// </para>
/// <para>
/// <b>Sparkplug mode:</b> the Sparkplug namespace only —
/// <c>spBv1.0/{groupId}/#</c>, matching the group the deployed driver itself subscribes
/// (design §3.1), or <c>spBv1.0/#</c> when no group has been authored yet, so the picker
/// can discover which groups exist. The Plain-mode topic prefix is deliberately ignored:
/// it is a different mode's key, and honouring it would silently narrow the window to a
/// prefix that cannot match a Sparkplug topic at all.
/// </para>
/// </summary>
/// <param name="options">The browse configuration.</param>
/// <returns>The MQTT topic filter to subscribe.</returns>
internal static string BuildRootFilter(MqttDriverOptions options)
{
if (options.Mode == MqttMode.SparkplugB)
{
var groupId = (options.Sparkplug?.GroupId ?? string.Empty).Trim();
return groupId.Length == 0 ? "spBv1.0/#" : $"spBv1.0/{groupId}/#";
}
var prefix = (options.Plain?.TopicPrefix ?? string.Empty).Trim();
if (prefix.Length == 0) return "#";
if (!prefix.EndsWith('/')) prefix += "/";
return prefix + "#";
}
/// <summary>
/// A unique per-session client-id suffix. A broker disconnects an existing client when a new
/// one CONNECTs with the <i>same</i> client id, so sharing the driver's id would knock the
/// live driver offline every time an operator opened the picker.
/// </summary>
/// <returns>The suffix appended to the configured client id.</returns>
internal static string BuildBrowseClientIdSuffix() =>
BrowseClientIdPrefix + Guid.NewGuid().ToString("N")[..8];
/// <summary>
/// Projects the authored options into the ones the browse CONNECT uses. Clean session is
/// forced: a transient browse identity must not leave queued-message state behind on the
/// broker after the picker closes.
/// <para>
/// ⚠ <b>THE landmine — a Last Will would break the read-only guarantee from outside it.</b>
/// <c>MqttDriverOptions</c> (and <c>MqttSparkplugOptions</c>) carry no will, <b>re-verified
/// when Task 23 unsealed Sparkplug browse</b>, so there is nothing to strip and the
/// projection stays a single <c>CleanSession</c> override. If a will is ever added — NDEATH
/// <i>is</i> a will message — it MUST be cleared here: a will is published by the
/// <i>broker</i>, not by us, so it is invisible to
/// <see cref="MqttBrowseSession.PublishCountForTest"/>, and any ungraceful end to a browse
/// session (crash, dropped link, the disconnect deadline expiring) would then fire an
/// NDEATH under the plant's own edge-node identity — killing a live Sparkplug node because
/// an operator opened an address picker. <c>MqttBrowseSessionTests</c>'
/// <c>MqttDriverOptions_CarryNothingWillShaped_…</c> is the reflection guard that turns that
/// "there is nothing to strip" into a checked fact rather than a claim.
/// </para>
/// </summary>
/// <param name="options">The authored configuration.</param>
/// <returns>The configuration the browse CONNECT is built from.</returns>
internal static MqttDriverOptions ToBrowseOptions(MqttDriverOptions options) =>
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.
/// </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));
}
@@ -0,0 +1,56 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<LangVersion>latest</LangVersion>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<RootNamespace>ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Browser</RootNamespace>
<AssemblyName>ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Browser</AssemblyName>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\..\Core\ZB.MOM.WW.OtOpcUa.Commons\ZB.MOM.WW.OtOpcUa.Commons.csproj"/>
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Contracts\ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Contracts.csproj"/>
<!--
┌──────────────────────────────────────────────────────────────────────────────────┐
│ KNOWN, DELIBERATE EXCEPTION to the .Browser → .Contracts pattern. │
│ DO NOT COPY THIS LINE INTO A NEW DRIVER'S BROWSER WITHOUT READING THIS. │
└──────────────────────────────────────────────────────────────────────────────────┘
Every other bespoke browser in this repo (OpcUaClient, Galaxy) references only its own
.Contracts project plus its transport package. This one also references the runtime
.Driver project. That is a real widening, not a free one, and it was accepted knowingly:
WHY: the browse CONNECT reuses MqttConnection.BuildClientOptions, which owns the TLS
posture, the PEM CA-pin chain validator, the credential handling and the protocol
version mapping (~100 lines). A second copy here would be a silent security
divergence — browse quietly not honouring a pinned CA while the runtime driver does.
The method is pure (no I/O, no network), so the dependency costs nothing at run time.
WHAT IT COSTS: the AdminUI's project graph does NOT otherwise include Core.csproj
(only Host.csproj and the runtime Driver.* projects do). Referencing this browser
from the AdminUI therefore pulls in Core + Polly.Core + Serilog at build/deploy time.
THE CLEAN FIX (deferred, not blocked): a small leaf project — say
ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Transport (net10, refs .Contracts + MQTTnet) — holding
BuildClientOptions/ConfigureTls/the CA-pin helpers, referenced by BOTH .Driver and
.Browser. Note the obvious-looking alternative does NOT work: those members cannot
move into .Contracts, because .Contracts is deliberately transport-free (Task 1
removed its MQTTnet reference to keep it so) and BuildClientOptions returns
MqttClientOptions, an MQTTnet type.
-->
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Driver.Mqtt\ZB.MOM.WW.OtOpcUa.Driver.Mqtt.csproj"/>
</ItemGroup>
<ItemGroup>
<PackageReference Include="MQTTnet"/>
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions"/>
</ItemGroup>
<ItemGroup>
<InternalsVisibleTo Include="ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests"/>
</ItemGroup>
</Project>
@@ -0,0 +1,225 @@
using System.ComponentModel.DataAnnotations;
using System.Text;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.Mqtt;
/// <summary>
/// MQTT / Sparkplug B driver configuration. Bound from <c>DriverConfig</c> JSON at
/// driver-host registration time. Models the settings documented in
/// <c>docs/plans/2026-07-15-mqtt-sparkplug-driver-design.md</c> §5.1.
/// </summary>
/// <remarks>
/// A record (not a plain class) so a future secret-resolution seam can produce a
/// credential-resolved copy with a <c>with</c> expression — mirrors
/// <c>OpcUaClientDriverOptions</c>. <see cref="Mode"/> selects the ingest shape;
/// <see cref="Sparkplug"/> and <see cref="Plain"/> are nullable sub-objects — only the one
/// matching <see cref="Mode"/> is populated.
/// </remarks>
public sealed record MqttDriverOptions
{
/// <summary>Broker hostname or IP address.</summary>
public string Host { get; init; } = "localhost";
/// <summary>Broker TCP port.</summary>
[Range(1, 65535)]
public int Port { get; init; } = 8883;
/// <summary>
/// MQTT client identifier sent at CONNECT. Leave unset to let the driver generate one
/// (a stable per-instance id is recommended so broker-side ACLs / session state persist
/// across reconnects).
/// </summary>
public string? ClientId { get; init; }
/// <summary>
/// When <c>true</c>, connect over TLS. Default <c>true</c> — this driver never ships an
/// anonymous/plaintext-by-default posture; a deployment must opt into <c>false</c> for an
/// on-prem/dev broker with no TLS listener.
/// </summary>
public bool UseTls { get; init; } = true;
/// <summary>
/// When <c>true</c>, accept any self-signed / untrusted broker certificate. Dev/on-prem
/// escape hatch only — mirrors the <c>ServerHistorian</c> TLS knobs. Must stay
/// <c>false</c> in production so MITM attacks against the broker connection fail closed.
/// </summary>
public bool AllowUntrustedServerCertificate { get; init; } = false;
/// <summary>
/// PEM CA file pinning the broker's TLS chain. <c>null</c>/empty uses the OS trust
/// store.
/// </summary>
public string? CaCertificatePath { get; init; }
/// <summary>Username for broker authentication. <c>null</c> connects without a username.</summary>
public string? Username { get; init; }
/// <summary>
/// Password for broker authentication. Blank in committed JSON — supply via env, never
/// commit or log.
/// </summary>
public string? Password { get; init; } = "";
/// <summary>MQTT protocol version to negotiate at CONNECT.</summary>
public MqttProtocolVersion ProtocolVersion { get; init; } = MqttProtocolVersion.V500;
/// <summary>Whether to request a clean session (v3.1.1) / clean start (v5.0) at CONNECT.</summary>
public bool CleanSession { get; init; } = true;
/// <summary>Keep-alive interval sent at CONNECT.</summary>
[Range(1, int.MaxValue)]
public int KeepAliveSeconds { get; init; } = 30;
/// <summary>Bounded connect deadline (see design §8) — the driver never hangs past this.</summary>
[Range(1, int.MaxValue)]
public int ConnectTimeoutSeconds { get; init; } = 15;
/// <summary>Initial reconnect backoff after a connection drop.</summary>
[Range(1, int.MaxValue)]
public int ReconnectMinBackoffSeconds { get; init; } = 1;
/// <summary>Cap on the exponential reconnect backoff.</summary>
[Range(1, int.MaxValue)]
public int ReconnectMaxBackoffSeconds { get; init; } = 30;
/// <summary>
/// Ceiling on an inbound message body, in bytes. A larger message is refused before any
/// decode or parse and degrades its own tags to <c>BadDecodingError</c>. Default 1 MiB.
/// </summary>
/// <remarks>
/// Decode and parse both run synchronously on MQTTnet's shared dispatcher thread, so their
/// cost is paid by <i>every</i> subscription, not just the offending topic — without a bound,
/// one publisher shipping a multi-megabyte body stalls the whole driver's delivery. "Unbounded"
/// is deliberately not offered; a non-positive value falls back to the driver's own 1 MiB
/// default. The literal is duplicated from <c>MqttSubscriptionManager.DefaultMaxPayloadBytes</c>
/// 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.
/// </remarks>
[Range(1, int.MaxValue)]
public int MaxPayloadBytes { get; init; } = 1024 * 1024;
/// <summary>Selects the ingest shape — Plain topics or Sparkplug B.</summary>
public MqttMode Mode { get; init; } = MqttMode.Plain;
/// <summary>
/// The cluster's authored raw MQTT tags, as delivered by the deploy artifact: each carries the
/// tag's <b>RawPath</b> (its v3 identity and driver wire reference) plus its <c>TagConfig</c>
/// blob. <see cref="MqttDriver"/> maps each through <see cref="MqttTagDefinitionFactory"/> at
/// initialize and re-registers the set <b>wholesale</b> on every reinitialize, so a redeploy
/// that drops a tag stops feeding it.
/// </summary>
/// <remarks>
/// This is also the <b>only</b> source of the driver's discoverable node set — plain MQTT has
/// no browsable address space, and a tag that is not authored here is never materialized no
/// matter how much traffic its topic carries. Mirrors <c>ModbusDriverOptions.RawTags</c> /
/// <c>FocasDriverOptions.RawTags</c>.
/// </remarks>
public IReadOnlyList<RawTagEntry> RawTags { get; init; } = [];
/// <summary>
/// Sparkplug-only settings. Populated when <see cref="Mode"/> is
/// <see cref="MqttMode.SparkplugB"/>; <c>null</c> in Plain mode.
/// </summary>
public MqttSparkplugOptions? Sparkplug { get; init; }
/// <summary>
/// Plain-mode-only settings. Populated when <see cref="Mode"/> is
/// <see cref="MqttMode.Plain"/>; <c>null</c> in Sparkplug B mode.
/// </summary>
public MqttPlainOptions? Plain { get; init; }
/// <summary>
/// Record-generated <c>ToString()</c> member printer, overridden so <see cref="Password"/>
/// never renders in plaintext — this DTO routinely lands in logs / exception messages /
/// debugger watches, and the plan's cross-cutting rule is explicit: never commit or log
/// creds. A set password prints as <c>***</c>; unset (<c>null</c>/empty) renders
/// distinguishably so the redaction can't be mistaken for a real secret.
/// </summary>
private bool PrintMembers(StringBuilder builder)
{
var passwordDisplay = Password switch
{
null => "<null>",
"" => "<empty>",
_ => "***",
};
builder.Append("Host = ").Append(Host);
builder.Append(", Port = ").Append(Port);
builder.Append(", ClientId = ").Append(ClientId);
builder.Append(", UseTls = ").Append(UseTls);
builder.Append(", AllowUntrustedServerCertificate = ").Append(AllowUntrustedServerCertificate);
builder.Append(", CaCertificatePath = ").Append(CaCertificatePath);
builder.Append(", Username = ").Append(Username);
builder.Append(", Password = ").Append(passwordDisplay);
builder.Append(", ProtocolVersion = ").Append(ProtocolVersion);
builder.Append(", CleanSession = ").Append(CleanSession);
builder.Append(", KeepAliveSeconds = ").Append(KeepAliveSeconds);
builder.Append(", ConnectTimeoutSeconds = ").Append(ConnectTimeoutSeconds);
builder.Append(", ReconnectMinBackoffSeconds = ").Append(ReconnectMinBackoffSeconds);
builder.Append(", ReconnectMaxBackoffSeconds = ").Append(ReconnectMaxBackoffSeconds);
builder.Append(", MaxPayloadBytes = ").Append(MaxPayloadBytes);
builder.Append(", Mode = ").Append(Mode);
builder.Append(", Sparkplug = ").Append(Sparkplug);
builder.Append(", Plain = ").Append(Plain);
// Count only: a deployment routinely authors thousands of raw tags and each carries a full
// TagConfig blob, so printing the list would turn any log of this DTO into a config dump.
builder.Append(", RawTags = ").Append(RawTags.Count).Append(" tag(s)");
return true;
}
}
/// <summary>
/// Sparkplug B settings for an <see cref="MqttDriverOptions"/> instance in
/// <see cref="MqttMode.SparkplugB"/> mode. See
/// <c>docs/plans/2026-07-15-mqtt-sparkplug-driver-design.md</c> §5.1.
/// </summary>
public sealed record MqttSparkplugOptions
{
/// <summary>Sparkplug group id — the driver subscribes <c>spBv1.0/{GroupId}/#</c>.</summary>
public string GroupId { get; init; } = "";
/// <summary>
/// Sparkplug Host Application identity — published as <c>spBv1.0/STATE/{HostId}</c>
/// (Sparkplug v3.0).
/// </summary>
public string HostId { get; init; } = "";
/// <summary>
/// When <c>true</c>, this driver instance acts as the Sparkplug Primary Host
/// Application. At most one primary host may exist per host-id per broker.
/// </summary>
public bool ActAsPrimaryHost { get; init; } = false;
/// <summary>
/// When <c>true</c>, a detected sequence-number gap triggers a Sparkplug rebirth
/// request (NCMD) rather than silently continuing with stale metric state.
/// </summary>
public bool RequestRebirthOnGap { get; init; } = true;
/// <summary>
/// How long, in seconds, browse/discovery collects NBIRTH/DBIRTH traffic before
/// considering the observed metric set stable.
/// </summary>
[Range(1, int.MaxValue)]
public int BirthObservationWindowSeconds { get; init; } = 15;
}
/// <summary>
/// Plain-MQTT-mode settings for an <see cref="MqttDriverOptions"/> instance in
/// <see cref="MqttMode.Plain"/> mode. See
/// <c>docs/plans/2026-07-15-mqtt-sparkplug-driver-design.md</c> §5.1.
/// </summary>
public sealed record MqttPlainOptions
{
/// <summary>
/// Optional prefix prepended when the driver needs to compose a topic (e.g. discovery
/// scoping); per-tag topics are authored explicitly and are unaffected.
/// </summary>
public string TopicPrefix { get; init; } = "";
/// <summary>Default QoS used when a tag's own <c>qos</c> is unset.</summary>
[Range(0, 2)]
public int DefaultQos { get; init; } = 1;
}
@@ -0,0 +1,53 @@
using System.Text.Json;
using System.Text.Json.Serialization;
namespace ZB.MOM.WW.OtOpcUa.Driver.Mqtt;
/// <summary>
/// The <b>single</b> <see cref="JsonSerializerOptions"/> instance every MQTT driver-config seam
/// parses <see cref="MqttDriverOptions"/> through — the runtime factory
/// (<c>MqttDriverFactoryExtensions</c>), the Test-connect probe (<c>MqttDriverProbe</c>), the
/// driver's own <c>ReinitializeAsync</c> re-parse, and the address-picker browser
/// (<c>MqttDriverBrowser</c>).
/// </summary>
/// <remarks>
/// <para>
/// <b>Why one instance, and why here.</b> Divergent per-seam options are this repo's
/// documented systemic enum bug: an AdminUI-authored config with an enum-valued field is
/// accepted by the seam that carries a <see cref="JsonStringEnumConverter"/> and faults the
/// one that does not, so "Test connect" goes green and the deployed driver dies. Two other
/// drivers already carry a copy per seam. Rather than repeat that, this driver keeps exactly
/// one instance.
/// </para>
/// <para>
/// It lives in <c>.Contracts</c> — the assembly that owns <see cref="MqttDriverOptions"/> and
/// the three enums the converter exists for — rather than in the factory or the probe,
/// because <c>.Contracts</c> is the <i>only</i> assembly all four consumers already reference.
/// In particular the browser lives in its own assembly and reaches the runtime <c>.Driver</c>
/// project only through a deliberate, documented layering exception that is scheduled to be
/// removed (see the <c>ProjectReference</c> comment in the browser's csproj); anchoring the
/// options in <c>.Driver</c> would resurrect the duplicate the day that reference goes away.
/// </para>
/// <para>
/// <b>Not a general-purpose JSON policy.</b> <c>UnmappedMemberHandling.Skip</c> means an
/// unknown key is ignored rather than rejected — deliberate, so a config blob authored
/// against a newer driver still binds — and <c>PropertyNameCaseInsensitive</c> accepts both
/// the camelCase the AdminUI emits and the PascalCase a hand-edited blob may carry. A
/// <see cref="JsonSerializerOptions"/> becomes read-only on first use, so this instance is
/// safe to share across threads and must never be mutated after startup.
/// </para>
/// </remarks>
public static class MqttJson
{
/// <summary>
/// The shared options. Enum-valued knobs (<see cref="MqttMode"/>,
/// <see cref="MqttProtocolVersion"/>, <see cref="MqttPayloadFormat"/>) round-trip by
/// <b>name</b>; numeric ordinals still bind, so an older blob is not broken by this.
/// </summary>
public static readonly JsonSerializerOptions Options = new()
{
PropertyNameCaseInsensitive = true,
UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip,
Converters = { new JsonStringEnumConverter() },
};
}
@@ -0,0 +1,14 @@
namespace ZB.MOM.WW.OtOpcUa.Driver.Mqtt;
/// <summary>
/// Selects the ingest shape an <see cref="MqttDriverOptions"/> instance uses. See
/// <c>docs/plans/2026-07-15-mqtt-sparkplug-driver-design.md</c> §5.1.
/// </summary>
public enum MqttMode
{
/// <summary>Plain MQTT — the driver subscribes to authored topics directly.</summary>
Plain,
/// <summary>Sparkplug B — the driver decodes Tahu-encoded NBIRTH/DBIRTH/NDATA/DDATA payloads.</summary>
SparkplugB,
}
@@ -0,0 +1,17 @@
namespace ZB.MOM.WW.OtOpcUa.Driver.Mqtt;
/// <summary>
/// How a Plain-mode MQTT tag's payload is decoded. See
/// <c>docs/plans/2026-07-15-mqtt-sparkplug-driver-design.md</c> §5.3.
/// </summary>
public enum MqttPayloadFormat
{
/// <summary>The payload is a JSON document; a JSONPath selects the value (<c>jsonPath</c>).</summary>
Json,
/// <summary>The payload bytes are used as-is (e.g. binary / opaque).</summary>
Raw,
/// <summary>The payload is a bare scalar string (e.g. <c>"23.5"</c>), parsed by <c>dataType</c>.</summary>
Scalar,
}
@@ -0,0 +1,14 @@
namespace ZB.MOM.WW.OtOpcUa.Driver.Mqtt;
/// <summary>
/// MQTT wire protocol version negotiated at CONNECT. See
/// <c>docs/plans/2026-07-15-mqtt-sparkplug-driver-design.md</c> §5.1.
/// </summary>
public enum MqttProtocolVersion
{
/// <summary>MQTT 3.1.1.</summary>
V311,
/// <summary>MQTT 5.0.</summary>
V500,
}
@@ -0,0 +1,40 @@
namespace ZB.MOM.WW.OtOpcUa.Driver.Mqtt;
/// <summary>
/// The <c>TagConfig</c> JSON key names that make up an MQTT tag's <b>address</b>, in the two shapes
/// the driver binds by: a single <see cref="Topic"/> (Plain) or the
/// <see cref="GroupId"/>/<see cref="EdgeNodeId"/>/<see cref="DeviceId"/>/<see cref="MetricName"/>
/// tuple (Sparkplug B).
/// </summary>
/// <remarks>
/// <para>
/// These exist because the address is produced in one place and consumed in another, and the
/// two used to agree only by coincidence: the AdminUI browse-commit mapper writes the blob,
/// <see cref="MqttTagDefinitionFactory"/> reads it, and a key-name drift between them is not a
/// compile error — it is a tag that deploys clean, resolves to nothing, and reports
/// <c>BadNodeIdUnknown</c> at runtime with no authoring-time signal at all. Single-sourcing
/// the names makes that drift impossible rather than merely unlikely.
/// </para>
/// <para>
/// <b>Address keys only.</b> The behavioural keys (<c>payloadFormat</c>, <c>jsonPath</c>,
/// <c>dataType</c>, <c>qos</c>, <c>retainSeed</c>) are deliberately not here — nothing produces
/// them from a browse, so they carry no cross-component drift risk.
/// </para>
/// </remarks>
public static class MqttTagConfigKeys
{
/// <summary>The concrete MQTT topic a Plain-mode tag subscribes to.</summary>
public const string Topic = "topic";
/// <summary>The Sparkplug group id — the first segment of the tag's binding tuple.</summary>
public const string GroupId = "groupId";
/// <summary>The Sparkplug edge-node id.</summary>
public const string EdgeNodeId = "edgeNodeId";
/// <summary>The Sparkplug device id; absent for a metric published by the edge node itself.</summary>
public const string DeviceId = "deviceId";
/// <summary>The Sparkplug metric's stable name — the tag's binding key across rebirths.</summary>
public const string MetricName = "metricName";
}
@@ -0,0 +1,85 @@
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.Mqtt;
/// <summary>
/// The driver's internal per-tag descriptor — the parsed form of an authored raw tag's
/// <c>TagConfig</c> JSON, produced by <see cref="MqttTagDefinitionFactory.FromTagConfig"/> and
/// looked up through the shared <see cref="EquipmentTagRefResolver{TDef}"/> exactly as Modbus does.
/// See <c>docs/plans/2026-07-15-mqtt-sparkplug-driver-design.md</c> §5.2 / §5.3.
/// <para>
/// The record carries <b>both</b> ingest shapes: the Plain-MQTT fields
/// (<see cref="Topic"/> … <see cref="RetainSeed"/>), populated by
/// <see cref="MqttTagDefinitionFactory.FromTagConfig"/>, and the Sparkplug B descriptor fields
/// (<see cref="GroupId"/>, <see cref="EdgeNodeId"/>, <see cref="DeviceId"/>,
/// <see cref="MetricName"/>), populated by
/// <see cref="MqttTagDefinitionFactory.FromSparkplugTagConfig"/> (Task 21). A Sparkplug tag is
/// resolved by the stable <c>(group, node, device, metricName)</c> tuple, <b>never</b> by the
/// per-birth metric alias — an alias may be reused across a rebirth for a different metric.
/// </para>
/// <para>
/// <b>Which factory runs is chosen by the driver's <see cref="MqttMode"/>, never sniffed from
/// the blob.</b> A blob carrying both shapes' keys is legal (the AdminUI editor preserves
/// unknown keys through a load→save, so a tag retyped from Plain to Sparkplug keeps its old
/// <c>topic</c>), and a heuristic would make the same authored tag mean two different things
/// depending on which key happened to survive.
/// </para>
/// </summary>
/// <param name="Name">
/// The definition's identity: the tag's <b>RawPath</b> — the cluster-scoped slash path that is the
/// v3 driver wire reference. This is the key <see cref="EquipmentTagRefResolver{TDef}"/> resolves
/// on, the key <c>OnDataChange</c> must publish under, and the key <c>DriverHostActor</c> fans out
/// to the raw + UNS NodeIds. It is emphatically <b>not</b> the authored <c>TagConfig</c> blob: the
/// pre-v3 blob-as-reference shape is retired, and publishing under a blob key would silently miss
/// the RawPath-keyed fan-out while every unit test still passed.
/// </param>
/// <param name="Topic">The concrete MQTT topic this tag subscribes to (Plain mode).</param>
/// <param name="PayloadFormat">How the received payload is decoded (Plain mode).</param>
/// <param name="JsonPath">
/// JSONPath selecting the value inside a <see cref="MqttPayloadFormat.Json"/> payload. Defaults
/// to the document root (<c>$</c>) when the blob omits it; meaningless for
/// <see cref="MqttPayloadFormat.Raw"/> / <see cref="MqttPayloadFormat.Scalar"/>.
/// </param>
/// <param name="DataType">
/// The tag's declared value type. Explicit authoring is strongly preferred — payload-shape
/// inference is the brittle fallback (design §4).
/// </param>
/// <param name="Qos">
/// The per-tag subscription QoS (02), or <see langword="null"/> to inherit the driver-level
/// <see cref="MqttPlainOptions.DefaultQos"/>.
/// </param>
/// <param name="RetainSeed">
/// Whether the broker's retained message for <see cref="Topic"/> seeds this tag's initial value
/// on subscribe (the OPC UA initial-data convention). Defaults to <see langword="true"/>.
/// </param>
/// <param name="DataTypeAuthored">
/// Whether <paramref name="DataType"/> came from an authored <c>dataType</c> key or is merely this
/// record's default. Load-bearing in <b>Sparkplug</b> mode only, where <c>dataType</c> is optional:
/// a Sparkplug metric's type is declared by its own birth certificate, so an unauthored tag must
/// take the type the NBIRTH/DBIRTH declared rather than silently coercing every value to the
/// <see cref="DriverDataType.String"/> default. Both factories set it from the key's presence so it
/// means the same thing in either mode; nothing on the Plain ingest path reads it.
/// </param>
/// <param name="GroupId">The Sparkplug group id; <see langword="null"/> for a Plain-mode tag.</param>
/// <param name="EdgeNodeId">The Sparkplug edge-node id; <see langword="null"/> for a Plain-mode tag.</param>
/// <param name="DeviceId">
/// The Sparkplug device id, or <see langword="null"/> for a metric published by the edge node
/// itself (and for every Plain-mode tag).
/// </param>
/// <param name="MetricName">
/// The Sparkplug metric name — the tag's stable identity across rebirths, and the key the ingest
/// state machine binds by. <see langword="null"/> for a Plain-mode tag.
/// </param>
public sealed record MqttTagDefinition(
string Name,
string Topic,
MqttPayloadFormat PayloadFormat,
string JsonPath,
DriverDataType DataType,
int? Qos,
bool RetainSeed,
bool DataTypeAuthored = true,
string? GroupId = null,
string? EdgeNodeId = null,
string? DeviceId = null,
string? MetricName = null);
@@ -0,0 +1,290 @@
using System.Text.Json;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.Mqtt;
/// <summary>
/// v3 pure mapper: turns an authored raw tag's <c>TagConfig</c> JSON (the shape produced by the
/// AdminUI <c>MqttTagConfigModel</c>) into an <see cref="MqttTagDefinition"/>. Under v3 a tag's
/// identity is its <b>RawPath</b> (a cluster-scoped slash path), not the address blob — so the
/// produced definition's <see cref="MqttTagDefinition.Name"/> is the RawPath the driver was handed,
/// which is exactly the wire reference the driver's <c>RawPath → def</c> resolver keys on. The driver
/// builds that table by mapping each <see cref="RawTagEntry"/> the deploy artifact delivers through
/// <see cref="FromTagConfig"/>.
/// <para>
/// Two entry points carry deliberately different strictness contracts.
/// <see cref="FromTagConfig"/> is the <b>runtime</b> path: it never throws, and anything
/// malformed returns <see langword="false"/> so the driver surfaces <c>BadNodeIdUnknown</c>
/// rather than a misleading <c>Good</c> off a wrong-typed default. <see cref="Inspect"/> is the
/// <b>deploy-time</b> path: it returns human-readable warnings so a bad tag config surfaces at
/// deploy instead of silently going dark at runtime.
/// </para>
/// <para>
/// <b>Two runtime entry points, one per ingest shape</b> — <see cref="FromTagConfig"/> (Plain)
/// and <see cref="FromSparkplugTagConfig"/> (Sparkplug B). The caller picks by the driver's
/// <see cref="MqttMode"/>; neither sniffs the blob. That is deliberate: the AdminUI editor
/// preserves unknown keys through a load→save, so a tag retyped from Plain to Sparkplug keeps
/// its old <c>topic</c> and a presence heuristic would make one authored blob mean two
/// different things depending on which key happened to survive. The <i>driver's</i> mode is
/// the single authority.
/// </para>
/// <para>
/// <b>No <c>ToTagConfig</c> inverse — a deliberate YAGNI call.</b> The six sibling factories
/// each carry one solely to serve their <c>Driver.&lt;X&gt;.Cli</c> project, which synthesises
/// <see cref="RawTagEntry"/> blobs from operator flags; the MQTT plan defines no
/// <c>Mqtt.Cli</c>. The AdminUI typed editor does not need one either — the
/// <c>&lt;Driver&gt;TagConfigModel</c> template serialises through its own preserved
/// <c>JsonObject</c> key bag and references no driver factory (verified: no
/// <c>TagDefinitionFactory</c> reference exists anywhere under the AdminUI project). Add the
/// inverse when a real caller appears, not before.
/// </para>
/// </summary>
public static class MqttTagDefinitionFactory
{
/// <summary>The JSONPath applied when a Json-format blob omits <c>jsonPath</c>: the document root.</summary>
private const string RootJsonPath = "$";
/// <summary>The MQTT wildcard characters — illegal in a <em>tag's</em> (concrete) subscription topic.</summary>
private static readonly char[] TopicWildcards = ['+', '#'];
/// <summary>
/// Maps an authored <c>TagConfig</c> object to a typed definition keyed by <paramref name="rawPath"/>.
/// The input is always an authored TagConfig JSON object (there is no name-vs-blob heuristic in v3).
/// Enum fields (<c>payloadFormat</c> / <c>dataType</c>) and <c>qos</c> are read STRICTLY — a
/// present-but-invalid (typo'd) value rejects the whole tag (returns <see langword="false"/> ⇒ the
/// driver surfaces <c>BadNodeIdUnknown</c>) rather than silently defaulting to a wrong-typed Good or
/// a downgraded delivery guarantee. Never throws.
/// <para>
/// 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
/// for it. Rejecting it at runtime would turn an authoring mistake into a silent
/// <c>BadNodeIdUnknown</c> with no stated cause.
/// </para>
/// </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="def">The mapped definition when this returns <see langword="true"/>.</param>
/// <returns><see langword="true"/> when <paramref name="tagConfig"/> is a valid MQTT tag-config object.</returns>
public static bool FromTagConfig(string tagConfig, string rawPath, out MqttTagDefinition def)
{
def = null!;
if (string.IsNullOrWhiteSpace(tagConfig) || tagConfig[0] != '{') return false;
try
{
using var doc = JsonDocument.Parse(tagConfig);
var root = doc.RootElement;
if (root.ValueKind != JsonValueKind.Object) return false;
// topic is the whole point of a Plain-mode tag — without one there is nothing to subscribe
// to, so an absent/blank value is a hard reject rather than a defaulted empty subscription.
var topic = ReadString(root, MqttTagConfigKeys.Topic);
if (string.IsNullOrWhiteSpace(topic)) return false;
// Strict enum reads: a present-but-invalid (typo'd) value rejects the whole tag
// (→ BadNodeIdUnknown) instead of silently defaulting to a wrong-typed Good.
if (!TagConfigJson.TryReadEnumStrict(root, "payloadFormat", MqttPayloadFormat.Json, out var payloadFormat))
return false;
var dataTypeAuthored = root.TryGetProperty("dataType", out _);
if (!TagConfigJson.TryReadEnumStrict(root, "dataType", DriverDataType.String, out var dataType))
return false;
// qos is read with the same strictness, for the same reason: silently absorbing a malformed
// "qos":"high" / "qos":1.5 / "qos":5 into the driver-level default would hand the operator a
// WEAKER delivery guarantee than the one they authored, with nothing to show for it.
if (!TryReadQosStrict(root, out var qos)) return false;
var jsonPath = ReadString(root, "jsonPath");
if (string.IsNullOrEmpty(jsonPath)) jsonPath = RootJsonPath;
def = new MqttTagDefinition(
Name: rawPath,
Topic: topic,
PayloadFormat: payloadFormat,
JsonPath: jsonPath,
DataType: dataType,
Qos: qos,
RetainSeed: ReadBoolOrDefault(root, "retainSeed", defaultValue: true),
DataTypeAuthored: dataTypeAuthored);
return true;
}
catch (JsonException) { return false; }
catch (FormatException) { return false; }
catch (InvalidOperationException) { return false; }
}
/// <summary>
/// Maps an authored <c>TagConfig</c> object to a <b>Sparkplug B</b> definition keyed by
/// <paramref name="rawPath"/>. The tag's binding identity is the stable
/// <c>(groupId, edgeNodeId, deviceId?, metricName)</c> tuple; <c>deviceId</c> is optional (absent
/// means the metric is published by the edge node itself), and there is <b>no</b> <c>topic</c> —
/// a Sparkplug driver subscribes one group-wide filter and routes by the decoded topic + birth
/// certificate, never by a per-tag topic. Never throws.
/// </summary>
/// <remarks>
/// <para>
/// <b><c>dataType</c> is OPTIONAL here, unlike in Plain mode.</b> A Sparkplug metric declares
/// its own datatype in its birth certificate, so an unauthored tag legitimately takes the
/// type the birth declares — that is the whole point of Task 22's <c>UntilStable</c>
/// discovery. It is still read <b>strictly</b> when present (a typo'd value rejects the tag,
/// exactly as in Plain mode) and the outcome is recorded on
/// <see cref="MqttTagDefinition.DataTypeAuthored"/> so the ingest path can tell "the operator
/// declared String" from "nobody declared anything and the record defaulted".
/// </para>
/// <para>
/// <b>Plain-shape keys are read but not required.</b> <c>topic</c>/<c>payloadFormat</c>/
/// <c>jsonPath</c> are meaningless to the Sparkplug ingest path and are deliberately NOT
/// validated — a blob retyped in the editor keeps them, and rejecting the tag for a stale
/// leftover key would take a correctly-authored Sparkplug tag dark with no stated cause.
/// </para>
/// </remarks>
/// <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="def">The mapped definition when this returns <see langword="true"/>.</param>
/// <returns><see langword="true"/> when the blob carries a usable Sparkplug descriptor.</returns>
public static bool FromSparkplugTagConfig(string tagConfig, string rawPath, out MqttTagDefinition def)
{
def = null!;
if (string.IsNullOrWhiteSpace(tagConfig) || tagConfig[0] != '{') return false;
try
{
using var doc = JsonDocument.Parse(tagConfig);
var root = doc.RootElement;
if (root.ValueKind != JsonValueKind.Object) return false;
// The binding tuple. Without all three of these there is nothing a birth certificate could
// ever bind the tag to, so an absent/blank value is a hard reject (→ BadNodeIdUnknown)
// rather than a definition that can never resolve and reports nothing about why.
var groupId = ReadString(root, MqttTagConfigKeys.GroupId);
var edgeNodeId = ReadString(root, MqttTagConfigKeys.EdgeNodeId);
var metricName = ReadString(root, MqttTagConfigKeys.MetricName);
if (string.IsNullOrWhiteSpace(groupId)
|| string.IsNullOrWhiteSpace(edgeNodeId)
|| string.IsNullOrWhiteSpace(metricName))
{
return false;
}
// Optional: a node-level metric has no device segment. Blank normalises to absent so
// "deviceId":"" and an omitted key describe the same scope rather than two.
var deviceId = ReadString(root, MqttTagConfigKeys.DeviceId);
if (string.IsNullOrWhiteSpace(deviceId)) deviceId = null;
// Strict, but only when present — see the remarks.
var dataTypeAuthored = root.TryGetProperty("dataType", out _);
if (!TagConfigJson.TryReadEnumStrict(root, "dataType", DriverDataType.String, out var dataType))
return false;
if (!TryReadQosStrict(root, out var qos)) return false;
def = new MqttTagDefinition(
Name: rawPath,
// Plain-shape fields carried through verbatim; the Sparkplug ingest path reads none of
// them, and a retyped blob's leftovers must not change what the tag means.
Topic: ReadString(root, MqttTagConfigKeys.Topic),
PayloadFormat: MqttPayloadFormat.Json,
JsonPath: RootJsonPath,
DataType: dataType,
Qos: qos,
RetainSeed: ReadBoolOrDefault(root, "retainSeed", defaultValue: true),
DataTypeAuthored: dataTypeAuthored,
GroupId: groupId,
EdgeNodeId: edgeNodeId,
DeviceId: deviceId,
MetricName: metricName);
return true;
}
catch (JsonException) { return false; }
catch (FormatException) { return false; }
catch (InvalidOperationException) { return false; }
}
/// <summary>
/// Deploy-time inspection of an equipment-tag <c>TagConfig</c> blob. Returns human-readable
/// warnings for a structurally unparseable blob (which the runtime turns into a silent
/// <c>BadNodeIdUnknown</c>), for present-but-invalid <c>payloadFormat</c> / <c>dataType</c> /
/// <c>qos</c> values, and for a <b>wildcard</b> tag topic (a tag bound to <c>+</c> / <c>#</c>
/// would receive values from many topics — ambiguous, and almost never what the operator meant).
/// Empty when the blob is clean or is not an equipment-tag TagConfig object. Never throws.
/// </summary>
/// <param name="reference">The equipment tag's TagConfig JSON.</param>
/// <returns>The warnings; empty when clean.</returns>
public static IReadOnlyList<string> Inspect(string reference)
{
var warnings = new List<string>();
if (string.IsNullOrWhiteSpace(reference) || reference[0] != '{') return warnings;
try
{
using var doc = JsonDocument.Parse(reference);
var root = doc.RootElement;
if (root.ValueKind != JsonValueKind.Object)
{
warnings.Add("Mqtt TagConfig root is not a JSON object — the tag will not resolve (BadNodeIdUnknown).");
return warnings;
}
foreach (var w in new[]
{
TagConfigJson.DescribeInvalidEnum<MqttPayloadFormat>(root, "payloadFormat"),
TagConfigJson.DescribeInvalidEnum<DriverDataType>(root, "dataType"),
DescribeInvalidQos(root),
})
{
if (w is not null) warnings.Add(w);
}
var topic = ReadString(root, "topic");
if (topic.IndexOfAny(TopicWildcards) >= 0)
{
warnings.Add(
$"value '{topic}' for 'topic' contains an MQTT wildcard (+ or #); a tag's topic must be " +
"concrete, or the tag will be fed by every matching topic.");
}
}
catch (JsonException)
{
warnings.Add("Mqtt TagConfig is not valid JSON — the tag will not resolve (BadNodeIdUnknown).");
}
return warnings;
}
/// <summary>
/// Strict <c>qos</c> read, mirroring <see cref="TagConfigJson.TryReadEnumStrict{TEnum}"/>'s
/// absent / valid / present-but-invalid split: absent ⇒ <see langword="null"/> (the driver-level
/// <see cref="MqttPlainOptions.DefaultQos"/> wins); a JSON integer in 02 ⇒ that value; anything
/// else present (non-number, non-integer, or out of range) ⇒ the read FAILS.
/// </summary>
/// <param name="o">The TagConfig root object.</param>
/// <param name="qos">The parsed QoS, or <see langword="null"/> when the field is absent.</param>
/// <returns><see langword="false"/> only when <c>qos</c> is present but not a legal MQTT QoS.</returns>
private static bool TryReadQosStrict(JsonElement o, out int? qos)
{
qos = null;
if (!o.TryGetProperty("qos", out var e)) return true; // absent
if (e.ValueKind != JsonValueKind.Number || !e.TryGetInt32(out var v)) return false;
if (v is < 0 or > 2) return false;
qos = v;
return true;
}
/// <summary>
/// A human-readable warning for a present-but-invalid <c>qos</c> field, or <see langword="null"/>
/// when it is absent or valid — the <c>qos</c> counterpart of
/// <see cref="TagConfigJson.DescribeInvalidEnum{TEnum}"/>.
/// </summary>
/// <param name="o">The TagConfig root object.</param>
/// <returns>The warning text, or <see langword="null"/>.</returns>
private static string? DescribeInvalidQos(JsonElement o)
{
if (!o.TryGetProperty("qos", out var e)) return null;
if (e.ValueKind == JsonValueKind.Number && e.TryGetInt32(out var v) && v is >= 0 and <= 2) return null;
return $"value '{e.GetRawText()}' for 'qos' is not a valid MQTT QoS; valid: 0, 1, 2";
}
private static string ReadString(JsonElement o, string name)
=> o.TryGetProperty(name, out var e) && e.ValueKind == JsonValueKind.String
? e.GetString() ?? ""
: "";
private static bool ReadBoolOrDefault(JsonElement o, string name, bool defaultValue)
=> o.TryGetProperty(name, out var e) && e.ValueKind is JsonValueKind.True or JsonValueKind.False
? e.GetBoolean()
: defaultValue;
}
@@ -0,0 +1,261 @@
// =====================================================================================================
// VENDORED THIRD-PARTY FILE — DO NOT EDIT. Re-vendor from upstream instead (recipe below).
//
// Upstream project Eclipse Tahu — the Sparkplug B reference implementation
// Upstream repo https://github.com/eclipse-tahu/tahu
// Upstream path sparkplug_b/sparkplug_b.proto
// Pinned commit 5736e404889d4b95910613040a99ba79589ffb13 (master, 2023-11-06)
// Permalink https://raw.githubusercontent.com/eclipse-tahu/tahu/5736e404889d4b95910613040a99ba79589ffb13/sparkplug_b/sparkplug_b.proto
// git blob SHA-1 bf72ab5f09a333afabcb40fd45362ffbb0c8c5bd (matches the tree entry at that commit)
// content SHA-256 4432c5c483b7fb9732d0594c98a2e97dca5e517e39c5374a8b918d837f0b4a19 (8330 bytes)
// last modified 46f25e79f34234e6145d11108660dfd9133ae50d (2022-05-16, template_ref comment fix)
// License Eclipse Public License 2.0 (EPL-2.0) — https://www.eclipse.org/legal/epl-2.0/
// Copyright (c) Cirrus Link Solutions and others. The upstream copyright + SPDX
// header is preserved verbatim as the first lines of the copied body below.
//
// Everything from line 38 down ("// * Copyright (c) 2015, 2018 Cirrus Link Solutions…") is a
// BYTE-FOR-BYTE copy of the upstream file. Only these header lines were added. To re-verify:
//
// curl -sSL <permalink above> -o /tmp/upstream.proto
// tail -n +38 src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Contracts/Protos/sparkplug_b.proto > /tmp/vendored.proto
// diff /tmp/upstream.proto /tmp/vendored.proto && shasum -a 256 /tmp/vendored.proto
//
// WHY VENDORED rather than a NuGet package: SparkplugNet is the only .NET Sparkplug library and it is
// stale (1.3.10, 2024-07-02), transitively pins MQTTnet 4.3.6.1152 against this repo's MQTTnet 5.2.0,
// and ships net6.0/net8.0 only — no net10.0 TFM. So the schema is vendored and decoded directly with
// Google.Protobuf, over the same single MQTTnet-5 client the plain-MQTT path already uses.
//
// WHY THE proto2 FILE and not the sibling sparkplug_b_c_sharp.proto that upstream also ships: this is
// the NORMATIVE Sparkplug B schema (the C# sibling is a lossy proto3 restatement that drops explicit
// presence and rewrites the `extensions` ranges as `google.protobuf.Any`). protoc from Grpc.Tools
// generates valid C# from proto2, and the generated namespace is identical (Org.Eclipse.Tahu.Protobuf,
// PascalCased from `package org.eclipse.tahu.protobuf` — there is no `option csharp_namespace`). Keeping
// proto2 buys explicit presence — Has{Name,Alias,Seq,IsNull,Datatype} — which the decoder needs to tell
// "field absent" from "field present and zero" (an NBIRTH legitimately carries seq = 0, and a DATA
// metric legitimately omits `name` and carries only `alias`).
// =====================================================================================================
// * Copyright (c) 2015, 2018 Cirrus Link Solutions and others
// *
// * This program and the accompanying materials are made available under the
// * terms of the Eclipse Public License 2.0 which is available at
// * http://www.eclipse.org/legal/epl-2.0.
// *
// * SPDX-License-Identifier: EPL-2.0
// *
// * Contributors:
// * Cirrus Link Solutions - initial implementation
//
// To compile:
// cd client_libraries/java
// protoc --proto_path=../../ --java_out=src/main/java ../../sparkplug_b.proto
//
syntax = "proto2";
package org.eclipse.tahu.protobuf;
option java_package = "org.eclipse.tahu.protobuf";
option java_outer_classname = "SparkplugBProto";
enum DataType {
// Indexes of Data Types
// Unknown placeholder for future expansion.
Unknown = 0;
// Basic Types
Int8 = 1;
Int16 = 2;
Int32 = 3;
Int64 = 4;
UInt8 = 5;
UInt16 = 6;
UInt32 = 7;
UInt64 = 8;
Float = 9;
Double = 10;
Boolean = 11;
String = 12;
DateTime = 13;
Text = 14;
// Additional Metric Types
UUID = 15;
DataSet = 16;
Bytes = 17;
File = 18;
Template = 19;
// Additional PropertyValue Types
PropertySet = 20;
PropertySetList = 21;
// Array Types
Int8Array = 22;
Int16Array = 23;
Int32Array = 24;
Int64Array = 25;
UInt8Array = 26;
UInt16Array = 27;
UInt32Array = 28;
UInt64Array = 29;
FloatArray = 30;
DoubleArray = 31;
BooleanArray = 32;
StringArray = 33;
DateTimeArray = 34;
}
message Payload {
message Template {
message Parameter {
optional string name = 1;
optional uint32 type = 2;
oneof value {
uint32 int_value = 3;
uint64 long_value = 4;
float float_value = 5;
double double_value = 6;
bool boolean_value = 7;
string string_value = 8;
ParameterValueExtension extension_value = 9;
}
message ParameterValueExtension {
extensions 1 to max;
}
}
optional string version = 1; // The version of the Template to prevent mismatches
repeated Metric metrics = 2; // Each metric includes a name, datatype, and optionally a value
repeated Parameter parameters = 3;
optional string template_ref = 4; // MUST be a reference to a template definition if this is an instance (i.e. the name of the template definition) - MUST be omitted for template definitions
optional bool is_definition = 5;
extensions 6 to max;
}
message DataSet {
message DataSetValue {
oneof value {
uint32 int_value = 1;
uint64 long_value = 2;
float float_value = 3;
double double_value = 4;
bool boolean_value = 5;
string string_value = 6;
DataSetValueExtension extension_value = 7;
}
message DataSetValueExtension {
extensions 1 to max;
}
}
message Row {
repeated DataSetValue elements = 1;
extensions 2 to max; // For third party extensions
}
optional uint64 num_of_columns = 1;
repeated string columns = 2;
repeated uint32 types = 3;
repeated Row rows = 4;
extensions 5 to max; // For third party extensions
}
message PropertyValue {
optional uint32 type = 1;
optional bool is_null = 2;
oneof value {
uint32 int_value = 3;
uint64 long_value = 4;
float float_value = 5;
double double_value = 6;
bool boolean_value = 7;
string string_value = 8;
PropertySet propertyset_value = 9;
PropertySetList propertysets_value = 10; // List of Property Values
PropertyValueExtension extension_value = 11;
}
message PropertyValueExtension {
extensions 1 to max;
}
}
message PropertySet {
repeated string keys = 1; // Names of the properties
repeated PropertyValue values = 2;
extensions 3 to max;
}
message PropertySetList {
repeated PropertySet propertyset = 1;
extensions 2 to max;
}
message MetaData {
// Bytes specific metadata
optional bool is_multi_part = 1;
// General metadata
optional string content_type = 2; // Content/Media type
optional uint64 size = 3; // File size, String size, Multi-part size, etc
optional uint64 seq = 4; // Sequence number for multi-part messages
// File metadata
optional string file_name = 5; // File name
optional string file_type = 6; // File type (i.e. xml, json, txt, cpp, etc)
optional string md5 = 7; // md5 of data
// Catchalls and future expansion
optional string description = 8; // Could be anything such as json or xml of custom properties
extensions 9 to max;
}
message Metric {
optional string name = 1; // Metric name - should only be included on birth
optional uint64 alias = 2; // Metric alias - tied to name on birth and included in all later DATA messages
optional uint64 timestamp = 3; // Timestamp associated with data acquisition time
optional uint32 datatype = 4; // DataType of the metric/tag value
optional bool is_historical = 5; // If this is historical data and should not update real time tag
optional bool is_transient = 6; // Tells consuming clients such as MQTT Engine to not store this as a tag
optional bool is_null = 7; // If this is null - explicitly say so rather than using -1, false, etc for some datatypes.
optional MetaData metadata = 8; // Metadata for the payload
optional PropertySet properties = 9;
oneof value {
uint32 int_value = 10;
uint64 long_value = 11;
float float_value = 12;
double double_value = 13;
bool boolean_value = 14;
string string_value = 15;
bytes bytes_value = 16; // Bytes, File
DataSet dataset_value = 17;
Template template_value = 18;
MetricValueExtension extension_value = 19;
}
message MetricValueExtension {
extensions 1 to max;
}
}
optional uint64 timestamp = 1; // Timestamp at message sending time
repeated Metric metrics = 2; // Repeated forever - no limit in Google Protobufs
optional uint64 seq = 3; // Sequence number
optional string uuid = 4; // UUID to track message type in terms of schema definitions
optional bytes body = 5; // To optionally bypass the whole definition above
extensions 6 to max; // For third party extensions
}
@@ -0,0 +1,139 @@
// `SparkplugDataType` is an ALIAS for the vendored proto's generated `Org.Eclipse.Tahu.Protobuf.DataType`
// enum, not a second, hand-maintained enum. See the remarks on `SparkplugDataTypeExtensions` below for
// the reasoning; the short version is that a duplicate enum is a drift hazard this repo has a documented
// systemic bug class around (see CLAUDE.md "Driver enum-serialization bug"), and `Payload.Types.Metric`'s
// wire-level `Datatype` field is a raw `uint32` anyway — nothing structurally forces a second CLR enum to
// exist, so the lowest-risk shape is for `SparkplugDataType` to be the exact same type as the generated
// one, not a value-compatible lookalike that some cast has to bridge.
global using SparkplugDataType = Org.Eclipse.Tahu.Protobuf.DataType;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.Mqtt;
/// <summary>
/// Maps a Sparkplug B metric <see cref="SparkplugDataType"/> (the vendored Eclipse Tahu proto's
/// generated <c>Org.Eclipse.Tahu.Protobuf.DataType</c> enum — see the <c>global using</c> alias
/// above) to a driver-agnostic <see cref="DriverDataType"/>, per design doc §3.5.
/// </summary>
/// <remarks>
/// <para>
/// <b>Why an alias, not a duplicate enum.</b> The obvious "clean seam" shape is a fresh
/// <c>Driver.Mqtt.Contracts</c>-owned enum with its own <c>ToDriverDataType()</c> — but that
/// creates a second definition of the same 35-member vocabulary that has to be hand-kept in
/// sync with whatever Eclipse Tahu's <c>sparkplug_b.proto</c> defines, which is exactly the
/// enum-drift shape this repo already has a name for (CLAUDE.md's "Driver enum-serialization
/// bug (AdminUI authoring)" — a systemic mismatch between two enums meant to describe the same
/// thing). It also does not match how the decoder actually produces values: Sparkplug's
/// <c>Metric.datatype</c> wire field is a raw <c>uint32</c> (see <c>sparkplug_b.proto</c> line
/// 230 — <c>optional uint32 datatype = 4</c>), not the <c>DataType</c> enum type itself, so
/// <c>SparkplugCodec</c> (Task 16) already casts the decoded value straight to the generated
/// <c>Org.Eclipse.Tahu.Protobuf.DataType</c> (locally aliased there as <c>TahuDataType</c>).
/// A second, hand-duplicated enum would force every downstream consumer (Tasks 18/19/20/21) to
/// cast between two value-compatible-but-nominally-different enums to call this extension
/// method — extra surface for exactly zero benefit, since both "enums" would need to enumerate
/// the identical 35 members in the identical order to stay castable.
/// </para>
/// <para>
/// Making <see cref="SparkplugDataType"/> a <c>global using</c> alias for the generated type
/// sidesteps all of that: there is no second definition to drift, by construction — the alias
/// and the generated enum are the exact same CLR type. <see cref="ToDriverDataType"/> below is
/// written as an extension on <see cref="SparkplugDataType"/> purely for call-site readability;
/// it is equally callable as <c>someDecodedDatatype.ToDriverDataType()</c> against a plain
/// <c>Org.Eclipse.Tahu.Protobuf.DataType</c> value with no cast, which is exactly the shape
/// <c>SparkplugCodec</c>'s decoded metrics hand back.
/// </para>
/// <para>
/// <b>Drift guard.</b> Because there is only one enum, <c>SparkplugDataTypeTests</c>'
/// completeness test (<c>ToDriverDataType_HandlesEveryGeneratedDataTypeMember_...</c>) can
/// enumerate <c>Enum.GetValues&lt;SparkplugDataType&gt;()</c> — which, being the alias, is
/// literally the live generated member set — and assert every member is either mapped or on
/// the explicit unsupported list below. If Eclipse Tahu's proto ever gains a member, that test
/// picks it up automatically and fails until this map makes an explicit decision about it,
/// rather than the new member silently falling through a duplicate-enum's stale default.
/// </para>
/// <para>
/// Per design §3.5: <c>Int8</c>/<c>UInt8</c> widen to <see cref="DriverDataType.Int16"/> /
/// <see cref="DriverDataType.UInt16"/> (no OPC UA signed-byte distinction is needed and
/// <see cref="DriverDataType"/> has no 8-bit members at all); <c>Float</c>/<c>Double</c> map to
/// <see cref="DriverDataType.Float32"/> / <see cref="DriverDataType.Float64"/> — note there is
/// no <c>DriverDataType.Double</c> member, only <c>Float64</c>; <c>Text</c>/<c>UUID</c>
/// (generated as <c>Uuid</c> — protoc mangles the wire spelling, see
/// <c>SparkplugProtoCodegenTests.DataTypeEnum_CSharpNamesAreProtocMangled_NotTheProtoSpelling</c>)
/// /<c>Bytes</c>/<c>File</c> all fall back to <see cref="DriverDataType.String"/> (v1 base64/raw
/// fallback for Bytes/File, per design); every <c>*Array</c> variant maps to its scalar element
/// type (<see cref="IsSparkplugArray"/> carries the "and it's an array" bit separately, since
/// <see cref="DriverDataType"/> itself has no array concept — that lives at the OPC UA
/// ValueRank/ArrayDimensions layer the address-space builder owns). <c>DataSet</c>/
/// <c>Template</c>/<c>PropertySet</c>/<c>PropertySetList</c>/<c>Unknown</c> are unsupported in
/// v1 and map to <see langword="null"/> — deliberately, not a guessed
/// <see cref="DriverDataType.String"/>, so a caller has to make an explicit skip-or-warn
/// decision (unlike Galaxy's <c>DataTypeMap</c>, which silently defaults unknown codes to
/// <c>String</c> for legacy wire-compatibility reasons that do not apply here).
/// </para>
/// </remarks>
public static class SparkplugDataTypeExtensions
{
/// <summary>
/// Maps a Sparkplug metric datatype to the equivalent <see cref="DriverDataType"/>, or
/// <see langword="null"/> if the type is unsupported in v1 (<c>DataSet</c>, <c>Template</c>,
/// <c>PropertySet</c>, <c>PropertySetList</c>, <c>Unknown</c>). Callers must treat
/// <see langword="null"/> as an explicit "skip and warn", never coerce it to a guessed type.
/// </summary>
public static DriverDataType? ToDriverDataType(this SparkplugDataType dataType) => dataType switch
{
SparkplugDataType.Int8 => DriverDataType.Int16,
SparkplugDataType.Int16 => DriverDataType.Int16,
SparkplugDataType.Int32 => DriverDataType.Int32,
SparkplugDataType.Int64 => DriverDataType.Int64,
SparkplugDataType.Uint8 => DriverDataType.UInt16,
SparkplugDataType.Uint16 => DriverDataType.UInt16,
SparkplugDataType.Uint32 => DriverDataType.UInt32,
SparkplugDataType.Uint64 => DriverDataType.UInt64,
SparkplugDataType.Float => DriverDataType.Float32,
SparkplugDataType.Double => DriverDataType.Float64,
SparkplugDataType.Boolean => DriverDataType.Boolean,
SparkplugDataType.String => DriverDataType.String,
SparkplugDataType.DateTime => DriverDataType.DateTime,
SparkplugDataType.Text => DriverDataType.String,
SparkplugDataType.Uuid => DriverDataType.String,
SparkplugDataType.Bytes => DriverDataType.String,
SparkplugDataType.File => DriverDataType.String,
SparkplugDataType.Int8Array => DriverDataType.Int16,
SparkplugDataType.Int16Array => DriverDataType.Int16,
SparkplugDataType.Int32Array => DriverDataType.Int32,
SparkplugDataType.Int64Array => DriverDataType.Int64,
SparkplugDataType.Uint8Array => DriverDataType.UInt16,
SparkplugDataType.Uint16Array => DriverDataType.UInt16,
SparkplugDataType.Uint32Array => DriverDataType.UInt32,
SparkplugDataType.Uint64Array => DriverDataType.UInt64,
SparkplugDataType.FloatArray => DriverDataType.Float32,
SparkplugDataType.DoubleArray => DriverDataType.Float64,
SparkplugDataType.BooleanArray => DriverDataType.Boolean,
SparkplugDataType.StringArray => DriverDataType.String,
SparkplugDataType.DateTimeArray => DriverDataType.DateTime,
// Unsupported v1 (design §3.5): DataSet/Template are deferred scope; PropertySet/
// PropertySetList are PropertyValue-only metadata types that never legitimately appear as a
// Metric's own datatype; Unknown is the proto's explicit "placeholder for future expansion"
// (index 0). All five fall through here deliberately rather than being listed with a fake
// mapping.
_ => null,
};
/// <summary>
/// Whether <paramref name="dataType"/> is one of the 13 <c>*Array</c> variants. Combine with
/// <see cref="ToDriverDataType"/> (which already returns the *element* type for an array
/// variant) to build an OPC UA ValueRank=1 array node per design §3.5.
/// </summary>
public static bool IsSparkplugArray(this SparkplugDataType dataType) => dataType switch
{
SparkplugDataType.Int8Array or SparkplugDataType.Int16Array or SparkplugDataType.Int32Array
or SparkplugDataType.Int64Array or SparkplugDataType.Uint8Array or SparkplugDataType.Uint16Array
or SparkplugDataType.Uint32Array or SparkplugDataType.Uint64Array or SparkplugDataType.FloatArray
or SparkplugDataType.DoubleArray or SparkplugDataType.BooleanArray or SparkplugDataType.StringArray
or SparkplugDataType.DateTimeArray => true,
_ => false,
};
}
@@ -0,0 +1,36 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\..\Core\ZB.MOM.WW.OtOpcUa.Core.Abstractions\ZB.MOM.WW.OtOpcUa.Core.Abstractions.csproj"/>
</ItemGroup>
<!-- Sparkplug B (P2): the vendored Eclipse Tahu schema is compiled here, in the one assembly all
four MQTT projects reference. Message-only codegen (GrpcServices="None") — Sparkplug rides MQTT,
there is no gRPC service, so no Grpc.Core.Api runtime reference is needed (Commons, this repo's
other locally-compiled proto, takes one only because it generates service stubs).
Google.Protobuf is a SERIALIZATION dependency, not a transport one, so .Contracts stays
transport-free: it still has no MQTTnet reference (dropped in Task 1) and Google.Protobuf's own
graph is framework-only. Grpc.Tools is build-time (PrivateAssets=all) and flows to no consumer.
Build-platform note (inherited, not new): Grpc.Tools' bundled linux_arm64 protoc segfaults
(exit 139) under Apple-Silicon Docker, which is why docker-dev/Dockerfile pins its build stage
to the linux/amd64 platform. That pin already exists for the Commons proto; this project adds
a second .proto to the same already-pinned build, not a new constraint. -->
<ItemGroup>
<PackageReference Include="Google.Protobuf"/>
<PackageReference Include="Grpc.Tools">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
</ItemGroup>
<ItemGroup>
<Protobuf Include="Protos\sparkplug_b.proto" GrpcServices="None"/>
</ItemGroup>
</Project>
@@ -0,0 +1,63 @@
using System.Collections.Concurrent;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.Mqtt;
/// <summary>
/// Thread-safe last-observed-value store backing <see cref="IReadable"/> for MQTT/Sparkplug B.
/// MQTT is a subscribe-first protocol — the driver holds one live broker connection and values
/// arrive pushed, not polled. But the OPC UA server still issues polled <c>IReadable.ReadAsync</c>
/// batches, so this cache is the bridge: the subscription path (message handler) calls
/// <see cref="Update"/> with the newest observed value per reference, and the read path serves
/// the most recent one via <see cref="Read"/>.
/// </summary>
/// <remarks>
/// Keyed by <c>RawPath</c> — the v3 driver-reference identity (see
/// <c>EquipmentTagRefResolver&lt;TDef&gt;</c> and <see cref="MqttTagDefinition.Name"/>,
/// which <b>is</b> the RawPath) — never a topic/JSON-path-derived key. The parameter is
/// therefore named <c>rawPath</c> throughout rather than "topic" or "key" to keep that identity
/// fact visible at every call site.
/// </remarks>
public sealed class LastValueCache
{
// OPC UA BadWaitingForInitialData (0x80320000) — the repo-wide convention for "no value has
// been observed yet" (mirrored by CalculationDriver before its first evaluation,
// VirtualTagEngine for a freshly-materialised node, and OtOpcUaNodeManager /
// AddressSpaceApplier for a just-deployed variable). MQTT is subscribe-first, so an unseen
// RawPath is in exactly that state until its first publish arrives. Deliberately NOT
// GoodNoData — that code is reserved (see NullHistorianDataSource, OtOpcUaNodeManager
// HistoryRead paths) for "the historian window held no samples", a different question from
// "has this live reference ever been observed".
private const uint BadWaitingForInitialData = 0x80320000u;
private readonly ConcurrentDictionary<string, DataValueSnapshot> _values = new();
/// <summary>
/// Record the newest observed value for <paramref name="rawPath"/>. Called from the MQTT
/// subscription/message-handling path. A <c>null</c> or empty <paramref name="rawPath"/>
/// is a no-op — never throws.
/// </summary>
/// <param name="rawPath">The RawPath identifying the tag.</param>
/// <param name="snapshot">The newest observed value/quality/timestamps for that tag.</param>
public void Update(string rawPath, DataValueSnapshot snapshot)
{
if (string.IsNullOrEmpty(rawPath)) return;
_values[rawPath] = snapshot;
}
/// <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
/// <see cref="BadWaitingForInitialData"/> snapshot rather than an exception, so a batch read
/// covering many references degrades per-reference instead of failing the whole call.
/// </summary>
/// <param name="rawPath">The RawPath identifying the tag.</param>
/// <returns>The last observed snapshot, or a BadWaitingForInitialData snapshot if none has been observed.</returns>
public DataValueSnapshot Read(string rawPath)
{
if (!string.IsNullOrEmpty(rawPath) && _values.TryGetValue(rawPath, out var snapshot))
return snapshot;
return new DataValueSnapshot(null, BadWaitingForInitialData, null, DateTime.UtcNow);
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,84 @@
using System.Text.Json;
using Microsoft.Extensions.Logging;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Core.Hosting;
namespace ZB.MOM.WW.OtOpcUa.Driver.Mqtt;
/// <summary>
/// Registers the MQTT / Sparkplug B driver with the <see cref="DriverFactoryRegistry"/>. The
/// Host's <c>DriverFactoryBootstrap</c> calls <see cref="Register"/> once at startup; the
/// driver-instance bootstrapper then materialises <c>DriverInstance</c> rows of type
/// <see cref="DriverTypeNames.Mqtt"/> into live <see cref="MqttDriver"/> instances.
/// </summary>
/// <remarks>
/// <para>
/// <b>Direct deserialization, no intermediate DTO.</b> The <c>DriverConfig</c> blob binds
/// straight onto <see cref="MqttDriverOptions"/> — the same shape
/// <see cref="MqttDriverProbe"/>, <c>MqttDriverBrowser</c> and
/// <see cref="MqttDriver.ReinitializeAsync"/> already bind, and the shape the options record
/// was designed for (every knob carries its own default, and <c>rawTags</c> is a plain
/// <see cref="RawTagEntry"/> list needing no translation). Mirrors
/// <c>OpcUaClientDriverFactoryExtensions</c>. <c>ModbusDriverFactoryExtensions</c>' separate
/// DTO exists to service a legacy nullable-everything blob plus string→enum parsing this
/// driver does with a converter instead; copying it here would add a <i>fourth</i> parse
/// shape for one config, which is precisely the divergence
/// <see cref="MqttJson.Options"/> exists to prevent.
/// </para>
/// <para>
/// <b>Connection-free.</b> <see cref="CreateInstance"/> parses and constructs only — the
/// <see cref="MqttDriver"/> constructor touches no network, and the registry contract
/// forbids the factory from calling <c>InitializeAsync</c> itself (the driver host owns the
/// retry semantics).
/// </para>
/// </remarks>
public static class MqttDriverFactoryExtensions
{
/// <summary>
/// Driver type name — matches <c>DriverInstance.DriverType</c> values. Sourced from
/// <see cref="DriverTypeNames.Mqtt"/> so the registration key can never drift from the
/// constant the dispatch maps, the probe and the browser reference.
/// </summary>
public const string DriverTypeName = DriverTypeNames.Mqtt;
/// <summary>
/// Register the MQTT factory with the driver registry. The optional
/// <paramref name="loggerFactory"/> is captured at registration time and used to construct an
/// <see cref="ILogger{MqttDriver}"/> per driver instance — without it the driver runs with no
/// logger (standalone/test callers stay unchanged).
/// </summary>
/// <param name="registry">The driver factory registry to register with.</param>
/// <param name="loggerFactory">Optional logger factory used to create per-instance loggers.</param>
public static void Register(DriverFactoryRegistry registry, ILoggerFactory? loggerFactory = null)
{
ArgumentNullException.ThrowIfNull(registry);
registry.Register(DriverTypeName, (id, json) => CreateInstance(id, json, loggerFactory));
}
/// <summary>Public for the Server-side bootstrapper + test consumers.</summary>
/// <param name="driverInstanceId">Stable logical id of the driver instance.</param>
/// <param name="driverConfigJson">The <c>DriverConfig</c> JSON blob for the instance.</param>
/// <param name="loggerFactory">Optional logger factory for the per-instance logger.</param>
/// <returns>A configured, not-yet-connected <see cref="MqttDriver"/>.</returns>
/// <exception cref="ArgumentException">
/// <paramref name="driverInstanceId"/> or <paramref name="driverConfigJson"/> is blank.
/// </exception>
/// <exception cref="InvalidOperationException">The config blob deserialised to <c>null</c>.</exception>
/// <exception cref="JsonException">The config blob is not valid JSON for the options shape.</exception>
public static MqttDriver CreateInstance(
string driverInstanceId,
string driverConfigJson,
ILoggerFactory? loggerFactory = null)
{
ArgumentException.ThrowIfNullOrWhiteSpace(driverInstanceId);
ArgumentException.ThrowIfNullOrWhiteSpace(driverConfigJson);
// MqttJson.Options — the one shared instance (see its remarks). A local copy here is the
// repo's documented systemic enum bug in the making.
var options = JsonSerializer.Deserialize<MqttDriverOptions>(driverConfigJson, MqttJson.Options)
?? throw new InvalidOperationException(
$"MQTT driver config for '{driverInstanceId}' deserialised to null");
return new MqttDriver(options, driverInstanceId, loggerFactory?.CreateLogger<MqttDriver>());
}
}
@@ -0,0 +1,226 @@
using System.Diagnostics;
using System.Net.Sockets;
using System.Security.Authentication;
using System.Text.Json;
using Microsoft.Extensions.Logging.Abstractions;
using MQTTnet;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.Mqtt;
/// <summary>
/// CONNECT-handshake probe for the <see cref="MqttDriverOptions"/>-shaped driver config.
/// Opens a bounded MQTT CONNECT against the configured broker; a CONNACK-accepted response
/// (<c>MqttClientConnectResultCode.Success</c>) is the "device is answering" proof (green +
/// latency). Refused/TLS/auth/timeout failures each surface a targeted message. Mirrors
/// <c>ModbusDriverProbe</c>'s shape.
/// </summary>
/// <remarks>
/// <para>
/// <b>Reuses <see cref="MqttConnection.BuildClientOptions"/></b> for the connect (TLS,
/// CA-pin, credentials, protocol-version mapping) rather than hand-rolling a second
/// connect path — a duplicate would be a security divergence. A distinct client-id suffix
/// (see <see cref="ProbeClientIdPrefix"/>) keeps a probe from colliding with the driver's
/// own session: an MQTT broker disconnects an existing client when a new one CONNECTs
/// with the same client id, so a probe that reused the driver's id would knock the
/// running driver offline every time an operator clicked "Test connect".
/// </para>
/// <para>
/// <b>A broker-rejected CONNACK is not a thrown exception.</b> Live-probed against
/// MQTTnet 5.2.0.1603: <c>IMqttClient.ConnectAsync</c> returns a
/// <c>MqttClientConnectResult</c> whose <c>ResultCode</c> carries the broker's CONNACK
/// reason (e.g. <c>NotAuthorized</c>, <c>BadUserNameOrPassword</c>) — it does
/// <b>not</b> throw for a rejected CONNACK. Only transport-level failures throw, and
/// their shape varies by where the failure occurs: a closed/refused port throws
/// <c>MqttCommunicationException</c> wrapping a <see cref="SocketException"/>; an
/// untrusted broker certificate throws <c>MqttCommunicationException</c> wrapping an
/// <see cref="AuthenticationException"/>; a broker that accepts the TCP handshake and
/// then never answers CONNACK throws either <c>MqttConnectingFailedException</c>
/// (wrapping "Connection closed") or a bare <see cref="OperationCanceledException"/>
/// ("MQTT connect canceled"), inconsistently, depending on whether MQTTnet's own
/// internal <c>Timeout</c> or the deadline token wins the race. Consequently this probe —
/// like <see cref="MqttConnection.Classify"/> — never switches on exception type to
/// detect a timeout; it checks the deadline token itself, which is authoritative
/// regardless of which exception shape MQTTnet happened to throw.
/// </para>
/// </remarks>
public sealed class MqttDriverProbe : IDriverProbe
{
/// <summary>
/// Marks the transient probe identity in the broker's client-id/session logs — mirrors the
/// browser's own <c>-browse-{guid8}</c> suffix so the two transient sessions are
/// distinguishable in broker-side logs, and so a probe can never collide with (and knock
/// offline) the driver's own live session.
/// </summary>
internal const string ProbeClientIdPrefix = "-probe-";
/// <inheritdoc />
public string DriverType => DriverTypeNames.Mqtt;
/// <inheritdoc />
public async Task<DriverProbeResult> ProbeAsync(string configJson, TimeSpan timeout, CancellationToken ct)
{
MqttDriverOptions? options;
try
{
// MqttJson.Options — the one shared instance across factory / probe / driver / browser
// (see its remarks): enums must round-trip by NAME or an AdminUI-authored config that
// Test-connect accepts would fault the deployed driver.
options = JsonSerializer.Deserialize<MqttDriverOptions>(configJson, MqttJson.Options);
}
catch (Exception ex)
{
return new DriverProbeResult(false, $"Config JSON is invalid: {ex.Message}", null);
}
if (options is null)
{
return new DriverProbeResult(false, "Config JSON deserialized to null.", null);
}
if (string.IsNullOrWhiteSpace(options.Host) || options.Port is <= 0 or > 65535)
{
return new DriverProbeResult(false, "Config has no host/port to probe.", null);
}
// Honour the config's own connectTimeoutSeconds (factory parity, mirrors
// ModbusDriverProbe) — the caller's timeout is the fallback when the config omits it.
var effectiveSeconds = options.ConnectTimeoutSeconds > 0
? options.ConnectTimeoutSeconds
: (int)Math.Ceiling(timeout.TotalSeconds);
if (effectiveSeconds <= 0)
{
effectiveSeconds = 1;
}
// Rebuild with the resolved timeout so BuildClientOptions' own WithTimeout(...) and this
// method's deadline agree — both must expire at the same instant for the deadline check
// below to be a reliable signal regardless of which of the two actually threw.
var probeOptions = options with { ConnectTimeoutSeconds = effectiveSeconds };
MqttClientOptions clientOptions;
try
{
clientOptions = MqttConnection.BuildClientOptions(
probeOptions,
ProbeClientIdPrefix + Guid.NewGuid().ToString("N")[..8],
NullLogger.Instance);
}
catch (Exception ex)
{
// Never let a configured Password reach the message — BuildClientOptions itself never
// logs it, and no exception path through it can format credential values.
return new DriverProbeResult(false, $"Config could not be used to build a connection: {ex.Message}", null);
}
var target = $"{probeOptions.Host}:{probeOptions.Port}";
var sw = Stopwatch.StartNew();
using var deadline = new CancellationTokenSource(TimeSpan.FromSeconds(effectiveSeconds));
using var linked = CancellationTokenSource.CreateLinkedTokenSource(ct, deadline.Token);
using var client = new MqttClientFactory().CreateMqttClient();
MqttClientConnectResult result;
try
{
result = await client.ConnectAsync(clientOptions, linked.Token).ConfigureAwait(false);
}
catch (Exception ex)
{
return new DriverProbeResult(false, Classify(target, ex, ct, deadline, effectiveSeconds), null);
}
try
{
if (result.ResultCode != MqttClientConnectResultCode.Success)
{
return new DriverProbeResult(false, ClassifyRejectedConnack(target, result.ResultCode), null);
}
sw.Stop();
return new DriverProbeResult(true, "MQTT CONNECT OK", sw.Elapsed);
}
finally
{
await DisconnectBestEffortAsync(client, effectiveSeconds).ConfigureAwait(false);
}
}
/// <summary>
/// Maps a broker-rejected CONNACK (<see cref="MqttClientConnectResult.ResultCode"/> not
/// <c>Success</c> — never an exception, see the type remarks) to a targeted, credential-free
/// message.
/// </summary>
/// <remarks>
/// Delegates to <see cref="MqttConnection.DescribeConnackRejection"/> so the "Test connect"
/// button and the running driver report an identical rejection in identical words — they now
/// both classify CONNACK, and disagreeing about it was the exact confusion the Task-13 live
/// gate surfaced.
/// </remarks>
private static string ClassifyRejectedConnack(string target, MqttClientConnectResultCode code)
=> MqttConnection.DescribeConnackRejection(target, code);
/// <summary>
/// Classifies a thrown connect failure. Precedence mirrors
/// <see cref="MqttConnection.Classify"/>: caller cancellation, then the deadline, then the
/// exception's own shape — the deadline check is authoritative regardless of which exception
/// type MQTTnet happened to throw (see the type remarks for why exception type alone is not
/// reliable here).
/// </summary>
private static string Classify(
string target,
Exception ex,
CancellationToken callerToken,
CancellationTokenSource deadline,
int effectiveSeconds)
{
if (callerToken.IsCancellationRequested)
{
return "Probe was cancelled.";
}
if (deadline.IsCancellationRequested)
{
return $"Probe timed out after {effectiveSeconds}s.";
}
// Walk to the root cause — MQTTnet wraps transport/TLS failures one or two levels deep.
var root = ex;
while (root.InnerException is not null)
{
root = root.InnerException;
}
return root switch
{
SocketException se => $"Connect to {target} failed: {se.SocketErrorCode}",
AuthenticationException => $"TLS handshake with {target} failed: {root.Message}",
_ => $"Connect to {target} failed: {root.Message}",
};
}
/// <summary>
/// Best-effort teardown of a session this probe established. Failure here is not the
/// probe's business — the CONNECT outcome already decided Ok/failure — and it must never be
/// allowed to hang past the connect budget.
/// </summary>
private static async Task DisconnectBestEffortAsync(IMqttClient client, int timeoutSeconds)
{
if (!client.IsConnected)
{
return;
}
try
{
using var disconnectDeadline = new CancellationTokenSource(TimeSpan.FromSeconds(timeoutSeconds));
await client.DisconnectAsync(new MqttClientDisconnectOptions(), disconnectDeadline.Token)
.ConfigureAwait(false);
}
catch
{
// Best-effort only.
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,263 @@
using System.Collections.Frozen;
using System.Diagnostics.CodeAnalysis;
using TahuDataType = Org.Eclipse.Tahu.Protobuf.DataType;
namespace ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Sparkplug;
/// <summary>
/// One <c>(edgeNode, device)</c> scope's metric catalog, as declared by its most recent
/// NBIRTH/DBIRTH: the alias→metric cache <b>and</b> the stable name→metric index, rebuilt wholesale
/// on every birth. Design doc §3.6 invariants #1 and #2.
/// </summary>
/// <remarks>
/// <para>
/// <b>The alias is a per-birth cache; the NAME is the binding key.</b> Sparkplug's alias
/// mechanism exists to shrink DATA payloads — a BIRTH declares <c>name ↔ alias</c> and every
/// subsequent DATA metric carries only the alias — and the alias is scoped to <i>that one
/// birth</i>. After a rebirth an edge node is entirely free to point alias 5 at a different
/// metric. So an authored tag binds by <see cref="SparkplugMetricBinding.Name"/>; the alias is
/// only ever the lookup that turns an incoming DATA metric back into a name. Binding a tag to
/// an alias instead means a rebirth silently starts routing Pressure into a Temperature tag —
/// <b>good quality, plausible values, completely wrong</b>, and invisible unless something
/// specifically reuses an alias across a rebirth.
/// </para>
/// <para>
/// <b><see cref="RebuildFromBirth"/> REPLACES; it never merges or upserts.</b> The whole
/// snapshot — both indexes — is built fresh and published in one assignment. An alias the new
/// birth did not declare is gone, including when the new birth declares no metrics at all. This
/// is the single behaviour the type exists to guarantee: a merge (or an "optimisation" that
/// skips an empty birth) reintroduces exactly the stale-alias mis-route above.
/// </para>
/// <para>
/// <b>Both indexes are replaced together, from the same birth.</b> They are deliberately held
/// in one type rather than split across an "alias table" and a separate "birth cache": they are
/// two views of one birth, and any seam between them is somewhere the two can be replaced
/// independently and skew — an alias resolving through the new birth into a name catalog still
/// holding the old one.
/// </para>
/// <para>
/// <b>Thread-safety: an immutable snapshot swapped atomically</b>, the same discipline
/// <c>MqttSubscriptionManager.AuthoredTable</c> uses and for the same reason — this is read on
/// MQTTnet's shared dispatcher thread as messages arrive, and a reader must never see a
/// half-rebuilt map. No lock is taken on any path.
/// </para>
/// <para>
/// <b>Last values do not live here.</b> The running last-observed value per tag is
/// <see cref="LastValueCache"/>'s, keyed by RawPath — the identity every publish in this driver
/// is made under. A second copy keyed by metric name would be a duplicate source of truth with
/// a different key, and it would grow with every metric the plant births whether or not any tag
/// was ever authored for it. A birth's own values flow straight through the ingest path to that
/// cache; nothing is retained here.
/// </para>
/// </remarks>
public sealed class AliasTable
{
private volatile Snapshot _snapshot = Snapshot.Empty;
/// <summary>How many distinct metrics the most recent birth declared.</summary>
public int Count => _snapshot.Metrics.Length;
/// <summary>
/// Every metric the most recent birth declared. The consumer's STALE fan-out on a death, and
/// the discovery path's "which metrics does this scope have" question, both read this.
/// </summary>
public IReadOnlyList<SparkplugMetricBinding> Metrics => _snapshot.Metrics;
/// <summary>
/// Replaces this scope's whole catalog with <paramref name="birthMetrics"/> — the metrics of one
/// NBIRTH/DBIRTH payload, exactly as <see cref="SparkplugCodec"/> projected them.
/// </summary>
/// <param name="birthMetrics">The birth payload's metrics.</param>
/// <returns>
/// What was installed, and what was refused — the caller's only window onto a malformed birth,
/// since this type takes no logger.
/// </returns>
/// <remarks>
/// <para>
/// <b>Only an unusable NAME is a rejection.</b> A metric with a blank/absent name cannot be
/// bound to an authored tag by any route, and storing it alias-only would create a binding
/// that can never resolve (or, worse, one that matches a tag whose authored metric name
/// happens to be blank), so it is dropped and counted.
/// </para>
/// <para>
/// <b>A missing datatype is NOT a rejection</b> — it becomes
/// <see cref="TahuDataType.Unknown"/>. Dropping such a metric would turn every DATA message
/// for its alias into an unknown-alias event, which the ingest state machine answers with a
/// rebirth request; the edge node would answer with the same malformed birth, and the pair
/// would loop. Kept as Unknown, the typed layer refuses the value once
/// (<c>ToDriverDataType()</c> returns null for it) and nothing storms.
/// </para>
/// <para>
/// <b>An alias claimed by two metrics in one birth resolves to neither.</b> Any choice
/// between them is a coin flip between two different signals, which is the mis-route this
/// type exists to prevent; the alias is dropped (the consumer sees an unknown alias and can
/// request a rebirth) while both metrics stay bindable by name. A duplicate <i>name</i>, by
/// contrast, is last-wins: the name is the binding key, so evicting it would take the
/// authored tag dark entirely.
/// </para>
/// </remarks>
public BirthApplyResult RebuildFromBirth(IEnumerable<SparkplugMetric> birthMetrics)
{
ArgumentNullException.ThrowIfNull(birthMetrics);
var byName = new Dictionary<string, SparkplugMetricBinding>(StringComparer.Ordinal);
var byAlias = new Dictionary<ulong, SparkplugMetricBinding>();
HashSet<ulong>? collidedAliases = null;
var rejectedUnnamed = 0;
var duplicateNames = 0;
var aliasCollisions = 0;
foreach (var metric in birthMetrics)
{
if (string.IsNullOrWhiteSpace(metric.Name))
{
rejectedUnnamed++;
continue;
}
var binding = new SparkplugMetricBinding(metric.Name, metric.Alias, metric.DataType ?? TahuDataType.Unknown);
if (!byName.TryAdd(binding.Name, binding))
{
duplicateNames++;
byName[binding.Name] = binding;
}
if (binding.Alias is { } alias && !byAlias.TryAdd(alias, binding))
{
aliasCollisions++;
(collidedAliases ??= []).Add(alias);
}
}
if (collidedAliases is not null)
{
foreach (var alias in collidedAliases)
{
byAlias.Remove(alias);
}
}
// A duplicate name is last-wins in `byName`, so the alias index can still hold the losing
// binding — drop those too rather than let an alias resolve to a name that resolves elsewhere.
if (duplicateNames > 0)
{
foreach (var alias in byAlias.Where(kv => !ReferenceEquals(byName[kv.Value.Name], kv.Value))
.Select(kv => kv.Key).ToList())
{
byAlias.Remove(alias);
}
}
// THE replace. One assignment of a fully-built snapshot — no merge, no upsert, and no
// short-circuit for an empty birth (an empty birth legitimately clears the scope).
_snapshot = new Snapshot(
byAlias.ToFrozenDictionary(),
byName.ToFrozenDictionary(StringComparer.Ordinal),
[.. byName.Values]);
return new BirthApplyResult(byName.Count, rejectedUnnamed, aliasCollisions, duplicateNames);
}
/// <summary>
/// Resolves a DATA metric's alias to the metric the <b>most recent</b> birth bound it to, or
/// <see langword="null"/> when this birth declared no such alias.
/// </summary>
/// <param name="alias">The alias carried by an inbound DATA metric.</param>
/// <returns>The binding, or <see langword="null"/> — which the consumer treats as an unknown alias.</returns>
public SparkplugMetricBinding? Resolve(ulong alias) =>
_snapshot.ByAlias.TryGetValue(alias, out var binding) ? binding : null;
/// <summary>The <see cref="Resolve(ulong)"/> try-shape.</summary>
/// <param name="alias">The alias carried by an inbound DATA metric.</param>
/// <param name="binding">The binding when this returns <see langword="true"/>.</param>
/// <returns><see langword="true"/> when the most recent birth declared this alias.</returns>
public bool TryResolve(ulong alias, [NotNullWhen(true)] out SparkplugMetricBinding? binding)
{
binding = Resolve(alias);
return binding is not null;
}
/// <summary>
/// Resolves by the stable metric name — the binding key. Used for a DATA metric that carried its
/// name rather than an alias (aliases are optional in Sparkplug), and to answer "does this scope
/// publish the metric this tag was authored against".
/// </summary>
/// <param name="name">The stable metric name. Matched ordinally; Sparkplug names are case-sensitive.</param>
/// <returns>The binding, or <see langword="null"/>.</returns>
public SparkplugMetricBinding? ResolveByName(string name) =>
!string.IsNullOrEmpty(name) && _snapshot.ByName.TryGetValue(name, out var binding) ? binding : null;
/// <summary>The <see cref="ResolveByName"/> try-shape.</summary>
/// <param name="name">The stable metric name.</param>
/// <param name="binding">The binding when this returns <see langword="true"/>.</param>
/// <returns><see langword="true"/> when the most recent birth declared this metric.</returns>
public bool TryResolveByName(string name, [NotNullWhen(true)] out SparkplugMetricBinding? binding)
{
binding = ResolveByName(name);
return binding is not null;
}
/// <summary>
/// One birth's catalog, immutable and published in a single assignment so the dispatcher thread
/// always reads one whole birth's worth of state.
/// </summary>
/// <param name="ByAlias">Alias → binding, for the birth that declared it. Collided aliases are absent.</param>
/// <param name="ByName">Stable metric name → binding — the index authored tags bind through.</param>
/// <param name="Metrics">Every declared metric, for the STALE fan-out and discovery.</param>
private sealed record Snapshot(
FrozenDictionary<ulong, SparkplugMetricBinding> ByAlias,
FrozenDictionary<string, SparkplugMetricBinding> ByName,
SparkplugMetricBinding[] Metrics)
{
public static readonly Snapshot Empty = new(
FrozenDictionary<ulong, SparkplugMetricBinding>.Empty,
FrozenDictionary<string, SparkplugMetricBinding>.Empty,
[]);
}
}
/// <summary>One metric as its birth declared it: the stable name, the per-birth alias, the datatype.</summary>
/// <param name="Name">
/// The stable metric name — <b>the binding key</b>. An authored tag's <c>metricName</c> matches
/// against this, never against <paramref name="Alias"/>.
/// </param>
/// <param name="Alias">
/// The alias this birth assigned, or <see langword="null"/> when the birth declared none (legal —
/// such a publisher sends names in DATA too). <b>Valid only until the next birth for this scope.</b>
/// </param>
/// <param name="DataType">
/// The datatype the birth declared, or <see cref="TahuDataType.Unknown"/> when it declared none.
/// This is the <i>only</i> place a DATA metric's datatype can come from — DATA carries none.
/// </param>
public sealed record SparkplugMetricBinding(string Name, ulong? Alias, TahuDataType DataType)
{
/// <summary>
/// Applies <see cref="SparkplugCodec.ReinterpretSigned"/> to a raw DATA value using
/// <b>this birth's</b> datatype.
/// </summary>
/// <param name="rawValue">The raw <see cref="SparkplugMetric.Value"/> from a DATA metric.</param>
/// <returns>The signed value for a signed-integer datatype; <paramref name="rawValue"/> otherwise.</returns>
/// <remarks>
/// Exposed here because this is the one object that holds the datatype at the moment a DATA
/// value is being resolved, and forgetting the call is silent: Sparkplug carries Int8/16/32/64
/// as two's complement in an <i>unsigned</i> proto field, so a metric whose value is -42
/// publishes as 4294967254 — plausible, Good quality, and invisible until someone reads a gauge.
/// </remarks>
public object? Reinterpret(object? rawValue) => SparkplugCodec.ReinterpretSigned(rawValue, DataType);
}
/// <summary>What one <see cref="AliasTable.RebuildFromBirth"/> installed, and what it refused.</summary>
/// <param name="Accepted">Distinct named metrics installed.</param>
/// <param name="RejectedUnnamed">Metrics dropped for carrying no usable name.</param>
/// <param name="AliasCollisions">Aliases claimed by more than one metric, and therefore left unresolvable.</param>
/// <param name="DuplicateNames">Metrics whose name a later metric in the same birth overwrote.</param>
public readonly record struct BirthApplyResult(
int Accepted,
int RejectedUnnamed,
int AliasCollisions,
int DuplicateNames)
{
/// <summary>Whether the birth was malformed in any way worth logging.</summary>
public bool HasAnomaly => RejectedUnnamed > 0 || AliasCollisions > 0 || DuplicateNames > 0;
}
@@ -0,0 +1,234 @@
using System.Collections.Concurrent;
namespace ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Sparkplug;
/// <summary>
/// The driver's live Sparkplug birth state: one <see cref="AliasTable"/> per
/// <see cref="SparkplugScope"/>, with the spec's node→device invalidation rules applied on birth
/// and on death. Design doc §3.6 invariants #2 and #4.
/// </summary>
/// <remarks>
/// <para>
/// <b>Scoping.</b> A scope is the full <c>(group, edgeNode, device?)</c> triple — never the
/// device id alone and never <c>node/device</c> without the group, or one plant's
/// <c>Filler1</c> would answer for another's. A node-scoped birth (NBIRTH) owns the metrics the
/// edge node publishes itself; each DBIRTH owns its device's. They are separate scopes because
/// Sparkplug gives them separate alias spaces: alias 5 on <c>EdgeA</c> and alias 5 on
/// <c>EdgeA/Filler1</c> are unrelated metrics.
/// </para>
/// <para>
/// <b>An NBIRTH invalidates every device under that node.</b> Per the Sparkplug spec an NBIRTH
/// means the edge node (re)started and must re-publish a DBIRTH for each of its devices, so
/// every previously published DBIRTH is void. Keeping a device's alias table across its node's
/// rebirth is the same stale-alias mis-route as
/// <see cref="AliasTable.RebuildFromBirth"/> guards against, one scope up — and it is easier to
/// miss, because nothing about the DBIRTH itself changed. The invalidated devices are returned
/// (<see cref="NodeBirthOutcome.InvalidatedDevices"/>) rather than merely dropped, so the
/// consumer can fan STALE out over their metrics without a lookup against a table that has
/// already been evicted.
/// </para>
/// <para>
/// <b>A death evicts, it does not blank.</b> After an NDEATH/DDEATH the scope is <i>absent</i>,
/// which is what makes a DATA message arriving before the next birth a data-before-birth event
/// (⇒ request a rebirth, §3.6 invariant #3) rather than a silent unknown-alias drop against a
/// table that still looks alive.
/// </para>
/// <para>
/// <b>Thread-safety.</b> A <see cref="ConcurrentDictionary{TKey,TValue}"/> of scopes, each
/// holding an <see cref="AliasTable"/> whose own state is an atomically swapped immutable
/// snapshot. A rebirth <i>rebuilds the existing table in place</i> rather than replacing the
/// table object, so a consumer holding a reference to a scope's table always observes the newest
/// birth instead of quietly reading a detached older one. No lock is taken on any path — this is
/// read (and written) on MQTTnet's shared dispatcher thread.
/// </para>
/// <para>
/// <b>Not a value store.</b> See the remarks on <see cref="AliasTable"/>: running values belong
/// to <see cref="LastValueCache"/>, keyed by RawPath.
/// </para>
/// </remarks>
public sealed class BirthCache
{
private readonly ConcurrentDictionary<SparkplugScope, AliasTable> _scopes = new();
/// <summary>How many scopes currently hold a live birth.</summary>
public int Count => _scopes.Count;
/// <summary>A snapshot of the scopes currently holding a live birth.</summary>
public IReadOnlyList<SparkplugScope> Scopes => [.. _scopes.Keys];
/// <summary>
/// Applies an NBIRTH: rebuilds the edge node's own catalog and <b>invalidates every device</b>
/// under it, per the Sparkplug spec.
/// </summary>
/// <param name="groupId">The Sparkplug group id.</param>
/// <param name="edgeNodeId">The Sparkplug edge-node id.</param>
/// <param name="birthMetrics">The NBIRTH payload's metrics.</param>
/// <returns>The node's apply result plus the devices this birth invalidated, with their metrics.</returns>
public NodeBirthOutcome ApplyNodeBirth(string groupId, string edgeNodeId, IEnumerable<SparkplugMetric> birthMetrics)
{
ArgumentException.ThrowIfNullOrEmpty(groupId);
ArgumentException.ThrowIfNullOrEmpty(edgeNodeId);
ArgumentNullException.ThrowIfNull(birthMetrics);
// Devices first: a device table must never outlive the node birth that voided it, whatever the
// node's own rebuild does.
var invalidated = Evict(scope => scope.DeviceId is not null && scope.IsUnder(groupId, edgeNodeId));
var result = TableFor(new SparkplugScope(groupId, edgeNodeId, null)).RebuildFromBirth(birthMetrics);
return new NodeBirthOutcome(result, invalidated);
}
/// <summary>Applies a DBIRTH: rebuilds exactly that device's catalog, wholesale.</summary>
/// <param name="groupId">The Sparkplug group id.</param>
/// <param name="edgeNodeId">The Sparkplug edge-node id.</param>
/// <param name="deviceId">The Sparkplug device id.</param>
/// <param name="birthMetrics">The DBIRTH payload's metrics.</param>
/// <returns>What the birth installed, and what it refused.</returns>
public BirthApplyResult ApplyDeviceBirth(
string groupId,
string edgeNodeId,
string deviceId,
IEnumerable<SparkplugMetric> birthMetrics)
{
ArgumentException.ThrowIfNullOrEmpty(groupId);
ArgumentException.ThrowIfNullOrEmpty(edgeNodeId);
ArgumentException.ThrowIfNullOrEmpty(deviceId);
ArgumentNullException.ThrowIfNull(birthMetrics);
return TableFor(new SparkplugScope(groupId, edgeNodeId, deviceId)).RebuildFromBirth(birthMetrics);
}
/// <summary>
/// The scope's catalog, or <see langword="null"/> when no live birth has been seen for it —
/// which is the consumer's data-before-birth signal.
/// </summary>
/// <param name="scope">The scope to look up.</param>
/// <returns>The catalog, or <see langword="null"/>.</returns>
public AliasTable? Find(SparkplugScope scope) => _scopes.TryGetValue(scope, out var table) ? table : null;
/// <summary>
/// Applies an NDEATH: forgets the edge node and every device under it, returning what was
/// forgotten so the consumer can emit STALE for those metrics.
/// </summary>
/// <param name="groupId">The Sparkplug group id.</param>
/// <param name="edgeNodeId">The Sparkplug edge-node id.</param>
/// <returns>The evicted scopes and their metrics; empty when nothing was live.</returns>
public IReadOnlyList<SparkplugScopeMetrics> ForgetNode(string groupId, string edgeNodeId)
{
ArgumentException.ThrowIfNullOrEmpty(groupId);
ArgumentException.ThrowIfNullOrEmpty(edgeNodeId);
return Evict(scope => scope.IsUnder(groupId, edgeNodeId));
}
/// <summary>
/// Applies a DDEATH: forgets exactly one device, returning its metrics for the STALE fan-out.
/// The node's own catalog is untouched.
/// </summary>
/// <param name="groupId">The Sparkplug group id.</param>
/// <param name="edgeNodeId">The Sparkplug edge-node id.</param>
/// <param name="deviceId">The Sparkplug device id.</param>
/// <param name="removed">The evicted scope and its metrics when this returns <see langword="true"/>.</param>
/// <returns><see langword="true"/> when the device had a live birth to forget.</returns>
public bool TryForgetDevice(
string groupId,
string edgeNodeId,
string deviceId,
out SparkplugScopeMetrics removed)
{
ArgumentException.ThrowIfNullOrEmpty(groupId);
ArgumentException.ThrowIfNullOrEmpty(edgeNodeId);
ArgumentException.ThrowIfNullOrEmpty(deviceId);
var scope = new SparkplugScope(groupId, edgeNodeId, deviceId);
if (_scopes.TryRemove(scope, out var table))
{
removed = new SparkplugScopeMetrics(scope, table.Metrics);
return true;
}
removed = default;
return false;
}
/// <summary>Forgets every scope — the disconnect/reinitialise reset.</summary>
public void Clear() => _scopes.Clear();
/// <summary>
/// The scope's catalog, created empty on first use. Rebuilds happen <b>in place</b> on the
/// returned instance, so a consumer holding it observes the newest birth rather than a detached
/// older one.
/// </summary>
/// <param name="scope">The scope.</param>
/// <returns>The scope's catalog.</returns>
private AliasTable TableFor(SparkplugScope scope) => _scopes.GetOrAdd(scope, static _ => new AliasTable());
/// <summary>Removes every scope matching <paramref name="predicate"/>, capturing their metrics first.</summary>
/// <param name="predicate">Which scopes to evict.</param>
/// <returns>The evicted scopes and the metrics they held.</returns>
private List<SparkplugScopeMetrics> Evict(Func<SparkplugScope, bool> predicate)
{
List<SparkplugScopeMetrics>? evicted = null;
// ConcurrentDictionary enumeration is weakly consistent, which is exactly right here: this runs
// on the dispatcher thread and only needs to see the scopes that existed when the birth/death
// arrived.
foreach (var scope in _scopes.Keys)
{
if (!predicate(scope) || !_scopes.TryRemove(scope, out var table))
{
continue;
}
(evicted ??= []).Add(new SparkplugScopeMetrics(scope, table.Metrics));
}
return evicted ?? [];
}
}
/// <summary>
/// A Sparkplug metric-namespace scope: an edge node, or one device under it. The identity every
/// birth, death and alias resolution is keyed by.
/// </summary>
/// <param name="GroupId">The Sparkplug group id.</param>
/// <param name="EdgeNodeId">The Sparkplug edge-node id.</param>
/// <param name="DeviceId">The Sparkplug device id, or <see langword="null"/> for the node's own scope.</param>
public readonly record struct SparkplugScope(string GroupId, string EdgeNodeId, string? DeviceId)
{
/// <summary>Whether this is a device scope (a DBIRTH's) rather than an edge node's own.</summary>
public bool IsDevice => DeviceId is not null;
/// <summary>The owning edge node's scope — this instance itself when it is already node-scoped.</summary>
public SparkplugScope NodeScope => DeviceId is null ? this : new SparkplugScope(GroupId, EdgeNodeId, null);
/// <summary>Whether this scope belongs to the given edge node (the node's own scope included).</summary>
/// <param name="groupId">The Sparkplug group id.</param>
/// <param name="edgeNodeId">The Sparkplug edge-node id.</param>
/// <returns><see langword="true"/> when both segments match ordinally.</returns>
public bool IsUnder(string groupId, string edgeNodeId) =>
string.Equals(GroupId, groupId, StringComparison.Ordinal)
&& string.Equals(EdgeNodeId, edgeNodeId, StringComparison.Ordinal);
/// <inheritdoc/>
public override string ToString() =>
DeviceId is null ? $"{GroupId}/{EdgeNodeId}" : $"{GroupId}/{EdgeNodeId}/{DeviceId}";
}
/// <summary>
/// A scope's metrics, captured at the moment it was evicted — the STALE fan-out's input, taken
/// before the eviction so it cannot race a re-birth of the same scope.
/// </summary>
/// <param name="Scope">The scope that was evicted.</param>
/// <param name="Metrics">The metrics its last birth declared.</param>
public readonly record struct SparkplugScopeMetrics(SparkplugScope Scope, IReadOnlyList<SparkplugMetricBinding> Metrics);
/// <summary>The result of an NBIRTH: the node's own apply, plus the devices it invalidated.</summary>
/// <param name="Result">What the node's catalog rebuild installed and refused.</param>
/// <param name="InvalidatedDevices">
/// The device scopes this NBIRTH voided, with the metrics they held — the STALE fan-out's input
/// until each device re-DBIRTHs.
/// </param>
public readonly record struct NodeBirthOutcome(
BirthApplyResult Result,
IReadOnlyList<SparkplugScopeMetrics> InvalidatedDevices);
@@ -0,0 +1,146 @@
using Google.Protobuf;
using Org.Eclipse.Tahu.Protobuf;
using TahuDataType = Org.Eclipse.Tahu.Protobuf.DataType;
namespace ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Sparkplug;
/// <summary>
/// The one seam through which a Sparkplug NCMD is published — <see cref="MqttConnection"/> is the
/// production implementation once it grows a publish leg (Task 21); tests substitute a fake so the
/// encode/topic/QoS/retain contract is exercisable without a broker. Deliberately narrower than
/// "publish anything": a Sparkplug driver's only outbound traffic is NCMD/DCMD, so this is not a
/// general MQTT client abstraction in waiting.
/// </summary>
public interface IMqttPublishTransport
{
/// <summary>
/// Publishes one application message. Implementations must be bounded — a broker that accepts
/// PUBLISH and never completes it (QoS &gt; 0 awaiting PUBACK) must fail at a deadline, not
/// hang; <see cref="RebirthRequester.RequestAsync"/> supplies one, but an implementation used
/// directly by a future caller must not rely on that.
/// </summary>
/// <param name="topic">The concrete topic to publish on.</param>
/// <param name="payload">The message body.</param>
/// <param name="qos">Requested QoS, 02.</param>
/// <param name="retain">The MQTT <c>retain</c> flag.</param>
/// <param name="cancellationToken">Caller cancellation.</param>
Task PublishAsync(string topic, byte[] payload, int qos, bool retain, CancellationToken cancellationToken);
}
/// <summary>
/// Encodes and publishes a Sparkplug B rebirth request — an NCMD carrying the well-known
/// <c>Node Control/Rebirth</c> boolean metric, addressed to one edge node's command topic. See
/// Sparkplug B v3.0 spec §6/§7 and design doc §3.6.
/// </summary>
/// <remarks>
/// <para>
/// <b><see cref="Build"/> is pure.</b> It does no I/O and touches no transport, so it is
/// trivially unit-testable and safe to call from anywhere (the ingest state machine on a
/// detected gap, the browser on an operator click) without pulling in a live connection. The
/// bounded, transport-facing publish is the separate <see cref="RequestAsync"/> overload.
/// </para>
/// <para>
/// <b>No <c>seq</c>.</b> Sparkplug's sequence-number stream (§6.4.1) belongs to the messages an
/// edge node itself publishes (NBIRTH/NDATA/DBIRTH/DDATA/NDEATH/DDEATH) — it is how a host
/// detects a gap in <i>that node's</i> outbound stream. An NCMD flows the other direction, host
/// → node, and is not a member of the node's sequence at all: stamping one on here would not be
/// "wrong per spec" so much as meaningless, and a strict edge-node implementation that validated
/// it against its own counter would have grounds to reject the command outright.
/// </para>
/// <para>
/// <b>QoS 0, retain <see langword="false"/> — always.</b> NCMD is ordinary Sparkplug command
/// traffic, published at the same QoS 0 the spec uses for NDATA/DDATA/NBIRTH/DBIRTH (only
/// NDEATH — the broker-issued Will — and STATE use QoS 1). <see langword="retain"/> is the one
/// that matters operationally: a retained NCMD would be replayed by the broker to the edge node
/// on <i>every</i> future subscribe — including the node's own reconnect — driving it into a
/// permanent rebirth loop from a single stale command. See the carry-forward landmine on
/// <c>MqttDriverBrowser</c>/NDEATH-as-Will for the sibling failure shape this type must not
/// introduce on the publish side.
/// </para>
/// <para>
/// <b><c>Datatype</c> and both timestamps are set.</b> <c>Node Control/Rebirth</c> is Boolean;
/// setting <see cref="Payload.Types.Metric.Datatype"/> explicitly (rather than leaving it for the
/// node to infer) and stamping both the metric and payload <c>timestamp</c> matches what a
/// well-behaved command payload carries per spec §6.4.13 and what the Task-25 simulator (and any
/// real Cirrus-Link-derived edge node) expects to validate against. An edge node that treats a
/// missing/ambiguous field as malformed and silently drops the command would otherwise leave the
/// driver retrying forever with no visible error — the whole point of a rebirth request is to
/// recover from exactly that kind of silent desync, so the request itself must not risk being
/// the next cause of one.
/// </para>
/// </remarks>
public static class RebirthRequester
{
/// <summary>The well-known Sparkplug B node-control metric name that triggers a rebirth.</summary>
public const string RebirthMetricName = "Node Control/Rebirth";
/// <summary>
/// Default bounded deadline for <see cref="RequestAsync"/> when the caller does not supply one.
/// Deliberately this type's own value rather than <c>MqttDriverOptions.ConnectTimeoutSeconds</c>
/// — that knob already governs every MQTTnet connect/subscribe operation, and repurposing it
/// would couple the rebirth-publish budget to the connect budget in a way neither side could
/// tune independently (the same reasoning <c>MqttConnection.SubscribeAsync</c> documents for its
/// own deadline).
/// </summary>
public static readonly TimeSpan DefaultRequestTimeout = TimeSpan.FromSeconds(5);
/// <summary>
/// Builds the wire bytes and target topic for a rebirth-request NCMD, without publishing it.
/// </summary>
/// <param name="groupId">The Sparkplug group id.</param>
/// <param name="edgeNodeId">The Sparkplug edge-node id to address the command to.</param>
/// <returns>The NCMD topic (<c>spBv1.0/{groupId}/NCMD/{edgeNodeId}</c>) and its encoded payload.</returns>
/// <exception cref="ArgumentException"><paramref name="groupId"/>/<paramref name="edgeNodeId"/> is null or empty.</exception>
public static (string Topic, byte[] Bytes) Build(string groupId, string edgeNodeId)
{
// Validates both ids and does the topic assembly — see the "don't hand-concatenate" remark on
// SparkplugTopic.Format itself.
var topic = SparkplugTopic.Format(groupId, SparkplugMessageType.NCMD, edgeNodeId);
var timestampMs = (ulong)DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
var payload = new Payload { Timestamp = timestampMs };
payload.Metrics.Add(new Payload.Types.Metric
{
Name = RebirthMetricName,
Datatype = (uint)TahuDataType.Boolean,
Timestamp = timestampMs,
BooleanValue = true,
});
return (topic, payload.ToByteArray());
}
/// <summary>
/// Builds and publishes a rebirth-request NCMD under a bounded deadline. QoS 0, retain
/// <see langword="false"/> — see the type remarks for why both are fixed rather than
/// configurable.
/// </summary>
/// <param name="transport">The publish seam — the live connection, or a test fake.</param>
/// <param name="groupId">The Sparkplug group id.</param>
/// <param name="edgeNodeId">The Sparkplug edge-node id to address the command to.</param>
/// <param name="cancellationToken">Caller cancellation; linked with the publish deadline.</param>
/// <param name="timeout">
/// The publish deadline. Defaults to <see cref="DefaultRequestTimeout"/> when omitted or
/// non-positive.
/// </param>
/// <exception cref="ArgumentException"><paramref name="groupId"/>/<paramref name="edgeNodeId"/> is null or empty.</exception>
/// <exception cref="ArgumentNullException"><paramref name="transport"/> is null.</exception>
public static async Task RequestAsync(
IMqttPublishTransport transport,
string groupId,
string edgeNodeId,
CancellationToken cancellationToken,
TimeSpan? timeout = null)
{
ArgumentNullException.ThrowIfNull(transport);
var (topic, bytes) = Build(groupId, edgeNodeId);
var deadlineSpan = timeout is { } t && t > TimeSpan.Zero ? t : DefaultRequestTimeout;
using var deadline = new CancellationTokenSource(deadlineSpan);
using var linked = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, deadline.Token);
await transport.PublishAsync(topic, bytes, qos: 0, retain: false, linked.Token).ConfigureAwait(false);
}
}
@@ -0,0 +1,386 @@
namespace ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Sparkplug;
/// <summary>
/// Per-edge-node Sparkplug B stream-continuity state: the wrapping <c>seq</c> counter that says
/// whether the driver has missed a message, and the <c>bdSeq</c> session token that ties an
/// NDEATH to the NBIRTH it belongs to. Design doc §3.6 invariant #3.
/// </summary>
/// <remarks>
/// <para>
/// <b>One instance per edge node — this object <i>is</i> the edge node's stream state.</b> It
/// holds no key and no map. Sparkplug numbers messages per edge node (an edge node's devices
/// share its counter, so a DDATA advances the same sequence an NDATA does), and the ingest
/// state machine already keeps per-scope state for the alias table and birth cache; giving
/// this type a second internal dictionary would mean two maps whose lifetimes must be kept in
/// step by hand, and a tracker that outlived a removed edge node would go on answering
/// questions about a stream nobody is reading.
/// </para>
/// <para>
/// <b>This type detects; it does not react.</b> It never publishes, never requests a rebirth
/// and never emits a quality change — the ingest state machine reads its verdicts and decides.
/// The split matters because "was a message lost" is a fact about the wire, while "request a
/// rebirth now" is a policy gated on <c>requestRebirthOnGap</c>.
/// </para>
/// <para>
/// <b>Thread-safe under a plain monitor.</b> Sparkplug messages arrive on MQTTnet's shared
/// dispatcher thread while the reconnect path can call <see cref="Reset"/> from another, and
/// every operation here is a read-modify-write of two coupled fields — the immutable-snapshot
/// discipline <c>MqttSubscriptionManager</c> uses fits a set that is swapped wholesale, not a
/// counter that is advanced. The critical sections are a handful of instructions each and
/// nothing inside them can block, so the simple lock this repo uses elsewhere
/// (<c>MqttDriver._hostLock</c>) is the right shape.
/// </para>
/// <para>
/// <b>Nothing here throws, for any input.</b> Same reasoning as
/// <see cref="SparkplugCodec"/>: this sits behind an unauthenticated firehose on the
/// dispatcher thread, so a spec-violating publisher gets a verdict, never an exception that
/// would stall delivery for every subscription on the connection.
/// </para>
/// </remarks>
public sealed class SequenceTracker
{
/// <summary>
/// The largest legal Sparkplug <c>seq</c>. The counter is a byte on the wire in spirit but a
/// <c>uint64</c> in the schema, so the range check lives here — see <see cref="Accept"/>.
/// </summary>
public const ulong MaxSeq = 255;
private readonly object _gate = new();
/// <summary>The last credible sequence number, meaningful only when <see cref="_hasLastSeq"/>.</summary>
private byte _lastSeq;
/// <summary>Whether any credible sequence number has been observed since the last reset.</summary>
private bool _hasLastSeq;
/// <summary>Whether the current baseline was established by a birth rather than adopted mid-stream.</summary>
private bool _birthSynchronized;
/// <summary>The current session's <c>bdSeq</c>, or null when no birth has supplied one.</summary>
private ulong? _birthBdSeq;
/// <summary>
/// Whether the current sequence baseline came from a birth. <see langword="false"/> also after
/// a first message that was <i>not</i> a birth — the driver joined mid-stream and holds no
/// alias table, which is a different situation from a gap even though both call for a rebirth.
/// </summary>
public bool IsBirthSynchronized
{
get { lock (_gate) return _birthSynchronized; }
}
/// <summary>
/// The last credible sequence number observed, or <see langword="null"/> when none has been —
/// before the first message, or after <see cref="Reset"/>.
/// </summary>
public byte? LastSeq
{
get { lock (_gate) return _hasLastSeq ? _lastSeq : null; }
}
/// <summary>
/// The <c>bdSeq</c> of the most recent birth, or <see langword="null"/> when no birth has
/// supplied one. This is the token <see cref="IsDeathForCurrentSession"/> matches against.
/// </summary>
public ulong? BirthBdSeq
{
get { lock (_gate) return _birthBdSeq; }
}
/// <summary>
/// Records an <b>NBIRTH</b>: restarts the sequence at the birth's <c>seq</c> and adopts its
/// <c>bdSeq</c> as the current session token.
/// </summary>
/// <param name="seq">
/// The birth payload's <c>seq</c> — <see cref="SparkplugPayload.Seq"/>, which is
/// <see langword="null"/> when the message carried none.
/// </param>
/// <param name="bdSeq">
/// The birth's session token, read from its <c>bdSeq</c> metric with
/// <see cref="TryReadBdSeq"/>; <see langword="null"/> when the birth carried none.
/// </param>
/// <returns>
/// <see langword="true"/> when the birth established a usable sequence baseline;
/// <see langword="false"/> when its <c>seq</c> was absent or out of range.
/// </returns>
/// <remarks>
/// <para>
/// <b>An NBIRTH is never a gap.</b> It restarts the sequence by definition, so it must not
/// be run through <see cref="Accept"/> — doing so would flag a gap on the very message
/// that just resynchronized everything, and answer a birth with a request for another one.
/// </para>
/// <para>
/// <b>A DBIRTH must NOT be offered here — it belongs to <see cref="Accept"/>.</b> Only the
/// NBIRTH restarts the sequence; a DBIRTH carries the next number in the edge node's
/// ongoing stream, and only the NBIRTH (with the NDEATH) carries <c>bdSeq</c> at all.
/// Routing a DBIRTH here would do two wrong things at once: silently rebase the sequence
/// mid-stream, and — because the DBIRTH has no <c>bdSeq</c> to pass — <b>wipe the node's
/// session token to null</b>. A stale Last Will arriving afterwards would then find
/// nothing to be compared against, fall through the fail-toward-stale rule in
/// <see cref="IsDeathForCurrentSession"/>, and kill the live node this type exists to
/// protect. The method is named for the node deliberately, so that call site reads wrong.
/// </para>
/// <para>
/// <b>An in-range <c>seq</c> other than the spec-required 0 is adopted, not refused.</b>
/// Refusing it would leave the tracker unsynchronized against a publisher whose stream is
/// otherwise perfectly followable, and every message that followed would demand a fresh
/// rebirth — the storm this type exists to prevent, arrived at by being strict.
/// </para>
/// <para>
/// <b>The <c>bdSeq</c> is recorded even when the <c>seq</c> was unusable.</b> Session
/// identity and position-in-stream are independent facts; discarding the tie because the
/// sequence number was garbled would leave the next NDEATH unmatchable, which is the one
/// thing that lets a stale will kill a live node.
/// </para>
/// </remarks>
public bool AcceptNodeBirth(ulong? seq, ulong? bdSeq = null)
{
lock (_gate)
{
_birthBdSeq = bdSeq;
if (seq is not { } value || value > MaxSeq)
{
_hasLastSeq = false;
_birthSynchronized = false;
return false;
}
_lastSeq = (byte)value;
_hasLastSeq = true;
_birthSynchronized = true;
return true;
}
}
/// <summary>
/// Offers the next sequenced message's <c>seq</c> — <b>DBIRTH</b>, NDATA, DDATA or DDEATH —
/// and reports whether the stream is still contiguous.
/// </summary>
/// <param name="seq">
/// The payload's <c>seq</c> — <see cref="SparkplugPayload.Seq"/>, which is
/// <see langword="null"/> when the message carried none.
/// </param>
/// <returns>
/// <see langword="true"/> when this <c>seq</c> is the one that follows the last;
/// <see langword="false"/> when a message was missed, when the value is out of range, or when
/// it is absent. A <see langword="false"/> is the caller's cue to request a rebirth.
/// </returns>
/// <remarks>
/// <para>
/// <b>The wrap is the whole point: next-expected is <c>(last + 1) &amp; 0xFF</c>, so
/// 255 → 0 is the sequence continuing, not a gap.</b> Getting the boundary wrong fails
/// loudly in one direction and silently in the other. Read the wrap as a gap and every
/// edge node is asked to rebirth once per 256 messages, so a busy node spends its life
/// republishing births instead of publishing data. Miss a real gap and the driver serves
/// values it knows are behind the device, at Good quality, indefinitely.
/// </para>
/// <para>
/// <b>A gap is reported once, then the tracker resynchronizes onto the observed value.</b>
/// Holding the baseline at the pre-gap number would make every following message report a
/// gap too, so a single lost message would become a permanent rebirth storm.
/// </para>
/// <para>
/// <b>An out-of-range <c>seq</c> is refused, never masked into range.</b> The codec hands
/// this over as a <see cref="ulong"/> precisely so a spec-violating publisher's value is
/// not silently truncated into a plausible-looking one, and masking would finish the job
/// it declined to do: with the baseline at 255, a <c>seq</c> of 256 masks to 0 — exactly
/// the value the wrap expects next — and the bogus message would be accepted as
/// contiguous. Refused values do not become the baseline either, so the next genuine
/// message is still measured against the last credible one.
/// </para>
/// <para>
/// <b>The first message is not a gap</b> — there is nothing to measure it against. It is
/// adopted as the baseline, and <see cref="IsBirthSynchronized"/> stays
/// <see langword="false"/> to record that the driver joined mid-stream.
/// </para>
/// <para>
/// <b>NDEATH must not be offered here.</b> An NDEATH is the broker-published Last Will and
/// carries no <c>seq</c> at all; feeding it in would report a gap on a message that never
/// had a place in the sequence. It is tied to its birth by
/// <see cref="IsDeathForCurrentSession"/> instead. A DDEATH is published by the edge node
/// and <i>is</i> sequenced, so it does belong here.
/// </para>
/// </remarks>
public bool Accept(ulong? seq)
{
lock (_gate)
{
// Refused without becoming the baseline: see the out-of-range remarks above.
if (seq is not { } value || value > MaxSeq)
{
return false;
}
var current = (byte)value;
if (!_hasLastSeq)
{
_lastSeq = current;
_hasLastSeq = true;
return true;
}
var expected = (byte)((_lastSeq + 1) & 0xFF);
// Resynchronize even on a gap — one lost message must not become a permanent storm.
_lastSeq = current;
return current == expected;
}
}
/// <summary>
/// Reports whether an NDEATH belongs to the session currently believed alive — the
/// <c>bdSeq</c> tie of §3.6 invariant #3.
/// </summary>
/// <param name="deathBdSeq">
/// The death payload's <c>bdSeq</c>, read with <see cref="TryReadBdSeq"/>;
/// <see langword="null"/> when it carried none.
/// </param>
/// <returns>
/// <see langword="true"/> when the death applies and the node's metrics should go stale;
/// <see langword="false"/> when it is a previous session's will and must be ignored.
/// </returns>
/// <remarks>
/// <para>
/// <b>What this prevents.</b> An edge node's connection drops, it reconnects and publishes
/// a fresh NBIRTH — and only <i>then</i> does the broker notice the old connection died
/// and deliver its retained Last Will. The NDEATH arrives after the NBIRTH, carrying the
/// previous session's <c>bdSeq</c>. Without this comparison that stale will marks a live,
/// freshly-born node dead and drives every one of its tags to Bad quality, with no further
/// message coming to correct it until the node happens to rebirth again.
/// </para>
/// <para>
/// <b>Unknowns fail toward stale, deliberately.</b> A death arriving before any birth was
/// seen, a death carrying no <c>bdSeq</c>, or a birth that carried none all resolve to
/// <see langword="true"/>. The two error directions are not symmetric: acting on a death
/// wrongly marks tags stale until the next birth restores them (§3.6 invariant #4, a
/// self-correcting error an operator can see), while ignoring a real death leaves a dead
/// node's last values flowing at Good quality forever. Only a <i>positive mismatch</i> —
/// two known, different tokens — is evidence enough to discard a death.
/// </para>
/// <para>
/// <b>Pure.</b> It answers a question and changes nothing, so the caller can ask it while
/// logging or deciding. A death that <i>is</i> acted on needs no reset here: the node is
/// offline, and the next birth calls <see cref="AcceptNodeBirth"/> which restarts the sequence
/// anyway.
/// </para>
/// </remarks>
public bool IsDeathForCurrentSession(ulong? deathBdSeq)
{
lock (_gate)
{
if (_birthBdSeq is not { } birth || deathBdSeq is not { } death)
{
return true;
}
return birth == death;
}
}
/// <summary>
/// Returns the tracker to its virgin state: no sequence baseline, not birth-synchronized, no
/// session token.
/// </summary>
/// <remarks>
/// The reconnect seam (§3.6 invariant #5 — late join). After a dropped connection the driver
/// has missed an unknown number of messages, so the baseline it was holding is no longer
/// evidence of anything: carrying it across would let the stream look contiguous purely
/// because the edge node happened to publish the right number of messages while the driver was
/// away.
/// </remarks>
public void Reset()
{
lock (_gate)
{
_lastSeq = 0;
_hasLastSeq = false;
_birthSynchronized = false;
_birthBdSeq = null;
}
}
/// <summary>
/// Reads the <c>bdSeq</c> session token out of a decoded NBIRTH or NDEATH payload.
/// </summary>
/// <param name="payload">The decoded payload; <see langword="null"/> and invalid are both handled.</param>
/// <param name="bdSeq">The token on success; <c>0</c> otherwise.</param>
/// <returns><see langword="true"/> when the payload carried a usable <c>bdSeq</c> metric.</returns>
/// <remarks>
/// <para>
/// <b><c>bdSeq</c> is not a top-level payload field</b> — unlike <c>seq</c> it rides as an
/// ordinary metric named <c>bdSeq</c>, which is why reading it is a helper rather than a
/// property. The name is matched exactly (ordinal): the spec fixes it, so a case variant
/// is far more likely to be a different metric than a typo of this one.
/// </para>
/// <para>
/// <b>Signed values are reinterpreted before they are judged.</b> Sparkplug carries
/// <c>Int64</c> as two's complement in an unsigned field, so a negative <c>bdSeq</c>
/// reaches the projection as an enormous <see cref="ulong"/>. Read raw it would become a
/// plausible-looking session token minted out of nonsense; run through
/// <see cref="SparkplugCodec.ReinterpretSigned"/> it is visibly negative and refused.
/// </para>
/// <para>
/// <b>The value is treated as an opaque token, not as a 0255 counter.</b> Its only job
/// here is equality against the birth's, and a range check could never make a match more
/// or less correct — it could only discard the one piece of evidence tying a death to its
/// birth. This is the opposite call from <c>seq</c>, whose range is load-bearing precisely
/// because its arithmetic wraps.
/// </para>
/// </remarks>
public static bool TryReadBdSeq(SparkplugPayload? payload, out ulong bdSeq)
{
bdSeq = 0;
if (payload is not { IsValid: true })
{
return false;
}
foreach (var metric in payload.Metrics)
{
if (!string.Equals(metric.Name, "bdSeq", StringComparison.Ordinal))
{
continue;
}
if (metric.ValueKind != SparkplugValueKind.Scalar)
{
return false;
}
var value = metric.DataType is { } datatype
? SparkplugCodec.ReinterpretSigned(metric.Value, datatype)
: metric.Value;
switch (value)
{
case ulong u:
bdSeq = u;
return true;
case uint u:
bdSeq = u;
return true;
case long l when l >= 0:
bdSeq = (ulong)l;
return true;
case int i when i >= 0:
bdSeq = (ulong)i;
return true;
case short s when s >= 0:
bdSeq = (ulong)s;
return true;
case sbyte sb when sb >= 0:
bdSeq = (ulong)sb;
return true;
default:
// Negative, or a wire type no integer token can come from.
return false;
}
}
return false;
}
}
@@ -0,0 +1,313 @@
using Google.Protobuf;
using Org.Eclipse.Tahu.Protobuf;
using TahuDataType = Org.Eclipse.Tahu.Protobuf.DataType;
namespace ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Sparkplug;
/// <summary>
/// Decodes Sparkplug-B wire bytes into <see cref="SparkplugPayload"/> — the driver-side projection
/// the ingest state machine consumes. Decode only; the NCMD <i>encode</i> path lives in
/// <c>RebirthRequester</c>.
/// </summary>
/// <remarks>
/// <para>
/// <b>Nothing here throws, for any input.</b> This runs on MQTTnet's shared dispatcher thread,
/// behind an unauthenticated firehose the plant's edge nodes publish into. An escaping
/// exception would not degrade one tag — it would stall or kill delivery for every
/// subscription on the connection. Garbage bytes, a truncated body, a zero-length payload, a
/// valid protobuf of some other schema and a pathologically nested Template all resolve to a
/// verdict: <see cref="TryDecode"/> returns <see langword="false"/> and
/// <see cref="Decode"/> returns <see cref="SparkplugPayload.Invalid"/>.
/// </para>
/// <para>
/// <b>Zero-length input is invalid, not empty.</b> Protobuf would happily parse zero bytes as
/// "a Payload with every field defaulted", but in Sparkplug an empty MQTT body is never a
/// legitimate message — treating it as a well-formed payload carrying no metrics is how a
/// truncated-to-nothing body gets mistaken for a real one. It is reported as invalid.
/// </para>
/// <para>
/// <b>Explicit presence is preserved, everywhere.</b> The vendored schema is proto2 precisely
/// so absence and zero stay distinguishable, and every projection below reads
/// <c>Has{Seq,Name,Alias,Datatype,Timestamp,IsNull}</c> rather than testing a value against its
/// default. Two cases make this load-bearing rather than pedantic: a Sparkplug NBIRTH is
/// REQUIRED to carry <c>seq = 0</c>, and every DATA metric after a birth carries an alias with
/// <b>no</b> name and <b>no</b> datatype. A zero-check decoder reports "no sequence" for every
/// birth and "" for every DATA metric name.
/// </para>
/// <para>
/// <b>Values are projected raw — nothing is coerced or reinterpreted here.</b> A metric's value
/// arrives as the CLR type of the wire field that carried it (see
/// <see cref="SparkplugMetric.Value"/>); coercion against the authored tag's declared
/// <c>DriverDataType</c> is the consumer's job, and follows this driver's standing rule that a
/// value which does not fit is refused rather than silently converted. The one wire-level
/// translation this type does offer is <see cref="ReinterpretSigned"/>, because Sparkplug's
/// two's-complement-in-an-unsigned-field encoding of signed integers is a property of the
/// <i>wire</i>, not of the tag.
/// </para>
/// <para>
/// <b><c>DataSet</c> and <c>Template</c> metrics are out of scope for v1.</b> They decode to
/// <see cref="SparkplugValueKind.Unsupported"/> with a null value — deliberately visible, so a
/// consumer can warn about a metric it cannot serve instead of silently treating it as one
/// that was never published.
/// </para>
/// </remarks>
public static class SparkplugCodec
{
/// <summary>Decodes Sparkplug-B wire bytes, reporting success rather than throwing.</summary>
/// <param name="wire">The MQTT message body.</param>
/// <param name="payload">
/// The decoded payload on success; <see cref="SparkplugPayload.Invalid"/> otherwise. Never
/// <see langword="null"/>.
/// </param>
/// <returns>
/// <see langword="true"/> when the bytes parsed as a Sparkplug-B <c>Payload</c>. Protobuf is a
/// permissive, self-describing-only-by-convention format, so this means "parsed" rather than
/// "was genuinely produced by a Sparkplug edge node" — unknown fields are preserved by
/// Google.Protobuf and simply ignored here.
/// </returns>
public static bool TryDecode(ReadOnlySpan<byte> wire, out SparkplugPayload payload)
{
payload = SparkplugPayload.Invalid;
if (wire.IsEmpty)
{
return false;
}
try
{
var proto = Payload.Parser.ParseFrom(wire);
var metrics = proto.Metrics.Count == 0
? []
: new SparkplugMetric[proto.Metrics.Count];
for (var i = 0; i < proto.Metrics.Count; i++)
{
metrics[i] = ProjectMetric(proto.Metrics[i]);
}
payload = new SparkplugPayload(
IsValid: true,
Seq: proto.HasSeq ? proto.Seq : null,
TimestampMs: proto.HasTimestamp ? proto.Timestamp : null,
Metrics: metrics);
return true;
}
catch (Exception)
{
// Deliberately broad, and deliberately spanning the projection as well as the parse.
// InvalidProtocolBufferException covers malformed, truncated and over-nested input, but
// this is the dispatcher-thread boundary and the projection walks an attacker-shaped
// object graph: the contract is a verdict for EVERY input, so a guard that stopped at
// the parse would be a contract with a hole in it. Narrowing the catch would trade the
// guarantee for a taxonomy nobody downstream can act on.
payload = SparkplugPayload.Invalid;
return false;
}
}
/// <summary>
/// Decodes Sparkplug-B wire bytes, returning <see cref="SparkplugPayload.Invalid"/> when they
/// cannot be decoded.
/// </summary>
/// <param name="wire">The MQTT message body.</param>
/// <returns>The decoded payload — never <see langword="null"/>.</returns>
/// <remarks>
/// The convenience shape. <b>Check <see cref="SparkplugPayload.IsValid"/>:</b> an undecodable
/// body and a genuinely metric-less one both present as an empty
/// <see cref="SparkplugPayload.Metrics"/>, and only the flag tells them apart. Prefer
/// <see cref="TryDecode"/> where the verdict drives control flow.
/// </remarks>
public static SparkplugPayload Decode(ReadOnlySpan<byte> wire) =>
TryDecode(wire, out var payload) ? payload : SparkplugPayload.Invalid;
/// <summary>
/// Reinterprets a raw metric value as the signed integer Sparkplug encoded it as, when its
/// datatype is a signed integer type; returns the value unchanged for every other datatype.
/// </summary>
/// <param name="value">
/// A <see cref="SparkplugMetric.Value"/> — a <see cref="uint"/> for the 8/16/32-bit arms, a
/// <see cref="ulong"/> for the 64-bit arm.
/// </param>
/// <param name="datatype">The metric's Sparkplug datatype, from its birth or its own field.</param>
/// <returns>
/// <see cref="sbyte"/> / <see cref="short"/> / <see cref="int"/> / <see cref="long"/> for the
/// signed integer datatypes; <paramref name="value"/> untouched otherwise (including when it is
/// <see langword="null"/> or not the wire type the datatype implies).
/// </returns>
/// <remarks>
/// Sparkplug carries <c>Int8</c>/<c>Int16</c>/<c>Int32</c> in the <b>unsigned</b>
/// <c>int_value</c> field and <c>Int64</c> in the unsigned <c>long_value</c> field, as two's
/// complement. Handing the raw field to a consumer publishes <c>4294967254</c> for a tag whose
/// value is <c>-42</c> — a wrong value that looks entirely plausible, arrives with Good
/// quality, and is invisible until someone reads a gauge. This is the single call that undoes
/// it, and it is separate from the decode itself because a DATA metric carries no datatype of
/// its own: only the consumer, holding the alias table built from the birth, knows which
/// datatype applies.
/// </remarks>
public static object? ReinterpretSigned(object? value, TahuDataType datatype) => datatype switch
{
TahuDataType.Int8 when value is uint raw => unchecked((sbyte)raw),
TahuDataType.Int16 when value is uint raw => unchecked((short)raw),
TahuDataType.Int32 when value is uint raw => unchecked((int)raw),
TahuDataType.Int64 when value is ulong raw => unchecked((long)raw),
_ => value,
};
/// <summary>Projects one generated metric onto the driver-side shape, presence intact.</summary>
/// <param name="metric">The generated metric.</param>
/// <returns>The projection.</returns>
private static SparkplugMetric ProjectMetric(Payload.Types.Metric metric)
{
var (kind, value) = ProjectValue(metric);
return new SparkplugMetric(
Name: metric.HasName ? metric.Name : null,
Alias: metric.HasAlias ? metric.Alias : null,
// Cast, never filter: an index this build's enum does not define is a future Sparkplug
// revision or a misbehaving publisher, and dropping it would deny the mapping layer the
// only evidence it has.
DataType: metric.HasDatatype ? (TahuDataType)metric.Datatype : null,
TimestampMs: metric.HasTimestamp ? metric.Timestamp : null,
ValueKind: kind,
Value: value);
}
/// <summary>Resolves a metric's value <c>oneof</c> to a kind plus a raw CLR value.</summary>
/// <param name="metric">The generated metric.</param>
/// <returns>The value kind and the projected value.</returns>
/// <remarks>
/// <c>is_null</c> wins over the <c>oneof</c>: the Sparkplug spec's whole reason for the field is
/// that some datatypes have no spare sentinel, so a publisher that sets it means "null" even if
/// a value field is also populated.
/// </remarks>
private static (SparkplugValueKind Kind, object? Value) ProjectValue(Payload.Types.Metric metric)
{
if (metric.HasIsNull && metric.IsNull)
{
return (SparkplugValueKind.Null, null);
}
return metric.ValueCase switch
{
Payload.Types.Metric.ValueOneofCase.IntValue => (SparkplugValueKind.Scalar, metric.IntValue),
Payload.Types.Metric.ValueOneofCase.LongValue => (SparkplugValueKind.Scalar, metric.LongValue),
Payload.Types.Metric.ValueOneofCase.FloatValue => (SparkplugValueKind.Scalar, metric.FloatValue),
Payload.Types.Metric.ValueOneofCase.DoubleValue => (SparkplugValueKind.Scalar, metric.DoubleValue),
Payload.Types.Metric.ValueOneofCase.BooleanValue => (SparkplugValueKind.Scalar, metric.BooleanValue),
Payload.Types.Metric.ValueOneofCase.StringValue => (SparkplugValueKind.Scalar, metric.StringValue),
// Copied out of the ByteString: the projection must outlive the parsed message.
Payload.Types.Metric.ValueOneofCase.BytesValue =>
(SparkplugValueKind.Scalar, metric.BytesValue.ToByteArray()),
// v1 scope boundary — decoded as a visible refusal, not as an exception or a silent drop.
Payload.Types.Metric.ValueOneofCase.DatasetValue => (SparkplugValueKind.Unsupported, null),
Payload.Types.Metric.ValueOneofCase.TemplateValue => (SparkplugValueKind.Unsupported, null),
Payload.Types.Metric.ValueOneofCase.ExtensionValue => (SparkplugValueKind.Unsupported, null),
_ => (SparkplugValueKind.Absent, null),
};
}
}
/// <summary>
/// A decoded Sparkplug-B payload: the driver-side projection of the generated <c>Payload</c>,
/// carrying only what the ingest state machine consumes.
/// </summary>
/// <param name="IsValid">
/// <see langword="false"/> for a payload that could not be decoded. An invalid payload also has an
/// empty <paramref name="Metrics"/> list, so <b>this flag is the only thing distinguishing an
/// undecodable body from a legitimately metric-less one</b>.
/// </param>
/// <param name="Seq">
/// The payload sequence number, or <see langword="null"/> when the message carried none. Kept as
/// the wire's <see cref="ulong"/> rather than narrowed to a byte: the Sparkplug range is 0255, but
/// a publisher that violates it is reporting a fact the consumer should be able to see and reject,
/// not one this layer should silently truncate into a plausible-looking sequence number.
/// </param>
/// <param name="TimestampMs">The payload timestamp in Sparkplug epoch milliseconds, or null when absent.</param>
/// <param name="Metrics">The payload's metrics, in wire order. Never <see langword="null"/>.</param>
public sealed record SparkplugPayload(
bool IsValid,
ulong? Seq,
ulong? TimestampMs,
IReadOnlyList<SparkplugMetric> Metrics)
{
/// <summary>The shared "could not decode this" result.</summary>
public static readonly SparkplugPayload Invalid = new(false, null, null, []);
}
/// <summary>One decoded Sparkplug metric.</summary>
/// <param name="Name">
/// The metric's stable name, or <see langword="null"/> when the message omitted it — which every
/// real DATA metric after a birth does, carrying only <paramref name="Alias"/>. Distinguishing
/// this from an empty string is the reason the vendored schema is proto2.
/// </param>
/// <param name="Alias">The per-birth alias, or <see langword="null"/> when the metric carried none.</param>
/// <param name="DataType">
/// The metric's declared datatype, or <see langword="null"/> when absent (again, the normal case
/// for a DATA metric — its datatype comes from the birth). An index this build's enum does not
/// define is preserved as an undefined enum value rather than dropped.
/// </param>
/// <param name="TimestampMs">
/// The metric's own acquisition timestamp in Sparkplug epoch milliseconds, or
/// <see langword="null"/> when it carried none — in which case the payload's timestamp applies.
/// </param>
/// <param name="ValueKind">
/// What <paramref name="Value"/> means. Check this before reading the value: a null value is
/// ambiguous between "explicitly null", "absent" and "a kind v1 does not support".
/// </param>
/// <param name="Value">
/// The <b>raw</b> wire value, boxed: <see cref="uint"/> (<c>int_value</c>), <see cref="ulong"/>
/// (<c>long_value</c>), <see cref="float"/>, <see cref="double"/>, <see cref="bool"/>,
/// <see cref="string"/> or <see cref="byte"/><c>[]</c> — and <see langword="null"/> for every
/// <paramref name="ValueKind"/> other than <see cref="SparkplugValueKind.Scalar"/>.
/// <para>
/// <b>Signed integers arrive as their unsigned two's-complement wire value</b> — a
/// <c>DataType.Int32</c> metric holding -42 is a <see cref="uint"/> of 4294967254 here. Run it
/// through <see cref="SparkplugCodec.ReinterpretSigned"/> with the metric's datatype (from the
/// birth, for a DATA metric) before publishing it.
/// </para>
/// <para>
/// Boxing is deliberate: the consumer coerces against the authored tag's declared
/// <c>DriverDataType</c> and hands the result to a <c>DataValueSnapshot</c>, which boxes
/// anyway, so a discriminated-union shape would buy an unboxing hop and cost every consumer a
/// switch over a dozen arms.
/// </para>
/// </param>
public readonly record struct SparkplugMetric(
string? Name,
ulong? Alias,
TahuDataType? DataType,
ulong? TimestampMs,
SparkplugValueKind ValueKind,
object? Value);
/// <summary>What a decoded metric's <see cref="SparkplugMetric.Value"/> represents.</summary>
/// <remarks>
/// Three of the four members have a null <see cref="SparkplugMetric.Value"/> and mean entirely
/// different things, which is exactly why the distinction is carried explicitly rather than left
/// for a consumer to infer from a null.
/// </remarks>
public enum SparkplugValueKind
{
/// <summary>The metric's value <c>oneof</c> carried nothing at all.</summary>
Absent = 0,
/// <summary>The metric set <c>is_null</c>: it exists, and its value is explicitly null.</summary>
Null,
/// <summary><see cref="SparkplugMetric.Value"/> holds the raw wire value.</summary>
Scalar,
/// <summary>
/// A <c>DataSet</c>, <c>Template</c> or extension value — decoded and reported, but not
/// supported in v1. The consumer should warn and skip the metric rather than treat it as
/// missing.
/// </summary>
Unsupported,
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,291 @@
using System.Diagnostics.CodeAnalysis;
namespace ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Sparkplug;
/// <summary>
/// The Sparkplug B topic-namespace element that identifies a message's purpose — the third
/// segment of <c>spBv1.0/{group}/{type}/{node}[/{device}]</c>, or the second segment of the
/// differently-shaped <c>spBv1.0/STATE/{hostId}</c>. See design doc §3.1/§3.6 and Sparkplug B
/// v3.0 spec §6 (Topic Namespace Elements).
/// </summary>
public enum SparkplugMessageType
{
/// <summary>Node birth certificate — (re)publishes an edge node's full metric/alias set.</summary>
NBIRTH,
/// <summary>Device birth certificate — (re)publishes a device's full metric/alias set.</summary>
DBIRTH,
/// <summary>Node data — incremental metric updates owned by the edge node itself.</summary>
NDATA,
/// <summary>Device data — incremental metric updates for a device under the edge node.</summary>
DDATA,
/// <summary>Node death certificate (the edge node's MQTT Will) — the node has gone offline.</summary>
NDEATH,
/// <summary>Device death certificate — the device has gone offline (published by its edge node).</summary>
DDEATH,
/// <summary>Node command — a write/rebirth request addressed to the edge node.</summary>
NCMD,
/// <summary>Device command — a write request addressed to a device under the edge node.</summary>
DCMD,
/// <summary>
/// Primary-host online/offline state. Shaped differently from every other message type — see
/// the remarks on <see cref="SparkplugTopic"/> and <see cref="SparkplugTopic.HostId"/>.
/// </summary>
STATE,
}
/// <summary>
/// A parsed Sparkplug B MQTT topic. See design doc §3.1/§3.6 and Sparkplug B v3.0 spec §6.
/// </summary>
/// <param name="Type">The message's purpose.</param>
/// <param name="GroupId">
/// The Sparkplug group id. <see langword="null"/> for <see cref="SparkplugMessageType.STATE"/>,
/// always populated otherwise.
/// </param>
/// <param name="EdgeNodeId">
/// The Sparkplug edge-node id. <see langword="null"/> for <see cref="SparkplugMessageType.STATE"/>,
/// always populated otherwise.
/// </param>
/// <param name="DeviceId">
/// The Sparkplug device id, present only for the device-scoped message types
/// (<see cref="SparkplugMessageType.DBIRTH"/>/<see cref="SparkplugMessageType.DDATA"/>/
/// <see cref="SparkplugMessageType.DDEATH"/>/<see cref="SparkplugMessageType.DCMD"/>);
/// <see langword="null"/> for node-scoped messages and for <see cref="SparkplugMessageType.STATE"/>.
/// </param>
/// <param name="HostId">
/// The primary-host id, populated only for <see cref="SparkplugMessageType.STATE"/>; carries the
/// third topic segment of <c>spBv1.0/STATE/{hostId}</c> (or the second segment of the legacy
/// pre-3.0 <c>STATE/{hostId}</c> form). <see langword="null"/> for every other message type.
/// </param>
/// <remarks>
/// <para>
/// <b>Never throws on arbitrary input.</b> <see cref="TryParse"/> is the primitive everything
/// else is built on — it is fed every topic string the broker delivers under a
/// <c>spBv1.0/{groupId}/#</c> subscription, none of it validated ahead of time, so it returns
/// <see langword="false"/> for anything malformed rather than throwing. <see cref="Parse"/> is
/// a throwing convenience wrapper for call sites (tests, hand-built topics) that already know
/// the string is well-formed.
/// </para>
/// <para>
/// <b>STATE does not fit the <c>{group}/{type}/{node}</c> mould — handled honestly, not
/// force-fit.</b> The Sparkplug v3.0 spec shapes the primary-host state topic as
/// <c>spBv1.0/STATE/{hostId}</c>: the message-type segment sits where a group id would
/// otherwise be, and there is no edge-node or device segment at all. Pre-3.0 peers additionally
/// published a bare <c>STATE/{hostId}</c> with no <c>spBv1.0</c> namespace prefix. This parser
/// targets the v3.0 form and tolerates the legacy one on receive (design §3.1); <see cref="Format"/>
/// and <see cref="FormatState"/> only ever produce the v3.0 form. A parsed STATE topic carries
/// its host id in <see cref="HostId"/> and leaves <see cref="GroupId"/>/<see cref="EdgeNodeId"/>/
/// <see cref="DeviceId"/> <see langword="null"/> — every other message type is the mirror image
/// (<see cref="GroupId"/>/<see cref="EdgeNodeId"/> populated, <see cref="HostId"/> null).
/// </para>
/// <para>
/// Group/edge-node/device/host segments are treated as opaque identifiers: validated only for
/// non-emptiness and the absence of the MQTT wildcard characters <c>+</c>/<c>#</c> (a broker
/// never delivers a PUBLISH on a topic containing either, so a topic string that does is
/// malformed, not a legitimate id worth preserving). No further character-set restriction is
/// applied — Sparkplug does not constrain id charsets beyond "not a topic-level separator".
/// </para>
/// </remarks>
public sealed record SparkplugTopic(
SparkplugMessageType Type,
string? GroupId,
string? EdgeNodeId,
string? DeviceId,
string? HostId)
{
private const string Namespace = "spBv1.0";
/// <summary>
/// Attempts to parse <paramref name="topic"/> as a Sparkplug B topic. Never throws — returns
/// <see langword="false"/> (and a <see langword="null"/> <paramref name="result"/>) for
/// anything that is not a well-formed Sparkplug topic, including <see langword="null"/>/empty
/// input, wrong namespace, an unrecognised message-type segment, a device-scoped message
/// missing its device segment (or vice versa), or a segment containing an MQTT wildcard.
/// </summary>
public static bool TryParse(string? topic, [NotNullWhen(true)] out SparkplugTopic? result)
{
result = null;
if (string.IsNullOrEmpty(topic))
{
return false;
}
var segments = topic.Split('/');
// Legacy pre-3.0 STATE form: `STATE/{hostId}`, no `spBv1.0` namespace prefix.
if (segments.Length == 2 && segments[0] == "STATE")
{
return TryBuildState(segments[1], out result);
}
if (segments.Length < 2 || segments[0] != Namespace)
{
return false;
}
// v3.0 STATE form: `spBv1.0/STATE/{hostId}`.
if (segments[1] == "STATE")
{
return segments.Length == 3 && TryBuildState(segments[2], out result);
}
// Every other message type: `spBv1.0/{group}/{type}/{node}[/{device}]`.
if (segments.Length is not (4 or 5))
{
return false;
}
if (!TryParseMessageType(segments[2], out var type))
{
return false;
}
var groupId = segments[1];
var edgeNodeId = segments[3];
if (!IsValidSegment(groupId) || !IsValidSegment(edgeNodeId))
{
return false;
}
var expectsDevice = IsDeviceScoped(type);
var hasDeviceSegment = segments.Length == 5;
if (expectsDevice != hasDeviceSegment)
{
return false;
}
string? deviceId = null;
if (hasDeviceSegment)
{
deviceId = segments[4];
if (!IsValidSegment(deviceId))
{
return false;
}
}
result = new SparkplugTopic(type, groupId, edgeNodeId, deviceId, null);
return true;
}
/// <summary>
/// Parses <paramref name="topic"/>, throwing <see cref="FormatException"/> if it is not a
/// well-formed Sparkplug B topic. See <see cref="TryParse"/> for the non-throwing form.
/// </summary>
public static SparkplugTopic Parse(string? topic)
{
if (TryParse(topic, out var result))
{
return result;
}
throw new FormatException($"'{topic}' is not a valid Sparkplug B topic.");
}
/// <summary>
/// Builds a Sparkplug B topic string for a non-STATE message — e.g.
/// <c>Format("Plant1", SparkplugMessageType.NCMD, "EdgeA")</c> for the Task 20 write path, so
/// it does not have to hand-concatenate segments itself.
/// </summary>
/// <exception cref="ArgumentException">
/// <paramref name="groupId"/>/<paramref name="edgeNodeId"/> is null/empty, or
/// <paramref name="type"/> is <see cref="SparkplugMessageType.STATE"/> (use
/// <see cref="FormatState"/> instead — STATE has no group/edge-node/device segments).
/// </exception>
public static string Format(string groupId, SparkplugMessageType type, string edgeNodeId, string? deviceId = null)
{
ArgumentException.ThrowIfNullOrEmpty(groupId);
ArgumentException.ThrowIfNullOrEmpty(edgeNodeId);
if (type == SparkplugMessageType.STATE)
{
throw new ArgumentException("Use FormatState to build a STATE topic.", nameof(type));
}
return deviceId is null
? $"{Namespace}/{groupId}/{type}/{edgeNodeId}"
: $"{Namespace}/{groupId}/{type}/{edgeNodeId}/{deviceId}";
}
/// <summary>Builds the v3.0 STATE topic string <c>spBv1.0/STATE/{hostId}</c>.</summary>
public static string FormatState(string hostId)
{
ArgumentException.ThrowIfNullOrEmpty(hostId);
return $"{Namespace}/STATE/{hostId}";
}
/// <summary>
/// Formats this instance back to its topic string (always the v3.0 STATE form for STATE
/// topics, even if this instance was parsed from the legacy no-prefix form).
/// </summary>
/// <exception cref="InvalidOperationException">
/// A required field for this instance's <see cref="Type"/> is <see langword="null"/> — i.e.
/// this instance was not built by <see cref="TryParse"/>/<see cref="Parse"/> and violates the
/// invariants documented on the type.
/// </exception>
public string ToTopicString() => Type == SparkplugMessageType.STATE
? FormatState(HostId ?? throw new InvalidOperationException("STATE topic is missing HostId."))
: Format(
GroupId ?? throw new InvalidOperationException("Non-STATE topic is missing GroupId."),
Type,
EdgeNodeId ?? throw new InvalidOperationException("Non-STATE topic is missing EdgeNodeId."),
DeviceId);
private static bool TryBuildState(string hostId, out SparkplugTopic? result)
{
result = null;
if (!IsValidSegment(hostId))
{
return false;
}
result = new SparkplugTopic(SparkplugMessageType.STATE, null, null, null, hostId);
return true;
}
private static bool TryParseMessageType(string segment, out SparkplugMessageType type)
{
switch (segment)
{
case nameof(SparkplugMessageType.NBIRTH):
type = SparkplugMessageType.NBIRTH;
return true;
case nameof(SparkplugMessageType.DBIRTH):
type = SparkplugMessageType.DBIRTH;
return true;
case nameof(SparkplugMessageType.NDATA):
type = SparkplugMessageType.NDATA;
return true;
case nameof(SparkplugMessageType.DDATA):
type = SparkplugMessageType.DDATA;
return true;
case nameof(SparkplugMessageType.NDEATH):
type = SparkplugMessageType.NDEATH;
return true;
case nameof(SparkplugMessageType.DDEATH):
type = SparkplugMessageType.DDEATH;
return true;
case nameof(SparkplugMessageType.NCMD):
type = SparkplugMessageType.NCMD;
return true;
case nameof(SparkplugMessageType.DCMD):
type = SparkplugMessageType.DCMD;
return true;
default:
type = default;
return false;
}
}
private static bool IsDeviceScoped(SparkplugMessageType type) => type is
SparkplugMessageType.DBIRTH or SparkplugMessageType.DDATA or SparkplugMessageType.DDEATH or SparkplugMessageType.DCMD;
private static bool IsValidSegment(string segment) =>
segment.Length > 0 && segment.IndexOf('+') < 0 && segment.IndexOf('#') < 0;
}
@@ -0,0 +1,29 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<LangVersion>latest</LangVersion>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
<NoWarn>$(NoWarn);CS1591</NoWarn>
<RootNamespace>ZB.MOM.WW.OtOpcUa.Driver.Mqtt</RootNamespace>
<AssemblyName>ZB.MOM.WW.OtOpcUa.Driver.Mqtt</AssemblyName>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Contracts\ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Contracts.csproj"/>
<ProjectReference Include="..\..\Core\ZB.MOM.WW.OtOpcUa.Core.Abstractions\ZB.MOM.WW.OtOpcUa.Core.Abstractions.csproj"/>
<ProjectReference Include="..\..\Core\ZB.MOM.WW.OtOpcUa.Core\ZB.MOM.WW.OtOpcUa.Core.csproj"/>
</ItemGroup>
<ItemGroup>
<PackageReference Include="MQTTnet"/>
</ItemGroup>
<ItemGroup>
<InternalsVisibleTo Include="ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests"/>
</ItemGroup>
</Project>
@@ -1,5 +1,8 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Components.Authorization;
using Microsoft.Extensions.Logging;
using ZB.MOM.WW.OtOpcUa.Commons.Browsing;
using ZB.MOM.WW.OtOpcUa.Security.Auth;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Browsing;
@@ -10,11 +13,19 @@ namespace ZB.MOM.WW.OtOpcUa.AdminUI.Browsing;
/// expand/attributes call in a 20-second linked CTS so a stuck driver cannot
/// stall the UI indefinitely.
/// </summary>
/// <param name="browsers">The bespoke per-driver browsers.</param>
/// <param name="registry">The open-session registry.</param>
/// <param name="logger">The logger.</param>
/// <param name="universalBrowser">The Discover-backed fallback browser.</param>
/// <param name="authorizationService">Policy evaluator for <see cref="RequestRebirthAsync"/>.</param>
/// <param name="authenticationStateProvider">Supplies the calling circuit's user.</param>
public sealed class BrowserSessionService(
IEnumerable<IDriverBrowser> browsers,
BrowseSessionRegistry registry,
ILogger<BrowserSessionService> logger,
IUniversalDriverBrowser universalBrowser) : IBrowserSessionService
IUniversalDriverBrowser universalBrowser,
IAuthorizationService authorizationService,
AuthenticationStateProvider authenticationStateProvider) : IBrowserSessionService
{
/// <summary>Upper bound on a single root/expand/attributes call.</summary>
public static readonly TimeSpan PerCallTimeout = TimeSpan.FromSeconds(20);
@@ -75,6 +86,64 @@ public sealed class BrowserSessionService(
public Task<IReadOnlyList<AttributeInfo>> AttributesAsync(Guid token, string nodeId, CancellationToken ct) =>
InvokeAsync<IReadOnlyList<AttributeInfo>>(token, ct, (s, c) => s.AttributesAsync(nodeId, c));
/// <inheritdoc />
/// <remarks>
/// <para>
/// <b>The authorization check is the point of routing this through the service at all.</b>
/// Every other member here is read-only; this one causes the browse session to publish onto
/// a live plant broker, so it is gated on the same <c>DriverOperator</c> policy the picker
/// bodies evaluate before rendering their Browse affordance. A render-time check alone is
/// not a control — it decides what a page draws, not what a circuit may invoke — so the
/// check is made here, where the action actually happens, and it fails closed.
/// </para>
/// <para>
/// Deliberately <b>not</b> wrapped in <see cref="PerCallTimeout"/>-style swallowing: unlike
/// a failed expand, a failed or refused rebirth is something the operator must see, so the
/// exception propagates to the caller for display.
/// </para>
/// </remarks>
public async Task<int> RequestRebirthAsync(Guid token, string scope, CancellationToken ct)
{
if (!registry.TryGet(token, out var session))
throw new BrowseSessionNotFoundException(token);
if (session is not IRebirthCapableBrowseSession rebirthable)
{
throw new NotSupportedException(
$"Browse session {token} ({session.GetType().Name}) has no re-announce action.");
}
var state = await authenticationStateProvider.GetAuthenticationStateAsync().ConfigureAwait(false);
var authorized = await authorizationService
.AuthorizeAsync(state.User, resource: null, AdminUiPolicies.DriverOperator)
.ConfigureAwait(false);
if (!authorized.Succeeded)
{
logger.LogWarning(
"Rebirth request REFUSED for scope '{Scope}' on browse session {Token}: caller does not "
+ "satisfy the {Policy} policy.", scope, token, AdminUiPolicies.DriverOperator);
throw new UnauthorizedAccessException(
$"Requesting a rebirth requires the {AdminUiPolicies.DriverOperator} policy.");
}
using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
cts.CancelAfter(PerCallTimeout);
var published = await rebirthable.RequestRebirthAsync(scope, cts.Token).ConfigureAwait(false);
logger.LogInformation(
"Rebirth requested by {User} for scope '{Scope}' on browse session {Token}: {Published} "
+ "message(s) published.",
state.User.Identity?.Name ?? "(anonymous)", scope, token, published);
return published;
}
/// <inheritdoc />
public bool CanRequestRebirth(Guid token) =>
registry.TryGet(token, out var session) && session is IRebirthCapableBrowseSession { RebirthAvailable: true };
/// <inheritdoc />
public async Task CloseAsync(Guid token)
{
@@ -50,6 +50,42 @@ public interface IBrowserSessionService
/// <returns>The attributes of the node.</returns>
Task<IReadOnlyList<AttributeInfo>> AttributesAsync(Guid token, string nodeId, CancellationToken ct);
/// <summary>
/// Asks the remote peer(s) addressed by <paramref name="scope"/> to re-announce themselves, on
/// a session whose driver offers that action (today: MQTT in Sparkplug B mode, where it is a
/// Sparkplug rebirth-request NCMD).
/// </summary>
/// <param name="token">Registry handle for the open browse session.</param>
/// <param name="scope">The driver-specific target — for Sparkplug, a browse node id from the
/// session's own tree, or a bare <c>{group}/{edgeNode}</c>.</param>
/// <param name="ct">Cancellation token for the operation.</param>
/// <returns>The number of request messages published.</returns>
/// <remarks>
/// <b>The only browse operation that writes to the device network</b>, and therefore the only
/// one carrying an authorization check: the caller must satisfy the same
/// <c>DriverOperator</c> policy that gates the picker's Browse affordance. Everything else on
/// this facade is read-only.
/// </remarks>
/// <exception cref="BrowseSessionNotFoundException">The token is unknown (or was reaped).</exception>
/// <exception cref="UnauthorizedAccessException">The caller is not a DriverOperator.</exception>
/// <exception cref="NotSupportedException">This session's driver has no re-announce action.</exception>
Task<int> RequestRebirthAsync(Guid token, string scope, CancellationToken ct);
/// <summary>
/// True when the open session behind <paramref name="token"/> can actually re-announce — i.e.
/// <see cref="RequestRebirthAsync"/> is worth offering. Cheap (no I/O); never throws; false for
/// an unknown/reaped token.
/// </summary>
/// <param name="token">Registry handle for the open browse session.</param>
/// <returns>True when the picker should draw a Request-rebirth affordance.</returns>
/// <remarks>
/// A capability probe, <b>not</b> an authorization check — the DriverOperator gate lives on
/// <see cref="RequestRebirthAsync"/> and fails closed there. The picker gates on both: this
/// decides whether the action exists at all (a plain-MQTT window has none), the policy decides
/// whether this operator may fire it.
/// </remarks>
bool CanRequestRebirth(Guid token);
/// <summary>Removes the session from the registry and disposes it. No-op for unknown tokens.</summary>
/// <param name="token">Registry handle for the browse session to close.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
@@ -0,0 +1,61 @@
@* MQTT device form — informational only. An MQTT driver instance holds exactly one broker session,
configured on the DRIVER (MqttDriverForm), so there is no per-device connection endpoint to author;
devices are pure organisational grouping in the RawPath. The DeviceConfig is round-tripped verbatim,
and Test-connect uses the driver's broker settings via the merged config. Same shape as
GalaxyDeviceForm.
The warning below fires only for a pre-form deployment that authored the connection on the device:
DriverDeviceConfigMerger merges a SOLE device's keys up over the driver's, so those keys keep
winning over what the driver form shows. *@
@using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers.DeviceForms
<section class="panel notice rise" style="animation-delay:.04s">
<div class="panel-head">Connection</div>
<div style="padding:1rem" class="text-muted">
MQTT connects to a single broker configured on the <strong>driver</strong> — there is no per-device
endpoint to author here. Edit host / port / TLS / credentials on the driver's config. Devices under an
MQTT driver exist purely to group tags in the RawPath.
</div>
</section>
@if (_model.LegacyConnectionKeys.Count > 0)
{
<section class="panel rise mt-3" style="animation-delay:.06s;border-color:var(--alert)">
<div class="panel-head">Legacy connection keys on this device</div>
<div style="padding:1rem">
<p class="mb-2 small">
This device's config still carries broker connection keys from before the driver form existed:
<span class="mono">@string.Join(", ", _model.LegacyConnectionKeys)</span>.
</p>
<p class="mb-0 small">
While this driver has <strong>exactly one</strong> device these keys are merged up and
<strong>override</strong> the driver form's values — so the driver form may not show what is
actually in effect. They are preserved (nothing is dropped), but the durable fix is to author the
connection on the driver and clear these keys: a second device on this driver stops the merge-up
entirely and the connection would vanish.
</p>
</div>
</section>
}
@code {
/// <summary>The device's DeviceConfig JSON (round-tripped verbatim — MQTT has no per-device endpoint).</summary>
[Parameter] public string DeviceConfigJson { get; set; } = "{}";
/// <summary>Fired when the (preserved) DeviceConfig changes — no-op UI, kept for the modal contract.</summary>
[Parameter] public EventCallback<string> DeviceConfigJsonChanged { get; set; }
private MqttDeviceModel _model = MqttDeviceModel.FromJson(null);
private string? _lastParsed;
protected override void OnParametersSet()
{
if (!string.Equals(_lastParsed, DeviceConfigJson, StringComparison.Ordinal))
{
_model = MqttDeviceModel.FromJson(DeviceConfigJson);
_lastParsed = DeviceConfigJson;
}
}
/// <summary>Serialises the (preserved) DeviceConfig JSON.</summary>
public string GetConfigJson() => _model.ToJson();
}
@@ -0,0 +1,73 @@
using ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers.DeviceForms;
/// <summary>
/// Device model for the MQTT / Sparkplug B driver. An MQTT driver instance holds exactly <b>one</b>
/// broker session, configured on the <b>driver</b> (<c>MqttDriverForm</c>) — so, like Galaxy, there
/// is no per-device connection endpoint to author and this model round-trips the <c>DeviceConfig</c>
/// JSON verbatim. Devices under an MQTT driver are pure organisational grouping in the RawPath.
/// </summary>
/// <remarks>
/// <para>
/// <b>Why the connection is not authored here.</b> <c>DriverDeviceConfigMerger</c> merges a device's
/// keys up to the top level <i>only when the driver has exactly one device</i>. A broker connection
/// authored on the device would therefore disappear from the merged blob the moment an operator adds
/// a second device — a silent, deploy-time-invisible outage. Driver-level authoring is correct for
/// 0, 1 or N devices.
/// </para>
/// <para>
/// <b><see cref="LegacyConnectionKeys"/> exists because that merge-up still happens.</b> A
/// pre-form deployment (hand-authored or SQL-seeded, as the P1 live gate had to do) may carry
/// <c>Host</c>/<c>Port</c>/… on a sole device's <c>DeviceConfig</c>, and those keys keep winning over
/// the driver form's values. They are preserved (never silently dropped) and reported so the device
/// form can tell the operator why the driver form's host is not the one in effect.
/// </para>
/// </remarks>
public sealed class MqttDeviceModel
{
/// <summary>
/// <c>MqttDriverOptions</c> connection-surface key names that a legacy <c>DeviceConfig</c> may
/// carry and that would merge up over the driver form's values.
/// </summary>
private static readonly string[] ConnectionKeyNames =
[
"Host", "Port", "ClientId", "UseTls", "AllowUntrustedServerCertificate",
"CaCertificatePath", "Username", "Password", "ProtocolVersion", "CleanSession",
"KeepAliveSeconds", "ConnectTimeoutSeconds", "ReconnectMinBackoffSeconds",
"ReconnectMaxBackoffSeconds",
];
private System.Text.Json.Nodes.JsonObject _bag = new();
/// <summary>
/// The connection keys this <c>DeviceConfig</c> actually carries, in the casing they were
/// authored with — empty for the normal case. Non-empty means a sole-device merge-up will
/// override the driver form for those keys.
/// </summary>
public IReadOnlyList<string> LegacyConnectionKeys { get; private set; } = [];
/// <summary>Loads a model from a <c>DeviceConfig</c> JSON string, retaining every original key.</summary>
/// <param name="json">The raw <c>DeviceConfig</c> JSON string, or <c>null</c> for a new/empty device.</param>
/// <returns>The populated <see cref="MqttDeviceModel"/>.</returns>
public static MqttDeviceModel FromJson(string? json)
{
var bag = TagConfigJson.ParseOrNew(json);
return new MqttDeviceModel
{
_bag = bag,
LegacyConnectionKeys = bag
.Select(p => p.Key)
.Where(k => ConnectionKeyNames.Contains(k, StringComparer.OrdinalIgnoreCase))
.ToList(),
};
}
/// <summary>Serialises the (preserved) <c>DeviceConfig</c> back to a JSON string.</summary>
/// <returns>The serialised <c>DeviceConfig</c> JSON string.</returns>
public string ToJson() => TagConfigJson.Serialize(_bag);
/// <summary>Validation hook; MQTT device rows carry no endpoint, so always valid.</summary>
/// <returns><c>null</c> — no per-device endpoint to validate.</returns>
public string? Validate() => null;
}
@@ -65,6 +65,9 @@
case DriverTypeNames.Galaxy:
<GalaxyDeviceForm @bind-DeviceConfigJson="_deviceConfigJson" />
break;
case DriverTypeNames.Mqtt:
<MqttDeviceForm @bind-DeviceConfigJson="_deviceConfigJson" />
break;
default:
<div class="alert alert-warning">No typed device form for driver type <span class="mono">@_driverType</span>.</div>
break;
@@ -34,6 +34,12 @@
/// <summary>Fired when the user clicks a leaf (or any node — caller decides what to do with it).</summary>
[Parameter] public EventCallback<BrowseNode> OnNodeSelected { get; set; }
/// <summary>The same click as <see cref="OnNodeSelected"/>, plus the node's captured ancestor-folder
/// path (root→parent) — which is how a caller learns a node's DEPTH, and therefore what kind of thing
/// it is in a driver whose tree levels mean different things (MQTT/Sparkplug: group / edge node /
/// device). Both callbacks fire; wiring only the first is the existing behaviour.</summary>
[Parameter] public EventCallback<ZB.MOM.WW.OtOpcUa.AdminUI.Browsing.BrowseLeafSelection> OnNodeSelectedWithPath { get; set; }
/// <summary>When true, leaves render a selection checkbox and clicking a leaf toggles it (WP6 "Browse
/// device…" multi-select) instead of single-selecting. Folders still expand/navigate as usual. Default
/// false preserves the single-select address-picker behavior.</summary>
@@ -93,10 +99,12 @@
finally { item.Loading = false; StateHasChanged(); }
}
private async Task SelectAsync(TreeItem item)
private async Task SelectAsync(TreeItem item, IReadOnlyList<string> ancestorPath)
{
_selectedNodeIdLocal = item.Node.NodeId;
await OnNodeSelected.InvokeAsync(item.Node);
await OnNodeSelectedWithPath.InvokeAsync(
new ZB.MOM.WW.OtOpcUa.AdminUI.Browsing.BrowseLeafSelection(item.Node, ancestorPath));
}
private async Task ToggleLeafAsync(TreeItem item, IReadOnlyList<string> ancestorPath)
@@ -133,17 +141,27 @@
{
<span style="width:18px"></span>
}
@* BUTTONS, NOT <a href="#">. This is a Blazor Web App: blazor.web.js installs a
document-level click interceptor for enhanced navigation, and it resolves a bare "#"
against <base href="/"> rather than the current URL — so clicking a node label
navigated the whole AdminUI to "/", tore down the circuit, and destroyed whatever modal
the tree was hosted in (losing the browse session AND the tag selection with it).
@onclick:preventDefault does not help: it suppresses the BROWSER's default action, not
Blazor's own interceptor. A <button> is never a navigation candidate, so the handler is
the only thing that runs. Found by the MQTT/Sparkplug P2 live gate — the Request-rebirth
scope is chosen by clicking a FOLDER label, which is the first feature that ever
required clicking a label rather than the ▶ toggle (a button, which always worked). *@
@if (isMultiLeaf)
{
<input type="checkbox" class="form-check-input" checked="@IsLeafSelected(item.Node.NodeId)"
@onchange="@(() => ToggleLeafAsync(item, ancestorPath))" />
<a href="#" @onclick="@(() => ToggleLeafAsync(item, ancestorPath))" @onclick:preventDefault
class="text-decoration-none mono small">@item.Node.DisplayName</a>
<button type="button" @onclick="@(() => ToggleLeafAsync(item, ancestorPath))"
class="btn btn-link btn-sm p-0 text-decoration-none mono small text-start">@item.Node.DisplayName</button>
}
else
{
<a href="#" @onclick="@(() => SelectAsync(item))" @onclick:preventDefault
class="text-decoration-none mono small">@item.Node.DisplayName</a>
<button type="button" @onclick="@(() => SelectAsync(item, ancestorPath))"
class="btn btn-link btn-sm p-0 text-decoration-none mono small text-start">@item.Node.DisplayName</button>
}
@if (item.Node.Kind == BrowseNodeKind.Leaf)
{
@@ -60,14 +60,30 @@
case DriverTypeNames.Sql:
<SqlDriverForm @bind-DriverConfigJson="_driverConfigJson" @bind-ResilienceConfig="_resilienceConfig" />
break;
case DriverTypeNames.Mqtt:
<MqttDriverForm @bind-DriverConfigJson="_driverConfigJson" @bind-ResilienceConfig="_resilienceConfig" />
break;
default:
<div class="alert alert-warning">No typed config form for driver type <span class="mono">@_driverType</span>.</div>
break;
}
<p class="form-text mt-3 mb-0">
The connection endpoint + Test-connect live on the driver's <strong>device</strong> — open the device modal to author host/port and verify connectivity.
</p>
@* Galaxy + MQTT hold ONE connection per driver instance, so they author it here and
their device forms are informational. Every other driver splits the endpoint onto
the device (the v3 endpoint→DeviceConfig split). *@
@if (_driverType is DriverTypeNames.Galaxy or DriverTypeNames.Mqtt)
{
<p class="form-text mt-3 mb-0">
This driver holds a single connection, authored above. Test-connect lives on its
<strong>device</strong> — open the device modal to verify connectivity.
</p>
}
else
{
<p class="form-text mt-3 mb-0">
The connection endpoint + Test-connect live on the driver's <strong>device</strong> — open the device modal to author host/port and verify connectivity.
</p>
}
@if (_saveError is not null)
{
@@ -0,0 +1,331 @@
@* Embeddable MQTT / Sparkplug B driver config form body. Hosted by DriverConfigModal.
UNLIKE Modbus / S7 / OPC UA Client, the connection is authored HERE, not on the device: an MQTT
driver instance holds exactly ONE broker session, so MqttDeviceForm is informational (the Galaxy
precedent). DriverDeviceConfigMerger only merges a device's keys up when the driver has exactly one
device, so a device-authored broker connection would silently vanish the moment a second device
exists. All parsing/serialisation goes through the ONE shared MqttJson.Options instance, via the
pure MqttDriverFormModel — enums must round-trip by NAME (this repo's systemic driver-enum bug).
The Sparkplug sub-object (groupId / hostId / actAsPrimaryHost / requestRebirthOnGap /
birthObservationWindowSeconds) is authored by the Mode == SparkplugB branch below. It is MERGED over
whatever the inbound blob had rather than replacing it, and in Plain mode it is not touched at all —
see MqttDriverFormModel.ToJson. Until the P2 live gate this branch was a "not implemented yet"
placeholder, which left the group id — the driver's entire subscription filter — unauthorable from
the UI after Sparkplug ingest had shipped. *@
@using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers
@using ZB.MOM.WW.OtOpcUa.Driver.Mqtt
@* Broker connection *@
<section class="panel rise mt-3" style="animation-delay:.06s">
<div class="panel-head">Broker connection</div>
<div style="padding:1rem">
<div class="row g-3">
<div class="col-md-5">
<label class="form-label" for="mqtt-host">Host</label>
<InputText id="mqtt-host" @bind-Value="_form.Host" @bind-Value:after="EmitAsync" class="form-control form-control-sm mono" placeholder="broker.internal" />
<div class="form-text">Broker hostname or IP. The connection lives on the driver — MQTT has one broker session per driver.</div>
</div>
<div class="col-md-3">
<label class="form-label" for="mqtt-port">Port</label>
<InputNumber id="mqtt-port" @bind-Value="_form.Port" @bind-Value:after="EmitAsync" class="form-control form-control-sm" />
<div class="form-text">8883 for TLS, 1883 for plaintext.</div>
</div>
<div class="col-md-4">
<label class="form-label" for="mqtt-client-id">Client ID <span class="badge bg-secondary">leave blank</span></label>
<InputText id="mqtt-client-id" @bind-Value="_form.ClientId" @bind-Value:after="EmitAsync" class="form-control form-control-sm mono" placeholder="(auto-generated — recommended)" />
<div class="form-text">Blank lets the client generate a unique id per connection. That is the correct setting.</div>
</div>
@if (!string.IsNullOrWhiteSpace(_form.ClientId))
{
<div class="col-12">
<div class="alert alert-warning py-2 px-3 mb-0 small" role="alert">
<strong>Fixed client ID set.</strong> @MqttDriverFormModel.ClientIdPairWarning
</div>
</div>
}
</div>
</div>
</section>
@* Transport security *@
<section class="panel rise mt-3" style="animation-delay:.08s">
<div class="panel-head">Transport security</div>
<div style="padding:1rem">
<div class="row g-3">
<div class="col-md-3">
<div class="form-check form-switch mt-4">
<InputCheckbox @bind-Value="_form.UseTls" @bind-Value:after="EmitAsync" class="form-check-input" id="mqtt-use-tls" />
<label class="form-check-label" for="mqtt-use-tls">Use TLS</label>
</div>
<div class="form-text mt-0">Default on. Must match the broker's listener.</div>
</div>
<div class="col-md-4">
<div class="form-check form-switch mt-4">
<InputCheckbox @bind-Value="_form.AllowUntrustedServerCertificate" @bind-Value:after="EmitAsync" class="form-check-input" id="mqtt-allow-untrusted" />
<label class="form-check-label" for="mqtt-allow-untrusted">
Allow untrusted server certificate <span class="badge bg-warning text-dark">Dev / on-prem only</span>
</label>
</div>
<div class="form-text mt-0">Accepts any self-signed broker cert.</div>
</div>
<div class="col-md-5">
<label class="form-label" for="mqtt-ca-path">CA certificate path (optional)</label>
<InputText id="mqtt-ca-path" @bind-Value="_form.CaCertificatePath" @bind-Value:after="EmitAsync" class="form-control form-control-sm mono" placeholder="/etc/ssl/certs/broker-ca.pem" />
<div class="form-text">PEM file pinning the broker's chain. Blank uses the OS trust store.</div>
</div>
@if (_form.UseTls && _form.AllowUntrustedServerCertificate)
{
<div class="col-12">
<div class="alert alert-warning py-2 px-3 mb-0 small" role="alert">
<strong>Not safe for production.</strong> Accepting an untrusted broker certificate defeats
TLS server authentication — a machine-in-the-middle on the broker connection will succeed.
Pin the broker's chain with a CA certificate path instead.
</div>
</div>
}
@if (!_form.UseTls)
{
<div class="col-12">
<div class="alert alert-warning py-2 px-3 mb-0 small" role="alert">
<strong>TLS is off.</strong> The broker credentials below are sent in cleartext. Use a
plaintext listener only on a trusted on-prem segment.
</div>
</div>
}
</div>
</div>
</section>
@* Authentication *@
<section class="panel rise mt-3" style="animation-delay:.10s">
<div class="panel-head">Authentication</div>
<div style="padding:1rem">
<div class="row g-3">
<div class="col-md-4">
<label class="form-label" for="mqtt-username">Username</label>
<InputText id="mqtt-username" @bind-Value="_form.Username" @bind-Value:after="EmitAsync" class="form-control form-control-sm" autocomplete="off" />
<div class="form-text">Blank connects without credentials.</div>
</div>
<div class="col-md-4">
<label class="form-label" for="mqtt-password">Password</label>
<InputText id="mqtt-password" @bind-Value="_form.Password" @bind-Value:after="EmitAsync" type="password" class="form-control form-control-sm" autocomplete="new-password" />
<div class="form-text">Stored in the driver config and carried in the deployment artifact. Never written to a log.</div>
</div>
</div>
</div>
</section>
@* Session *@
<section class="panel rise mt-3" style="animation-delay:.12s">
<div class="panel-head">Session</div>
<div style="padding:1rem">
<div class="row g-3">
<div class="col-md-3">
<label class="form-label" for="mqtt-protocol-version">Protocol version</label>
<InputSelect id="mqtt-protocol-version" @bind-Value="_form.ProtocolVersion" @bind-Value:after="EmitAsync" class="form-select form-select-sm">
@foreach (var v in Enum.GetValues<MqttProtocolVersion>())
{
<option value="@v">@v</option>
}
</InputSelect>
<div class="form-text">Default V500 (MQTT 5.0).</div>
</div>
<div class="col-md-3">
<div class="form-check form-switch mt-4">
<InputCheckbox @bind-Value="_form.CleanSession" @bind-Value:after="EmitAsync" class="form-check-input" id="mqtt-clean-session" />
<label class="form-check-label" for="mqtt-clean-session">Clean session / clean start</label>
</div>
</div>
<div class="col-md-3">
<label class="form-label" for="mqtt-keepalive">Keep-alive (seconds)</label>
<InputNumber id="mqtt-keepalive" @bind-Value="_form.KeepAliveSeconds" @bind-Value:after="EmitAsync" class="form-control form-control-sm" />
<div class="form-text">Default 30 s.</div>
</div>
<div class="col-md-3">
<label class="form-label" for="mqtt-connect-timeout">Connect timeout (seconds)</label>
<InputNumber id="mqtt-connect-timeout" @bind-Value="_form.ConnectTimeoutSeconds" @bind-Value:after="EmitAsync" class="form-control form-control-sm" />
<div class="form-text">Default 15 s. Must be at least 1 — also bounds Test connect.</div>
</div>
<div class="col-md-3">
<label class="form-label" for="mqtt-backoff-min">Reconnect min backoff (seconds)</label>
<InputNumber id="mqtt-backoff-min" @bind-Value="_form.ReconnectMinBackoffSeconds" @bind-Value:after="EmitAsync" class="form-control form-control-sm" />
<div class="form-text">Default 1 s.</div>
</div>
<div class="col-md-3">
<label class="form-label" for="mqtt-backoff-max">Reconnect max backoff (seconds)</label>
<InputNumber id="mqtt-backoff-max" @bind-Value="_form.ReconnectMaxBackoffSeconds" @bind-Value:after="EmitAsync" class="form-control form-control-sm" />
<div class="form-text">Default 30 s. Caps the exponential backoff.</div>
</div>
</div>
</div>
</section>
@* Ingest *@
<section class="panel rise mt-3" style="animation-delay:.14s">
<div class="panel-head">Ingest</div>
<div style="padding:1rem">
<div class="row g-3">
<div class="col-md-3">
<label class="form-label" for="mqtt-mode">Mode</label>
<InputSelect id="mqtt-mode" @bind-Value="_form.Mode" @bind-Value:after="EmitAsync" class="form-select form-select-sm">
@foreach (var v in Enum.GetValues<MqttMode>())
{
<option value="@v">@v</option>
}
</InputSelect>
<div class="form-text">Plain = topic-bound tags. Sparkplug B = birth-described metrics under one group.</div>
</div>
<div class="col-md-3">
<label class="form-label" for="mqtt-max-payload">Max payload bytes</label>
<InputNumber id="mqtt-max-payload" @bind-Value="_form.MaxPayloadBytes" @bind-Value:after="EmitAsync" class="form-control form-control-sm" />
<div class="form-text">Default 1048576 (1 MiB). A larger message is refused before decode.</div>
</div>
@if (_form.Mode == MqttMode.Plain)
{
<div class="col-md-3">
<label class="form-label" for="mqtt-topic-prefix">Topic prefix (optional)</label>
<InputText id="mqtt-topic-prefix" @bind-Value="_form.TopicPrefix" @bind-Value:after="EmitAsync" class="form-control form-control-sm mono" placeholder="otopcua/fixture/" />
<div class="form-text">Scopes browse/discovery. Per-tag topics are authored explicitly and unaffected.</div>
</div>
<div class="col-md-3">
<label class="form-label" for="mqtt-default-qos">Default QoS</label>
@* A plain select, not InputSelect: InputSelect<T> natively parses string + enum only,
and QoS is an int. Same control shape MqttTagConfigEditor uses for per-tag QoS. *@
<select id="mqtt-default-qos" class="form-select form-select-sm" value="@_form.DefaultQos"
@onchange="@(e => OnDefaultQosChangedAsync(e.Value))">
<option value="0" selected="@(_form.DefaultQos == 0)">0 — at most once</option>
<option value="1" selected="@(_form.DefaultQos == 1)">1 — at least once</option>
<option value="2" selected="@(_form.DefaultQos == 2)">2 — exactly once</option>
</select>
<div class="form-text">Applied when a tag's own QoS is unset.</div>
</div>
}
else
{
@* Sparkplug B. The group id is the ONE mandatory field: it is the driver's entire
subscription filter (spBv1.0/{GroupId}/#), so a blank one leaves the driver connected,
Healthy, and ingesting nothing. Validate() surfaces that inline; like every other knob
on this form it is ADVISORY — DriverConfigModal saves regardless — which is why the
model clamps on serialize instead of relying on the operator reading the notice. *@
<div class="col-md-3">
<label class="form-label" for="mqtt-spb-group">Group ID</label>
<InputText id="mqtt-spb-group" @bind-Value="_form.SparkplugGroupId" @bind-Value:after="EmitAsync" class="form-control form-control-sm mono" placeholder="Plant1" />
<div class="form-text">
Subscribes <code class="mono">spBv1.0/@(string.IsNullOrWhiteSpace(_form.SparkplugGroupId) ? "{GroupId}" : _form.SparkplugGroupId)/#</code>.
No <code>/</code>, <code>+</code>, or <code>#</code>.
</div>
</div>
<div class="col-md-3">
<label class="form-label" for="mqtt-spb-window">Birth observation window (seconds)</label>
<InputNumber id="mqtt-spb-window" @bind-Value="_form.SparkplugBirthObservationWindowSeconds" @bind-Value:after="EmitAsync" class="form-control form-control-sm" />
<div class="form-text">Default 15 s. How long browse/discovery collects births before calling the metric set stable.</div>
</div>
<div class="col-md-3">
<label class="form-label" for="mqtt-spb-host">Host ID (optional)</label>
<InputText id="mqtt-spb-host" @bind-Value="_form.SparkplugHostId" @bind-Value:after="EmitAsync" class="form-control form-control-sm mono" placeholder="scada-primary" />
<div class="form-text">Sparkplug Host Application identity. Receive-only in this version.</div>
</div>
<div class="col-md-3 d-flex flex-column justify-content-center">
<div class="form-check form-switch">
<InputCheckbox id="mqtt-spb-rebirth" @bind-Value="_form.SparkplugRequestRebirthOnGap" @bind-Value:after="EmitAsync" class="form-check-input" />
<label class="form-check-label" for="mqtt-spb-rebirth">Request rebirth on sequence gap</label>
</div>
<div class="form-text mb-2">A detected seq gap asks the edge node to re-announce (NCMD), rather than running on stale metric state.</div>
<div class="form-check form-switch">
<InputCheckbox id="mqtt-spb-primary" @bind-Value="_form.SparkplugActAsPrimaryHost" @bind-Value:after="EmitAsync" class="form-check-input" />
<label class="form-check-label" for="mqtt-spb-primary">
Act as Primary Host
<span class="badge text-bg-warning">not implemented</span>
</label>
</div>
</div>
@if (_form.SparkplugActAsPrimaryHost)
{
@* Shown rather than the flag being hidden: an operator who needs a primary host must
learn it is absent HERE, not from a plant that never sees a STATE message. The
driver logs the same warning at startup so the two cannot drift. *@
<div class="col-12">
<div class="alert alert-warning py-2 px-3 mb-0 small" role="alert">
<strong>Primary-host STATE publishing is not implemented.</strong> This driver is
receive-only: it will not publish <code class="mono">spBv1.0/STATE/@(string.IsNullOrWhiteSpace(_form.SparkplugHostId) ? "{HostId}" : _form.SparkplugHostId)</code>,
so edge nodes will not treat it as their primary host. The driver logs a warning at
startup rather than being silently inert. Leave this off unless you are staging
configuration ahead of that feature.
</div>
</div>
}
<div class="col-12">
<div class="alert alert-info py-2 px-3 mb-0 small" role="alert">
Sparkplug tags bind by the <code class="mono">group / edge node / device / metric</code>
tuple, not by topic — author them with <strong>Browse device…</strong> on a device under
this driver, which builds its tree from observed birth certificates.
<strong>MQTT tags are read-only</strong> (write-through is not implemented).
</div>
</div>
}
</div>
</div>
</section>
@if (_validationError is not null)
{
<div class="panel notice mt-3" style="border-color:var(--alert)">@_validationError</div>
}
<DriverResilienceSection ResilienceConfig="@ResilienceConfig" ResilienceConfigChanged="OnResilienceChanged" />
@code {
/// <summary>The driver-level DriverConfig JSON (broker connection + ingest mode).</summary>
[Parameter] public string DriverConfigJson { get; set; } = "{}";
/// <summary>Fired whenever a field changes — enables @bind-DriverConfigJson.</summary>
[Parameter] public EventCallback<string> DriverConfigJsonChanged { get; set; }
/// <summary>The per-instance resilience-pipeline overrides JSON, or null.</summary>
[Parameter] public string? ResilienceConfig { get; set; }
/// <summary>Fired when the resilience overrides change.</summary>
[Parameter] public EventCallback<string?> ResilienceConfigChanged { get; set; }
private MqttDriverFormModel _form = MqttDriverFormModel.FromJson(null);
private string? _lastParsed;
private string? _validationError;
protected override void OnParametersSet()
{
// Re-parse only when the inbound value actually changed, so an unrelated parent re-render
// cannot clobber an in-progress edit.
if (!string.Equals(_lastParsed, DriverConfigJson, StringComparison.Ordinal))
{
_form = MqttDriverFormModel.FromJson(DriverConfigJson);
_validationError = _form.Validate();
_lastParsed = DriverConfigJson;
}
}
/// <summary>Serialises the current config to DriverConfig JSON (PascalCase keys, enums as names).</summary>
public string GetConfigJson() => _form.ToJson();
// TryParse so a bad/empty change value can never throw into the Blazor circuit — it falls back to
// the driver's own default QoS rather than poisoning the config.
private Task OnDefaultQosChangedAsync(object? value)
{
_form.DefaultQos = int.TryParse(value?.ToString(), out var qos) ? qos : 1;
return EmitAsync();
}
private async Task EmitAsync()
{
_validationError = _form.Validate();
var json = GetConfigJson();
_lastParsed = json;
await DriverConfigJsonChanged.InvokeAsync(json);
}
private async Task OnResilienceChanged(string? r)
{
ResilienceConfig = r;
await ResilienceConfigChanged.InvokeAsync(r);
}
}
@@ -0,0 +1,430 @@
using System.Text.Json;
using System.Text.Json.Nodes;
using ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors;
using ZB.MOM.WW.OtOpcUa.Driver.Mqtt;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers.Forms;
/// <summary>
/// Typed working model behind <c>MqttDriverForm</c> — the MQTT driver's whole broker-connection and
/// ingest surface, as authored in the <c>/raw</c> driver-config modal. Kept as a plain class (not
/// nested in the razor) so every rule here is unit-testable; the razor is a thin shell over it.
/// </summary>
/// <remarks>
/// <para>
/// <b>The connection lives on the DRIVER, not the device.</b> MQTT holds exactly one broker session
/// per driver instance, so <c>MqttDeviceForm</c> is informational (the Galaxy precedent) and this
/// form owns host/port/TLS/credentials. Authoring them on the device would work only while the
/// driver has exactly ONE device — <c>DriverDeviceConfigMerger</c> merges a sole device's keys up to
/// the top level and stops doing so the moment a second device exists, at which point the broker
/// connection would silently vanish from the merged blob.
/// </para>
/// <para>
/// <b>One JSON instance, always <see cref="MqttJson.Options"/>.</b> Every MQTT config seam — the
/// runtime factory, the Test-connect probe, the driver's re-parse, the address-picker browser and
/// now this form — parses and serialises through that single shared instance. Divergent per-seam
/// options are this repo's documented systemic driver-enum bug: a form that wrote
/// <c>"mode": 1</c> would be accepted by a seam carrying a <c>JsonStringEnumConverter</c> and fault
/// the one that does not, so "Test connect" goes green and the deployed driver dies. Consequently
/// the emitted keys are <b>PascalCase</b> (the shared instance carries no camelCase naming policy)
/// and enum values are <b>names</b>.
/// </para>
/// <para>
/// <b>Preserving what this form does not author.</b> The inbound blob's keys are retained in a bag
/// and the typed fields are written over it, so (a) the <c>Sparkplug</c> sub-object — P2, Task 21+ —
/// survives a load→save untouched, and (b) any key a newer driver adds is not silently dropped by an
/// older AdminUI. <c>RawTags</c> is the one key deliberately REMOVED: the deploy artifact injects it
/// via <c>DriverDeviceConfigMerger</c>, and a form-authored copy would be dead weight.
/// </para>
/// </remarks>
public sealed class MqttDriverFormModel
{
/// <summary>
/// The <see cref="MqttDriverOptions"/> property name the deploy artifact owns; never authored
/// here and stripped from the emitted blob.
/// </summary>
private const string RawTagsKey = nameof(MqttDriverOptions.RawTags);
/// <summary>
/// The <see cref="MqttDriverOptions"/> property name for the Sparkplug sub-object. Authored here
/// in <see cref="MqttMode.SparkplugB"/> mode and otherwise preserved verbatim from the inbound
/// blob — see <see cref="ToJson"/> for why the mode decides which of the two happens.
/// </summary>
private const string SparkplugKey = nameof(MqttDriverOptions.Sparkplug);
/// <summary>
/// Characters that cannot appear in a Sparkplug group id. It is a literal MQTT topic segment
/// inside the driver's one subscription filter <c>spBv1.0/{GroupId}/#</c>, so a <c>/</c> would
/// silently widen the filter and a <c>+</c>/<c>#</c> is not even legal mid-segment. Mirrors
/// <c>MqttTagConfigModel</c>'s identical rule on the tag side, so the driver and the tags bound
/// to it cannot disagree about what a group id may be.
/// </summary>
private static readonly char[] SparkplugGroupIdIllegalChars = ['/', '+', '#'];
// --- Broker connection ----------------------------------------------------------------------
/// <summary>Broker hostname or IP address.</summary>
public string Host { get; set; } = "localhost";
/// <summary>Broker TCP port (8883 TLS / 1883 plaintext by convention).</summary>
public int Port { get; set; } = 8883;
/// <summary>
/// MQTT client identifier sent at CONNECT. <b>Blank is the correct default</b> — see
/// <see cref="ClientIdPairWarning"/>.
/// </summary>
public string ClientId { get; set; } = "";
// --- Transport security ---------------------------------------------------------------------
/// <summary>Connect over TLS. Defaults <c>true</c>; this driver ships no plaintext-by-default posture.</summary>
public bool UseTls { get; set; } = true;
/// <summary>Accept any self-signed / untrusted broker certificate. Dev / on-prem escape hatch only.</summary>
public bool AllowUntrustedServerCertificate { get; set; }
/// <summary>PEM CA file pinning the broker's TLS chain; blank uses the OS trust store.</summary>
public string CaCertificatePath { get; set; } = "";
// --- Authentication -------------------------------------------------------------------------
/// <summary>Username for broker authentication; blank connects without one.</summary>
public string Username { get; set; } = "";
/// <summary>
/// Password for broker authentication. Stored in the driver's config blob and carried in the
/// deployment artifact — the same plaintext-round-trip posture the OPC UA Client driver form
/// uses. Never logged: <see cref="MqttDriverOptions"/> redacts it in its own member printer.
/// </summary>
public string Password { get; set; } = "";
// --- Session --------------------------------------------------------------------------------
/// <summary>MQTT protocol version negotiated at CONNECT.</summary>
public MqttProtocolVersion ProtocolVersion { get; set; } = MqttProtocolVersion.V500;
/// <summary>Request a clean session (v3.1.1) / clean start (v5.0) at CONNECT.</summary>
public bool CleanSession { get; set; } = true;
/// <summary>Keep-alive interval, in seconds, sent at CONNECT.</summary>
public int KeepAliveSeconds { get; set; } = 30;
/// <summary>Bounded connect deadline, in seconds — the driver never hangs past this.</summary>
public int ConnectTimeoutSeconds { get; set; } = 15;
/// <summary>Initial reconnect backoff, in seconds, after a connection drop.</summary>
public int ReconnectMinBackoffSeconds { get; set; } = 1;
/// <summary>Cap on the exponential reconnect backoff, in seconds.</summary>
public int ReconnectMaxBackoffSeconds { get; set; } = 30;
// --- Ingest ---------------------------------------------------------------------------------
/// <summary>Selects the ingest shape — Plain topics or Sparkplug B (the latter is P2).</summary>
public MqttMode Mode { get; set; } = MqttMode.Plain;
/// <summary>Ceiling on an inbound message body, in bytes; a larger message is refused before decode.</summary>
public int MaxPayloadBytes { get; set; } = 1024 * 1024;
/// <summary>Plain mode: optional prefix used when the driver composes a topic (e.g. browse scoping).</summary>
public string TopicPrefix { get; set; } = "";
/// <summary>Plain mode: default subscription QoS applied when a tag's own <c>qos</c> is unset.</summary>
public int DefaultQos { get; set; } = 1;
// --- Sparkplug B ----------------------------------------------------------------------------
/// <summary>
/// Sparkplug mode: the group id. <b>Required</b> — it is the driver's entire subscription scope
/// (<c>spBv1.0/{GroupId}/#</c>), so a blank one subscribes to nothing and the driver ingests
/// nothing while still reporting a healthy broker connection.
/// </summary>
public string SparkplugGroupId { get; set; } = "";
/// <summary>
/// Sparkplug mode: the Host Application identity. Optional, and <b>receive-only</b> in this
/// version — see <see cref="SparkplugActAsPrimaryHost"/>.
/// </summary>
public string SparkplugHostId { get; set; } = "";
/// <summary>
/// Sparkplug mode: claim the Primary Host Application role. <b>Not implemented</b> — STATE
/// publishing is out of scope, and the driver logs a warning rather than being silently inert
/// when this is set. Surfaced (rather than hidden) so an operator who needs it learns that from
/// the form instead of from a quiet plant.
/// </summary>
public bool SparkplugActAsPrimaryHost { get; set; }
/// <summary>Sparkplug mode: answer a detected sequence-number gap with a rebirth request (NCMD).</summary>
public bool SparkplugRequestRebirthOnGap { get; set; } = true;
/// <summary>Sparkplug mode: how long browse/discovery collects births before calling the set stable.</summary>
public int SparkplugBirthObservationWindowSeconds { get; set; } = 15;
/// <summary>
/// The inbound blob's keys, retained so any key a newer driver adds survives a load→save.
/// </summary>
private JsonObject _bag = new();
/// <summary>
/// The operator-facing consequence of pinning <see cref="ClientId"/>, surfaced by the form
/// whenever it is non-blank. Observed live during the P1 gate: MQTT requires client ids to be
/// unique per broker, and both nodes of a redundant pair run the driver — so a fixed id makes
/// them evict each other in a few-second reconnect loop <i>while both still report Healthy</i>.
/// </summary>
public const string ClientIdPairWarning =
"Both nodes of a redundant pair run this driver. MQTT client ids must be unique per broker, so a "
+ "fixed client id makes the two nodes evict each other forever — they reconnect every few seconds "
+ "while both still report Healthy. Leave this blank unless exactly one node will ever connect.";
/// <summary>
/// Loads a model from a <c>DriverConfig</c> JSON string. Typed values are bound through
/// <see cref="MqttJson.Options"/> (the same instance the runtime factory uses, so what this form
/// shows is what the driver would see); the raw key bag is retained alongside for preservation.
/// Never throws — a blank/malformed blob degrades to the driver's own defaults.
/// </summary>
/// <param name="json">The raw <c>DriverConfig</c> JSON string, or <c>null</c> for a new driver.</param>
/// <returns>The populated <see cref="MqttDriverFormModel"/>.</returns>
public static MqttDriverFormModel FromJson(string? json)
{
var bag = TagConfigJson.ParseOrNew(json);
var o = TryDeserialize(json) ?? new MqttDriverOptions();
return new MqttDriverFormModel
{
Host = o.Host,
Port = o.Port,
ClientId = o.ClientId ?? "",
UseTls = o.UseTls,
AllowUntrustedServerCertificate = o.AllowUntrustedServerCertificate,
CaCertificatePath = o.CaCertificatePath ?? "",
Username = o.Username ?? "",
Password = o.Password ?? "",
ProtocolVersion = o.ProtocolVersion,
CleanSession = o.CleanSession,
KeepAliveSeconds = o.KeepAliveSeconds,
ConnectTimeoutSeconds = o.ConnectTimeoutSeconds,
ReconnectMinBackoffSeconds = o.ReconnectMinBackoffSeconds,
ReconnectMaxBackoffSeconds = o.ReconnectMaxBackoffSeconds,
Mode = o.Mode,
MaxPayloadBytes = o.MaxPayloadBytes,
TopicPrefix = o.Plain?.TopicPrefix ?? "",
DefaultQos = o.Plain?.DefaultQos ?? new MqttPlainOptions().DefaultQos,
SparkplugGroupId = o.Sparkplug?.GroupId ?? "",
SparkplugHostId = o.Sparkplug?.HostId ?? "",
SparkplugActAsPrimaryHost = o.Sparkplug?.ActAsPrimaryHost ?? false,
// Defaulted from the record, not from a literal, so the form shows the driver's own default
// for a blob that has no Sparkplug sub-object at all.
SparkplugRequestRebirthOnGap =
o.Sparkplug?.RequestRebirthOnGap ?? new MqttSparkplugOptions().RequestRebirthOnGap,
SparkplugBirthObservationWindowSeconds =
o.Sparkplug?.BirthObservationWindowSeconds
?? new MqttSparkplugOptions().BirthObservationWindowSeconds,
_bag = bag,
};
}
/// <summary>
/// Builds the typed options this form authors. Every numeric knob is <b>clamped</b> into the
/// range <see cref="MqttDriverOptions"/> declares, so an operator who ignores
/// <see cref="Validate"/> and saves anyway still cannot persist a driver-bricking blob — a
/// <c>connectTimeoutSeconds: 0</c> is exactly the operator-authorable brick this repo has hit
/// before. <see cref="MqttDriverOptions.RawTags"/> stays empty (the deploy artifact owns it) and
/// <see cref="MqttDriverOptions.Sparkplug"/> is emitted only in
/// <see cref="MqttMode.SparkplugB"/> mode; both are finished by <see cref="ToJson"/>.
/// </summary>
/// <returns>The clamped, driver-legal options record.</returns>
public MqttDriverOptions ToOptions() => new()
{
Host = Host.Trim(),
Port = Math.Clamp(Port, 1, 65535),
ClientId = Blank(ClientId),
UseTls = UseTls,
AllowUntrustedServerCertificate = AllowUntrustedServerCertificate,
CaCertificatePath = Blank(CaCertificatePath),
Username = Blank(Username),
Password = Password,
ProtocolVersion = ProtocolVersion,
CleanSession = CleanSession,
KeepAliveSeconds = Math.Max(1, KeepAliveSeconds),
ConnectTimeoutSeconds = Math.Max(1, ConnectTimeoutSeconds),
ReconnectMinBackoffSeconds = Math.Max(1, ReconnectMinBackoffSeconds),
ReconnectMaxBackoffSeconds = Math.Max(1, ReconnectMaxBackoffSeconds),
Mode = Mode,
MaxPayloadBytes = Math.Max(1, MaxPayloadBytes),
Plain = new MqttPlainOptions
{
TopicPrefix = TopicPrefix.Trim(),
DefaultQos = Math.Clamp(DefaultQos, 0, 2),
},
// Null in Plain mode so ToJson leaves whatever the inbound blob had; see its remarks.
Sparkplug = Mode == MqttMode.SparkplugB
? new MqttSparkplugOptions
{
GroupId = SparkplugGroupId.Trim(),
HostId = SparkplugHostId.Trim(),
ActAsPrimaryHost = SparkplugActAsPrimaryHost,
RequestRebirthOnGap = SparkplugRequestRebirthOnGap,
BirthObservationWindowSeconds = Math.Max(1, SparkplugBirthObservationWindowSeconds),
}
: null,
};
/// <summary>
/// Serialises the authored fields over the preserved key bag and returns the <c>DriverConfig</c>
/// JSON. Keys are PascalCase and enums are names, because the serialisation runs through
/// <see cref="MqttJson.Options"/> — see the type remarks. <c>RawTags</c> is removed (the deploy
/// artifact owns it).
/// </summary>
/// <remarks>
/// <b><c>Sparkplug</c> is merged, never replaced, and only in Sparkplug mode.</b> In
/// <see cref="MqttMode.Plain"/> the sub-object is left exactly as the inbound blob had it, so an
/// operator who flips a Sparkplug driver to Plain to look at it and flips back does not lose the
/// group id. In <see cref="MqttMode.SparkplugB"/> the five fields this form owns are written
/// <i>over</i> the existing sub-object rather than replacing it, so a key a newer driver adds
/// inside <c>Sparkplug</c> survives an older AdminUI — the same preserve-what-you-do-not-author
/// discipline the top-level bag applies.
/// </remarks>
/// <returns>The serialised <c>DriverConfig</c> JSON string.</returns>
public string ToJson()
{
var typed = JsonSerializer.SerializeToNode(ToOptions(), MqttJson.Options)!.AsObject();
// Never let this form's always-empty placeholder overwrite what it does not own.
typed.Remove(RawTagsKey);
// Pulled out of the flat copy loop below: unlike every other key, this one merges.
var authoredSparkplug = TakeIgnoringCase(typed, SparkplugKey) as JsonObject;
foreach (var (key, value) in typed.ToList())
{
// Case-insensitive replace: MqttJson.Options binds a hand-edited camelCase blob happily, so
// the bag may hold "host" while we emit "Host" — writing both would leave two keys fighting.
RemoveIgnoringCase(_bag, key);
_bag[key] = value?.DeepClone();
}
if (authoredSparkplug is not null)
{
// Merge over whatever was already there, under the key name the bag already uses so a
// camelCase hand-edited blob does not end up with both "sparkplug" and "Sparkplug".
var existingKey = FindIgnoringCase(_bag, SparkplugKey) ?? SparkplugKey;
if (_bag[existingKey] is not JsonObject target)
{
target = new JsonObject();
_bag[existingKey] = target;
}
foreach (var (key, value) in authoredSparkplug.ToList())
{
RemoveIgnoringCase(target, key);
target[key] = value?.DeepClone();
}
}
RemoveIgnoringCase(_bag, RawTagsKey);
return TagConfigJson.Serialize(_bag);
}
/// <summary>
/// Client-side validation surfaced inline by the form. Returns the first error, or <c>null</c>
/// when the model is valid. Every bound mirrors a <c>[Range]</c> on
/// <see cref="MqttDriverOptions"/> (or, for QoS, on <see cref="MqttPlainOptions"/>), so the
/// authoring surface accepts exactly what the driver accepts.
/// </summary>
/// <returns>An error message describing the validation failure, or <c>null</c> when valid.</returns>
public string? Validate()
{
if (string.IsNullOrWhiteSpace(Host)) { return "A broker host is required."; }
if (Port is < 1 or > 65535) { return "Port must be between 1 and 65535."; }
if (KeepAliveSeconds < 1) { return "Keep-alive must be at least 1 second."; }
if (ConnectTimeoutSeconds < 1)
{
return "Connect timeout must be at least 1 second — 0 would make every connect attempt "
+ "expire instantly and the driver would never come up.";
}
if (ReconnectMinBackoffSeconds < 1) { return "Minimum reconnect backoff must be at least 1 second."; }
if (ReconnectMaxBackoffSeconds < 1) { return "Maximum reconnect backoff must be at least 1 second."; }
if (ReconnectMaxBackoffSeconds < ReconnectMinBackoffSeconds)
{
return "Maximum reconnect backoff must not be below the minimum.";
}
if (MaxPayloadBytes < 1) { return "Max payload bytes must be at least 1."; }
if (DefaultQos is < 0 or > 2) { return "Default QoS must be 0, 1 or 2."; }
if (Mode == MqttMode.SparkplugB)
{
var groupId = SparkplugGroupId.Trim();
if (groupId.Length == 0)
{
return "A Sparkplug group ID is required — it is the driver's whole subscription scope "
+ "(spBv1.0/{GroupId}/#), so a blank one ingests nothing.";
}
if (groupId.IndexOfAny(SparkplugGroupIdIllegalChars) >= 0)
{
return $"Sparkplug group ID '{groupId}' contains a character ('/', '+', or '#') that "
+ "cannot appear in an MQTT topic segment.";
}
if (SparkplugBirthObservationWindowSeconds < 1)
{
return "Birth observation window must be at least 1 second.";
}
}
return null;
}
/// <summary>Blank ⇒ <c>null</c> so the key is omitted and the driver's own default applies.</summary>
/// <param name="value">The raw form value.</param>
/// <returns>The trimmed value, or <c>null</c> when blank.</returns>
private static string? Blank(string? value)
=> string.IsNullOrWhiteSpace(value) ? null : value.Trim();
/// <summary>Removes every key matching <paramref name="name"/> case-insensitively.</summary>
/// <param name="o">The object to mutate.</param>
/// <param name="name">The key name to remove.</param>
private static void RemoveIgnoringCase(JsonObject o, string name)
{
foreach (var key in o.Select(p => p.Key)
.Where(k => string.Equals(k, name, StringComparison.OrdinalIgnoreCase))
.ToList())
{
o.Remove(key);
}
}
/// <summary>The actual key in <paramref name="o"/> matching <paramref name="name"/> case-insensitively.</summary>
/// <param name="o">The object to search.</param>
/// <param name="name">The key name to look for.</param>
/// <returns>The matching key as it is spelled in <paramref name="o"/>, or <c>null</c>.</returns>
private static string? FindIgnoringCase(JsonObject o, string name)
=> o.Select(p => p.Key)
.FirstOrDefault(k => string.Equals(k, name, StringComparison.OrdinalIgnoreCase));
/// <summary>Removes and returns the value of a case-insensitively matched key.</summary>
/// <param name="o">The object to mutate.</param>
/// <param name="name">The key name to take.</param>
/// <returns>The detached value, or <c>null</c> when the key was absent (or its value was null).</returns>
private static JsonNode? TakeIgnoringCase(JsonObject o, string name)
{
if (FindIgnoringCase(o, name) is not { } key) { return null; }
var value = o[key];
o.Remove(key);
// Detached before return: a node still parented to `typed` cannot be re-parented into the bag.
return value?.DeepClone();
}
/// <summary>Binds the blob through the shared options; <c>null</c> on blank/malformed input.</summary>
/// <param name="json">The raw <c>DriverConfig</c> JSON.</param>
/// <returns>The bound options, or <c>null</c>.</returns>
private static MqttDriverOptions? TryDeserialize(string? json)
{
if (string.IsNullOrWhiteSpace(json)) { return null; }
try { return JsonSerializer.Deserialize<MqttDriverOptions>(json, MqttJson.Options); }
catch (JsonException) { return null; }
}
}
@@ -11,6 +11,8 @@
SupportsOnlineDiscovery (AbCip/TwinCAT/FOCAS); otherwise browse is unavailable and the modal grays out
with a tooltip. *@
@implements IAsyncDisposable
@using Microsoft.AspNetCore.Authorization
@using Microsoft.AspNetCore.Components.Authorization
@using ZB.MOM.WW.OtOpcUa.AdminUI.Browsing
@using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers
@using ZB.MOM.WW.OtOpcUa.AdminUI.Uns
@@ -18,6 +20,8 @@
@inject IBrowserSessionService BrowserService
@inject IRawTreeService Svc
@inject RawTagCsvExportReader ExportReader
@inject AuthenticationStateProvider AuthState
@inject IAuthorizationService AuthorizationService
@if (Visible)
{
@@ -66,7 +70,22 @@
else
{
<div class="d-flex align-items-center justify-content-between mb-2">
<span class="chip chip-ok">Browser open</span>
<div class="d-flex align-items-center gap-2">
<span class="chip chip-ok">Browser open</span>
@* An observation window ACCUMULATES — the session keeps recording every
message after the tree was first rendered — but the tree itself loads
exactly once, when it is created. Without this the operator sees the
t=0 snapshot forever: on MQTT the first render is usually empty (a
topic that has not published yet is invisible) and on Sparkplug it is
almost always empty, because births are never retained. Re-keying the
tree re-runs its root load against the SAME session, so nothing
reconnects and nothing already observed is lost. *@
<button type="button" class="btn btn-outline-secondary btn-sm"
title="Re-read what this session has observed since it opened"
@onclick="RefreshTree">
Refresh
</button>
</div>
<div class="form-check form-switch mb-0">
<input class="form-check-input" type="checkbox" id="raw-browse-mirror"
checked="@_createGroups" @onchange="@(e => _createGroups = e.Value is true)" />
@@ -76,8 +95,90 @@
</div>
</div>
<DriverBrowseTree SessionToken="_token" MultiSelect="true"
SelectedLeafIds="_selectedIds" OnLeafToggled="OnLeafToggledAsync" />
<DriverBrowseTree @key="_treeGeneration" SessionToken="_token" MultiSelect="true"
SelectedLeafIds="_selectedIds" OnLeafToggled="OnLeafToggledAsync"
OnNodeSelectedWithPath="OnScopeNodeSelected" />
@if (_canRebirth && _canOperate)
{
@* Sparkplug only: births are never retained, so a quiet-but-healthy plant
shows an EMPTY tree until one lands. This is the only way to fill it on
demand — and the only browse action that publishes to the live broker,
hence the explicit confirm step. *@
<div class="border rounded p-2 mt-2 bg-body-tertiary">
<div class="d-flex align-items-center justify-content-between gap-2">
<div class="small">
<strong>Request rebirth</strong>
<span class="text-muted">
&mdash; asks edge nodes to republish their metric set, so this tree fills
without waiting for a natural birth.
</span>
</div>
@if (!_rebirthConfirming)
{
<button type="button" class="btn btn-outline-warning btn-sm text-nowrap"
disabled="@(_rebirthTarget is null || _rebirthBusy)"
@onclick="BeginRebirth">
Request rebirth&hellip;
</button>
}
</div>
@if (_rebirthTarget is null)
{
<div class="small text-muted mt-1">
Click a group, edge node, device or metric in the tree above to choose the scope.
</div>
}
else
{
<div class="small mt-1">
Scope: <strong>@_rebirthTarget.Kind</strong>
<code class="mono ms-1">@_rebirthTarget.Scope</code>
</div>
<div class="small text-muted">@_rebirthTarget.Detail</div>
}
@if (_rebirthConfirming && _rebirthTarget is not null)
{
<div class="alert alert-warning py-2 mt-2 mb-0">
<div class="small">
This <strong>publishes to the live broker</strong>: a Sparkplug
rebirth-request command addressed to @_rebirthTarget.Kind
(<code class="mono">@_rebirthTarget.Scope</code>).
@if (_rebirthTarget.IsGroup)
{
<text>
Every edge node observed in this group is addressed; a group with
more than @MqttGroupRebirthNodeCap observed edge nodes is refused
outright, and nothing is published.
</text>
}
</div>
<div class="d-flex gap-2 mt-2">
<button type="button" class="btn btn-warning btn-sm"
disabled="@_rebirthBusy" @onclick="ConfirmRebirthAsync">
@if (_rebirthBusy) { <span class="spinner-border spinner-border-sm me-1"></span> }
Publish rebirth request
</button>
<button type="button" class="btn btn-outline-secondary btn-sm"
disabled="@_rebirthBusy" @onclick="CancelRebirth">
Cancel
</button>
</div>
</div>
}
@if (_rebirthOutcome is not null)
{
<div class="small text-success mt-1">@_rebirthOutcome</div>
}
@if (_rebirthError is not null)
{
<div class="alert alert-danger py-2 mt-2 mb-0 small">@_rebirthError</div>
}
</div>
}
<div class="mt-2 small">
<strong>@_selected.Count</strong> tag@(_selected.Count == 1 ? "" : "s") selected.
@@ -116,6 +217,11 @@
/// type (the OPC UA Client tree). Driver-typed browsers (AbCip/TwinCAT/FOCAS) report the real type.</summary>
private const string DefaultDataType = "Double";
/// <summary>Mirrors <c>MqttBrowseSession.MaxGroupRebirthNodes</c> for the confirm-step wording. The
/// session owns the rule and refuses whole past it; this is the operator-facing number, and a drift
/// only ever makes the warning less precise, never the refusal wrong.</summary>
private const int MqttGroupRebirthNodeCap = 32;
/// <summary>Whether the modal is shown. Two-way (@bind-Visible) so the modal can self-close.</summary>
[Parameter] public bool Visible { get; set; }
@@ -154,9 +260,32 @@
private bool _busy;
private List<string> _commitErrors = new();
/// <summary>
/// Bumped to force a fresh <c>DriverBrowseTree</c> against the same session — see the Refresh
/// button's remarks. Seeded from the session so re-opening a modal always starts a new generation.
/// </summary>
private int _treeGeneration;
// Request-rebirth affordance (MQTT/Sparkplug only). _canOperate is defence in depth — the real gate
// is server-side in BrowserSessionService.RequestRebirthAsync, which fails closed.
private bool _canOperate;
private bool _canRebirth;
private RebirthTarget? _rebirthTarget;
private bool _rebirthConfirming;
private bool _rebirthBusy;
private string? _rebirthOutcome;
private string? _rebirthError;
private readonly Dictionary<string, SelectedLeaf> _selected = new(StringComparer.Ordinal);
private readonly HashSet<string> _selectedIds = new(StringComparer.Ordinal);
protected override async Task OnInitializedAsync()
{
var auth = await AuthState.GetAuthenticationStateAsync();
var result = await AuthorizationService.AuthorizeAsync(auth.User, null, AdminUiPolicies.DriverOperator);
_canOperate = result.Succeeded;
}
protected override async Task OnParametersSetAsync()
{
if (!Visible)
@@ -185,6 +314,8 @@
_groupPrefix = null;
_canBrowse = false;
_disabledReason = null;
ResetRebirthState();
_canRebirth = false;
_resolving = true;
StateHasChanged();
@@ -238,7 +369,13 @@
try
{
var result = await BrowserService.OpenAsync(_driverType, _mergedConfig, default);
if (result.Ok) { _token = result.Token; }
if (result.Ok)
{
_token = result.Token;
// Capability, not authorization: a plain-MQTT window (and every non-MQTT browser) has no
// re-announce action at all, so the affordance must not be drawn for it.
_canRebirth = BrowserService.CanRequestRebirth(_token);
}
else { _openError = result.Message; }
}
finally
@@ -263,6 +400,7 @@
// the display name + the default data type.
string name = sel.Leaf.DisplayName;
string? driverDataType = null;
IReadOnlyDictionary<string, string>? addressFields = null;
try
{
var attrs = await BrowserService.AttributesAsync(_token, nodeId, default);
@@ -270,15 +408,164 @@
{
name = string.IsNullOrWhiteSpace(attrs[0].Name) ? sel.Leaf.DisplayName : attrs[0].Name;
driverDataType = attrs[0].DriverDataType;
// The STRUCTURED address, for a driver whose binding is a tuple (MQTT/Sparkplug). Never
// reconstructed from the node id: a metric name may contain '/', so the id cannot be
// split back into (group, edgeNode, device?, metric) — see RawBrowseCommitMapper.
addressFields = attrs[0].AddressFields;
}
}
catch
{
// Attribute lookup is best-effort — a leaf is still selectable with display-name + default type.
// For a tuple-addressed driver that also means "no address", which CommitAsync refuses in words.
}
_selectedIds.Add(nodeId);
_selected[nodeId] = new SelectedLeaf(nodeId, name, driverDataType, sel.FolderPath);
_selected[nodeId] = new SelectedLeaf(nodeId, name, driverDataType, sel.FolderPath, addressFields);
// A metric is also a legal rebirth scope (it resolves up to its owning edge node), so a toggle
// moves the scope the same way a folder click does.
SetRebirthTarget(sel.Leaf, sel.FolderPath);
}
/// <summary>Tracks the tree click that defines the Request-rebirth scope (folders and metrics alike).</summary>
private void OnScopeNodeSelected(BrowseLeafSelection sel) => SetRebirthTarget(sel.Leaf, sel.FolderPath);
/// <summary>
/// Re-derives the rebirth scope from the clicked node, and — because the scope changed — drops any
/// pending confirmation. A confirm step that survived a selection change would publish to a target
/// the operator is no longer looking at.
/// </summary>
private void SetRebirthTarget(BrowseNode node, IReadOnlyList<string> ancestorPath)
{
if (!_canRebirth) { return; }
var target = DescribeRebirthTarget(node, ancestorPath);
if (_rebirthTarget?.Scope == target.Scope) { return; }
_rebirthTarget = target;
_rebirthConfirming = false;
_rebirthOutcome = null;
_rebirthError = null;
}
/// <summary>
/// Names what a clicked node means as a rebirth scope. The DEPTH comes from the captured ancestor
/// path, not from splitting the node id — the id is the session's own key and the AdminUI must not
/// reverse-engineer its shape.
/// </summary>
/// <param name="node">The clicked browse node; its NodeId is passed to the session verbatim.</param>
/// <param name="ancestorPath">The node's ancestor display names, root→parent.</param>
/// <returns>The described target.</returns>
private static RebirthTarget DescribeRebirthTarget(BrowseNode node, IReadOnlyList<string> ancestorPath)
{
var depth = ancestorPath.Count;
var name = node.DisplayName;
// Sparkplug has no device- or metric-scoped rebirth: an NCMD addresses an EDGE NODE, so a
// deeper selection resolves upward. Say so, rather than letting the operator assume the
// narrower scope they clicked.
if (node.Kind == BrowseNodeKind.Leaf && depth >= 2)
{
return new RebirthTarget(
node.NodeId,
$"edge node {ancestorPath[1]}",
$"Selected the metric '{name}'. Sparkplug addresses rebirth at the edge node, so "
+ $"{ancestorPath[1]} republishes its whole metric set.",
IsGroup: false);
}
return depth switch
{
0 => new RebirthTarget(
node.NodeId,
$"group {name}",
$"Every edge node observed in group '{name}' is asked to republish.",
IsGroup: true),
1 => new RebirthTarget(
node.NodeId,
$"edge node {name}",
$"Edge node '{name}' in group '{ancestorPath[0]}' republishes its whole metric set.",
IsGroup: false),
2 => new RebirthTarget(
node.NodeId,
$"edge node {ancestorPath[1]}",
$"Selected the device '{name}'. Sparkplug addresses rebirth at the edge node, so "
+ $"{ancestorPath[1]} republishes its whole metric set.",
IsGroup: false),
_ => new RebirthTarget(node.NodeId, "the selected node", "", IsGroup: false),
};
}
/// <summary>Arms the confirm step. Publishing is deliberately two clicks — it reaches a live plant broker.</summary>
private void BeginRebirth()
{
_rebirthOutcome = null;
_rebirthError = null;
_rebirthConfirming = true;
}
/// <summary>Disarms the confirm step without publishing anything.</summary>
private void CancelRebirth() => _rebirthConfirming = false;
/// <summary>
/// The one browse action that publishes. Exceptions are SHOWN, not swallowed: a refusal (an
/// over-wide group scope, a missing DriverOperator grant, an unresolvable scope) is exactly what
/// the operator needs to read, and nothing was published in any of those cases.
/// </summary>
private async Task ConfirmRebirthAsync()
{
if (_rebirthTarget is null) { return; }
_rebirthBusy = true;
_rebirthOutcome = null;
_rebirthError = null;
StateHasChanged();
try
{
var published = await BrowserService.RequestRebirthAsync(_token, _rebirthTarget.Scope, default);
_rebirthOutcome =
$"Rebirth requested for {_rebirthTarget.Kind} — {published} command"
+ (published == 1 ? "" : "s")
+ " published. Re-expand the tree in a few seconds to see the republished metrics.";
_rebirthConfirming = false;
}
catch (Exception ex)
{
_rebirthError = ex.Message;
}
finally
{
_rebirthBusy = false;
StateHasChanged();
}
}
/// <summary>
/// Re-reads the root of the open session's observed tree. Only the tree is rebuilt: the session,
/// its broker connection and everything it has recorded are untouched, so this is strictly a
/// re-render and never re-observes from scratch.
/// </summary>
/// <remarks>
/// The tag <b>selection</b> is deliberately kept — <c>_selectedIds</c> is keyed by browse node id
/// and a refresh does not renumber anything, so an operator who ticked leaves, waited for more
/// of the plant to appear, and refreshed does not silently lose the ticks. The rebirth scope IS
/// cleared, because the node it pointed at may no longer be in the rebuilt tree and a stale
/// armed scope is exactly the thing that panel's two-click confirm exists to prevent.
/// </remarks>
private void RefreshTree()
{
_treeGeneration++;
ResetRebirthState();
}
/// <summary>Clears every rebirth-panel field (modal re-open, browser close).</summary>
private void ResetRebirthState()
{
_rebirthTarget = null;
_rebirthConfirming = false;
_rebirthBusy = false;
_rebirthOutcome = null;
_rebirthError = null;
}
private async Task CommitAsync()
@@ -288,10 +575,21 @@
StateHasChanged();
try
{
// Refuse a selection the driver could not bind, IN WORDS — for a tuple-addressed driver
// (MQTT/Sparkplug) a leaf whose attribute lookup returned nothing carries no address, and
// committing it would produce a tag that deploys clean and then reports BadNodeIdUnknown
// forever with nothing to point at.
_commitErrors = _selected.Values
.Select(s => RawBrowseCommitMapper.DescribeUncommittableLeaf(_driverType, s.Name, s.AddressFields))
.Where(e => e is not null)
.Select(e => e!)
.ToList();
if (_commitErrors.Count > 0) { return; }
var rows = _selected.Values
.Select(s => RawBrowseCommitMapper.MapLeaf(
_driverType, s.NodeId, s.Name, s.DriverDataType, DefaultDataType,
_groupPrefix, s.FolderPath, _createGroups))
_groupPrefix, s.FolderPath, _createGroups, s.AddressFields))
.ToList();
var outcome = await Svc.ImportTagsAsync(_effectiveDeviceId, rows);
@@ -314,6 +612,8 @@
{
var t = _token;
_token = Guid.Empty;
_canRebirth = false;
ResetRebirthState();
Visible = false;
_open = false;
await VisibleChanged.InvokeAsync(false);
@@ -335,5 +635,19 @@
return ValueTask.CompletedTask;
}
private sealed record SelectedLeaf(string NodeId, string Name, string? DriverDataType, IReadOnlyList<string> FolderPath);
/// <param name="AddressFields">The structured address the browse session stated for this leaf, for a
/// driver whose binding is a tuple rather than the NodeId itself; null for every other driver.</param>
private sealed record SelectedLeaf(
string NodeId,
string Name,
string? DriverDataType,
IReadOnlyList<string> FolderPath,
IReadOnlyDictionary<string, string>? AddressFields = null);
/// <summary>A described Request-rebirth scope: what gets published, and to what.</summary>
/// <param name="Scope">The browse node id handed to the session verbatim.</param>
/// <param name="Kind">What the scope resolves to, in operator words (e.g. "edge node EdgeA").</param>
/// <param name="Detail">The one-line consequence, spelled out before the confirm step.</param>
/// <param name="IsGroup">True for a whole-group fan-out — the bounded, all-or-nothing case.</param>
private sealed record RebirthTarget(string Scope, string Kind, string Detail, bool IsGroup);
}
@@ -67,6 +67,7 @@
("TwinCAT", DriverTypeNames.TwinCAT),
("FOCAS", DriverTypeNames.FOCAS),
("OpcUaClient", DriverTypeNames.OpcUaClient),
("MQTT", DriverTypeNames.Mqtt),
("Galaxy", DriverTypeNames.Galaxy),
("Sql", DriverTypeNames.Sql),
("Calculation", "Calculation"),
@@ -0,0 +1,212 @@
@* Typed TagConfig editor for the Mqtt driver. Same (ConfigJson/ConfigJsonChanged/DriverType/
GetDriverConfigJson) parameter shape every typed editor takes; the last two are accepted for
dispatch uniformity and unused here (MQTT has no address-builder picker — topics are discovered
through the /raw browse tree, not composed from parts).
The shape switch is real (it drives which field group renders and what Validate() applies) but it
is a UI-only choice derived from the blob, never serialised — the driver takes Plain vs SparkplugB
from its DRIVER config, and the tag blob has no 'mode' key. A SparkplugB tag survives a save→reopen
because its descriptor keys (groupId/edgeNodeId/deviceId/metricName) re-infer the mode on load —
there is nothing else that needs to persist. *@
@using ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors
@using ZB.MOM.WW.OtOpcUa.Core.Abstractions
@using ZB.MOM.WW.OtOpcUa.Driver.Mqtt
<div class="row g-2">
<div class="col-md-4">
<label class="form-label" for="mqtt-mode">Tag shape</label>
<select id="mqtt-mode" class="form-select form-select-sm" value="@_m.Mode"
@onchange="@(e => Update(() => _m.Mode = ParseEnum(e.Value, MqttMode.Plain)))">
@foreach (var v in Enum.GetValues<MqttMode>()) { <option value="@v">@v</option> }
</select>
</div>
@if (_m.Mode == MqttMode.Plain)
{
<div class="col-md-8">
<label class="form-label" for="mqtt-topic">Topic</label>
<input id="mqtt-topic" type="text" class="form-control form-control-sm mono"
placeholder="factory/oven/temp" value="@_m.Topic"
@onchange="@(e => Update(() => _m.Topic = e.Value?.ToString() ?? ""))" />
<div class="form-text">Concrete topic — MQTT wildcards (+ / #) are not valid for a tag.</div>
</div>
<div class="col-md-4">
<label class="form-label" for="mqtt-payload-format">Payload format</label>
<select id="mqtt-payload-format" class="form-select form-select-sm" value="@_m.PayloadFormat"
@onchange="@(e => Update(() => _m.PayloadFormat = ParseEnum(e.Value, MqttPayloadFormat.Json)))">
@foreach (var v in Enum.GetValues<MqttPayloadFormat>()) { <option value="@v">@v</option> }
</select>
</div>
@if (_m.PayloadFormat == MqttPayloadFormat.Json)
{
<div class="col-md-4">
<label class="form-label" for="mqtt-json-path">JSON path</label>
<input id="mqtt-json-path" type="text" class="form-control form-control-sm mono"
placeholder="$.value" value="@_m.JsonPath"
@onchange="@(e => Update(() => _m.JsonPath = e.Value?.ToString() ?? ""))" />
<div class="form-text">Use <code>$</code> for the whole document.</div>
</div>
}
<div class="col-md-4">
<label class="form-label" for="mqtt-data-type">Data type</label>
@* The driver's own DriverDataType members — authoring anything outside this set produces a
blob the driver's strict parser rejects at deploy (there is no 'Double'; it is Float64). *@
<select id="mqtt-data-type" class="form-select form-select-sm" value="@_m.DataType"
@onchange="@(e => Update(() => _m.DataType = ParseEnum(e.Value, DriverDataType.String)))">
@foreach (var v in Enum.GetValues<DriverDataType>()) { <option value="@v">@v</option> }
</select>
</div>
<div class="col-md-4">
<label class="form-label" for="mqtt-qos">QoS</label>
<select id="mqtt-qos" class="form-select form-select-sm" value="@(_m.Qos?.ToString() ?? "")"
@onchange="@(e => Update(() => _m.Qos = ParseNullableInt(e.Value)))">
<option value="">(driver default)</option>
<option value="0">0 — at most once</option>
<option value="1">1 — at least once</option>
<option value="2">2 — exactly once</option>
</select>
</div>
<div class="col-md-4">
<label class="form-label" for="mqtt-retain-seed">Retained-message seed</label>
<select id="mqtt-retain-seed" class="form-select form-select-sm"
value="@(_m.RetainSeed is null ? "" : _m.RetainSeed.Value ? "true" : "false")"
@onchange="@(e => Update(() => _m.RetainSeed = ParseNullableBool(e.Value)))">
<option value="">(driver default)</option>
<option value="true">Seed from retained message</option>
<option value="false">Wait for a live publish</option>
</select>
</div>
}
else
{
<div class="col-md-4">
<label class="form-label" for="mqtt-sp-group">Group ID</label>
<input id="mqtt-sp-group" type="text" class="form-control form-control-sm mono"
placeholder="Plant1" value="@_m.GroupId"
@onchange="@(e => Update(() => _m.GroupId = e.Value?.ToString() ?? ""))" />
<div class="form-text">The driver subscribes <code>spBv1.0/@(string.IsNullOrEmpty(_m.GroupId) ? "{GroupId}" : _m.GroupId)/#</code>. No <code>/</code>, <code>+</code>, or <code>#</code>.</div>
</div>
<div class="col-md-4">
<label class="form-label" for="mqtt-sp-node">Edge node ID</label>
<input id="mqtt-sp-node" type="text" class="form-control form-control-sm mono"
placeholder="EdgeA" value="@_m.EdgeNodeId"
@onchange="@(e => Update(() => _m.EdgeNodeId = e.Value?.ToString() ?? ""))" />
<div class="form-text">The publishing edge node's id.</div>
</div>
<div class="col-md-4">
<label class="form-label" for="mqtt-sp-device">Device ID <span class="text-muted">(optional)</span></label>
<input id="mqtt-sp-device" type="text" class="form-control form-control-sm mono"
placeholder="(node-level metric)" value="@_m.DeviceId"
@onchange="@(e => Update(() => _m.DeviceId = e.Value?.ToString() ?? ""))" />
<div class="form-text">Leave blank for a metric published by the edge node itself.</div>
</div>
<div class="col-md-6">
<label class="form-label" for="mqtt-sp-metric">Metric name</label>
<input id="mqtt-sp-metric" type="text" class="form-control form-control-sm mono"
placeholder="Node Control/Rebirth" value="@_m.MetricName"
@onchange="@(e => Update(() => _m.MetricName = e.Value?.ToString() ?? ""))" />
<div class="form-text">The metric's stable name from the birth certificate — unlike the ids above, this MAY contain <code>/</code> (e.g. <code>Properties/Hardware Make</code>).</div>
</div>
<div class="col-md-3">
<label class="form-label" for="mqtt-sp-data-type">Data type override</label>
@* Same DriverDataType set as Plain's field — the factory reads a Sparkplug 'dataType' key as
a DriverDataType override, not the raw wire SparkplugDataType. Absent ⇒ take whatever type
the birth certificate declares (the driver's UntilStable discovery). *@
<select id="mqtt-sp-data-type" class="form-select form-select-sm" value="@(_m.MetricDataType?.ToString() ?? "")"
@onchange="@(e => Update(() => _m.MetricDataType = ParseNullableEnum<DriverDataType>(e.Value)))">
<option value="">(from birth certificate)</option>
@foreach (var v in Enum.GetValues<DriverDataType>()) { <option value="@v">@v</option> }
</select>
</div>
<div class="col-md-3">
<label class="form-label" for="mqtt-sp-qos">QoS</label>
<select id="mqtt-sp-qos" class="form-select form-select-sm" value="@(_m.Qos?.ToString() ?? "")"
@onchange="@(e => Update(() => _m.Qos = ParseNullableInt(e.Value)))">
<option value="">(driver default)</option>
<option value="0">0 — at most once</option>
<option value="1">1 — at least once</option>
<option value="2">2 — exactly once</option>
</select>
</div>
<div class="col-md-3">
<label class="form-label" for="mqtt-sp-retain-seed">Retained-message seed</label>
<select id="mqtt-sp-retain-seed" class="form-select form-select-sm"
value="@(_m.RetainSeed is null ? "" : _m.RetainSeed.Value ? "true" : "false")"
@onchange="@(e => Update(() => _m.RetainSeed = ParseNullableBool(e.Value)))">
<option value="">(driver default)</option>
<option value="true">Seed from retained message</option>
<option value="false">Wait for a live publish</option>
</select>
</div>
}
@if (_validationError is not null)
{
<div class="col-12"><div class="text-danger small">@_validationError</div></div>
}
</div>
@code {
/// <summary>The tag's TagConfig JSON, owned by the host modal.</summary>
[Parameter] public string? ConfigJson { get; set; }
/// <summary>Raised with the re-serialised TagConfig JSON after every field edit.</summary>
[Parameter] public EventCallback<string> ConfigJsonChanged { get; set; }
/// <summary>DriverType of the owning driver — accepted for dispatch uniformity; unused here.</summary>
[Parameter] public string DriverType { get; set; } = "";
/// <summary>Live accessor for the owning driver's DriverConfig JSON — accepted for dispatch uniformity; unused here.</summary>
[Parameter] public Func<string> GetDriverConfigJson { get; set; } = () => "{}";
private MqttTagConfigModel _m = new();
private string? _lastConfigJson;
private string? _validationError;
// Re-parse only when the incoming JSON actually changes, so an unrelated parent re-render
// (Blazor Server live-status pushes do this) can't reset the user's in-progress edits.
protected override void OnParametersSet()
{
if (ConfigJson == _lastConfigJson) { return; }
_lastConfigJson = ConfigJson;
_m = MqttTagConfigModel.FromJson(ConfigJson);
_validationError = _m.Validate();
}
// TryParse so a bad/empty change value can never throw into the Blazor circuit — it falls back.
private static TEnum ParseEnum<TEnum>(object? v, TEnum fallback) where TEnum : struct, Enum
=> Enum.TryParse<TEnum>(v?.ToString(), out var r) ? r : fallback;
// "" (the "(from birth certificate)" sentinel option) ⇒ null ⇒ the key is omitted.
private static TEnum? ParseNullableEnum<TEnum>(object? v) where TEnum : struct, Enum
=> Enum.TryParse<TEnum>(v?.ToString(), out var r) ? r : null;
// "" ⇒ null ⇒ the key is omitted and the driver's own default applies.
private static int? ParseNullableInt(object? v)
=> int.TryParse(v?.ToString(), out var i) ? i : null;
private static bool? ParseNullableBool(object? v)
=> bool.TryParse(v?.ToString(), out var b) ? b : null;
private async Task Update(Action apply)
{
apply();
_validationError = _m.Validate();
var json = _m.ToJson();
// Keep the OnParametersSet guard in step with what we just emitted, so the host echoing the new
// JSON straight back as a parameter cannot re-parse and clobber an in-progress edit.
//
// WHY THIS DIVERGES FROM THE MODBUS TEMPLATE (which does not do this): the general landmine is a
// DERIVED, NON-PERSISTED UI FIELD INFERRED FROM BLOB CONTENT. Mode is exactly that — it is
// re-inferred by FromJson from the Sparkplug keys and never serialised, so a plain re-parse of
// our own output would silently reset the operator's shape selection to Plain on the very next
// render. Modbus has no such field, which is why it needs no guard. Task 24's Sparkplug field
// group will be tempted to add more of them; each one needs this guard to hold.
//
// TRADE (does not manifest today): the editor will not pick up a change the HOST makes to the
// JSON while it is open. That is safe only because RawTagModal/RawManualTagEntryModal mutate
// _form.TagConfig exclusively through this callback. A host-side feature that rewrites the blob
// out-of-band — a "reformat JSON" action, a bulk address re-pick — would reintroduce staleness
// here and must re-key the guard (e.g. compare against a host-supplied revision, not the text).
_lastConfigJson = json;
await ConfigJsonChanged.InvokeAsync(json);
}
}
@@ -10,6 +10,7 @@ using ZB.MOM.WW.OtOpcUa.AdminUI.Uns;
using ZB.MOM.WW.OtOpcUa.Commons.Browsing;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browser;
using ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Browser;
using ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser;
using ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser;
using ZB.MOM.WW.Secrets.Ui;
@@ -80,6 +81,7 @@ public static class EndpointRouteBuilderExtensions
// for Sql: SqlDriver.CanBrowse is false, and this bespoke IDriverBrowser wins by construction
// (BrowserSessionService indexes bespoke browsers by DriverType before falling back to universal).
services.AddSingleton<IDriverBrowser, SqlDriverBrowser>();
services.AddSingleton<IDriverBrowser, MqttDriverBrowser>();
// Universal Discover-backed fallback browser (Wave 0). IDriverFactory is bound by the
// fused Host's DriverFactoryBootstrap; a standalone AdminUI has none → NullDriverFactory
// → CanBrowse false → pickers gracefully stay manual-entry (design §6).
@@ -3,6 +3,7 @@ using ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors;
using ZB.MOM.WW.OtOpcUa.Commons.Types;
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.Mqtt;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Uns;
@@ -33,6 +34,9 @@ public static class RawBrowseCommitMapper
/// <param name="folderPath">The captured browse-folder nesting above the leaf (root→parent display
/// names); mirrored onto nested TagGroups only when <paramref name="createGroups"/> is true.</param>
/// <param name="createGroups">When true, mirror <paramref name="folderPath"/> as nested TagGroups.</param>
/// <param name="addressFields">The leaf's structured address (<see cref="AttributeInfo.AddressFields"/>),
/// for a driver whose binding is a tuple rather than a single reference string; null for every driver
/// whose address is the <paramref name="fullName"/> itself.</param>
/// <returns>The assembled import row.</returns>
public static RawTagImportRow MapLeaf(
string driverType,
@@ -42,10 +46,11 @@ public static class RawBrowseCommitMapper
string defaultDataType,
string? groupPrefix,
IReadOnlyList<string>? folderPath,
bool createGroups)
bool createGroups,
IReadOnlyDictionary<string, string>? addressFields = null)
{
var dataType = MapDataType(driverDataType) ?? defaultDataType;
var tagConfig = BuildTagConfig(driverType, fullName);
var tagConfig = BuildTagConfig(driverType, fullName, addressFields);
var mirrored = createGroups && folderPath is { Count: > 0 }
? string.Join(RawPaths.Separator, folderPath)
: null;
@@ -96,13 +101,25 @@ public static class RawBrowseCommitMapper
/// address field to <paramref name="fullName"/> and leaving every other field at its model default. Reuses
/// the <c>&lt;Driver&gt;TagConfigModel</c> for driver types that have a typed editor; the model-less Galaxy
/// driver gets the canonical camelCase <c>attributeRef</c> key directly.
/// <para>
/// <b>MQTT is the one driver whose address is not a single string</b> — a Sparkplug tag binds by a
/// <c>(group, edgeNode, device?, metric)</c> tuple — so it is built from the browse session's stated
/// <paramref name="addressFields"/> instead. See <see cref="BuildMqttTagConfig"/>.
/// </para>
/// </summary>
/// <param name="driverType">The owning device's driver type.</param>
/// <param name="fullName">The leaf's driver-side full reference to write into the address field.</param>
/// <param name="addressFields">The leaf's structured address, when the driver binds by a tuple —
/// see <see cref="BuildMqttTagConfig"/>. Ignored by every single-reference driver.</param>
/// <returns>The serialised driver-typed <c>TagConfig</c> JSON.</returns>
public static string BuildTagConfig(string driverType, string fullName)
public static string BuildTagConfig(
string driverType,
string fullName,
IReadOnlyDictionary<string, string>? addressFields = null)
{
var address = fullName ?? "";
if (Is(driverType, DriverTypeNames.Mqtt))
return BuildMqttTagConfig(address, addressFields);
if (Is(driverType, DriverTypeNames.OpcUaClient))
return new OpcUaClientTagConfigModel { NodeId = address }.ToJson();
if (Is(driverType, DriverTypeNames.AbCip))
@@ -123,6 +140,103 @@ public static class RawBrowseCommitMapper
return WriteSingleKey("address", address);
}
/// <summary>
/// Builds the <c>TagConfig</c> for a browse-committed <b>MQTT</b> leaf, whose address is a
/// <i>descriptor</i> rather than a single reference string: <c>topic</c> in Plain mode, and the
/// <c>groupId</c>/<c>edgeNodeId</c>/<c>deviceId?</c>/<c>metricName</c> tuple in Sparkplug B mode.
/// </summary>
/// <param name="fullName">The leaf's browse node id — the Plain-mode fallback address only.</param>
/// <param name="addressFields">The structured address the browse session stated.</param>
/// <returns>The serialised <c>TagConfig</c> JSON.</returns>
/// <remarks>
/// <para>
/// <b>The address is read from <paramref name="addressFields"/>, never parsed out of
/// <paramref name="fullName"/>.</b> A Sparkplug browse node id is
/// <c>{group}/{node}[/{device}]::{metric}</c>, and a metric name legitimately contains <c>/</c>
/// (<c>Node Control/Rebirth</c>) — so splitting the id cannot recover the tuple in general, and a
/// mapper that guessed would silently bind the wrong metric. The session that decoded the birth
/// already holds the decomposition and hands it over; this method only picks the keys it knows.
/// </para>
/// <para>
/// <b>Plain vs Sparkplug is likewise stated, not inferred</b> — the driver <i>type</i> reaching
/// here is just <c>Mqtt</c>, and the mode lives on the driver config, not on the tag. The
/// producing session knows its own mode and says so by which keys it emits: a
/// <c>metricName</c> ⇒ Sparkplug, a <c>topic</c> ⇒ Plain. (The blob deliberately carries no
/// <c>mode</c> key: <c>MqttTagDefinitionFactory</c> takes the shape from the driver's mode and
/// would ignore one, and <c>MqttTagConfigModel</c> re-infers it from these same keys.)
/// </para>
/// <para>
/// Keys are <b>whitelisted</b>, not splatted: these values came off a broker, and everything
/// beyond the address (payload format, JSONPath, QoS, dataType) stays at the driver's own
/// defaults for the operator to author afterwards. A leaf with no stated address at all is
/// rejected before this is reached — see <see cref="DescribeUncommittableLeaf"/>.
/// </para>
/// </remarks>
private static string BuildMqttTagConfig(string fullName, IReadOnlyDictionary<string, string>? addressFields)
{
if (TryGetField(addressFields, MqttTagConfigKeys.MetricName) is { } metricName)
{
var o = new JsonObject
{
[MqttTagConfigKeys.GroupId] = TryGetField(addressFields, MqttTagConfigKeys.GroupId) ?? "",
[MqttTagConfigKeys.EdgeNodeId] = TryGetField(addressFields, MqttTagConfigKeys.EdgeNodeId) ?? "",
[MqttTagConfigKeys.MetricName] = metricName,
};
// Absent, not blank, for a node-level metric — the two describe the same scope to the
// factory, but only the absent form says "this metric has no device" to a reader.
if (TryGetField(addressFields, MqttTagConfigKeys.DeviceId) is { } deviceId)
o[MqttTagConfigKeys.DeviceId] = deviceId;
return o.ToJsonString();
}
// Plain: the session states the topic; the node id is the same value and is the fallback for a
// session that stated nothing (a shape DescribeUncommittableLeaf refuses upstream).
return WriteSingleKey(
MqttTagConfigKeys.Topic,
TryGetField(addressFields, MqttTagConfigKeys.Topic) ?? fullName);
}
/// <summary>
/// Describes why a selected browse leaf cannot be committed as a working tag, or <c>null</c> when it
/// can. The AdminUI calls this before mapping so an unbindable selection fails <b>at commit, in
/// words</b>.
/// </summary>
/// <param name="driverType">The owning device's driver type.</param>
/// <param name="browseName">The leaf's browse name, for the message.</param>
/// <param name="addressFields">The structured address the browse session stated, if any.</param>
/// <returns>The operator-facing reason, or <c>null</c> when the leaf is committable.</returns>
/// <remarks>
/// Only MQTT can fail this today, and only in one shape: a leaf whose attribute lookup returned
/// nothing, so no address was stated. Committing it anyway is the defect this whole seam exists
/// to close — a Sparkplug tuple cannot be reconstructed from the node id, so the row would deploy
/// clean and then report <c>BadNodeIdUnknown</c> forever with nothing to point at. Every
/// single-reference driver is unaffected: its address IS the node id.
/// </remarks>
public static string? DescribeUncommittableLeaf(
string driverType,
string browseName,
IReadOnlyDictionary<string, string>? addressFields)
{
if (!Is(driverType, DriverTypeNames.Mqtt)) return null;
if (TryGetField(addressFields, MqttTagConfigKeys.MetricName) is not null) return null;
if (TryGetField(addressFields, MqttTagConfigKeys.Topic) is not null) return null;
return $"'{browseName}' could not be committed: the browse session reported no MQTT address for it "
+ "(its attribute lookup returned nothing). Re-open the browser and re-select it, or author "
+ "the tag manually.";
}
/// <summary>Reads one non-blank address field, or <c>null</c> when it is absent or blank.</summary>
/// <param name="fields">The stated address fields, or null.</param>
/// <param name="key">The field to read.</param>
/// <returns>The trimmed value, or <c>null</c>.</returns>
private static string? TryGetField(IReadOnlyDictionary<string, string>? fields, string key)
=> fields is not null && fields.TryGetValue(key, out var v) && !string.IsNullOrWhiteSpace(v)
? v.Trim()
: null;
/// <summary>
/// Combines a target-group path prefix with an optional mirrored sub-path, matching the WP5 CSV import
/// combine semantics: blank collapses to null, and a present prefix + suffix join with the RawPath
@@ -0,0 +1,396 @@
using System.Text.Json.Nodes;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.Mqtt;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors;
/// <summary>
/// Typed working model for an MQTT tag's TagConfig JSON — the driver-specific binding fields
/// (name / access level / writability live on the Tag entity). Preserves unrecognised JSON keys
/// across a load→save.
/// </summary>
/// <remarks>
/// <para>
/// The authoritative consumer of the produced blob is
/// <c>MqttTagDefinitionFactory.FromTagConfig</c> (<c>Driver.Mqtt.Contracts</c>); every key name and
/// strictness rule here mirrors that factory rather than inventing an editor-side schema.
/// </para>
/// <para>
/// <b>There is deliberately no <c>FullName</c> (or any other identity) key.</b> Under the v3
/// identity contract a tag is identified by its <b>RawPath</b> — the factory keys the produced
/// definition's <c>Name</c> off the RawPath it is handed, and the TagConfig is a pure address blob.
/// Writing a composed identity key here would be dead weight nothing reads.
/// </para>
/// <para>
/// <see cref="Mode"/> is a <b>UI-only</b> sub-shape selector, inferred from the blob (the presence
/// of any Sparkplug descriptor key) and never serialised — the driver takes its
/// <c>Plain</c>/<c>SparkplugB</c> mode from the <em>driver</em> config, not from a tag, so
/// persisting a per-tag <c>mode</c> key would add a field the contract does not define.
/// Consequently a SparkplugB tag survives a save→reopen purely because the descriptor keys it
/// writes (<see cref="GroupId"/>/<see cref="EdgeNodeId"/>/<see cref="DeviceId"/>/
/// <see cref="MetricName"/>) re-infer the mode on the next load — there is nothing else to persist.
/// </para>
/// </remarks>
public sealed class MqttTagConfigModel
{
private const string TopicKey = "topic";
private const string PayloadFormatKey = "payloadFormat";
private const string JsonPathKey = "jsonPath";
private const string DataTypeKey = "dataType";
private const string QosKey = "qos";
private const string RetainSeedKey = "retainSeed";
private const string GroupIdKey = "groupId";
private const string EdgeNodeIdKey = "edgeNodeId";
private const string DeviceIdKey = "deviceId";
private const string MetricNameKey = "metricName";
/// <summary>
/// The JSONPath the driver applies when the blob omits <c>jsonPath</c> — the document root.
/// Seeded into an unauthored tag's field so the "no extraction needed" case is one click away
/// rather than something the operator has to know.
/// </summary>
private const string RootJsonPath = "$";
/// <summary>The Sparkplug B descriptor keys — any non-blank one marks a tag SparkplugB (see <see cref="InferMode"/>).</summary>
private static readonly string[] SparkplugKeys = [GroupIdKey, EdgeNodeIdKey, DeviceIdKey, MetricNameKey];
/// <summary>The MQTT wildcard characters; a tag's subscription topic must be concrete.</summary>
private static readonly char[] TopicWildcards = ['+', '#'];
/// <summary>
/// Characters illegal in a Sparkplug group/edge-node/device id. <c>/</c> is rejected because
/// these ids are literal MQTT topic segments — a decoded incoming id can never contain one
/// (the broker has already split the topic into segments before the driver sees it), so an
/// authored id containing <c>/</c> could never match a real birth: a permanently dead binding,
/// not an ambiguous one. <c>+</c>/<c>#</c> are rejected for the same "no legitimate
/// interpretation" reason the Plain topic wildcard check uses — here doubly so, since
/// <see cref="MqttDriverOptions.GroupId"/>'s only consumer builds the literal subscription
/// filter <c>spBv1.0/{GroupId}/#</c>, and embedding a wildcard character mid-segment is not
/// even legal there. <b>Not enforced by <c>MqttTagDefinitionFactory.FromSparkplugTagConfig</c></b>
/// — this is an editor-side rule stricter than the runtime parser, the same shape as the Plain
/// topic-wildcard rule below.
/// </summary>
private static readonly char[] SparkplugSegmentIllegalChars = ['/', '+', '#'];
/// <summary>
/// Which ingest shape this tag is authored under — inferred from the blob, never serialised.
/// </summary>
public MqttMode Mode { get; set; } = MqttMode.Plain;
/// <summary>The concrete MQTT topic the tag subscribes to (Plain mode). Required; no wildcards.</summary>
public string Topic { get; set; } = "";
/// <summary>How the received payload is decoded (Plain mode).</summary>
public MqttPayloadFormat PayloadFormat { get; set; } = MqttPayloadFormat.Json;
/// <summary>
/// JSONPath selecting the value inside a <see cref="MqttPayloadFormat.Json"/> payload. Blank is
/// legal and NOT a validation failure — the key is omitted and the driver applies the document
/// root (<c>$</c>), which is the real "publisher puts a bare JSON scalar on the topic" case.
/// An <b>unauthored</b> tag is seeded with <c>$</c> by <see cref="FromJson"/> so that common case
/// is visible and one click away; see <see cref="RootJsonPath"/>.
/// </summary>
public string JsonPath { get; set; } = "";
/// <summary>The tag's declared value type (Plain mode; always written). The driver's <see cref="DriverDataType"/> set.</summary>
public DriverDataType DataType { get; set; } = DriverDataType.String;
/// <summary>
/// Per-tag subscription QoS (02), or <c>null</c> to omit the key and inherit the driver-level
/// default. An absent key stays absent through a load→save. Shared by both modes —
/// <c>MqttTagDefinitionFactory</c> reads <c>qos</c> identically for Plain and Sparkplug.
/// </summary>
public int? Qos { get; set; }
/// <summary>
/// Whether the broker's retained message seeds the tag's initial value, or <c>null</c> to omit
/// 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
/// Sparkplug.
/// </summary>
public bool? RetainSeed { get; set; }
/// <summary>The Sparkplug group id (Sparkplug mode). Required; becomes part of the subscription filter.</summary>
public string GroupId { get; set; } = "";
/// <summary>The Sparkplug edge-node id (Sparkplug mode). Required.</summary>
public string EdgeNodeId { get; set; } = "";
/// <summary>
/// The Sparkplug device id (Sparkplug mode), or blank for a metric published by the edge node
/// itself rather than a device beneath it. Optional.
/// </summary>
public string DeviceId { get; set; } = "";
/// <summary>
/// The Sparkplug metric's stable name (Sparkplug mode). Required. Unlike
/// <see cref="GroupId"/>/<see cref="EdgeNodeId"/>/<see cref="DeviceId"/> this is NOT a topic
/// segment — it is read from the birth/data payload — so it is deliberately unrestricted and MAY
/// contain <c>/</c> (the canonical Sparkplug examples do: <c>Node Control/Rebirth</c>,
/// <c>Properties/Hardware Make</c>).
/// </summary>
public string MetricName { get; set; } = "";
/// <summary>
/// Sparkplug-mode data-type OVERRIDE (Sparkplug mode only), or <c>null</c> to omit the key and
/// let the tag take whatever type the metric's birth certificate declares — the whole point of
/// the driver's <c>UntilStable</c> discovery. Distinct from <see cref="DataType"/>, which is the
/// always-written Plain-mode field; both read/write the same <c>dataType</c> JSON key
/// (<c>MqttTagDefinitionFactory.FromSparkplugTagConfig</c> reads it as a <see cref="DriverDataType"/>
/// override, strictly, when present — the same enum Plain mode uses, not the raw wire
/// <c>SparkplugDataType</c>).
/// </summary>
public DriverDataType? MetricDataType { get; set; }
private JsonObject _bag = new();
/// <summary>
/// Loads a model from a TagConfig JSON string, defaulting any absent field and retaining every
/// original key (so fields this editor doesn't expose — history intent, array/alarm objects, and
/// the Sparkplug descriptor keys — survive a load→save).
/// </summary>
/// <remarks>
/// An <b>unauthored</b> Plain tag — no <c>topic</c> AND no <c>jsonPath</c>, i.e. a brand-new tag
/// rather than an existing one the operator deliberately left path-less — has its
/// <see cref="JsonPath"/> seeded to <see cref="RootJsonPath"/>. The condition is deliberately
/// narrow: an existing tag that carries a topic and no <c>jsonPath</c> keeps the key ABSENT
/// through a load→save, so this can never rewrite already-deployed blobs.
/// </remarks>
/// <param name="json">The raw TagConfig JSON string, or <c>null</c> for a new/empty config.</param>
/// <returns>The populated <see cref="MqttTagConfigModel"/>.</returns>
public static MqttTagConfigModel FromJson(string? json)
{
var o = TagConfigJson.ParseOrNew(json);
var topic = TagConfigJson.GetString(o, TopicKey) ?? "";
var jsonPath = TagConfigJson.GetString(o, JsonPathKey) ?? "";
var mode = InferMode(o);
if (mode == MqttMode.Plain && topic.Length == 0 && jsonPath.Length == 0) { jsonPath = RootJsonPath; }
return new MqttTagConfigModel
{
Mode = mode,
Topic = topic,
PayloadFormat = TagConfigJson.GetEnum(o, PayloadFormatKey, MqttPayloadFormat.Json),
JsonPath = jsonPath,
DataType = TagConfigJson.GetEnum(o, DataTypeKey, DriverDataType.String),
Qos = GetIntNullable(o, QosKey),
RetainSeed = TagConfigJson.GetBoolNullable(o, RetainSeedKey),
GroupId = TagConfigJson.GetString(o, GroupIdKey) ?? "",
EdgeNodeId = TagConfigJson.GetString(o, EdgeNodeIdKey) ?? "",
DeviceId = TagConfigJson.GetString(o, DeviceIdKey) ?? "",
MetricName = TagConfigJson.GetString(o, MetricNameKey) ?? "",
// Same "dataType" key as Plain's DataType above, read as an optional override — see the
// MetricDataType doc comment for why there are two typed fields over one JSON key.
MetricDataType = GetEnumNullable<DriverDataType>(o, DataTypeKey),
_bag = o,
};
}
/// <summary>
/// Serialises this model back to a TagConfig JSON string over the preserved key bag. Enums are
/// written as their <b>names</b> (the driver reads them strictly by name; an ordinal would be
/// rejected outright), and blank/null optionals are written as an absent key so the driver's own
/// defaults apply.
/// </summary>
/// <remarks>
/// <c>dataType</c> is the one key both modes write, from two different typed fields
/// (<see cref="DataType"/> for Plain — always present; <see cref="MetricDataType"/> for
/// Sparkplug — omitted unless the operator set an override): which field wins is decided by
/// <see cref="Mode"/> at write time. Every other key is unconditional — mode-inapplicable keys
/// (e.g. <c>topic</c> while Sparkplug, or <c>groupId</c> while Plain) are written/cleared exactly
/// as their typed field says, which naturally preserves a retyped tag's other-mode leftovers
/// untouched until the operator edits that field too.
/// </remarks>
/// <returns>The serialised TagConfig JSON string.</returns>
public string ToJson()
{
// Blank ⇒ omit, like every other optional here: toggling the shape dropdown on an untouched tag
// must not leave a stray "topic":"" behind.
TagConfigJson.Set(_bag, TopicKey, string.IsNullOrWhiteSpace(Topic) ? null : Topic.Trim());
TagConfigJson.Set(_bag, PayloadFormatKey, PayloadFormat);
TagConfigJson.Set(_bag, JsonPathKey, string.IsNullOrWhiteSpace(JsonPath) ? null : JsonPath.Trim());
TagConfigJson.Set(_bag, QosKey, Qos);
TagConfigJson.Set(_bag, RetainSeedKey, RetainSeed);
// Mode-dependent: Plain always writes DataType; Sparkplug writes MetricDataType (null ⇒ omitted,
// "take the birth's declared type").
TagConfigJson.Set(_bag, DataTypeKey, Mode == MqttMode.SparkplugB ? MetricDataType : DataType);
TagConfigJson.Set(_bag, GroupIdKey, string.IsNullOrWhiteSpace(GroupId) ? null : GroupId.Trim());
TagConfigJson.Set(_bag, EdgeNodeIdKey, string.IsNullOrWhiteSpace(EdgeNodeId) ? null : EdgeNodeId.Trim());
TagConfigJson.Set(_bag, DeviceIdKey, string.IsNullOrWhiteSpace(DeviceId) ? null : DeviceId.Trim());
TagConfigJson.Set(_bag, MetricNameKey, string.IsNullOrWhiteSpace(MetricName) ? null : MetricName.Trim());
return TagConfigJson.Serialize(_bag);
}
/// <summary>
/// Client-side validation run by <see cref="TagConfigValidator"/> before a tag is saved.
/// Returns the first error, or <c>null</c> when the config is valid.
/// </summary>
/// <remarks>
/// <para>
/// Exactly ONE rule is deliberately <b>stricter</b> than the runtime parser: a wildcard topic,
/// which the runtime accepts and <c>Inspect</c> only warns about at deploy. It earns the
/// strictness because a wildcard has no legitimate single-Tag interpretation — one Tag holds one
/// value, and <c>+</c>/<c>#</c> would feed it from many source topics. Everything else —
/// required topic, strict enums, QoS 02 — matches <c>MqttTagDefinitionFactory</c> exactly.
/// </para>
/// <para>
/// A blank <c>jsonPath</c> under a <c>Json</c> payload is explicitly <b>accepted</b>, matching
/// the runtime's document-root default. Rejecting it would make this editor refuse a config the
/// driver handles happily — inverting the "authoring surface accepts ⇔ publish accepts"
/// principle — and, because this validator also gates the CSV-import review grid
/// (<c>RawManualTagEntryModal</c>), would block whole import batches over a sane default. The
/// operator is guided by the seeded <c>$</c> from <see cref="FromJson"/> instead of a blocker.
/// </para>
/// <para>
/// The strict enum/QoS checks read the ORIGINAL key bag, not the defaulted typed fields, so a
/// blob that never went through this editor (e.g. a CSV import) cannot pass validation while the
/// driver would reject it.
/// </para>
/// <para>
/// <b>Sparkplug rules mirror <c>MqttTagDefinitionFactory.FromSparkplugTagConfig</c></b>:
/// <c>groupId</c>/<c>edgeNodeId</c>/<c>metricName</c> required (blank rejected exactly as the
/// factory hard-rejects them), <c>deviceId</c> optional, <c>dataType</c> and <c>qos</c> read
/// strictly but ONLY when present (matching <c>TryReadEnumStrict</c>'s absent/valid/invalid
/// split — a Sparkplug tag legitimately omits <c>dataType</c> and takes whatever the birth
/// certificate declares). Plain-only fields (<c>topic</c>, <c>payloadFormat</c>, <c>jsonPath</c>)
/// are NOT checked in this mode, matching the factory's "Plain-shape keys are read but not
/// required" stance for a retyped blob's leftovers.
/// </para>
/// <para>
/// ONE Sparkplug rule is likewise stricter than the factory: <c>groupId</c>/<c>edgeNodeId</c>/
/// <c>deviceId</c> reject <c>/</c>, <c>+</c>, and <c>#</c> — see
/// <see cref="SparkplugSegmentIllegalChars"/> for why (a decoded incoming id can never contain
/// one, so an authored id that does is a permanently dead binding, and <c>+</c>/<c>#</c> would
/// corrupt the driver's own subscription filter). <c>metricName</c> carries NO such restriction —
/// it is not a topic segment, and Sparkplug's own canonical metric names use <c>/</c> (e.g.
/// <c>Node Control/Rebirth</c>); rejecting it would block legitimate authoring.
/// </para>
/// </remarks>
/// <returns>An error message describing the validation failure, or <c>null</c> when valid.</returns>
public string? Validate()
{
if (Mode == MqttMode.SparkplugB) { return ValidateSparkplug(); }
if (DescribeInvalidEnum<MqttPayloadFormat>(_bag, PayloadFormatKey) is { } pfError) { return pfError; }
if (DescribeInvalidEnum<DriverDataType>(_bag, DataTypeKey) is { } dtError) { return dtError; }
if (DescribeInvalidQos(_bag) is { } qosError) { return qosError; }
var topic = Topic.Trim();
if (string.IsNullOrEmpty(topic)) { return "A topic is required."; }
if (topic.IndexOfAny(TopicWildcards) >= 0)
{
return $"Topic '{topic}' contains an MQTT wildcard (+ or #); a tag's topic must be concrete, "
+ "or the tag would be fed by every matching topic.";
}
// NB no jsonPath rule — see the remarks above. A blank path is the driver's document-root
// default, not an authoring error.
return null;
}
/// <summary>Sparkplug-mode half of <see cref="Validate"/> — see its remarks for the full rationale.</summary>
/// <returns>An error message describing the validation failure, or <c>null</c> when valid.</returns>
private string? ValidateSparkplug()
{
// Same DriverDataType enum + same strict absent/valid/invalid split as Plain's DataType check —
// dataType is one JSON key read by both FromTagConfig and FromSparkplugTagConfig identically.
if (DescribeInvalidEnum<DriverDataType>(_bag, DataTypeKey) is { } dtError) { return dtError; }
if (DescribeInvalidQos(_bag) is { } qosError) { return qosError; }
var groupId = GroupId.Trim();
var edgeNodeId = EdgeNodeId.Trim();
var deviceId = DeviceId.Trim();
var metricName = MetricName.Trim();
if (string.IsNullOrEmpty(groupId)) { return "A Sparkplug group ID is required."; }
if (string.IsNullOrEmpty(edgeNodeId)) { return "A Sparkplug edge node ID is required."; }
if (string.IsNullOrEmpty(metricName)) { return "A Sparkplug metric name is required."; }
if (DescribeInvalidSparkplugSegment("group ID", groupId) is { } gErr) { return gErr; }
if (DescribeInvalidSparkplugSegment("edge node ID", edgeNodeId) is { } eErr) { return eErr; }
// deviceId is optional — only validated when the operator actually supplied one.
if (deviceId.Length > 0 && DescribeInvalidSparkplugSegment("device ID", deviceId) is { } dErr) { return dErr; }
return null;
}
/// <summary>
/// Describes an <paramref name="value"/> that is illegal as a Sparkplug group/edge-node/device
/// id segment (see <see cref="SparkplugSegmentIllegalChars"/>), or <c>null</c> when clean.
/// </summary>
/// <param name="fieldLabel">The human-readable field name for the error message.</param>
/// <param name="value">The trimmed, non-blank field value to check.</param>
/// <returns>The error text, or <c>null</c>.</returns>
private static string? DescribeInvalidSparkplugSegment(string fieldLabel, string value)
=> value.IndexOfAny(SparkplugSegmentIllegalChars) >= 0
? $"Sparkplug {fieldLabel} '{value}' contains a character ('/', '+', or '#') that cannot appear " +
"in an MQTT topic segment; a decoded incoming id can never contain one, so this could never bind."
: null;
/// <summary>
/// Infers the editor's sub-shape from the blob: any non-blank Sparkplug descriptor key marks a
/// Sparkplug tag, otherwise Plain.
/// </summary>
/// <param name="o">The parsed TagConfig key bag.</param>
/// <returns>The inferred mode.</returns>
private static MqttMode InferMode(JsonObject o)
=> SparkplugKeys.Any(k => !string.IsNullOrWhiteSpace(TagConfigJson.GetString(o, k)))
? MqttMode.SparkplugB
: MqttMode.Plain;
/// <summary>Reads an int value, or <c>null</c> when absent/null/not an integer (so an absent key stays absent).</summary>
/// <param name="o">The JSON object to read from.</param>
/// <param name="name">The property name to read.</param>
/// <returns>The int value, or <c>null</c>.</returns>
private static int? GetIntNullable(JsonObject o, string name)
=> o.TryGetPropertyValue(name, out var n) && n is JsonValue v && v.TryGetValue<int>(out var i) ? i : null;
/// <summary>
/// Reads an enum by its serialised name, or <c>null</c> when absent/unparseable — the nullable
/// counterpart of <see cref="TagConfigJson.GetEnum{TEnum}"/>, used for <see cref="MetricDataType"/>
/// so "the key is absent" (take the birth's declared type) is distinguishable from any real member.
/// </summary>
/// <typeparam name="TEnum">The enum type to parse.</typeparam>
/// <param name="o">The JSON object to read from.</param>
/// <param name="name">The property name to read.</param>
/// <returns>The parsed enum value, or <c>null</c>.</returns>
private static TEnum? GetEnumNullable<TEnum>(JsonObject o, string name) where TEnum : struct, Enum
=> TagConfigJson.GetString(o, name) is { } s && Enum.TryParse<TEnum>(s, ignoreCase: true, out var v) ? v : null;
/// <summary>
/// Describes a present-but-invalid enum field, or <c>null</c> when it is absent or valid.
/// Mirrors <c>TagConfigJson.TryReadEnum</c>'s absent/valid/invalid split exactly — including its
/// treatment of a present-but-non-string value as ABSENT — so the editor never blocks a blob the
/// driver would happily default.
/// </summary>
/// <typeparam name="TEnum">The enum type expected.</typeparam>
/// <param name="o">The TagConfig key bag.</param>
/// <param name="name">The property name to describe.</param>
/// <returns>The error text, or <c>null</c>.</returns>
private static string? DescribeInvalidEnum<TEnum>(JsonObject o, string name) where TEnum : struct, Enum
{
if (!o.TryGetPropertyValue(name, out var n) || n is not JsonValue v || !v.TryGetValue<string>(out var raw))
{
return null;
}
return Enum.TryParse<TEnum>(raw, ignoreCase: true, out _)
? null
: $"'{raw}' is not a valid {name}; valid: {string.Join(", ", Enum.GetNames<TEnum>())}.";
}
/// <summary>
/// Describes a present-but-invalid <c>qos</c> field, or <c>null</c> when it is absent or a legal
/// MQTT QoS. Matches the factory's strict read: absent ⇒ fine; a JSON integer 02 ⇒ fine;
/// anything else present (non-number, non-integer, out of range) ⇒ rejected.
/// </summary>
/// <param name="o">The TagConfig key bag.</param>
/// <returns>The error text, or <c>null</c>.</returns>
private static string? DescribeInvalidQos(JsonObject o)
{
if (!o.TryGetPropertyValue(QosKey, out var n) || n is null) { return null; }
if (n is JsonValue v && v.TryGetValue<int>(out var i) && i is >= 0 and <= 2) { return null; }
return $"'{n.ToJsonString()}' is not a valid QoS; valid: 0, 1, 2.";
}
}
@@ -26,6 +26,7 @@ public static class TagConfigEditorMap
// constant is deliberately absent until Task 11 wires the factory (DriverTypeNamesGuardTests
// asserts bidirectional parity). Task 11 repoints all Sql keys onto DriverTypeNames.Sql.
[SqlDriver.DriverTypeName] = typeof(Components.Shared.Uns.TagEditors.SqlTagConfigEditor),
[DriverTypeNames.Mqtt] = typeof(Components.Shared.Uns.TagEditors.MqttTagConfigEditor),
};
/// <summary>Returns the editor component type for a driver type, or null if none is registered.</summary>
@@ -26,6 +26,7 @@ public static class TagConfigValidator
[DriverTypeNames.Calculation] = j => CalculationTagConfigModel.FromJson(j).Validate(),
// Keyed off SqlDriver.DriverTypeName (= "Sql") — see the note in TagConfigEditorMap.
[SqlDriver.DriverTypeName] = j => SqlTagConfigModel.FromJson(j).Validate(),
[DriverTypeNames.Mqtt] = j => MqttTagConfigModel.FromJson(j).Validate(),
};
/// <summary>
@@ -43,6 +43,7 @@
Driver.Sql + .Contracts + Microsoft.Data.SqlClient transitively — reviewed/accepted, see
the Browser project's own csproj comment (AdminUI already carries SqlClient for ConfigDb). -->
<ProjectReference Include="..\..\Drivers\ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser\ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser.csproj"/>
<ProjectReference Include="..\..\Drivers\ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Browser\ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Browser.csproj"/>
</ItemGroup>
</Project>
@@ -19,6 +19,7 @@ using OpcUaProbe = Driver.OpcUaClient.OpcUaClientDriverProbe;
using GalaxyProbe = Driver.Galaxy.GalaxyDriverProbe;
using CalculationProbe = Driver.Calculation.CalculationDriverProbe;
using SqlProbe = Driver.Sql.SqlDriverProbe;
using MqttProbe = Driver.Mqtt.MqttDriverProbe;
/// <summary>
/// Wires every cross-platform driver assembly's <c>Register(registry, loggerFactory)</c>
@@ -124,6 +125,7 @@ public static class DriverFactoryBootstrap
services.TryAddEnumerable(ServiceDescriptor.Singleton<IDriverProbe, GalaxyProbe>());
services.TryAddEnumerable(ServiceDescriptor.Singleton<IDriverProbe, CalculationProbe>());
services.TryAddEnumerable(ServiceDescriptor.Singleton<IDriverProbe, SqlProbe>());
services.TryAddEnumerable(ServiceDescriptor.Singleton<IDriverProbe, MqttProbe>());
return services;
}
@@ -146,6 +148,7 @@ public static class DriverFactoryBootstrap
Driver.FOCAS.FocasDriverFactoryExtensions.Register(registry);
Driver.Galaxy.GalaxyDriverFactoryExtensions.Register(registry, secretResolver, loggerFactory);
Driver.Modbus.ModbusDriverFactoryExtensions.Register(registry, loggerFactory);
Driver.Mqtt.MqttDriverFactoryExtensions.Register(registry, loggerFactory);
Driver.OpcUaClient.OpcUaClientDriverFactoryExtensions.Register(registry, loggerFactory, secretResolver);
Driver.S7.S7DriverFactoryExtensions.Register(registry);
Driver.Sql.SqlDriverFactoryExtensions.Register(registry, loggerFactory);
@@ -74,6 +74,7 @@
<ProjectReference Include="..\..\Drivers\ZB.MOM.WW.OtOpcUa.Driver.Galaxy\ZB.MOM.WW.OtOpcUa.Driver.Galaxy.csproj"/>
<ProjectReference Include="..\..\Drivers\ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway\ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.csproj"/>
<ProjectReference Include="..\..\Drivers\ZB.MOM.WW.OtOpcUa.Driver.Modbus\ZB.MOM.WW.OtOpcUa.Driver.Modbus.csproj"/>
<ProjectReference Include="..\..\Drivers\ZB.MOM.WW.OtOpcUa.Driver.Mqtt\ZB.MOM.WW.OtOpcUa.Driver.Mqtt.csproj"/>
<ProjectReference Include="..\..\Drivers\ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient\ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.csproj"/>
<ProjectReference Include="..\..\Drivers\ZB.MOM.WW.OtOpcUa.Driver.S7\ZB.MOM.WW.OtOpcUa.Driver.S7.csproj"/>
<ProjectReference Include="..\..\Drivers\ZB.MOM.WW.OtOpcUa.Driver.Sql\ZB.MOM.WW.OtOpcUa.Driver.Sql.csproj"/>