@@ -120,6 +120,25 @@ parameter-write switch enabled.
|
|||||||
**Writes are non-idempotent by default** — a timeout after the CNC already
|
**Writes are non-idempotent by default** — a timeout after the CNC already
|
||||||
applied the write will NOT auto-retry (plan decisions #44 + #45).
|
applied the write will NOT auto-retry (plan decisions #44 + #45).
|
||||||
|
|
||||||
|
#### Server-side `Writes.Enabled` enforcement (issue #268, plan PR F4-a)
|
||||||
|
|
||||||
|
The OtOpcUa server gates every FOCAS write behind two independent opt-ins:
|
||||||
|
`FocasDriverOptions.Writes.Enabled` (driver-level master switch, default
|
||||||
|
`false`) and `FocasTagDefinition.Writable` (per-tag, default `false`). When
|
||||||
|
either is off, the server-side `WriteAsync` short-circuits to
|
||||||
|
`BadNotWritable` before the wire client is touched. See
|
||||||
|
[`docs/drivers/FOCAS.md`](drivers/FOCAS.md) "Writes (opt-in, off by
|
||||||
|
default)" subsection + [`docs/v2/decisions.md`](v2/decisions.md) for the
|
||||||
|
decision record.
|
||||||
|
|
||||||
|
**The CLI bypasses the server-side flag.** `otopcua-focas-cli write` is a
|
||||||
|
per-invocation operator tool — it sets `Writes.Enabled = true` locally for
|
||||||
|
the lifetime of one process and creates the synthesised tag with
|
||||||
|
`Writable = true`. This is intentional: the CLI is the operator's
|
||||||
|
direct-to-CNC fallback, not a long-lived process bound to the central
|
||||||
|
config DB. Configuring the server still requires both opt-ins to be set
|
||||||
|
explicitly in the DriverInstance JSON.
|
||||||
|
|
||||||
### `subscribe` — watch an address until Ctrl+C
|
### `subscribe` — watch an address until Ctrl+C
|
||||||
|
|
||||||
FOCAS has no push model; the shared `PollGroupEngine` handles the tick
|
FOCAS has no push model; the shared `PollGroupEngine` handles the tick
|
||||||
|
|||||||
@@ -53,3 +53,56 @@ giant request. Typical FANUC ring buffers cap at ~100 entries; the default
|
|||||||
- Tier-C Fwlib32 backend short-circuits the packed-buffer decoder by
|
- Tier-C Fwlib32 backend short-circuits the packed-buffer decoder by
|
||||||
surfacing the FWLIB struct fields directly into
|
surfacing the FWLIB struct fields directly into
|
||||||
`FocasAlarmHistoryEntry`.
|
`FocasAlarmHistoryEntry`.
|
||||||
|
|
||||||
|
## Writes (opt-in, off by default) — issue #268, plan PR F4-a
|
||||||
|
|
||||||
|
Writes ship behind two independent opt-ins. Both default off so a freshly
|
||||||
|
deployed FOCAS driver is read-only until the deployment makes a deliberate
|
||||||
|
choice. Decision record: [`docs/v2/decisions.md`](../v2/decisions.md) →
|
||||||
|
"FOCAS write-path opt-in".
|
||||||
|
|
||||||
|
| Knob | Default | Effect when off |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `FocasDriverOptions.Writes.Enabled` *(driver-level master switch)* | `false` | Every entry in a `WriteAsync` batch short-circuits to `BadNotWritable` with status text `writes disabled at driver level`. Wire client never gets touched. |
|
||||||
|
| `FocasTagDefinition.Writable` *(per-tag opt-in)* | `false` | The per-tag check returns `BadNotWritable` for that tag even when the driver-level flag is on. |
|
||||||
|
|
||||||
|
### Config shape
|
||||||
|
|
||||||
|
```jsonc
|
||||||
|
{
|
||||||
|
"Writes": { "Enabled": true },
|
||||||
|
"Tags": [
|
||||||
|
{ "Name": "RPM", "Address": "PARAM:1815", "DataType": "Int32",
|
||||||
|
"Writable": true, "WriteIdempotent": false }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`WriteIdempotent` is plumbed through Polly retry by the server-layer
|
||||||
|
`CapabilityInvoker.ExecuteWriteAsync`. When `false` (default), failed writes
|
||||||
|
are NOT auto-retried per plan decisions #44/#45 — a timeout that fires after
|
||||||
|
the CNC already accepted the write would otherwise risk a duplicate
|
||||||
|
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).
|
||||||
|
|
||||||
|
### Status-code semantics post-F4-a
|
||||||
|
|
||||||
|
- `BadNotWritable` — driver-level `Writes.Enabled = false`, OR per-tag
|
||||||
|
`Writable = false`. Two distinct paths, same status code.
|
||||||
|
- `BadNotSupported` — both opt-ins flipped on, but the wire client doesn't
|
||||||
|
yet implement the kind being written. F4-a wires the dispatch surface;
|
||||||
|
F4-b/c land the actual macro / parameter / PMC writes for unimplemented
|
||||||
|
kinds, replacing those `BadNotSupported` responses with real wire calls.
|
||||||
|
- `BadNodeIdUnknown` — full-reference doesn't match any configured
|
||||||
|
`FocasTagDefinition.Name`.
|
||||||
|
- `BadCommunicationError` — wire failure (DLL not loaded, IPC peer dead,
|
||||||
|
etc.).
|
||||||
|
|
||||||
|
### CLI bypass
|
||||||
|
|
||||||
|
`otopcua-focas-cli write` ([`docs/Driver.FOCAS.Cli.md`](../Driver.FOCAS.Cli.md))
|
||||||
|
sets `Writes.Enabled=true` locally for the lifetime of one invocation
|
||||||
|
because the CLI is a per-operator tool — not a long-lived process bound to
|
||||||
|
the central config DB. The server-side flag is untouched; configure-the-
|
||||||
|
server code paths remain safer-by-default.
|
||||||
|
|||||||
73
docs/v2/decisions.md
Normal file
73
docs/v2/decisions.md
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
# Decisions
|
||||||
|
|
||||||
|
Architecture-level decisions taken during the v2 implementation, captured
|
||||||
|
once and referenced from feature docs / PR descriptions / ADR-style
|
||||||
|
follow-ups. Each entry lists the decision, the alternatives we considered,
|
||||||
|
and the rationale that tipped the call.
|
||||||
|
|
||||||
|
## FOCAS write-path opt-in
|
||||||
|
|
||||||
|
**Issue:** [#268](https://github.com/dohertj2/lmxopcua/issues/268). **Plan PR:** F4-a.
|
||||||
|
|
||||||
|
### Decision
|
||||||
|
|
||||||
|
The FOCAS driver ships writes behind two independent opt-ins, both default
|
||||||
|
off:
|
||||||
|
|
||||||
|
1. **Driver-level master switch** — `FocasDriverOptions.Writes.Enabled`,
|
||||||
|
default `false`. When off, every entry in a `WriteAsync` batch short-
|
||||||
|
circuits to `BadNotWritable` with status text `writes disabled at
|
||||||
|
driver level`. The wire client is never touched.
|
||||||
|
2. **Per-tag opt-in** — `FocasTagDefinition.Writable`, default `false`
|
||||||
|
(flipped from `true` in F4-a). A `Writable = false` tag returns
|
||||||
|
`BadNotWritable` even when the driver-level flag is on.
|
||||||
|
|
||||||
|
`BadNotSupported` is reserved for kinds the wire client hasn't yet
|
||||||
|
implemented; F4-b/c land actual macro / parameter / PMC writes that
|
||||||
|
currently dispatch to `BadNotSupported` (or to `Good` against the F4-a
|
||||||
|
fake) for unimplemented branches.
|
||||||
|
|
||||||
|
### Alternatives considered
|
||||||
|
|
||||||
|
- **Always-on writes (the pre-F4-a default).** Rejected: a single
|
||||||
|
misconfigured tag flipping `Writable = true` by accident would let an
|
||||||
|
operator overwrite a CNC parameter from any OPC UA client. The two-
|
||||||
|
opt-in posture means an accidental tag flip alone isn't enough.
|
||||||
|
- **Driver-level switch only.** Rejected: doesn't protect against an
|
||||||
|
operator with admin rights flipping the master switch to do bulk diag
|
||||||
|
reads but inheriting write capability for tags that were intended
|
||||||
|
read-only.
|
||||||
|
- **Per-tag opt-in only.** Rejected: doesn't give the deployment an "all
|
||||||
|
writes off" emergency lever — useful during a CNC commissioning where
|
||||||
|
writes are unsafe across the board for a period.
|
||||||
|
|
||||||
|
### Rationale
|
||||||
|
|
||||||
|
CNC writes are non-idempotent in the field's worst-case shape: feed
|
||||||
|
overrides, M-code pulses, alarm acks, recipe-step advances. Two opt-ins
|
||||||
|
is the cheapest defence-in-depth posture that still lets writes ship.
|
||||||
|
Both default off so a fresh deployment is read-only — the explicit choice
|
||||||
|
to enable writes lands at config time where it's reviewable, not at
|
||||||
|
runtime where it's invisible.
|
||||||
|
|
||||||
|
`WriteIdempotent` plumbs through `CapabilityInvoker.ExecuteWriteAsync`
|
||||||
|
into the Polly retry pipeline; default `false` means failed writes are
|
||||||
|
not auto-retried (plan decisions #44 / #45). Per-tag flip required for
|
||||||
|
genuinely-idempotent writes.
|
||||||
|
|
||||||
|
### CLI carve-out
|
||||||
|
|
||||||
|
`otopcua-focas-cli write` sets `Writes.Enabled = true` locally for the
|
||||||
|
lifetime of one process and synthesises a `Writable = true` tag. The CLI
|
||||||
|
is a per-operator direct-to-CNC tool — not a long-lived process bound to
|
||||||
|
the central config DB. Configuring the server still requires both opt-ins
|
||||||
|
to be set explicitly in the DriverInstance JSON. The bypass is documented
|
||||||
|
in `docs/Driver.FOCAS.Cli.md` so operators understand the asymmetry.
|
||||||
|
|
||||||
|
### Migration
|
||||||
|
|
||||||
|
Pre-F4-a deployments that relied on the `Writable = true` default need to
|
||||||
|
add `"Writable": true` to every tag they intend to write + an enclosing
|
||||||
|
`"Writes": { "Enabled": true }` block in their DriverInstance JSON.
|
||||||
|
Bootstrap rows seeded before F4-a get `Writable = false` after upgrade —
|
||||||
|
this is intentional; review-then-flip is the safer migration path.
|
||||||
@@ -26,6 +26,14 @@
|
|||||||
|
|
||||||
.PARAMETER BridgeNodeId
|
.PARAMETER BridgeNodeId
|
||||||
NodeId at which the server publishes the Address.
|
NodeId at which the server publishes the Address.
|
||||||
|
|
||||||
|
.PARAMETER Write
|
||||||
|
Issue #268 / plan PR F4-a — opts the script into the post-F4-a write
|
||||||
|
stages. F4-a ships the write infrastructure (driver-level Writes.Enabled
|
||||||
|
flag + per-tag Writable opt-in) without any actual wire writes; F4-b/c
|
||||||
|
populate this stage with real macro / parameter / PMC write coverage.
|
||||||
|
Until then the switch is a no-op marker so the e2e harness records that
|
||||||
|
the write surface was deliberately exercised (or skipped).
|
||||||
#>
|
#>
|
||||||
|
|
||||||
param(
|
param(
|
||||||
@@ -33,7 +41,8 @@ param(
|
|||||||
[int]$CncPort = 8193,
|
[int]$CncPort = 8193,
|
||||||
[string]$Address = "R100",
|
[string]$Address = "R100",
|
||||||
[string]$OpcUaUrl = "opc.tcp://localhost:4840",
|
[string]$OpcUaUrl = "opc.tcp://localhost:4840",
|
||||||
[Parameter(Mandatory)] [string]$BridgeNodeId
|
[Parameter(Mandatory)] [string]$BridgeNodeId,
|
||||||
|
[switch]$Write
|
||||||
)
|
)
|
||||||
|
|
||||||
$ErrorActionPreference = "Stop"
|
$ErrorActionPreference = "Stop"
|
||||||
@@ -92,5 +101,13 @@ $results += Test-SubscribeSeesChange `
|
|||||||
-DriverWriteArgs (@("write") + $commonFocas + @("-a", $Address, "-t", "Int16", "-v", $subValue)) `
|
-DriverWriteArgs (@("write") + $commonFocas + @("-a", $Address, "-t", "Int16", "-v", $subValue)) `
|
||||||
-ExpectedValue "$subValue"
|
-ExpectedValue "$subValue"
|
||||||
|
|
||||||
|
if ($Write) {
|
||||||
|
# F4-a no-op stage. Real per-kind write coverage lands in F4-b/c which extend the wire
|
||||||
|
# client past the BadNotSupported short-circuit + populate this branch with
|
||||||
|
# macro / parameter / PMC write assertions. Logged here so the harness records that the
|
||||||
|
# operator deliberately requested the write path.
|
||||||
|
Write-Host "[skip] -Write requested; F4-a ships infrastructure only — wire-write stages land in F4-b/c (issue #268)."
|
||||||
|
}
|
||||||
|
|
||||||
Write-Summary -Title "FOCAS e2e" -Results $results
|
Write-Summary -Title "FOCAS e2e" -Results $results
|
||||||
if ($results | Where-Object { -not $_.Passed }) { exit 1 }
|
if ($results | Where-Object { -not $_.Passed }) { exit 1 }
|
||||||
|
|||||||
@@ -38,7 +38,11 @@ public sealed class WriteCommand : FocasCommandBase
|
|||||||
Address: Address,
|
Address: Address,
|
||||||
DataType: DataType,
|
DataType: DataType,
|
||||||
Writable: true);
|
Writable: true);
|
||||||
var options = BuildOptions([tag]);
|
// The CLI is a per-invocation operator tool; it bypasses the server-side
|
||||||
|
// FocasDriverOptions.Writes.Enabled gate by enabling writes locally for this single
|
||||||
|
// process. Configure-the-server code paths still respect the safer-by-default flag —
|
||||||
|
// see docs/Driver.FOCAS.Cli.md "Writes" subsection (issue #268, plan PR F4-a).
|
||||||
|
var options = BuildOptions([tag], writesEnabled: true);
|
||||||
|
|
||||||
var parsed = ParseValue(Value, DataType);
|
var parsed = ParseValue(Value, DataType);
|
||||||
|
|
||||||
|
|||||||
@@ -41,9 +41,12 @@ public abstract class FocasCommandBase : DriverCommandBase
|
|||||||
/// + the tag list a subclass supplies. Probe disabled; the default
|
/// + the tag list a subclass supplies. Probe disabled; the default
|
||||||
/// <see cref="FwlibFocasClientFactory"/> attempts <c>Fwlib32.dll</c> P/Invoke, which
|
/// <see cref="FwlibFocasClientFactory"/> attempts <c>Fwlib32.dll</c> P/Invoke, which
|
||||||
/// throws <see cref="DllNotFoundException"/> at first call when the DLL is absent —
|
/// throws <see cref="DllNotFoundException"/> at first call when the DLL is absent —
|
||||||
/// surfaced through the driver as <c>BadCommunicationError</c>.
|
/// surfaced through the driver as <c>BadCommunicationError</c>. Pass
|
||||||
|
/// <paramref name="writesEnabled"/> = <c>true</c> to bypass the F4-a driver-level
|
||||||
|
/// write gate for the lifetime of this CLI invocation (issue #268).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
protected FocasDriverOptions BuildOptions(IReadOnlyList<FocasTagDefinition> tags) => new()
|
protected FocasDriverOptions BuildOptions(
|
||||||
|
IReadOnlyList<FocasTagDefinition> tags, bool writesEnabled = false) => new()
|
||||||
{
|
{
|
||||||
Devices = [new FocasDeviceOptions(
|
Devices = [new FocasDeviceOptions(
|
||||||
HostAddress: HostAddress,
|
HostAddress: HostAddress,
|
||||||
@@ -52,6 +55,7 @@ public abstract class FocasCommandBase : DriverCommandBase
|
|||||||
Tags = tags,
|
Tags = tags,
|
||||||
Timeout = Timeout,
|
Timeout = Timeout,
|
||||||
Probe = new FocasProbeOptions { Enabled = false },
|
Probe = new FocasProbeOptions { Enabled = false },
|
||||||
|
Writes = new FocasWritesOptions { Enabled = writesEnabled },
|
||||||
};
|
};
|
||||||
|
|
||||||
protected string DriverInstanceId => $"focas-cli-{CncHost}:{CncPort}";
|
protected string DriverInstanceId => $"focas-cli-{CncHost}:{CncPort}";
|
||||||
|
|||||||
@@ -482,6 +482,18 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
|||||||
ArgumentNullException.ThrowIfNull(writes);
|
ArgumentNullException.ThrowIfNull(writes);
|
||||||
var results = new WriteResult[writes.Count];
|
var results = new WriteResult[writes.Count];
|
||||||
|
|
||||||
|
// Driver-level master switch (issue #268, plan PR F4-a). When the deployment hasn't
|
||||||
|
// explicitly opted into writes, every batch entry short-circuits to BadNotWritable
|
||||||
|
// before we touch the wire. The status text "writes disabled at driver level" is
|
||||||
|
// surfaced through the resilience pipeline + Admin diagnostics so operators can tell
|
||||||
|
// the driver-level gate apart from a per-tag Writable=false rejection.
|
||||||
|
if (!_options.Writes.Enabled)
|
||||||
|
{
|
||||||
|
for (var i = 0; i < writes.Count; i++)
|
||||||
|
results[i] = new WriteResult(FocasStatusMapper.BadNotWritable);
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
for (var i = 0; i < writes.Count; i++)
|
for (var i = 0; i < writes.Count; i++)
|
||||||
{
|
{
|
||||||
var w = writes[i];
|
var w = writes[i];
|
||||||
|
|||||||
@@ -73,7 +73,10 @@ public static class FocasDriverFactoryExtensions
|
|||||||
Address: t.Address ?? throw new InvalidOperationException(
|
Address: t.Address ?? throw new InvalidOperationException(
|
||||||
$"FOCAS tag '{t.Name}' in '{driverInstanceId}' missing Address"),
|
$"FOCAS tag '{t.Name}' in '{driverInstanceId}' missing Address"),
|
||||||
DataType: ParseDataType(t.DataType, t.Name!, driverInstanceId),
|
DataType: ParseDataType(t.DataType, t.Name!, driverInstanceId),
|
||||||
Writable: t.Writable ?? true,
|
// Per-tag Writable defaults to false post-F4-a (issue #268). A config-DB row
|
||||||
|
// with Writable null means "not opted in" — operators must explicitly flip
|
||||||
|
// the flag per tag before writes flow.
|
||||||
|
Writable: t.Writable ?? false,
|
||||||
WriteIdempotent: t.WriteIdempotent ?? false))]
|
WriteIdempotent: t.WriteIdempotent ?? false))]
|
||||||
: [],
|
: [],
|
||||||
Probe = new FocasProbeOptions
|
Probe = new FocasProbeOptions
|
||||||
@@ -83,6 +86,13 @@ public static class FocasDriverFactoryExtensions
|
|||||||
Timeout = TimeSpan.FromMilliseconds(dto.Probe?.TimeoutMs ?? 2_000),
|
Timeout = TimeSpan.FromMilliseconds(dto.Probe?.TimeoutMs ?? 2_000),
|
||||||
},
|
},
|
||||||
Timeout = TimeSpan.FromMilliseconds(dto.TimeoutMs ?? 2_000),
|
Timeout = TimeSpan.FromMilliseconds(dto.TimeoutMs ?? 2_000),
|
||||||
|
// Driver-level write opt-in (issue #268, plan PR F4-a). Default false — config rows
|
||||||
|
// that omit the section keep the safer-by-default read-only posture; flipping it on
|
||||||
|
// requires an explicit deployment-time choice.
|
||||||
|
Writes = new FocasWritesOptions
|
||||||
|
{
|
||||||
|
Enabled = dto.Writes?.Enabled ?? false,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
var clientFactory = BuildClientFactory(dto, driverInstanceId);
|
var clientFactory = BuildClientFactory(dto, driverInstanceId);
|
||||||
@@ -170,6 +180,12 @@ public static class FocasDriverFactoryExtensions
|
|||||||
public List<FocasDeviceDto>? Devices { get; init; }
|
public List<FocasDeviceDto>? Devices { get; init; }
|
||||||
public List<FocasTagDto>? Tags { get; init; }
|
public List<FocasTagDto>? Tags { get; init; }
|
||||||
public FocasProbeDto? Probe { get; init; }
|
public FocasProbeDto? Probe { get; init; }
|
||||||
|
public FocasWritesDto? Writes { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed class FocasWritesDto
|
||||||
|
{
|
||||||
|
public bool? Enabled { get; init; }
|
||||||
}
|
}
|
||||||
|
|
||||||
internal sealed class FocasDeviceDto
|
internal sealed class FocasDeviceDto
|
||||||
|
|||||||
@@ -28,6 +28,32 @@ public sealed class FocasDriverOptions
|
|||||||
/// <see cref="FocasAlarmProjectionMode.ActivePlusHistory"/>.
|
/// <see cref="FocasAlarmProjectionMode.ActivePlusHistory"/>.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public FocasAlarmProjectionOptions AlarmProjection { get; init; } = new();
|
public FocasAlarmProjectionOptions AlarmProjection { get; init; } = new();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Driver-level write opt-in (issue #268, plan PR F4-a). Defaults to
|
||||||
|
/// <c>Enabled = false</c> — the driver short-circuits every <c>IWritable.WriteAsync</c>
|
||||||
|
/// call to <see cref="FocasStatusMapper.BadNotWritable"/> until the deployment explicitly
|
||||||
|
/// flips this on. Combined with the per-tag <see cref="FocasTagDefinition.Writable"/>
|
||||||
|
/// gate (also default-off), every CNC write requires two opt-ins.
|
||||||
|
/// </summary>
|
||||||
|
public FocasWritesOptions Writes { get; init; } = new();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Driver-level write controls (issue #268, plan PR F4-a). Per the F4-a decision record
|
||||||
|
/// writes ship behind a flag with a safe default: an operator who pulls the FOCAS driver
|
||||||
|
/// into production without touching <c>Writes.Enabled</c> gets read-only behaviour, and
|
||||||
|
/// even with the flag flipped on each individual tag must still set
|
||||||
|
/// <see cref="FocasTagDefinition.Writable"/> = <c>true</c>.
|
||||||
|
/// </summary>
|
||||||
|
public sealed record FocasWritesOptions
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Driver-level master switch. Default <c>false</c> — every write returns
|
||||||
|
/// <see cref="FocasStatusMapper.BadNotWritable"/> with the status text
|
||||||
|
/// <c>"writes disabled at driver level"</c>.
|
||||||
|
/// </summary>
|
||||||
|
public bool Enabled { get; init; } = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -141,12 +167,22 @@ public sealed record FocasDeviceOptions(
|
|||||||
/// <c>X0.0</c> / <c>R100</c> / <c>PARAM:1815/0</c> / <c>MACRO:500</c> /
|
/// <c>X0.0</c> / <c>R100</c> / <c>PARAM:1815/0</c> / <c>MACRO:500</c> /
|
||||||
/// <c>DIAG:1031</c> / <c>DIAG:280/2</c>.
|
/// <c>DIAG:1031</c> / <c>DIAG:280/2</c>.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <paramref name="Writable"/> defaults to <c>false</c> per issue #268 / plan PR F4-a — a
|
||||||
|
/// newly-onboarded tag is read-only until the deployment explicitly opts it in, matching
|
||||||
|
/// the driver-level <see cref="FocasWritesOptions.Enabled"/> safer-by-default posture.
|
||||||
|
/// <paramref name="WriteIdempotent"/> is plumbed through the
|
||||||
|
/// <see cref="Core.Resilience.CapabilityInvoker.ExecuteWriteAsync"/> retry path at the
|
||||||
|
/// server layer (see <see cref="Core.Abstractions.WriteIdempotentAttribute"/>); a
|
||||||
|
/// <c>true</c> value lets the Polly pipeline retry on transient failures while
|
||||||
|
/// <c>false</c> (the default) disables retry per decisions #44/#45.
|
||||||
|
/// </remarks>
|
||||||
public sealed record FocasTagDefinition(
|
public sealed record FocasTagDefinition(
|
||||||
string Name,
|
string Name,
|
||||||
string DeviceHostAddress,
|
string DeviceHostAddress,
|
||||||
string Address,
|
string Address,
|
||||||
FocasDataType DataType,
|
FocasDataType DataType,
|
||||||
bool Writable = true,
|
bool Writable = false,
|
||||||
bool WriteIdempotent = false);
|
bool WriteIdempotent = false);
|
||||||
|
|
||||||
public sealed class FocasProbeOptions
|
public sealed class FocasProbeOptions
|
||||||
|
|||||||
@@ -20,7 +20,9 @@ public sealed class FocasCapabilityTests
|
|||||||
Devices = [new FocasDeviceOptions("focas://10.0.0.5:8193", DeviceName: "Lathe-1")],
|
Devices = [new FocasDeviceOptions("focas://10.0.0.5:8193", DeviceName: "Lathe-1")],
|
||||||
Tags =
|
Tags =
|
||||||
[
|
[
|
||||||
new FocasTagDefinition("Run", "focas://10.0.0.5:8193", "R100", FocasDataType.Byte),
|
// Writable: true required after the F4-a default flip (issue #268) so the
|
||||||
|
// discovered Run tag still surfaces with SecurityClassification.Operate.
|
||||||
|
new FocasTagDefinition("Run", "focas://10.0.0.5:8193", "R100", FocasDataType.Byte, Writable: true),
|
||||||
new FocasTagDefinition("Alarm", "focas://10.0.0.5:8193", "R200", FocasDataType.Byte, Writable: false),
|
new FocasTagDefinition("Alarm", "focas://10.0.0.5:8193", "R200", FocasDataType.Byte, Writable: false),
|
||||||
],
|
],
|
||||||
Probe = new FocasProbeOptions { Enabled = false },
|
Probe = new FocasProbeOptions { Enabled = false },
|
||||||
|
|||||||
@@ -53,11 +53,18 @@ public sealed class FocasPmcBitRmwTests
|
|||||||
{
|
{
|
||||||
var fake = new PmcRmwFake();
|
var fake = new PmcRmwFake();
|
||||||
var factory = new FakeFocasClientFactory { Customise = () => fake };
|
var factory = new FakeFocasClientFactory { Customise = () => fake };
|
||||||
|
// PMC bit RMW exercises the write path; opt every supplied tag into Writable + flip the
|
||||||
|
// driver-level Writes.Enabled gate so the tests still drive the wire path after F4-a's
|
||||||
|
// safer-by-default flip (issue #268).
|
||||||
|
var writableTags = tags
|
||||||
|
.Select(t => t with { Writable = true })
|
||||||
|
.ToArray();
|
||||||
var drv = new FocasDriver(new FocasDriverOptions
|
var drv = new FocasDriver(new FocasDriverOptions
|
||||||
{
|
{
|
||||||
Devices = [new FocasDeviceOptions("focas://10.0.0.5:8193")],
|
Devices = [new FocasDeviceOptions("focas://10.0.0.5:8193")],
|
||||||
Tags = tags,
|
Tags = writableTags,
|
||||||
Probe = new FocasProbeOptions { Enabled = false },
|
Probe = new FocasProbeOptions { Enabled = false },
|
||||||
|
Writes = new FocasWritesOptions { Enabled = true },
|
||||||
}, "drv-1", factory);
|
}, "drv-1", factory);
|
||||||
return (drv, fake);
|
return (drv, fake);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,6 +16,10 @@ public sealed class FocasReadWriteTests
|
|||||||
Devices = [new FocasDeviceOptions("focas://10.0.0.5:8193")],
|
Devices = [new FocasDeviceOptions("focas://10.0.0.5:8193")],
|
||||||
Tags = tags,
|
Tags = tags,
|
||||||
Probe = new FocasProbeOptions { Enabled = false },
|
Probe = new FocasProbeOptions { Enabled = false },
|
||||||
|
// F4-a flipped Writable + Writes.Enabled defaults to false for safer-by-default
|
||||||
|
// posture (issue #268). The legacy read-write test fixture opts both back on so
|
||||||
|
// existing assertions exercise the same wire path the original tests covered.
|
||||||
|
Writes = new FocasWritesOptions { Enabled = true },
|
||||||
}, "drv-1", factory);
|
}, "drv-1", factory);
|
||||||
return (drv, factory);
|
return (drv, factory);
|
||||||
}
|
}
|
||||||
@@ -170,7 +174,7 @@ public sealed class FocasReadWriteTests
|
|||||||
public async Task Successful_write_logs_address_type_value()
|
public async Task Successful_write_logs_address_type_value()
|
||||||
{
|
{
|
||||||
var (drv, factory) = NewDriver(
|
var (drv, factory) = NewDriver(
|
||||||
new FocasTagDefinition("Speed", "focas://10.0.0.5:8193", "R100", FocasDataType.Int16));
|
new FocasTagDefinition("Speed", "focas://10.0.0.5:8193", "R100", FocasDataType.Int16, Writable: true));
|
||||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||||
|
|
||||||
var results = await drv.WriteAsync(
|
var results = await drv.WriteAsync(
|
||||||
@@ -187,7 +191,7 @@ public sealed class FocasReadWriteTests
|
|||||||
public async Task Write_status_code_maps_via_FocasStatusMapper()
|
public async Task Write_status_code_maps_via_FocasStatusMapper()
|
||||||
{
|
{
|
||||||
var (drv, factory) = NewDriver(
|
var (drv, factory) = NewDriver(
|
||||||
new FocasTagDefinition("Protected", "focas://10.0.0.5:8193", "R100", FocasDataType.Byte));
|
new FocasTagDefinition("Protected", "focas://10.0.0.5:8193", "R100", FocasDataType.Byte, Writable: true));
|
||||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||||
factory.Customise = () =>
|
factory.Customise = () =>
|
||||||
{
|
{
|
||||||
@@ -210,10 +214,11 @@ public sealed class FocasReadWriteTests
|
|||||||
Devices = [new FocasDeviceOptions("focas://10.0.0.5:8193")],
|
Devices = [new FocasDeviceOptions("focas://10.0.0.5:8193")],
|
||||||
Tags =
|
Tags =
|
||||||
[
|
[
|
||||||
new FocasTagDefinition("A", "focas://10.0.0.5:8193", "R100", FocasDataType.Byte),
|
new FocasTagDefinition("A", "focas://10.0.0.5:8193", "R100", FocasDataType.Byte, Writable: true),
|
||||||
new FocasTagDefinition("B", "focas://10.0.0.5:8193", "R101", FocasDataType.Byte, Writable: false),
|
new FocasTagDefinition("B", "focas://10.0.0.5:8193", "R101", FocasDataType.Byte, Writable: false),
|
||||||
],
|
],
|
||||||
Probe = new FocasProbeOptions { Enabled = false },
|
Probe = new FocasProbeOptions { Enabled = false },
|
||||||
|
Writes = new FocasWritesOptions { Enabled = true },
|
||||||
}, "drv-1", factory);
|
}, "drv-1", factory);
|
||||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,262 @@
|
|||||||
|
using Shouldly;
|
||||||
|
using Xunit;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Core.Resilience;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Issue #268, plan PR F4-a — write infrastructure tests covering the driver-level
|
||||||
|
/// <c>Writes.Enabled</c> opt-in, the per-tag <c>Writable</c> default flip to false,
|
||||||
|
/// <c>WriteIdempotent</c> plumbing through Polly retry, and DTO/JSON config
|
||||||
|
/// round-tripping for the new <c>Writes</c> section.
|
||||||
|
/// </summary>
|
||||||
|
[Trait("Category", "Unit")]
|
||||||
|
public sealed class FocasWriteInfrastructureTests
|
||||||
|
{
|
||||||
|
private static FocasDriver NewDriver(
|
||||||
|
FocasWritesOptions writes,
|
||||||
|
FocasTagDefinition[] tags,
|
||||||
|
out FakeFocasClientFactory factory)
|
||||||
|
{
|
||||||
|
factory = new FakeFocasClientFactory();
|
||||||
|
return new FocasDriver(new FocasDriverOptions
|
||||||
|
{
|
||||||
|
Devices = [new FocasDeviceOptions("focas://10.0.0.5:8193")],
|
||||||
|
Tags = tags,
|
||||||
|
Probe = new FocasProbeOptions { Enabled = false },
|
||||||
|
Writes = writes,
|
||||||
|
}, "drv-1", factory);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task DriverLevel_Writes_disabled_returns_BadNotWritable_even_when_per_tag_Writable_true()
|
||||||
|
{
|
||||||
|
var drv = NewDriver(
|
||||||
|
writes: new FocasWritesOptions { Enabled = false },
|
||||||
|
tags:
|
||||||
|
[
|
||||||
|
new FocasTagDefinition("X", "focas://10.0.0.5:8193", "R100", FocasDataType.Byte, Writable: true),
|
||||||
|
],
|
||||||
|
out _);
|
||||||
|
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||||
|
|
||||||
|
var results = await drv.WriteAsync(
|
||||||
|
[new WriteRequest("X", (sbyte)1)], CancellationToken.None);
|
||||||
|
|
||||||
|
results.Single().StatusCode.ShouldBe(FocasStatusMapper.BadNotWritable);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task DriverLevel_Writes_enabled_per_tag_Writable_false_returns_BadNotWritable()
|
||||||
|
{
|
||||||
|
var drv = NewDriver(
|
||||||
|
writes: new FocasWritesOptions { Enabled = true },
|
||||||
|
tags:
|
||||||
|
[
|
||||||
|
new FocasTagDefinition("X", "focas://10.0.0.5:8193", "R100", FocasDataType.Byte, Writable: false),
|
||||||
|
],
|
||||||
|
out _);
|
||||||
|
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||||
|
|
||||||
|
var results = await drv.WriteAsync(
|
||||||
|
[new WriteRequest("X", (sbyte)1)], CancellationToken.None);
|
||||||
|
|
||||||
|
results.Single().StatusCode.ShouldBe(FocasStatusMapper.BadNotWritable);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task DriverLevel_Writes_enabled_per_tag_Writable_true_dispatches_to_wire_client()
|
||||||
|
{
|
||||||
|
// F4-a's wire dispatch surface is unchanged — when both flags are flipped, the call
|
||||||
|
// reaches the (fake) wire client, which by default returns Good. F4-b will introduce
|
||||||
|
// BadNotSupported branches for kinds the wire layer hasn't implemented yet.
|
||||||
|
var drv = NewDriver(
|
||||||
|
writes: new FocasWritesOptions { Enabled = true },
|
||||||
|
tags:
|
||||||
|
[
|
||||||
|
new FocasTagDefinition("X", "focas://10.0.0.5:8193", "R100", FocasDataType.Byte, Writable: true),
|
||||||
|
],
|
||||||
|
out var factory);
|
||||||
|
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||||
|
|
||||||
|
var results = await drv.WriteAsync(
|
||||||
|
[new WriteRequest("X", (sbyte)1)], CancellationToken.None);
|
||||||
|
|
||||||
|
results.Single().StatusCode.ShouldBe(FocasStatusMapper.Good);
|
||||||
|
factory.Clients[0].WriteLog.Count.ShouldBe(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task DriverLevel_Writes_disabled_short_circuits_before_touching_wire_client()
|
||||||
|
{
|
||||||
|
// Regression: the driver-level flag must reject before EnsureConnectedAsync, so a
|
||||||
|
// misconfigured wire client (no DLL, no IPC peer) doesn't fault when writes are off.
|
||||||
|
var drv = NewDriver(
|
||||||
|
writes: new FocasWritesOptions { Enabled = false },
|
||||||
|
tags:
|
||||||
|
[
|
||||||
|
new FocasTagDefinition("X", "focas://10.0.0.5:8193", "R100", FocasDataType.Byte, Writable: true),
|
||||||
|
],
|
||||||
|
out var factory);
|
||||||
|
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||||
|
|
||||||
|
var results = await drv.WriteAsync(
|
||||||
|
[new WriteRequest("X", (sbyte)1)], CancellationToken.None);
|
||||||
|
|
||||||
|
results.Single().StatusCode.ShouldBe(FocasStatusMapper.BadNotWritable);
|
||||||
|
factory.Clients.Count.ShouldBe(0); // never even constructed a client
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void PerTag_Writable_default_is_false_post_F4a()
|
||||||
|
{
|
||||||
|
// Regression test for the flipped default — the safer-by-default posture means a
|
||||||
|
// newly-onboarded tag is read-only until the deployment explicitly opts in.
|
||||||
|
var def = new FocasTagDefinition(
|
||||||
|
Name: "X",
|
||||||
|
DeviceHostAddress: "focas://10.0.0.5:8193",
|
||||||
|
Address: "R100",
|
||||||
|
DataType: FocasDataType.Byte);
|
||||||
|
|
||||||
|
def.Writable.ShouldBeFalse();
|
||||||
|
def.WriteIdempotent.ShouldBeFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void FocasWritesOptions_default_Enabled_is_false()
|
||||||
|
{
|
||||||
|
// The driver-level master switch is the second of the two opt-ins required for any
|
||||||
|
// CNC write to flow. Default-off matches plan PR F4-a (issue #268).
|
||||||
|
new FocasWritesOptions().Enabled.ShouldBeFalse();
|
||||||
|
new FocasDriverOptions().Writes.Enabled.ShouldBeFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Dto_round_trip_preserves_Writes_Enabled()
|
||||||
|
{
|
||||||
|
// JSON config -> FocasDriverOptions -> JSON; the Writes section must survive the
|
||||||
|
// bootstrapper's Deserialize step + the driver factory's options materialisation.
|
||||||
|
const string json = """
|
||||||
|
{
|
||||||
|
"Backend": "unimplemented",
|
||||||
|
"Devices": [{ "HostAddress": "focas://10.0.0.5:8193" }],
|
||||||
|
"Writes": { "Enabled": true }
|
||||||
|
}
|
||||||
|
""";
|
||||||
|
|
||||||
|
var drv = FocasDriverFactoryExtensions.CreateInstance("drv-1", json);
|
||||||
|
|
||||||
|
// The driver type is sealed; reach into the public options surface via reflection-free
|
||||||
|
// path — InitializeAsync would parse Tags, but here we just want to confirm the flag
|
||||||
|
// round-tripped. Use a known-tagless config + assert via a sentinel: a write call
|
||||||
|
// returns BadNodeIdUnknown rather than the BadNotWritable short-circuit, which proves
|
||||||
|
// the driver-level gate was opened by the JSON.
|
||||||
|
var task = drv.InitializeAsync("{}", CancellationToken.None);
|
||||||
|
task.IsCompleted.ShouldBeTrue();
|
||||||
|
|
||||||
|
// Issue a write at an unknown reference; if Writes.Enabled was false the driver
|
||||||
|
// would short-circuit every entry to BadNotWritable. Instead, with Writes.Enabled=true
|
||||||
|
// the per-entry tag-lookup runs and returns BadNodeIdUnknown for the unmapped name.
|
||||||
|
var results = drv.WriteAsync(
|
||||||
|
[new WriteRequest("Unknown", 0)], CancellationToken.None).GetAwaiter().GetResult();
|
||||||
|
results.Single().StatusCode.ShouldBe(FocasStatusMapper.BadNodeIdUnknown);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Dto_default_omitted_Writes_section_keeps_safer_default()
|
||||||
|
{
|
||||||
|
// A config with no Writes section at all should keep the safer-by-default off posture.
|
||||||
|
const string json = """
|
||||||
|
{
|
||||||
|
"Backend": "unimplemented",
|
||||||
|
"Devices": [{ "HostAddress": "focas://10.0.0.5:8193" }]
|
||||||
|
}
|
||||||
|
""";
|
||||||
|
|
||||||
|
var drv = FocasDriverFactoryExtensions.CreateInstance("drv-1", json);
|
||||||
|
drv.InitializeAsync("{}", CancellationToken.None).GetAwaiter().GetResult();
|
||||||
|
|
||||||
|
var results = drv.WriteAsync(
|
||||||
|
[new WriteRequest("Unknown", 0)], CancellationToken.None).GetAwaiter().GetResult();
|
||||||
|
// Off-by-default + no tag-lookup short-circuit means BadNotWritable, not BadNodeIdUnknown.
|
||||||
|
results.Single().StatusCode.ShouldBe(FocasStatusMapper.BadNotWritable);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task CapabilityInvoker_honours_WriteIdempotent_for_Polly_retry()
|
||||||
|
{
|
||||||
|
// Plumb-through test: WriteIdempotent=false disables retry regardless of pipeline
|
||||||
|
// configuration; WriteIdempotent=true lets the Write capability's retry policy take
|
||||||
|
// effect. We exercise CapabilityInvoker.ExecuteWriteAsync directly because the
|
||||||
|
// server-layer dispatch (DriverNodeManager) is what actually reads the per-tag flag —
|
||||||
|
// the FOCAS driver itself just surfaces the WriteIdempotent value through
|
||||||
|
// FocasTagDefinition for the server to consume.
|
||||||
|
var builder = new DriverResiliencePipelineBuilder();
|
||||||
|
DriverResilienceOptions Options() => new()
|
||||||
|
{
|
||||||
|
Tier = DriverTier.A,
|
||||||
|
CapabilityPolicies = new Dictionary<DriverCapability, CapabilityPolicy>
|
||||||
|
{
|
||||||
|
[DriverCapability.Write] = new CapabilityPolicy(
|
||||||
|
TimeoutSeconds: 30, RetryCount: 3, BreakerFailureThreshold: 0),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
var invoker = new CapabilityInvoker(builder, "drv-1", Options, "FOCAS");
|
||||||
|
|
||||||
|
var idempotentAttempts = 0;
|
||||||
|
await Should.ThrowAsync<InvalidOperationException>(async () =>
|
||||||
|
await invoker.ExecuteWriteAsync<int>(
|
||||||
|
hostName: "host-1",
|
||||||
|
isIdempotent: true,
|
||||||
|
callSite: _ =>
|
||||||
|
{
|
||||||
|
idempotentAttempts++;
|
||||||
|
throw new InvalidOperationException("boom");
|
||||||
|
},
|
||||||
|
cancellationToken: CancellationToken.None));
|
||||||
|
idempotentAttempts.ShouldBe(4); // 1 initial + 3 retries
|
||||||
|
|
||||||
|
var nonIdempotentAttempts = 0;
|
||||||
|
await Should.ThrowAsync<InvalidOperationException>(async () =>
|
||||||
|
await invoker.ExecuteWriteAsync<int>(
|
||||||
|
hostName: "host-1",
|
||||||
|
isIdempotent: false,
|
||||||
|
callSite: _ =>
|
||||||
|
{
|
||||||
|
nonIdempotentAttempts++;
|
||||||
|
throw new InvalidOperationException("boom");
|
||||||
|
},
|
||||||
|
cancellationToken: CancellationToken.None));
|
||||||
|
nonIdempotentAttempts.ShouldBe(1); // no retry — invoker swaps in a no-retry pipeline
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Batch_with_writes_disabled_short_circuits_every_entry()
|
||||||
|
{
|
||||||
|
// The driver-level gate fires once and applies to every batch entry — useful when an
|
||||||
|
// operator submits a 50-entry write batch against a server with Writes.Enabled=false.
|
||||||
|
var drv = NewDriver(
|
||||||
|
writes: new FocasWritesOptions { Enabled = false },
|
||||||
|
tags:
|
||||||
|
[
|
||||||
|
new FocasTagDefinition("A", "focas://10.0.0.5:8193", "R100", FocasDataType.Byte, Writable: true),
|
||||||
|
new FocasTagDefinition("B", "focas://10.0.0.5:8193", "R101", FocasDataType.Byte, Writable: true),
|
||||||
|
new FocasTagDefinition("C", "focas://10.0.0.5:8193", "R102", FocasDataType.Byte, Writable: false),
|
||||||
|
],
|
||||||
|
out _);
|
||||||
|
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||||
|
|
||||||
|
var results = await drv.WriteAsync(
|
||||||
|
[
|
||||||
|
new WriteRequest("A", (sbyte)1),
|
||||||
|
new WriteRequest("B", (sbyte)2),
|
||||||
|
new WriteRequest("C", (sbyte)3),
|
||||||
|
new WriteRequest("Unknown", (sbyte)4),
|
||||||
|
], CancellationToken.None);
|
||||||
|
|
||||||
|
results.Count.ShouldBe(4);
|
||||||
|
foreach (var r in results)
|
||||||
|
r.StatusCode.ShouldBe(FocasStatusMapper.BadNotWritable);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user