17130d9c82
Two guards enumerated the same RawBrowseCommitMapper.BuildTagConfig seam against
two hand-authored lists, which can only drift. The older one's list was also
misnamed: DriverDispatchMapParityTests.NonBrowsableDriverTypes really meant
"types that produce the generic key" — AbLegacy is genuinely non-browsable yet
correctly absent from it, because it has a typed branch.
Retire the older fact. RawBrowseCommitMapperParityTests.GenericKeyExemptions is
now the single hand-authored list for this seam: named for what it holds, one
written reason per entry, and checked against a DI-derived browsable set rather
than a second list. The one assertion the retired fact made that nothing here
covered — set EQUALITY, i.e. an exemption must still DESCRIBE the mapper — moves
across as Every_generic_key_exemption_still_commits_to_the_generic_key. Without
it, a driver that gained a typed branch would keep a silently inert exemption.
Item 2: OpcUaClientDriverBrowser and GalaxyDriverBrowser returned string
literals ("OpcUaClient", "GalaxyMxGateway") where Sql/Mqtt use DriverTypeNames.
The values matched, and nothing guarded that. Repoint both at the constants —
DriverTypeNames.Galaxy carries the identical value — and add
Every_registered_browser_is_keyed_by_a_declared_driver_type_name over the same
AddAdminUI() read-back. Compared ORDINALLY: BrowserSessionService indexes on the
exact persisted string, and case-only drift (TwinCat/TwinCAT) is what
DriverTypeNames exists to end. Browser projects are out of reach of
DriverTypeNamesGuardTests, which pins the driver FACTORIES' names.
Falsified, four ways, each red naming the offender and green on revert:
- #555 re-run: disabling the Sql branch reddens the two enumeration facts
("...no dedicated BuildTagConfig branch...: Sql"), the same catch the
retired fact made.
- giving Modbus a typed branch reddens ONLY the new stale-exemption fact,
proving it is not redundant with the four that were already there.
- SqlDriverBrowser.DriverType => "SQL" and Galaxy => "GalaxyMXGateway" each
redden the new browser-key guard by name — case-only typos, which an
OrdinalIgnoreCase compare would have waved through.
569 lines
34 KiB
C#
569 lines
34 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 WP6 "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 (Gitea #509). 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);
|
|
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 (Gitea #509): 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 #509 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 (Gitea #536).
|
|
/// </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>
|
|
/// </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,
|
|
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))
|
|
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.
|
|
if (Is(driverType, DriverTypeNames.MTConnect))
|
|
return new MTConnectTagConfigModel { FullName = address }.ToJson();
|
|
if (Is(driverType, DriverTypeNames.Galaxy))
|
|
return WriteSingleKey("attributeRef", address);
|
|
// 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 (Gitea #558) — 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 (Gitea #555).
|
|
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>
|
|
/// <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> (Gitea #552): 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)
|
|
{
|
|
if (Is(driverType, DriverTypeNames.Mqtt)) return DescribeUncommittableMqttLeaf(browseName, addressFields);
|
|
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>
|
|
/// <returns>The operator-facing reason, or <c>null</c>.</returns>
|
|
private static string? DescribeUncommittableMqttLeaf(
|
|
string browseName, IReadOnlyDictionary<string, string>? addressFields)
|
|
{
|
|
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>
|
|
/// Describes a Sql column leaf whose wide-row binding cannot be completed from the catalog, or
|
|
/// <c>null</c> when it can (Gitea #552).
|
|
/// </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 (Gitea #536).</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
|
|
=> TryGetField(addressFields, "dataType") is { Length: > 0 } raw
|
|
&& !char.IsAsciiDigit(raw[0]) && raw[0] is not ('-' or '+')
|
|
&& Enum.TryParse<TEnum>(raw, 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 WP5 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>
|
|
/// (Gitea #552). 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 #551 put the gate 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();
|
|
}
|
|
}
|