fix(browse): commit the browsed data type, and fix the probe classification the fixture was hiding (#536)
A browse-committed AB CIP / TwinCAT / FOCAS tag bound with its model's DEFAULT
data type, not the one the device reported. A browsed REAL committed as
`{"tagPath":"Pump_1_Speed","dataType":"DInt"}` — and those three drivers decode
the wire bytes by that field, not by the raw row's OPC UA DataType — so the tag
read four bytes of a float through an integer decoder and published a plausible
wrong number with no error raised anywhere. FOCAS was missed when #536 was filed.
The type is CARRIED, not derived. Reverse-mapping the leaf's driver-agnostic
DriverDataType looks like the smaller fix and is wrong: SInt/Int/DInt/USInt/UInt
all collapse into Int32 on the way out of the driver, so that version would bind
a 16-bit INT as a 32-bit DINT while appearing to fix the REAL. Drivers now state
their own type name on DriverAttributeInfo.NativeDataType, which reaches the
picker as AttributeInfo.AddressFields["dataType"] and the commit mapper from
there. A leaf that states nothing keeps the model default — the pre-#536
behaviour, kept deliberately as the fallback rather than a guess.
Numeric strings are refused before parsing: Enum.TryParse accepts "9" for any
enum and Enum.IsDefined then confirms it, so an unguarded parse would bind a tag
to a type nobody named. A test caught this in the first version of the guard.
--- and the other half, which the fixture was hiding ---
Test Connect on a device with NO authored tags — every AB CIP device at the
moment an operator first presses the button — reported "CIP handshake failed"
against a healthy controller. That was written down as a fixture limitation
("ab_server doesn't implement @raw_cpu_type"). Both halves of that were wrong:
* @raw_cpu_type is not a libplctag special tag at all (only @raw, @tags and
@udt/ are), so it goes out as an ordinary symbolic name that no controller
defines, and the controller's "tag does not exist" IS the successful answer.
* ab_server answered an unresolvable tag path with CIP 0x03 where a Logix
controller answers 0x04 — ErrorBadParam vs ErrorNotFound, which the probe
classifies oppositely. Fixed in vendored patch 0002.
* With the fixture then telling the truth, the real defect surfaced:
AbCipDriverProbe.IsReachableException matched the native C status spellings
("STATUS_NOT_FOUND"), but libplctag.NET throws with the MANAGED enum name
("ErrorNotFound"). Those substrings can never match, so the reachable
classification could not fire on the exception path — which is the path every
no-authored-tags probe takes. This was failing on real hardware too, not just
against the sim. It now recovers the Status and defers to IsReachableStatus,
so there is one classification rather than two that can drift.
Rejected: switching the fallback to @tags. libplctag builds a tag-listing tag
with NO network I/O, so it reports OK against any TCP listener — measured green
against a Mosquitto broker.
Verification: full solution suite green. Falsification runs confirm exactly the
intended tests go red — 9 for the type carry, 3 (the reachable statuses only) for
the probe classification. Live on docker-dev: browse-committed TestString/
Motor_Run/TestSINT now store String/Bool/SInt, alongside last session's
Pump_1_Speed still holding the wrong DInt. TestSINT is the falsifier — its row
DataType is Int32, identical to TestDINT's, so no reverse map could have produced
"SInt".
Known consequence: pre-existing browse-committed tags keep their wrong dataType;
the fix applies to new commits only.
Claude-Session: https://claude.ai/code/session_015p7wGqy3YpZNCpDzTpGMKo
This commit is contained in:
@@ -3,7 +3,10 @@ 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.AbCip;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.TwinCAT;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Mqtt;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Uns;
|
||||
@@ -192,11 +195,19 @@ public static class RawBrowseCommitMapper
|
||||
}
|
||||
|
||||
/// <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
|
||||
/// Builds the driver-typed <c>TagConfig</c> JSON for a browse-committed tag: the driver's address field
|
||||
/// from <paramref name="fullName"/>, plus — for a driver that binds by its OWN data-type enum — the
|
||||
/// native type the browse leaf stated (see <see cref="NativeType{TEnum}"/>). Every other field stays at
|
||||
/// its model default, for the operator to author afterwards. 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>The data type is not optional decoration for AB CIP / TwinCAT / FOCAS</b> — their tag-definition
|
||||
/// factories decode the wire bytes by this field, not by the raw tag row's OPC UA <c>DataType</c>. A
|
||||
/// committed leaf left at the model default therefore reads real data through the wrong decoder and
|
||||
/// publishes a plausible wrong number rather than an error (Gitea #536).
|
||||
/// </para>
|
||||
/// <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"/>.
|
||||
@@ -218,13 +229,25 @@ public static class RawBrowseCommitMapper
|
||||
if (Is(driverType, DriverTypeNames.OpcUaClient))
|
||||
return new OpcUaClientTagConfigModel { NodeId = address }.ToJson();
|
||||
if (Is(driverType, DriverTypeNames.AbCip))
|
||||
return new AbCipTagConfigModel { TagPath = address }.ToJson();
|
||||
return new AbCipTagConfigModel
|
||||
{
|
||||
TagPath = address,
|
||||
DataType = NativeType(addressFields, AbCipDataType.DInt),
|
||||
}.ToJson();
|
||||
if (Is(driverType, DriverTypeNames.AbLegacy))
|
||||
return new AbLegacyTagConfigModel { Address = address }.ToJson();
|
||||
if (Is(driverType, DriverTypeNames.TwinCAT))
|
||||
return new TwinCATTagConfigModel { SymbolPath = address }.ToJson();
|
||||
return new TwinCATTagConfigModel
|
||||
{
|
||||
SymbolPath = address,
|
||||
DataType = NativeType(addressFields, TwinCATDataType.DInt),
|
||||
}.ToJson();
|
||||
if (Is(driverType, DriverTypeNames.FOCAS))
|
||||
return new FocasTagConfigModel { Address = address }.ToJson();
|
||||
return new FocasTagConfigModel
|
||||
{
|
||||
Address = address,
|
||||
DataType = NativeType(addressFields, FocasDataType.Int32),
|
||||
}.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
|
||||
@@ -338,6 +361,46 @@ public static class RawBrowseCommitMapper
|
||||
+ "the tag manually.";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reads the browse leaf's driver-native data type out of the stated address fields, falling
|
||||
/// back to <paramref name="fallback"/> — the driver model's own default — when the browser
|
||||
/// stated none or stated one this driver's enum does not name.
|
||||
/// </summary>
|
||||
/// <typeparam name="TEnum">The driver's own data-type enum.</typeparam>
|
||||
/// <param name="addressFields">The leaf's stated address fields.</param>
|
||||
/// <param name="fallback">The model default to keep when no usable native type was stated.</param>
|
||||
/// <returns>The parsed native type, or <paramref name="fallback"/>.</returns>
|
||||
/// <remarks>
|
||||
/// <b>The type is READ, never derived (Gitea #536).</b> A commit used to leave this at the
|
||||
/// model default, so every browsed AB CIP leaf — a REAL, a BOOL, a STRING — was written as
|
||||
/// <c>DInt</c> and the driver decoded four bytes of a float as an integer: a plausible
|
||||
/// number, no error, wrong value. Deriving it from the leaf's driver-AGNOSTIC
|
||||
/// <c>DriverDataType</c> instead would fix the REAL and still get the integers wrong —
|
||||
/// <c>SInt</c>, <c>Int</c> and <c>DInt</c> all collapse into <c>Int32</c> on the way out, so
|
||||
/// a 16-bit INT would come back as a 32-bit DINT. The producing driver knows its own type
|
||||
/// and states it; see <c>CapturedTreeBrowseSession.NativeTypeFields</c>.
|
||||
/// <para>
|
||||
/// Case-insensitive because the key travels as an enum NAME, and a driver is free to spell
|
||||
/// it however its own enum does. An unparseable value keeps the default rather than failing
|
||||
/// the commit: the address — the part that cannot be re-derived later — is still correct,
|
||||
/// and the operator can fix the type in the typed editor.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>A numeric string is refused before parsing.</b> <c>Enum.TryParse</c> accepts <c>"9"</c>
|
||||
/// for any enum and hands back whatever member sits at ordinal 9 — which <c>Enum.IsDefined</c>
|
||||
/// then happily confirms — so a driver that stamped a number would bind a tag to a type nobody
|
||||
/// named. Same trap <c>MTConnectTagConfigModel</c> documents for its own <c>dataType</c>.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private static TEnum NativeType<TEnum>(IReadOnlyDictionary<string, string>? addressFields, TEnum fallback)
|
||||
where TEnum : struct, Enum
|
||||
=> TryGetField(addressFields, "dataType") is { Length: > 0 } raw
|
||||
&& !char.IsAsciiDigit(raw[0]) && raw[0] is not ('-' or '+')
|
||||
&& Enum.TryParse<TEnum>(raw, ignoreCase: true, out var parsed)
|
||||
&& Enum.IsDefined(parsed)
|
||||
? parsed
|
||||
: fallback;
|
||||
|
||||
/// <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>
|
||||
|
||||
Reference in New Issue
Block a user