6a01358b9a
A parity guard already existed for DriverTypeNames <-> the /raw driver picker, but not for the two dispatch maps downstream, so a driver could be registered, offered in the picker, and then have no config form. That is exactly what happened to Calculation (G-1) and to Sql/MTConnect/Calculation on the device modal (G-2) — both survived review because nothing could see them. The maps had to become data first. A Razor @switch compiles into BuildRenderTree's IL, so no test can enumerate its cases; the picker guard works only because RawDriverTypeDialog keeps its data in a field the markup enumerates. Both modals now render from DriverConfigFormMap / DeviceFormMap through <DynamicComponent>, and being public those maps need none of the picker test's BindingFlags.NonPublic fragility. - G-1 CalculationDriverForm.razor — RunTimeout was unauthorable via the UI. - G-2 Sql/MTConnect/Calculation declared single-connection rather than given hollow device forms; each holds one connection at the driver level. - G-3 DriverConfigModal's hardcoded "Galaxy or Mqtt" -> IsSingleConnection, so Sql/MTConnect authors are no longer sent to a device form with no endpoint. - G-4 DriverFormMapParityTests (5, both directions) + DriverDispatchMapParityTests. - G-5 CsvColumnMap entries for Sql, Mqtt, MTConnect. - G-6 RawBrowseCommitMapper Sql branch — and the browser end, which the audit missed: SqlBrowseSession emitted NO AddressFields, so a browsed leaf carried only a column name, and a column alone cannot address a Sql tag. It now travels schema/table/column and the mapper builds a WideRow config from them. A branch alone would have produced a half-built blob. Two findings while writing the guards. The G-6 test showed Modbus and Calculation also hit the generic address fallback — correctly, as neither is browsable — so it asserts the fall-through set EQUALS a documented non-browsable list in both directions; a one-way check would let a newly browsable driver be quietly added to the exclusion list. And TagConfigDriverTypeNameGuardTests was hollow: it enumerated a hand-written TheoryData that had already drifted (omitting Galaxy, Sql and Mqtt), guarding renames but not gaps. Now enumerated from the map with a coverage test facing the other way. Live-verified on docker-dev, since this repo has no bUnit and no unit test reaches Blazor parameter binding: opened Calculation's config (previously "No typed config form"), typed 3500, saved, reopened — the value persisted, so the DynamicComponent two-way binding round-trips. Sql's form renders fully and now reads "This driver holds a single connection, authored above". AdminUI.Tests 930 passed.
316 lines
18 KiB
C#
316 lines
18 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.Sql.Contracts;
|
|
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> (a RawPath segment).</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 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(browseName, dataType, TagAccessLevel.Read, WriteIdempotent: false, PollGroupId: null, tagConfig));
|
|
}
|
|
|
|
/// <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, setting ONLY the driver's
|
|
/// 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,
|
|
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 }.ToJson();
|
|
if (Is(driverType, DriverTypeNames.AbLegacy))
|
|
return new AbLegacyTagConfigModel { Address = address }.ToJson();
|
|
if (Is(driverType, DriverTypeNames.TwinCAT))
|
|
return new TwinCATTagConfigModel { SymbolPath = address }.ToJson();
|
|
if (Is(driverType, DriverTypeNames.FOCAS))
|
|
return new FocasTagConfigModel { Address = address }.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. Every BROWSABLE driver is handled above — guarded by
|
|
// RawBrowseCommitMapperParityTests, which enumerates DriverTypeNames.All rather than trusting
|
|
// this comment.
|
|
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
|
|
/// 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>
|
|
private static string BuildSqlTagConfig(string address, IReadOnlyDictionary<string, string>? addressFields)
|
|
{
|
|
if (addressFields is null
|
|
|| !addressFields.TryGetValue("table", out var table)
|
|
|| string.IsNullOrWhiteSpace(table))
|
|
{
|
|
return WriteSingleKey("address", address);
|
|
}
|
|
|
|
addressFields.TryGetValue("schema", out var schema);
|
|
addressFields.TryGetValue("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,
|
|
}.ToJson();
|
|
}
|
|
|
|
private static string WriteSingleKey(string key, string value)
|
|
{
|
|
var o = new JsonObject { [key] = value };
|
|
return o.ToJsonString();
|
|
}
|
|
}
|