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

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