[focas] FOCAS — Password / unlock parameter #391

Merged
dohertj2 merged 1 commits from auto/focas/F4-d into auto/driver-gaps 2026-04-26 05:50:15 -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) | | `-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` | | `-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 | | `--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 | | `--verbose` | off | Serilog debug output |
## Addressing ## 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 `WriteIdempotent` on per tag for genuinely-idempotent writes (a parameter
value that the operator simply wants forced to a target). 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 ### Status-code semantics post-F4-b
- `BadNotWritable` — one of: driver-level `Writes.Enabled = false`; per-tag - `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, **`Writes.AllowPmc = false` for a PMC tag (F4-c)**. Same status code,
five distinct paths — operators distinguish by checking the knobs. five distinct paths — operators distinguish by checking the knobs.
- `BadUserAccessDenied`**F4-b** — the CNC reported `EW_PASSWD` - `BadUserAccessDenied`**F4-b** — the CNC reported `EW_PASSWD`
(parameter-write switch off / unlock required). F4-d will land the (parameter-write switch off / unlock required). **F4-d** wires the
unlock workflow on top of this surface; today the deployment instructs `cnc_wrunlockparam` retry path on top: when `Password` is configured
the operator to flip the parameter-write switch on the CNC pendant. 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 - `BadNotSupported` — both opt-ins flipped on, but the wire client doesn't
implement the kind being written (e.g. older transport variant). F4-a implement the kind being written (e.g. older transport variant). F4-a
wired the generic dispatch; F4-b adds typed `WriteParameterAsync` / 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 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 flip on for windows where the ladder team has signed off on the write
path. 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** | | **`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** | | **`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** | | **`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)** | | **`0x0F1A`** | **`cnc_rdalmhistry`** | **dumps the per-profile alarm-history ring buffer (issue #267, plan PR F3-a)** |
## `cnc_rdalmhistry` mock behaviour ## `cnc_rdalmhistry` mock behaviour
@@ -124,6 +125,12 @@ Each profile owns:
- `unlock_state: bool` — defaults `False`. When `False`, every - `unlock_state: bool` — defaults `False`. When `False`, every
`cnc_wrparam` returns `EW_PASSWD` (numeric `11`) regardless of `cnc_wrparam` returns `EW_PASSWD` (numeric `11`) regardless of
parameter. Macro writes are NOT gated by `unlock_state`. 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 - `last_write: Optional[LastWrite]` — most-recent successful
`(kind, number, value, ts)` tuple, surfaced via the admin endpoint `(kind, number, value, ts)` tuple, surfaced via the admin endpoint
below for audit-log assertions. below for audit-log assertions.
@@ -163,6 +170,37 @@ POST /admin/mock_set_unlock_state
{ "profile": "Series30i", "unlocked": true } { "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` ### Admin endpoint — `GET /admin/mock_get_last_write`
Returns the simulator's view of the most-recent successful write, used by 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)** | | **`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)** | | **`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)** | | **`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)** | | `0x0F1A` | **`cnc_rdalmhistry`** | **ODBALMHIS alarm-history ring-buffer dump (issue #267, plan PR F3-a)** |
## ODBALMHIS — alarm history (`cnc_rdalmhistry`, command `0x0F1A`) ## 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 the data buffer from the request and emits a status int16 in the
response. Tests `FocasWritePmcTests.PMC_*` exercise the round-trip on response. Tests `FocasWritePmcTests.PMC_*` exercise the round-trip on
the fake wire client. 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 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 off by default — see docs/v2/focas-deployment.md "Write safety / PMC
pre-checks". 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( param(
@@ -67,7 +77,8 @@ param(
[switch]$Write, [switch]$Write,
[string]$ParamAddress = "PARAM:1815", [string]$ParamAddress = "PARAM:1815",
[string]$MacroAddress = "MACRO:500", [string]$MacroAddress = "MACRO:500",
[string]$PmcBitAddress = "R100.3" [string]$PmcBitAddress = "R100.3",
[string]$CncPassword = ""
) )
$ErrorActionPreference = "Stop" $ErrorActionPreference = "Stop"
@@ -86,6 +97,15 @@ $opcUaCli = Get-CliInvocation `
-ExeName "otopcua-cli" -ExeName "otopcua-cli"
$commonFocas = @("-h", $CncHost, "-p", $CncPort) $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 = @()
$results += Test-Probe ` $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).")] [CommandOption("timeout-ms", Description = "Per-operation timeout in ms (default 2000).")]
public int TimeoutMs { get; init; } = 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 /> /// <inheritdoc />
public override TimeSpan Timeout public override TimeSpan Timeout
{ {
@@ -51,7 +65,12 @@ public abstract class FocasCommandBase : DriverCommandBase
Devices = [new FocasDeviceOptions( Devices = [new FocasDeviceOptions(
HostAddress: HostAddress, HostAddress: HostAddress,
DeviceName: $"cli-{CncHost}:{CncPort}", 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, Tags = tags,
Timeout = Timeout, Timeout = Timeout,
Probe = new FocasProbeOptions { Enabled = false }, Probe = new FocasProbeOptions { Enabled = false },

View File

@@ -451,10 +451,28 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
continue; continue;
} }
var (value, status) = parsed.Kind == FocasAreaKind.Diagnostic // 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( ? await client.ReadDiagnosticAsync(
parsed.Number, parsed.BitIndex ?? 0, def.DataType, cancellationToken).ConfigureAwait(false) parsed.Number, parsed.BitIndex ?? 0, def.DataType, cancellationToken).ConfigureAwait(false)
: await client.ReadAsync(parsed, 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); results[i] = new DataValueSnapshot(value, status, now, now);
if (status == FocasStatusMapper.Good) if (status == FocasStatusMapper.Good)
@@ -573,40 +591,59 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
// issue #270). The fallback generic WriteAsync path is preserved for // issue #270). The fallback generic WriteAsync path is preserved for
// kinds that don't have a typed entry point yet, plus the unit-test // kinds that don't have a typed entry point yet, plus the unit-test
// FakeFocasClient that overrides WriteAsync directly. // FakeFocasClient that overrides WriteAsync directly.
uint status; //
// 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()
{
if (parsed.Kind == FocasAreaKind.Parameter) if (parsed.Kind == FocasAreaKind.Parameter)
{ {
status = await client.WriteParameterAsync( return await client.WriteParameterAsync(
parsed, def.DataType, w.Value, cancellationToken).ConfigureAwait(false); parsed, def.DataType, w.Value, cancellationToken).ConfigureAwait(false);
} }
else if (parsed.Kind == FocasAreaKind.Macro) if (parsed.Kind == FocasAreaKind.Macro)
{ {
status = await client.WriteMacroAsync( return await client.WriteMacroAsync(
parsed, w.Value, cancellationToken).ConfigureAwait(false); parsed, w.Value, cancellationToken).ConfigureAwait(false);
} }
else if (parsed.Kind == FocasAreaKind.Pmc if (parsed.Kind == FocasAreaKind.Pmc
&& def.DataType == FocasDataType.Bit && def.DataType == FocasDataType.Bit
&& parsed.BitIndex is int bit && parsed.BitIndex is int bit
&& parsed.PmcLetter is string letter) && parsed.PmcLetter is string letter)
{ {
status = await client.WritePmcBitAsync( return await client.WritePmcBitAsync(
letter, parsed.PathId, parsed.Number, bit, letter, parsed.PathId, parsed.Number, bit,
Convert.ToBoolean(w.Value), cancellationToken).ConfigureAwait(false); Convert.ToBoolean(w.Value), cancellationToken).ConfigureAwait(false);
} }
else if (parsed.Kind == FocasAreaKind.Pmc if (parsed.Kind == FocasAreaKind.Pmc
&& def.DataType == FocasDataType.Byte && def.DataType == FocasDataType.Byte
&& parsed.PmcLetter is string byteLetter) && parsed.PmcLetter is string byteLetter)
{ {
var b = unchecked((byte)Convert.ToSByte(w.Value)); var b = unchecked((byte)Convert.ToSByte(w.Value));
status = await client.WritePmcRangeAsync( return await client.WritePmcRangeAsync(
byteLetter, parsed.PathId, parsed.Number, new[] { b }, byteLetter, parsed.PathId, parsed.Number, new[] { b },
cancellationToken).ConfigureAwait(false); cancellationToken).ConfigureAwait(false);
} }
else return await client.WriteAsync(
{
status = await client.WriteAsync(
parsed, def.DataType, w.Value, cancellationToken).ConfigureAwait(false); parsed, def.DataType, w.Value, cancellationToken).ConfigureAwait(false);
} }
var status = await DispatchWriteAsync().ConfigureAwait(false);
if (status == FocasStatusMapper.BadUserAccessDenied
&& !string.IsNullOrEmpty(device.Options.Password))
{
// 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); results[i] = new WriteResult(status);
} }
catch (OperationCanceledException) { throw; } catch (OperationCanceledException) { throw; }
@@ -1458,6 +1495,31 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
device.Client = null; device.Client = null;
throw; 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 // 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 // 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 // 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; 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 void Dispose() => DisposeAsync().AsTask().GetAwaiter().GetResult();
public async ValueTask DisposeAsync() => await ShutdownAsync(CancellationToken.None).ConfigureAwait(false); public async ValueTask DisposeAsync() => await ShutdownAsync(CancellationToken.None).ConfigureAwait(false);
@@ -1590,6 +1678,16 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
/// </summary> /// </summary>
public int LastSetPath { get; set; } 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() public void DisposeClient()
{ {
Client?.Dispose(); Client?.Dispose();

View File

@@ -62,7 +62,13 @@ public static class FocasDriverFactoryExtensions
HostAddress: d.HostAddress ?? throw new InvalidOperationException( HostAddress: d.HostAddress ?? throw new InvalidOperationException(
$"FOCAS config for '{driverInstanceId}' has a device missing HostAddress"), $"FOCAS config for '{driverInstanceId}' has a device missing HostAddress"),
DeviceName: d.DeviceName, 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 } Tags = dto.Tags is { Count: > 0 }
? [.. dto.Tags.Select(t => new FocasTagDefinition( ? [.. dto.Tags.Select(t => new FocasTagDefinition(
@@ -222,6 +228,20 @@ public static class FocasDriverFactoryExtensions
public string? HostAddress { get; init; } public string? HostAddress { get; init; }
public string? DeviceName { get; init; } public string? DeviceName { get; init; }
public string? Series { 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 internal sealed class FocasTagDto

View File

@@ -197,12 +197,44 @@ public sealed record FocasFixedTreeOptions
/// <paramref name="OverrideParameters"/> declares the four MTB-specific override /// <paramref name="OverrideParameters"/> declares the four MTB-specific override
/// <c>cnc_rdparam</c> numbers surfaced under <c>Override/</c>; pass <c>null</c> to /// <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). /// 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> /// </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( public sealed record FocasDeviceOptions(
string HostAddress, string HostAddress,
string? DeviceName = null, string? DeviceName = null,
FocasCncSeries Series = FocasCncSeries.Unknown, 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> /// <summary>
/// One FOCAS-backed OPC UA variable. <paramref name="Address"/> is the canonical FOCAS /// 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; 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( public Task<(object? value, uint status)> ReadAsync(
FocasAddress address, FocasDataType type, CancellationToken cancellationToken) FocasAddress address, FocasDataType type, CancellationToken cancellationToken)
{ {

View File

@@ -66,6 +66,25 @@ internal static class FwlibNative
short length, short length,
ref IODBPSD buffer); 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 ---- // ---- Macro variables ----
[DllImport(Library, EntryPoint = "cnc_rdmacro", ExactSpelling = true)] [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> /// <summary>True when the FWLIB handle is valid + the socket is up.</summary>
bool IsConnected { get; } 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> /// <summary>
/// Read the value at <paramref name="address"/> in the requested /// Read the value at <paramref name="address"/> in the requested
/// <paramref name="type"/>. Returns a boxed .NET value + the OPC UA status mapped /// <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; 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( public virtual Task<(object? value, uint status)> ReadAsync(
FocasAddress address, FocasDataType type, CancellationToken ct) 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;
}
}