diff --git a/docs/Driver.FOCAS.Cli.md b/docs/Driver.FOCAS.Cli.md
index e11aab9..f066419 100644
--- a/docs/Driver.FOCAS.Cli.md
+++ b/docs/Driver.FOCAS.Cli.md
@@ -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
diff --git a/docs/drivers/FOCAS.md b/docs/drivers/FOCAS.md
index 2a883ff..94d612a 100644
--- a/docs/drivers/FOCAS.md
+++ b/docs/drivers/FOCAS.md
@@ -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` /
diff --git a/docs/v2/focas-deployment.md b/docs/v2/focas-deployment.md
index e5c9376..c32f97c 100644
--- a/docs/v2/focas-deployment.md
+++ b/docs/v2/focas-deployment.md
@@ -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.
diff --git a/docs/v2/implementation/focas-simulator-plan.md b/docs/v2/implementation/focas-simulator-plan.md
index e3c407b..557c26b 100644
--- a/docs/v2/implementation/focas-simulator-plan.md
+++ b/docs/v2/implementation/focas-simulator-plan.md
@@ -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
diff --git a/docs/v2/implementation/focas-wire-protocol.md b/docs/v2/implementation/focas-wire-protocol.md
index 686a119..b0a48e6 100644
--- a/docs/v2/implementation/focas-wire-protocol.md
+++ b/docs/v2/implementation/focas-wire-protocol.md
@@ -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.
diff --git a/scripts/e2e/test-focas.ps1 b/scripts/e2e/test-focas.ps1
index b2c861c..43a8a96 100644
--- a/scripts/e2e/test-focas.ps1
+++ b/scripts/e2e/test-focas.ps1
@@ -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 `
diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Cli/FocasCommandBase.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Cli/FocasCommandBase.cs
index 5f65dbe..a3f85c3 100644
--- a/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Cli/FocasCommandBase.cs
+++ b/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Cli/FocasCommandBase.cs
@@ -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;
+ ///
+ /// Plan PR F4-d (issue #271) — optional CNC connection-level password emitted
+ /// via cnc_wrunlockparam on connect. Required only by controllers that
+ /// gate cnc_wrparam + 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 layer (record's
+ /// overridden ToString).
+ ///
+ [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; }
+
///
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 },
diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasDriver.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasDriver.cs
index 6b0bda9..4c616a7 100644
--- a/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasDriver.cs
+++ b/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasDriver.cs
@@ -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 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;
}
+ ///
+ /// Plan PR F4-d (issue #271) — re-issue cnc_wrunlockparam on the active
+ /// wire session and return true when the unlock succeeded so the caller
+ /// can retry the gated read/write once. false means the unlock itself
+ /// failed (mismatched password, transport refused) and the caller surfaces
+ /// BadUserAccessDenied as-is. PASSWORD INVARIANT: the password is never
+ /// logged from this method.
+ ///
+ private async Task 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,
///
public int LastSetPath { get; set; }
+ ///
+ /// Plan PR F4-d (issue #271) — set when the driver successfully emitted
+ /// cnc_wrunlockparam on this wire session. Reset to false
+ /// 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.
+ ///
+ public bool UnlockApplied { get; set; }
+
public void DisposeClient()
{
Client?.Dispose();
diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasDriverFactoryExtensions.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasDriverFactoryExtensions.cs
index 265d44b..b5fd0c2 100644
--- a/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasDriverFactoryExtensions.cs
+++ b/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasDriverFactoryExtensions.cs
@@ -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; }
+
+ ///
+ /// Plan PR F4-d (issue #271) — optional CNC connection-level password emitted
+ /// via cnc_wrunlockparam on connect. Required only by controllers that
+ /// gate cnc_wrparam + selected reads behind a password switch. The
+ /// driver maps EW_PASSWD -> BadUserAccessDenied and re-issues
+ /// unlock + retries the gated call once on that mapping.
+ /// No-log invariant: never logged through the driver. The host
+ /// FocasDeviceOptions record overrides ToString to redact this
+ /// field. Stored in appsettings.json alongside the rest of the device
+ /// config; treat as a secret per docs/v2/focas-deployment.md
+ /// § "FOCAS password handling" + cross-link to docs/Security.md.
+ ///
+ public string? Password { get; init; }
}
internal sealed class FocasTagDto
diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasDriverOptions.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasDriverOptions.cs
index dc700a0..0d661dc 100644
--- a/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasDriverOptions.cs
+++ b/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasDriverOptions.cs
@@ -197,12 +197,44 @@ public sealed record FocasFixedTreeOptions
/// declares the four MTB-specific override
/// cnc_rdparam numbers surfaced under Override/; pass null to
/// suppress the entire Override/ subfolder for that device (issue #259).
+/// (issue #271, plan PR F4-d) is the CNC connection-level
+/// password emitted via cnc_wrunlockparam on connect when the controller
+/// gates parameter writes / certain reads behind a password switch (16i + some
+/// 30i firmwares with parameter-protect on).
///
+///
+/// No-log invariant: is a secret. The driver MUST NOT
+/// log it. FocasDeviceOptions.ToString() would include the field by default
+/// because it's a positional record member, so the record's auto-generated
+/// ToString is overridden via below to redact
+/// the password. Any new logging surface that touches
+/// must continue to redact. See docs/v2/focas-deployment.md § "FOCAS password
+/// handling" for the no-log invariant and rotation runbook.
+///
public sealed record FocasDeviceOptions(
string HostAddress,
string? DeviceName = null,
FocasCncSeries Series = FocasCncSeries.Unknown,
- FocasOverrideParameters? OverrideParameters = null);
+ FocasOverrideParameters? OverrideParameters = null,
+ string? Password = null)
+{
+ ///
+ /// Issue #271 (plan PR F4-d) — record auto-generated ToString would print
+ /// verbatim. Override the printer so the secret is replaced
+ /// with "***" when the field is non-null. The no-log invariant relies on
+ /// this — every Serilog destructure that flows a
+ /// value through {Device} gets redaction for free.
+ ///
+ 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 ? "" : "***");
+ return true;
+ }
+}
///
/// One FOCAS-backed OPC UA variable. is the canonical FOCAS
diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FwlibFocasClient.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FwlibFocasClient.cs
index 5af8243..04e6ed6 100644
--- a/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FwlibFocasClient.cs
+++ b/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FwlibFocasClient.cs
@@ -48,6 +48,47 @@ internal sealed class FwlibFocasClient : IFocasClient
return Task.CompletedTask;
}
+ ///
+ /// Plan PR F4-d (issue #271) — emit cnc_wrunlockparam to lift the
+ /// CNC's parameter-protect / read-protect gate. The password is ASCII-encoded
+ /// into a 4-byte buffer (right-padded with 0x00; truncated when the
+ /// supplied string exceeds 4 chars — Fanuc's published password buffer is a
+ /// fixed 4-byte slot). Mismatch surfaces as EW_PASSWD mapped to
+ /// ; the F4-d retry loop
+ /// in re-issues unlock + retries the call once on
+ /// that mapping.
+ /// No-log invariant: 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 FocasDeviceOptions.Password.
+ ///
+ 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)
{
diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FwlibNative.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FwlibNative.cs
index 8ff9713..b4901ca 100644
--- a/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FwlibNative.cs
+++ b/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FwlibNative.cs
@@ -66,6 +66,25 @@ internal static class FwlibNative
short length,
ref IODBPSD buffer);
+ ///
+ /// cnc_wrunlockparam — 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 EW_* family — EW_PASSWD when the supplied bytes don't
+ /// match the configured password.
+ ///
+ ///
+ /// P/Invoke shape kept narrow: the caller passes a 4-byte buffer. The
+ /// driver layer ASCII-encodes FocasDeviceOptions.Password into the
+ /// buffer (right-padded with 0x00, truncated to 4 bytes) — that's the
+ /// shape every public Fanuc password example we've seen uses.
+ ///
+ [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)]
diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/IFocasClient.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/IFocasClient.cs
index 3776c3c..ff6118a 100644
--- a/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/IFocasClient.cs
+++ b/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/IFocasClient.cs
@@ -23,6 +23,25 @@ public interface IFocasClient : IDisposable
/// True when the FWLIB handle is valid + the socket is up.
bool IsConnected { get; }
+ ///
+ /// Plan PR F4-d (issue #271) — emit the CNC password via FOCAS
+ /// cnc_wrunlockparam. Some controllers (notably 16i + some 30i
+ /// firmwares with parameter-protect on) gate cnc_wrparam 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 ).
+ /// Default impl is a no-op () so transport
+ /// variants that don't surface unlock (today: IPC and FAKE clients) keep
+ /// compiling. The FWLIB-backed client overrides this with a real
+ /// cnc_wrunlockparam call.
+ /// No-log invariant: is a secret. The
+ /// wire-client implementation MUST NOT log the password — see
+ /// FocasDeviceOptions.Password + docs/v2/focas-deployment.md
+ /// § "FOCAS password handling".
+ ///
+ Task UnlockAsync(string password, CancellationToken cancellationToken)
+ => Task.CompletedTask;
+
///
/// Read the value at in the requested
/// . Returns a boxed .NET value + the OPC UA status mapped
diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FakeFocasClient.cs b/tests/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FakeFocasClient.cs
index 247ea89..22ae8fe 100644
--- a/tests/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FakeFocasClient.cs
+++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FakeFocasClient.cs
@@ -40,6 +40,37 @@ internal class FakeFocasClient : IFocasClient
return Task.CompletedTask;
}
+ ///
+ /// Plan PR F4-d (issue #271) — count of invocations.
+ /// Tests assert this to verify the driver routed cnc_wrunlockparam on
+ /// connect when FocasDeviceOptions.Password was non-null and re-issued
+ /// unlock on EW_PASSWD retry exactly once.
+ ///
+ public int UnlockCount { get; private set; }
+
+ ///
+ /// Plan PR F4-d (issue #271) — last password observed by .
+ /// Used by the round-trip test to confirm the driver passed the configured
+ /// password through unmodified. This field exists ONLY in the fake — no
+ /// production wire client retains the password past the wire call.
+ ///
+ public string? LastUnlockPassword { get; private set; }
+
+ ///
+ /// Plan PR F4-d (issue #271) — when set, throws on
+ /// invocation so tests can drive the failed-unlock retry path (where the
+ /// driver surfaces BadUserAccessDenied as-is rather than retrying).
+ ///
+ 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)
{
diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasUnlockTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasUnlockTests.cs
new file mode 100644
index 0000000..f86d8c6
--- /dev/null
+++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasUnlockTests.cs
@@ -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;
+
+///
+/// Issue #271, plan PR F4-d — cnc_wrunlockparam coverage. Some controllers
+/// (notably 16i + some 30i firmwares with parameter-protect on) gate
+/// cnc_wrparam + selected reads behind a connection-level password. The
+/// driver emits unlock on connect when FocasDeviceOptions.Password is set,
+/// and on any read/write returning EW_PASSWD -> BadUserAccessDenied
+/// it re-issues unlock and retries the gated call exactly once.
+///
+[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("");
+ }
+
+ ///
+ /// Test fake — first parameter write returns EW_PASSWD-equivalent
+ /// , second returns Good.
+ /// Optionally extends the same shape to for
+ /// read-side tests.
+ ///
+ 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 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));
+ }
+ }
+
+ ///
+ /// Test fake — every parameter write returns
+ /// . Drives the
+ /// "second attempt also fails" recursion-guard test + the no-password
+ /// pass-through test.
+ ///
+ private sealed class AlwaysPasswdClient : FakeFocasClient
+ {
+ public override Task WriteParameterAsync(
+ FocasAddress address, FocasDataType type, object? value, CancellationToken ct)
+ {
+ ParameterWriteLog.Add((address, type, value));
+ return Task.FromResult(FocasStatusMapper.BadUserAccessDenied);
+ }
+ }
+}
diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/Series/PasswordUnlockTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/Series/PasswordUnlockTests.cs
new file mode 100644
index 0000000..d754866
--- /dev/null
+++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/Series/PasswordUnlockTests.cs
@@ -0,0 +1,61 @@
+using Shouldly;
+using Xunit;
+using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
+
+namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests.Series;
+
+///
+/// Issue #271, plan PR F4-d — series-level (would-be integration) coverage of
+/// cnc_wrunlockparam. 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 ; this file is a scaffold that runs
+/// against a simulator + matching mock_set_password admin endpoint when
+/// FOCAS_TRUST_WIRE=1.
+///
+///
+/// Build-only today: the simulator gate (FOCAS_TRUST_WIRE) skips at runtime so
+/// CI doesn't need the simulator binary. When the simulator's
+/// cnc_wrunlockparam + mock_set_password endpoints land
+/// (docs/v2/implementation/focas-simulator-plan.md) the gated test
+/// becomes a real round-trip.
+///
+[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 docs/v2/implementation/focas-simulator-plan.md
+ // § "FOCAS password unlock" for the simulator-side endpoints.
+ return Task.CompletedTask;
+ }
+}