Files
lmxopcua/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/IFocasClient.cs
2026-04-26 04:54:28 -04:00

445 lines
23 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><b>No default wire implementation ships with this assembly.</b> FWLIB
/// (<c>Fwlib32.dll</c>) is Fanuc-proprietary and requires a valid customer license — it
/// cannot legally be redistributed. The deployment team supplies an
/// <see cref="IFocasClientFactory"/> that wraps the licensed <c>Fwlib32.dll</c> via
/// P/Invoke and registers it at server startup.</para>
///
/// <para>The default <see cref="UnimplementedFocasClientFactory"/> throws with a pointer at
/// the deployment docs so misconfigured servers fail fast with a clear error rather than
/// mysteriously hanging.</para>
/// </remarks>
public interface IFocasClient : IDisposable
{
/// <summary>Open the FWLIB handle + TCP session. Idempotent.</summary>
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>
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>
Task<uint> WriteAsync(
FocasAddress address,
FocasDataType type,
object? value,
CancellationToken cancellationToken);
/// <summary>
/// Write a CNC parameter value via <c>cnc_wrparam</c> (FWLIB <c>IODBPSD</c> packet —
/// byte layout symmetric with the <c>cnc_rdparam</c> read side). Plan PR F4-b
/// (issue #269). The <paramref name="address"/> is parsed from a <c>PARAM:N</c>
/// tag string; <paramref name="type"/> drives the payload width (Byte / Int16 /
/// Int32). Default impl returns <see cref="FocasStatusMapper.BadNotSupported"/>
/// so transports that haven't yet routed the write keep compiling.
/// <para>EW_PASSWD from the CNC (parameter-write switch off / unlock required)
/// surfaces as <see cref="FocasStatusMapper.BadUserAccessDenied"/>; F4-d will
/// wire the unlock workflow on top.</para>
/// </summary>
Task<uint> WriteParameterAsync(
FocasAddress address,
FocasDataType type,
object? value,
CancellationToken cancellationToken)
=> Task.FromResult(FocasStatusMapper.BadNotSupported);
/// <summary>
/// Write a CNC macro variable value via <c>cnc_wrmacro</c> (FWLIB <c>ODBM</c> packet
/// symmetric with the <c>cnc_rdmacro</c> read side). Plan PR F4-b (issue #269).
/// The implementation encodes <paramref name="value"/> as <c>(intValue,
/// decimalPointCount)</c>; today we ship integer-only (<c>decimalPointCount = 0</c>)
/// to match the most common HMI pattern, and a future <c>WriteMacroScaled</c>
/// overload can land if the field calls for fractional macro setpoints.
/// Default impl returns <see cref="FocasStatusMapper.BadNotSupported"/>.
/// </summary>
Task<uint> WriteMacroAsync(
FocasAddress address,
object? value,
CancellationToken cancellationToken)
=> Task.FromResult(FocasStatusMapper.BadNotSupported);
/// <summary>
/// Cheap health probe — e.g. <c>cnc_rdcncstat</c>. Returns <c>true</c> when the CNC
/// responds with any valid status.
/// </summary>
Task<bool> ProbeAsync(CancellationToken cancellationToken);
/// <summary>
/// Read the full <c>cnc_rdcncstat</c> ODBST struct (9 small-int status flags). The
/// boolean <see cref="ProbeAsync"/> is preserved for cheap reachability checks; this
/// method exposes the per-field detail used by the FOCAS driver's <c>Status/</c>
/// fixed-tree nodes (see issue #257). Returns <c>null</c> if the wire client cannot
/// supply the struct (e.g. transport/IPC variant where the contract has not been
/// extended yet) — callers fall back to surfacing Bad on the per-field nodes.
/// </summary>
Task<FocasStatusInfo?> GetStatusAsync(CancellationToken cancellationToken)
=> Task.FromResult<FocasStatusInfo?>(null);
/// <summary>
/// Read the per-CNC production counters (parts produced / required / total via
/// <c>cnc_rdparam(6711/6712/6713)</c>) plus the current cycle-time seconds counter
/// (<c>cnc_rdtimer(2)</c>). Surfaced on the FOCAS driver's <c>Production/</c>
/// fixed-tree per device (issue #258). Returns <c>null</c> when the wire client
/// cannot supply the snapshot (e.g. older transport variant) — the driver leaves
/// the cache untouched and the per-field nodes report Bad until the first refresh.
/// </summary>
Task<FocasProductionInfo?> GetProductionAsync(CancellationToken cancellationToken)
=> Task.FromResult<FocasProductionInfo?>(null);
/// <summary>
/// Read the active modal M/S/T/B codes via <c>cnc_modal</c>. G-group decoding is
/// deferred — the FWLIB <c>ODBMDL</c> union differs per series + group and the
/// issue body permits surfacing only the universally-present M/S/T/B fields in
/// the first cut (issue #259). Returns <c>null</c> when the wire client cannot
/// supply the snapshot.
/// </summary>
Task<FocasModalInfo?> GetModalAsync(CancellationToken cancellationToken)
=> Task.FromResult<FocasModalInfo?>(null);
/// <summary>
/// Read the four operator override values (feed / rapid / spindle / jog) via
/// <c>cnc_rdparam</c>. The parameter numbers are MTB-specific so the caller passes
/// them in via <paramref name="parameters"/>; a <c>null</c> entry suppresses that
/// field's read (the corresponding node is also omitted from the address space).
/// Returns <c>null</c> when the wire client cannot supply the snapshot (issue #259).
/// </summary>
Task<FocasOverrideInfo?> GetOverrideAsync(
FocasOverrideParameters parameters, CancellationToken cancellationToken)
=> Task.FromResult<FocasOverrideInfo?>(null);
/// <summary>
/// Read the current tool number via <c>cnc_rdtnum</c>. Surfaced on the FOCAS driver's
/// <c>Tooling/</c> fixed-tree per device (issue #260). Tool life + current offset
/// index are deferred — <c>cnc_rdtlinfo</c>/<c>cnc_rdtlsts</c> vary heavily across
/// CNC series + the FWLIB <c>ODBTLIFE*</c> unions need per-series shape handling
/// that exceeds the L-sized scope of this PR. Returns <c>null</c> when the wire
/// client cannot supply the snapshot (e.g. older transport variant).
/// </summary>
Task<FocasToolingInfo?> GetToolingAsync(CancellationToken cancellationToken)
=> Task.FromResult<FocasToolingInfo?>(null);
/// <summary>
/// Read the standard G54..G59 work-coordinate offsets via
/// <c>cnc_rdzofs(handle, n=1..6)</c>. Returns one <see cref="FocasWorkOffset"/>
/// per slot (issue #260). Extended G54.1 P1..P48 offsets are deferred — they use
/// a different FOCAS call (<c>cnc_rdzofsr</c>) + different range handling. Each
/// offset surfaces a fixed X/Y/Z view; lathes/mills with extra rotational axes
/// have those columns reported as 0.0. Returns <c>null</c> when the wire client
/// cannot supply the snapshot.
/// </summary>
Task<FocasWorkOffsetsInfo?> GetWorkOffsetsAsync(CancellationToken cancellationToken)
=> Task.FromResult<FocasWorkOffsetsInfo?>(null);
/// <summary>
/// Read the four FANUC operator-message classes via <c>cnc_rdopmsg3</c> (issue #261).
/// The call returns up to 4 active messages per class; the driver collapses the
/// latest non-empty message per class onto the <c>Messages/External/Latest</c>
/// fixed-tree node — the issue body permits this minimal surface in the first cut.
/// Trailing nulls / spaces are trimmed before publishing so the same message
/// round-trips with stable text. Returns <c>null</c> when the wire client cannot
/// supply the snapshot (older transport variant).
/// </summary>
Task<FocasOperatorMessagesInfo?> GetOperatorMessagesAsync(CancellationToken cancellationToken)
=> Task.FromResult<FocasOperatorMessagesInfo?>(null);
/// <summary>
/// Read the currently-executing block text via <c>cnc_rdactpt</c> (issue #261).
/// The call returns the active block of the running program; surfaced as
/// <c>Program/CurrentBlock</c> Float-trimmed string. Returns <c>null</c> when the
/// wire client cannot supply the snapshot.
/// </summary>
Task<FocasCurrentBlockInfo?> GetCurrentBlockAsync(CancellationToken cancellationToken)
=> Task.FromResult<FocasCurrentBlockInfo?>(null);
/// <summary>
/// Read the per-axis decimal-place counts via <c>cnc_getfigure</c> (issue #262).
/// Returned dictionary maps axis name (or fallback <c>"axis{n}"</c> when
/// <c>cnc_rdaxisname</c> isn't available) to the decimal-place count the CNC
/// reports for that axis's increment system. Cached at bootstrap by the driver +
/// applied to position values before publishing — raw integer / 10^decimalPlaces.
/// Returns <c>null</c> when the wire client cannot supply the snapshot (older
/// transport variant) — the driver leaves the cache untouched and falls back to
/// publishing raw values.
/// </summary>
Task<IReadOnlyDictionary<string, int>?> GetFigureScalingAsync(CancellationToken cancellationToken)
=> Task.FromResult<IReadOnlyDictionary<string, int>?>(null);
/// <summary>
/// Read a CNC diagnostic value via <c>cnc_rddiag</c>. <paramref name="diagNumber"/> is
/// the diagnostic number (validated against <see cref="FocasCapabilityMatrix.DiagnosticRange"/>
/// by <see cref="FocasDriver.InitializeAsync"/>). <paramref name="axisOrZero"/>
/// is 0 for whole-CNC diagnostics or the 1-based axis index for per-axis diagnostics.
/// The shape of the returned value depends on the diagnostic — Int / Float / Bit are
/// all possible. Returns <c>null</c> on default (transport variants that haven't yet
/// implemented diagnostics) so the driver falls back to BadNotSupported on those nodes
/// until the wire client is extended (issue #263).
/// </summary>
Task<(object? value, uint status)> ReadDiagnosticAsync(
int diagNumber, int axisOrZero, FocasDataType type, CancellationToken cancellationToken)
=> Task.FromResult<(object?, uint)>((null, FocasStatusMapper.BadNotSupported));
/// <summary>
/// Discover the number of CNC paths (channels) the controller exposes via
/// <c>cnc_rdpathnum</c>. Multi-path CNCs (lathe + sub-spindle, dual-turret,
/// etc.) report 2..N; single-path CNCs return 1. The driver caches the result
/// once per device after connect + uses it to validate per-tag <c>PathId</c>
/// values (issue #264). Default returns 1 so transports that haven't extended
/// their wire surface keep behaving as single-path.
/// </summary>
Task<int> GetPathCountAsync(CancellationToken cancellationToken)
=> Task.FromResult(1);
/// <summary>
/// Switch the active CNC path (channel) for subsequent reads via
/// <c>cnc_setpath</c>. Called by the driver before every read whose
/// <c>FocasAddress.PathId</c> differs from the path most recently set on the
/// session — single-path devices (PathId=1 only) skip the wire call entirely.
/// Default is a no-op so transports that haven't extended their wire surface
/// simply read whatever path the CNC has selected (issue #264).
/// </summary>
Task SetPathAsync(int pathId, CancellationToken cancellationToken)
=> Task.CompletedTask;
/// <summary>
/// Read up to <paramref name="depth"/> most-recent entries from the CNC's alarm-history
/// ring buffer via <c>cnc_rdalmhistry</c>. Used by <see cref="FocasAlarmProjection"/>
/// when <see cref="FocasAlarmProjectionOptions.Mode"/> is
/// <see cref="FocasAlarmProjectionMode.ActivePlusHistory"/> (issue #267, plan PR F3-a).
/// Default returns an empty list so transport variants that have not yet implemented
/// the call keep working — the projection's history poll becomes a no-op rather than
/// faulting. Wire decode of the FWLIB <c>ODBALMHIS</c> struct lives in
/// <see cref="Wire.FocasAlarmHistoryDecoder"/>.
/// </summary>
Task<IReadOnlyList<FocasAlarmHistoryEntry>> ReadAlarmHistoryAsync(
int depth, CancellationToken cancellationToken)
=> Task.FromResult<IReadOnlyList<FocasAlarmHistoryEntry>>(Array.Empty<FocasAlarmHistoryEntry>());
/// <summary>
/// Read a contiguous range of PMC bytes in a single wire call (FOCAS
/// <c>pmc_rdpmcrng</c> with byte data type) for the given <paramref name="letter"/>
/// (<c>R</c>, <c>D</c>, <c>X</c>, etc.) starting at <paramref name="startByte"/> and
/// spanning <paramref name="byteCount"/> bytes. Returned tuple has the byte buffer
/// (length <paramref name="byteCount"/> on success) + the OPC UA status mapped through
/// <see cref="FocasStatusMapper"/>. Used by <see cref="FocasDriver"/> to coalesce
/// same-letter/same-path PMC reads in a batch into one round trip per range
/// (issue #266 — see <see cref="Wire.FocasPmcCoalescer"/>).
/// <para>
/// Default falls back to per-byte <see cref="ReadAsync(FocasAddress, FocasDataType, CancellationToken)"/>
/// calls so transport variants that haven't extended their wire surface still work
/// correctly — they just won't see the round-trip reduction. The fallback short-circuits
/// on the first non-Good status so a partial buffer isn't returned with a Good code.
/// </para>
/// </summary>
async Task<(byte[]? buffer, uint status)> ReadPmcRangeAsync(
string letter, int pathId, int startByte, int byteCount, CancellationToken cancellationToken)
{
if (byteCount <= 0) return (Array.Empty<byte>(), FocasStatusMapper.Good);
var buf = new byte[byteCount];
for (var i = 0; i < byteCount; i++)
{
cancellationToken.ThrowIfCancellationRequested();
var addr = new FocasAddress(FocasAreaKind.Pmc, letter, startByte + i, BitIndex: null, PathId: pathId);
var (value, status) = await ReadAsync(addr, FocasDataType.Byte, cancellationToken).ConfigureAwait(false);
if (status != FocasStatusMapper.Good) return (null, status);
buf[i] = value switch
{
sbyte s => unchecked((byte)s),
byte b => b,
int n => unchecked((byte)n),
short s => unchecked((byte)s),
_ => 0,
};
}
return (buf, FocasStatusMapper.Good);
}
}
/// <summary>
/// Snapshot of the 9 fields returned by Fanuc's <c>cnc_rdcncstat</c> (ODBST). All fields
/// are <c>short</c> per the FWLIB header — small enums whose meaning is documented in the
/// Fanuc FOCAS reference (e.g. <c>emergency</c>: 0=released, 1=stop, 2=reset). Surfaced as
/// <c>Int16</c> in the OPC UA address space rather than mapped enums so operators see
/// exactly what the CNC reported.
/// </summary>
public sealed record FocasStatusInfo(
short Dummy,
short Tmmode,
short Aut,
short Run,
short Motion,
short Mstb,
short EmergencyStop,
short Alarm,
short Edit);
/// <summary>
/// Snapshot of per-CNC production counters refreshed on the probe tick (issue #258).
/// Sourced from <c>cnc_rdparam(6711/6712/6713)</c> for the parts counts + the cycle-time
/// timer counter (FWLIB <c>cnc_rdtimer</c> when available). All values surfaced as
/// <c>Int32</c> in the OPC UA address space.
/// </summary>
public sealed record FocasProductionInfo(
int PartsProduced,
int PartsRequired,
int PartsTotal,
int CycleTimeSeconds);
/// <summary>
/// Snapshot of the active modal M/S/T/B codes (issue #259). G-group decoding is a
/// deferred follow-up — the FWLIB <c>ODBMDL</c> union differs per series + group, and
/// the issue body permits the first cut to surface only the universally-present
/// M/S/T/B fields. <c>short</c> matches the FWLIB <c>aux_data</c> width.
/// </summary>
public sealed record FocasModalInfo(
short MCode,
short SCode,
short TCode,
short BCode);
/// <summary>
/// MTB-specific FOCAS parameter numbers for the four operator overrides (issue #259).
/// Defaults match Fanuc 30i — Feed=6010, Rapid=6011, Spindle=6014, Jog=6015. A
/// <c>null</c> entry suppresses that field's read on the wire and removes the matching
/// node from the address space; this lets a deployment hide overrides their MTB doesn't
/// wire up rather than always serving Bad.
/// </summary>
public sealed record FocasOverrideParameters(
ushort? FeedParam,
ushort? RapidParam,
ushort? SpindleParam,
ushort? JogParam)
{
/// <summary>Stock 30i defaults — Feed=6010, Rapid=6011, Spindle=6014, Jog=6015.</summary>
public static FocasOverrideParameters Default { get; } = new(6010, 6011, 6014, 6015);
}
/// <summary>
/// Snapshot of the four operator overrides (issue #259). Each value is a percentage
/// surfaced as <c>Int16</c>; a value of <c>null</c> means the corresponding parameter
/// was not configured (suppressed at <see cref="FocasOverrideParameters"/>). All four
/// fields nullable so the driver can omit nodes whose MTB parameter is unset.
/// </summary>
public sealed record FocasOverrideInfo(
short? Feed,
short? Rapid,
short? Spindle,
short? Jog);
/// <summary>
/// Snapshot of the currently selected tool number (issue #260). Sourced from
/// <c>cnc_rdtnum</c>. The active offset index is deferred — most modern CNCs
/// interleave tool number and offset H/D codes through different FOCAS calls
/// (<c>cnc_rdtofs</c> against a specific slot) and the issue body permits
/// surfacing tool number alone in the first cut. Surfaced as <c>Int16</c> in
/// the OPC UA address space.
/// </summary>
public sealed record FocasToolingInfo(short CurrentTool);
/// <summary>
/// One work-coordinate offset slot (G54..G59). Three axis columns are surfaced
/// (X / Y / Z) — the issue body permits a fixed 3-axis view because lathes and
/// mills typically don't expose extended rotational offsets via the standard
/// <c>cnc_rdzofs</c> call. Extended <c>G54.1 Pn</c> offsets via <c>cnc_rdzofsr</c>
/// are deferred to a follow-up PR. Values surfaced as <c>Float64</c> in microns
/// converted to user units (the FWLIB <c>data</c> field is an integer + decimal-
/// point count, decoded the same way <c>cnc_rdmacro</c> values are).
/// </summary>
public sealed record FocasWorkOffset(string Name, double X, double Y, double Z);
/// <summary>
/// Snapshot of the six standard work-coordinate offsets (G54..G59). Refreshed on
/// the probe tick + served from the per-device cache by reads of the
/// <c>Offsets/{name}/{X|Y|Z}</c> fixed-tree nodes (issue #260).
/// </summary>
public sealed record FocasWorkOffsetsInfo(IReadOnlyList<FocasWorkOffset> Offsets);
/// <summary>
/// One FANUC operator message — the <see cref="Number"/> + <see cref="Class"/>
/// + <see cref="Text"/> tuple returned by <c>cnc_rdopmsg3</c> for a single
/// active message slot. <see cref="Class"/> is one of <c>"OPMSG"</c> /
/// <c>"MACRO"</c> / <c>"EXTERN"</c> / <c>"REJ-EXT"</c> per the FOCAS reference
/// for the four message types. <see cref="Text"/> is trimmed of trailing
/// nulls + spaces so round-trips through the OPC UA address space stay stable
/// (issue #261).
/// </summary>
public sealed record FocasOperatorMessage(short Number, string Class, string Text);
/// <summary>
/// Snapshot of all active FANUC operator messages across the four message
/// classes (issue #261). Surfaced under the FOCAS driver's
/// <c>Messages/External/Latest</c> fixed-tree node — the latest non-empty
/// message in the list is what gets published. Empty list means the CNC
/// reported no active messages; the node publishes an empty string in that
/// case.
/// </summary>
public sealed record FocasOperatorMessagesInfo(IReadOnlyList<FocasOperatorMessage> Messages);
/// <summary>
/// Snapshot of the currently-executing program block text via
/// <c>cnc_rdactpt</c> (issue #261). <see cref="Text"/> is trimmed of trailing
/// nulls + spaces so the same block round-trips with stable text. Surfaced
/// as a String node at <c>Program/CurrentBlock</c>.
/// </summary>
public sealed record FocasCurrentBlockInfo(string Text);
/// <summary>
/// One entry returned by <c>cnc_rdalmhistry</c> — a single historical alarm
/// occurrence the CNC retained in its ring buffer (issue #267, plan PR F3-a).
/// The projection emits these as historic <see cref="Core.Abstractions.AlarmEventArgs"/>
/// with <c>SourceTimestampUtc</c> set from <see cref="OccurrenceTime"/> so OPC UA clients
/// see the real CNC timestamp rather than the moment the projection polled.
/// </summary>
/// <remarks>
/// <para>The dedup key for the projection is
/// <c>(<see cref="OccurrenceTime"/>, <see cref="AlarmNumber"/>, <see cref="AlarmType"/>)</c>.
/// Same triple across two polls only emits once — see
/// <see cref="FocasAlarmProjection"/>.</para>
///
/// <para>FANUC ring buffers are typically capped at ~100 entries; the host parameter that
/// governs the cap varies by series + MTB so the driver clamps user-requested depth to a
/// conservative <c>250</c> ceiling (see <see cref="FocasAlarmProjectionOptions.HistoryDepth"/>).</para>
/// </remarks>
public sealed record FocasAlarmHistoryEntry(
DateTimeOffset OccurrenceTime,
int AxisNo,
int AlarmType,
int AlarmNumber,
string Message);
/// <summary>Factory for <see cref="IFocasClient"/>s. One client per configured device.</summary>
public interface IFocasClientFactory
{
IFocasClient Create();
}
/// <summary>
/// Default factory that throws at construction time — the deployment must register a real
/// factory. Keeps the driver assembly licence-clean while still allowing the skeleton to
/// compile + the abstraction tests to run.
/// </summary>
public sealed class UnimplementedFocasClientFactory : IFocasClientFactory
{
public IFocasClient Create() => throw new NotSupportedException(
"FOCAS driver has no wire client configured. Register a real IFocasClientFactory at " +
"server startup wrapping the licensed Fwlib32.dll — see docs/v2/focas-deployment.md. " +
"Fanuc licensing forbids shipping Fwlib32.dll in the OtOpcUa package.");
}