Files
lmxopcua/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/GalaxyAttributeRef.cs
T
Joseph Doherty 3a312ffdd1
v2-ci / unit-tests (push) Has been cancelled
fix(history): a folder's event history reads the Galaxy objects behind its native conditions (#886)
A notifier folder read one event source, its NodeId identifier (a RawPath or an EquipmentId). AVEVA stores a
Galaxy object's A&E history under the object's tag_name (Source_Object), so AVEVA's own alarm and event
history reached no production folder. Its lower-case property keys were also missed, so every row read
Severity 1 with no ConditionName.

Sources. A folder's event history now reads its own source plus the Galaxy object of every native condition
that fires on it live: those parented under it and those WireAlarmNotifiers wired to it. Live and history use
the same membership. The object comes from the condition's Galaxy alarm reference, its raw tag's attributeRef,
up to the first '.':
- The composer reads it with the driver's own parser (GalaxyAttributeRef, shared with GalaxyDriver), only for
  a GalaxyMxGateway tag with an alarm (RawTagPlan.NativeAlarmReference, part of plan equality).
- The applier binds it after materialising the condition (IOpcUaAddressSpaceSink.BindNativeAlarmHistory,
  forwarded by the deferred sink). A rebuild or the condition's removal forgets it.
Each object is read with the #882 rule: its own events and <Object>.<Attribute> rows, never a prefix sibling.

Fan-out. historiangw's ReadEvents filter is a single predicate, so there is one request per source; the wire
contract is unchanged. EventSourceFanOut merges the reads as one source:
- order is (time, source position, arrival), reversed for a reverse read; ties are never re-sorted by EventId,
  so the boundary skip stays stable;
- duplicates by EventId are dropped and the merge is cut to the cap;
- a truncated source bounds the page at the time it stopped.
Paging, continuation points (bound to the whole source set), the walked-back reverse read and the tie-cluster
over-fetch are unchanged. A folder with one source is read exactly as before.

ACL. Every source is read inside the folder's own HistoryRead, after AuthorizeHistoryRead (#826). A denied
folder reads nothing and fails closed.

Mapping (EventMapper, property names now case-insensitive):
- Severity: an AVEVA row's priority goes through GalaxyAlarmPriority, the table live Galaxy alarms use
  (MxAccessSeverityMapper now delegates to it), so history matches live. Otherwise severity (OtOpcUa's own
  rows, which AVEVA stores lower-cased), clamped. AVEVA's own 1-4 severity is ignored. The table ranks a
  higher priority as more severe, which inverts AVEVA's convention: filed as #887.
- An AVEVA row whose source_conditionvariable names a native condition that fires on the folder takes that
  condition's SourceNode and live ConditionName. Otherwise ConditionName is alarmname or the condition
  variable's attribute.
- alarm_id/alarmid map to AlarmId.

Tests:
- GalaxyEquipmentEventHistoryWireTests (4, real SDK session, production Gateway backend, AVEVA-shaped fake
  gateway): equipment EqA backed by objects Oven_007 + OvenDoor_07. Covers object and attribute events from
  both objects, the prefix sibling excluded, cross-object and in-object ties, forward paging at 1-4 a page,
  reverse whole and paged, SourceNode/ConditionName/Severity, the raw device folder, ACL deny with no backend
  call plus a granted positive control, and a rebuild forgetting the bindings.
- EventSourceFanOutTests (8), EventMapper AVEVA cases (11), GalaxyAttributeRefTests (16), a composer test and
  an applier ordering test.
- Mutation-checked, 17 mutants, all killed. Covered: parent-only membership, no scope, a case-sensitive
  condition match, no condition rewrite, no truncation bound, EventId tie order, no sort, no cap, no stop at
  the bound, unreversed source order, no dedupe, no '.' boundary, priority ignored, case-sensitive keys, no
  applier bind, no deferred forward, and a composer that ignores the driver type. The forget-on-rematerialise
  guard survives as belt-and-braces: the rebuild clear covers it.
