Auto: focas-f4a — write infrastructure + per-tag opt-in

Closes #268
This commit is contained in:
Joseph Doherty
2026-04-26 04:32:43 -04:00
parent 6f1657b1c0
commit 1bfe8fba0e
13 changed files with 521 additions and 11 deletions

View File

@@ -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);

View File

@@ -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}";

View File

@@ -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];

View File

@@ -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

View File

@@ -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