269 lines
14 KiB
C#
269 lines
14 KiB
C#
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
|
|
|
|
/// <summary>
|
|
/// FOCAS driver configuration. One instance supports N CNC devices. Per plan decision #144
|
|
/// each device gets its own <c>(DriverInstanceId, HostAddress)</c> bulkhead key at the
|
|
/// Phase 6.1 resilience layer.
|
|
/// </summary>
|
|
public sealed class FocasDriverOptions
|
|
{
|
|
public IReadOnlyList<FocasDeviceOptions> Devices { get; init; } = [];
|
|
public IReadOnlyList<FocasTagDefinition> Tags { get; init; } = [];
|
|
public FocasProbeOptions Probe { get; init; } = new();
|
|
public TimeSpan Timeout { get; init; } = TimeSpan.FromSeconds(2);
|
|
|
|
/// <summary>
|
|
/// Fixed-tree behaviour knobs (issue #262, plan PR F1-f). Carries the
|
|
/// <c>ApplyFigureScaling</c> toggle that gates the <c>cnc_getfigure</c>
|
|
/// decimal-place division applied to position values before publishing.
|
|
/// </summary>
|
|
public FocasFixedTreeOptions FixedTree { get; init; } = new();
|
|
|
|
/// <summary>
|
|
/// Alarm projection knobs (issue #267, plan PR F3-a). Default mode is
|
|
/// <see cref="FocasAlarmProjectionMode.ActiveOnly"/> — the projection only surfaces
|
|
/// currently-active alarms. Operators who want the on-CNC ring-buffer history
|
|
/// replayed as historic OPC UA events (so dashboards see the real CNC timestamp,
|
|
/// not the moment the projection polled) flip this to
|
|
/// <see cref="FocasAlarmProjectionMode.ActivePlusHistory"/>.
|
|
/// </summary>
|
|
public FocasAlarmProjectionOptions AlarmProjection { get; init; } = new();
|
|
|
|
/// <summary>
|
|
/// Driver-level write opt-in (issue #268, plan PR F4-a). Defaults to
|
|
/// <c>Enabled = false</c> — the driver short-circuits every <c>IWritable.WriteAsync</c>
|
|
/// call to <see cref="FocasStatusMapper.BadNotWritable"/> until the deployment explicitly
|
|
/// flips this on. Combined with the per-tag <see cref="FocasTagDefinition.Writable"/>
|
|
/// gate (also default-off), every CNC write requires two opt-ins.
|
|
/// </summary>
|
|
public FocasWritesOptions Writes { get; init; } = new();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Driver-level write controls (issue #268, plan PR F4-a). Per the F4-a decision record
|
|
/// writes ship behind a flag with a safe default: an operator who pulls the FOCAS driver
|
|
/// into production without touching <c>Writes.Enabled</c> gets read-only behaviour, and
|
|
/// even with the flag flipped on each individual tag must still set
|
|
/// <see cref="FocasTagDefinition.Writable"/> = <c>true</c>.
|
|
/// </summary>
|
|
public sealed record FocasWritesOptions
|
|
{
|
|
/// <summary>
|
|
/// Driver-level master switch. Default <c>false</c> — every write returns
|
|
/// <see cref="FocasStatusMapper.BadNotWritable"/> with the status text
|
|
/// <c>"writes disabled at driver level"</c>.
|
|
/// </summary>
|
|
public bool Enabled { get; init; } = false;
|
|
|
|
/// <summary>
|
|
/// Issue #269, plan PR F4-b — granular kill-switch for <c>cnc_wrparam</c>
|
|
/// parameter writes (defense in depth on top of <see cref="Enabled"/> and the
|
|
/// per-tag <see cref="FocasTagDefinition.Writable"/>). Default <c>false</c>: an
|
|
/// operator who flips <see cref="Enabled"/> on without explicitly opting into
|
|
/// parameter writes still gets <see cref="FocasStatusMapper.BadNotWritable"/>
|
|
/// for every <c>PARAM:</c> tag. A misdirected parameter write can put the CNC
|
|
/// in a bad state, so the third opt-in keeps the blast radius bounded.
|
|
/// <para>Server-layer ACL: <c>PARAM:</c> tags additionally surface a
|
|
/// <see cref="Core.Abstractions.SecurityClassification.Configure"/> classification
|
|
/// so the OPC UA gate requires <c>WriteConfigure</c> group membership; this
|
|
/// flag is the driver-level kill switch the operator team can flip without a
|
|
/// redeploy.</para>
|
|
/// </summary>
|
|
public bool AllowParameter { get; init; } = false;
|
|
|
|
/// <summary>
|
|
/// Issue #269, plan PR F4-b — granular kill-switch for <c>cnc_wrmacro</c> macro
|
|
/// variable writes (defense in depth on top of <see cref="Enabled"/> and the
|
|
/// per-tag <see cref="FocasTagDefinition.Writable"/>). Default <c>false</c>:
|
|
/// macro writes are gated separately from parameter writes because they're a
|
|
/// normal HMI-driven recipe / setpoint surface where parameter writes are
|
|
/// mostly emergency commissioning territory.
|
|
/// <para>Server-layer ACL: <c>MACRO:</c> tags surface
|
|
/// <see cref="Core.Abstractions.SecurityClassification.Operate"/> so the OPC UA
|
|
/// gate requires <c>WriteOperate</c> group membership.</para>
|
|
/// </summary>
|
|
public bool AllowMacro { get; init; } = false;
|
|
|
|
/// <summary>
|
|
/// Issue #270, plan PR F4-c — granular kill-switch for <c>pmc_wrpmcrng</c> PMC
|
|
/// range writes (and the bit-level read-modify-write that wraps it). Default
|
|
/// <c>false</c>: PMC is ladder working memory — a mistargeted bit can move
|
|
/// motion, latch a feedhold, or flip a safety interlock. Even with
|
|
/// <see cref="Enabled"/> on and a tag's <see cref="FocasTagDefinition.Writable"/>
|
|
/// flag flipped on, PMC writes stay locked until this third opt-in fires.
|
|
/// <para>Server-layer ACL: PMC tags surface
|
|
/// <see cref="Core.Abstractions.SecurityClassification.Operate"/> so the OPC UA
|
|
/// gate requires <c>WriteOperate</c> group membership; this flag is the driver-
|
|
/// level kill switch the operator team can flip without a redeploy.</para>
|
|
/// </summary>
|
|
public bool AllowPmc { get; init; } = false;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Mode for the FOCAS alarm projection (issue #267, plan PR F3-a). Default
|
|
/// <see cref="ActiveOnly"/> matches today's behaviour — only currently-active
|
|
/// alarms surface as OPC UA events. <see cref="ActivePlusHistory"/> additionally
|
|
/// polls <c>cnc_rdalmhistry</c> on connect + on a configurable cadence and emits the
|
|
/// ring-buffer entries as historic events, deduped by <c>(OccurrenceTime, AlarmNumber,
|
|
/// AlarmType)</c> so a polled entry never re-fires.
|
|
/// </summary>
|
|
public enum FocasAlarmProjectionMode
|
|
{
|
|
/// <summary>Surface only currently-active CNC alarms. No history poll. Default.</summary>
|
|
ActiveOnly = 0,
|
|
|
|
/// <summary>
|
|
/// Surface active alarms plus the on-CNC ring-buffer history. The projection
|
|
/// polls <c>cnc_rdalmhistry</c> on connect and on
|
|
/// <see cref="FocasAlarmProjectionOptions.HistoryPollInterval"/> ticks afterward.
|
|
/// Each new entry (keyed by <c>(OccurrenceTime, AlarmNumber, AlarmType)</c>)
|
|
/// fires an <see cref="Core.Abstractions.IAlarmSource.OnAlarmEvent"/> with
|
|
/// <c>SourceTimestampUtc</c> set from the CNC's reported timestamp, not Now.
|
|
/// </summary>
|
|
ActivePlusHistory = 1,
|
|
}
|
|
|
|
/// <summary>
|
|
/// FOCAS alarm-projection knobs (issue #267, plan PR F3-a). Carries the mode switch +
|
|
/// the cadence / depth tuning for the <c>cnc_rdalmhistry</c> poll loop. Defaults match
|
|
/// "operator dashboard with five-minute refresh" — the single most common deployment
|
|
/// shape per the F3-a deployment doc.
|
|
/// </summary>
|
|
public sealed record FocasAlarmProjectionOptions
|
|
{
|
|
/// <summary>Default poll interval — 5 minutes. Matches dashboard-class cadences.</summary>
|
|
public static readonly TimeSpan DefaultHistoryPollInterval = TimeSpan.FromMinutes(5);
|
|
|
|
/// <summary>
|
|
/// Default ring-buffer depth requested per poll — <c>100</c>. Most FANUC controllers
|
|
/// keep ~100 entries by default; pulling the full depth on every poll keeps the
|
|
/// dedup set authoritative across reconnects without burning extra wire bandwidth on
|
|
/// entries the dedup key would discard anyway.
|
|
/// </summary>
|
|
public const int DefaultHistoryDepth = 100;
|
|
|
|
/// <summary>
|
|
/// Hard ceiling on <see cref="HistoryDepth"/>. The projection clamps user-requested
|
|
/// depths above this value down — typical CNC ring buffers cap well below this and
|
|
/// letting an operator type <c>10000</c> by accident shouldn't take down the wire
|
|
/// session with a giant <c>cnc_rdalmhistry</c> request.
|
|
/// </summary>
|
|
public const int MaxHistoryDepth = 250;
|
|
|
|
/// <summary>Active-only (default) vs Active-plus-history. See <see cref="FocasAlarmProjectionMode"/>.</summary>
|
|
public FocasAlarmProjectionMode Mode { get; init; } = FocasAlarmProjectionMode.ActiveOnly;
|
|
|
|
/// <summary>
|
|
/// Cadence at which the projection re-polls <c>cnc_rdalmhistry</c> when
|
|
/// <see cref="Mode"/> is <see cref="FocasAlarmProjectionMode.ActivePlusHistory"/>.
|
|
/// Default <see cref="DefaultHistoryPollInterval"/> = 5 minutes. Only applies after
|
|
/// the on-connect poll fires.
|
|
/// </summary>
|
|
public TimeSpan HistoryPollInterval { get; init; } = DefaultHistoryPollInterval;
|
|
|
|
/// <summary>
|
|
/// Number of most-recent ring-buffer entries to request per poll. Clamped to
|
|
/// <c>[1..<see cref="MaxHistoryDepth"/>]</c> at projection startup so misconfigured
|
|
/// values can't hammer the CNC. Default <see cref="DefaultHistoryDepth"/> = 100.
|
|
/// </summary>
|
|
public int HistoryDepth { get; init; } = DefaultHistoryDepth;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Per-driver fixed-tree options. New installs default <see cref="ApplyFigureScaling"/>
|
|
/// to <c>true</c> so position values surface in user units (mm / inch). Existing
|
|
/// deployments that already published raw scaled integers can flip this to <c>false</c>
|
|
/// for migration parity — the operator-facing concern is that switching the flag
|
|
/// mid-deployment changes the values clients see, so the migration path is
|
|
/// documentation-only (issue #262).
|
|
/// </summary>
|
|
public sealed record FocasFixedTreeOptions
|
|
{
|
|
/// <summary>
|
|
/// When <c>true</c> (default), position values from <c>cnc_absolute</c> /
|
|
/// <c>cnc_machine</c> / <c>cnc_relative</c> / <c>cnc_distance</c> /
|
|
/// <c>cnc_actf</c> are divided by <c>10^decimalPlaces</c> per axis using the
|
|
/// <c>cnc_getfigure</c> snapshot cached at probe time. When <c>false</c>, the
|
|
/// raw integer values are published unchanged — used for migrations from
|
|
/// older drivers that didn't apply the scaling.
|
|
/// </summary>
|
|
public bool ApplyFigureScaling { get; init; } = true;
|
|
}
|
|
|
|
/// <summary>
|
|
/// One CNC the driver talks to. <paramref name="Series"/> enables per-series
|
|
/// address validation at <see cref="FocasDriver.InitializeAsync"/>; leave as
|
|
/// <see cref="FocasCncSeries.Unknown"/> to skip validation (legacy behaviour).
|
|
/// <paramref name="OverrideParameters"/> declares the four MTB-specific override
|
|
/// <c>cnc_rdparam</c> numbers surfaced under <c>Override/</c>; pass <c>null</c> to
|
|
/// suppress the entire <c>Override/</c> subfolder for that device (issue #259).
|
|
/// <paramref name="Password"/> (issue #271, plan PR F4-d) is the CNC connection-level
|
|
/// password emitted via <c>cnc_wrunlockparam</c> on connect when the controller
|
|
/// gates parameter writes / certain reads behind a password switch (16i + some
|
|
/// 30i firmwares with parameter-protect on).
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para><b>No-log invariant:</b> <see cref="Password"/> is a secret. The driver MUST NOT
|
|
/// log it. <c>FocasDeviceOptions.ToString()</c> would include the field by default
|
|
/// because it's a positional record member, so the record's auto-generated
|
|
/// <c>ToString</c> is overridden via <see cref="PrintMembers"/> below to redact
|
|
/// the password. Any new logging surface that touches <see cref="FocasDeviceOptions"/>
|
|
/// must continue to redact. See <c>docs/v2/focas-deployment.md</c> § "FOCAS password
|
|
/// handling" for the no-log invariant and rotation runbook.</para>
|
|
/// </remarks>
|
|
public sealed record FocasDeviceOptions(
|
|
string HostAddress,
|
|
string? DeviceName = null,
|
|
FocasCncSeries Series = FocasCncSeries.Unknown,
|
|
FocasOverrideParameters? OverrideParameters = null,
|
|
string? Password = null)
|
|
{
|
|
/// <summary>
|
|
/// Issue #271 (plan PR F4-d) — record auto-generated <c>ToString</c> would print
|
|
/// <see cref="Password"/> verbatim. Override the printer so the secret is replaced
|
|
/// with <c>"***"</c> when the field is non-null. The no-log invariant relies on
|
|
/// this — every Serilog destructure that flows a <see cref="FocasDeviceOptions"/>
|
|
/// value through <c>{Device}</c> gets redaction for free.
|
|
/// </summary>
|
|
private bool PrintMembers(System.Text.StringBuilder builder)
|
|
{
|
|
builder.Append("HostAddress = ").Append(HostAddress);
|
|
builder.Append(", DeviceName = ").Append(DeviceName);
|
|
builder.Append(", Series = ").Append(Series);
|
|
builder.Append(", OverrideParameters = ").Append(OverrideParameters);
|
|
builder.Append(", Password = ").Append(Password is null ? "<null>" : "***");
|
|
return true;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// One FOCAS-backed OPC UA variable. <paramref name="Address"/> is the canonical FOCAS
|
|
/// address string that parses via <see cref="FocasAddress.TryParse"/> —
|
|
/// <c>X0.0</c> / <c>R100</c> / <c>PARAM:1815/0</c> / <c>MACRO:500</c> /
|
|
/// <c>DIAG:1031</c> / <c>DIAG:280/2</c>.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <paramref name="Writable"/> defaults to <c>false</c> per issue #268 / plan PR F4-a — a
|
|
/// newly-onboarded tag is read-only until the deployment explicitly opts it in, matching
|
|
/// the driver-level <see cref="FocasWritesOptions.Enabled"/> safer-by-default posture.
|
|
/// <paramref name="WriteIdempotent"/> is plumbed through the
|
|
/// <see cref="Core.Resilience.CapabilityInvoker.ExecuteWriteAsync"/> retry path at the
|
|
/// server layer (see <see cref="Core.Abstractions.WriteIdempotentAttribute"/>); a
|
|
/// <c>true</c> value lets the Polly pipeline retry on transient failures while
|
|
/// <c>false</c> (the default) disables retry per decisions #44/#45.
|
|
/// </remarks>
|
|
public sealed record FocasTagDefinition(
|
|
string Name,
|
|
string DeviceHostAddress,
|
|
string Address,
|
|
FocasDataType DataType,
|
|
bool Writable = false,
|
|
bool WriteIdempotent = false);
|
|
|
|
public sealed class FocasProbeOptions
|
|
{
|
|
public bool Enabled { get; init; } = true;
|
|
public TimeSpan Interval { get; init; } = TimeSpan.FromSeconds(5);
|
|
public TimeSpan Timeout { get; init; } = TimeSpan.FromSeconds(2);
|
|
}
|