Auto: focas-f4d — password / unlock parameter

Closes #271
This commit is contained in:
Joseph Doherty
2026-04-26 05:45:13 -04:00
parent d676b4056d
commit 86f3fc2733
16 changed files with 1016 additions and 40 deletions

View File

@@ -26,6 +26,20 @@ public abstract class FocasCommandBase : DriverCommandBase
[CommandOption("timeout-ms", Description = "Per-operation timeout in ms (default 2000).")]
public int TimeoutMs { get; init; } = 2000;
/// <summary>
/// Plan PR F4-d (issue #271) — optional CNC connection-level password emitted
/// via <c>cnc_wrunlockparam</c> on connect. Required only by controllers that
/// gate <c>cnc_wrparam</c> + selected reads behind a password switch.
/// PASSWORD INVARIANT: never logged. The CLI's Serilog config does not
/// include this option in any console / file destructure; the redaction is
/// enforced at the <see cref="FocasDeviceOptions"/> layer (record's
/// overridden <c>ToString</c>).
/// </summary>
[CommandOption("cnc-password", Description =
"Optional CNC connection password emitted via cnc_wrunlockparam on connect. " +
"Required by controllers that gate parameter writes behind a password switch.")]
public string? CncPassword { get; init; }
/// <inheritdoc />
public override TimeSpan Timeout
{
@@ -51,7 +65,12 @@ public abstract class FocasCommandBase : DriverCommandBase
Devices = [new FocasDeviceOptions(
HostAddress: HostAddress,
DeviceName: $"cli-{CncHost}:{CncPort}",
Series: Series)],
Series: Series,
OverrideParameters: null,
// PR F4-d (issue #271) — thread the CLI's --cnc-password through to
// the driver. Null when the operator didn't supply the flag — the
// driver short-circuits the unlock call in that case.
Password: CncPassword)],
Tags = tags,
Timeout = Timeout,
Probe = new FocasProbeOptions { Enabled = false },

View File

