[focas] FOCAS — Write infrastructure + per-tag opt-in #388
@@ -120,6 +120,25 @@ parameter-write switch enabled.
|
||||
**Writes are non-idempotent by default** — a timeout after the CNC already
|
||||
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
|
||||
|
||||
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
|
||||
surfacing the FWLIB struct fields directly into
|
||||
`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
|
||||
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(
|
||||
@@ -33,7 +41,8 @@ param(
|
||||
[int]$CncPort = 8193,
|
||||
[string]$Address = "R100",
|
||||
[string]$OpcUaUrl = "opc.tcp://localhost:4840",
|
||||
[Parameter(Mandatory)] [string]$BridgeNodeId
|
||||
[Parameter(Mandatory)] [string]$BridgeNodeId,
|
||||
[switch]$Write
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
@@ -92,5 +101,13 @@ $results += Test-SubscribeSeesChange `
|
||||
-DriverWriteArgs (@("write") + $commonFocas + @("-a", $Address, "-t", "Int16", "-v", $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
|
||||
if ($results | Where-Object { -not $_.Passed }) { exit 1 }
|
||||
|
||||
@@ -38,7 +38,11 @@ public sealed class WriteCommand : FocasCommandBase
|
||||
Address: Address,
|
||||
DataType: DataType,
|
||||
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);
|
||||
|
||||
|
||||
@@ -41,9 +41,12 @@ public abstract class FocasCommandBase : DriverCommandBase
|
||||
/// + the tag list a subclass supplies. Probe disabled; the default
|
||||
/// <see cref="FwlibFocasClientFactory"/> attempts <c>Fwlib32.dll</c> P/Invoke, which
|
||||
/// 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>
|
||||
protected FocasDriverOptions BuildOptions(IReadOnlyList<FocasTagDefinition> tags) => new()
|
||||
protected FocasDriverOptions BuildOptions(
|
||||
IReadOnlyList<FocasTagDefinition> tags, bool writesEnabled = false) => new()
|
||||
{
|
||||
Devices = [new FocasDeviceOptions(
|
||||
HostAddress: HostAddress,
|
||||
@@ -52,6 +55,7 @@ public abstract class FocasCommandBase : DriverCommandBase
|
||||
Tags = tags,
|
||||
Timeout = Timeout,
|
||||
Probe = new FocasProbeOptions { Enabled = false },
|
||||
Writes = new FocasWritesOptions { Enabled = writesEnabled },
|
||||
};
|
||||
|
||||
protected string DriverInstanceId => $"focas-cli-{CncHost}:{CncPort}";
|
||||
|
||||
@@ -482,6 +482,18 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
ArgumentNullException.ThrowIfNull(writes);
|
||||
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++)
|
||||
{
|
||||
var w = writes[i];
|
||||
|
||||
@@ -73,7 +73,10 @@ public static class FocasDriverFactoryExtensions
|
||||
Address: t.Address ?? throw new InvalidOperationException(
|
||||
$"FOCAS tag '{t.Name}' in '{driverInstanceId}' missing Address"),
|
||||
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))]
|
||||
: [],
|
||||
Probe = new FocasProbeOptions
|
||||
@@ -83,6 +86,13 @@ public static class FocasDriverFactoryExtensions
|
||||
Timeout = TimeSpan.FromMilliseconds(dto.Probe?.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);
|
||||
@@ -170,6 +180,12 @@ public static class FocasDriverFactoryExtensions
|
||||
public List<FocasDeviceDto>? Devices { get; init; }
|
||||
public List<FocasTagDto>? Tags { 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
|
||||
|
||||
@@ -28,6 +28,32 @@ public sealed class FocasDriverOptions
|
||||
/// <see cref="FocasAlarmProjectionMode.ActivePlusHistory"/>.
|
||||
/// </summary>
|
||||
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>
|
||||
@@ -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>DIAG:1031</c> / <c>DIAG:280/2</c>.
|
||||
/// </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(
|
||||
string Name,
|
||||
string DeviceHostAddress,
|
||||
string Address,
|
||||
FocasDataType DataType,
|
||||
bool Writable = true,
|
||||
bool Writable = false,
|
||||
bool WriteIdempotent = false);
|
||||
|
||||
public sealed class FocasProbeOptions
|
||||
|
||||
@@ -20,7 +20,9 @@ public sealed class FocasCapabilityTests
|
||||
Devices = [new FocasDeviceOptions("focas://10.0.0.5:8193", DeviceName: "Lathe-1")],
|
||||
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),
|
||||
],
|
||||
Probe = new FocasProbeOptions { Enabled = false },
|
||||
|
||||
@@ -53,11 +53,18 @@ public sealed class FocasPmcBitRmwTests
|
||||
{
|
||||
var fake = new PmcRmwFake();
|
||||
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
|
||||
{
|
||||
Devices = [new FocasDeviceOptions("focas://10.0.0.5:8193")],
|
||||
Tags = tags,
|
||||
Tags = writableTags,
|
||||
Probe = new FocasProbeOptions { Enabled = false },
|
||||
Writes = new FocasWritesOptions { Enabled = true },
|
||||
}, "drv-1", factory);
|
||||
return (drv, fake);
|
||||
}
|
||||
|
||||
@@ -16,6 +16,10 @@ public sealed class FocasReadWriteTests
|
||||
Devices = [new FocasDeviceOptions("focas://10.0.0.5:8193")],
|
||||
Tags = tags,
|
||||
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);
|
||||
return (drv, factory);
|
||||
}
|
||||
@@ -170,7 +174,7 @@ public sealed class FocasReadWriteTests
|
||||
public async Task Successful_write_logs_address_type_value()
|
||||
{
|
||||
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);
|
||||
|
||||
var results = await drv.WriteAsync(
|
||||
@@ -187,7 +191,7 @@ public sealed class FocasReadWriteTests
|
||||
public async Task Write_status_code_maps_via_FocasStatusMapper()
|
||||
{
|
||||
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);
|
||||
factory.Customise = () =>
|
||||
{
|
||||
@@ -210,10 +214,11 @@ public sealed class FocasReadWriteTests
|
||||
Devices = [new FocasDeviceOptions("focas://10.0.0.5:8193")],
|
||||
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),
|
||||
],
|
||||
Probe = new FocasProbeOptions { Enabled = false },
|
||||
Writes = new FocasWritesOptions { Enabled = true },
|
||||
}, "drv-1", factory);
|
||||
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