- Live, read-only, no WinRM, vd03 HistorianGateway ea9f21c: equipment folder EQ-886-ZipsenLeft (objects
  DevWinPlatform + Left_001). It read 0 events before and 92 now, equal to the unfiltered read's rows for both
  objects. 1/7-event pages and reverse whole/5-event pages agree, and 73 rows resolve to their live condition.
  diff_runs vs the #882 run: 221/226 identical. The 5 that differ are expected: DevWinPlatform Severity
  1 -> 750/1000, and #881 capabilities.

Claude-Session: https://claude.ai/code/session_01GT67ACmpMR7b9LGRPLijMi
2026-11-12 11:45:48 -05:00

76 lines
3.6 KiB
C#

using System.Text.Json;
namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions;
/// <summary>
/// The Galaxy (<see cref="DriverTypeNames.Galaxy"/>) tag reference: the <c>attributeRef</c> a Galaxy raw tag's
/// <c>TagConfig</c> carries, <c>tag_name.AttributeName</c>, which the driver dials through MXAccess. One parser for the
/// driver (which dials it) and the address-space composer (which names the AVEVA event source it implies, Gitea #886),
/// so the two can never disagree about which tags carry one.
/// </summary>
public static class GalaxyAttributeRef
{
/// <summary>The <c>TagConfig</c> key (camelCase; matched case-insensitively).</summary>
public const string Key = "attributeRef";
/// <summary>
/// Read the Galaxy dialled address from a tag's <c>TagConfig</c> JSON — the camelCase <c>attributeRef</c> key (the v3
/// rename of the pre-v3 PascalCase <c>FullName</c>). Case-insensitive on the property name for authoring tolerance;
/// returns null on an absent/blank/non-string value or an unparseable blob.
/// </summary>
/// <param name="tagConfigJson">The tag's <c>TagConfig</c> JSON.</param>
/// <returns>The attributeRef, or null.</returns>
public static string? TryRead(string? tagConfigJson)
{
if (string.IsNullOrWhiteSpace(tagConfigJson)) return null;
try
{
using var doc = JsonDocument.Parse(tagConfigJson);
if (doc.RootElement.ValueKind != JsonValueKind.Object) return null;
foreach (var prop in doc.RootElement.EnumerateObject())
{
if (!prop.NameEquals(Key) && !string.Equals(prop.Name, Key, StringComparison.OrdinalIgnoreCase)) continue;
if (prop.Value.ValueKind != JsonValueKind.String) return null;
var v = prop.Value.GetString();
return string.IsNullOrWhiteSpace(v) ? null : v;
}
}
catch (JsonException)
{
// Malformed blob: no dialable address.
}
return null;
}
/// <summary>
/// The AVEVA alarm reference of a native-alarm tag, or null: the tag's <see cref="TryRead">attributeRef</see> when its
/// driver is the Galaxy driver, otherwise null (no other driver's alarms are stored by AVEVA under a Galaxy name).
/// </summary>
/// <param name="driverType">The owning driver's <c>DriverType</c>.</param>
/// <param name="tagConfigJson">The tag's <c>TagConfig</c> JSON.</param>
/// <returns>The Galaxy alarm reference (<c>tag_name.AlarmAttribute</c>), or null.</returns>
public static string? AlarmReferenceOf(string? driverType, string? tagConfigJson) =>
string.Equals(driverType, DriverTypeNames.Galaxy, StringComparison.Ordinal) ? TryRead(tagConfigJson) : null;
/// <summary>
/// The Galaxy object (<c>tag_name</c>) a reference belongs to: the text before the first <c>.</c>, or the whole
/// reference when it has none. This is AVEVA's <c>Source_Object</c> for the object's events and its attributes'
/// alarms. Null for a blank reference or one that starts with a <c>.</c>.
/// </summary>
/// <param name="reference">A Galaxy reference, e.g. <c>Oven_007.TempHiAlarm</c>.</param>
/// <returns>The object name, e.g. <c>Oven_007</c>.</returns>
public static string? ObjectNameOf(string? reference)
{
if (string.IsNullOrWhiteSpace(reference)) return null;
var trimmed = reference.Trim();
var dot = trimmed.IndexOf('.');
return dot switch
{
< 0 => trimmed,
0 => null,
_ => trimmed[..dot],
};
}
}