@@ -451,10 +451,28 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
continue;
}
var (value, status) = parsed.Kind == FocasAreaKind.Diagnostic
? await client.ReadDiagnosticAsync(
parsed.Number, parsed.BitIndex ?? 0, def.DataType, cancellationToken).ConfigureAwait(false)
: await client.ReadAsync(parsed, def.DataType, cancellationToken).ConfigureAwait(false);
// PR F4-d (issue #271) — single-shot unlock + retry on
// BadUserAccessDenied. Some controllers gate selected reads (not just
// writes) behind cnc_wrunlockparam; mirrors the WriteAsync retry
// shape so a session that lost its unlock state mid-flight (e.g.
// because the controller cycled it) self-heals on the first read.
async Task<(object? value, uint status)> DispatchReadAsync()
{
return parsed.Kind == FocasAreaKind.Diagnostic
? await client.ReadDiagnosticAsync(
parsed.Number, parsed.BitIndex ?? 0, def.DataType, cancellationToken).ConfigureAwait(false)
: await client.ReadAsync(parsed, def.DataType, cancellationToken).ConfigureAwait(false);
}
var (value, status) = await DispatchReadAsync().ConfigureAwait(false);
if (status == FocasStatusMapper.BadUserAccessDenied
&& !string.IsNullOrEmpty(device.Options.Password))
{
if (await TryReunlockAsync(device, cancellationToken).ConfigureAwait(false))
{
(value, status) = await DispatchReadAsync().ConfigureAwait(false);
}
}
results[i] = new DataValueSnapshot(value, status, now, now);
if (status == FocasStatusMapper.Good)
@@ -573,39 +591,58 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
// issue #270). The fallback generic WriteAsync path is preserved for
// kinds that don't have a typed entry point yet, plus the unit-test
// FakeFocasClient that overrides WriteAsync directly.
uint status;
if (parsed.Kind == FocasAreaKind.Parameter)
//
// PR F4-d (issue #271) — wrap the dispatch in a single-shot retry on
// BadUserAccessDenied (EW_PASSWD mapping from F4-b). When the device
// has a password configured AND the wire call fires EW_PASSWD, the
// retry path re-issues UnlockAsync and re-dispatches once. The
// `attempted` flag bounds the loop so a second EW_PASSWD propagates
// unchanged — no infinite retry on a mismatched password.
async Task<uint> DispatchWriteAsync()
{
status = await client.WriteParameterAsync(
if (parsed.Kind == FocasAreaKind.Parameter)
{
return await client.WriteParameterAsync(
parsed, def.DataType, w.Value, cancellationToken).ConfigureAwait(false);
}
if (parsed.Kind == FocasAreaKind.Macro)
{
return await client.WriteMacroAsync(
parsed, w.Value, cancellationToken).ConfigureAwait(false);
}
if (parsed.Kind == FocasAreaKind.Pmc
&& def.DataType == FocasDataType.Bit
&& parsed.BitIndex is int bit
&& parsed.PmcLetter is string letter)
{
return await client.WritePmcBitAsync(
letter, parsed.PathId, parsed.Number, bit,
Convert.ToBoolean(w.Value), cancellationToken).ConfigureAwait(false);
}
if (parsed.Kind == FocasAreaKind.Pmc
&& def.DataType == FocasDataType.Byte
&& parsed.PmcLetter is string byteLetter)
{
var b = unchecked((byte)Convert.ToSByte(w.Value));
return await client.WritePmcRangeAsync(
byteLetter, parsed.PathId, parsed.Number, new[] { b },
cancellationToken).ConfigureAwait(false);
}
return await client.WriteAsync(
parsed, def.DataType, w.Value, cancellationToken).ConfigureAwait(false);
}
else if (parsed.Kind == FocasAreaKind.Macro)
var status = await DispatchWriteAsync().ConfigureAwait(false);
if (status == FocasStatusMapper.BadUserAccessDenied
&& !string.IsNullOrEmpty(device.Options.Password))
{
status = await client.WriteMacroAsync(
parsed, w.Value, cancellationToken).ConfigureAwait(false);
}
else if (parsed.Kind == FocasAreaKind.Pmc
&& def.DataType == FocasDataType.Bit
&& parsed.BitIndex is int bit
&& parsed.PmcLetter is string letter)
{
status = await client.WritePmcBitAsync(
letter, parsed.PathId, parsed.Number, bit,
Convert.ToBoolean(w.Value), cancellationToken).ConfigureAwait(false);
}
else if (parsed.Kind == FocasAreaKind.Pmc
&& def.DataType == FocasDataType.Byte
&& parsed.PmcLetter is string byteLetter)
{
var b = unchecked((byte)Convert.ToSByte(w.Value));
status = await client.WritePmcRangeAsync(
byteLetter, parsed.PathId, parsed.Number, new[] { b },
cancellationToken).ConfigureAwait(false);
}
else
{
status = await client.WriteAsync(
parsed, def.DataType, w.Value, cancellationToken).ConfigureAwait(false);
// Single-shot retry: re-issue cnc_wrunlockparam, then redispatch
// exactly once. A second EW_PASSWD bubbles up as-is so a wrong
// password doesn't loop forever on the wire.
if (await TryReunlockAsync(device, cancellationToken).ConfigureAwait(false))
{
status = await DispatchWriteAsync().ConfigureAwait(false);
}
}
results[i] = new WriteResult(status);
}
@@ -1458,6 +1495,31 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
device.Client = null;
throw;
}
// PR F4-d (issue #271) — emit cnc_wrunlockparam on connect when the device
// configured a password. Resets on every reconnect because FWLIB unlock state
// is bound to the handle's lifetime. Failure here is non-fatal (the
// controller may surface password requirements only on certain reads/writes,
// not on every session) — the per-call retry path catches EW_PASSWD on the
// actual gated wire call. We still record the failure on _health for
// operator visibility. PASSWORD INVARIANT: never log device.Options.Password.
if (!string.IsNullOrEmpty(device.Options.Password))
{
try
{
await device.Client.UnlockAsync(device.Options.Password, ct).ConfigureAwait(false);
device.UnlockApplied = true;
// Status text deliberately uses the host address only — no password.
_health = new DriverHealth(_health.State, _health.LastSuccessfulRead,
$"FOCAS unlock applied for {device.Options.HostAddress}");
}
catch (OperationCanceledException) when (ct.IsCancellationRequested) { throw; }
catch (Exception ex)
{
device.UnlockApplied = false;
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead,
$"FOCAS unlock attempt failed for {device.Options.HostAddress}: {ex.Message}");
}
}
// Multi-path bootstrap (issue #264). cnc_rdpathnum runs once per session — the
// controller's path topology is fixed at boot. A reconnect resets the wire
// session's "last set path" so the next non-default-path read forces a fresh
@@ -1474,6 +1536,32 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
return device.Client;
}
/// <summary>
/// Plan PR F4-d (issue #271) — re-issue <c>cnc_wrunlockparam</c> on the active
/// wire session and return <c>true</c> when the unlock succeeded so the caller
/// can retry the gated read/write once. <c>false</c> means the unlock itself
/// failed (mismatched password, transport refused) and the caller surfaces
/// <c>BadUserAccessDenied</c> as-is. PASSWORD INVARIANT: the password is never
/// logged from this method.
/// </summary>
private async Task<bool> TryReunlockAsync(DeviceState device, CancellationToken ct)
{
if (device.Client is null || !device.Client.IsConnected) return false;
if (string.IsNullOrEmpty(device.Options.Password)) return false;
try
{
await device.Client.UnlockAsync(device.Options.Password, ct).ConfigureAwait(false);
device.UnlockApplied = true;
return true;
}
catch (OperationCanceledException) when (ct.IsCancellationRequested) { throw; }
catch
{
device.UnlockApplied = false;
return false;
}
}
public void Dispose() => DisposeAsync().AsTask().GetAwaiter().GetResult();
public async ValueTask DisposeAsync() => await ShutdownAsync(CancellationToken.None).ConfigureAwait(false);
@@ -1590,6 +1678,16 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
/// </summary>
public int LastSetPath { get; set; }
/// <summary>
/// Plan PR F4-d (issue #271) — set when the driver successfully emitted
/// <c>cnc_wrunlockparam</c> on this wire session. Reset to <c>false</c>
/// on every reconnect because FWLIB unlock state is bound to the handle's
/// lifetime. Surfaced as a flag (not a counter) so a future diagnostics
/// surface can light up "unlocked" / "needs unlock" in the Admin UI
/// without changing the public API.
/// </summary>
public bool UnlockApplied { get; set; }
public void DisposeClient()
{
Client?.Dispose();

View File

@@ -62,7 +62,13 @@ public static class FocasDriverFactoryExtensions
HostAddress: d.HostAddress ?? throw new InvalidOperationException(
$"FOCAS config for '{driverInstanceId}' has a device missing HostAddress"),
DeviceName: d.DeviceName,
Series: ParseSeries(d.Series ?? dto.Series)))]
Series: ParseSeries(d.Series ?? dto.Series),
OverrideParameters: null,
// Plan PR F4-d (issue #271) — optional CNC password for cnc_wrunlockparam.
// The DTO carries it through JSON config round-trip; the driver-layer
// record overrides ToString to redact (no-log invariant). See
// docs/v2/focas-deployment.md § "FOCAS password handling".
Password: d.Password))]
: [],
Tags = dto.Tags is { Count: > 0 }
? [.. dto.Tags.Select(t => new FocasTagDefinition(
@@ -222,6 +228,20 @@ public static class FocasDriverFactoryExtensions
public string? HostAddress { get; init; }
public string? DeviceName { get; init; }
public string? Series { get; init; }
/// <summary>
/// Plan PR F4-d (issue #271) — optional CNC connection-level password emitted
/// via <c>cnc_wrunlockparam</c> on connect. Required only by controllers that
/// gate <c>cnc_wrparam</c> + selected reads behind a password switch. The
/// driver maps <c>EW_PASSWD</c> -> <c>BadUserAccessDenied</c> and re-issues
/// unlock + retries the gated call once on that mapping.
/// <para><b>No-log invariant:</b> never logged through the driver. The host
/// <c>FocasDeviceOptions</c> record overrides <c>ToString</c> to redact this
/// field. Stored in <c>appsettings.json</c> alongside the rest of the device
/// config; treat as a secret per <c>docs/v2/focas-deployment.md</c>
/// § "FOCAS password handling" + cross-link to <c>docs/Security.md</c>.</para>
/// </summary>
public string? Password { get; init; }
}
internal sealed class FocasTagDto

View File

@@ -197,12 +197,44 @@ public sealed record FocasFixedTreeOptions
/// <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);
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

View File

@@ -48,6 +48,47 @@ internal sealed class FwlibFocasClient : IFocasClient
return Task.CompletedTask;
}
/// <summary>
/// Plan PR F4-d (issue #271) — emit <c>cnc_wrunlockparam</c> to lift the
/// CNC's parameter-protect / read-protect gate. The password is ASCII-encoded
/// into a 4-byte buffer (right-padded with <c>0x00</c>; truncated when the
/// supplied string exceeds 4 chars — Fanuc's published password buffer is a
/// fixed 4-byte slot). Mismatch surfaces as <c>EW_PASSWD</c> mapped to
/// <see cref="FocasStatusMapper.BadUserAccessDenied"/>; the F4-d retry loop
/// in <see cref="FocasDriver"/> re-issues unlock + retries the call once on
/// that mapping.
/// <para><b>No-log invariant:</b> the password is NOT logged from this method
/// and never appears in any exception message. The caller logs only "FOCAS
/// unlock applied for {host}" (no password). See <c>FocasDeviceOptions.Password</c>.</para>
/// </summary>
public Task UnlockAsync(string password, CancellationToken cancellationToken)
{
if (!_connected)
throw new InvalidOperationException(
"FOCAS UnlockAsync called before Connect — handle is not yet open.");
cancellationToken.ThrowIfCancellationRequested();
// Fixed 4-byte buffer (FOCAS password slot). Right-pad with 0x00; truncate
// longer inputs. Truncation is a deployment-error not a runtime concern —
// the password the operator put in appsettings.json must already match the
// controller's slot exactly. We don't surface a different error code for
// length mismatch because the controller will reject with EW_PASSWD anyway.
var buf = new byte[4];
var pwd = password ?? string.Empty;
var bytes = System.Text.Encoding.ASCII.GetBytes(pwd);
Array.Copy(bytes, 0, buf, 0, Math.Min(bytes.Length, buf.Length));
var ret = FwlibNative.WrUnlockParam(_handle, buf);
if (ret != 0)
{
// Note: deliberately do NOT include `password` in the exception message —
// exceptions get logged. The error code is enough for diagnosis.
throw new InvalidOperationException(
$"FWLIB cnc_wrunlockparam failed with EW_{ret}.");
}
return Task.CompletedTask;
}
public Task<(object? value, uint status)> ReadAsync(
FocasAddress address, FocasDataType type, CancellationToken cancellationToken)
{

View File

@@ -66,6 +66,25 @@ internal static class FwlibNative
short length,
ref IODBPSD buffer);
/// <summary>
/// <c>cnc_wrunlockparam</c> — emit the connection-level password that lifts
/// the parameter-protect / read-protect gate on certain firmwares (issue #271,
/// plan PR F4-d). The Fanuc FOCAS reference describes the password buffer as
/// a 4-byte binary array (the controller compares byte-for-byte). Returns the
/// usual <c>EW_*</c> family — <c>EW_PASSWD</c> when the supplied bytes don't
/// match the configured password.
/// </summary>
/// <remarks>
/// <para>P/Invoke shape kept narrow: the caller passes a 4-byte buffer. The
/// driver layer ASCII-encodes <c>FocasDeviceOptions.Password</c> into the
/// buffer (right-padded with <c>0x00</c>, truncated to 4 bytes) — that's the
/// shape every public Fanuc password example we've seen uses.</para>
/// </remarks>
[DllImport(Library, EntryPoint = "cnc_wrunlockparam", ExactSpelling = true)]
public static extern short WrUnlockParam(
ushort handle,
[In] byte[] password);
// ---- Macro variables ----
[DllImport(Library, EntryPoint = "cnc_rdmacro", ExactSpelling = true)]

View File

@@ -23,6 +23,25 @@ public interface IFocasClient : IDisposable
/// <summary>True when the FWLIB handle is valid + the socket is up.</summary>
bool IsConnected { get; }
/// <summary>
/// Plan PR F4-d (issue #271) — emit the CNC password via FOCAS
/// <c>cnc_wrunlockparam</c>. Some controllers (notably 16i + some 30i
/// firmwares with parameter-protect on) gate <c>cnc_wrparam</c> and selected
/// reads behind a connection-level password; this call lifts the gate for
/// the lifetime of the FWLIB handle (resets on reconnect, hence the driver
/// re-issues unlock on every <see cref="ConnectAsync"/>).
/// <para>Default impl is a no-op (<see cref="Task.CompletedTask"/>) so transport
/// variants that don't surface unlock (today: IPC and FAKE clients) keep
/// compiling. The FWLIB-backed client overrides this with a real
/// <c>cnc_wrunlockparam</c> call.</para>
/// <para><b>No-log invariant:</b> <paramref name="password"/> is a secret. The
/// wire-client implementation MUST NOT log the password — see
/// <c>FocasDeviceOptions.Password</c> + <c>docs/v2/focas-deployment.md</c>
/// § "FOCAS password handling".</para>
/// </summary>
Task UnlockAsync(string password, CancellationToken cancellationToken)
=> Task.CompletedTask;
/// <summary>
/// Read the value at <paramref name="address"/> in the requested
/// <paramref name="type"/>. Returns a boxed .NET value + the OPC UA status mapped