diff --git a/graccesscli/src/ZB.MOM.WW.GRAccess.Cli/GRAccess/GRAccessCommandDispatcher.cs b/graccesscli/src/ZB.MOM.WW.GRAccess.Cli/GRAccess/GRAccessCommandDispatcher.cs index e2ffbca..292a39a 100644 --- a/graccesscli/src/ZB.MOM.WW.GRAccess.Cli/GRAccess/GRAccessCommandDispatcher.cs +++ b/graccesscli/src/ZB.MOM.WW.GRAccess.Cli/GRAccess/GRAccessCommandDispatcher.cs @@ -995,6 +995,20 @@ namespace ZB.MOM.WW.GRAccess.Cli.GRAccess ?? throw new InvalidOperationException($"Attribute '{attributeName}' was not found."); } + private static object TryReadAttributeValue(IAttribute attr) + { + if (attr == null) return null; + try { return attr.value; } + catch { return null; } + } + + private static string SafeAttributeName(IAttribute attr) + { + if (attr == null) return string.Empty; + try { return attr.Name ?? string.Empty; } + catch { return string.Empty; } + } + private static IAttribute TryFindAttribute(IAttributes attributes, string attributeName) { if (attributes == null) @@ -1340,7 +1354,25 @@ namespace ZB.MOM.WW.GRAccess.Cli.GRAccess private static object AttributeValueDetails(IGalaxy galaxy, IgObject obj, IAttribute attr) { var unavailable = new List(); - var direct = MxValueDetails(ReadProperty(attr, "Value")); + // Use the typed `IAttribute.value` accessor. For ScriptExtension / + // PackageOnly fields the IAttribute proxy must come from + // `ConfigurableAttributes`, not the runtime `Attributes` snapshot — + // the runtime view often returns a stub whose `.value` is null even + // when the persisted value is set. Re-look-up from + // ConfigurableAttributes by name when needed; fall back to whatever + // `attr` was passed in if the configurable lookup fails. + object rawMxValue = TryReadAttributeValue(attr); + if (rawMxValue == null) + { + var name = SafeAttributeName(attr); + if (!string.IsNullOrEmpty(name)) + { + var cfgAttr = TryFindAttribute(obj.ConfigurableAttributes, name); + if (cfgAttr != null && !ReferenceEquals(cfgAttr, attr)) + rawMxValue = TryReadAttributeValue(cfgAttr); + } + } + var direct = MxValueDetails(rawMxValue); var package = new PackageSnapshot(); var packageValue = (PackageAttributeValue)null; @@ -1384,45 +1416,65 @@ namespace ZB.MOM.WW.GRAccess.Cli.GRAccess if (value == null) return new { Supported = false, Value = (object)null, Unavailable = "Attribute value is not exposed by this GRAccess attribute." }; - var dataType = ReadString(value, "DataType"); - var isArray = ReadString(value, "IsArray"); - if (string.Equals(isArray, "True", StringComparison.OrdinalIgnoreCase)) + // The MxValue COM interface is vtable-only — its `GetString` / + // `GetInteger` / etc. accessors are NOT reachable via IDispatch + // late-binding. We must cast to the typed `IMxValue` interface. + // This was the root cause of the earlier "writeback gap" mirage: + // every read returned "not exposed" because `Invoke(value, "GetString")` + // threw, never because the underlying value was actually missing. + var mx = value as IMxValue; + if (mx == null) + return new { Supported = false, Value = (object)null, Unavailable = $"Value object is not an IMxValue (got {value.GetType().FullName})." }; + + string dataType; + try { dataType = mx.GetDataType().ToString(); } + catch { dataType = string.Empty; } + + // Array detection via typed call. `GetDimensionSize` returns 0 for + // scalars and >0 for arrays (with Dim1 reflecting element count). + int dimSize = 0; + try { mx.GetDimensionSize(out dimSize); } catch { } + var isArray = dimSize > 0; + if (isArray) return new { Supported = false, DataType = dataType, IsArray = true, Value = (object)null, Unavailable = "Array and complex MxValue readback is not yet implemented." }; + // Dispatch on the typed DataType enum. Names mirror the + // `MxDataType` enum in `ArchestrA.GRAccess.dll`. try { - var elapsed = (VB_LARGE_INTEGER)Invoke(value, "GetElapsedTime"); - return new + switch (dataType) { - Supported = true, - DataType = dataType, - IsArray = false, - Value = ElapsedTimeToInt64(elapsed), - Unit = "100ns" - }; + case "MxBoolean": + return new { Supported = true, DataType = dataType, IsArray = false, Value = (object)mx.GetBoolean() }; + case "MxInteger": + return new { Supported = true, DataType = dataType, IsArray = false, Value = (object)mx.GetInteger() }; + case "MxFloat": + return new { Supported = true, DataType = dataType, IsArray = false, Value = (object)mx.GetFloat() }; + case "MxDouble": + return new { Supported = true, DataType = dataType, IsArray = false, Value = (object)mx.GetDouble() }; + case "MxString": + case "MxInternationalizedString": + case "MxBigString": + return new { Supported = true, DataType = dataType, IsArray = false, Value = (object)mx.GetString() }; + case "MxTime": + // MxTime returns a VBFILETIME via out-param; field + // layout differs across interop builds. Skip the + // typed read for now; consumers that need timestamps + // can fall through to package-fallback reads. + return new { Supported = false, DataType = dataType, IsArray = false, Value = (object)null, Unavailable = "MxTime readback is not yet implemented for this dispatcher path." }; + case "MxElapsedTime": + { + var e = mx.GetElapsedTime(); + return new { Supported = true, DataType = dataType, IsArray = false, Value = (object)ElapsedTimeToInt64(e), Unit = "100ns" }; + } + } } - catch + catch (Exception ex) { - // Try the next scalar accessor; GRAccess throws when the accessor does not match. + return new { Supported = false, DataType = dataType, IsArray = false, Value = (object)null, Unavailable = $"Typed accessor for {dataType} threw: {ex.GetType().Name}: {ex.Message}" }; } - foreach (var read in new[] { "GetBoolean", "GetInteger", "GetFloat", "GetDouble", "GetString" }) - { - try - { - return new - { - Supported = true, - DataType = dataType, - IsArray = false, - Value = Invoke(value, read) - }; - } - catch - { - // Try the next scalar accessor; GRAccess throws when the accessor does not match. - } - } + return new { Supported = false, DataType = dataType, IsArray = false, Value = (object)null, Unavailable = $"No typed accessor for MxDataType {dataType}." }; return new { Supported = false, DataType = dataType, IsArray = false, Value = (object)null, Unavailable = "No supported scalar accessor succeeded for this MxValue." }; } @@ -1929,11 +1981,18 @@ namespace ZB.MOM.WW.GRAccess.Cli.GRAccess return $"{operation}: {results.CompletelySuccessful}"; } + // COM IDispatch is case-insensitive at runtime, but .NET's `InvokeMember` + // is not unless `BindingFlags.IgnoreCase` is set. Some IAttribute proxies + // expose properties under a lowercase name (e.g. `value` on `IAttribute`) + // while siblings expose CamelCase (e.g. `DataType` on `IMxValue`). Without + // IgnoreCase a `ReadProperty(attr, "Value")` lookup silently returns null + // for one and works for the other — the gap that masked all script-body + // writes as "no-ops" in earlier versions of `MxValueDetails`. private static object Invoke(object target, string method, params object[] parameters) { return target.GetType().InvokeMember( method, - BindingFlags.InvokeMethod, + BindingFlags.InvokeMethod | BindingFlags.IgnoreCase, null, target, parameters); @@ -1944,7 +2003,12 @@ namespace ZB.MOM.WW.GRAccess.Cli.GRAccess if (target == null || string.IsNullOrWhiteSpace(property)) return null; try { - return target.GetType().InvokeMember(property, BindingFlags.GetProperty, null, target, null); + return target.GetType().InvokeMember( + property, + BindingFlags.GetProperty | BindingFlags.IgnoreCase, + null, + target, + null); } catch {