Merge pull request '[focas] FOCAS — Password / unlock parameter' (#391) from auto/focas/F4-d into auto/driver-gaps

This commit was merged in pull request #391.
This commit is contained in:
2026-04-26 05:50:13 -04:00
16 changed files with 1016 additions and 40 deletions

View File

@@ -51,6 +51,7 @@ Every command accepts:
| `-p` / `--cnc-port` | `8193` | FOCAS TCP port (FOCAS-over-EIP default) |
| `-s` / `--series` | `Unknown` | CNC series — `Unknown` / `Zero_i_D` / `Zero_i_F` / `Zero_i_MF` / `Zero_i_TF` / `Sixteen_i` / `Thirty_i` / `ThirtyOne_i` / `ThirtyTwo_i` / `PowerMotion_i` |
| `--timeout-ms` | `2000` | Per-operation timeout |
| `--cnc-password` | (none) | **F4-d (issue #271)** — optional CNC connection-level password emitted via `cnc_wrunlockparam` on connect. Required only by controllers that gate parameter writes / selected reads behind a password switch (16i + some 30i firmwares with parameter-protect on). **PASSWORD INVARIANT: never logged.** The CLI's Serilog config does not destructure this flag and `FocasDeviceOptions.ToString` redacts the value. See [`v2/focas-deployment.md`](v2/focas-deployment.md) § "FOCAS password handling". |
| `--verbose` | off | Serilog debug output |
## Addressing

View File

@@ -147,6 +147,64 @@ non-idempotent action (alarm acks, M-code pulses, recipe steps). Flip
`WriteIdempotent` on per tag for genuinely-idempotent writes (a parameter
value that the operator simply wants forced to a target).
### FOCAS password — issue #271 (F4-d)
Some controllers — notably 16i and certain 30i firmwares with the
parameter-protect switch on — gate `cnc_wrparam` and a handful of reads
behind a connection-level password. Without unlocking the session, every
gated wire call returns `EW_PASSWD`, which the F4-b mapping surfaces as
`BadUserAccessDenied`.
`FocasDeviceOptions.Password` plumbs the password through the device config:
```jsonc
{
"Devices": [
{
"HostAddress": "focas://10.0.0.5:8193",
"Password": "1234" // F4-d — optional CNC password
}
]
}
```
When set, the driver:
1. **On connect**, calls `IFocasClient.UnlockAsync(password, ct)` after
the FWLIB handle opens but before any read/write fires. The FWLIB-backed
client emits `cnc_wrunlockparam` with the password ASCII-encoded into
the 4-byte FOCAS password slot (right-padded with `0x00`, truncated at
4 bytes — that's the shape the public Fanuc samples document).
2. **On `BadUserAccessDenied` from any gated read or write**, re-issues
`UnlockAsync` and retries the call **exactly once**. A second
`EW_PASSWD` propagates unchanged so a wrong password doesn't loop
forever on the wire.
3. **Reset on reconnect** — FWLIB unlock state lives on the handle, so
any reconnect path (planned or unplanned) re-runs unlock automatically
via `EnsureConnectedAsync`.
**No-log invariant.** The password is a secret. The driver MUST NOT log
it. Specifically:
- `FocasDeviceOptions` overrides the record's auto-generated `ToString`
to print `Password = ***` when the field is non-null. Any Serilog
destructure that flows the device options through `{Device}` gets the
redaction for free.
- `FwlibFocasClient.UnlockAsync` does not include the password in any
exception message — only the FWLIB return code (`EW_PASSWD`,
`EW_HANDLE`, etc.) makes it into the surface.
- `FocasDriver` logs only `"FOCAS unlock applied for {host}"` when the
unlock succeeds — no password.
- The Driver.FOCAS.Cli `--cnc-password` flag is also redacted at the
same `FocasDeviceOptions` choke point.
- See [`docs/v2/focas-deployment.md`](../v2/focas-deployment.md)
§ "FOCAS password handling" for the storage/rotation runbook + the
cross-link to [`docs/Security.md`](../Security.md).
When the controller does **not** need a password, leave `Password`
unset (`null`) and the driver short-circuits the unlock call entirely —
no wire-level cost.
### Status-code semantics post-F4-b
- `BadNotWritable` — one of: driver-level `Writes.Enabled = false`; per-tag
@@ -155,9 +213,13 @@ value that the operator simply wants forced to a target).
**`Writes.AllowPmc = false` for a PMC tag (F4-c)**. Same status code,
five distinct paths — operators distinguish by checking the knobs.
- `BadUserAccessDenied`**F4-b** — the CNC reported `EW_PASSWD`
(parameter-write switch off / unlock required). F4-d will land the
unlock workflow on top of this surface; today the deployment instructs
the operator to flip the parameter-write switch on the CNC pendant.
(parameter-write switch off / unlock required). **F4-d** wires the
`cnc_wrunlockparam` retry path on top: when `Password` is configured
the driver re-issues unlock + retries the gated call once before
surfacing this status. A persistent `BadUserAccessDenied` after F4-d
means either (a) the password doesn't match the controller, or (b)
the parameter-write switch on the pendant is still off and the
controller wants both the switch + the password.
- `BadNotSupported` — both opt-ins flipped on, but the wire client doesn't
implement the kind being written (e.g. older transport variant). F4-a
wired the generic dispatch; F4-b adds typed `WriteParameterAsync` /

View File

@@ -183,3 +183,108 @@ granular kill switches are lightweight runtime toggles, not config-DB
redeploys. PMC in particular should default OFF in production and only
flip on for windows where the ladder team has signed off on the write
path.
## FOCAS password handling — issue #271 (F4-d)
Some controllers (16i + certain 30i firmwares with parameter-protect on)
gate `cnc_wrparam` and selected reads behind a connection-level password.
The driver supports this via the `Password` field on `FocasDeviceOptions`
which is emitted via `cnc_wrunlockparam` on connect and re-emitted on any
`EW_PASSWD` read/write retry path. See
[`docs/drivers/FOCAS.md`](../drivers/FOCAS.md) § "FOCAS password" for the
driver-side behaviour; this section covers the deployment side.
### Storage in `appsettings.json`
```jsonc
{
"Drivers": {
"Focas01": {
"DriverConfigJson": {
"Backend": "fwlib",
"Series": "Sixteen_i",
"Devices": [
{
"HostAddress": "focas://10.0.0.5:8193",
"Password": "1234"
}
]
}
}
}
}
```
For dev environments, the password is materialised under
`.local/focas-passwords.txt` (or whichever .local subkey the deployment
team prefers); production deployments use the same secrets-store /
KeyVault pattern the LDAP `Authentication.Ldap.Password` field follows.
**The `.local/` directory is .gitignore'd** — this is the same posture
as `.local/galaxy-host-secret.txt` and other dev secrets in this repo.
### No-log invariant
The driver guarantees the password is **never logged**:
1. **`FocasDeviceOptions` ToString redaction.** The record overrides
`PrintMembers` so any Serilog destructure of the device options renders
`Password = ***` when the field is non-null. This catches the most
common leak path — a structured-log statement that included
`{@Device}` for diagnostic context.
2. **No password in exception messages.** `FwlibFocasClient.UnlockAsync`
omits the password from its `InvalidOperationException` text — only
the FWLIB error code (`EW_PASSWD`, `EW_HANDLE`, etc.) makes it through.
3. **Driver log line uses host only.** When unlock succeeds the driver
updates `DriverHealth.StatusText` to `"FOCAS unlock applied for
{host}"` — no password.
4. **CLI flag covered by the same choke point.** The
`Driver.FOCAS.Cli --cnc-password` flag flows through
`FocasDeviceOptions.Password`, so its redaction is identical to the
server's. The PowerShell e2e harness (`scripts/e2e/test-focas.ps1
-CncPassword`) follows the same path.
Any new logging surface that touches `FocasDeviceOptions` MUST continue
to use the record's `ToString` (or otherwise omit `Password`). A code
review checklist item: "no log statement contains `device.Options.Password`
or `device.Password` directly."
### Password-rotation runbook
When the CNC password rotates (operator team flipped a parameter-protect
gate, or your security policy requires periodic rotation):
1. **Update the password on the controller** (CNC pendant or vendor's
admin tool). The exact path varies by series — Fanuc service manual
page reference depends on the MTB.
2. **Update `appsettings.json`** in place with the new value.
- Production: bump the secrets-store entry that backs the
`Devices[*].Password` config-DB column. Same workflow as rotating
the LDAP service-account password.
- Dev: update `.local/focas-passwords.txt` (or wherever the dev
deployment sources the secret).
3. **Restart the OtOpcUa server** (or trigger a config-DB bump that
forces driver reinitialise). The driver picks up the new password
on the next `EnsureConnectedAsync` call. **No need to manually
reconnect each device** — `cnc_wrunlockparam` emits on the next
wire-call boundary.
4. **Verify**. The first wire call after restart logs
`"FOCAS unlock applied for focas://{host}:{port}"` at info. A wrong
password surfaces as `BadUserAccessDenied` on the next gated read or
write.
5. **Audit.** OPC UA wrote-event entries (per
[`audit-log-rules.md`](audit-log-rules.md)) cover the
parameter/macro write paths. Password rotation itself is NOT logged
beyond "unlock applied" — same posture as LDAP service-account
rotation, where the password change is logged out-of-band by the IAM
system.
### Cross-references
- [`docs/Security.md`](../Security.md) — server-wide secrets handling +
the same `.local/` pattern used for LDAP and the Galaxy.Host pipe
secret. The FOCAS password follows the same posture.
- [`docs/drivers/FOCAS.md`](../drivers/FOCAS.md) § "FOCAS password" —
driver-side behaviour, EW_PASSWD retry semantics, status-code
surface.
- [`docs/v2/implementation/focas-wire-protocol.md`](implementation/focas-wire-protocol.md)
§ "cnc_wrunlockparam" — wire-frame layout for the password buffer.

View File

@@ -32,6 +32,7 @@ command ids (and their request/response payloads) don't drift between the
| **`0x0102`** | **`cnc_wrparam`** | **mutates per-profile parameter map; returns `EW_PASSWD` (`11`) when the profile's `unlock_state` is off (sets up F4-d's unlock workflow) — issue #269, plan PR F4-b** |
| **`0x0103`** | **`cnc_wrmacro`** | **mutates per-profile macro map; integer-only writes for now (decimalPointCount=0) — issue #269, plan PR F4-b** |
| **`0x0104`** | **`pmc_wrpmcrng`** | **mutates per-profile PMC byte tables; byte-aligned writes preserve untouched bytes; bit-level writes never reach the simulator (driver wraps with RMW) — issue #270, plan PR F4-c** |
| **`0x0105`** | **`cnc_wrunlockparam`** | **flips the per-profile `unlock_state` to true when the supplied 4-byte password buffer matches the profile's `unlock_password`; otherwise returns `EW_PASSWD`. State persists for the connection lifetime (per-session). — issue #271, plan PR F4-d** |
| **`0x0F1A`** | **`cnc_rdalmhistry`** | **dumps the per-profile alarm-history ring buffer (issue #267, plan PR F3-a)** |
## `cnc_rdalmhistry` mock behaviour
@@ -124,6 +125,12 @@ Each profile owns:
- `unlock_state: bool` — defaults `False`. When `False`, every
`cnc_wrparam` returns `EW_PASSWD` (numeric `11`) regardless of
parameter. Macro writes are NOT gated by `unlock_state`.
- `unlock_password: bytes` (4-byte buffer) — defaults to the profile's
fixture default (e.g. `b"1234"` for Series30i). Compared byte-for-byte
by the `cnc_wrunlockparam` handler; flips `unlock_state = True` on
match, leaves it untouched on mismatch (and returns `EW_PASSWD`).
Mutable via `POST /admin/mock_set_password` for tests that exercise
rotation. Issue #271, plan PR F4-d.
- `last_write: Optional[LastWrite]` — most-recent successful
`(kind, number, value, ts)` tuple, surfaced via the admin endpoint
below for audit-log assertions.
@@ -163,6 +170,37 @@ POST /admin/mock_set_unlock_state
{ "profile": "Series30i", "unlocked": true }
```
### `cnc_wrunlockparam` request decode — issue #271, plan PR F4-d
```
[byte[4] password]
```
Match `password == profile.unlock_password` byte-for-byte. On match:
flip `unlock_state = True`, return `[int16 LE 0]`. On mismatch: leave
`unlock_state` untouched, return `[int16 LE 11]` (`EW_PASSWD`).
The simulator deliberately keeps unlock state per-session (per OpenSession
handle) so a reconnect drops back to `unlock_state = False` — matching the
FWLIB lifetime semantics described in
[`focas-wire-protocol.md`](./focas-wire-protocol.md) § "cnc_wrunlockparam".
### Admin endpoint — `POST /admin/mock_set_password`
Rotates the per-profile `unlock_password` for tests that exercise the
F4-d password-rotation runbook (`docs/v2/focas-deployment.md`
§ "FOCAS password handling"). Idempotent — call again to revert.
```
POST /admin/mock_set_password
{ "profile": "Series30i", "password": "5678" }
```
The endpoint accepts the password as a UTF-8/ASCII string and applies
the same right-pad-to-4-bytes / truncate-to-4-bytes normalisation the
driver does, so simulator-side matching is byte-symmetric with the
production wire encoder.
### Admin endpoint — `GET /admin/mock_get_last_write`
Returns the simulator's view of the most-recent successful write, used by

View File

@@ -22,6 +22,7 @@ Each FOCAS-equivalent call gets a stable wire-protocol command id. Ids are
| **`0x0102`** | **`cnc_wrparam`** | **IODBPSD parameter-write packet (issue #269, plan PR F4-b)** |
| **`0x0103`** | **`cnc_wrmacro`** | **ODBM macro-write packet (issue #269, plan PR F4-b)** |
| **`0x0104`** | **`pmc_wrpmcrng`** | **IODBPMC PMC range-write packet (issue #270, plan PR F4-c)** |
| **`0x0105`** | **`cnc_wrunlockparam`** | **4-byte password buffer for the parameter-protect / read-protect unlock (issue #271, plan PR F4-d)** |
| `0x0F1A` | **`cnc_rdalmhistry`** | **ODBALMHIS alarm-history ring-buffer dump (issue #267, plan PR F3-a)** |
## ODBALMHIS — alarm history (`cnc_rdalmhistry`, command `0x0F1A`)
@@ -214,3 +215,53 @@ data buffer in the response; the write side parses all five fields plus
the data buffer from the request and emits a status int16 in the
response. Tests `FocasWritePmcTests.PMC_*` exercise the round-trip on
the fake wire client.
## cnc_wrunlockparam — connection-level password unlock (command `0x0105`)
Issue #271, plan PR F4-d. Some controllers (notably 16i + certain 30i
firmwares with parameter-protect on) gate `cnc_wrparam` and selected
reads behind a connection-level password switch. The driver emits this
frame on connect when `FocasDeviceOptions.Password` is configured, and
re-emits it on any read/write that returns `EW_PASSWD` (then retries the
gated call once).
### Request
| Offset | Width | Field |
| --- | --- | --- |
| 0 | `byte[4]` | `password[4]` — 4-byte password buffer. ASCII-encoded from `FocasDeviceOptions.Password`, right-padded with `0x00`, truncated at 4 bytes. |
The 4-byte fixed slot matches the FANUC published shape — the controller
compares byte-for-byte. Longer / shorter source strings are normalised at
the driver layer before they hit this frame so the wire surface stays
canonical.
### Response
Same single-int16 envelope as the write frames:
| Offset | Width | Field |
| --- | --- | --- |
| 0 | `int16 LE` | `ew_status``0` = success (gate now lifted for the lifetime of this FWLIB handle), `EW_PASSWD` = supplied password did not match the controller's slot, `EW_HANDLE` = handle invalid. |
### Lifetime
Unlock is bound to the FWLIB handle: it persists until the handle closes
(disconnect / reconnect). The driver reinvokes unlock on every
`EnsureConnectedAsync` reconnect path so a planned or unplanned wire
restart self-heals without operator intervention. A `BadUserAccessDenied`
on a read/write triggers a single-shot retry: re-emit unlock + redispatch
the gated call once. A second `EW_PASSWD` propagates unchanged so a
mismatched password doesn't loop forever on the wire.
### No-log invariant
The password is a secret. Wire-client implementations MUST NOT log the
password on either request or response. The current
`FwlibFocasClient.UnlockAsync` constructs an exception that includes
only the `EW_*` return code; the `FocasDeviceOptions` record overrides
its auto-generated `ToString` so any Serilog destructure renders
`Password = ***`. See
[`docs/v2/focas-deployment.md`](../focas-deployment.md)
§ "FOCAS password handling" for the deployment-side guarantees +
rotation runbook.

View File

@@ -56,6 +56,16 @@
mistargeted bit can move motion or latch a feedhold) so the gate is
off by default — see docs/v2/focas-deployment.md "Write safety / PMC
pre-checks".
.PARAMETER CncPassword
Issue #271 (F4-d) — optional CNC connection-level password emitted via
cnc_wrunlockparam on connect. Required only when the controller gates
parameter writes behind a password switch (16i + some 30i firmwares
with parameter-protect on). Threaded through to every CLI invocation
in the -Write stage as --cnc-password. PASSWORD INVARIANT: never
logged — the CLI's Serilog config does not destructure this flag.
See docs/v2/focas-deployment.md § "FOCAS password handling" for the
no-log invariant + rotation runbook.
#>
param(
@@ -67,7 +77,8 @@ param(
[switch]$Write,
[string]$ParamAddress = "PARAM:1815",
[string]$MacroAddress = "MACRO:500",
[string]$PmcBitAddress = "R100.3"
[string]$PmcBitAddress = "R100.3",
[string]$CncPassword = ""
)
$ErrorActionPreference = "Stop"
@@ -86,6 +97,15 @@ $opcUaCli = Get-CliInvocation `
-ExeName "otopcua-cli"
$commonFocas = @("-h", $CncHost, "-p", $CncPort)
# F4-d (issue #271) — thread the CNC connection password through to every CLI
# invocation. The CLI's --cnc-password flag emits cnc_wrunlockparam on connect
# and the driver's per-call retry path re-issues unlock + retries once on
# EW_PASSWD. PASSWORD INVARIANT: the password is NOT logged here. Write-Host
# and Test-* helpers never destructure $commonFocas, but we still avoid
# Write-Host'ing the array directly; the CLI's Serilog config also redacts.
if (-not [string]::IsNullOrWhiteSpace($CncPassword)) {
$commonFocas += @("--cnc-password", $CncPassword)
}
$results = @()
$results += Test-Probe `

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

View File

@@ -40,6 +40,37 @@ internal class FakeFocasClient : IFocasClient
return Task.CompletedTask;
}
/// <summary>
/// Plan PR F4-d (issue #271) — count of <see cref="UnlockAsync"/> invocations.
/// Tests assert this to verify the driver routed <c>cnc_wrunlockparam</c> on
/// connect when <c>FocasDeviceOptions.Password</c> was non-null and re-issued
/// unlock on EW_PASSWD retry exactly once.
/// </summary>
public int UnlockCount { get; private set; }
/// <summary>
/// Plan PR F4-d (issue #271) — last password observed by <see cref="UnlockAsync"/>.
/// Used by the round-trip test to confirm the driver passed the configured
/// password through unmodified. <b>This field exists ONLY in the fake — no
/// production wire client retains the password past the wire call.</b>
/// </summary>
public string? LastUnlockPassword { get; private set; }
/// <summary>
/// Plan PR F4-d (issue #271) — when set, <see cref="UnlockAsync"/> throws on
/// invocation so tests can drive the failed-unlock retry path (where the
/// driver surfaces BadUserAccessDenied as-is rather than retrying).
/// </summary>
public bool ThrowOnUnlock { get; set; }
public virtual Task UnlockAsync(string password, CancellationToken ct)
{
UnlockCount++;
LastUnlockPassword = password;
if (ThrowOnUnlock) throw Exception ?? new InvalidOperationException("Unlock fails");
return Task.CompletedTask;
}
public virtual Task<(object? value, uint status)> ReadAsync(
FocasAddress address, FocasDataType type, CancellationToken ct)
{

View File

@@ -0,0 +1,359 @@
using System.Text.Json;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests;
/// <summary>
/// Issue #271, plan PR F4-d — <c>cnc_wrunlockparam</c> coverage. Some controllers
/// (notably 16i + some 30i firmwares with parameter-protect on) gate
/// <c>cnc_wrparam</c> + selected reads behind a connection-level password. The
/// driver emits unlock on connect when <c>FocasDeviceOptions.Password</c> is set,
/// and on any read/write returning <c>EW_PASSWD</c> -> <c>BadUserAccessDenied</c>
/// it re-issues unlock and retries the gated call exactly once.
/// </summary>
[Trait("Category", "Unit")]
public sealed class FocasUnlockTests
{
private const string Host = "focas://10.0.0.5:8193";
private const string Pwd = "1234";
private static FocasDriver NewDriver(
string? password,
FocasWritesOptions writes,
FocasTagDefinition[] tags,
out FakeFocasClientFactory factory)
{
factory = new FakeFocasClientFactory();
return new FocasDriver(new FocasDriverOptions
{
Devices = [new FocasDeviceOptions(Host, Password: password)],
Tags = tags,
Probe = new FocasProbeOptions { Enabled = false },
Writes = writes,
}, "drv-1", factory);
}
[Fact]
public async Task Password_set_invokes_UnlockAsync_on_connect()
{
// First connect attempt happens on the first wire call; trigger one read so
// EnsureConnectedAsync runs.
var drv = NewDriver(
password: Pwd,
writes: new FocasWritesOptions { Enabled = true, AllowParameter = true },
tags:
[
new FocasTagDefinition("R100", Host, "R100", FocasDataType.Int16, Writable: false),
],
out var factory);
await drv.InitializeAsync("{}", CancellationToken.None);
// Drive one wire call to force the connect path.
_ = await drv.ReadAsync(["R100"], CancellationToken.None);
var fake = factory.Clients.Single();
fake.ConnectCount.ShouldBe(1);
fake.UnlockCount.ShouldBe(1);
fake.LastUnlockPassword.ShouldBe(Pwd);
}
[Fact]
public async Task Password_null_does_NOT_invoke_UnlockAsync()
{
var drv = NewDriver(
password: null,
writes: new FocasWritesOptions { Enabled = true, AllowParameter = true },
tags:
[
new FocasTagDefinition("R100", Host, "R100", FocasDataType.Int16, Writable: false),
],
out var factory);
await drv.InitializeAsync("{}", CancellationToken.None);
_ = await drv.ReadAsync(["R100"], CancellationToken.None);
var fake = factory.Clients.Single();
fake.ConnectCount.ShouldBe(1);
fake.UnlockCount.ShouldBe(0);
}
[Fact]
public async Task EW_PASSWD_on_write_with_Password_set_triggers_unlock_and_retries()
{
var drv = NewDriver(
password: Pwd,
writes: new FocasWritesOptions { Enabled = true, AllowParameter = true },
tags:
[
new FocasTagDefinition("Param", Host, "PARAM:1815", FocasDataType.Int32, Writable: true),
],
out var factory);
await drv.InitializeAsync("{}", CancellationToken.None);
// Seed: first WriteParameterAsync returns BadUserAccessDenied, second returns Good.
// We model this as a fake that flips the write status after the first call.
// Use a subclass with one-shot EW_PASSWD then Good.
factory.Customise = () => new OneShotPasswdClient(Pwd);
// Re-init by issuing a fresh write — Customise applies on next Create() call.
// The first driver instance already constructed a non-customised client. Build
// a fresh driver here so the customised factory wins.
var factory2 = new FakeFocasClientFactory
{
Customise = () => new OneShotPasswdClient(Pwd),
};
var drv2 = new FocasDriver(new FocasDriverOptions
{
Devices = [new FocasDeviceOptions(Host, Password: Pwd)],
Tags =
[
new FocasTagDefinition("Param", Host, "PARAM:1815", FocasDataType.Int32, Writable: true),
],
Probe = new FocasProbeOptions { Enabled = false },
Writes = new FocasWritesOptions { Enabled = true, AllowParameter = true },
}, "drv-2", factory2);
await drv2.InitializeAsync("{}", CancellationToken.None);
var results = await drv2.WriteAsync(
[new WriteRequest("Param", 42)], CancellationToken.None);
results.Single().StatusCode.ShouldBe(FocasStatusMapper.Good);
var fake = (OneShotPasswdClient)factory2.Clients.Single();
// Unlock fires twice: once on connect (Password set) + once on retry.
fake.UnlockCount.ShouldBe(2);
// Two writes attempted (first returned EW_PASSWD, retry returned Good).
fake.ParameterWriteLog.Count.ShouldBe(2);
}
[Fact]
public async Task EW_PASSWD_on_write_without_Password_propagates_BadUserAccessDenied()
{
// No password configured — the driver must propagate BadUserAccessDenied
// immediately rather than attempting a (would-fail) unlock. The retry path
// is only meaningful when the deployment has a password to re-emit.
var factory = new FakeFocasClientFactory();
var drv = new FocasDriver(new FocasDriverOptions
{
Devices = [new FocasDeviceOptions(Host, Password: null)],
Tags =
[
new FocasTagDefinition("Param", Host, "PARAM:1815", FocasDataType.Int32, Writable: true),
],
Probe = new FocasProbeOptions { Enabled = false },
Writes = new FocasWritesOptions { Enabled = true, AllowParameter = true },
}, "drv-3", factory);
await drv.InitializeAsync("{}", CancellationToken.None);
// Seed an EW_PASSWD response on the parameter write — fake returns it as-is.
factory.Customise = null; // first Create returns plain FakeFocasClient
// Trigger the connect first so Customise can swap behaviour:
// simpler: pre-seed status on the existing fake by creating one and registering it.
// Use a derived class instead.
// -- redo via direct subclass approach:
var factory2 = new FakeFocasClientFactory
{
Customise = () => new AlwaysPasswdClient(),
};
var drv2 = new FocasDriver(new FocasDriverOptions
{
Devices = [new FocasDeviceOptions(Host, Password: null)],
Tags =
[
new FocasTagDefinition("Param", Host, "PARAM:1815", FocasDataType.Int32, Writable: true),
],
Probe = new FocasProbeOptions { Enabled = false },
Writes = new FocasWritesOptions { Enabled = true, AllowParameter = true },
}, "drv-3b", factory2);
await drv2.InitializeAsync("{}", CancellationToken.None);
var results = await drv2.WriteAsync(
[new WriteRequest("Param", 42)], CancellationToken.None);
results.Single().StatusCode.ShouldBe(FocasStatusMapper.BadUserAccessDenied);
var fake = (AlwaysPasswdClient)factory2.Clients.Single();
fake.UnlockCount.ShouldBe(0); // no password -> no unlock attempt at all
fake.ParameterWriteLog.Count.ShouldBe(1); // single attempt, no retry
}
[Fact]
public async Task Retry_happens_at_most_once_when_second_attempt_also_returns_EW_PASSWD()
{
// Recursion guard — the driver retries exactly once. A second EW_PASSWD on
// the retry surfaces BadUserAccessDenied unchanged so a wrong password
// doesn't loop forever.
var factory = new FakeFocasClientFactory
{
Customise = () => new AlwaysPasswdClient(),
};
var drv = new FocasDriver(new FocasDriverOptions
{
Devices = [new FocasDeviceOptions(Host, Password: Pwd)],
Tags =
[
new FocasTagDefinition("Param", Host, "PARAM:1815", FocasDataType.Int32, Writable: true),
],
Probe = new FocasProbeOptions { Enabled = false },
Writes = new FocasWritesOptions { Enabled = true, AllowParameter = true },
}, "drv-4", factory);
await drv.InitializeAsync("{}", CancellationToken.None);
var results = await drv.WriteAsync(
[new WriteRequest("Param", 42)], CancellationToken.None);
results.Single().StatusCode.ShouldBe(FocasStatusMapper.BadUserAccessDenied);
var fake = (AlwaysPasswdClient)factory.Clients.Single();
// Connect-time unlock + one retry-time unlock.
fake.UnlockCount.ShouldBe(2);
// Two write attempts: original + one retry. Never three.
fake.ParameterWriteLog.Count.ShouldBe(2);
}
[Fact]
public async Task EW_PASSWD_on_read_also_triggers_unlock_and_retries()
{
// Some firmwares gate selected reads behind cnc_wrunlockparam too — mirrors the
// write retry shape so a session that lost its unlock state mid-flight self-heals.
var factory = new FakeFocasClientFactory
{
Customise = () => new OneShotPasswdClient(Pwd) { GateReads = true },
};
var drv = new FocasDriver(new FocasDriverOptions
{
Devices = [new FocasDeviceOptions(Host, Password: Pwd)],
Tags =
[
new FocasTagDefinition("Param", Host, "PARAM:1815", FocasDataType.Int32, Writable: false),
],
Probe = new FocasProbeOptions { Enabled = false },
Writes = new FocasWritesOptions { Enabled = false },
}, "drv-5", factory);
await drv.InitializeAsync("{}", CancellationToken.None);
var snaps = await drv.ReadAsync(["Param"], CancellationToken.None);
snaps.Single().StatusCode.ShouldBe(FocasStatusMapper.Good);
var fake = (OneShotPasswdClient)factory.Clients.Single();
// Connect-time unlock + retry unlock = 2.
fake.UnlockCount.ShouldBe(2);
}
[Fact]
public async Task Password_is_NOT_present_in_FakeFocasClient_WriteLog_invocations()
{
// Write calls only ever see the address + data type + value — the password
// never appears in the per-call wire arguments. This guards against a future
// refactor accidentally threading the secret through the per-call surface.
var factory = new FakeFocasClientFactory();
var drv = new FocasDriver(new FocasDriverOptions
{
Devices = [new FocasDeviceOptions(Host, Password: "supersecret")],
Tags =
[
new FocasTagDefinition("Param", Host, "PARAM:1815", FocasDataType.Int32, Writable: true),
],
Probe = new FocasProbeOptions { Enabled = false },
Writes = new FocasWritesOptions { Enabled = true, AllowParameter = true },
}, "drv-6", factory);
await drv.InitializeAsync("{}", CancellationToken.None);
await drv.WriteAsync([new WriteRequest("Param", 42)], CancellationToken.None);
var fake = factory.Clients.Single();
// None of the captured write-log tuples contains the password string.
foreach (var entry in fake.WriteLog.Concat(
fake.ParameterWriteLog.Select(p => (p.addr, p.type, p.value))))
{
entry.ToString()!.ShouldNotContain("supersecret");
}
}
[Fact]
public void DTO_JSON_round_trip_preserves_Password()
{
const string json = """
{
"Backend": "fwlib",
"Series": "Sixteen_i",
"Devices": [
{ "HostAddress": "focas://10.0.0.5:8193", "Password": "1234" }
],
"Tags": [
{ "Name": "Param", "DeviceHostAddress": "focas://10.0.0.5:8193",
"Address": "PARAM:1815", "DataType": "Int32", "Writable": true }
]
}
""";
var driver = FocasDriverFactoryExtensions.CreateInstance("focas-pwd", json);
driver.ShouldNotBeNull();
// The instance was constructed; we don't expose Password on the driver
// surface (that would invert the no-log invariant), but ToString of
// FocasDeviceOptions redacts the password — verify that round-trip.
var opts = new FocasDeviceOptions("focas://1.2.3.4:8193", Password: "topsecret");
opts.ToString().ShouldNotContain("topsecret");
opts.ToString().ShouldContain("***");
new FocasDeviceOptions("focas://1.2.3.4:8193", Password: null)
.ToString().ShouldContain("<null>");
}
/// <summary>
/// Test fake — first parameter write returns EW_PASSWD-equivalent
/// <see cref="FocasStatusMapper.BadUserAccessDenied"/>, second returns Good.
/// Optionally extends the same shape to <see cref="ReadAsync"/> for
/// read-side tests.
/// </summary>
private sealed class OneShotPasswdClient : FakeFocasClient
{
private readonly string _expectedPassword;
private bool _firstWriteSeen;
private bool _firstReadSeen;
public OneShotPasswdClient(string expectedPassword)
{
_expectedPassword = expectedPassword;
}
public bool GateReads { get; set; }
public override Task<uint> WriteParameterAsync(
FocasAddress address, FocasDataType type, object? value, CancellationToken ct)
{
ParameterWriteLog.Add((address, type, value));
if (!_firstWriteSeen)
{
_firstWriteSeen = true;
return Task.FromResult(FocasStatusMapper.BadUserAccessDenied);
}
return Task.FromResult(FocasStatusMapper.Good);
}
public override Task<(object? value, uint status)> ReadAsync(
FocasAddress address, FocasDataType type, CancellationToken ct)
{
if (GateReads && !_firstReadSeen)
{
_firstReadSeen = true;
return Task.FromResult<(object?, uint)>((null, FocasStatusMapper.BadUserAccessDenied));
}
return Task.FromResult<(object?, uint)>(((object?)0, FocasStatusMapper.Good));
}
}
/// <summary>
/// Test fake — every parameter write returns
/// <see cref="FocasStatusMapper.BadUserAccessDenied"/>. Drives the
/// "second attempt also fails" recursion-guard test + the no-password
/// pass-through test.
/// </summary>
private sealed class AlwaysPasswdClient : FakeFocasClient
{
public override Task<uint> WriteParameterAsync(
FocasAddress address, FocasDataType type, object? value, CancellationToken ct)
{
ParameterWriteLog.Add((address, type, value));
return Task.FromResult(FocasStatusMapper.BadUserAccessDenied);
}
}
}

View File

@@ -0,0 +1,61 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests.Series;
/// <summary>
/// Issue #271, plan PR F4-d — series-level (would-be integration) coverage of
/// <c>cnc_wrunlockparam</c>. Hardware-gated: the FOCAS driver has no public
/// simulator (task #222) so the live-controller cases require a real CNC with
/// parameter-protect on. The CI lane for this assembly runs the unit-test fakes
/// under <see cref="FocasUnlockTests"/>; this file is a scaffold that runs
/// against a simulator + matching <c>mock_set_password</c> admin endpoint when
/// <c>FOCAS_TRUST_WIRE=1</c>.
/// </summary>
/// <remarks>
/// Build-only today: the simulator gate (FOCAS_TRUST_WIRE) skips at runtime so
/// CI doesn't need the simulator binary. When the simulator's
/// <c>cnc_wrunlockparam</c> + <c>mock_set_password</c> endpoints land
/// (<c>docs/v2/implementation/focas-simulator-plan.md</c>) the gated test
/// becomes a real round-trip.
/// </remarks>
[Trait("Category", "Series")]
public sealed class PasswordUnlockTests
{
[Fact]
public void Single_unlock_retry_path_is_documented()
{
// Build-only scaffold — see FocasUnlockTests for the actual fake-backed
// assertion. The integration version of this test (gated on a FOCAS
// simulator with mock_set_password) will:
// 1. Configure the simulator with password "1234".
// 2. Spin up FocasDriver with FocasDeviceOptions.Password = "1234".
// 3. Issue a cnc_wrparam against PARAM:1815 — expect Good (unlock applied
// on connect).
// 4. Use the simulator's admin endpoint to flip the password to "5678"
// mid-session (forces EW_PASSWD on the next write).
// 5. Issue another write — expect EW_PASSWD on attempt 1, then BadUserAccessDenied
// surfaced because the new password doesn't match the cached one.
// 6. Reconfigure FocasDeviceOptions.Password = "5678" on a new instance,
// issue write — expect Good (unlock applied on first connect).
// For now this test merely asserts the type contract; the simulator is
// tracked under task #222 + the focas-simulator-plan.md document.
typeof(IFocasClient).GetMethod(nameof(IFocasClient.UnlockAsync))
.ShouldNotBeNull();
// Driver-side records the password redaction invariant.
var dev = new FocasDeviceOptions("focas://1.2.3.4:8193", Password: "1234");
dev.ToString().ShouldNotContain("1234");
}
[Fact(Skip = "Hardware-gated — requires the FOCAS simulator with cnc_wrunlockparam + mock_set_password endpoints (task #222 / focas-simulator-plan.md).")]
public Task Live_simulator_unlock_retry_round_trip()
{
// Body deliberately empty — the [Skip] attribute keeps this off the CI lane.
// When the simulator lands, this test materialises a FocasDriver pointed at
// the simulator + drives the EW_PASSWD -> unlock -> retry path through real
// wire calls. See <c>docs/v2/implementation/focas-simulator-plan.md</c>
// § "FOCAS password unlock" for the simulator-side endpoints.
return Task.CompletedTask;
}
}