Merge branch 'feat/mqtt-sparkplug-driver'
# 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:
@@ -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));
|
||||
}
|
||||
+56
@@ -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 (0–2), 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.<X>.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><Driver>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 0–2 ⇒ 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<SparkplugDataType>()</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,
|
||||
};
|
||||
}
|
||||
+36
@@ -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<TDef></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 > 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, 0–2.</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) & 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 0–255 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 0–255, 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>
|
||||
|
||||
+61
@@ -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();
|
||||
}
|
||||
+73
@@ -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;
|
||||
|
||||
+23
-5
@@ -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)
|
||||
{
|
||||
|
||||
+19
-3
@@ -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)
|
||||
{
|
||||
|
||||
+331
@@ -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);
|
||||
}
|
||||
}
|
||||
+430
@@ -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">
|
||||
— 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…
|
||||
</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"),
|
||||
|
||||
+212
@@ -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><Driver>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 (0–2), 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 0–2 — 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 0–2 ⇒ 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"/>
|
||||
|
||||
Reference in New Issue
Block a user