Files
lmxopcua/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/IFocasClient.cs
T
Joseph Doherty 482bafdfac feat(focas): CNC alarms reach Part 9 conditions via ALARM: tags (#647)
An ALARM: address (*, <Type>, <Type>/<n>, <Type>/<lo>-<hi>, T<code>, OTHER) on a raw tag that
also carries the cross-driver alarm object is ONE condition with ConditionId == RawPath and a
Kind (Raise / Retrigger / Clear), so DriverHostActor.ForwardNativeAlarm routes it and
NativeAlarmProjector activates it. Fixes all seven triage defects:

1. SubscribeAlarmsAsync treated the host's RawPaths as device hosts -> nothing polled.
2. Decorated "{host}#{Type}:{Number}" ConditionId -> every event dropped.
3. No Kind -> projector kept prior state.
4. WireFocasClient.ReadAlarmsAsync answered [] on failure -> mass clear + un-acked re-raise.
   It now throws; a failed device read keeps every condition's state.
5. Acks lost on reconnect -> told-record dedup (MTConnect #626 pattern) held for the driver's
   lifetime; same-set re-subscribe / re-init re-raise nothing; changed set prunes leavers only.
6. Subscribe threw while AlarmProjection.Enabled=false -> the tag is the opt-in; flag is a no-op.
7. Alarm tags could not be authored -> ALARM: grammar (FocasAlarmSelector, Contracts) backs the
   editor, the address picker, the deploy-time inspector and driver init.

Severity: authored alarm.severity -> exact OpcUaSeverity; else the 16i-era type bucket. The type
table stays the only one (moved to Contracts, documented 16i-era; 30i codes not invented; raw
T<code> selector + raw code in every message). Read-only: ack is server-side. Also clears the
value table on a re-initialise (#630 gap that let a removed/moved tag keep reading).

E2E: Runtime.Tests FocasNativeAlarmEndToEndTests drives the real driver through the real
DriverHostActor and asserts the condition goes Active then inactive.

Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-08 19:46:48 -04:00

455 lines
22 KiB
C#

namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
/// <summary>
/// Wire-layer abstraction over one FOCAS session to a CNC. The driver holds one per
/// configured device; lifetime matches the device.
/// </summary>
/// <remarks>
/// <para>The default implementation is <see cref="Wire.WireFocasClient"/> — a pure-managed
/// FOCAS/2 Ethernet client that speaks the wire protocol directly on TCP:8193. No
/// P/Invoke, no native DLLs, no out-of-process isolation.</para>
///
/// <para><see cref="UnimplementedFocasClientFactory"/> is a scaffolding backend that
/// throws on <see cref="IFocasClientFactory.Create"/> — selected by
/// <c>"Backend": "unimplemented"</c> so a DriverInstance row can be seeded before the CNC
/// endpoint is reachable without silently reading stale data.</para>
/// </remarks>
public interface IFocasClient : IDisposable
{
/// <summary>Open the FWLIB handle + TCP session. Idempotent.</summary>
/// <param name="address">The CNC host address and port.</param>
/// <param name="timeout">The connection timeout duration.</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
Task ConnectAsync(FocasHostAddress address, TimeSpan timeout, CancellationToken cancellationToken);
/// <summary>True when the FWLIB handle is valid + the socket is up.</summary>
bool IsConnected { get; }
/// <summary>
/// Read the value at <paramref name="address"/> in the requested
/// <paramref name="type"/>. Returns a boxed .NET value + the OPC UA status mapped
/// through <see cref="FocasStatusMapper"/>.
/// </summary>
/// <param name="address">The CNC memory address to read from.</param>
/// <param name="type">The FOCAS data type to read.</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>A tuple of the boxed value read and the mapped OPC UA status code.</returns>
Task<(object? value, uint status)> ReadAsync(
FocasAddress address,
FocasDataType type,
CancellationToken cancellationToken);
/// <summary>
/// Write <paramref name="value"/> to <paramref name="address"/>. Returns the mapped
/// OPC UA status (0 = Good).
/// </summary>
/// <param name="address">The CNC memory address to write to.</param>
/// <param name="type">The FOCAS data type to write.</param>
/// <param name="value">The value to write.</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>The mapped OPC UA status code (0 = Good).</returns>
Task<uint> WriteAsync(
FocasAddress address,
FocasDataType type,
object? value,
CancellationToken cancellationToken);
/// <summary>
/// Cheap health probe — e.g. <c>cnc_rdcncstat</c>. Returns <c>true</c> when the CNC
/// responds with any valid status.
/// </summary>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns><c>true</c> when the CNC responds with a valid status; otherwise <c>false</c>.</returns>
Task<bool> ProbeAsync(CancellationToken cancellationToken);
/// <summary>
/// Read active alarm messages from the CNC via <c>cnc_rdalmmsg2</c>. Returns
/// zero-or-more active alarms; an empty list means "the CNC answered, and no alarm is
/// active". The alarm projection polls this per device and diffs it into Part 9
/// condition transitions on the driver's <c>OnAlarmEvent</c>.
/// <para>
/// <b>A failed read MUST throw</b>, never answer empty (Gitea #647): the projection treats an
/// empty answer as "every alarm cleared", so a failure reported as <c>[]</c> becomes a mass
/// clear followed by an un-acknowledged re-raise on recovery. A throw keeps the last state.
/// </para>
/// </summary>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>The list of currently active alarms; empty when none are active.</returns>
/// <exception cref="Exception">Any failure to obtain the list — the session is down, the CNC refused, the wire faulted.</exception>
Task<IReadOnlyList<FocasActiveAlarm>> ReadAlarmsAsync(CancellationToken cancellationToken);
/// <summary>
/// Read one PMC range (<c>pmc_rdpmcrng</c>) as raw slots: <c>(end - start + 1)</c> bytes split into
/// slots of <paramref name="slotType"/>'s width, from <paramref name="start"/>. The primitive behind PMC
/// range coalescing (Gitea #266) — the driver slices individual tag values out of the returned slots with
/// the same decode a per-tag read uses.
/// </summary>
/// <param name="letter">PMC area letter.</param>
/// <param name="slotType">Decides the slot width (Bit/Byte = 1, Int16 = 2, Int32/Float32 = 4, Float64 = 8).</param>
/// <param name="start">First byte address.</param>
/// <param name="end">Last byte address, inclusive; a whole number of slots from <paramref name="start"/>.</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>
/// The raw slot values (null when not Good) and the mapped OPC UA status. A returned non-Good status is
/// always a CNC return-code refusal; a transport failure or timeout throws instead.
/// </returns>
Task<(IReadOnlyList<long>? slots, uint status)> ReadPmcSlotsAsync(
string letter, FocasDataType slotType, int start, int end, CancellationToken cancellationToken);
// ---- Fixed-tree T1 (identity + axis discovery + fast-poll dynamic bundle) ----
/// <summary>
/// Read CNC identity via <c>cnc_sysinfo</c>. Populates the <c>Identity/*</c>
/// subtree of the fixed-node surface. Callable once at session open; the
/// values don't change across the session.
/// </summary>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>The CNC identity snapshot.</returns>
Task<FocasSysInfo> GetSysInfoAsync(CancellationToken cancellationToken);
/// <summary>
/// Read the CNC's configured axis names via <c>cnc_rdaxisname</c>. The driver
/// uses these to build the <c>Axes/{name}/</c> subtree and to index
/// <see cref="ReadDynamicAsync"/> calls.
/// </summary>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>The CNC's configured axis names.</returns>
Task<IReadOnlyList<FocasAxisName>> GetAxisNamesAsync(CancellationToken cancellationToken);
/// <summary>
/// Read the CNC's configured spindle names via <c>cnc_rdspdlname</c>. Drives
/// the <c>Spindle/{name}/</c> subtree.
/// </summary>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>The CNC's configured spindle names.</returns>
Task<IReadOnlyList<FocasSpindleName>> GetSpindleNamesAsync(CancellationToken cancellationToken);
/// <summary>
/// Read the fast-poll dynamic bundle for one axis via <c>cnc_rddynamic2</c>.
/// Returns the current position quadruple (absolute / machine / relative /
/// distance-to-go) plus actual feed rate + actual spindle speed + alarm
/// flags + program / sequence numbers — one network round-trip per call.
/// </summary>
/// <param name="axisIndex">The axis index to read dynamics for.</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>The fast-poll dynamic bundle for the requested axis.</returns>
Task<FocasDynamicSnapshot> ReadDynamicAsync(int axisIndex, CancellationToken cancellationToken);
// ---- Fixed-tree T2 (program + operation mode) ----
/// <summary>
/// Aggregate program + operation-mode snapshot. One wire round-trip per
/// underlying FWLIB call — <c>cnc_rdblkcount</c>, <c>cnc_exeprgname2</c>,
/// <c>cnc_rdopmode</c>. The driver polls this on a slower cadence than
/// <see cref="ReadDynamicAsync"/> since program / mode transitions happen
/// on human-operator timescales.
/// </summary>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>The aggregate program + operation-mode snapshot.</returns>
Task<FocasProgramInfo> GetProgramInfoAsync(CancellationToken cancellationToken);
// ---- Fixed-tree T2.5 (CNC status flags, Gitea #257) ----
/// <summary>
/// Read the CNC status flags via <c>cnc_statinfo</c> (<c>ODBST</c>). Populates the
/// <c>Status/*</c> subtree. Throws when the CNC refuses the call, which the driver's
/// bootstrap probe reads as "this series has no status block" and suppresses the subtree.
/// </summary>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>The CNC status flag snapshot.</returns>
Task<FocasStatus> GetStatusAsync(CancellationToken cancellationToken);
// ---- Fixed-tree T3 (timers) ----
/// <summary>
/// Read one CNC cumulative timer. Kind selects PowerOn / Operating / Cutting /
/// Cycle. Values are seconds — the managed side already converted the native
/// minute+msec representation so downstream nodes display uniform units.
/// </summary>
/// <param name="kind">The timer kind to read.</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>The requested cumulative timer reading.</returns>
Task<FocasTimer> GetTimerAsync(FocasTimerKind kind, CancellationToken cancellationToken);
// ---- Fixed-tree T3.5 (servo meters) ----
/// <summary>
/// Read the servo-load meter percentages across all configured axes. Each record's own
/// <c>value / base^exponent</c> envelope is applied (servo loads are direct percentages);
/// a record whose envelope describes no value carries a null <see cref="FocasServoLoad.LoadPercent"/>.
/// Empty list on a disconnected session or unsupported CNC.
/// </summary>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>The servo-load meter percentages across all configured axes.</returns>
Task<IReadOnlyList<FocasServoLoad>> GetServoLoadsAsync(CancellationToken cancellationToken);
// ---- Fixed-tree T3.6 (spindle meters) ----
/// <summary>
/// Read per-spindle load as a percentage of rated load (<c>cnc_rdspmeter</c> type 0; the
/// raw meter is a 1/16384 fraction of full scale). Result list index corresponds to
/// spindle index from <see cref="GetSpindleNamesAsync"/>; a slot whose record describes no
/// value is null. Empty list on a disconnected session or when the CNC doesn't support the
/// call (older series like 16i may return EW_FUNC).
/// </summary>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>Per-spindle load percentages, indexed as per <see cref="GetSpindleNamesAsync"/>.</returns>
Task<IReadOnlyList<double?>> GetSpindleLoadsAsync(CancellationToken cancellationToken);
/// <summary>
/// Read per-spindle motor speed as a percentage of maximum speed (<c>cnc_rdspmeter</c>
/// type 1, historically labelled <c>cnc_rdspmaxrpm</c>). A live value, not a max-RPM
/// constant. Index alignment and null semantics as per <see cref="GetSpindleLoadsAsync"/>.
/// </summary>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>Per-spindle motor-speed percentages, indexed as per <see cref="GetSpindleLoadsAsync"/>.</returns>
Task<IReadOnlyList<double?>> GetSpindleMotorSpeedsAsync(CancellationToken cancellationToken);
}
/// <summary>
/// CNC status flags from <c>cnc_statinfo</c> (<c>ODBST</c>) — raw FANUC codes, published verbatim as
/// Int32 so a client sees exactly what the control reported. <see cref="Run"/> additionally has a
/// text projection (<see cref="FocasRunState.ToText"/>).
/// </summary>
/// <param name="Auto">Automatic-operation mode code.</param>
/// <param name="Run">Automatic-operation state (0 RESET, 1 STOP, 2 HOLD, 3 START, 4 MSTR).</param>
/// <param name="Motion">Axis motion (0 none, 1 motion, 2 dwell).</param>
/// <param name="Mstb">M/S/T/B function state (0 none, 1 executing).</param>
/// <param name="Emergency">Emergency stop (0 not, 1 emergency, 2 reset).</param>
/// <param name="Alarm">Alarm state (0 none, 1 alarm, 2 battery-low, 3 fan).</param>
/// <param name="Edit">Program-edit state.</param>
/// <param name="TmMode">T/M mode as reported by the control.</param>
public sealed record FocasStatus(int Auto, int Run, int Motion, int Mstb, int Emergency, int Alarm, int Edit, int TmMode);
/// <summary>Human-readable text for <see cref="FocasStatus.Run"/>.</summary>
public static class FocasRunState
{
/// <summary>Converts the <c>ODBST.run</c> code to its operator-facing label.</summary>
/// <param name="run">The run-state code.</param>
/// <returns>The label, or <c>Run{n}</c> for a code outside the documented set.</returns>
public static string ToText(int run) => run switch
{
0 => "RESET",
1 => "STOP",
2 => "HOLD",
3 => "START",
4 => "MSTR",
_ => $"Run{run}",
};
}
/// <summary>
/// One servo-meter entry — one axis's current load percentage; null when the CNC's record
/// described no usable value (never a fabricated 0).
/// </summary>
public sealed record FocasServoLoad(string AxisName, double? LoadPercent);
/// <summary>Which cumulative counter <see cref="IFocasClient.GetTimerAsync"/> reads.</summary>
public enum FocasTimerKind
{
/// <summary>Machine power-on hours — resets never.</summary>
PowerOn = 0,
/// <summary>Cycle operating time — resets when the operator clears the counter.</summary>
Operating = 1,
/// <summary>Cutting time — only counts while in cutting feed.</summary>
Cutting = 2,
/// <summary>Cycle time since the last program start.</summary>
Cycle = 3,
}
/// <summary>One cumulative timer reading. <see cref="TotalSeconds"/> is the canonical unit.</summary>
public sealed record FocasTimer(FocasTimerKind Kind, int Minutes, int Milliseconds)
{
/// <summary>Cumulative time in seconds — <c>Minutes * 60 + Milliseconds / 1000</c>.</summary>
public double TotalSeconds => Minutes * 60.0 + Milliseconds / 1000.0;
}
/// <summary>
/// CNC identity snapshot from <c>cnc_sysinfo</c>. Strings are trimmed ASCII.
/// </summary>
public sealed record FocasSysInfo(
int AddInfo,
int MaxAxis,
string CncType, // "M" (mill) / "T" (lathe)
string MtType,
string Series, // e.g. "30i"
string Version, // e.g. "A1.0"
int AxesCount);
/// <summary>One configured axis name (e.g. "X", "X1").</summary>
public sealed record FocasAxisName(string Name, string Suffix)
{
/// <summary>
/// Display name — name + suffix concatenated, trimmed. Empty suffix yields
/// just the name (the common case on single-channel CNCs).
/// </summary>
public string Display => string.IsNullOrEmpty(Suffix) ? Name : $"{Name}{Suffix}";
}
/// <summary>One configured spindle name (e.g. "S1").</summary>
public sealed record FocasSpindleName(string Name, string Suffix1, string Suffix2, string Suffix3)
{
/// <summary>Gets the display name — name + concatenated suffixes, trimmed.</summary>
public string Display
{
get
{
var s = Name + Suffix1 + Suffix2 + Suffix3;
return s.TrimEnd('\0', ' ');
}
}
}
/// <summary>
/// Program + operation-mode snapshot. Name is the currently-executing
/// program filename (e.g. "O0001.NC"); ONumber is its Fanuc O-number (1-9999).
/// Mode is the numeric code from <c>cnc_rdopmode</c> — see <see cref="FocasOpMode"/>.
/// <b>A null field means the CNC did not report it</b> (its sub-read was refused, or the
/// payload was too short to carry it); the driver publishes that node Bad. It is never
/// defaulted to <c>0</c>, which is a genuine O-number, block count, and mode (MDI).
/// </summary>
public sealed record FocasProgramInfo(
string? Name,
int? ONumber,
int? BlockCount,
int? Mode);
/// <summary>Human-readable text for the <see cref="FocasProgramInfo.Mode"/> integer.</summary>
public static class FocasOpMode
{
/// <summary>Converts a numeric operation mode to its text representation.</summary>
/// <param name="mode">The operation mode integer code.</param>
/// <returns>The text representation of the mode, or a fallback format if unknown.</returns>
public static string ToText(int mode) => mode switch
{
0 => "MDI",
1 => "AUTO",
2 => "TJOG",
3 => "EDIT",
4 => "HANDLE",
5 => "JOG",
6 => "TEACH_IN_HANDLE",
7 => "REFERENCE",
8 => "REMOTE",
9 => "TEST",
_ => $"Mode{mode}",
};
}
/// <summary>
/// Fast-poll bundle for one axis (<c>cnc_rddynamic2</c>). Each position carries its own scale
/// (see <see cref="FocasPosition"/>); the driver converts it to engineering units at publish.
/// </summary>
public sealed record FocasDynamicSnapshot(
int AxisIndex,
int AlarmFlags,
int ProgramNumber,
int MainProgramNumber,
int SequenceNumber,
int ActualFeedRate,
int ActualSpindleSpeed,
FocasPosition AbsolutePosition,
FocasPosition MachinePosition,
FocasPosition RelativePosition,
FocasPosition DistanceToGo);
/// <summary>
/// One <c>cnc_rddynamic2</c> position (command <c>0x0026</c>) as the CNC reported it. The 8-byte
/// wire block is self-describing — <c>[0..3] i32 raw</c>, <c>[4..5] u16 base</c> (2 or 10),
/// <c>[6..7] u16 exponent</c> (<c>0xFFFF</c> = the slot holds no valid position) — and the real
/// coordinate is <c>raw / base^exponent</c>. Confirmed on a real FANUC 31i-B: every block of the
/// <c>ref-dynamic2-axis1.bin</c> capture reports base 10 / exponent 4 (280.1574 mm, not
/// 2801574). That envelope is why no separate figure read exists: the <c>cnc_getfigure</c>
/// (0x00D3) this driver used to send is not a FOCAS wire command, and the 31i-B refuses it.
/// </summary>
/// <param name="Raw">The raw i32 position value.</param>
/// <param name="Base">The block's scale base (only 2 and 10 are meaningful).</param>
/// <param name="Exponent">The block's scale exponent.</param>
/// <param name="HasScale">
/// True when the block carried the base/exponent envelope. A scale-less block (a 4-byte payload
/// from an older simulator, or a raw integer a test supplies) falls back to the device's
/// configured <c>PositionDecimalPlaces</c>.
/// </param>
public readonly record struct FocasPosition(int Raw, int Base = 0, int Exponent = 0, bool HasScale = false)
{
/// <summary>
/// False when the block says it holds no usable position: the <c>0xFFFF</c> vacant marker, a
/// base other than 2 or 10, or an absurd exponent. A per-node quality decision — that node
/// publishes Bad, its siblings are unaffected.
/// </summary>
/// <remarks>
/// The exponent bound matters: left unbounded, <c>raw / 10^400</c> is
/// <c>raw / Infinity = 0.0</c> — a Good reading of a machine parked at the origin.
/// </remarks>
public bool IsValid => !HasScale || Wire.FocasValueEnvelope.IsValid(Base, Exponent);
/// <summary>
/// The position in engineering units: <c>Raw / Base^Exponent</c> when the block carried its
/// own scale (the configured fallback is then ignored), else <c>Raw / 10^fallbackDecimalPlaces</c>.
/// Null when <see cref="IsValid"/> is false.
/// </summary>
/// <param name="fallbackDecimalPlaces">The device's configured <c>PositionDecimalPlaces</c>.</param>
/// <returns>The scaled position, or null when the block describes none.</returns>
public double? ToEngineering(int fallbackDecimalPlaces)
{
if (!IsValid) return null;
if (HasScale) return Wire.FocasValueEnvelope.Scale(Raw, Base, Exponent);
var dp = Math.Max(0, fallbackDecimalPlaces);
return dp > 0 ? Raw / Math.Pow(10, dp) : Raw;
}
/// <summary>A bare integer is a scale-less position (legacy shape; tests and fakes).</summary>
/// <param name="raw">The raw position value.</param>
public static implicit operator FocasPosition(int raw) => new(raw);
}
/// <summary>
/// One active alarm surfaced by <see cref="IFocasClient.ReadAlarmsAsync"/>. Shape
/// mirrors <c>ODBALMMSG2</c> but normalises the message bytes to a .NET string.
/// </summary>
public sealed record FocasActiveAlarm(
int AlarmNumber,
short Type,
short Axis,
string Message);
/// <summary>Factory for <see cref="IFocasClient"/>s. One client per configured device.</summary>
public interface IFocasClientFactory
{
/// <summary>Creates a new FOCAS client instance.</summary>
/// <returns>A new IFocasClient instance.</returns>
IFocasClient Create();
/// <summary>
/// Config-time usability probe called by <see cref="FocasDriver.InitializeAsync"/> before
/// any background loops start. Must NOT create a live wire client. Implementations that
/// are permanently non-functional (e.g. <see cref="UnimplementedFocasClientFactory"/>)
/// throw <see cref="NotSupportedException"/> here so the driver faults immediately at
/// init rather than lazily on the first read.
/// </summary>
/// <exception cref="NotSupportedException">
/// Thrown by stub/unimplemented backends to force a fail-fast init fault.
/// </exception>
void EnsureUsable();
}
/// <summary>
/// Scaffolding factory — throws on <see cref="Create"/> so a DriverInstance row can be
/// seeded ahead of the CNC endpoint being reachable without silently reading stale data.
/// Select via <c>"Backend": "unimplemented"</c> in driver config. Flip to
/// <c>"Backend": "wire"</c> once the CNC is provisioned.
/// </summary>
public sealed class UnimplementedFocasClientFactory : IFocasClientFactory
{
private const string Message =
"FOCAS driver backend is 'unimplemented'. Switch to 'Backend: \"wire\"' in driver config " +
"once the CNC is provisioned — see docs/drivers/FOCAS.md.";
/// <inheritdoc />
public void EnsureUsable() => throw new NotSupportedException(Message);
/// <inheritdoc />
public IFocasClient Create() => throw new NotSupportedException(Message);
}