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:
@@ -14,8 +14,10 @@ public sealed class ProbeCommand : AbCipCommandBase
|
||||
{
|
||||
/// <summary>Gets the tag path to probe.</summary>
|
||||
[CommandOption("tag", 't', Description =
|
||||
"Tag path to probe. ControlLogix default is '@raw_cpu_type' (the canonical libplctag " +
|
||||
"system tag); Micro800 takes a user-supplied global (e.g. '_SYSVA_CLOCK_HOUR').",
|
||||
"Tag path to probe — a real tag on the controller. Any name works to prove reachability: " +
|
||||
"libplctag knows only @raw, @tags and @udt/ as special names, so anything else (including " +
|
||||
"'@raw_cpu_type', which no controller defines) is sent as an ordinary symbolic name and the " +
|
||||
"controller's 'tag does not exist' answer already proves the CIP session is live.",
|
||||
IsRequired = true)]
|
||||
public string TagPath { get; init; } = default!;
|
||||
|
||||
|
||||
@@ -213,8 +213,11 @@ public sealed class AbCipProbeOptions
|
||||
/// Tag path used for the probe. When <see cref="Enabled"/> is <c>true</c> but this is
|
||||
/// <c>null</c>/blank, the driver logs a warning and runs no probe loops;
|
||||
/// <c>GetHostStatuses()</c> will then report every device as <c>Unknown</c>. A family-default
|
||||
/// system-tag fallback (e.g. <c>@raw_cpu_type</c> on ControlLogix) is a deferred follow-up;
|
||||
/// today an operator opting into the probe must supply a tag path explicitly.
|
||||
/// fallback is a deferred follow-up; today an operator opting into the probe must supply a tag
|
||||
/// path explicitly. (This used to name <c>@raw_cpu_type</c> as the ControlLogix system tag such
|
||||
/// a default would read. It is not one — libplctag's CIP special-tag dispatch knows only
|
||||
/// <c>@raw</c>, <c>@tags</c> and <c>@udt/</c> — so any default here would be a name the
|
||||
/// controller rejects, which proves reachability but never returns a value.)
|
||||
/// </summary>
|
||||
public string? ProbeTagPath { get; init; }
|
||||
}
|
||||
|
||||
@@ -1068,7 +1068,8 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
: SecurityClassification.ViewOnly,
|
||||
IsHistorized: false,
|
||||
IsAlarm: false,
|
||||
WriteIdempotent: member.WriteIdempotent));
|
||||
WriteIdempotent: member.WriteIdempotent,
|
||||
NativeDataType: member.DataType.ToString()));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
@@ -1145,7 +1146,8 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
: SecurityClassification.Operate,
|
||||
IsHistorized: false,
|
||||
IsAlarm: false,
|
||||
WriteIdempotent: false));
|
||||
WriteIdempotent: false,
|
||||
NativeDataType: discovered.DataType.ToString()));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1248,7 +1250,8 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
: SecurityClassification.Operate,
|
||||
IsHistorized: false,
|
||||
IsAlarm: false,
|
||||
WriteIdempotent: false));
|
||||
WriteIdempotent: false,
|
||||
NativeDataType: member.DataType.ToString()));
|
||||
anyEmitted = true;
|
||||
}
|
||||
|
||||
@@ -1277,7 +1280,8 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
: SecurityClassification.ViewOnly,
|
||||
IsHistorized: false,
|
||||
IsAlarm: false,
|
||||
WriteIdempotent: tag.WriteIdempotent);
|
||||
WriteIdempotent: tag.WriteIdempotent,
|
||||
NativeDataType: tag.DataType.ToString());
|
||||
|
||||
/// <summary>Count of registered devices — exposed for diagnostics + tests.</summary>
|
||||
internal int DeviceCount => _devices.Count;
|
||||
|
||||
@@ -91,9 +91,16 @@ public sealed class AbCipDriverProbe : IDriverProbe
|
||||
|
||||
// Prefer the first authored tag path as the probe tag name (it must exist on the PLC). v3: the
|
||||
// tags arrive as RawTagEntry blobs — map each through the pure factory to recover its TagPath +
|
||||
// device routing key. Fall back to a benign system attribute present on all ControlLogix /
|
||||
// CompactLogix controllers (@raw_cpu_type); still returns ErrorNotFound on Micro800 / MicroLogix,
|
||||
// which is sufficient to confirm CIP reachability.
|
||||
// device routing key.
|
||||
//
|
||||
// The fallback is NOT a system attribute the controller can read, despite its name and despite
|
||||
// what this comment used to claim: libplctag's CIP special-tag dispatch knows only @raw, @tags
|
||||
// and @udt/, so "@raw_cpu_type" is encoded and sent as an ORDINARY symbolic name. That is what
|
||||
// makes it a usable probe — no controller defines it, so the controller answers CIP 0x04 ("tag
|
||||
// does not exist") and the answer itself proves the session is live. IsReachableStatus is what
|
||||
// turns that into a green tick, so a device with no authored tags reports reachable, not failed.
|
||||
// Do not "fix" this to @tags: libplctag builds a tag-listing tag WITHOUT any network I/O, so it
|
||||
// reports OK against any TCP listener — measured green against a Mosquitto broker.
|
||||
var mappedTags = opts.RawTags
|
||||
.Select(e => AbCipTagDefinitionFactory.FromTagConfig(e.TagConfig, e.RawPath, out var d)
|
||||
? (Ok: true, Device: e.DeviceName, d.TagPath)
|
||||
@@ -167,22 +174,35 @@ public sealed class AbCipDriverProbe : IDriverProbe
|
||||
/// but the probe tag name was not found or did not match — sufficient to confirm CIP
|
||||
/// reachability. Returns <c>true</c> for <see cref="Status.Ok"/> (tag was found).
|
||||
/// </summary>
|
||||
private static bool IsReachableStatus(Status s) => s is
|
||||
internal static bool IsReachableStatus(Status s) => s is
|
||||
Status.Ok or
|
||||
Status.ErrorNotFound or
|
||||
Status.ErrorNoMatch or
|
||||
Status.ErrorBadDevice;
|
||||
|
||||
/// <summary>
|
||||
/// Inspects a <see cref="LibPlcTagException"/> message to decide whether the error is a
|
||||
/// "controller answered CIP but tag not found" condition (reachable) versus a session /
|
||||
/// transport failure (unreachable). Falls back to checking the exception message for
|
||||
/// known tag-not-found phrases used by the libplctag native library.
|
||||
/// Decides whether a <see cref="LibPlcTagException"/> is a "controller answered CIP but the
|
||||
/// probe tag did not resolve" condition (reachable) or a session / transport failure
|
||||
/// (unreachable), by recovering the <see cref="Status"/> the wrapper threw on and deferring
|
||||
/// to <see cref="IsReachableStatus"/> — one classification, not two that can drift.
|
||||
/// </summary>
|
||||
private static bool IsReachableException(LibPlcTagException ex)
|
||||
/// <param name="ex">The exception libplctag threw out of <c>InitializeAsync</c>.</param>
|
||||
/// <returns><c>true</c> when the controller answered; <c>false</c> for a transport/session failure.</returns>
|
||||
/// <remarks>
|
||||
/// <b>The status arrives as the managed enum NAME.</b> <c>NativeTagWrapper.ThrowIfStatusNotOk</c>
|
||||
/// constructs the exception with <c>Status.ToString()</c>, so the message is exactly
|
||||
/// <c>"ErrorNotFound"</c> — never the native C spelling <c>"STATUS_NOT_FOUND"</c> this method
|
||||
/// used to look for. Those substrings could not match, so the reachable classification was
|
||||
/// unreachable on this path — and this IS the path a probe takes whenever its tag does not
|
||||
/// resolve, which is every Test Connect against a device with no authored tags. The native
|
||||
/// spellings are kept as a secondary match in case a future wrapper reports them.
|
||||
/// </remarks>
|
||||
internal static bool IsReachableException(LibPlcTagException ex)
|
||||
{
|
||||
// libplctag wraps the Status in the message as "STATUS_NOT_FOUND", "STATUS_NO_MATCH", etc.
|
||||
var msg = ex.Message ?? string.Empty;
|
||||
if (Enum.TryParse<Status>(msg.Trim(), ignoreCase: true, out var status) && Enum.IsDefined(status))
|
||||
return IsReachableStatus(status);
|
||||
|
||||
return msg.Contains("NOT_FOUND", StringComparison.OrdinalIgnoreCase)
|
||||
|| msg.Contains("NO_MATCH", StringComparison.OrdinalIgnoreCase)
|
||||
|| msg.Contains("BAD_DEVICE", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
@@ -613,7 +613,8 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
SecurityClass: SecurityClassification.ViewOnly,
|
||||
IsHistorized: false,
|
||||
IsAlarm: false,
|
||||
WriteIdempotent: tag.WriteIdempotent));
|
||||
WriteIdempotent: tag.WriteIdempotent,
|
||||
NativeDataType: tag.DataType.ToString()));
|
||||
}
|
||||
}
|
||||
return Task.CompletedTask;
|
||||
@@ -638,7 +639,8 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
SecurityClass: SecurityClassification.ViewOnly,
|
||||
IsHistorized: false,
|
||||
IsAlarm: false,
|
||||
WriteIdempotent: false));
|
||||
WriteIdempotent: false,
|
||||
NativeDataType: NativeName(type)));
|
||||
}
|
||||
|
||||
private static void EmitAxisVariable(
|
||||
@@ -653,9 +655,24 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
SecurityClass: SecurityClassification.ViewOnly,
|
||||
IsHistorized: false,
|
||||
IsAlarm: false,
|
||||
WriteIdempotent: false));
|
||||
WriteIdempotent: false,
|
||||
NativeDataType: nameof(FocasDataType.Float64)));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The <see cref="FocasDataType"/> name a fixed-tree node binds by, for the browse-commit
|
||||
/// path (Gitea #536). The fixed tree only ever emits these three, and each has an
|
||||
/// identically-named <see cref="FocasDataType"/> member — the driver-agnostic
|
||||
/// <see cref="DriverDataType"/> cannot be reversed (Byte/Int16/Int32 all collapse to
|
||||
/// <see cref="DriverDataType.Int32"/>), so the native name is carried rather than derived.
|
||||
/// </summary>
|
||||
private static string NativeName(FocasDriverDataType type) => type switch
|
||||
{
|
||||
FocasDriverDataType.Int32 => nameof(FocasDataType.Int32),
|
||||
FocasDriverDataType.Float64 => nameof(FocasDataType.Float64),
|
||||
_ => nameof(FocasDataType.String),
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Emit a variable under a named fixed-tree folder (Program, OperationMode,
|
||||
/// …). Full-reference shape is <c>{deviceHost}/{folderPath}/{field}</c>.
|
||||
@@ -673,7 +690,13 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
SecurityClass: SecurityClassification.ViewOnly,
|
||||
IsHistorized: false,
|
||||
IsAlarm: false,
|
||||
WriteIdempotent: false));
|
||||
WriteIdempotent: false,
|
||||
NativeDataType: NativeName(type switch
|
||||
{
|
||||
DriverDataType.Int32 => FocasDriverDataType.Int32,
|
||||
DriverDataType.Float64 => FocasDriverDataType.Float64,
|
||||
_ => FocasDriverDataType.String,
|
||||
})));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -421,7 +421,8 @@ public sealed class TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery
|
||||
: SecurityClassification.ViewOnly,
|
||||
IsHistorized: false,
|
||||
IsAlarm: false,
|
||||
WriteIdempotent: tag.WriteIdempotent));
|
||||
WriteIdempotent: tag.WriteIdempotent,
|
||||
NativeDataType: tag.DataType.ToString()));
|
||||
}
|
||||
|
||||
// Controller-side symbol browse — opt-in. Falls back to pre-declared-only on any
|
||||
@@ -452,7 +453,8 @@ public sealed class TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery
|
||||
: SecurityClassification.Operate,
|
||||
IsHistorized: false,
|
||||
IsAlarm: false,
|
||||
WriteIdempotent: false));
|
||||
WriteIdempotent: false,
|
||||
NativeDataType: dt.ToString()));
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException) { throw; }
|
||||
|
||||
Reference in New Issue
Block a user