using System.Collections.Concurrent; using System.Collections.Frozen; using System.Globalization; using ZB.MOM.WW.OtOpcUa.Core.Abstractions; namespace ZB.MOM.WW.OtOpcUa.Driver.MTConnect; /// /// The driver's live dataItemId → map: the single place /// an Agent observation's weakly-typed text becomes an OPC UA-shaped value + quality + /// timestamp. Written by the /current baseline read and by the /sample long-poll /// pump; read by IReadable.ReadAsync and the subscription fan-out. /// /// /// /// Quality mapping (design §3.3). MTConnect has exactly one way to say "I have no /// value": the literal token UNAVAILABLE. It arrives as a Sample/Event's text, and — /// after normalizes the <Unavailable/> /// element name — as a CONDITION's value on that same exact spelling. It maps to /// BadNoCommunication with a null value: the Agent is reachable, the machine's /// data item is not. The match is ordinal and case-sensitive against the one spelling /// the parser guarantees; a case-insensitive match would swallow a legitimate free-text /// EVENT whose value happens to read "Unavailable". /// /// /// Failure posture. This type sits under IReadable and must never throw across /// that boundary on account of observation content. Every unusable observation degrades to a /// Bad-coded snapshot that still carries the Agent's source timestamp, so an operator can /// always see when the driver last heard about the tag even when it cannot show /// what. The only exceptions raised are for a null /// argument — a programming error, not data. /// /// /// Status codes are the canonical Opc.Ua.StatusCodes numeric values, declared /// once each below. Note that is 0x80740000: the /// 0x80730000 that four sibling drivers declare under that name is really /// BadWriteNotSupported, which would be actively misleading on a read path and /// renders as bare "Bad" in the driver CLI's SnapshotFormatter. /// /// /// Thread safety. Backed by a and /// guaranteed per-key atomic — nothing more, deliberately. writes /// each dataItemId exactly once per call with a single whole-snapshot assignment, so a /// reader can never observe a torn snapshot (a Good code beside a stale value). It is /// explicitly not a whole-batch atomic swap: OPC UA reads are per-tag and the driver /// offers no cross-tag consistency contract, /sample chunks are deltas whose /// interleaving is indistinguishable from two adjacent legal chunks, and a whole-index lock /// would serialize the pump against every read for no semantic gain. /// /// /// Shapes this build does not represent are coded BadNotSupported with a null /// value rather than approximated: a TIME_SERIES vector (array materialization is /// deferred to P1.5) and a DATA_SET / TABLE observation. The latter carries its /// real content in <Entry key=…> child elements, which reach a text-valued /// observation concatenated with the keys discarded — a two-entry set reads as the single /// token "12" — and because the type inference correctly demotes those /// representations to , coercion would otherwise /// "succeed" into a Good-coded snapshot carrying noise. This index cannot detect that shape /// itself (no value heuristic can separate concatenated entries from a legitimate /// space-bearing EVENT such as a Message reading "Coolant level low"), so it relies on /// , which /// sets while the XML is still XML. /// /// internal sealed class MTConnectObservationIndex { /// /// The one token that means "the Agent has no value for this data item". Matched ordinally: /// the parser already normalizes a CONDITION's <Unavailable/> element name onto /// this exact spelling, so every category lands on one comparison. /// private const string UnavailableSentinel = "UNAVAILABLE"; private const uint Good = 0x00000000u; /// The Agent reported UNAVAILABLE, or supplied no text at all. private const uint BadNoCommunication = 0x80310000u; /// The tag is authored but the Agent has not yet reported it. private const uint BadWaitingForInitialData = 0x80320000u; /// The dataItemId is neither authored nor ever observed. private const uint BadNodeIdUnknown = 0x80340000u; /// The Agent reported a number the authored type cannot represent. private const uint BadOutOfRange = 0x803C0000u; /// The observation's shape is real data this build cannot represent (TIME_SERIES vectors). private const uint BadNotSupported = 0x803D0000u; /// The Agent reported text that is not a value of the authored type at all. private const uint BadTypeMismatch = 0x80740000u; /// /// MTConnect's CONDITION state vocabulary, ranked by severity. Used only to reconcile /// several states reported for one dataItemId within a single document — see /// . An active Fault outranks UNAVAILABLE because a /// fault is information and an absent value is not. Case-insensitive so a vendor's casing /// of a state element still ranks; the parser itself emits the canonical spellings. /// private static readonly FrozenDictionary ConditionSeverity = new Dictionary(StringComparer.OrdinalIgnoreCase) { [UnavailableSentinel] = 0, ["Normal"] = 1, ["Warning"] = 2, ["Fault"] = 3, }.ToFrozenDictionary(StringComparer.OrdinalIgnoreCase); private readonly ConcurrentDictionary _snapshots = new(StringComparer.Ordinal); private readonly FrozenDictionary _tags; private readonly TimeProvider _timeProvider; /// /// Builds an index over the driver instance's authored tags. /// /// /// The authored tags, keyed by (the DataItem /// id) — normally . null or empty is /// legal and means "no authored types": every observation is then indexed as its raw text /// under . /// /// Nothing enforces FullName uniqueness at the options layer, so a duplicate is /// operator-authorable. The last definition wins rather than throwing: refusing to /// construct would turn an authoring slip into a driver that will not start, whereas /// last-wins is deterministic and costs only that one tag's coercion type. /// /// /// Clock behind ServerTimestampUtc; defaults to . public MTConnectObservationIndex( IEnumerable? tags = null, TimeProvider? timeProvider = null) { _timeProvider = timeProvider ?? TimeProvider.System; var map = new Dictionary(StringComparer.Ordinal); foreach (var tag in tags ?? []) { if (tag is null || string.IsNullOrWhiteSpace(tag.FullName)) continue; map[tag.FullName] = tag; } _tags = map.ToFrozenDictionary(StringComparer.Ordinal); } /// The number of distinct dataItemIds currently indexed. public int Count => _snapshots.Count; /// /// Folds a /current snapshot or one /sample chunk into the index. Observations /// with no authored tag are indexed too — the Agent streams the whole device, so they are /// normal, and dropping them would make a streaming-but-not-yet-authored data item /// indistinguishable from one that does not exist. /// /// The parsed streams document. An observation-free result is a legal /// keep-alive and leaves the index untouched. /// is null. public void Apply(MTConnectStreamsResult result) { ArgumentNullException.ThrowIfNull(result); if (result.Observations is not { Count: > 0 }) return; // Duplicates are reconciled on the RAW observations first, so each dataItemId is written // exactly once below as a single atomic whole-snapshot assignment. Doing it the other way // — writing every observation and merging snapshots in place — would need a read-modify-write // per key and could expose a half-merged state to a concurrent reader. var resolved = new Dictionary(StringComparer.Ordinal); foreach (var observation in result.Observations) { if (observation is null || string.IsNullOrWhiteSpace(observation.DataItemId)) continue; resolved[observation.DataItemId] = resolved.TryGetValue(observation.DataItemId, out var incumbent) ? Reconcile(incumbent, observation) : observation; } foreach (var (dataItemId, observation) in resolved) { _snapshots[dataItemId] = BuildSnapshot(observation); } } /// /// Returns the current snapshot for a DataItem. Never null and never throws: an /// unknown or not-yet-reported id yields a Bad-coded snapshot rather than an error. /// /// The DataItem id the tag binds. /// /// The indexed snapshot; else BadWaitingForInitialData when the id is authored but /// the Agent has not reported it yet (the standard OPC UA "subscribed, no value yet" /// state); else BadNodeIdUnknown. The two are kept distinct because collapsing them /// would tell an operator their correctly-authored tag does not exist. /// public DataValueSnapshot Get(string dataItemId) { if (!string.IsNullOrWhiteSpace(dataItemId) && _snapshots.TryGetValue(dataItemId, out var snapshot)) { return snapshot; } var status = !string.IsNullOrWhiteSpace(dataItemId) && _tags.ContainsKey(dataItemId) ? BadWaitingForInitialData : BadNodeIdUnknown; return new DataValueSnapshot(null, status, null, Now); } /// /// Drops every indexed observation. The Agent-restart re-baseline: when the streams header's /// instanceId changes, every value the index holds describes a device model that no /// longer exists, and serving it would be worse than serving nothing. /// public void Clear() => _snapshots.Clear(); private DateTime Now => _timeProvider.GetUtcNow().UtcDateTime; /// /// Picks the winner when one dataItemId appears more than once in a single document. A /// <Condition> container legitimately carries several simultaneously active /// states (a Fault and a Warning at once), so for a pair of recognized condition state words /// the most severe wins — plain last-wins would under-report an active Fault as a /// Warning purely because of element order. Anything else is last-wins, matching the Agent's /// ascending-sequence ordering. /// /// Deliberately scoped to one document: a later is the Agent's new /// statement of the state, so a cleared fault must fall back to Normal. A worst-of that /// spanned Applies would latch every fault forever. /// /// private static MTConnectObservation Reconcile(MTConnectObservation incumbent, MTConnectObservation challenger) { if (ConditionSeverity.TryGetValue(incumbent.Value ?? string.Empty, out var incumbentSeverity) && ConditionSeverity.TryGetValue(challenger.Value ?? string.Empty, out var challengerSeverity)) { return challengerSeverity >= incumbentSeverity ? challenger : incumbent; } return challenger; } private DataValueSnapshot BuildSnapshot(MTConnectObservation observation) { var serverTimestamp = Now; var sourceTimestamp = DateTime.SpecifyKind(observation.TimestampUtc, DateTimeKind.Utc); // No text at all is the degenerate spelling of "no value": MTConnect defines UNAVAILABLE as // the only way to report absence, so an empty element is not an empty string value. if (string.IsNullOrWhiteSpace(observation.Value) || string.Equals(observation.Value, UnavailableSentinel, StringComparison.Ordinal)) { return new DataValueSnapshot(null, BadNoCommunication, sourceTimestamp, serverTimestamp); } // DATA_SET / TABLE: the Agent carried the content in child elements, so // Value is their concatenated text with the keys discarded (a two-entry set reads "12"). // Publishing that as a Good string would be noise an operator would trust. Checked before // the tag lookup because it is a fact about the wire shape, true whether or not the data // item is authored — and after the sentinel, so an unavailable data set still reports the // truthful no-comms. if (observation.IsStructured) { return new DataValueSnapshot(null, BadNotSupported, sourceTimestamp, serverTimestamp); } _tags.TryGetValue(observation.DataItemId, out var tag); // TIME_SERIES vectors arrive as one space-separated string. P1 does not materialize OPC UA // arrays (deferred to P1.5), and the honest answer is "real data I cannot represent" — not a // type mismatch, and emphatically not the first element parsed out and served as Good. // Checked AFTER the sentinel so an unavailable array tag still reports the truthful no-comms. if (tag is { IsArray: true }) { return new DataValueSnapshot(null, BadNotSupported, sourceTimestamp, serverTimestamp); } // An unauthored observation has no declared target type; String is the only coercion that // cannot fail, and it carries the Agent's text with no interpretation applied. var status = Coerce(observation.Value, tag?.DriverDataType ?? DriverDataType.String, out var value); return status == Good ? new DataValueSnapshot(value, Good, sourceTimestamp, serverTimestamp) : new DataValueSnapshot(null, status, sourceTimestamp, serverTimestamp); } /// /// Converts the Agent's text to the tag's authored type. Every parse is /// : MTConnect is an invariant-format wire, and a /// host running a comma-decimal culture would otherwise read 123.4567 as /// 1234567 — a silently wrong value under Good quality, the worst possible outcome. /// /// on success; otherwise a Bad code and a null value. private static uint Coerce(string raw, DriverDataType type, out object? value) { value = null; var text = raw.Trim(); switch (type) { // Reference is a Galaxy-style attribute reference encoded as an OPC UA String. It is // never a sensible authoring for MTConnect, but degrading it to text is harmless where // rejecting it would break a tag for no protection. case DriverDataType.String: case DriverDataType.Reference: value = raw; return Good; case DriverDataType.Boolean: if (bool.TryParse(text, out var flag)) { value = flag; return Good; } if (text is "1") { value = true; return Good; } if (text is "0") { value = false; return Good; } return BadTypeMismatch; case DriverDataType.DateTime: if (DateTime.TryParse( text, CultureInfo.InvariantCulture, DateTimeStyles.AdjustToUniversal | DateTimeStyles.AssumeUniversal, out var moment)) { value = DateTime.SpecifyKind(moment, DateTimeKind.Utc); return Good; } return BadTypeMismatch; case DriverDataType.Float32: if (float.TryParse(text, NumberStyles.Float, CultureInfo.InvariantCulture, out var single)) { value = single; return Good; } return BadTypeMismatch; case DriverDataType.Float64: if (double.TryParse(text, NumberStyles.Float, CultureInfo.InvariantCulture, out var real)) { value = real; return Good; } return BadTypeMismatch; case DriverDataType.Int16: if (short.TryParse(text, NumberStyles.Integer, CultureInfo.InvariantCulture, out var i16)) { value = i16; return Good; } break; case DriverDataType.Int32: if (int.TryParse(text, NumberStyles.Integer, CultureInfo.InvariantCulture, out var i32)) { value = i32; return Good; } break; case DriverDataType.Int64: if (long.TryParse(text, NumberStyles.Integer, CultureInfo.InvariantCulture, out var i64)) { value = i64; return Good; } break; case DriverDataType.UInt16: if (ushort.TryParse(text, NumberStyles.Integer, CultureInfo.InvariantCulture, out var u16)) { value = u16; return Good; } break; case DriverDataType.UInt32: if (uint.TryParse(text, NumberStyles.Integer, CultureInfo.InvariantCulture, out var u32)) { value = u32; return Good; } break; case DriverDataType.UInt64: if (ulong.TryParse(text, NumberStyles.Integer, CultureInfo.InvariantCulture, out var u64)) { value = u64; return Good; } break; default: // A DriverDataType this driver has no rule for. Text always round-trips, so serving // it beats failing a tag over an enum member added later. value = raw; return Good; } // Integer parse failed. Distinguishing the two causes is worth one extra parse: it tells an // operator whether to retype the tag or widen it. return double.TryParse(text, NumberStyles.Float, CultureInfo.InvariantCulture, out _) ? BadOutOfRange // a number the authored type cannot represent (overflow, or a fraction) : BadTypeMismatch; // not a number at all } }