3641d56154
- HeidenhainDriverForm over HeidenhainDriverFormModel (JsonObject bag preserves Devices and unknown keys; no enum fields; Validate asks HeidenhainOptionsParser) + DriverConfigFormMap. - HeidenhainDeviceForm/Model (Host, Port 19000, optional per-device password references) + DeviceFormMap. Literal passwords are refused in both forms. - RawDriverTypeDialog entry; HeidenhainTagConfigEditor over HeidenhainTagConfigModel, whose Validate is the driver's own TryFromTagConfig; catalog leaves derive dataType + isArray; registered in TagConfigEditorMap + TagConfigValidator. - CsvColumnMap (HhAddress/HhDataType/HhIsArray) and RawBrowseCommitMapper branch. - Guards: DriverPageJsonConverterTests external-serializer entry, TagConfigNonEnumParityTests runtime map + corpus + freshly-authored rows, TagConfigRoundTrip, HeidenhainAuthoringTests.
640 lines
39 KiB
C#
640 lines
39 KiB
C#
using System.Text.Json.Nodes;
|
|
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.AbCip;
|
|
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
|
|
using ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts;
|
|
using ZB.MOM.WW.OtOpcUa.Driver.TwinCAT;
|
|
using ZB.MOM.WW.OtOpcUa.Driver.Mqtt;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Uns;
|
|
|
|
/// <summary>
|
|
/// Pure mapper for the "Browse device…" re-target: turns a selected driver-browse leaf into a
|
|
/// <see cref="RawTagImportRow"/> ready for <see cref="IRawTreeService.ImportTagsAsync"/>. Under the v3
|
|
/// identity contract the tag's identity is its RawPath (device + optional group + name), so the leaf's
|
|
/// driver reference is written into the driver-typed <c>TagConfig</c> as an ordinary <b>address field</b>
|
|
/// (<c>nodeId</c>/<c>tagPath</c>/<c>symbolPath</c>/<c>address</c>/<c>attributeRef</c> per driver) — never an
|
|
/// identity key. The address-field write reuses the same <c><Driver>TagConfigModel</c> the typed tag
|
|
/// editors use, so a browse-committed tag round-trips through that editor unchanged.
|
|
/// </summary>
|
|
public static class RawBrowseCommitMapper
|
|
{
|
|
/// <summary>
|
|
/// Maps one selected browse leaf into an import row.
|
|
/// </summary>
|
|
/// <param name="driverType">The owning device's driver type (a <see cref="DriverTypeNames"/> value).</param>
|
|
/// <param name="fullName">The leaf's driver-side full reference (the <c>BrowseNode.NodeId</c> = the
|
|
/// captured <c>DriverAttributeInfo.FullName</c>) — becomes the driver-typed address field.</param>
|
|
/// <param name="browseName">The leaf's browse name. Becomes the raw tag <c>Name</c> after
|
|
/// <see cref="ToRawSegment"/> coerces it into a legal RawPath segment — a Sparkplug metric name
|
|
/// legitimately contains <c>/</c> and a Name may not. The leaf's binding address is
|
|
/// unaffected: it travels separately into the <c>TagConfig</c>.</param>
|
|
/// <param name="driverDataType">The leaf's <see cref="DriverDataType"/> name (from the browse
|
|
/// attribute side-channel), or null when the browser cannot report a type (e.g. the OPC UA Client tree).</param>
|
|
/// <param name="defaultDataType">The OPC-UA built-in type name to fall back to when
|
|
/// <paramref name="driverDataType"/> is null/unmappable.</param>
|
|
/// <param name="groupPrefix">The target TagGroup's device-relative path when committing under a group,
|
|
/// or null when committing at the device root. Prepended to any mirrored folder path.</param>
|
|
/// <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,
|
|
string fullName,
|
|
string browseName,
|
|
string? driverDataType,
|
|
string defaultDataType,
|
|
string? groupPrefix,
|
|
IReadOnlyList<string>? folderPath,
|
|
bool createGroups,
|
|
IReadOnlyDictionary<string, string>? addressFields = null)
|
|
{
|
|
var dataType = MapDataType(driverDataType) ?? defaultDataType;
|
|
var tagConfig = BuildTagConfig(driverType, fullName, addressFields, driverDataType);
|
|
var name = ToRawSegment(browseName);
|
|
var mirrored = createGroups && folderPath is { Count: > 0 }
|
|
? string.Join(RawPaths.Separator, folderPath)
|
|
: null;
|
|
var groupPath = CombineGroupPath(groupPrefix, mirrored);
|
|
|
|
// Access baseline mirrors manual entry (Read); write-idempotent stays false (unknown at browse time);
|
|
// no poll-group (batching is authored later).
|
|
return new RawTagImportRow(
|
|
groupPath,
|
|
new RawTagInput(name, dataType, TagAccessLevel.Read, WriteIdempotent: false, PollGroupId: null, tagConfig));
|
|
}
|
|
|
|
/// <summary>
|
|
/// Coerces a browse leaf's name into a legal RawPath segment: trims the ends and
|
|
/// replaces every <see cref="RawPaths.Separator"/> with <see cref="SeparatorReplacement"/>.
|
|
/// </summary>
|
|
/// <param name="browseName">The leaf's browse name, as the driver reported it.</param>
|
|
/// <returns>A <see cref="RawPaths.ValidateSegment"/>-clean segment; blank only if the input was blank.</returns>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// <b>The Name and the address are two fields, and only the Name is constrained.</b> A tag Name is
|
|
/// a RawPath segment, so it may not contain <c>/</c>; the driver-side reference it binds by has no
|
|
/// such rule and is written into the <c>TagConfig</c> untouched by <see cref="BuildTagConfig"/>.
|
|
/// Refusing the commit outright conflated the two — and it refused a metric the Sparkplug B spec
|
|
/// makes <em>mandatory</em> on every edge node (<c>Node Control/Rebirth</c>), so the one metric an
|
|
/// operator is most likely to browse for was the one that could not be committed.
|
|
/// </para>
|
|
/// <para>
|
|
/// <b>This deliberately covers every driver, not just MQTT.</b> The argument is about what a Name
|
|
/// is, not about who produced it: any browser whose leaf name carries a <c>/</c> hits the same
|
|
/// wall, and for all of them the binding address travels separately and survives intact.
|
|
/// </para>
|
|
/// <para>
|
|
/// <b>The rule set is <see cref="RawPaths.ValidateSegment"/>'s, not an invented one</b> — empty,
|
|
/// an embedded separator, and leading/trailing whitespace are exactly what it rejects, so trimming
|
|
/// and replacing is total rather than a best-effort guess. <c>RawBrowseCommitMapperTests</c> pins
|
|
/// that by asserting the output validates, so a new rule there fails here instead of silently
|
|
/// letting an illegal Name through to the import.
|
|
/// </para>
|
|
/// <para>
|
|
/// Sanitising is not injective, so two distinct leaves can land on one Name — see
|
|
/// <see cref="DescribeNameCollisions"/>, which is why a caller must run that check rather than
|
|
/// trusting this in isolation.
|
|
/// </para>
|
|
/// </remarks>
|
|
public static string ToRawSegment(string? browseName)
|
|
{
|
|
var trimmed = (browseName ?? "").Trim();
|
|
return trimmed.Length == 0 ? "" : trimmed.Replace(RawPaths.Separator, SeparatorReplacement);
|
|
}
|
|
|
|
/// <summary>What <see cref="ToRawSegment"/> substitutes for a separator inside a browse name.</summary>
|
|
private const char SeparatorReplacement = '_';
|
|
|
|
/// <summary>
|
|
/// Names the browse leaves that <see cref="ToRawSegment"/> would land on one tag Name within one
|
|
/// target group, or an empty list when the mapped rows are all distinct.
|
|
/// </summary>
|
|
/// <param name="browseNames">The ORIGINAL leaf names, in the same order as <paramref name="rows"/>.</param>
|
|
/// <param name="rows">The mapped import rows.</param>
|
|
/// <returns>One operator-facing message per colliding set; empty when there is nothing to refuse.</returns>
|
|
/// <remarks>
|
|
/// Sanitising maps many names onto one — <c>A/B</c> and <c>A_B</c> both become <c>A_B</c> — and a
|
|
/// silent collision is materially worse than the refusal it replaced: two distinct metrics would
|
|
/// become one tag bound to whichever address the import happened to keep. So the coercion is only
|
|
/// safe when paired with this check.
|
|
/// <para>
|
|
/// Scoped per target group, because the Name only has to be unique within its group — the same
|
|
/// metric name under two devices is not a collision.
|
|
/// </para>
|
|
/// <para>
|
|
/// The message names the specific pair rather than failing the batch anonymously. The commit
|
|
/// itself stays all-or-nothing, matching the CSV import's staged-review semantics: nothing is
|
|
/// written while any row is unresolvable.
|
|
/// </para>
|
|
/// </remarks>
|
|
public static IReadOnlyList<string> DescribeNameCollisions(
|
|
IReadOnlyList<string> browseNames,
|
|
IReadOnlyList<RawTagImportRow> rows)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(browseNames);
|
|
ArgumentNullException.ThrowIfNull(rows);
|
|
|
|
return rows
|
|
.Select((row, i) => (row, original: i < browseNames.Count ? browseNames[i] : row.Tag.Name))
|
|
.GroupBy(x => (x.row.TagGroupPath ?? "", x.row.Tag.Name), TupleComparer)
|
|
.Where(g => g.Count() > 1)
|
|
.Select(g =>
|
|
$"'{g.Key.Item2}' would be the name of {g.Count()} different tags"
|
|
+ (string.IsNullOrEmpty(g.Key.Item1) ? "" : $" in group '{g.Key.Item1}'")
|
|
+ $": {string.Join(", ", g.Select(x => $"'{x.original}'"))}. "
|
|
+ "Their names differ only by characters that are not legal in a tag name. Commit them "
|
|
+ "separately, or author them by manual entry with distinct names.")
|
|
.ToList();
|
|
}
|
|
|
|
/// <summary>Ordinal comparer for the (group path, name) collision key — RawPath comparison is ordinal.</summary>
|
|
private static readonly IEqualityComparer<(string, string)> TupleComparer =
|
|
EqualityComparer<(string, string)>.Create(
|
|
(a, b) => RawPaths.Comparer.Equals(a.Item1, b.Item1) && RawPaths.Comparer.Equals(a.Item2, b.Item2),
|
|
x => HashCode.Combine(RawPaths.Comparer.GetHashCode(x.Item1), RawPaths.Comparer.GetHashCode(x.Item2)));
|
|
|
|
/// <summary>
|
|
/// Maps a <see cref="DriverDataType"/> enum NAME (as reported by the browse attribute side-channel) to the
|
|
/// OPC-UA built-in type name a <see cref="RawTagInput.DataType"/> carries. Mirrors
|
|
/// <c>DiscoveredNodeMapper.ToBuiltinTypeString</c>: most names pass through, <c>Float32</c>/<c>Float64</c>
|
|
/// become <c>Float</c>/<c>Double</c>, and <c>Reference</c> (a Galaxy attribute ref) carries as <c>String</c>.
|
|
/// Returns null when the input is null/blank or not a known driver data type (caller supplies a default).
|
|
/// </summary>
|
|
/// <param name="driverDataType">The <see cref="DriverDataType"/> name, or null/blank.</param>
|
|
/// <returns>The OPC-UA built-in type name, or null when unmappable.</returns>
|
|
public static string? MapDataType(string? driverDataType)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(driverDataType)
|
|
|| !Enum.TryParse<DriverDataType>(driverDataType, ignoreCase: true, out var dt))
|
|
return null;
|
|
|
|
return dt switch
|
|
{
|
|
DriverDataType.Boolean => "Boolean",
|
|
DriverDataType.Int16 => "Int16",
|
|
DriverDataType.Int32 => "Int32",
|
|
DriverDataType.Int64 => "Int64",
|
|
DriverDataType.UInt16 => "UInt16",
|
|
DriverDataType.UInt32 => "UInt32",
|
|
DriverDataType.UInt64 => "UInt64",
|
|
DriverDataType.Float32 => "Float",
|
|
DriverDataType.Float64 => "Double",
|
|
DriverDataType.String => "String",
|
|
DriverDataType.DateTime => "DateTime",
|
|
DriverDataType.Reference => "String",
|
|
_ => null,
|
|
};
|
|
}
|
|
|
|
/// <summary>
|
|
/// Builds the driver-typed <c>TagConfig</c> JSON for a browse-committed tag: the driver's address field
|
|
/// from <paramref name="fullName"/>, plus — for a driver that binds by its OWN data-type enum — the
|
|
/// native type the browse leaf stated (see <see cref="NativeType{TEnum}"/>). Every other field stays at
|
|
/// its model default, for the operator to author afterwards. 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>The data type is not optional decoration for AB CIP / TwinCAT / FOCAS</b> — their tag-definition
|
|
/// factories decode the wire bytes by this field, not by the raw tag row's OPC UA <c>DataType</c>. A
|
|
/// committed leaf left at the model default therefore reads real data through the wrong decoder and
|
|
/// publishes a plausible wrong number rather than an error.
|
|
/// </para>
|
|
/// <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>
|
|
/// <para>
|
|
/// <b>MTConnect binds by the driver-AGNOSTIC type, so it is forwarded from
|
|
/// <paramref name="driverDataType"/> rather than from a stated native field</b>. Its
|
|
/// coercion type IS a <see cref="DriverDataType"/> — the very enum the browse leaf carries, produced
|
|
/// by the driver's own <c>/probe</c> inference — so there is no separate native spelling for the
|
|
/// browser to state and nothing to re-derive. Left unforwarded, the model's <c>String</c> default was
|
|
/// written as a non-blank authored type, which OUT-RANKS that same inference at the driver: every
|
|
/// browsed SAMPLE published text into the numeric node <see cref="MapLeaf"/> had just typed from the
|
|
/// identical inference. Same defect class as the type-inference bug above, one door along.
|
|
/// </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>
|
|
/// <param name="driverDataType">The leaf's <see cref="DriverDataType"/> NAME, for a driver whose tag
|
|
/// config carries that agnostic enum rather than one of its own (MTConnect); null when the browser
|
|
/// reported no type, which keeps the driver model's default.</param>
|
|
/// <returns>The serialised driver-typed <c>TagConfig</c> JSON.</returns>
|
|
public static string BuildTagConfig(
|
|
string driverType,
|
|
string fullName,
|
|
IReadOnlyDictionary<string, string>? addressFields = null,
|
|
string? driverDataType = 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))
|
|
return new AbCipTagConfigModel
|
|
{
|
|
TagPath = address,
|
|
DataType = NativeType(addressFields, AbCipDataType.DInt),
|
|
}.ToJson();
|
|
if (Is(driverType, DriverTypeNames.AbLegacy))
|
|
return new AbLegacyTagConfigModel { Address = address }.ToJson();
|
|
if (Is(driverType, DriverTypeNames.TwinCAT))
|
|
return new TwinCATTagConfigModel
|
|
{
|
|
SymbolPath = address,
|
|
DataType = NativeType(addressFields, TwinCATDataType.DInt),
|
|
}.ToJson();
|
|
if (Is(driverType, DriverTypeNames.FOCAS))
|
|
return new FocasTagConfigModel
|
|
{
|
|
Address = address,
|
|
DataType = NativeType(addressFields, FocasDataType.Int32),
|
|
}.ToJson();
|
|
if (Is(driverType, DriverTypeNames.S7))
|
|
return new S7TagConfigModel { Address = address }.ToJson();
|
|
// MTConnect: the DataItem id. The driver also accepts "address"/"dataItemId", but the typed
|
|
// editor's canonical spelling is fullName — committing under the generic key below would open
|
|
// in that editor with an EMPTY id field and blank it on save.
|
|
//
|
|
// The coercion type is the leaf's own inferred DriverDataType — see the remarks above.
|
|
// A stated native "dataType" address field still wins where a browser supplies one, so this
|
|
// branch reads exactly like its AB CIP / TwinCAT / FOCAS siblings; the difference is only that
|
|
// MTConnect's fallback is the leaf's inference rather than a fixed model default.
|
|
if (Is(driverType, DriverTypeNames.MTConnect))
|
|
return new MTConnectTagConfigModel
|
|
{
|
|
FullName = address,
|
|
DataType = NativeType(addressFields, ParseNativeType(driverDataType, DriverDataType.String)),
|
|
}.ToJson();
|
|
if (Is(driverType, DriverTypeNames.Galaxy))
|
|
return WriteSingleKey("attributeRef", address);
|
|
// Heidenhain: a browsed leaf is a fixed catalog address; the typed model derives its data type and array
|
|
// flag from the catalog, so the committed tag opens in the editor and passes the driver's gate unchanged.
|
|
if (Is(driverType, DriverTypeNames.Heidenhain))
|
|
return new HeidenhainTagConfigModel { Address = address }.ToJson();
|
|
// Sql: a browsed leaf is a COLUMN, and a column name alone cannot address a tag — SqlTagConfigModel
|
|
// needs the table too. SqlBrowseSession therefore travels schema/table/columnName in AddressFields.
|
|
// Without this branch a Sql commit fell through to the generic "address" key below, which the typed
|
|
// Sql editor does not read: it would open with empty fields and blank them on save — the exact
|
|
// failure the MTConnect branch above was added to avoid (deferment.md G-6). Sql IS browsable
|
|
// (SqlDriverBrowser), so the fallback's "browsable drivers are all handled above" was untrue.
|
|
if (Is(driverType, DriverTypeNames.Sql))
|
|
return BuildSqlTagConfig(address, addressFields);
|
|
|
|
// Unknown/flat-address driver (e.g. Modbus is not browsable): record the reference under a generic
|
|
// "address" key so nothing is lost. RawBrowseCommitMapperParityTests enumerates DriverTypeNames.All
|
|
// and holds this line to two claims rather than to the assertion the comment used to make:
|
|
// - a driver type with a bespoke IDriverBrowser registered by AddAdminUI() — read back from the
|
|
// real registration, not a list — must NOT reach here; and
|
|
// - any type that does reach here must be named in that test's GenericKeyExemptions with a reason,
|
|
// and every name on that list must still reach here — an exemption for a driver
|
|
// that has since gained a branch above is a stale claim, and a silently inert one.
|
|
// The universal DiscoveryDriverBrowser's arm is not derivable from the AdminUI assembly (it needs
|
|
// the runtime drivers + the Host's IDriverFactory), so it is covered by the exemption list rather
|
|
// than mechanically — see the test's remarks for the residual trust that leaves.
|
|
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;
|
|
|
|
// A Sparkplug *Array metric binds only to an ARRAY tag (#754): the session states the bit, and it
|
|
// is written as the JSON literal true — the only form the composer and the driver both read as an
|
|
// array. No arrayLength: a Sparkplug array is variable-length, so the node is ArrayDimensions=[0].
|
|
if (string.Equals(TryGetField(addressFields, MqttTagConfigKeys.IsArray), "true", StringComparison.OrdinalIgnoreCase))
|
|
o[MqttTagConfigKeys.IsArray] = true;
|
|
|
|
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>The phrase the MQTT/Sparkplug browser puts on the descriptor of a metric it refuses to
|
|
/// bind (<c>MqttBrowseSession.DescribeMetric</c>); matched case-insensitively.</summary>
|
|
public const string NotBindableMarker = "not bindable";
|
|
|
|
/// <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>
|
|
/// <param name="descriptor">The leaf attribute's descriptor line (<c>AttributeInfo.SecurityClass</c>),
|
|
/// when the lookup returned one. MQTT uses it to tell a deliberately unbindable Sparkplug metric
|
|
/// (<see cref="NotBindableMarker"/>) from a lookup that returned nothing.</param>
|
|
/// <returns>The operator-facing reason, or <c>null</c> when the leaf is committable.</returns>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// Exactly two drivers can fail this, and both for the same underlying reason: their binding is a
|
|
/// TUPLE, so a leaf can be selectable in the tree and still not carry enough to bind. Every
|
|
/// single-reference driver is unaffected — its address IS the node id.
|
|
/// </para>
|
|
/// <para>
|
|
/// <b>MQTT</b>: 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.
|
|
/// </para>
|
|
/// <para>
|
|
/// <b>Sql</b>: a column of a table carrying no date/time column. A wide-row tag must say
|
|
/// which ROW it reads, and with no timestamp to order by the only remaining selector is a
|
|
/// <c>whereColumn</c>/<c>whereValue</c> pair — whose value is <em>data</em>, not schema, and therefore
|
|
/// is not something any browse can know. Refusing here rather than letting the import's own gate
|
|
/// refuse the blob is what makes the message name the table and the fix; the gate would only report
|
|
/// that a row selector is missing, on every selected column at once.
|
|
/// </para>
|
|
/// </remarks>
|
|
public static string? DescribeUncommittableLeaf(
|
|
string driverType,
|
|
string browseName,
|
|
IReadOnlyDictionary<string, string>? addressFields,
|
|
string? descriptor = null)
|
|
{
|
|
if (Is(driverType, DriverTypeNames.Mqtt)) return DescribeUncommittableMqttLeaf(browseName, addressFields, descriptor);
|
|
if (Is(driverType, DriverTypeNames.Sql)) return DescribeUncommittableSqlLeaf(browseName, addressFields);
|
|
return null;
|
|
}
|
|
|
|
/// <summary>Describes an MQTT leaf that states no address at all, or <c>null</c> when it states one.</summary>
|
|
/// <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>
|
|
/// <param name="descriptor">The attribute's descriptor line (<c>AttributeInfo.SecurityClass</c>), if the
|
|
/// lookup returned one.</param>
|
|
/// <returns>The operator-facing reason, or <c>null</c>.</returns>
|
|
private static string? DescribeUncommittableMqttLeaf(
|
|
string browseName, IReadOnlyDictionary<string, string>? addressFields, string? descriptor)
|
|
{
|
|
if (TryGetField(addressFields, MqttTagConfigKeys.MetricName) is not null) return null;
|
|
if (TryGetField(addressFields, MqttTagConfigKeys.Topic) is not null) return null;
|
|
|
|
// G2-F2: the Sparkplug browser deliberately states no address for a metric the ingestor will not
|
|
// read (a type with no driver mapping — R06:M-5; *Array types are read since #754), and says so on its descriptor
|
|
// line. That is a "not bindable in this version" refusal, not a failed lookup, so say which.
|
|
if (descriptor is not null && descriptor.Contains(NotBindableMarker, StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
return $"'{browseName}' cannot be bound in this version: the Sparkplug browser reports it as "
|
|
+ $"\"{descriptor}\". Metrics of an unsupported type are not read yet; "
|
|
+ "remove it from the selection.";
|
|
}
|
|
|
|
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>
|
|
/// Describes a Sql column leaf whose wide-row binding cannot be completed from the catalog, or
|
|
/// <c>null</c> when it can.
|
|
/// </summary>
|
|
/// <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>.</returns>
|
|
private static string? DescribeUncommittableSqlLeaf(
|
|
string browseName, IReadOnlyDictionary<string, string>? addressFields)
|
|
{
|
|
if (TryGetField(addressFields, SqlBrowseAddressFields.Table) is not { } table)
|
|
{
|
|
return $"'{browseName}' could not be committed: the browse session reported no Sql table for it "
|
|
+ "(its attribute lookup returned nothing). Re-open the browser and re-select it, or author "
|
|
+ "the tag manually.";
|
|
}
|
|
|
|
if (TryGetField(addressFields, SqlBrowseAddressFields.TopByTimestamp) is not null) return null;
|
|
|
|
return $"'{browseName}' could not be committed: '{table}' has no date/time column, so which ROW the "
|
|
+ "tag reads cannot be derived from the schema. Author it in the tag editor with a "
|
|
+ "whereColumn/whereValue row selector — the value it matches on is data, not schema, so the "
|
|
+ "browser cannot supply it.";
|
|
}
|
|
|
|
/// <summary>
|
|
/// Reads the browse leaf's driver-native data type out of the stated address fields, falling
|
|
/// back to <paramref name="fallback"/> — the driver model's own default — when the browser
|
|
/// stated none or stated one this driver's enum does not name.
|
|
/// </summary>
|
|
/// <typeparam name="TEnum">The driver's own data-type enum.</typeparam>
|
|
/// <param name="addressFields">The leaf's stated address fields.</param>
|
|
/// <param name="fallback">The model default to keep when no usable native type was stated.</param>
|
|
/// <returns>The parsed native type, or <paramref name="fallback"/>.</returns>
|
|
/// <remarks>
|
|
/// <b>The type is READ, never derived.</b> A commit used to leave this at the
|
|
/// model default, so every browsed AB CIP leaf — a REAL, a BOOL, a STRING — was written as
|
|
/// <c>DInt</c> and the driver decoded four bytes of a float as an integer: a plausible
|
|
/// number, no error, wrong value. Deriving it from the leaf's driver-AGNOSTIC
|
|
/// <c>DriverDataType</c> instead would fix the REAL and still get the integers wrong —
|
|
/// <c>SInt</c>, <c>Int</c> and <c>DInt</c> all collapse into <c>Int32</c> on the way out, so
|
|
/// a 16-bit INT would come back as a 32-bit DINT. The producing driver knows its own type
|
|
/// and states it; see <c>CapturedTreeBrowseSession.NativeTypeFields</c>.
|
|
/// <para>
|
|
/// Case-insensitive because the key travels as an enum NAME, and a driver is free to spell
|
|
/// it however its own enum does. An unparseable value keeps the default rather than failing
|
|
/// the commit: the address — the part that cannot be re-derived later — is still correct,
|
|
/// and the operator can fix the type in the typed editor.
|
|
/// </para>
|
|
/// <para>
|
|
/// <b>A numeric string is refused before parsing.</b> <c>Enum.TryParse</c> accepts <c>"9"</c>
|
|
/// for any enum and hands back whatever member sits at ordinal 9 — which <c>Enum.IsDefined</c>
|
|
/// then happily confirms — so a driver that stamped a number would bind a tag to a type nobody
|
|
/// named. Same trap <c>MTConnectTagConfigModel</c> documents for its own <c>dataType</c>.
|
|
/// </para>
|
|
/// </remarks>
|
|
private static TEnum NativeType<TEnum>(IReadOnlyDictionary<string, string>? addressFields, TEnum fallback)
|
|
where TEnum : struct, Enum
|
|
=> ParseNativeType(TryGetField(addressFields, "dataType"), fallback);
|
|
|
|
/// <summary>
|
|
/// Parses one stated type NAME into a driver's data-type enum, keeping <paramref name="fallback"/>
|
|
/// for anything unusable — the rule <see cref="NativeType{TEnum}"/> applies to a stated address
|
|
/// field, split out so a type that arrives by another route (MTConnect's leaf-level
|
|
/// <see cref="DriverDataType"/>) is judged by exactly the same one.
|
|
/// </summary>
|
|
/// <typeparam name="TEnum">The data-type enum the driver's tag config carries.</typeparam>
|
|
/// <param name="raw">The stated type name, or null/blank.</param>
|
|
/// <param name="fallback">The default to keep when the name is absent or unusable.</param>
|
|
/// <returns>The parsed type, or <paramref name="fallback"/>.</returns>
|
|
/// <remarks>
|
|
/// See <see cref="NativeType{TEnum}"/> for why an unparseable name keeps the default rather than
|
|
/// failing the commit, and why a numeric string is refused before parsing at all.
|
|
/// </remarks>
|
|
private static TEnum ParseNativeType<TEnum>(string? raw, TEnum fallback)
|
|
where TEnum : struct, Enum
|
|
=> raw?.Trim() is { Length: > 0 } name
|
|
&& !char.IsAsciiDigit(name[0]) && name[0] is not ('-' or '+')
|
|
&& Enum.TryParse<TEnum>(name, ignoreCase: true, out var parsed)
|
|
&& Enum.IsDefined(parsed)
|
|
? parsed
|
|
: fallback;
|
|
|
|
/// <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 CSV import
|
|
/// combine semantics: blank collapses to null, and a present prefix + suffix join with the RawPath
|
|
/// separator. Returns null when both are absent (⇒ commit at the device root).
|
|
/// </summary>
|
|
/// <param name="prefix">The target TagGroup's device-relative path, or null/blank for the device root.</param>
|
|
/// <param name="suffix">The mirrored folder sub-path, or null/blank.</param>
|
|
/// <returns>The combined device-relative group path, or null for the device root.</returns>
|
|
public static string? CombineGroupPath(string? prefix, string? suffix)
|
|
{
|
|
prefix = string.IsNullOrWhiteSpace(prefix) ? null : prefix.Trim();
|
|
suffix = string.IsNullOrWhiteSpace(suffix) ? null : suffix.Trim();
|
|
if (prefix is null) return suffix;
|
|
return suffix is null ? prefix : $"{prefix}{RawPaths.SeparatorString}{suffix}";
|
|
}
|
|
|
|
private static bool Is(string driverType, string name)
|
|
=> string.Equals(driverType, name, StringComparison.OrdinalIgnoreCase);
|
|
|
|
/// <summary>
|
|
/// Builds a <c>SqlTagConfigModel</c> blob from a browsed COLUMN leaf. Maps to
|
|
/// <c>SqlTagModel.WideRow</c> — the browse tree is schema → table → column, which is precisely the
|
|
/// wide-row shape (one row holds many signals as columns). A KeyValue (EAV) tag cannot be derived
|
|
/// from a browse pick because its key VALUE is data, not schema, so the operator authors that in the
|
|
/// typed editor.
|
|
/// <para>Falls back to the generic key when the fields are absent — an older browser build, or a
|
|
/// hand-made selection — rather than emitting a half-built Sql blob.</para>
|
|
/// </summary>
|
|
/// <param name="address">The leaf's browse node id — the column-name fallback only.</param>
|
|
/// <param name="addressFields">The structured address the browse session stated.</param>
|
|
/// <returns>The serialised <c>TagConfig</c> JSON.</returns>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// <b>A wide-row tag needs three things, not two: the table, the column, and WHICH ROW</b>
|
|
/// This branch used to emit only the first two, and a selector-less wide-row blob is
|
|
/// refused by <c>SqlEquipmentTagParser</c> and by <c>SqlTagConfigModel.Validate</c> alike — so every
|
|
/// browse-committed Sql tag was unreadable, first silently (<c>BadNodeIdUnknown</c> forever) and then,
|
|
/// once the gate was put on the import, loudly at commit.
|
|
/// </para>
|
|
/// <para>
|
|
/// <b>The row selector is READ from the session's stated fields, never inferred here</b> — same rule
|
|
/// as MQTT's Sparkplug tuple. The session holds the table's column metadata and names a date/time
|
|
/// column by TYPE (see <c>SqlBrowseSession.PreferredTimestampColumn</c>); this mapper has only a
|
|
/// column name and a table name, from which any selector it invented would be a guess about data.
|
|
/// </para>
|
|
/// <para>
|
|
/// <b>When no candidate was stated the blob stays selector-less on purpose.</b> The alternatives are
|
|
/// worse: a fabricated selector binds the tag to a row nobody chose, and dropping to the generic
|
|
/// <c>{"address": …}</c> key throws away the table the session did know. Kept in the wide-row shape,
|
|
/// the blob opens in the typed editor with everything but the selector filled in — and the commit
|
|
/// itself does not reach here, because <see cref="DescribeUncommittableLeaf"/> refuses that leaf in
|
|
/// words first.
|
|
/// </para>
|
|
/// <para>
|
|
/// The blob's top-level <c>timestampColumn</c> (the SOURCE-timestamp column, which makes the driver
|
|
/// stamp values with the row's time instead of the poll time) is deliberately left unset: ordering by
|
|
/// a column is not a claim that it timestamps the value, and turning it on silently would change every
|
|
/// browse-committed tag's published SourceTimestamp.
|
|
/// </para>
|
|
/// </remarks>
|
|
private static string BuildSqlTagConfig(string address, IReadOnlyDictionary<string, string>? addressFields)
|
|
{
|
|
if (addressFields is null
|
|
|| !addressFields.TryGetValue(SqlBrowseAddressFields.Table, out var table)
|
|
|| string.IsNullOrWhiteSpace(table))
|
|
{
|
|
return WriteSingleKey("address", address);
|
|
}
|
|
|
|
addressFields.TryGetValue(SqlBrowseAddressFields.Schema, out var schema);
|
|
addressFields.TryGetValue(SqlBrowseAddressFields.ColumnName, out var columnName);
|
|
|
|
// Qualify the table with its schema when the schema is not the default, so a tag authored against
|
|
// "sales.Readings" cannot silently resolve to "dbo.Readings".
|
|
var qualified = !string.IsNullOrWhiteSpace(schema) && !string.Equals(schema, "dbo", StringComparison.OrdinalIgnoreCase)
|
|
? $"{schema}.{table}"
|
|
: table;
|
|
|
|
return new SqlTagConfigModel
|
|
{
|
|
Model = SqlTagModel.WideRow,
|
|
Table = qualified,
|
|
ColumnName = string.IsNullOrWhiteSpace(columnName) ? address : columnName,
|
|
|
|
// Null when the session stated none: SqlRowSelectorModel.ToJsonObject then returns null and the
|
|
// "rowSelector" key is omitted entirely, rather than written as an empty or blank-valued object.
|
|
RowSelector = new SqlRowSelectorModel
|
|
{
|
|
TopByTimestamp = TryGetField(addressFields, SqlBrowseAddressFields.TopByTimestamp),
|
|
},
|
|
}.ToJson();
|
|
}
|
|
|
|
private static string WriteSingleKey(string key, string value)
|
|
{
|
|
var o = new JsonObject { [key] = value };
|
|
return o.ToJsonString();
|
|
}
|
|
}
|