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; /// /// Pure mapper for the WP6 "Browse device…" re-target: turns a selected driver-browse leaf into a /// ready for . 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 TagConfig as an ordinary address field /// (nodeId/tagPath/symbolPath/address/attributeRef per driver) — never an /// identity key. The address-field write reuses the same <Driver>TagConfigModel the typed tag /// editors use, so a browse-committed tag round-trips through that editor unchanged. /// public static class RawBrowseCommitMapper { /// /// Maps one selected browse leaf into an import row. /// /// The owning device's driver type (a value). /// The leaf's driver-side full reference (the BrowseNode.NodeId = the /// captured DriverAttributeInfo.FullName) — becomes the driver-typed address field. /// The leaf's browse name — becomes the raw tag Name (a RawPath segment). /// The leaf's name (from the browse /// attribute side-channel), or null when the browser cannot report a type (e.g. the OPC UA Client tree). /// The OPC-UA built-in type name to fall back to when /// is null/unmappable. /// 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. /// The captured browse-folder nesting above the leaf (root→parent display /// names); mirrored onto nested TagGroups only when is true. /// When true, mirror as nested TagGroups. /// The leaf's structured address (), /// for a driver whose binding is a tuple rather than a single reference string; null for every driver /// whose address is the itself. /// The assembled import row. public static RawTagImportRow MapLeaf( string driverType, string fullName, string browseName, string? driverDataType, string defaultDataType, string? groupPrefix, IReadOnlyList? folderPath, bool createGroups, IReadOnlyDictionary? 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)); } /// /// Maps a enum NAME (as reported by the browse attribute side-channel) to the /// OPC-UA built-in type name a carries. Mirrors /// DiscoveredNodeMapper.ToBuiltinTypeString: most names pass through, Float32/Float64 /// become Float/Double, and Reference (a Galaxy attribute ref) carries as String. /// Returns null when the input is null/blank or not a known driver data type (caller supplies a default). /// /// The name, or null/blank. /// The OPC-UA built-in type name, or null when unmappable. public static string? MapDataType(string? driverDataType) { if (string.IsNullOrWhiteSpace(driverDataType) || !Enum.TryParse(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, }; } /// /// Builds the driver-typed TagConfig JSON for a browse-committed tag, setting ONLY the driver's /// address field to and leaving every other field at its model default. Reuses /// the <Driver>TagConfigModel for driver types that have a typed editor; the model-less Galaxy /// driver gets the canonical camelCase attributeRef key directly. /// /// MQTT is the one driver whose address is not a single string — a Sparkplug tag binds by a /// (group, edgeNode, device?, metric) tuple — so it is built from the browse session's stated /// instead. See . /// /// /// The owning device's driver type. /// The leaf's driver-side full reference to write into the address field. /// The leaf's structured address, when the driver binds by a tuple — /// see . Ignored by every single-reference driver. /// The serialised driver-typed TagConfig JSON. public static string BuildTagConfig( string driverType, string fullName, IReadOnlyDictionary? 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); } /// /// Builds the TagConfig for a browse-committed MQTT leaf, whose address is a /// descriptor rather than a single reference string: topic in Plain mode, and the /// groupId/edgeNodeId/deviceId?/metricName tuple in Sparkplug B mode. /// /// The leaf's browse node id — the Plain-mode fallback address only. /// The structured address the browse session stated. /// The serialised TagConfig JSON. /// /// /// The address is read from , never parsed out of /// . A Sparkplug browse node id is /// {group}/{node}[/{device}]::{metric}, and a metric name legitimately contains / /// (Node Control/Rebirth) — 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. /// /// /// Plain vs Sparkplug is likewise stated, not inferred — the driver type reaching /// here is just Mqtt, 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 /// metricName ⇒ Sparkplug, a topic ⇒ Plain. (The blob deliberately carries no /// mode key: MqttTagDefinitionFactory takes the shape from the driver's mode and /// would ignore one, and MqttTagConfigModel re-infers it from these same keys.) /// /// /// Keys are whitelisted, 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 . /// /// private static string BuildMqttTagConfig(string fullName, IReadOnlyDictionary? 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); } /// /// Describes why a selected browse leaf cannot be committed as a working tag, or null when it /// can. The AdminUI calls this before mapping so an unbindable selection fails at commit, in /// words. /// /// The owning device's driver type. /// The leaf's browse name, for the message. /// The structured address the browse session stated, if any. /// The operator-facing reason, or null when the leaf is committable. /// /// 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 BadNodeIdUnknown forever with nothing to point at. Every /// single-reference driver is unaffected: its address IS the node id. /// public static string? DescribeUncommittableLeaf( string driverType, string browseName, IReadOnlyDictionary? 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."; } /// Reads one non-blank address field, or null when it is absent or blank. /// The stated address fields, or null. /// The field to read. /// The trimmed value, or null. private static string? TryGetField(IReadOnlyDictionary? fields, string key) => fields is not null && fields.TryGetValue(key, out var v) && !string.IsNullOrWhiteSpace(v) ? v.Trim() : null; /// /// 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). /// /// The target TagGroup's device-relative path, or null/blank for the device root. /// The mirrored folder sub-path, or null/blank. /// The combined device-relative group path, or null for the device root. 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); /// /// Builds a SqlTagConfigModel blob from a browsed COLUMN leaf. Maps to /// SqlTagModel.WideRow — 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. /// 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. /// private static string BuildSqlTagConfig(string address, IReadOnlyDictionary? 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(); } }